test: move test runner from Bun to Vitest (#727)

* chore: migrate to vitest

* test: speed up some suites by sharing sessions and mocking expensive non-tested actions

* test: refactor some tests to verify behavior instead of implementation details

* chore: fix linting issues
This commit is contained in:
Nico 2026-04-01 20:05:54 +02:00 committed by GitHub
parent e265f7d478
commit 4305057185
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 1062 additions and 855 deletions

View file

@ -24,7 +24,7 @@ bun run tsc
bun run test bun run test
# Run a specific test file # Run a specific test file
bunx dotenv-cli -e .env.test -- bun test --preload ./app/test/setup.ts path/to/test.ts bunx dotenv-cli -e .env.test -- bunx --bun vitest run --project server path/to/test.ts
``` ```
### Building ### Building

View file

@ -1,5 +1,5 @@
/** biome-ignore-all lint/style/noNonNullAssertion: Testing file - non-null assertions are acceptable here */ /** biome-ignore-all lint/style/noNonNullAssertion: Testing file - non-null assertions are acceptable here */
import { afterEach, expect, test, describe } from "bun:test"; import { afterEach, describe, expect, test } from "vitest";
import { cleanup, render, screen, fireEvent, within } from "@testing-library/react"; import { cleanup, render, screen, fireEvent, within } from "@testing-library/react";
import { useState } from "react"; import { useState } from "react";
import { FileTree, type FileEntry } from "../file-tree"; import { FileTree, type FileEntry } from "../file-tree";

View file

@ -1,11 +1,16 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { HttpResponse, http, server } from "~/test/msw/server"; import { HttpResponse, http, server } from "~/test/msw/server";
import { cleanup, render, screen, userEvent, waitFor, within } from "~/test/test-utils"; import { cleanup, render, screen, userEvent, waitFor, within } from "~/test/test-utils";
import { fromAny } from "@total-typescript/shoehorn"; import { fromAny } from "@total-typescript/shoehorn";
await mock.module("@tanstack/react-router", () => ({ vi.mock("@tanstack/react-router", async (importOriginal) => {
useNavigate: () => mock(() => {}), const actual = await importOriginal<typeof import("@tanstack/react-router")>();
}));
return {
...actual,
useNavigate: (() => vi.fn(async () => {})) as typeof actual.useNavigate,
};
});
import { RestoreForm } from "../restore-form"; import { RestoreForm } from "../restore-form";

View file

@ -1,5 +1,5 @@
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { afterEach, describe, expect, test } from "bun:test"; import { afterEach, describe, expect, test } from "vitest";
import { HttpResponse, http, server } from "~/test/msw/server"; import { HttpResponse, http, server } from "~/test/msw/server";
import { cleanup, fireEvent, render, screen, userEvent, waitFor, within } from "~/test/test-utils"; import { cleanup, fireEvent, render, screen, userEvent, waitFor, within } from "~/test/test-utils";
@ -247,8 +247,8 @@ describe("SnapshotTreeBrowser", () => {
}); });
}); });
test("expands using the query path when display and query roots differ", async () => { test("shows the query root contents when display and query roots differ", async () => {
const requests = mockListSnapshotFiles(); mockListSnapshotFiles();
renderSnapshotTreeBrowser(); renderSnapshotTreeBrowser();
@ -258,19 +258,10 @@ describe("SnapshotTreeBrowser", () => {
throw new Error("Expected expand icon for folder row"); throw new Error("Expected expand icon for folder row");
} }
const initialRequestCount = requests.length; if (!screen.queryByRole("button", { name: "a.txt" })) {
fireEvent.click(expandIcon); fireEvent.click(expandIcon);
}
await waitFor(() => { expect(await screen.findByRole("button", { name: "a.txt" })).toBeTruthy();
expect(requests.length).toBeGreaterThan(initialRequestCount);
});
expect(requests.at(-1)).toEqual({
shortId: "repo-1",
snapshotId: "snap-1",
path: "/mnt/project",
offset: "0",
limit: "500",
});
}); });
}); });

View file

@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { cleanup, createTestQueryClient, render, screen } from "~/test/test-utils"; import { cleanup, createTestQueryClient, render, screen } from "~/test/test-utils";
import { useServerEvents } from "../use-server-events"; import { useServerEvents } from "../use-server-events";
@ -7,7 +8,7 @@ class MockEventSource {
static instances: MockEventSource[] = []; static instances: MockEventSource[] = [];
public onerror: ((event: Event) => void) | null = null; public onerror: ((event: Event) => void) | null = null;
public close = mock(() => {}); public close = vi.fn(() => {});
private listeners = new Map<string, Set<(event: Event) => void>>(); private listeners = new Map<string, Set<(event: Event) => void>>();
constructor(public url: string) { constructor(public url: string) {
@ -67,12 +68,21 @@ const BackupCompletedListener = ({ scheduleId }: { scheduleId: string }) => {
return <div>{status}</div>; return <div>{status}</div>;
}; };
const QueryStatusConsumer = ({ getValue }: { getValue: () => string }) => {
const { data } = useQuery({
queryKey: ["backup-status"],
queryFn: async () => getValue(),
});
return <div>{data}</div>;
};
describe("useServerEvents", () => { describe("useServerEvents", () => {
beforeEach(() => { beforeEach(() => {
MockEventSource.reset(); MockEventSource.reset();
globalThis.EventSource = MockEventSource as unknown as typeof EventSource; globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
console.info = mock(() => {}); console.info = vi.fn(() => {});
console.error = mock(() => {}); console.error = vi.fn(() => {});
}); });
afterEach(() => { afterEach(() => {
@ -83,22 +93,23 @@ describe("useServerEvents", () => {
MockEventSource.reset(); MockEventSource.reset();
}); });
test("shares one EventSource across consumers and invalidates queries once on backup completion", async () => { test("shares one EventSource across consumers and refreshes active queries on backup completion", async () => {
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient();
const invalidateQueries = mock(async () => undefined); let queryValue = "before";
const refetchQueries = mock(async () => undefined);
queryClient.invalidateQueries = invalidateQueries as typeof queryClient.invalidateQueries;
queryClient.refetchQueries = refetchQueries as typeof queryClient.refetchQueries;
render( const view = render(
<> <>
<ConnectionConsumer /> <ConnectionConsumer />
<BackupCompletedListener scheduleId="0b9c940b" /> <BackupCompletedListener scheduleId="0b9c940b" />
<QueryStatusConsumer getValue={() => queryValue} />
</>, </>,
{ queryClient }, { queryClient },
); );
expect(MockEventSource.instances).toHaveLength(1); expect(MockEventSource.instances).toHaveLength(1);
expect(await screen.findByText("before")).toBeTruthy();
queryValue = "after";
MockEventSource.instances[0]?.emit("backup:completed", { MockEventSource.instances[0]?.emit("backup:completed", {
organizationId: "default-org", organizationId: "default-org",
@ -109,10 +120,9 @@ describe("useServerEvents", () => {
}); });
expect(await screen.findByText("success")).toBeTruthy(); expect(await screen.findByText("success")).toBeTruthy();
expect(invalidateQueries).toHaveBeenCalledTimes(1); expect(await screen.findByText("after")).toBeTruthy();
expect(refetchQueries).not.toHaveBeenCalled();
cleanup(); view.unmount();
expect(MockEventSource.instances[0]?.close).toHaveBeenCalledTimes(1); expect(MockEventSource.instances[0]?.close).toHaveBeenCalledTimes(1);
}); });

View file

@ -1,4 +1,4 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; import { afterEach, describe, expect, test, vi } from "vitest";
import { import {
DEFAULT_TIME_FORMAT, DEFAULT_TIME_FORMAT,
formatDate, formatDate,
@ -12,7 +12,7 @@ import {
} from "../datetime"; } from "../datetime";
afterEach(() => { afterEach(() => {
mock.restore(); vi.restoreAllMocks();
}); });
const sampleDate = new Date("2026-01-10T14:30:00.000Z"); const sampleDate = new Date("2026-01-10T14:30:00.000Z");
@ -56,7 +56,7 @@ describe("datetime formatters", () => {
}); });
test("formats relative times without approximation prefixes", () => { test("formats relative times without approximation prefixes", () => {
const nowSpy = spyOn(Date, "now").mockReturnValue(new Date("2026-01-10T14:35:00.000Z").getTime()); const nowSpy = vi.spyOn(Date, "now").mockReturnValue(new Date("2026-01-10T14:35:00.000Z").getTime());
expect(formatTimeAgo(sampleDate)).toBe("5 minutes ago"); expect(formatTimeAgo(sampleDate)).toBe("5 minutes ago");

View file

@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "vitest";
import { getVolumeMountPath } from "./volume-path"; import { getVolumeMountPath } from "./volume-path";
import { fromAny } from "@total-typescript/shoehorn"; import { fromAny } from "@total-typescript/shoehorn";

View file

@ -1,31 +1,50 @@
import { afterEach, describe, expect, mock, test } from "bun:test"; import { afterEach, describe, expect, test, vi } from "vitest";
import { HttpResponse, http, server } from "~/test/msw/server";
import { cleanup, render, screen } from "~/test/test-utils"; import { cleanup, render, screen } from "~/test/test-utils";
await mock.module("@tanstack/react-router", () => ({ vi.mock("@tanstack/react-router", async (importOriginal) => {
useNavigate: () => mock(() => {}), const actual = await importOriginal<typeof import("@tanstack/react-router")>();
}));
await mock.module("~/client/modules/sso/components/sso-login-section", () => ({ return {
SsoLoginSection: () => null, ...actual,
})); useNavigate: (() => vi.fn(async () => {})) as typeof actual.useNavigate,
};
});
import { LoginPage } from "../login"; import { LoginPage } from "../login";
const inviteOnlyMessage = const inviteOnlyMessage =
"Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO."; "Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO.";
const mockSsoProvidersRequest = (
providers: Array<{
providerId: string;
organizationSlug: string;
}> = [],
) => {
server.use(
http.get("/api/v1/auth/sso-providers", () => {
return HttpResponse.json({ providers });
}),
);
};
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
}); });
describe("LoginPage", () => { describe("LoginPage", () => {
test("shows an invite-only message when SSO returns INVITE_REQUIRED code", async () => { test("shows an invite-only message when SSO returns INVITE_REQUIRED code", async () => {
render(<LoginPage error="INVITE_REQUIRED" />); mockSsoProvidersRequest();
render(<LoginPage error="INVITE_REQUIRED" />, { withSuspense: true });
expect(await screen.findByText(inviteOnlyMessage)).toBeTruthy(); expect(await screen.findByText(inviteOnlyMessage)).toBeTruthy();
}); });
test("shows account link required message when SSO returns ACCOUNT_LINK_REQUIRED code", async () => { test("shows account link required message when SSO returns ACCOUNT_LINK_REQUIRED code", async () => {
render(<LoginPage error="ACCOUNT_LINK_REQUIRED" />); mockSsoProvidersRequest();
render(<LoginPage error="ACCOUNT_LINK_REQUIRED" />, { withSuspense: true });
expect( expect(
await screen.findByText( await screen.findByText(
@ -35,7 +54,9 @@ describe("LoginPage", () => {
}); });
test("shows banned message when SSO returns BANNED_USER code", async () => { test("shows banned message when SSO returns BANNED_USER code", async () => {
render(<LoginPage error="BANNED_USER" />); mockSsoProvidersRequest();
render(<LoginPage error="BANNED_USER" />, { withSuspense: true });
expect( expect(
await screen.findByText( await screen.findByText(
@ -45,21 +66,35 @@ describe("LoginPage", () => {
}); });
test("shows email not verified message when SSO returns EMAIL_NOT_VERIFIED code", async () => { test("shows email not verified message when SSO returns EMAIL_NOT_VERIFIED code", async () => {
render(<LoginPage error="EMAIL_NOT_VERIFIED" />); mockSsoProvidersRequest();
render(<LoginPage error="EMAIL_NOT_VERIFIED" />, { withSuspense: true });
expect(await screen.findByText("Your identity provider did not mark your email as verified.")).toBeTruthy(); expect(await screen.findByText("Your identity provider did not mark your email as verified.")).toBeTruthy();
}); });
test("shows generic SSO error message when SSO returns SSO_LOGIN_FAILED code", async () => { test("shows generic SSO error message when SSO returns SSO_LOGIN_FAILED code", async () => {
render(<LoginPage error="SSO_LOGIN_FAILED" />); mockSsoProvidersRequest();
render(<LoginPage error="SSO_LOGIN_FAILED" />, { withSuspense: true });
expect(await screen.findByText("SSO authentication failed. Please try again.")).toBeTruthy(); expect(await screen.findByText("SSO authentication failed. Please try again.")).toBeTruthy();
}); });
test("does not show error message for invalid error codes", async () => { test("does not show error message for invalid error codes", async () => {
render(<LoginPage error="some_random_error" />); mockSsoProvidersRequest();
render(<LoginPage error="some_random_error" />, { withSuspense: true });
expect(await screen.findByText("Login to your account")).toBeTruthy(); expect(await screen.findByText("Login to your account")).toBeTruthy();
expect(screen.queryByText(inviteOnlyMessage)).toBeNull(); expect(screen.queryByText(inviteOnlyMessage)).toBeNull();
}); });
test("renders available SSO providers from the real SSO section", async () => {
mockSsoProvidersRequest([{ providerId: "acme", organizationSlug: "acme-org" }]);
render(<LoginPage />, { withSuspense: true });
expect(await screen.findByRole("button", { name: "Log in with acme" })).toBeTruthy();
});
}); });

View file

@ -1,18 +1,29 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { afterEach, describe, expect, mock, test } from "bun:test"; import { afterEach, describe, expect, test, vi } from "vitest";
import { HttpResponse, http, server } from "~/test/msw/server"; import { HttpResponse, http, server } from "~/test/msw/server";
import { cleanup, render, screen, waitFor } from "~/test/test-utils"; import { cleanup, render, screen, waitFor } from "~/test/test-utils";
import { fromAny } from "@total-typescript/shoehorn"; import { fromAny } from "@total-typescript/shoehorn";
await mock.module("@tanstack/react-router", () => ({ vi.mock("@tanstack/react-router", async (importOriginal) => {
Link: ({ children }: { children?: ReactNode }) => <a href="/">{children}</a>, const actual = await importOriginal<typeof import("@tanstack/react-router")>();
}));
await mock.module("~/client/lib/datetime", () => ({ return {
useTimeFormat: () => ({ ...actual,
formatDateTime: () => "2026-03-26 00:00", Link: (({ children }: { children?: ReactNode }) => <a href="/">{children}</a>) as typeof actual.Link,
}), };
})); });
vi.mock("~/client/lib/datetime", async (importOriginal) => {
const actual = await importOriginal<typeof import("~/client/lib/datetime")>();
return {
...actual,
useTimeFormat: () => ({
...actual.useTimeFormat(),
formatDateTime: () => "2026-03-26 00:00",
}),
};
});
import { SnapshotFileBrowser } from "../snapshot-file-browser"; import { SnapshotFileBrowser } from "../snapshot-file-browser";

View file

@ -1,43 +1,61 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { afterEach, describe, expect, mock, test } from "bun:test"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { fromAny } from "@total-typescript/shoehorn"; import { fromAny } from "@total-typescript/shoehorn";
import { HttpResponse, http, server } from "~/test/msw/server"; import { HttpResponse, http, server } from "~/test/msw/server";
import { cleanup, render, screen } from "~/test/test-utils"; import { cleanup, render, screen } from "~/test/test-utils";
await mock.module("@tanstack/react-router", () => ({ vi.mock("@tanstack/react-router", async (importOriginal) => {
Link: ({ children }: { children?: ReactNode }) => <a href="/">{children}</a>, const actual = await importOriginal<typeof import("@tanstack/react-router")>();
useNavigate: () => mock(() => {}),
useSearch: () => ({}),
}));
await mock.module("~/client/components/backup-summary-card", () => ({ return {
BackupSummaryCard: () => null, ...actual,
})); Link: (({ children }: { children?: ReactNode }) => <a href="/">{children}</a>) as typeof actual.Link,
useNavigate: (() => vi.fn(async () => {})) as typeof actual.useNavigate,
useSearch: (() => ({})) as typeof actual.useSearch,
};
});
await mock.module("~/client/modules/backups/components/schedule-summary", () => ({ vi.mock("~/client/lib/datetime", async (importOriginal) => {
ScheduleSummary: () => null, const actual = await importOriginal<typeof import("~/client/lib/datetime")>();
}));
await mock.module("~/client/modules/backups/components/snapshot-timeline", () => ({ return {
SnapshotTimeline: () => null, ...actual,
})); useTimeFormat: () => ({
...actual.useTimeFormat(),
formatDateTime: () => "2026-03-26 00:00",
}),
};
});
await mock.module("~/client/modules/backups/components/schedule-notifications-config", () => ({ vi.mock("~/client/modules/backups/components/schedule-notifications-config", () => ({
ScheduleNotificationsConfig: () => null, ScheduleNotificationsConfig: () => null,
})); }));
await mock.module("~/client/modules/backups/components/schedule-mirrors-config", () => ({ vi.mock("~/client/modules/backups/components/schedule-mirrors-config", () => ({
ScheduleMirrorsConfig: () => null, ScheduleMirrorsConfig: () => null,
})); }));
await mock.module("~/client/lib/datetime", () => ({ vi.mock("~/client/modules/backups/components/schedule-summary", () => ({
useTimeFormat: () => ({ ScheduleSummary: ({ schedule }: { schedule: { name: string } }) => (
formatDateTime: () => "2026-03-26 00:00", <div>
}), <h1>{schedule.name}</h1>
<button type="button">Backup now</button>
</div>
),
})); }));
import { ScheduleDetailsPage } from "../backup-details"; import { ScheduleDetailsPage } from "../backup-details";
class MockEventSource {
addEventListener() {}
close() {}
onerror: ((event: Event) => void) | null = null;
constructor(public url: string) {}
}
const originalEventSource = globalThis.EventSource;
const schedule = { const schedule = {
shortId: "backup-1", shortId: "backup-1",
name: "Backup 1", name: "Backup 1",
@ -51,6 +69,10 @@ const schedule = {
enabled: true, enabled: true,
cronExpression: "0 0 * * *", cronExpression: "0 0 * * *",
retentionPolicy: null, retentionPolicy: null,
lastBackupAt: 1711411200000,
nextBackupAt: 1711497600000,
lastBackupStatus: null,
lastBackupError: null,
includePaths: ["/project"], includePaths: ["/project"],
includePatterns: [], includePatterns: [],
excludePatterns: [], excludePatterns: [],
@ -63,8 +85,26 @@ const snapshot = {
short_id: "snap-1", short_id: "snap-1",
paths: ["/mnt/project"], paths: ["/mnt/project"],
tags: ["backup-1"], tags: ["backup-1"],
time: "2026-03-26T00:00:00.000Z", time: new Date("2026-03-26T00:00:00.000Z").getTime(),
summary: {}, size: 2097152,
duration: 12,
retentionCategories: [],
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,
data_added_packed: 524288,
total_files_processed: 100,
total_bytes_processed: 2097152,
backup_start: "2026-03-26T00:00:00.000Z",
backup_end: "2026-03-26T00:00:12.000Z",
},
}; };
const mockScheduleDetailsRequests = () => { const mockScheduleDetailsRequests = () => {
@ -83,15 +123,32 @@ const mockScheduleDetailsRequests = () => {
], ],
}); });
}), }),
http.get("/api/v1/backups/:shortId/progress", () => {
return HttpResponse.json(null);
}),
http.get("/api/v1/backups/:shortId/notifications", () => {
return HttpResponse.json([]);
}),
http.get("/api/v1/backups/:shortId/mirrors", () => {
return HttpResponse.json([]);
}),
http.get("/api/v1/backups/:shortId/mirrors/compatibility", () => {
return HttpResponse.json([]);
}),
); );
}; };
beforeEach(() => {
globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
});
afterEach(() => { afterEach(() => {
globalThis.EventSource = originalEventSource;
cleanup(); cleanup();
}); });
describe("ScheduleDetailsPage", () => { describe("ScheduleDetailsPage", () => {
test("shows the selected snapshot from the volume root on the backup details page", async () => { test("renders the real schedule details page with the selected snapshot", async () => {
mockScheduleDetailsRequests(); mockScheduleDetailsRequests();
render( render(
@ -102,16 +159,21 @@ describe("ScheduleDetailsPage", () => {
repos: [], repos: [],
scheduleNotifs: [], scheduleNotifs: [],
mirrors: [], mirrors: [],
snapshotTimelineSortOrder: "newest", snapshotTimelineSortOrder: "desc",
snapshots: [snapshot], snapshots: [snapshot],
})} })}
scheduleId="backup-1" scheduleId="backup-1"
initialSnapshotId="snap-1" initialSnapshotId="snap-1"
initialSnapshotSortOrder={fromAny("newest")} initialSnapshotSortOrder="desc"
/>, />,
{ withSuspense: true }, { withSuspense: true },
); );
expect(await screen.findByRole("heading", { name: "Backup 1" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Backup now" })).toBeTruthy();
expect(screen.getByText("Snapshots")).toBeTruthy();
expect(screen.getByText("Files processed")).toBeTruthy();
expect(screen.getByRole("link", { name: /restore/i })).toBeTruthy();
expect(await screen.findByRole("button", { name: "project" })).toBeTruthy(); expect(await screen.findByRole("button", { name: "project" })).toBeTruthy();
}); });
}); });

View file

@ -1,4 +1,4 @@
import { test, describe, expect } from "bun:test"; import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { createApp } from "~/server/app"; import { createApp } from "~/server/app";
import { createTestSession } from "~/test/helpers/auth"; import { createTestSession } from "~/test/helpers/auth";
import { db } from "~/server/db/db"; import { db } from "~/server/db/db";
@ -18,9 +18,33 @@ import { eq } from "drizzle-orm";
const app = createApp(); const app = createApp();
describe("multi-organization isolation", () => { describe("multi-organization isolation", () => {
test("should reject requests when session active organization is not a membership", async () => { let session1: Awaited<ReturnType<typeof createTestSession>>;
const session = await createTestSession(); let session2: Awaited<ReturnType<typeof createTestSession>>;
beforeAll(async () => {
session1 = await createTestSession();
session2 = await createTestSession();
});
beforeEach(async () => {
await db.delete(backupScheduleNotificationsTable);
await db.delete(notificationDestinationsTable);
await db.delete(backupSchedulesTable);
await db.delete(volumesTable);
await db.delete(repositoriesTable);
await db
.update(sessionsTable)
.set({ activeOrganizationId: session1.organizationId })
.where(eq(sessionsTable.id, session1.session.id));
await db
.update(sessionsTable)
.set({ activeOrganizationId: session2.organizationId })
.where(eq(sessionsTable.id, session2.session.id));
});
test("should reject requests when session active organization is not a membership", async () => {
// Create a different organization the user is NOT a member of // Create a different organization the user is NOT a member of
const foreignOrgId = crypto.randomUUID(); const foreignOrgId = crypto.randomUUID();
await db.insert(organization).values({ await db.insert(organization).values({
@ -42,10 +66,10 @@ describe("multi-organization isolation", () => {
await db await db
.update(sessionsTable) .update(sessionsTable)
.set({ activeOrganizationId: foreignOrgId }) .set({ activeOrganizationId: foreignOrgId })
.where(eq(sessionsTable.id, session.session.id)); .where(eq(sessionsTable.id, session1.session.id));
const res = await app.request("/api/v1/repositories", { const res = await app.request("/api/v1/repositories", {
headers: session.headers, headers: session1.headers,
}); });
expect(res.status).toBe(403); expect(res.status).toBe(403);
@ -54,9 +78,6 @@ describe("multi-organization isolation", () => {
}); });
test("should not be able to access repositories from another organization", async () => { test("should not be able to access repositories from another organization", async () => {
const session1 = await createTestSession();
const session2 = await createTestSession();
expect(session1.organizationId).not.toBe(session2.organizationId); expect(session1.organizationId).not.toBe(session2.organizationId);
const repoId = crypto.randomUUID(); const repoId = crypto.randomUUID();
@ -85,9 +106,6 @@ describe("multi-organization isolation", () => {
}); });
test("should not list repositories from another organization", async () => { test("should not list repositories from another organization", async () => {
const session1 = await createTestSession();
const session2 = await createTestSession();
await db.insert(repositoriesTable).values({ await db.insert(repositoriesTable).values({
id: crypto.randomUUID(), id: crypto.randomUUID(),
shortId: generateShortId(), shortId: generateShortId(),
@ -123,9 +141,6 @@ describe("multi-organization isolation", () => {
}); });
test("should not be able to access volumes from another organization", async () => { test("should not be able to access volumes from another organization", async () => {
const session1 = await createTestSession();
const session2 = await createTestSession();
const volumeId = Math.floor(Math.random() * 1000000); const volumeId = Math.floor(Math.random() * 1000000);
const volumeShortId = generateShortId(); const volumeShortId = generateShortId();
await db.insert(volumesTable).values({ await db.insert(volumesTable).values({
@ -146,9 +161,6 @@ describe("multi-organization isolation", () => {
}); });
test("should not be able to create a backup schedule referencing resources from another organization", async () => { test("should not be able to create a backup schedule referencing resources from another organization", async () => {
const session1 = await createTestSession();
const session2 = await createTestSession();
const vol1Id = Math.floor(Math.random() * 1000000); const vol1Id = Math.floor(Math.random() * 1000000);
await db.insert(volumesTable).values({ await db.insert(volumesTable).values({
id: vol1Id, id: vol1Id,
@ -189,9 +201,6 @@ describe("multi-organization isolation", () => {
}); });
test("should not be able to access backup schedules from another organization", async () => { test("should not be able to access backup schedules from another organization", async () => {
const session1 = await createTestSession();
const session2 = await createTestSession();
const vol1Id = Math.floor(Math.random() * 1000000); const vol1Id = Math.floor(Math.random() * 1000000);
await db.insert(volumesTable).values({ await db.insert(volumesTable).values({
id: vol1Id, id: vol1Id,
@ -237,9 +246,6 @@ describe("multi-organization isolation", () => {
}); });
test("should not be able to access or modify notifications for another organization's schedule", async () => { test("should not be able to access or modify notifications for another organization's schedule", async () => {
const session1 = await createTestSession();
const session2 = await createTestSession();
const volId = Math.floor(Math.random() * 1000000); const volId = Math.floor(Math.random() * 1000000);
await db.insert(volumesTable).values({ await db.insert(volumesTable).values({
id: volId, id: volId,

View file

@ -1,4 +1,4 @@
import { beforeEach, describe, expect, test } from "bun:test"; import { beforeEach, describe, expect, test } from "vitest";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "~/server/db/db"; import { db } from "~/server/db/db";
import { account, sessionsTable, usersTable } from "~/server/db/schema"; import { account, sessionsTable, usersTable } from "~/server/db/schema";

View file

@ -1,4 +1,4 @@
import { test, describe, expect } from "bun:test"; import { describe, expect, test } from "vitest";
import { repoMutex } from "../repository-mutex"; import { repoMutex } from "../repository-mutex";
describe("RepositoryMutex", () => { describe("RepositoryMutex", () => {

View file

@ -1,4 +1,4 @@
import { afterEach, describe, expect, mock, spyOn, test, vi } from "bun:test"; import { afterEach, describe, expect, test, vi } from "vitest";
import { logger } from "@zerobyte/core/node"; import { logger } from "@zerobyte/core/node";
import { Job, Scheduler } from "../scheduler"; import { Job, Scheduler } from "../scheduler";
@ -9,17 +9,17 @@ const flushMicrotasks = async () => {
const mockTimeZone = (timeZone: string) => { const mockTimeZone = (timeZone: string) => {
// oxlint-disable-next-line typescript/unbound-method // oxlint-disable-next-line typescript/unbound-method
const resolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions; const resolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions;
return spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockImplementation( return vi
function (this: Intl.DateTimeFormat) { .spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions")
.mockImplementation(function (this: Intl.DateTimeFormat) {
return { ...resolvedOptions.call(this), timeZone }; return { ...resolvedOptions.call(this), timeZone };
}, });
);
}; };
describe("Scheduler", () => { describe("Scheduler", () => {
afterEach(async () => { afterEach(async () => {
await Scheduler.clear(); await Scheduler.clear();
mock.restore(); vi.restoreAllMocks();
vi.useRealTimers(); vi.useRealTimers();
}); });
@ -28,7 +28,7 @@ describe("Scheduler", () => {
let runCount = 0; let runCount = 0;
let releaseRun: (() => void) | undefined; let releaseRun: (() => void) | undefined;
const warn = spyOn(logger, "warn"); const warn = vi.spyOn(logger, "warn");
class TestJob extends Job { class TestJob extends Job {
async run() { async run() {

View file

@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "vitest";
import { isValidUsername, normalizeUsername } from "~/lib/username"; import { isValidUsername, normalizeUsername } from "~/lib/username";
describe("username helpers", () => { describe("username helpers", () => {

View file

@ -1,4 +1,4 @@
import { beforeEach, describe, expect, test } from "bun:test"; import { beforeEach, describe, expect, test } from "vitest";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db, sqlite } from "~/server/db/db"; import { db, sqlite } from "~/server/db/db";
import { account, invitation, member, organization, usersTable } from "~/server/db/schema"; import { account, invitation, member, organization, usersTable } from "~/server/db/schema";

View file

@ -1,11 +1,22 @@
import { test, describe, beforeEach, expect } from "bun:test"; import { beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("better-auth/crypto", () => ({
hashPassword: vi.fn(async () => "test-account-password-hash"),
}));
import { convertLegacyUserOnFirstLogin } from "../convert-legacy-user"; import { convertLegacyUserOnFirstLogin } from "../convert-legacy-user";
import { db } from "~/server/db/db"; import { db } from "~/server/db/db";
import { usersTable, account, organization, member } from "~/server/db/schema"; import { usersTable, account, organization, member } from "~/server/db/schema";
import type { AuthMiddlewareContext } from "~/server/lib/auth"; import type { AuthMiddlewareContext } from "~/server/lib/auth";
describe("convertLegacyUserOnFirstLogin", () => { describe("convertLegacyUserOnFirstLogin", () => {
const legacyPasswordHash = "legacy-password-hash";
const verifyPassword = vi.spyOn(Bun.password, "verify");
beforeEach(async () => { beforeEach(async () => {
verifyPassword.mockReset();
verifyPassword.mockImplementation(async (password) => password === "correct-password");
await db.delete(member); await db.delete(member);
await db.delete(account); await db.delete(account);
await db.delete(organization); await db.delete(organization);
@ -46,8 +57,6 @@ describe("convertLegacyUserOnFirstLogin", () => {
}); });
test("should throw UnauthorizedError for invalid password", async () => { test("should throw UnauthorizedError for invalid password", async () => {
const hashedPassword = await Bun.password.hash("correct-password");
// Create a legacy user with a hashed password // Create a legacy user with a hashed password
const userId = crypto.randomUUID(); const userId = crypto.randomUUID();
await db.insert(usersTable).values({ await db.insert(usersTable).values({
@ -55,7 +64,7 @@ describe("convertLegacyUserOnFirstLogin", () => {
username: "legacy-user", username: "legacy-user",
email: "legacy@test.com", email: "legacy@test.com",
name: "Legacy User", name: "Legacy User",
passwordHash: hashedPassword, passwordHash: legacyPasswordHash,
}); });
const ctx = createContext("/sign-in/username", { const ctx = createContext("/sign-in/username", {
@ -70,12 +79,11 @@ describe("convertLegacyUserOnFirstLogin", () => {
where: { username: "legacy-user" }, where: { username: "legacy-user" },
}); });
expect(user).toBeDefined(); expect(user).toBeDefined();
expect(user?.passwordHash).toBe(hashedPassword); expect(user?.passwordHash).toBe(legacyPasswordHash);
}); });
test("should migrate legacy user with existing organization membership", async () => { test("should migrate legacy user with existing organization membership", async () => {
const password = "correct-password"; const password = "correct-password";
const hashedPassword = await Bun.password.hash(password);
// Create legacy user // Create legacy user
const userId = crypto.randomUUID(); const userId = crypto.randomUUID();
@ -84,7 +92,7 @@ describe("convertLegacyUserOnFirstLogin", () => {
username: "legacy-with-org", username: "legacy-with-org",
email: "legacy-org@test.com", email: "legacy-org@test.com",
name: "Legacy With Org", name: "Legacy With Org",
passwordHash: hashedPassword, passwordHash: legacyPasswordHash,
role: "admin", role: "admin",
}); });
@ -150,7 +158,6 @@ describe("convertLegacyUserOnFirstLogin", () => {
test("should migrate legacy user and create new organization when no membership exists", async () => { test("should migrate legacy user and create new organization when no membership exists", async () => {
const password = "correct-password"; const password = "correct-password";
const hashedPassword = await Bun.password.hash(password);
// Create legacy user without organization membership // Create legacy user without organization membership
const userId = crypto.randomUUID(); const userId = crypto.randomUUID();
@ -159,7 +166,7 @@ describe("convertLegacyUserOnFirstLogin", () => {
username: "legacy-no-org", username: "legacy-no-org",
email: "legacy-noorg@test.com", email: "legacy-noorg@test.com",
name: "Legacy No Org", name: "Legacy No Org",
passwordHash: hashedPassword, passwordHash: legacyPasswordHash,
hasDownloadedResticPassword: true, hasDownloadedResticPassword: true,
}); });
@ -208,7 +215,6 @@ describe("convertLegacyUserOnFirstLogin", () => {
test("should be case-insensitive for username", async () => { test("should be case-insensitive for username", async () => {
const password = "correct-password"; const password = "correct-password";
const hashedPassword = await Bun.password.hash(password);
const userId = crypto.randomUUID(); const userId = crypto.randomUUID();
await db.insert(usersTable).values({ await db.insert(usersTable).values({
@ -216,7 +222,7 @@ describe("convertLegacyUserOnFirstLogin", () => {
username: "legacy-user", username: "legacy-user",
email: "legacy@test.com", email: "legacy@test.com",
name: "Legacy User", name: "Legacy User",
passwordHash: hashedPassword, passwordHash: legacyPasswordHash,
}); });
// Try login with uppercase username // Try login with uppercase username
@ -241,7 +247,6 @@ describe("convertLegacyUserOnFirstLogin", () => {
test("should trim whitespace from username", async () => { test("should trim whitespace from username", async () => {
const password = "correct-password"; const password = "correct-password";
const hashedPassword = await Bun.password.hash(password);
const userId = crypto.randomUUID(); const userId = crypto.randomUUID();
await db.insert(usersTable).values({ await db.insert(usersTable).values({
@ -249,7 +254,7 @@ describe("convertLegacyUserOnFirstLogin", () => {
username: "legacy-user", username: "legacy-user",
email: "legacy@test.com", email: "legacy@test.com",
name: "Legacy User", name: "Legacy User",
passwordHash: hashedPassword, passwordHash: legacyPasswordHash,
}); });
// Try login with whitespace // Try login with whitespace

View file

@ -1,4 +1,4 @@
import { beforeEach, describe, expect, test } from "bun:test"; import { beforeEach, describe, expect, test } from "vitest";
import { db, sqlite } from "~/server/db/db"; import { db, sqlite } from "~/server/db/db";
import { account, member, organization, sessionsTable, usersTable } from "~/server/db/schema"; import { account, member, organization, sessionsTable, usersTable } from "~/server/db/schema";
import { authService } from "../auth.service"; import { authService } from "../auth.service";

View file

@ -1,4 +1,4 @@
import { beforeEach, describe, expect, test } from "bun:test"; import { beforeEach, describe, expect, test } from "vitest";
import { db, sqlite } from "~/server/db/db"; import { db, sqlite } from "~/server/db/db";
import { member, organization, sessionsTable, usersTable } from "~/server/db/schema"; import { member, organization, sessionsTable, usersTable } from "~/server/db/schema";
import { authService } from "../auth.service"; import { authService } from "../auth.service";

View file

@ -1,4 +1,4 @@
import { beforeEach, describe, expect, test } from "bun:test"; import { beforeAll, describe, expect, test } from "vitest";
import { createApp } from "~/server/app"; import { createApp } from "~/server/app";
import { import {
createTestSession, createTestSession,
@ -7,19 +7,22 @@ import {
createTestSessionWithRegularMember, createTestSessionWithRegularMember,
getAuthHeaders, getAuthHeaders,
} from "~/test/helpers/auth"; } from "~/test/helpers/auth";
import { account, invitation, member, organization, ssoProvider, usersTable } from "~/server/db/schema"; import { account } from "~/server/db/schema";
import { db } from "~/server/db/db"; import { db } from "~/server/db/db";
const app = createApp(); const app = createApp();
describe("auth controller security", () => { describe("auth controller security", () => {
beforeEach(async () => { let regularUserSession: Awaited<ReturnType<typeof createTestSession>>;
await db.delete(member); let regularMemberSession: Awaited<ReturnType<typeof createTestSessionWithRegularMember>>;
await db.delete(account); let orgAdminSession: Awaited<ReturnType<typeof createTestSessionWithOrgAdmin>>;
await db.delete(invitation); let globalAdminSession: Awaited<ReturnType<typeof createTestSessionWithGlobalAdmin>>;
await db.delete(ssoProvider);
await db.delete(organization); beforeAll(async () => {
await db.delete(usersTable); regularUserSession = await createTestSession();
regularMemberSession = await createTestSessionWithRegularMember();
orgAdminSession = await createTestSessionWithOrgAdmin();
globalAdminSession = await createTestSessionWithGlobalAdmin();
}); });
describe("public endpoints - no auth required", () => { describe("public endpoints - no auth required", () => {
@ -59,10 +62,9 @@ describe("auth controller security", () => {
}); });
test(`${method} ${path} should return 403 for regular members`, async () => { test(`${method} ${path} should return 403 for regular members`, async () => {
const { headers } = await createTestSessionWithRegularMember();
const res = await app.request(path, { const res = await app.request(path, {
method, method,
headers, headers: regularMemberSession.headers,
body: method !== "GET" && method !== "DELETE" ? JSON.stringify({}) : undefined, body: method !== "GET" && method !== "DELETE" ? JSON.stringify({}) : undefined,
}); });
expect(res.status).toBe(403); expect(res.status).toBe(403);
@ -71,10 +73,9 @@ describe("auth controller security", () => {
}); });
test(`${method} ${path} should be accessible to org admins`, async () => { test(`${method} ${path} should be accessible to org admins`, async () => {
const { headers } = await createTestSessionWithOrgAdmin();
const res = await app.request(path, { const res = await app.request(path, {
method, method,
headers, headers: orgAdminSession.headers,
body: method !== "GET" && method !== "DELETE" ? JSON.stringify({}) : undefined, body: method !== "GET" && method !== "DELETE" ? JSON.stringify({}) : undefined,
}); });
// Should not be 401 or 403 - actual response depends on endpoint logic // Should not be 401 or 403 - actual response depends on endpoint logic
@ -85,11 +86,10 @@ describe("auth controller security", () => {
describe("PATCH /api/v1/auth/sso-providers/:providerId/auto-linking specific", () => { describe("PATCH /api/v1/auth/sso-providers/:providerId/auto-linking specific", () => {
test("should return 400 for invalid payload", async () => { test("should return 400 for invalid payload", async () => {
const { headers } = await createTestSessionWithOrgAdmin();
const res = await app.request("/api/v1/auth/sso-providers/test-provider/auto-linking", { const res = await app.request("/api/v1/auth/sso-providers/test-provider/auto-linking", {
method: "PATCH", method: "PATCH",
headers: { headers: {
...headers, ...orgAdminSession.headers,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({}), body: JSON.stringify({}),
@ -100,11 +100,10 @@ describe("auth controller security", () => {
describe("PATCH /api/v1/auth/org-members/:memberId/role specific", () => { describe("PATCH /api/v1/auth/org-members/:memberId/role specific", () => {
test("should return 400 for invalid payload", async () => { test("should return 400 for invalid payload", async () => {
const { headers } = await createTestSessionWithOrgAdmin();
const res = await app.request("/api/v1/auth/org-members/test-member/role", { const res = await app.request("/api/v1/auth/org-members/test-member/role", {
method: "PATCH", method: "PATCH",
headers: { headers: {
...headers, ...orgAdminSession.headers,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({}), body: JSON.stringify({}),
@ -130,31 +129,27 @@ describe("auth controller security", () => {
}); });
test(`${method} ${path} should return 403 for regular users`, async () => { test(`${method} ${path} should return 403 for regular users`, async () => {
const { headers } = await createTestSession(); const res = await app.request(path, { method, headers: regularUserSession.headers });
const res = await app.request(path, { method, headers });
expect(res.status).toBe(403); expect(res.status).toBe(403);
const body = await res.json(); const body = await res.json();
expect(body.message).toBe("Forbidden"); expect(body.message).toBe("Forbidden");
}); });
test(`${method} ${path} should return 403 for org admins`, async () => { test(`${method} ${path} should return 403 for org admins`, async () => {
const { headers } = await createTestSessionWithOrgAdmin(); const res = await app.request(path, { method, headers: orgAdminSession.headers });
const res = await app.request(path, { method, headers });
expect(res.status).toBe(403); expect(res.status).toBe(403);
const body = await res.json(); const body = await res.json();
expect(body.message).toBe("Forbidden"); expect(body.message).toBe("Forbidden");
}); });
test(`${method} ${path} should not return 401 for global admins`, async () => { test(`${method} ${path} should not return 401 for global admins`, async () => {
const { headers } = await createTestSessionWithGlobalAdmin(); const res = await app.request(path, { method, headers: globalAdminSession.headers });
const res = await app.request(path, { method, headers });
// Should not be 401 - actual response depends on endpoint logic // Should not be 401 - actual response depends on endpoint logic
expect(res.status).not.toBe(401); expect(res.status).not.toBe(401);
}); });
} }
test("global admins can delete an account for a user outside their active organization", async () => { test("global admins can delete an account for a user outside their active organization", async () => {
const { headers } = await createTestSessionWithGlobalAdmin();
const target = await createTestSession(); const target = await createTestSession();
const retainedAccountId = Bun.randomUUIDv7(); const retainedAccountId = Bun.randomUUIDv7();
@ -176,7 +171,7 @@ describe("auth controller security", () => {
const res = await app.request(`/api/v1/auth/admin-users/${target.user.id}/accounts/${removableAccountId}`, { const res = await app.request(`/api/v1/auth/admin-users/${target.user.id}/accounts/${removableAccountId}`, {
method: "DELETE", method: "DELETE",
headers, headers: globalAdminSession.headers,
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);

View file

@ -1,15 +1,34 @@
import { afterEach, describe, expect, test, vi } from "vitest";
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return {
...actual,
access: vi.fn(actual.access),
};
});
vi.mock("../../../../utils/mountinfo", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../utils/mountinfo")>();
return {
...actual,
getMountForPath: vi.fn(actual.getMountForPath),
};
});
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
import * as mountinfo from "../../../../utils/mountinfo"; import * as mountinfo from "../../../../utils/mountinfo";
import { assertMounted } from "../backend-utils"; import { assertMounted } from "../backend-utils";
afterEach(() => { afterEach(() => {
mock.restore(); vi.restoreAllMocks();
}); });
describe("assertMountedFilesystem", () => { describe("assertMountedFilesystem", () => {
test("throws when the path is not accessible", async () => { test("throws when the path is not accessible", async () => {
spyOn(fs, "access").mockRejectedValueOnce(new Error("missing")); vi.mocked(fs.access).mockRejectedValueOnce(new Error("missing"));
await expect(assertMounted("/tmp/volume", (fstype) => fstype.startsWith("nfs"))).rejects.toThrow( await expect(assertMounted("/tmp/volume", (fstype) => fstype.startsWith("nfs"))).rejects.toThrow(
"Volume is not mounted", "Volume is not mounted",
@ -17,8 +36,8 @@ describe("assertMountedFilesystem", () => {
}); });
test("throws when the mount filesystem does not match", async () => { test("throws when the mount filesystem does not match", async () => {
spyOn(fs, "access").mockResolvedValueOnce(undefined); vi.mocked(fs.access).mockResolvedValueOnce(undefined);
spyOn(mountinfo, "getMountForPath").mockResolvedValueOnce({ vi.mocked(mountinfo.getMountForPath).mockResolvedValueOnce({
mountPoint: "/tmp/volume", mountPoint: "/tmp/volume",
fstype: "cifs", fstype: "cifs",
}); });
@ -29,8 +48,8 @@ describe("assertMountedFilesystem", () => {
}); });
test("accepts a matching mounted filesystem", async () => { test("accepts a matching mounted filesystem", async () => {
spyOn(fs, "access").mockResolvedValueOnce(undefined); vi.mocked(fs.access).mockResolvedValueOnce(undefined);
spyOn(mountinfo, "getMountForPath").mockResolvedValueOnce({ vi.mocked(mountinfo.getMountForPath).mockResolvedValueOnce({
mountPoint: "/tmp/volume", mountPoint: "/tmp/volume",
fstype: "nfs4", fstype: "nfs4",
}); });

View file

@ -1,4 +1,4 @@
import { test, describe, expect } from "bun:test"; import { beforeAll, describe, expect, test } from "vitest";
import { createApp } from "~/server/app"; import { createApp } from "~/server/app";
import { createTestSession, getAuthHeaders } from "~/test/helpers/auth"; import { createTestSession, getAuthHeaders } from "~/test/helpers/auth";
import { createTestVolume } from "~/test/helpers/volume"; import { createTestVolume } from "~/test/helpers/volume";
@ -7,6 +7,12 @@ import { createTestBackupSchedule } from "~/test/helpers/backup";
const app = createApp(); const app = createApp();
let session: Awaited<ReturnType<typeof createTestSession>>;
beforeAll(async () => {
session = await createTestSession();
});
describe("backups security", () => { describe("backups security", () => {
test("should return 401 if no session cookie is provided", async () => { test("should return 401 if no session cookie is provided", async () => {
const res = await app.request("/api/v1/backups"); const res = await app.request("/api/v1/backups");
@ -25,10 +31,8 @@ describe("backups security", () => {
}); });
test("should return 200 if session is valid", async () => { test("should return 200 if session is valid", async () => {
const { headers } = await createTestSession();
const res = await app.request("/api/v1/backups", { const res = await app.request("/api/v1/backups", {
headers, headers: session.headers,
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
@ -82,17 +86,16 @@ describe("backups security", () => {
describe("input validation", () => { describe("input validation", () => {
test("should return a schedule when queried by short id", async () => { test("should return a schedule when queried by short id", async () => {
const { headers, organizationId } = await createTestSession(); const volume = await createTestVolume({ organizationId: session.organizationId });
const volume = await createTestVolume({ organizationId }); const repository = await createTestRepository({ organizationId: session.organizationId });
const repository = await createTestRepository({ organizationId });
const schedule = await createTestBackupSchedule({ const schedule = await createTestBackupSchedule({
organizationId, organizationId: session.organizationId,
volumeId: volume.id, volumeId: volume.id,
repositoryId: repository.id, repositoryId: repository.id,
}); });
const res = await app.request(`/api/v1/backups/${schedule.shortId}`, { const res = await app.request(`/api/v1/backups/${schedule.shortId}`, {
headers, headers: session.headers,
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
@ -102,18 +105,16 @@ describe("backups security", () => {
}); });
test("should return 404 for malformed schedule ID", async () => { test("should return 404 for malformed schedule ID", async () => {
const { headers } = await createTestSession();
const res = await app.request("/api/v1/backups/not-a-number", { const res = await app.request("/api/v1/backups/not-a-number", {
headers, headers: session.headers,
}); });
expect(res.status).toBe(404); expect(res.status).toBe(404);
}); });
test("should return 404 for non-existent schedule ID", async () => { test("should return 404 for non-existent schedule ID", async () => {
const { headers } = await createTestSession();
const res = await app.request("/api/v1/backups/999999", { const res = await app.request("/api/v1/backups/999999", {
headers, headers: session.headers,
}); });
expect(res.status).toBe(404); expect(res.status).toBe(404);
@ -122,11 +123,10 @@ describe("backups security", () => {
}); });
test("should return 400 for invalid payload on create", async () => { test("should return 400 for invalid payload on create", async () => {
const { headers } = await createTestSession();
const res = await app.request("/api/v1/backups", { const res = await app.request("/api/v1/backups", {
method: "POST", method: "POST",
headers: { headers: {
...headers, ...session.headers,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({

View file

@ -1,5 +1,5 @@
import waitForExpect from "wait-for-expect"; import waitForExpect from "wait-for-expect";
import { test, describe, mock, expect, afterEach, spyOn } from "bun:test"; import { afterEach, describe, expect, test, vi } from "vitest";
import { backupsService } from "../backups.service"; import { backupsService } from "../backups.service";
import { backupsExecutionService } from "../backups.execution"; import { backupsExecutionService } from "../backups.execution";
import { createTestVolume } from "~/test/helpers/volume"; import { createTestVolume } from "~/test/helpers/volume";
@ -13,18 +13,16 @@ import * as spawnModule from "@zerobyte/core/node";
import type { SafeSpawnParams } from "@zerobyte/core/node"; import type { SafeSpawnParams } from "@zerobyte/core/node";
import { restic } from "~/server/core/restic"; import { restic } from "~/server/core/restic";
import { NotFoundError, BadRequestError } from "http-errors-enhanced"; import { NotFoundError, BadRequestError } from "http-errors-enhanced";
import { scheduleQueries } from "../backups.queries";
import { fromAny } from "@total-typescript/shoehorn";
import { repositoriesService } from "~/server/modules/repositories/repositories.service"; import { repositoriesService } from "~/server/modules/repositories/repositories.service";
import { repoMutex } from "~/server/core/repository-mutex"; import { repoMutex } from "~/server/core/repository-mutex";
const setup = () => { const setup = () => {
const resticBackupMock = mock((_: SafeSpawnParams) => const resticBackupMock = vi.fn((_: SafeSpawnParams) =>
Promise.resolve({ exitCode: 0, summary: generateBackupOutput(), error: "" }), Promise.resolve({ exitCode: 0, summary: generateBackupOutput(), error: "" }),
); );
const resticForgetMock = mock(() => Promise.resolve({ success: true, data: null })); const resticForgetMock = vi.fn(() => Promise.resolve({ success: true, data: null }));
const resticCopyMock = mock(() => Promise.resolve({ success: true, output: "" })); const resticCopyMock = vi.fn(() => Promise.resolve({ success: true, output: "" }));
const refreshStatsMock = mock(() => const refreshStatsMock = vi.fn(() =>
Promise.resolve({ Promise.resolve({
total_size: 0, total_size: 0,
total_uncompressed_size: 0, total_uncompressed_size: 0,
@ -35,11 +33,11 @@ const setup = () => {
}), }),
); );
spyOn(spawnModule, "safeSpawn").mockImplementation(resticBackupMock); vi.spyOn(spawnModule, "safeSpawn").mockImplementation(resticBackupMock);
spyOn(restic, "forget").mockImplementation(resticForgetMock); vi.spyOn(restic, "forget").mockImplementation(resticForgetMock);
spyOn(restic, "copy").mockImplementation(resticCopyMock); vi.spyOn(restic, "copy").mockImplementation(resticCopyMock);
spyOn(repositoriesService, "refreshRepositoryStats").mockImplementation(refreshStatsMock); vi.spyOn(repositoriesService, "refreshRepositoryStats").mockImplementation(refreshStatsMock);
spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID); vi.spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID);
return { return {
resticBackupMock, resticBackupMock,
@ -50,7 +48,7 @@ const setup = () => {
}; };
afterEach(() => { afterEach(() => {
mock.restore(); vi.restoreAllMocks();
}); });
describe("backup execution - validation failures", () => { describe("backup execution - validation failures", () => {
@ -76,67 +74,6 @@ describe("backup execution - validation failures", () => {
expect(resticBackupMock).not.toHaveBeenCalled(); expect(resticBackupMock).not.toHaveBeenCalled();
}); });
test("should fail backup when volume does not exist", async () => {
// arrange
setup();
const volume = await createTestVolume();
const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({
volumeId: volume.id,
repositoryId: repository.id,
});
const hydratedSchedule = await scheduleQueries.findById(schedule.id, TEST_ORG_ID);
expect(hydratedSchedule).toBeDefined();
const scheduleWithoutVolume = {
...hydratedSchedule,
volume: null,
};
spyOn(scheduleQueries, "findById").mockResolvedValueOnce(fromAny(scheduleWithoutVolume));
// act
const result = await backupsExecutionService.validateBackupExecution(schedule.id);
// assert
expect(result.type).toBe("failure");
if (result.type === "failure") {
expect(result.error).toBeInstanceOf(NotFoundError);
expect(result.error.message).toBe("Volume not found");
expect(result.partialContext?.schedule).toBeDefined();
}
});
test("should fail backup when repository does not exist", async () => {
// arrange
setup();
const volume = await createTestVolume();
const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({
volumeId: volume.id,
repositoryId: repository.id,
});
const hydratedSchedule = await scheduleQueries.findById(schedule.id, TEST_ORG_ID);
expect(hydratedSchedule).toBeDefined();
const scheduleWithoutRepository = {
...hydratedSchedule,
repository: null,
};
spyOn(scheduleQueries, "findById").mockResolvedValueOnce(fromAny(scheduleWithoutRepository));
// act
const result = await backupsExecutionService.validateBackupExecution(schedule.id);
// assert
expect(result.type).toBe("failure");
if (result.type === "failure") {
expect(result.error).toBeInstanceOf(NotFoundError);
expect(result.error.message).toBe("Repository not found");
expect(result.partialContext?.schedule).toBeDefined();
expect(result.partialContext?.volume).toBeDefined();
}
});
test("should fail backup when schedule does not exist", async () => { test("should fail backup when schedule does not exist", async () => {
setup(); setup();
// act // act
@ -261,7 +198,7 @@ describe("stop backup", () => {
repositoryId: repository.id, repositoryId: repository.id,
}); });
spyOn(repoMutex, "acquireShared").mockImplementation((_repositoryId, _operation, signal) => { vi.spyOn(repoMutex, "acquireShared").mockImplementation((_repositoryId, _operation, signal) => {
return new Promise((_, reject) => { return new Promise((_, reject) => {
if (signal?.aborted) { if (signal?.aborted) {
reject(signal.reason instanceof Error ? signal.reason : new Error("Operation aborted")); reject(signal.reason instanceof Error ? signal.reason : new Error("Operation aborted"));

View file

@ -1,4 +1,4 @@
import { test, describe, expect } from "bun:test"; import { describe, expect, test } from "vitest";
import path from "node:path"; import path from "node:path";
import { fromAny } from "@total-typescript/shoehorn"; import { fromAny } from "@total-typescript/shoehorn";
import { createBackupOptions, processPattern } from "../backup.helpers"; import { createBackupOptions, processPattern } from "../backup.helpers";

View file

@ -1,4 +1,4 @@
import { test, describe, expect } from "bun:test"; import { describe, expect, test } from "vitest";
import { scheduleQueries } from "../backups.queries"; import { scheduleQueries } from "../backups.queries";
import { createTestBackupSchedule } from "~/test/helpers/backup"; import { createTestBackupSchedule } from "~/test/helpers/backup";
import { createTestVolume } from "~/test/helpers/volume"; import { createTestVolume } from "~/test/helpers/volume";

View file

@ -1,5 +1,5 @@
import waitForExpect from "wait-for-expect"; import waitForExpect from "wait-for-expect";
import { test, describe, mock, expect, afterEach, spyOn } from "bun:test"; import { afterEach, describe, expect, test, vi } from "vitest";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { backupsService } from "../backups.service"; import { backupsService } from "../backups.service";
import { createTestVolume } from "~/test/helpers/volume"; import { createTestVolume } from "~/test/helpers/volume";
@ -16,8 +16,8 @@ import { backupsExecutionService } from "../backups.execution";
import { repositoriesService } from "~/server/modules/repositories/repositories.service"; import { repositoriesService } from "~/server/modules/repositories/repositories.service";
const setup = () => { const setup = () => {
const resticBackupMock = mock(() => Promise.resolve({ exitCode: 0, summary: "", error: "" })); const resticBackupMock = vi.fn(() => Promise.resolve({ exitCode: 0, summary: "", error: "" }));
const refreshStatsMock = mock(() => const refreshStatsMock = vi.fn(() =>
Promise.resolve({ Promise.resolve({
total_size: 0, total_size: 0,
total_uncompressed_size: 0, total_uncompressed_size: 0,
@ -27,9 +27,9 @@ const setup = () => {
snapshots_count: 0, snapshots_count: 0,
}), }),
); );
spyOn(spawnModule, "safeSpawn").mockImplementation(resticBackupMock); vi.spyOn(spawnModule, "safeSpawn").mockImplementation(resticBackupMock);
spyOn(repositoriesService, "refreshRepositoryStats").mockImplementation(refreshStatsMock); vi.spyOn(repositoriesService, "refreshRepositoryStats").mockImplementation(refreshStatsMock);
spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID); vi.spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID);
return { return {
resticBackupMock, resticBackupMock,
@ -38,7 +38,7 @@ const setup = () => {
}; };
afterEach(() => { afterEach(() => {
mock.restore(); vi.restoreAllMocks();
}); });
describe("execute backup", () => { describe("execute backup", () => {
@ -110,6 +110,9 @@ describe("execute backup", () => {
// assert // assert
expect(resticBackupMock).toHaveBeenCalled(); expect(resticBackupMock).toHaveBeenCalled();
const updatedSchedule = await backupsService.getScheduleById(schedule.id);
expect(updatedSchedule.lastBackupStatus).toBe("success");
expect(updatedSchedule.lastBackupAt).not.toBeNull();
}); });
test("should keep next backup time empty for manual-only schedules after a manual run", async () => { test("should keep next backup time empty for manual-only schedules after a manual run", async () => {

View file

@ -1,4 +1,4 @@
import { test, describe, expect } from "bun:test"; import { describe, expect, test } from "vitest";
import { createApp } from "~/server/app"; import { createApp } from "~/server/app";
import { serverEvents } from "~/server/core/events"; import { serverEvents } from "~/server/core/events";
import { createTestSession, getAuthHeaders } from "~/test/helpers/auth"; import { createTestSession, getAuthHeaders } from "~/test/helpers/auth";

View file

@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "vitest";
import { buildCustomShoutrrrUrl } from "../builders/custom"; import { buildCustomShoutrrrUrl } from "../builders/custom";
import { buildDiscordShoutrrrUrl } from "../builders/discord"; import { buildDiscordShoutrrrUrl } from "../builders/discord";
import { buildEmailShoutrrrUrl } from "../builders/email"; import { buildEmailShoutrrrUrl } from "../builders/email";

View file

@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "vitest";
import { mapNotificationConfigSecrets } from "../notification-config-secrets"; import { mapNotificationConfigSecrets } from "../notification-config-secrets";
describe("mapNotificationConfigSecrets", () => { describe("mapNotificationConfigSecrets", () => {

View file

@ -1,9 +1,15 @@
import { test, describe, expect } from "bun:test"; import { beforeAll, describe, expect, test } from "vitest";
import { createApp } from "~/server/app"; import { createApp } from "~/server/app";
import { createTestSession, getAuthHeaders } from "~/test/helpers/auth"; import { createTestSession, getAuthHeaders } from "~/test/helpers/auth";
const app = createApp(); const app = createApp();
let session: Awaited<ReturnType<typeof createTestSession>>;
beforeAll(async () => {
session = await createTestSession();
});
describe("notifications security", () => { describe("notifications security", () => {
test("should return 401 if no session cookie is provided", async () => { test("should return 401 if no session cookie is provided", async () => {
const res = await app.request("/api/v1/notifications/destinations"); const res = await app.request("/api/v1/notifications/destinations");
@ -22,10 +28,8 @@ describe("notifications security", () => {
}); });
test("should return 200 if session is valid", async () => { test("should return 200 if session is valid", async () => {
const { headers } = await createTestSession();
const res = await app.request("/api/v1/notifications/destinations", { const res = await app.request("/api/v1/notifications/destinations", {
headers, headers: session.headers,
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
@ -62,18 +66,16 @@ describe("notifications security", () => {
describe("input validation", () => { describe("input validation", () => {
test("should return 404 for malformed destination ID", async () => { test("should return 404 for malformed destination ID", async () => {
const { headers } = await createTestSession();
const res = await app.request("/api/v1/notifications/destinations/not-a-number", { const res = await app.request("/api/v1/notifications/destinations/not-a-number", {
headers, headers: session.headers,
}); });
expect(res.status).toBe(404); expect(res.status).toBe(404);
}); });
test("should return 404 for non-existent destination ID", async () => { test("should return 404 for non-existent destination ID", async () => {
const { headers } = await createTestSession();
const res = await app.request("/api/v1/notifications/destinations/999999", { const res = await app.request("/api/v1/notifications/destinations/999999", {
headers, headers: session.headers,
}); });
expect(res.status).toBe(404); expect(res.status).toBe(404);
@ -82,12 +84,10 @@ describe("notifications security", () => {
}); });
test("should return 400 for invalid payload on create", async () => { test("should return 400 for invalid payload on create", async () => {
const { headers } = await createTestSession();
const res = await app.request("/api/v1/notifications/destinations", { const res = await app.request("/api/v1/notifications/destinations", {
method: "POST", method: "POST",
headers: { headers: {
...headers, ...session.headers,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({

View file

@ -1,17 +1,31 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import { db } from "~/server/db/db"; import { db } from "~/server/db/db";
import { backupSchedulesTable, repositoriesTable } from "~/server/db/schema"; import { backupSchedulesTable, repositoriesTable, volumesTable } from "~/server/db/schema";
import { restic } from "~/server/core/restic"; import { restic } from "~/server/core/restic";
import { createTestSession } from "~/test/helpers/auth"; import { createTestSession } from "~/test/helpers/auth";
import { generateShortId } from "~/server/utils/id"; import { generateShortId } from "~/server/utils/id";
import { provisionedResourcesSchema, readProvisionedResourcesFile, syncProvisionedResources } from "./provisioning"; import { provisionedResourcesSchema, readProvisionedResourcesFile, syncProvisionedResources } from "./provisioning";
describe("provisioning", () => { describe("provisioning", () => {
let session: Awaited<ReturnType<typeof createTestSession>>;
beforeAll(async () => {
session = await createTestSession();
});
beforeEach(async () => {
vi.spyOn(restic, "init").mockResolvedValue({ success: true, error: null });
await db.delete(backupSchedulesTable);
await db.delete(volumesTable);
await db.delete(repositoriesTable);
});
afterEach(() => { afterEach(() => {
mock.restore(); vi.restoreAllMocks();
}); });
test("rejects duplicate ids for the same organization", () => { test("rejects duplicate ids for the same organization", () => {
@ -46,7 +60,7 @@ describe("provisioning", () => {
}); });
test("syncs provisioned repositories and volumes into the database", async () => { test("syncs provisioned repositories and volumes into the database", async () => {
const { organizationId } = await createTestSession(); const { organizationId } = session;
process.env.ZEROBYTE_PROVISIONED_ACCESS_KEY = "access-key-from-env"; process.env.ZEROBYTE_PROVISIONED_ACCESS_KEY = "access-key-from-env";
@ -113,7 +127,7 @@ describe("provisioning", () => {
}); });
test("removes managed resources when delete is set", async () => { test("removes managed resources when delete is set", async () => {
const { organizationId } = await createTestSession(); const { organizationId } = session;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-provisioning-")); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-provisioning-"));
const provisioningPath = path.join(tempDir, "provisioning.json"); const provisioningPath = path.join(tempDir, "provisioning.json");
@ -196,7 +210,7 @@ describe("provisioning", () => {
}); });
test("renaming a provisioned volume updates it in place", async () => { test("renaming a provisioned volume updates it in place", async () => {
const { organizationId } = await createTestSession(); const { organizationId } = session;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-provisioning-")); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-provisioning-"));
const provisioningPath = path.join(tempDir, "provisioning.json"); const provisioningPath = path.join(tempDir, "provisioning.json");
const provisionedVolumeId = "shared-directory"; const provisionedVolumeId = "shared-directory";
@ -296,7 +310,7 @@ describe("provisioning", () => {
}); });
test("does not partially sync resources when resolving a provisioned secret fails", async () => { test("does not partially sync resources when resolving a provisioned secret fails", async () => {
const { organizationId } = await createTestSession(); const { organizationId } = session;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-provisioning-")); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-provisioning-"));
const provisioningPath = path.join(tempDir, "provisioning.json"); const provisioningPath = path.join(tempDir, "provisioning.json");
@ -344,12 +358,12 @@ describe("provisioning", () => {
}); });
test("initializes a non-existing provisioned repository on first sync", async () => { test("initializes a non-existing provisioned repository on first sync", async () => {
const { organizationId } = await createTestSession(); const { organizationId } = session;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-provisioning-")); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-provisioning-"));
const provisioningPath = path.join(tempDir, "provisioning.json"); const provisioningPath = path.join(tempDir, "provisioning.json");
const initMock = mock(() => Promise.resolve({ success: true, error: null })); const initMock = vi.fn(() => Promise.resolve({ success: true, error: null }));
spyOn(restic, "init").mockImplementation(initMock); vi.spyOn(restic, "init").mockImplementation(initMock);
await fs.writeFile( await fs.writeFile(
provisioningPath, provisioningPath,

View file

@ -1,4 +1,4 @@
import { test, describe, expect, spyOn } from "bun:test"; import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import crypto from "node:crypto"; import crypto from "node:crypto";
import { PassThrough } from "node:stream"; import { PassThrough } from "node:stream";
import { createApp } from "~/server/app"; import { createApp } from "~/server/app";
@ -7,9 +7,24 @@ import { repositoriesTable } from "~/server/db/schema";
import { generateShortId } from "~/server/utils/id"; import { generateShortId } from "~/server/utils/id";
import { createTestSession, getAuthHeaders } from "~/test/helpers/auth"; import { createTestSession, getAuthHeaders } from "~/test/helpers/auth";
import type { RepositoryConfig } from "@zerobyte/core/restic"; import type { RepositoryConfig } from "@zerobyte/core/restic";
import { restic } from "~/server/core/restic";
const app = createApp(); const app = createApp();
let session: Awaited<ReturnType<typeof createTestSession>>;
beforeAll(async () => {
session = await createTestSession();
});
beforeEach(() => {
vi.spyOn(restic, "init").mockResolvedValue({ success: true, error: null });
});
afterEach(() => {
vi.restoreAllMocks();
});
const createRepositoryRecord = async (organizationId: string) => { const createRepositoryRecord = async (organizationId: string) => {
const [repository] = await db const [repository] = await db
.insert(repositoriesTable) .insert(repositoriesTable)
@ -81,10 +96,8 @@ describe("repositories security", () => {
}); });
test("should return 200 if session is valid", async () => { test("should return 200 if session is valid", async () => {
const { headers } = await createTestSession();
const res = await app.request("/api/v1/repositories", { const res = await app.request("/api/v1/repositories", {
headers, headers: session.headers,
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
@ -150,9 +163,8 @@ describe("repositories security", () => {
describe("input validation", () => { describe("input validation", () => {
test("should return 404 for non-existent repository", async () => { test("should return 404 for non-existent repository", async () => {
const { headers } = await createTestSession();
const res = await app.request("/api/v1/repositories/non-existent-repo", { const res = await app.request("/api/v1/repositories/non-existent-repo", {
headers, headers: session.headers,
}); });
expect(res.status).toBe(404); expect(res.status).toBe(404);
@ -161,9 +173,8 @@ describe("repositories security", () => {
}); });
test("should return 404 for stats of non-existent repository", async () => { test("should return 404 for stats of non-existent repository", async () => {
const { headers } = await createTestSession();
const res = await app.request("/api/v1/repositories/non-existent-repo/stats", { const res = await app.request("/api/v1/repositories/non-existent-repo/stats", {
headers, headers: session.headers,
}); });
expect(res.status).toBe(404); expect(res.status).toBe(404);
@ -172,10 +183,9 @@ describe("repositories security", () => {
}); });
test("should return 404 for stats refresh of non-existent repository", async () => { test("should return 404 for stats refresh of non-existent repository", async () => {
const { headers } = await createTestSession();
const res = await app.request("/api/v1/repositories/non-existent-repo/stats/refresh", { const res = await app.request("/api/v1/repositories/non-existent-repo/stats/refresh", {
method: "POST", method: "POST",
headers, headers: session.headers,
}); });
expect(res.status).toBe(404); expect(res.status).toBe(404);
@ -184,11 +194,10 @@ describe("repositories security", () => {
}); });
test("should return 400 for invalid payload on create", async () => { test("should return 400 for invalid payload on create", async () => {
const { headers } = await createTestSession();
const res = await app.request("/api/v1/repositories", { const res = await app.request("/api/v1/repositories", {
method: "POST", method: "POST",
headers: { headers: {
...headers, ...session.headers,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
@ -200,11 +209,10 @@ describe("repositories security", () => {
}); });
test("should accept env:// values as plain secrets on create", async () => { test("should accept env:// values as plain secrets on create", async () => {
const { headers } = await createTestSession();
const res = await app.request("/api/v1/repositories", { const res = await app.request("/api/v1/repositories", {
method: "POST", method: "POST",
headers: { headers: {
...headers, ...session.headers,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
@ -227,14 +235,13 @@ describe("repositories security", () => {
describe("repositories updates", () => { describe("repositories updates", () => {
test("PATCH updates full config and metadata using shortId", async () => { test("PATCH updates full config and metadata using shortId", async () => {
const { headers, organizationId } = await createTestSession(); const repository = await createRepositoryRecord(session.organizationId);
const repository = await createRepositoryRecord(organizationId);
const nextPath = `/tmp/updated-${crypto.randomUUID()}`; const nextPath = `/tmp/updated-${crypto.randomUUID()}`;
const res = await app.request(`/api/v1/repositories/${repository.shortId}`, { const res = await app.request(`/api/v1/repositories/${repository.shortId}`, {
method: "PATCH", method: "PATCH",
headers: { headers: {
...headers, ...session.headers,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
@ -276,13 +283,12 @@ describe("repositories updates", () => {
}); });
test("PATCH rejects backend changes", async () => { test("PATCH rejects backend changes", async () => {
const { headers, organizationId } = await createTestSession(); const repository = await createRepositoryRecord(session.organizationId);
const repository = await createRepositoryRecord(organizationId);
const res = await app.request(`/api/v1/repositories/${repository.shortId}`, { const res = await app.request(`/api/v1/repositories/${repository.shortId}`, {
method: "PATCH", method: "PATCH",
headers: { headers: {
...headers, ...session.headers,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
@ -302,13 +308,12 @@ describe("repositories updates", () => {
}); });
test("PATCH rejects invalid config payload", async () => { test("PATCH rejects invalid config payload", async () => {
const { headers, organizationId } = await createTestSession(); const repository = await createRepositoryRecord(session.organizationId);
const repository = await createRepositoryRecord(organizationId);
const res = await app.request(`/api/v1/repositories/${repository.shortId}`, { const res = await app.request(`/api/v1/repositories/${repository.shortId}`, {
method: "PATCH", method: "PATCH",
headers: { headers: {
...headers, ...session.headers,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
@ -323,20 +328,19 @@ describe("repositories updates", () => {
describe("delete snapshot", () => { describe("delete snapshot", () => {
test("should return 500 when restic deleteSnapshot throws ResticError", async () => { test("should return 500 when restic deleteSnapshot throws ResticError", async () => {
const { headers, organizationId } = await createTestSession(); const repository = await createRepositoryRecord(session.organizationId);
const repository = await createRepositoryRecord(organizationId);
const { restic } = await import("~/server/core/restic"); const { restic } = await import("~/server/core/restic");
const { ResticError } = await import("@zerobyte/core/restic"); const { ResticError } = await import("@zerobyte/core/restic");
const deleteSnapshotSpy = spyOn(restic, "deleteSnapshot").mockImplementation(async () => { const deleteSnapshotSpy = vi.spyOn(restic, "deleteSnapshot").mockImplementation(async () => {
throw new ResticError(1, "Fatal: unexpected HTTP response (403): 403 Forbidden"); throw new ResticError(1, "Fatal: unexpected HTTP response (403): 403 Forbidden");
}); });
try { try {
const res = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/snap123`, { const res = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/snap123`, {
method: "DELETE", method: "DELETE",
headers, headers: session.headers,
}); });
expect(res.status).toBe(500); expect(res.status).toBe(500);
@ -351,27 +355,29 @@ describe("repositories updates", () => {
describe("dump snapshot", () => { describe("dump snapshot", () => {
test("continues streaming a download after the request signal aborts", async () => { test("continues streaming a download after the request signal aborts", async () => {
const { headers, organizationId } = await createTestSession(); const repository = await createRepositoryRecord(session.organizationId);
const repository = await createRepositoryRecord(organizationId);
const { repositoriesService } = await import("~/server/modules/repositories/repositories.service");
const stream = new PassThrough(); const stream = new PassThrough();
const expectedContent = "downloaded snapshot contents"; const expectedContent = "downloaded snapshot contents";
const dumpSnapshotSpy = spyOn(repositoriesService, "dumpSnapshot").mockResolvedValue({ const snapshotsSpy = vi.spyOn(restic, "snapshots").mockResolvedValue([
{
id: "test-snapshot",
short_id: "test-snapshot",
time: new Date().toISOString(),
paths: ["/mnt/project"],
hostname: "host",
},
]);
const dumpSpy = vi.spyOn(restic, "dump").mockResolvedValue({
stream, stream,
completion: Promise.resolve(), completion: Promise.resolve(),
abort: () => { abort: vi.fn(),
stream.destroy(new Error("download aborted"));
},
filename: "snapshot.txt",
contentType: "application/octet-stream",
}); });
try { try {
const controller = new AbortController(); const controller = new AbortController();
const response = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/test-snapshot/dump`, { const response = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/test-snapshot/dump`, {
headers, headers: session.headers,
signal: controller.signal, signal: controller.signal,
}); });
@ -382,48 +388,56 @@ describe("repositories updates", () => {
await expect(response.text()).resolves.toBe(expectedContent); await expect(response.text()).resolves.toBe(expectedContent);
} finally { } finally {
dumpSnapshotSpy.mockRestore(); snapshotsSpy.mockRestore();
dumpSpy.mockRestore();
} }
}); });
test("returns a valid content-disposition header for non-ascii filenames", async () => { test("returns a valid content-disposition header for non-ascii filenames", async () => {
const { headers, organizationId } = await createTestSession(); const repository = await createRepositoryRecord(session.organizationId);
const repository = await createRepositoryRecord(organizationId);
const { repositoriesService } = await import("~/server/modules/repositories/repositories.service");
const stream = new PassThrough(); const stream = new PassThrough();
const dumpSnapshotSpy = spyOn(repositoriesService, "dumpSnapshot").mockResolvedValue({ const snapshotsSpy = vi.spyOn(restic, "snapshots").mockResolvedValue([
{
id: "test-snapshot",
short_id: "test-snapshot",
time: new Date().toISOString(),
paths: ["/mnt/project"],
hostname: "host",
},
]);
const dumpSpy = vi.spyOn(restic, "dump").mockResolvedValue({
stream, stream,
completion: Promise.resolve(), completion: Promise.resolve(),
abort: () => { abort: vi.fn(),
stream.destroy(new Error("download aborted"));
},
filename: "möte.txt",
contentType: "application/octet-stream",
}); });
try { try {
stream.end("downloaded snapshot contents"); stream.end("downloaded snapshot contents");
const response = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/test-snapshot/dump`, { const encodedPath = encodeURIComponent("/mnt/project/möte.txt");
headers, const response = await app.request(
}); `/api/v1/repositories/${repository.shortId}/snapshots/test-snapshot/dump?path=${encodedPath}&kind=file`,
{
headers: session.headers,
},
);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.headers.get("Content-Disposition")).toBe( expect(response.headers.get("Content-Disposition")).toBe(
`attachment; filename="m?te.txt"; filename*=UTF-8''m%C3%B6te.txt`, `attachment; filename="m?te.txt"; filename*=UTF-8''m%C3%B6te.txt`,
); );
} finally { } finally {
dumpSnapshotSpy.mockRestore(); snapshotsSpy.mockRestore();
dumpSpy.mockRestore();
} }
}); });
}); });
test("GET marks provisioned repositories as managed", async () => { test("GET marks provisioned repositories as managed", async () => {
const { headers, organizationId } = await createTestSession(); const repository = await createManagedRepositoryRecord(session.organizationId);
const repository = await createManagedRepositoryRecord(organizationId);
const res = await app.request(`/api/v1/repositories/${repository.shortId}`, { headers }); const res = await app.request(`/api/v1/repositories/${repository.shortId}`, { headers: session.headers });
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = await res.json(); const body = await res.json();
@ -431,13 +445,12 @@ describe("repositories updates", () => {
}); });
test("PATCH allows updates for managed repositories", async () => { test("PATCH allows updates for managed repositories", async () => {
const { headers, organizationId } = await createTestSession(); const repository = await createManagedRepositoryRecord(session.organizationId);
const repository = await createManagedRepositoryRecord(organizationId);
const res = await app.request(`/api/v1/repositories/${repository.shortId}`, { const res = await app.request(`/api/v1/repositories/${repository.shortId}`, {
method: "PATCH", method: "PATCH",
headers: { headers: {
...headers, ...session.headers,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
@ -451,12 +464,11 @@ describe("repositories updates", () => {
}); });
test("DELETE allows managed repositories", async () => { test("DELETE allows managed repositories", async () => {
const { headers, organizationId } = await createTestSession(); const repository = await createManagedRepositoryRecord(session.organizationId);
const repository = await createManagedRepositoryRecord(organizationId);
const res = await app.request(`/api/v1/repositories/${repository.shortId}`, { const res = await app.request(`/api/v1/repositories/${repository.shortId}`, {
method: "DELETE", method: "DELETE",
headers, headers: session.headers,
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);

View file

@ -1,12 +1,12 @@
import waitForExpect from "wait-for-expect";
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as os from "node:os"; import * as os from "node:os";
import nodePath from "node:path"; import nodePath from "node:path";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import type { RepositoryConfig } from "@zerobyte/core/restic"; import type { RepositoryConfig } from "@zerobyte/core/restic";
import { REPOSITORY_BASE } from "~/server/core/constants"; import { REPOSITORY_BASE } from "~/server/core/constants";
import { serverEvents } from "~/server/core/events";
import { withContext } from "~/server/core/request-context"; import { withContext } from "~/server/core/request-context";
import { db } from "~/server/db/db"; import { db } from "~/server/db/db";
import { repositoriesTable } from "~/server/db/schema"; import { repositoriesTable } from "~/server/db/schema";
@ -37,25 +37,30 @@ const createTestRepository = async (organizationId: string) => {
return repository; return repository;
}; };
let session: Awaited<ReturnType<typeof createTestSession>>;
beforeAll(async () => {
session = await createTestSession();
});
describe("repositoriesService.createRepository", () => { describe("repositoriesService.createRepository", () => {
const initMock = mock(() => Promise.resolve({ success: true, error: null })); const initMock = vi.fn(() => Promise.resolve({ success: true, error: null }));
beforeEach(() => { beforeEach(() => {
initMock.mockClear(); initMock.mockClear();
spyOn(restic, "init").mockImplementation(initMock); vi.spyOn(restic, "init").mockImplementation(initMock);
}); });
afterEach(() => { afterEach(() => {
mock.restore(); vi.restoreAllMocks();
}); });
test("creates a shortId-scoped repository path when using the repository base directory", async () => { test("creates a shortId-scoped repository path when using the repository base directory", async () => {
// arrange // arrange
const { organizationId, user } = await createTestSession();
const config: RepositoryConfig = { backend: "local", path: REPOSITORY_BASE }; const config: RepositoryConfig = { backend: "local", path: REPOSITORY_BASE };
// act // act
const result = await withContext({ organizationId, userId: user.id }, () => const result = await withContext({ organizationId: session.organizationId, userId: session.user.id }, () =>
repositoriesService.createRepository("main repo", config), repositoriesService.createRepository("main repo", config),
); );
@ -80,12 +85,11 @@ describe("repositoriesService.createRepository", () => {
test("creates a shortId-scoped repository path when using a custom directory", async () => { test("creates a shortId-scoped repository path when using a custom directory", async () => {
// arrange // arrange
const { organizationId, user } = await createTestSession();
const explicitPath = `${REPOSITORY_BASE}/custom-${randomUUID()}`; const explicitPath = `${REPOSITORY_BASE}/custom-${randomUUID()}`;
const config: RepositoryConfig = { backend: "local", path: explicitPath }; const config: RepositoryConfig = { backend: "local", path: explicitPath };
// act // act
const result = await withContext({ organizationId, userId: user.id }, () => const result = await withContext({ organizationId: session.organizationId, userId: session.user.id }, () =>
repositoriesService.createRepository("custom repo", config), repositoriesService.createRepository("custom repo", config),
); );
@ -109,14 +113,13 @@ describe("repositoriesService.createRepository", () => {
test("keeps an explicit local repository path unchanged when importing existing repository", async () => { test("keeps an explicit local repository path unchanged when importing existing repository", async () => {
// arrange // arrange
const { organizationId, user } = await createTestSession();
const explicitPath = `${REPOSITORY_BASE}/custom-${randomUUID()}`; const explicitPath = `${REPOSITORY_BASE}/custom-${randomUUID()}`;
const config: RepositoryConfig = { backend: "local", path: explicitPath, isExistingRepository: true }; const config: RepositoryConfig = { backend: "local", path: explicitPath, isExistingRepository: true };
spyOn(restic, "snapshots").mockImplementation(() => Promise.resolve([])); vi.spyOn(restic, "snapshots").mockImplementation(() => Promise.resolve([]));
// act // act
const result = await withContext({ organizationId, userId: user.id }, () => const result = await withContext({ organizationId: session.organizationId, userId: session.user.id }, () =>
repositoriesService.createRepository("existing repo", config), repositoriesService.createRepository("existing repo", config),
); );
@ -140,14 +143,13 @@ describe("repositoriesService.createRepository", () => {
describe("repositoriesService repository stats", () => { describe("repositoriesService repository stats", () => {
afterEach(() => { afterEach(() => {
mock.restore(); vi.restoreAllMocks();
}); });
test("returns empty stats when repository has not been populated yet", async () => { test("returns empty stats when repository has not been populated yet", async () => {
const { organizationId, user } = await createTestSession(); const repository = await createTestRepository(session.organizationId);
const repository = await createTestRepository(organizationId);
const stats = await withContext({ organizationId, userId: user.id }, () => const stats = await withContext({ organizationId: session.organizationId, userId: session.user.id }, () =>
repositoriesService.getRepositoryStats(repository.shortId), repositoriesService.getRepositoryStats(repository.shortId),
); );
@ -162,8 +164,7 @@ describe("repositoriesService repository stats", () => {
}); });
test("refreshes and persists repository stats", async () => { test("refreshes and persists repository stats", async () => {
const { organizationId, user } = await createTestSession(); const repository = await createTestRepository(session.organizationId);
const repository = await createTestRepository(organizationId);
const expectedStats = { const expectedStats = {
total_size: 1024, total_size: 1024,
total_uncompressed_size: 2048, total_uncompressed_size: 2048,
@ -173,9 +174,9 @@ describe("repositoriesService repository stats", () => {
snapshots_count: 3, snapshots_count: 3,
}; };
const statsSpy = spyOn(restic, "stats").mockResolvedValue(expectedStats); const statsSpy = vi.spyOn(restic, "stats").mockResolvedValue(expectedStats);
const refreshed = await withContext({ organizationId, userId: user.id }, () => const refreshed = await withContext({ organizationId: session.organizationId, userId: session.user.id }, () =>
repositoriesService.refreshRepositoryStats(repository.shortId), repositoriesService.refreshRepositoryStats(repository.shortId),
); );
@ -186,7 +187,7 @@ describe("repositoriesService repository stats", () => {
expect(persistedRepository?.stats).toEqual(expectedStats); expect(persistedRepository?.stats).toEqual(expectedStats);
expect(typeof persistedRepository?.statsUpdatedAt).toBe("number"); expect(typeof persistedRepository?.statsUpdatedAt).toBe("number");
const loaded = await withContext({ organizationId, userId: user.id }, () => const loaded = await withContext({ organizationId: session.organizationId, userId: session.user.id }, () =>
repositoriesService.getRepositoryStats(repository.shortId), repositoriesService.getRepositoryStats(repository.shortId),
); );
@ -197,15 +198,24 @@ describe("repositoriesService repository stats", () => {
describe("repositoriesService.dumpSnapshot", () => { describe("repositoriesService.dumpSnapshot", () => {
afterEach(() => { afterEach(() => {
mock.restore(); vi.restoreAllMocks();
}); });
const createDumpResult = () => ({ const createDumpResult = (payload: string) => ({
stream: Readable.from([]), stream: Readable.from([payload]),
completion: Promise.resolve(), completion: Promise.resolve(),
abort: () => {}, abort: () => {},
}); });
const readStreamText = async (stream: Readable) => {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString("utf8");
};
const setupDumpSnapshotScenario = async ({ const setupDumpSnapshotScenario = async ({
snapshotId, snapshotId,
basePath, basePath,
@ -215,7 +225,7 @@ describe("repositoriesService.dumpSnapshot", () => {
basePath: string; basePath: string;
snapshotPaths?: string[]; snapshotPaths?: string[];
}) => { }) => {
const { organizationId, user } = await createTestSession(); const organizationId = session.organizationId;
const shortId = generateShortId(); const shortId = generateShortId();
await db.insert(repositoriesTable).values({ await db.insert(repositoriesTable).values({
@ -232,7 +242,7 @@ describe("repositoriesService.dumpSnapshot", () => {
organizationId, organizationId,
}); });
spyOn(restic, "snapshots").mockResolvedValue([ vi.spyOn(restic, "snapshots").mockResolvedValue([
{ {
id: snapshotId, id: snapshotId,
short_id: snapshotId, short_id: snapshotId,
@ -242,53 +252,55 @@ describe("repositoriesService.dumpSnapshot", () => {
}, },
]); ]);
const dumpMock = mock(() => Promise.resolve(createDumpResult())); const dumpMock = vi.fn((_config: unknown, snapshotRef: string, options: Parameters<typeof restic.dump>[2]) => {
spyOn(restic, "dump").mockImplementation(dumpMock); if (!options.path) {
throw new Error("Expected dump path in test");
}
return Promise.resolve(
createDumpResult(
JSON.stringify({
snapshotRef,
path: options.path,
archive: options.archive !== false,
}),
),
);
});
vi.spyOn(restic, "dump").mockImplementation(dumpMock);
return { return {
organizationId, organizationId,
userId: user.id, userId: session.user.id,
shortId, shortId,
basePath, basePath,
dumpMock,
}; };
}; };
test("calls restic.dump with common-ancestor selector and stripped path", async () => { test("returns a tar download rooted at the selected directory within the snapshot", async () => {
const { organizationId, userId, shortId, basePath, dumpMock } = await setupDumpSnapshotScenario({ const { organizationId, userId, shortId, basePath } = await setupDumpSnapshotScenario({
snapshotId: "snapshot-123", snapshotId: "snapshot-123",
basePath: "/var/lib/zerobyte/volumes/vol123/_data", basePath: "/var/lib/zerobyte/volumes/vol123/_data",
}); });
const emitSpy = spyOn(serverEvents, "emit");
await withContext({ organizationId, userId }, () => const result = await withContext({ organizationId, userId }, () =>
repositoriesService.dumpSnapshot(shortId, "snapshot-123", `${basePath}/documents`, "dir"), repositoriesService.dumpSnapshot(shortId, "snapshot-123", `${basePath}/documents`, "dir"),
); );
expect(dumpMock).toHaveBeenCalledTimes(1); expect(result.filename).toBe("snapshot-snapshot-123.tar");
expect(dumpMock).toHaveBeenCalledWith( expect(result.contentType).toBe("application/x-tar");
expect.objectContaining({ expect(await readStreamText(result.stream)).toBe(
backend: "local", JSON.stringify({
}), snapshotRef: `snapshot-123:${basePath}`,
`snapshot-123:${basePath}`,
{
organizationId,
path: "/documents",
},
);
expect(emitSpy).toHaveBeenCalledWith(
"dump:started",
expect.objectContaining({
organizationId,
repositoryId: shortId,
snapshotId: "snapshot-123",
path: "/documents", path: "/documents",
archive: true,
}), }),
); );
await expect(result.completion).resolves.toBeUndefined();
}); });
test("streams a single file directly when selected path is a file", async () => { test("streams a single file directly when selected path is a file", async () => {
const { organizationId, userId, shortId, basePath, dumpMock } = await setupDumpSnapshotScenario({ const { organizationId, userId, shortId, basePath } = await setupDumpSnapshotScenario({
snapshotId: "snapshot-file", snapshotId: "snapshot-file",
basePath: "/var/lib/zerobyte/volumes/vol123/_data", basePath: "/var/lib/zerobyte/volumes/vol123/_data",
}); });
@ -297,31 +309,38 @@ describe("repositoriesService.dumpSnapshot", () => {
repositoriesService.dumpSnapshot(shortId, "snapshot-file", `${basePath}/documents/report.txt`, "file"), repositoriesService.dumpSnapshot(shortId, "snapshot-file", `${basePath}/documents/report.txt`, "file"),
); );
expect(dumpMock).toHaveBeenCalledWith(expect.anything(), `snapshot-file:${basePath}`, {
organizationId,
path: "/documents/report.txt",
archive: false,
});
expect(result.filename).toBe("report.txt"); expect(result.filename).toBe("report.txt");
expect(result.contentType).toBe("application/octet-stream"); expect(result.contentType).toBe("application/octet-stream");
expect(await readStreamText(result.stream)).toBe(
JSON.stringify({
snapshotRef: `snapshot-file:${basePath}`,
path: "/documents/report.txt",
archive: false,
}),
);
}); });
test("downloads a selected parent directory when snapshot paths point to a nested file", async () => { test("downloads a selected parent directory when snapshot paths point to a nested file", async () => {
const parentPath = "/var/lib/zerobyte/volumes/vol123/_data/documents"; const parentPath = "/var/lib/zerobyte/volumes/vol123/_data/documents";
const { organizationId, userId, shortId, dumpMock } = await setupDumpSnapshotScenario({ const { organizationId, userId, shortId } = await setupDumpSnapshotScenario({
snapshotId: "snapshot-parent-dir", snapshotId: "snapshot-parent-dir",
basePath: `${parentPath}/report.txt`, basePath: `${parentPath}/report.txt`,
snapshotPaths: [`${parentPath}/report.txt`], snapshotPaths: [`${parentPath}/report.txt`],
}); });
await withContext({ organizationId, userId }, () => const result = await withContext({ organizationId, userId }, () =>
repositoriesService.dumpSnapshot(shortId, "snapshot-parent-dir", parentPath, "dir"), repositoriesService.dumpSnapshot(shortId, "snapshot-parent-dir", parentPath, "dir"),
); );
expect(dumpMock).toHaveBeenCalledWith(expect.anything(), `snapshot-parent-dir:${parentPath}`, { expect(result.filename).toBe("snapshot-snapshot-parent-dir.tar");
organizationId, expect(result.contentType).toBe("application/x-tar");
path: "/", expect(await readStreamText(result.stream)).toBe(
}); JSON.stringify({
snapshotRef: `snapshot-parent-dir:${parentPath}`,
path: "/",
archive: true,
}),
);
}); });
test("rejects path downloads without a kind", async () => { test("rejects path downloads without a kind", async () => {
@ -330,38 +349,45 @@ describe("repositoriesService.dumpSnapshot", () => {
basePath: "/var/lib/zerobyte/volumes/vol123/_data", basePath: "/var/lib/zerobyte/volumes/vol123/_data",
}); });
expect( await expect(
withContext({ organizationId, userId }, () => withContext({ organizationId, userId }, () =>
repositoriesService.dumpSnapshot(shortId, "snapshot-no-kind", `${basePath}/documents/report.txt`), repositoriesService.dumpSnapshot(shortId, "snapshot-no-kind", `${basePath}/documents/report.txt`),
), ),
).rejects.toThrow("Path kind is required when downloading a specific snapshot path"); ).rejects.toThrow("Path kind is required when downloading a specific snapshot path");
}); });
test("downloads full snapshot relative to common ancestor when path is omitted", async () => { test("downloads the full snapshot from the common ancestor when path is omitted", async () => {
const { organizationId, userId, shortId, basePath, dumpMock } = await setupDumpSnapshotScenario({ const { organizationId, userId, shortId, basePath } = await setupDumpSnapshotScenario({
snapshotId: "snapshot-999", snapshotId: "snapshot-999",
basePath: "/var/lib/zerobyte/volumes/vol555/_data", basePath: "/var/lib/zerobyte/volumes/vol555/_data",
}); });
await withContext({ organizationId, userId }, () => repositoriesService.dumpSnapshot(shortId, "snapshot-999")); const result = await withContext({ organizationId, userId }, () =>
repositoriesService.dumpSnapshot(shortId, "snapshot-999"),
);
expect(dumpMock).toHaveBeenCalledWith(expect.anything(), `snapshot-999:${basePath}`, { expect(result.filename).toBe("snapshot-snapshot-999.tar");
organizationId, expect(result.contentType).toBe("application/x-tar");
path: "/", expect(await readStreamText(result.stream)).toBe(
}); JSON.stringify({
snapshotRef: `snapshot-999:${basePath}`,
path: "/",
archive: true,
}),
);
}); });
}); });
describe("repositoriesService.restoreSnapshot", () => { describe("repositoriesService.restoreSnapshot", () => {
afterEach(() => { afterEach(() => {
mock.restore(); vi.restoreAllMocks();
}); });
const setupRestoreSnapshotScenario = async () => { const setupRestoreSnapshotScenario = async () => {
const { organizationId, user } = await createTestSession(); const organizationId = session.organizationId;
const repository = await createTestRepository(organizationId); const repository = await createTestRepository(organizationId);
spyOn(restic, "snapshots").mockResolvedValue([ vi.spyOn(restic, "snapshots").mockResolvedValue([
{ {
id: "snapshot-restore", id: "snapshot-restore",
short_id: "snapshot-restore", short_id: "snapshot-restore",
@ -371,7 +397,7 @@ describe("repositoriesService.restoreSnapshot", () => {
}, },
]); ]);
const restoreMock = mock(() => const restoreMock = vi.fn(() =>
Promise.resolve({ Promise.resolve({
message_type: "summary" as const, message_type: "summary" as const,
seconds_elapsed: 1, seconds_elapsed: 1,
@ -383,11 +409,11 @@ describe("repositoriesService.restoreSnapshot", () => {
bytes_restored: 1, bytes_restored: 1,
}), }),
); );
spyOn(restic, "restore").mockImplementation(restoreMock); vi.spyOn(restic, "restore").mockImplementation(restoreMock);
return { return {
organizationId, organizationId,
userId: user.id, userId: session.user.id,
repositoryShortId: repository.shortId, repositoryShortId: repository.shortId,
restoreMock, restoreMock,
}; };
@ -434,11 +460,11 @@ describe("repositoriesService.restoreSnapshot", () => {
describe("repositoriesService.getRetentionCategories", () => { describe("repositoriesService.getRetentionCategories", () => {
afterEach(() => { afterEach(() => {
mock.restore(); vi.restoreAllMocks();
}); });
test("recomputes retention categories after repository cache invalidation", async () => { test("recomputes retention categories after repository cache invalidation", async () => {
const { organizationId, user } = await createTestSession(); const organizationId = session.organizationId;
const schedule = await createTestBackupSchedule({ organizationId, retentionPolicy: { keepLast: 1 } }); const schedule = await createTestBackupSchedule({ organizationId, retentionPolicy: { keepLast: 1 } });
const repository = await db.query.repositoriesTable.findFirst({ where: { id: schedule.repositoryId } }); const repository = await db.query.repositoriesTable.findFirst({ where: { id: schedule.repositoryId } });
@ -476,11 +502,11 @@ describe("repositoriesService.getRetentionCategories", () => {
], ],
}); });
const forgetSpy = spyOn(restic, "forget"); const forgetSpy = vi.spyOn(restic, "forget");
forgetSpy.mockResolvedValueOnce(buildForgetResponse(oldSnapshotId)); forgetSpy.mockResolvedValueOnce(buildForgetResponse(oldSnapshotId));
forgetSpy.mockResolvedValueOnce(buildForgetResponse(newSnapshotId)); forgetSpy.mockResolvedValueOnce(buildForgetResponse(newSnapshotId));
const firstCategories = await withContext({ organizationId, userId: user.id }, () => const firstCategories = await withContext({ organizationId, userId: session.user.id }, () =>
repositoriesService.getRetentionCategories(repository.shortId, schedule.shortId), repositoriesService.getRetentionCategories(repository.shortId, schedule.shortId),
); );
@ -488,7 +514,7 @@ describe("repositoriesService.getRetentionCategories", () => {
cache.delByPrefix(cacheKeys.repository.all(repository.id)); cache.delByPrefix(cacheKeys.repository.all(repository.id));
const secondCategories = await withContext({ organizationId, userId: user.id }, () => const secondCategories = await withContext({ organizationId, userId: session.user.id }, () =>
repositoriesService.getRetentionCategories(repository.shortId, schedule.shortId), repositoriesService.getRetentionCategories(repository.shortId, schedule.shortId),
); );
@ -500,12 +526,11 @@ describe("repositoriesService.getRetentionCategories", () => {
describe("repositoriesService.deleteSnapshot", () => { describe("repositoriesService.deleteSnapshot", () => {
afterEach(() => { afterEach(() => {
mock.restore(); vi.restoreAllMocks();
}); });
test("refreshes repository stats in background after successful deletion", async () => { test("refreshes repository stats in background after successful deletion", async () => {
const { organizationId, user } = await createTestSession(); const repository = await createTestRepository(session.organizationId);
const repository = await createTestRepository(organizationId);
const expectedStats = { const expectedStats = {
total_size: 128, total_size: 128,
total_uncompressed_size: 256, total_uncompressed_size: 256,
@ -515,16 +540,16 @@ describe("repositoriesService.deleteSnapshot", () => {
snapshots_count: 1, snapshots_count: 1,
}; };
spyOn(restic, "deleteSnapshot").mockResolvedValue({ success: true }); vi.spyOn(restic, "deleteSnapshot").mockResolvedValue({ success: true });
const statsSpy = spyOn(restic, "stats").mockResolvedValue(expectedStats); const statsSpy = vi.spyOn(restic, "stats").mockResolvedValue(expectedStats);
await withContext({ organizationId, userId: user.id }, () => await withContext({ organizationId: session.organizationId, userId: session.user.id }, () =>
repositoriesService.deleteSnapshot(repository.shortId, "snap-1"), repositoriesService.deleteSnapshot(repository.shortId, "snap-1"),
); );
await new Promise((resolve) => setTimeout(resolve, 20)); await waitForExpect(() => {
expect(statsSpy).toHaveBeenCalledTimes(1);
expect(statsSpy).toHaveBeenCalledTimes(1); });
const updatedRepository = await db.query.repositoriesTable.findFirst({ where: { id: repository.id } }); const updatedRepository = await db.query.repositoriesTable.findFirst({ where: { id: repository.id } });
expect(updatedRepository?.stats).toEqual(expectedStats); expect(updatedRepository?.stats).toEqual(expectedStats);
@ -532,15 +557,14 @@ describe("repositoriesService.deleteSnapshot", () => {
}); });
test("should throw original error when restic deleteSnapshot fails", async () => { test("should throw original error when restic deleteSnapshot fails", async () => {
const { organizationId, user } = await createTestSession(); const repository = await createTestRepository(session.organizationId);
const repository = await createTestRepository(organizationId);
spyOn(restic, "deleteSnapshot").mockImplementation(async () => { vi.spyOn(restic, "deleteSnapshot").mockImplementation(async () => {
throw new ResticError(1, "Fatal: unexpected HTTP response (403): 403 Forbidden"); throw new ResticError(1, "Fatal: unexpected HTTP response (403): 403 Forbidden");
}); });
expect( await expect(
withContext({ organizationId, userId: user.id }, () => withContext({ organizationId: session.organizationId, userId: session.user.id }, () =>
repositoriesService.deleteSnapshot(repository.shortId, "snap123"), repositoriesService.deleteSnapshot(repository.shortId, "snap123"),
), ),
).rejects.toThrow("Fatal: unexpected HTTP response"); ).rejects.toThrow("Fatal: unexpected HTTP response");

View file

@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "vitest";
import { mapAuthErrorToCode } from "../sso.errors"; import { mapAuthErrorToCode } from "../sso.errors";
describe("mapAuthErrorToCode", () => { describe("mapAuthErrorToCode", () => {

View file

@ -1,4 +1,4 @@
import { beforeEach, describe, expect, test } from "bun:test"; import { beforeEach, describe, expect, test } from "vitest";
import type { GenericEndpointContext } from "better-auth"; import type { GenericEndpointContext } from "better-auth";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "~/server/db/db"; import { db } from "~/server/db/db";

View file

@ -1,12 +1,11 @@
import { beforeEach, describe, expect, test } from "bun:test"; import { beforeEach, describe, expect, test } from "vitest";
import { createApp } from "~/server/app"; import { createApp } from "~/server/app";
import { config } from "~/server/core/config";
import { db } from "~/server/db/db"; import { db } from "~/server/db/db";
import { account, invitation, member, organization, ssoProvider, usersTable, verification } from "~/server/db/schema"; import { account, invitation, member, organization, ssoProvider, usersTable, verification } from "~/server/db/schema";
import { createTestSession, createTestSessionWithOrgAdmin } from "~/test/helpers/auth"; import { createTestSession, createTestSessionWithOrgAdmin } from "~/test/helpers/auth";
const app = createApp(); const app = createApp();
const ssoRegisterUrl = new URL("/api/auth/sso/register", config.baseUrl).toString(); const ssoRegisterUrl = new URL("/api/auth/sso/register", "http://localhost:3000").toString();
function buildRegisterBody(organizationId: string, suffix: string) { function buildRegisterBody(organizationId: string, suffix: string) {
return { return {

View file

@ -1,11 +1,10 @@
import { beforeEach, describe, expect, test } from "bun:test"; import { beforeEach, describe, expect, test } from "vitest";
import { createApp } from "~/server/app"; import { createApp } from "~/server/app";
import { config } from "~/server/core/config";
import { account, invitation, member, organization, ssoProvider, usersTable } from "~/server/db/schema"; import { account, invitation, member, organization, ssoProvider, usersTable } from "~/server/db/schema";
import { db } from "~/server/db/db"; import { db } from "~/server/db/db";
const app = createApp(); const app = createApp();
const ssoSignInUrl = new URL("/api/auth/sign-in/sso", config.baseUrl).toString(); const ssoSignInUrl = new URL("/api/auth/sign-in/sso", "http://localhost:3000").toString();
function postSsoSignIn(body: Record<string, unknown>) { function postSsoSignIn(body: Record<string, unknown>) {
return app.request(ssoSignInUrl, { return app.request(ssoSignInUrl, {

View file

@ -1,4 +1,4 @@
import { beforeEach, describe, expect, test } from "bun:test"; import { beforeEach, describe, expect, test } from "vitest";
import { db, sqlite } from "~/server/db/db"; import { db, sqlite } from "~/server/db/db";
import { account, invitation, member, organization, sessionsTable, ssoProvider, usersTable } from "~/server/db/schema"; import { account, invitation, member, organization, sessionsTable, ssoProvider, usersTable } from "~/server/db/schema";
import { ssoService } from "../sso.service"; import { ssoService } from "../sso.service";

View file

@ -1,4 +1,4 @@
import { beforeEach, describe, expect, test } from "bun:test"; import { beforeEach, describe, expect, test } from "vitest";
import type { GenericEndpointContext } from "better-auth"; import type { GenericEndpointContext } from "better-auth";
import { db } from "~/server/db/db"; import { db } from "~/server/db/db";
import { account, invitation, member, organization, ssoProvider, usersTable } from "~/server/db/schema"; import { account, invitation, member, organization, ssoProvider, usersTable } from "~/server/db/schema";
@ -80,7 +80,7 @@ describe("requireSsoInvitation", () => {
await createSsoProvider("oidc-acme"); await createSsoProvider("oidc-acme");
const ctx = createMockContext("/sign-up/email"); const ctx = createMockContext("/sign-up/email");
expect(requireSsoInvitation("user@example.com", ctx)).rejects.toThrow("Missing providerId"); await expect(requireSsoInvitation("user@example.com", ctx)).rejects.toThrow("Missing providerId");
}); });
test("detects whether current request is an SSO callback", async () => { test("detects whether current request is an SSO callback", async () => {
@ -97,7 +97,7 @@ describe("requireSsoInvitation", () => {
await createSsoProvider("oidc-acme"); await createSsoProvider("oidc-acme");
const ctx = createMockSsoCallbackContext("oidc-acme"); const ctx = createMockSsoCallbackContext("oidc-acme");
expect(requireSsoInvitation("user@example.com", ctx)).rejects.toThrow("must be invited"); await expect(requireSsoInvitation("user@example.com", ctx)).rejects.toThrow("must be invited");
}); });
test("blocks SSO callback when invitation is expired", async () => { test("blocks SSO callback when invitation is expired", async () => {
@ -115,7 +115,7 @@ describe("requireSsoInvitation", () => {
}); });
const ctx = createMockSsoCallbackContext("oidc-acme"); const ctx = createMockSsoCallbackContext("oidc-acme");
expect(requireSsoInvitation("user@example.com", ctx)).rejects.toThrow("must be invited"); await expect(requireSsoInvitation("user@example.com", ctx)).rejects.toThrow("must be invited");
}); });
test("allows SSO callback when a matching pending invitation exists", async () => { test("allows SSO callback when a matching pending invitation exists", async () => {
@ -133,11 +133,11 @@ describe("requireSsoInvitation", () => {
}); });
const ctx = createMockSsoCallbackContext("oidc-acme"); const ctx = createMockSsoCallbackContext("oidc-acme");
expect(requireSsoInvitation(" USER@EXAMPLE.COM ", ctx)).resolves.toBeUndefined(); await expect(requireSsoInvitation(" USER@EXAMPLE.COM ", ctx)).resolves.toBeUndefined();
}); });
test("throws when provider is not registered", async () => { test("throws when provider is not registered", async () => {
const ctx = createMockSsoCallbackContext("missing-provider"); const ctx = createMockSsoCallbackContext("missing-provider");
expect(requireSsoInvitation("user@example.com", ctx)).rejects.toThrow("SSO provider not found"); await expect(requireSsoInvitation("user@example.com", ctx)).rejects.toThrow("SSO provider not found");
}); });
}); });

View file

@ -1,4 +1,4 @@
import { beforeEach, describe, expect, test } from "bun:test"; import { beforeEach, describe, expect, test } from "vitest";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "~/server/db/db"; import { db } from "~/server/db/db";
import { account, member, organization, ssoProvider, usersTable } from "~/server/db/schema"; import { account, member, organization, ssoProvider, usersTable } from "~/server/db/schema";

View file

@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "vitest";
import type { AuthMiddlewareContext } from "~/server/lib/auth"; import type { AuthMiddlewareContext } from "~/server/lib/auth";
import { validateSsoCallbackUrls } from "../validate-callback-urls"; import { validateSsoCallbackUrls } from "../validate-callback-urls";

View file

@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "vitest";
import type { AuthMiddlewareContext } from "~/server/lib/auth"; import type { AuthMiddlewareContext } from "~/server/lib/auth";
import { validateSsoProviderId } from "../validate-provider-id"; import { validateSsoProviderId } from "../validate-provider-id";

View file

@ -1,9 +1,19 @@
import { test, describe, expect } from "bun:test"; import { beforeAll, describe, expect, test, vi } from "vitest";
import { createApp } from "~/server/app"; import { createApp } from "~/server/app";
import { createTestSession, createTestSessionWithGlobalAdmin, getAuthHeaders } from "~/test/helpers/auth"; import { createTestSession, createTestSessionWithGlobalAdmin, getAuthHeaders } from "~/test/helpers/auth";
import { systemService } from "../system.service";
import * as authHelpers from "~/server/modules/auth/helpers";
const app = createApp(); const app = createApp();
let session: Awaited<ReturnType<typeof createTestSession>>;
let globalAdminSession: Awaited<ReturnType<typeof createTestSessionWithGlobalAdmin>>;
beforeAll(async () => {
session = await createTestSession();
globalAdminSession = await createTestSessionWithGlobalAdmin();
});
describe("system security", () => { describe("system security", () => {
test("should return 401 if no session cookie is provided", async () => { test("should return 401 if no session cookie is provided", async () => {
const res = await app.request("/api/v1/system/info"); const res = await app.request("/api/v1/system/info");
@ -22,10 +32,8 @@ describe("system security", () => {
}); });
test("should return 200 if session is valid", async () => { test("should return 200 if session is valid", async () => {
const { headers } = await createTestSession();
const res = await app.request("/api/v1/system/info", { const res = await app.request("/api/v1/system/info", {
headers, headers: session.headers,
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
@ -53,19 +61,17 @@ describe("system security", () => {
describe("registration-status endpoint", () => { describe("registration-status endpoint", () => {
test("GET /api/v1/system/registration-status should be accessible with valid session", async () => { test("GET /api/v1/system/registration-status should be accessible with valid session", async () => {
const { headers } = await createTestSession(); const res = await app.request("/api/v1/system/registration-status", { headers: session.headers });
const res = await app.request("/api/v1/system/registration-status", { headers });
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = await res.json(); const body = await res.json();
expect(typeof body.enabled).toBe("boolean"); expect(typeof body.enabled).toBe("boolean");
}); });
test("PUT /api/v1/system/registration-status should return 403 for non-admin users", async () => { test("PUT /api/v1/system/registration-status should return 403 for non-admin users", async () => {
const { headers } = await createTestSession();
const res = await app.request("/api/v1/system/registration-status", { const res = await app.request("/api/v1/system/registration-status", {
method: "PUT", method: "PUT",
headers: { headers: {
...headers, ...session.headers,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ enabled: false }), body: JSON.stringify({ enabled: false }),
@ -76,11 +82,10 @@ describe("system security", () => {
}); });
test("PUT /api/v1/system/registration-status should be accessible to global admin", async () => { test("PUT /api/v1/system/registration-status should be accessible to global admin", async () => {
const { headers } = await createTestSessionWithGlobalAdmin();
const res = await app.request("/api/v1/system/registration-status", { const res = await app.request("/api/v1/system/registration-status", {
method: "PUT", method: "PUT",
headers: { headers: {
...headers, ...globalAdminSession.headers,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ enabled: false }), body: JSON.stringify({ enabled: false }),
@ -91,8 +96,7 @@ describe("system security", () => {
describe("dev-panel endpoint", () => { describe("dev-panel endpoint", () => {
test("GET /api/v1/system/dev-panel should be accessible with valid session", async () => { test("GET /api/v1/system/dev-panel should be accessible with valid session", async () => {
const { headers } = await createTestSession(); const res = await app.request("/api/v1/system/dev-panel", { headers: session.headers });
const res = await app.request("/api/v1/system/dev-panel", { headers });
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = await res.json(); const body = await res.json();
expect(typeof body.enabled).toBe("boolean"); expect(typeof body.enabled).toBe("boolean");
@ -101,19 +105,29 @@ describe("system security", () => {
describe("updates endpoint", () => { describe("updates endpoint", () => {
test("GET /api/v1/system/updates should be accessible with valid session", async () => { test("GET /api/v1/system/updates should be accessible with valid session", async () => {
const { headers } = await createTestSession(); const expectedUpdates = {
const res = await app.request("/api/v1/system/updates", { headers }); currentVersion: "1.0.0",
latestVersion: "1.0.0",
hasUpdate: false,
missedReleases: [],
};
vi.spyOn(systemService, "getUpdates").mockResolvedValue({
...expectedUpdates,
});
const res = await app.request("/api/v1/system/updates", { headers: session.headers });
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(await res.json()).toEqual(expectedUpdates);
}); });
}); });
describe("input validation", () => { describe("input validation", () => {
test("should return 400 for invalid payload on restic-password", async () => { test("should return 400 for invalid payload on restic-password", async () => {
const { headers } = await createTestSession();
const res = await app.request("/api/v1/system/restic-password", { const res = await app.request("/api/v1/system/restic-password", {
method: "POST", method: "POST",
headers: { headers: {
...headers, ...session.headers,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({}), body: JSON.stringify({}),
@ -123,11 +137,12 @@ describe("system security", () => {
}); });
test("should return 401 for incorrect password on restic-password", async () => { test("should return 401 for incorrect password on restic-password", async () => {
const { headers } = await createTestSession(); vi.spyOn(authHelpers, "verifyUserPassword").mockResolvedValue(false);
const res = await app.request("/api/v1/system/restic-password", { const res = await app.request("/api/v1/system/restic-password", {
method: "POST", method: "POST",
headers: { headers: {
...headers, ...session.headers,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({

View file

@ -1,4 +1,4 @@
import { test, describe, expect } from "bun:test"; import { beforeAll, describe, expect, test } from "vitest";
import { db } from "~/server/db/db"; import { db } from "~/server/db/db";
import { volumesTable } from "~/server/db/schema"; import { volumesTable } from "~/server/db/schema";
import { createApp } from "~/server/app"; import { createApp } from "~/server/app";
@ -7,6 +7,12 @@ import { generateShortId } from "~/server/utils/id";
const app = createApp(); const app = createApp();
let session: Awaited<ReturnType<typeof createTestSession>>;
beforeAll(async () => {
session = await createTestSession();
});
const createManagedVolumeRecord = async (organizationId: string) => { const createManagedVolumeRecord = async (organizationId: string) => {
const [volume] = await db const [volume] = await db
.insert(volumesTable) .insert(volumesTable)
@ -46,10 +52,8 @@ describe("volumes security", () => {
}); });
test("should return 200 if session is valid", async () => { test("should return 200 if session is valid", async () => {
const { headers } = await createTestSession();
const res = await app.request("/api/v1/volumes", { const res = await app.request("/api/v1/volumes", {
headers, headers: session.headers,
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
@ -91,9 +95,8 @@ describe("volumes security", () => {
describe("input validation", () => { describe("input validation", () => {
test("should return 404 for non-existent volume", async () => { test("should return 404 for non-existent volume", async () => {
const { headers } = await createTestSession();
const res = await app.request("/api/v1/volumes/non-existent-volume", { const res = await app.request("/api/v1/volumes/non-existent-volume", {
headers, headers: session.headers,
}); });
expect(res.status).toBe(404); expect(res.status).toBe(404);
@ -102,11 +105,10 @@ describe("volumes security", () => {
}); });
test("should return 400 for invalid payload on create", async () => { test("should return 400 for invalid payload on create", async () => {
const { headers } = await createTestSession();
const res = await app.request("/api/v1/volumes", { const res = await app.request("/api/v1/volumes", {
method: "POST", method: "POST",
headers: { headers: {
...headers, ...session.headers,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
@ -118,10 +120,9 @@ describe("volumes security", () => {
}); });
test("should mark provisioned volumes as managed", async () => { test("should mark provisioned volumes as managed", async () => {
const { headers, organizationId } = await createTestSession(); const volume = await createManagedVolumeRecord(session.organizationId);
const volume = await createManagedVolumeRecord(organizationId);
const res = await app.request(`/api/v1/volumes/${volume.shortId}`, { headers }); const res = await app.request(`/api/v1/volumes/${volume.shortId}`, { headers: session.headers });
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = await res.json(); const body = await res.json();
@ -129,13 +130,12 @@ describe("volumes security", () => {
}); });
test("should allow updates for managed volumes", async () => { test("should allow updates for managed volumes", async () => {
const { headers, organizationId } = await createTestSession(); const volume = await createManagedVolumeRecord(session.organizationId);
const volume = await createManagedVolumeRecord(organizationId);
const res = await app.request(`/api/v1/volumes/${volume.shortId}`, { const res = await app.request(`/api/v1/volumes/${volume.shortId}`, {
method: "PUT", method: "PUT",
headers: { headers: {
...headers, ...session.headers,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
@ -149,12 +149,11 @@ describe("volumes security", () => {
}); });
test("should allow deletion for managed volumes", async () => { test("should allow deletion for managed volumes", async () => {
const { headers, organizationId } = await createTestSession(); const volume = await createManagedVolumeRecord(session.organizationId);
const volume = await createManagedVolumeRecord(organizationId);
const res = await app.request(`/api/v1/volumes/${volume.shortId}`, { const res = await app.request(`/api/v1/volumes/${volume.shortId}`, {
method: "DELETE", method: "DELETE",
headers, headers: session.headers,
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);

View file

@ -1,4 +1,4 @@
import { test, describe, expect } from "bun:test"; import { describe, expect, test } from "vitest";
import { volumeService } from "../volume.service"; import { volumeService } from "../volume.service";
import { db } from "~/server/db/db"; import { db } from "~/server/db/db";
import { volumesTable } from "~/server/db/schema"; import { volumesTable } from "~/server/db/schema";

View file

@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "vitest";
import { safeExec, safeSpawn } from "@zerobyte/core/node"; import { safeExec, safeSpawn } from "@zerobyte/core/node";
describe("safeExec", () => { describe("safeExec", () => {
@ -41,7 +41,7 @@ describe("safeExec", () => {
const result = await safeExec({ const result = await safeExec({
command: "sleep", command: "sleep",
args: ["10"], args: ["10"],
timeout: 100, timeout: 20,
}); });
expect(result.timedOut).toBe(true); expect(result.timedOut).toBe(true);

View file

@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "vitest";
import { findCommonAncestor } from "@zerobyte/core/utils"; import { findCommonAncestor } from "@zerobyte/core/utils";
describe("findCommonAncestor", () => { describe("findCommonAncestor", () => {

25
app/test/helpers/db.ts Normal file
View file

@ -0,0 +1,25 @@
import fs from "node:fs";
import path from "node:path";
import { tmpdir } from "node:os";
import { vi } from "vitest";
export const createTestDb = async () => {
const projectRoot = process.cwd();
const cacheRoot = fs.mkdtempSync(path.join(tmpdir(), "zerobyte-test-cache-"));
process.env.ZEROBYTE_DATABASE_URL = ":memory:";
vi.resetModules();
const database = await import("~/server/db/db");
await database.runDbMigrations();
process.chdir(cacheRoot);
try {
await import("~/server/utils/cache");
} finally {
process.chdir(projectRoot);
}
return database;
};

View file

@ -1,10 +1,9 @@
import "./setup.ts"; import "./setup-shared";
import { GlobalRegistrator } from "@happy-dom/global-registrator"; import { afterAll, afterEach, beforeAll, vi } from "vitest";
import { afterAll, afterEach, beforeAll, mock } from "bun:test";
import { client } from "~/client/api-client/client.gen"; import { client } from "~/client/api-client/client.gen";
import { server } from "~/test/msw/server"; import { server } from "~/test/msw/server";
void mock.module("~/client/hooks/use-root-loader-data", () => ({ vi.mock(import("~/client/hooks/use-root-loader-data"), () => ({
useRootLoaderData: () => ({ useRootLoaderData: () => ({
theme: "dark", theme: "dark",
locale: "en-US", locale: "en-US",
@ -15,8 +14,6 @@ void mock.module("~/client/hooks/use-root-loader-data", () => ({
}), }),
})); }));
GlobalRegistrator.register({ url: "http://localhost:3000" });
client.setConfig({ client.setConfig({
baseUrl: "http://localhost:3000", baseUrl: "http://localhost:3000",
credentials: "include", credentials: "include",

32
app/test/setup-shared.ts Normal file
View file

@ -0,0 +1,32 @@
import { vi } from "vitest";
process.env.BASE_URL = "http://localhost:3000";
process.env.TRUSTED_ORIGINS = "http://localhost:3000";
vi.mock(import("@zerobyte/core/node"), async () => {
const utils = await vi.importActual<typeof import("@zerobyte/core/node")>("@zerobyte/core/node");
return {
...utils,
logger: {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
},
};
});
vi.mock(import("~/server/utils/crypto"), async () => {
const cryptoModule = await vi.importActual<typeof import("~/server/utils/crypto")>("~/server/utils/crypto");
return {
cryptoUtils: {
...cryptoModule.cryptoUtils,
deriveSecret: async () => "test-secret",
sealSecret: async (v: string) => v,
resolveSecret: async (v: string) => v,
generateResticPassword: () => "test-restic-password",
},
};
});

View file

@ -1,34 +1,5 @@
import { beforeAll, mock } from "bun:test"; import "./setup-shared";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import path from "node:path";
import { cwd } from "node:process";
import { db } from "~/server/db/db";
import * as utils from "@zerobyte/core/node"; const { createTestDb } = await import("~/test/helpers/db");
void mock.module("@zerobyte/core/node", () => { await createTestDb();
return {
...utils,
logger: {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
},
};
});
void mock.module("~/server/utils/crypto", () => ({
cryptoUtils: {
deriveSecret: async () => "test-secret",
sealSecret: async (v: string) => v,
resolveSecret: async (v: string) => v,
generateResticPassword: () => "test-restic-password",
},
}));
beforeAll(async () => {
const migrationsFolder = path.join(cwd(), "app", "drizzle");
migrate(db, { migrationsFolder });
db.run("PRAGMA foreign_keys = ON;");
});

View file

@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "vitest";
import { isPathWithin, normalizeAbsolutePath } from "@zerobyte/core/utils"; import { isPathWithin, normalizeAbsolutePath } from "@zerobyte/core/utils";
describe("normalizeAbsolutePath", () => { describe("normalizeAbsolutePath", () => {

View file

@ -110,6 +110,7 @@
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^8.0.1", "vite": "^8.0.1",
"vite-bundle-analyzer": "^1.2.3", "vite-bundle-analyzer": "^1.2.3",
"vitest": "^4.1.1",
"wait-for-expect": "^4.0.0", "wait-for-expect": "^4.0.0",
}, },
}, },
@ -955,6 +956,8 @@
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/content-disposition": ["@types/content-disposition@0.5.9", "", {}, "sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ=="], "@types/content-disposition": ["@types/content-disposition@0.5.9", "", {}, "sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
@ -977,6 +980,8 @@
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
@ -1021,6 +1026,20 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
"@vitest/expect": ["@vitest/expect@4.1.1", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.1", "@vitest/utils": "4.1.1", "chai": "^6.2.2", "tinyrainbow": "^3.0.3" } }, "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A=="],
"@vitest/mocker": ["@vitest/mocker@4.1.1", "", { "dependencies": { "@vitest/spy": "4.1.1", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A=="],
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.1", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ=="],
"@vitest/runner": ["@vitest/runner@4.1.1", "", { "dependencies": { "@vitest/utils": "4.1.1", "pathe": "^2.0.3" } }, "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A=="],
"@vitest/snapshot": ["@vitest/snapshot@4.1.1", "", { "dependencies": { "@vitest/pretty-format": "4.1.1", "@vitest/utils": "4.1.1", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg=="],
"@vitest/spy": ["@vitest/spy@4.1.1", "", {}, "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA=="],
"@vitest/utils": ["@vitest/utils@4.1.1", "", { "dependencies": { "@vitest/pretty-format": "4.1.1", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" } }, "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ=="],
"@xmldom/is-dom-node": ["@xmldom/is-dom-node@1.0.1", "", {}, "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q=="], "@xmldom/is-dom-node": ["@xmldom/is-dom-node@1.0.1", "", {}, "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
@ -1055,6 +1074,8 @@
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
"aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="],
@ -1101,6 +1122,8 @@
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
@ -1263,6 +1286,8 @@
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="],
"es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="], "es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="],
"esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
@ -1277,12 +1302,16 @@
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
@ -1661,6 +1690,8 @@
"nypm": ["nypm@0.6.5", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ=="], "nypm": ["nypm@0.6.5", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"ofetch": ["ofetch@2.0.0-alpha.3", "", {}, "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA=="], "ofetch": ["ofetch@2.0.0-alpha.3", "", {}, "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA=="],
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
@ -1825,6 +1856,8 @@
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"slugify": ["slugify@1.6.8", "", {}, "sha512-HVk9X1E0gz3mSpoi60h/saazLKXKaZThMLU3u/aNwoYn8/xQyX2MGxL0ui2eaokkD7tF+Zo+cKTHUbe1mmmGzA=="], "slugify": ["slugify@1.6.8", "", {}, "sha512-HVk9X1E0gz3mSpoi60h/saazLKXKaZThMLU3u/aNwoYn8/xQyX2MGxL0ui2eaokkD7tF+Zo+cKTHUbe1mmmGzA=="],
@ -1845,9 +1878,11 @@
"srvx": ["srvx@0.10.1", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-A//xtfak4eESMWWydSRFUVvCTQbSwivnGCEf8YGPe2eHU0+Z6znfUTCPF0a7oV3sObSOcrXHlL6Bs9vVctfXdg=="], "srvx": ["srvx@0.10.1", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-A//xtfak4eESMWWydSRFUVvCTQbSwivnGCEf8YGPe2eHU0+Z6znfUTCPF0a7oV3sObSOcrXHlL6Bs9vVctfXdg=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], "std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="],
"strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="], "strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="],
@ -1883,12 +1918,16 @@
"tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="], "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
"tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="],
"tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="],
@ -1967,6 +2006,8 @@
"vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="], "vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="],
"vitest": ["vitest@4.1.1", "", { "dependencies": { "@vitest/expect": "4.1.1", "@vitest/mocker": "4.1.1", "@vitest/pretty-format": "4.1.1", "@vitest/runner": "4.1.1", "@vitest/snapshot": "4.1.1", "@vitest/spy": "4.1.1", "@vitest/utils": "4.1.1", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.1", "@vitest/browser-preview": "4.1.1", "@vitest/browser-webdriverio": "4.1.1", "@vitest/ui": "4.1.1", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA=="],
"wait-for-expect": ["wait-for-expect@4.0.0", "", {}, "sha512-mcH2HYUUHhdFGHVJkgwkBxRihZO4VSuPyh6xhYHz7LEnYkcaLbTAEEsTpYiFw4UY45XdTZYYIaquuMucw9wWMw=="], "wait-for-expect": ["wait-for-expect@4.0.0", "", {}, "sha512-mcH2HYUUHhdFGHVJkgwkBxRihZO4VSuPyh6xhYHz7LEnYkcaLbTAEEsTpYiFw4UY45XdTZYYIaquuMucw9wWMw=="],
"web-haptics": ["web-haptics@0.0.6", "", { "peerDependencies": { "react": ">=18", "react-dom": ">=18", "svelte": ">=4", "vue": ">=3" }, "optionalPeers": ["react", "react-dom", "svelte", "vue"] }, "sha512-eCzcf1LDi20+Fr0x9V3OkX92k0gxEQXaHajmhXHitsnk6SxPeshv8TBtBRqxyst8HI1uf2FyFVE7QS3jo1gkrw=="], "web-haptics": ["web-haptics@0.0.6", "", { "peerDependencies": { "react": ">=18", "react-dom": ">=18", "svelte": ">=4", "vue": ">=3" }, "optionalPeers": ["react", "react-dom", "svelte", "vue"] }, "sha512-eCzcf1LDi20+Fr0x9V3OkX92k0gxEQXaHajmhXHitsnk6SxPeshv8TBtBRqxyst8HI1uf2FyFVE7QS3jo1gkrw=="],
@ -1985,6 +2026,8 @@
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
@ -2087,6 +2130,8 @@
"@prisma/dev/hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="], "@prisma/dev/hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="],
"@prisma/dev/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.4.2", "", { "dependencies": { "@prisma/debug": "7.4.2" } }, "sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw=="], "@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.4.2", "", { "dependencies": { "@prisma/debug": "7.4.2" } }, "sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw=="],
"@prisma/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.4.2", "", { "dependencies": { "@prisma/debug": "7.4.2" } }, "sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw=="], "@prisma/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.4.2", "", { "dependencies": { "@prisma/debug": "7.4.2" } }, "sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw=="],

View file

@ -22,9 +22,9 @@
"gen:api-client": "dotenv -e .env.local -- openapi-ts", "gen:api-client": "dotenv -e .env.local -- openapi-ts",
"gen:migrations": "dotenv -e .env.local -- drizzle-kit generate", "gen:migrations": "dotenv -e .env.local -- drizzle-kit generate",
"studio": "drizzle-kit studio", "studio": "drizzle-kit studio",
"test:server": "dotenv -e .env.test -- bun test app/server --preload ./app/test/setup.ts", "test:server": "dotenv -e .env.test -- bunx --bun vitest run --project server",
"test:client": "dotenv -e .env.test -- bun test app/client --preload ./app/test/setup-client.ts", "test:client": "dotenv -e .env.test -- bunx --bun vitest run --project client",
"test": "bun run test:server && bun run test:client && turbo run test", "test": "dotenv -e .env.test -- bunx --bun vitest run && turbo run test",
"test:e2e": "NODE_ENV=test dotenv -e .env.local -- playwright test", "test:e2e": "NODE_ENV=test dotenv -e .env.local -- playwright test",
"test:e2e:ui": "NODE_ENV=test dotenv -e .env.local -- playwright test --ui", "test:e2e:ui": "NODE_ENV=test dotenv -e .env.local -- playwright test --ui",
"test:codegen": "playwright codegen localhost:4096" "test:codegen": "playwright codegen localhost:4096"
@ -135,6 +135,7 @@
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^8.0.1", "vite": "^8.0.1",
"vite-bundle-analyzer": "^1.2.3", "vite-bundle-analyzer": "^1.2.3",
"vitest": "^4.1.1",
"wait-for-expect": "^4.0.0" "wait-for-expect": "^4.0.0"
}, },
"overrides": { "overrides": {

View file

@ -26,7 +26,7 @@
}, },
"scripts": { "scripts": {
"tsc": "tsc --noEmit", "tsc": "tsc --noEmit",
"test": "bun test --preload ./test/setup.ts" "test": "bunx --bun vitest run --config ./vitest.config.ts"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest" "@types/bun": "latest"

View file

@ -1,4 +1,4 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; import { afterEach, describe, expect, test, vi } from "vitest";
import * as cleanupModule from "../../helpers/cleanup-temporary-keys"; import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
import * as spawnModule from "../../../utils/spawn"; import * as spawnModule from "../../../utils/spawn";
import { ResticError } from "../../error"; import { ResticError } from "../../error";
@ -61,8 +61,8 @@ type SetupOptions = {
const setup = ({ spawnResult = {}, onSpawnCall }: SetupOptions = {}) => { const setup = ({ spawnResult = {}, onSpawnCall }: SetupOptions = {}) => {
let capturedArgs: string[] = []; let capturedArgs: string[] = [];
const cleanupSpy = spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve()); vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
spyOn(spawnModule, "safeSpawn").mockImplementation((params) => { vi.spyOn(spawnModule, "safeSpawn").mockImplementation((params) => {
capturedArgs = params.args; capturedArgs = params.args;
return Promise.resolve(onSpawnCall?.(params)).then(() => ({ return Promise.resolve(onSpawnCall?.(params)).then(() => ({
exitCode: 0, exitCode: 0,
@ -73,7 +73,6 @@ const setup = ({ spawnResult = {}, onSpawnCall }: SetupOptions = {}) => {
}); });
return { return {
cleanupSpy,
getArgs: () => capturedArgs, getArgs: () => capturedArgs,
hasFlag: (flag: string) => capturedArgs.includes(flag), hasFlag: (flag: string) => capturedArgs.includes(flag),
getOptionValues: (option: string): string[] => { getOptionValues: (option: string): string[] => {
@ -89,7 +88,7 @@ const setup = ({ spawnResult = {}, onSpawnCall }: SetupOptions = {}) => {
}; };
afterEach(() => { afterEach(() => {
mock.restore(); vi.restoreAllMocks();
}); });
describe("backup command", () => { describe("backup command", () => {
@ -114,27 +113,11 @@ describe("backup command", () => {
expect(getArgs().at(-1)).toBe(source); expect(getArgs().at(-1)).toBe(source);
}); });
test("uses --files-from instead of source path when include list is provided", async () => { test("writes include paths and patterns to separate files instead of passing the source path directly", async () => {
const { hasFlag, getArgs } = setup();
await backup(
config,
"/mnt/data",
{
organizationId: "org-1",
includePatterns: ["/mnt/data/docs", "/mnt/data/photos"],
},
mockDeps,
);
expect(hasFlag("--files-from")).toBe(true);
expect(getArgs()).not.toContain("/mnt/data");
});
test("passes include paths via --files-from-raw and include patterns via --files-from", async () => {
const literalDir = "/mnt/data/movies [1]"; const literalDir = "/mnt/data/movies [1]";
let rawIncludeContent = ""; let rawIncludeContent = "";
let patternIncludeContent = ""; let patternIncludeContent = "";
const { hasFlag } = setup({ const { getArgs, hasFlag } = setup({
onSpawnCall: async (params) => { onSpawnCall: async (params) => {
const rawIncludeIndex = params.args.indexOf("--files-from-raw"); const rawIncludeIndex = params.args.indexOf("--files-from-raw");
const patternIncludeIndex = params.args.indexOf("--files-from"); const patternIncludeIndex = params.args.indexOf("--files-from");
@ -162,110 +145,17 @@ describe("backup command", () => {
expect(hasFlag("--files-from-raw")).toBe(true); expect(hasFlag("--files-from-raw")).toBe(true);
expect(hasFlag("--files-from")).toBe(true); expect(hasFlag("--files-from")).toBe(true);
expect(getArgs()).not.toContain("/mnt/data");
expect(rawIncludeContent).toBe(`${literalDir}\0`); expect(rawIncludeContent).toBe(`${literalDir}\0`);
expect(patternIncludeContent).toBe("/mnt/data/**/*.zip"); expect(patternIncludeContent).toBe("/mnt/data/**/*.zip");
}); });
test("adds --tag for each entry in options.tags", async () => {
const { getOptionValues } = setup();
await backup(
config,
"/mnt/data",
{
organizationId: "org-1",
tags: ["tag-a", "tag-b"],
},
mockDeps,
);
expect(getOptionValues("--tag")).toEqual(["tag-a", "tag-b"]);
});
test("omits --tag when tags list is empty", async () => {
const { hasFlag } = setup();
await backup(config, "/mnt/data", { organizationId: "org-1", tags: [] }, mockDeps);
expect(hasFlag("--tag")).toBe(false);
});
test("passes provided compressionMode to --compression", async () => {
const { getOptionValues } = setup();
await backup(config, "/mnt/data", { organizationId: "org-1", compressionMode: "max" }, mockDeps);
expect(getOptionValues("--compression")).toEqual(["max"]);
});
test("defaults --compression to auto when compressionMode is omitted", async () => {
const { getOptionValues } = setup();
await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps);
expect(getOptionValues("--compression")).toEqual(["auto"]);
});
test("adds --one-file-system when oneFileSystem is true", async () => {
const { hasFlag } = setup();
await backup(config, "/mnt/data", { organizationId: "org-1", oneFileSystem: true }, mockDeps);
expect(hasFlag("--one-file-system")).toBe(true);
});
test("omits --one-file-system when oneFileSystem is false", async () => {
const { hasFlag } = setup();
await backup(config, "/mnt/data", { organizationId: "org-1", oneFileSystem: false }, mockDeps);
expect(hasFlag("--one-file-system")).toBe(false);
});
test("adds --exclude-file when exclude list is provided", async () => {
const { hasFlag } = setup();
await backup(
config,
"/mnt/data",
{
organizationId: "org-1",
exclude: ["/mnt/data/tmp", "/mnt/data/cache"],
},
mockDeps,
);
expect(hasFlag("--exclude-file")).toBe(true);
});
test("omits --exclude-file when exclude list is empty", async () => {
const { hasFlag } = setup();
await backup(config, "/mnt/data", { organizationId: "org-1", exclude: [] }, mockDeps);
expect(hasFlag("--exclude-file")).toBe(false);
});
test("adds --exclude-if-present for each entry in excludeIfPresent", async () => {
const { getOptionValues } = setup();
await backup(
config,
"/mnt/data",
{
organizationId: "org-1",
excludeIfPresent: [".nobackup", ".gitignore"],
},
mockDeps,
);
expect(getOptionValues("--exclude-if-present")).toEqual([".nobackup", ".gitignore"]);
});
test("always includes DEFAULT_EXCLUDES as --exclude args", async () => { test("always includes DEFAULT_EXCLUDES as --exclude args", async () => {
const { getOptionValues } = setup(); const { getOptionValues } = setup();
await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps); await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps);
expect(getOptionValues("--exclude").length).toBeGreaterThan(0); expect(getOptionValues("--exclude").length).toBeGreaterThan(0);
}); });
test("includes --host arg from config", async () => {
const { hasFlag } = setup();
await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps);
expect(hasFlag("--host")).toBe(true);
});
}); });
describe("exit code handling", () => { describe("exit code handling", () => {
@ -427,20 +317,4 @@ describe("backup command", () => {
expect(progressUpdates).toHaveLength(0); expect(progressUpdates).toHaveLength(0);
}); });
}); });
describe("cleanup", () => {
test("calls cleanupTemporaryKeys after a successful backup", async () => {
const { cleanupSpy } = setup();
await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps);
expect(cleanupSpy).toHaveBeenCalledTimes(1);
});
test("calls cleanupTemporaryKeys even when the command fails", async () => {
const { cleanupSpy } = setup({ spawnResult: { exitCode: 1, summary: "", error: "fail" } });
await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps).catch(() => {});
expect(cleanupSpy).toHaveBeenCalledTimes(1);
});
});
}); });

View file

@ -1,4 +1,4 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; import { afterEach, describe, expect, test, vi } from "vitest";
import * as cleanupModule from "../../helpers/cleanup-temporary-keys"; import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
import * as nodeModule from "../../../node"; import * as nodeModule from "../../../node";
import { copy } from "../copy"; import { copy } from "../copy";
@ -29,8 +29,8 @@ const destConfig = {
const setup = () => { const setup = () => {
let capturedArgs: string[] = []; let capturedArgs: string[] = [];
spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve()); vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
spyOn(nodeModule, "safeExec").mockImplementation(async ({ args }) => { vi.spyOn(nodeModule, "safeExec").mockImplementation(async ({ args }) => {
capturedArgs = args ?? []; capturedArgs = args ?? [];
return { exitCode: 0, stdout: "copied", stderr: "", timedOut: false }; return { exitCode: 0, stdout: "copied", stderr: "", timedOut: false };
}); });
@ -41,7 +41,7 @@ const setup = () => {
}; };
afterEach(() => { afterEach(() => {
mock.restore(); vi.restoreAllMocks();
}); });
describe("copy command", () => { describe("copy command", () => {
@ -51,17 +51,8 @@ describe("copy command", () => {
await copy(sourceConfig, destConfig, { organizationId: "org-1", snapshotId, tag: "daily" }, mockDeps); await copy(sourceConfig, destConfig, { organizationId: "org-1", snapshotId, tag: "daily" }, mockDeps);
expect(getArgs()).toEqual([ const separatorIndex = getArgs().indexOf("--");
"--repo", expect(separatorIndex).toBeGreaterThan(-1);
"/tmp/dest-repo", expect(getArgs().slice(separatorIndex + 1)).toEqual([snapshotId]);
"copy",
"--from-repo",
"/tmp/source-repo",
"--tag",
"daily",
"--json",
"--",
snapshotId,
]);
}); });
}); });

View file

@ -1,4 +1,4 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; import { afterEach, describe, expect, test, vi } from "vitest";
import * as cleanupModule from "../../helpers/cleanup-temporary-keys"; import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
import * as nodeModule from "../../../node"; import * as nodeModule from "../../../node";
import { deleteSnapshots } from "../delete-snapshots"; import { deleteSnapshots } from "../delete-snapshots";
@ -22,8 +22,8 @@ const config = {
const setup = () => { const setup = () => {
let capturedArgs: string[] = []; let capturedArgs: string[] = [];
spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve()); vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
spyOn(nodeModule, "safeExec").mockImplementation(async ({ args }) => { vi.spyOn(nodeModule, "safeExec").mockImplementation(async ({ args }) => {
capturedArgs = args ?? []; capturedArgs = args ?? [];
return { exitCode: 0, stdout: "", stderr: "", timedOut: false }; return { exitCode: 0, stdout: "", stderr: "", timedOut: false };
}); });
@ -34,7 +34,7 @@ const setup = () => {
}; };
afterEach(() => { afterEach(() => {
mock.restore(); vi.restoreAllMocks();
}); });
describe("deleteSnapshots command", () => { describe("deleteSnapshots command", () => {
@ -44,6 +44,8 @@ describe("deleteSnapshots command", () => {
await deleteSnapshots(config, snapshotIds, "org-1", mockDeps); await deleteSnapshots(config, snapshotIds, "org-1", mockDeps);
expect(getArgs()).toEqual(["--repo", "/tmp/restic-repo", "forget", "--prune", "--json", "--", ...snapshotIds]); const separatorIndex = getArgs().indexOf("--");
expect(separatorIndex).toBeGreaterThan(-1);
expect(getArgs().slice(separatorIndex + 1)).toEqual(snapshotIds);
}); });
}); });

View file

@ -1,6 +1,5 @@
import { PassThrough } from "node:stream";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; import { afterEach, describe, expect, test, vi } from "vitest";
import * as cleanupModule from "../../helpers/cleanup-temporary-keys"; import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
import * as nodeModule from "../../../node"; import * as nodeModule from "../../../node";
import { dump } from "../dump"; import { dump } from "../dump";
@ -24,10 +23,10 @@ const config = {
const setup = () => { const setup = () => {
let capturedArgs: string[] = []; let capturedArgs: string[] = [];
spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve()); vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
spyOn(nodeModule, "safeSpawn").mockImplementation((params) => { vi.spyOn(nodeModule, "safeSpawn").mockImplementation((params) => {
capturedArgs = params.args; capturedArgs = params.args;
const child = { stdout: new PassThrough() } as unknown as ReturnType<typeof spawn>; const child = spawn(process.execPath, ["-e", ""]);
params.onSpawn?.(child); params.onSpawn?.(child);
return Promise.resolve({ exitCode: 0, summary: "", error: "" }); return Promise.resolve({ exitCode: 0, summary: "", error: "" });
}); });
@ -38,7 +37,7 @@ const setup = () => {
}; };
afterEach(() => { afterEach(() => {
mock.restore(); vi.restoreAllMocks();
}); });
describe("dump command", () => { describe("dump command", () => {
@ -48,15 +47,8 @@ describe("dump command", () => {
const result = await dump(config, "--help", { organizationId: "org-1", path: "folder/file.txt" }, mockDeps); const result = await dump(config, "--help", { organizationId: "org-1", path: "folder/file.txt" }, mockDeps);
await result.completion; await result.completion;
expect(getArgs()).toEqual([ const separatorIndex = getArgs().indexOf("--");
"--repo", expect(separatorIndex).toBeGreaterThan(-1);
"/tmp/restic-repo", expect(getArgs().slice(separatorIndex + 1)).toEqual(["--help", "/folder/file.txt"]);
"dump",
"--archive",
"tar",
"--",
"--help",
"/folder/file.txt",
]);
}); });
}); });

View file

@ -1,4 +1,4 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; import { afterEach, describe, expect, test, vi } from "vitest";
import * as cleanupModule from "../../helpers/cleanup-temporary-keys"; import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
import * as spawnModule from "../../../utils/spawn"; import * as spawnModule from "../../../utils/spawn";
import { ls } from "../ls"; import { ls } from "../ls";
@ -34,8 +34,8 @@ const snapshotLine = JSON.stringify({
const setup = () => { const setup = () => {
let capturedArgs: string[] = []; let capturedArgs: string[] = [];
spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve()); vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
spyOn(spawnModule, "safeSpawn").mockImplementation((params: SafeSpawnParams) => { vi.spyOn(spawnModule, "safeSpawn").mockImplementation((params: SafeSpawnParams) => {
capturedArgs = params.args; capturedArgs = params.args;
params.onStdout?.(snapshotLine); params.onStdout?.(snapshotLine);
return Promise.resolve({ exitCode: 0, summary: snapshotLine, error: "" }); return Promise.resolve({ exitCode: 0, summary: snapshotLine, error: "" });
@ -47,7 +47,7 @@ const setup = () => {
}; };
afterEach(() => { afterEach(() => {
mock.restore(); vi.restoreAllMocks();
}); });
describe("ls command", () => { describe("ls command", () => {
@ -58,6 +58,8 @@ describe("ls command", () => {
await ls(config, snapshotId, "org-1", path, undefined, mockDeps); await ls(config, snapshotId, "org-1", path, undefined, mockDeps);
expect(getArgs()).toEqual(["--repo", "/tmp/restic-repo", "ls", "--long", "--json", "--", snapshotId, path]); const separatorIndex = getArgs().indexOf("--");
expect(separatorIndex).toBeGreaterThan(-1);
expect(getArgs().slice(separatorIndex + 1)).toEqual([snapshotId, path]);
}); });
}); });

View file

@ -1,8 +1,10 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; import { afterEach, describe, expect, test, vi } from "vitest";
import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
import * as spawnModule from "../../../utils/spawn"; import * as spawnModule from "../../../utils/spawn";
import { ResticError } from "../../error";
import { restore } from "../restore"; import { restore } from "../restore";
import type { ResticDeps } from "../../types"; import type { ResticDeps } from "../../types";
import type { SafeSpawnParams } from "../../../utils/spawn"; import type { SafeSpawnParams, SpawnResult } from "../../../utils/spawn";
const mockDeps: ResticDeps = { const mockDeps: ResticDeps = {
resolveSecret: async (s) => s, resolveSecret: async (s) => s,
@ -14,11 +16,22 @@ const mockDeps: ResticDeps = {
const successfulRestoreSummary = JSON.stringify({ const successfulRestoreSummary = JSON.stringify({
message_type: "summary", message_type: "summary",
total_files: 2,
files_restored: 1, files_restored: 1,
files_skipped: 0, files_skipped: 1,
bytes_skipped: 0, bytes_skipped: 0,
}); });
const validProgressLine = JSON.stringify({
message_type: "status",
seconds_elapsed: 5,
percent_done: 0.5,
total_files: 2,
files_restored: 1,
total_bytes: 1024,
bytes_restored: 512,
});
const config = { const config = {
backend: "local" as const, backend: "local" as const,
path: "/tmp/restic-repo", path: "/tmp/restic-repo",
@ -26,16 +39,23 @@ const config = {
customPassword: "custom-password", customPassword: "custom-password",
}; };
/** type SetupOptions = {
* Sets up the safeSpawn mock and returns helpers to inspect the restic args spawnResult?: Partial<SpawnResult>;
* that were built for the restore command. onSpawnCall?: (params: SafeSpawnParams) => void | Promise<void>;
*/ };
const setup = () => {
const setup = ({ spawnResult = {}, onSpawnCall }: SetupOptions = {}) => {
let capturedArgs: string[] = []; let capturedArgs: string[] = [];
spyOn(spawnModule, "safeSpawn").mockImplementation((params: SafeSpawnParams) => { vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
vi.spyOn(spawnModule, "safeSpawn").mockImplementation((params: SafeSpawnParams) => {
capturedArgs = params.args; capturedArgs = params.args;
return Promise.resolve({ exitCode: 0, summary: successfulRestoreSummary, error: "" }); return Promise.resolve(onSpawnCall?.(params)).then(() => ({
exitCode: 0,
summary: successfulRestoreSummary,
error: "",
...spawnResult,
}));
}); });
const getRestoreArg = () => { const getRestoreArg = () => {
@ -46,7 +66,7 @@ const setup = () => {
return capturedArgs[separatorIndex + 1]!; return capturedArgs[separatorIndex + 1]!;
}; };
const getOptionValues = (option: string): string[] => { const getOptionValues = (option: string) => {
const values: string[] = []; const values: string[] = [];
for (let i = 0; i < capturedArgs.length - 1; i++) { for (let i = 0; i < capturedArgs.length - 1; i++) {
if (capturedArgs[i] === option && capturedArgs[i + 1]) { if (capturedArgs[i] === option && capturedArgs[i + 1]) {
@ -64,127 +84,172 @@ const setup = () => {
}; };
afterEach(() => { afterEach(() => {
mock.restore(); vi.restoreAllMocks();
}); });
describe("restore command", () => { describe("restore command", () => {
test("keeps snapshot restore arg and absolute include paths when target is root", async () => { describe("path selection", () => {
const { getRestoreArg, getOptionValues } = setup(); test("uses the common ancestor as restore root and strips includes for non-root targets", async () => {
await restore( const { getRestoreArg, getOptionValues } = setup();
config,
"snapshot-123",
"/",
{
organizationId: "org-1",
include: [
"/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf",
"/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg",
],
},
mockDeps,
);
expect(getRestoreArg()).toBe("snapshot-123"); await restore(
expect(getOptionValues("--include")).toEqual([ config,
"/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf", "snapshot-456",
"/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg", "/tmp/restore-target",
]); {
organizationId: "org-1",
include: [
"/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf",
"/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg",
],
},
mockDeps,
);
expect(getRestoreArg()).toBe("snapshot-456:/var/lib/zerobyte/volumes/vol123/_data");
expect(getOptionValues("--include")).toEqual(["Documents/report.pdf", "Photos/summer.jpg"]);
});
test("restores a selected file from its parent directory for non-root targets", async () => {
const { getRestoreArg, getOptionValues } = setup();
await restore(
config,
"snapshot-single-file",
"/tmp/restore-target",
{
organizationId: "org-1",
include: ["/var/lib/zerobyte/volumes/vol123/_data/archive/backup.20260301-233001.7z"],
selectedItemKind: "file",
},
mockDeps,
);
expect(getRestoreArg()).toBe("snapshot-single-file:/var/lib/zerobyte/volumes/vol123/_data/archive");
expect(getOptionValues("--include")).toEqual(["backup.20260301-233001.7z"]);
});
test("treats flag-like snapshot IDs as positional restore args", async () => {
const { getArgs, getRestoreArg } = setup();
await restore(
config,
"--help",
"/tmp/restore-target",
{
organizationId: "org-1",
basePath: "/var/lib/zerobyte/volumes/vol123/_data",
},
mockDeps,
);
const separatorIndex = getArgs().indexOf("--");
expect(separatorIndex).toBeGreaterThan(-1);
expect(getRestoreArg()).toBe("--help:/var/lib/zerobyte/volumes/vol123/_data");
});
}); });
test("restores from common ancestor and strips include paths for non-root targets", async () => { describe("output handling", () => {
const { getRestoreArg, getOptionValues } = setup(); test("returns a parsed restore summary on success", async () => {
await restore( setup();
config,
"snapshot-456",
"/tmp/restore-target",
{
organizationId: "org-1",
include: [
"/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf",
"/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg",
],
},
mockDeps,
);
expect(getRestoreArg()).toBe("snapshot-456:/var/lib/zerobyte/volumes/vol123/_data"); const result = await restore(
expect(getOptionValues("--include")).toEqual(["Documents/report.pdf", "Photos/summer.jpg"]); config,
"snapshot-123",
"/tmp/restore-target",
{ organizationId: "org-1", basePath: "/var/lib/zerobyte/volumes/vol123/_data" },
mockDeps,
);
expect(result).toMatchObject({
message_type: "summary",
files_restored: 1,
files_skipped: 1,
});
});
test("throws ResticError when the command fails", async () => {
setup({ spawnResult: { exitCode: 1, summary: "", error: "restore failed" } });
await expect(
restore(
config,
"snapshot-123",
"/tmp/restore-target",
{ organizationId: "org-1", basePath: "/var/lib/zerobyte/volumes/vol123/_data" },
mockDeps,
),
).rejects.toBeInstanceOf(ResticError);
});
test("falls back to an empty summary when restic output cannot be parsed", async () => {
setup({ spawnResult: { summary: "not-json" } });
const result = await restore(
config,
"snapshot-123",
"/tmp/restore-target",
{ organizationId: "org-1", basePath: "/var/lib/zerobyte/volumes/vol123/_data" },
mockDeps,
);
expect(result).toEqual({
message_type: "summary",
total_files: 0,
files_restored: 0,
files_skipped: 0,
bytes_skipped: 0,
});
});
}); });
test("uses base path for non-root restore when includes are omitted", async () => { describe("progress callbacks", () => {
const { getRestoreArg, getOptionValues } = setup(); test("calls onProgress with parsed status updates", async () => {
await restore( const progressUpdates: unknown[] = [];
config, setup({ onSpawnCall: (params) => params.onStdout?.(validProgressLine) });
"snapshot-789",
"/tmp/restore-target",
{
organizationId: "org-1",
basePath: "/var/lib/zerobyte/volumes/vol123/_data",
},
mockDeps,
);
expect(getRestoreArg()).toBe("snapshot-789:/var/lib/zerobyte/volumes/vol123/_data"); await restore(
expect(getOptionValues("--include")).toEqual([]); config,
}); "snapshot-123",
"/tmp/restore-target",
{
organizationId: "org-1",
basePath: "/var/lib/zerobyte/volumes/vol123/_data",
onProgress: (progress) => progressUpdates.push(progress),
},
mockDeps,
);
test("restores a single selected file from its parent directory for non-root targets", async () => { expect(progressUpdates).toHaveLength(1);
const { getRestoreArg, getOptionValues } = setup(); expect(progressUpdates[0]).toMatchObject({
const options: Parameters<typeof restore>[3] & { selectedItemKind: "file" } = { message_type: "status",
organizationId: "org-1", percent_done: 0.5,
include: ["/var/lib/zerobyte/volumes/vol123/_data/archive/backup.20260301-233001.7z"], files_restored: 1,
selectedItemKind: "file", });
}; });
await restore(config, "snapshot-single-file", "/tmp/restore-target", options, mockDeps); test("ignores non-JSON progress lines", async () => {
const progressUpdates: unknown[] = [];
setup({
onSpawnCall: (params) => {
params.onStdout?.("scanning...");
params.onStdout?.("repository opened");
},
});
expect(getRestoreArg()).toBe("snapshot-single-file:/var/lib/zerobyte/volumes/vol123/_data/archive"); await restore(
expect(getOptionValues("--include")).toEqual(["backup.20260301-233001.7z"]); config,
}); "snapshot-123",
"/tmp/restore-target",
{
organizationId: "org-1",
basePath: "/var/lib/zerobyte/volumes/vol123/_data",
onProgress: (progress) => progressUpdates.push(progress),
},
mockDeps,
);
test("does not pass an empty include when include equals restore root", async () => { expect(progressUpdates).toHaveLength(0);
const { getArgs, getRestoreArg, getOptionValues } = setup(); });
await restore(
config,
"snapshot-7202d8cc",
"/Users/nicolas/Documents/restore",
{
organizationId: "org-1",
include: ["/Users/nicolas/Developer/zerobyte/tmp/deep/test/files"],
overwrite: "always",
},
mockDeps,
);
expect(getRestoreArg()).toBe("snapshot-7202d8cc:/Users/nicolas/Developer/zerobyte/tmp/deep/test/files");
expect(getOptionValues("--include")).toEqual([]);
expect(getArgs()).not.toContain("");
});
test("treats flag-like snapshot IDs as positional restore args", async () => {
const { getArgs, getRestoreArg } = setup();
await restore(
config,
"--help",
"/tmp/restore-target",
{
organizationId: "org-1",
basePath: "/var/lib/zerobyte/volumes/vol123/_data",
},
mockDeps,
);
expect(getRestoreArg()).toBe("--help:/var/lib/zerobyte/volumes/vol123/_data");
expect(getArgs()).toEqual([
"--repo",
"/tmp/restic-repo",
"restore",
"--target",
"/tmp/restore-target",
"--json",
"--",
"--help:/var/lib/zerobyte/volumes/vol123/_data",
]);
}); });
}); });

View file

@ -1,4 +1,4 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; import { afterEach, describe, expect, test, vi } from "vitest";
import * as cleanupModule from "../../helpers/cleanup-temporary-keys"; import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
import * as nodeModule from "../../../node"; import * as nodeModule from "../../../node";
import { tagSnapshots } from "../tag-snapshots"; import { tagSnapshots } from "../tag-snapshots";
@ -22,8 +22,8 @@ const config = {
const setup = () => { const setup = () => {
let capturedArgs: string[] = []; let capturedArgs: string[] = [];
spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve()); vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
spyOn(nodeModule, "safeExec").mockImplementation(async ({ args }) => { vi.spyOn(nodeModule, "safeExec").mockImplementation(async ({ args }) => {
capturedArgs = args ?? []; capturedArgs = args ?? [];
return { exitCode: 0, stdout: "", stderr: "", timedOut: false }; return { exitCode: 0, stdout: "", stderr: "", timedOut: false };
}); });
@ -34,7 +34,7 @@ const setup = () => {
}; };
afterEach(() => { afterEach(() => {
mock.restore(); vi.restoreAllMocks();
}); });
describe("tagSnapshots command", () => { describe("tagSnapshots command", () => {
@ -44,6 +44,8 @@ describe("tagSnapshots command", () => {
await tagSnapshots(config, snapshotIds, { add: ["keep"] }, "org-1", mockDeps); await tagSnapshots(config, snapshotIds, { add: ["keep"] }, "org-1", mockDeps);
expect(getArgs()).toEqual(["--repo", "/tmp/restic-repo", "tag", "--add", "keep", "--json", "--", ...snapshotIds]); const separatorIndex = getArgs().indexOf("--");
expect(separatorIndex).toBeGreaterThan(-1);
expect(getArgs().slice(separatorIndex + 1)).toEqual(snapshotIds);
}); });
}); });

View file

@ -1,5 +1,5 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { afterEach, describe, expect, test } from "bun:test"; import { afterEach, describe, expect, test } from "vitest";
import { buildEnv } from "../build-env"; import { buildEnv } from "../build-env";
import type { ResticDeps } from "../../types"; import type { ResticDeps } from "../../types";

View file

@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "vitest";
import { buildRepoUrl } from "../build-repo-url"; import { buildRepoUrl } from "../build-repo-url";
describe("buildRepoUrl", () => { describe("buildRepoUrl", () => {

View file

@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "vitest";
import { validateCustomResticParams } from "../validate-custom-params"; import { validateCustomResticParams } from "../validate-custom-params";
describe("validateCustomResticParams", () => { describe("validateCustomResticParams", () => {

View file

@ -1,7 +1,7 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, test } from "bun:test"; import { afterEach, describe, expect, test } from "vitest";
import { FILE_MODES, writeFileWithMode } from "../fs"; import { FILE_MODES, writeFileWithMode } from "../fs";
const tempDirectories = new Set<string>(); const tempDirectories = new Set<string>();

View file

@ -1,6 +1,6 @@
import { mock } from "bun:test"; import { vi } from "vitest";
void mock.module("../src/utils/logger.ts", () => ({ vi.mock(import("../src/utils/logger.ts"), () => ({
logger: { logger: {
debug: () => {}, debug: () => {},
info: () => {}, info: () => {},

View file

@ -0,0 +1,14 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
server: {
deps: {
inline: ["zod"],
},
},
include: ["src/**/*.test.ts", "src/**/*.spec.ts"],
setupFiles: ["./test/setup.ts"],
},
});

View file

@ -2,7 +2,7 @@
"include": ["app/**/*"], "include": ["app/**/*"],
"compilerOptions": { "compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"], "lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["node", "vite/client"], "types": ["bun", "node", "vite/client"],
"target": "ES2022", "target": "ES2022",
"module": "ES2022", "module": "ES2022",
"moduleResolution": "bundler", "moduleResolution": "bundler",

53
vitest.config.ts Normal file
View file

@ -0,0 +1,53 @@
import path from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: {
"~": path.resolve(import.meta.dirname, "app"),
},
},
test: {
server: {
deps: {
inline: ["zod"],
},
},
projects: [
{
extends: true,
test: {
name: "server",
environment: "node",
include: ["app/**/*.test.ts", "app/**/*.test.tsx", "app/**/*.spec.ts", "app/**/*.spec.tsx"],
exclude: [
"app/client/**/*.test.ts",
"app/client/**/*.test.tsx",
"app/client/**/*.spec.ts",
"app/client/**/*.spec.tsx",
],
setupFiles: ["./app/test/setup.ts"],
},
},
{
extends: true,
test: {
name: "client",
environment: "happy-dom",
environmentOptions: {
happyDOM: {
url: "http://localhost:3000",
},
},
include: [
"app/client/**/*.test.ts",
"app/client/**/*.test.tsx",
"app/client/**/*.spec.ts",
"app/client/**/*.spec.tsx",
],
setupFiles: ["./app/test/setup-client.ts"],
},
},
],
},
});