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:
parent
e265f7d478
commit
4305057185
70 changed files with 1062 additions and 855 deletions
|
|
@ -24,7 +24,7 @@ bun run tsc
|
|||
bun run test
|
||||
|
||||
# Run a specific test file
|
||||
bunx dotenv-cli -e .env.test -- bun test --preload ./app/test/setup.ts path/to/test.ts
|
||||
bunx dotenv-cli -e .env.test -- bunx --bun vitest run --project server path/to/test.ts
|
||||
```
|
||||
|
||||
### Building
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/** 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 { useState } from "react";
|
||||
import { FileTree, type FileEntry } from "../file-tree";
|
||||
|
|
|
|||
|
|
@ -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 { cleanup, render, screen, userEvent, waitFor, within } from "~/test/test-utils";
|
||||
import { fromAny } from "@total-typescript/shoehorn";
|
||||
|
||||
await mock.module("@tanstack/react-router", () => ({
|
||||
useNavigate: () => mock(() => {}),
|
||||
}));
|
||||
vi.mock("@tanstack/react-router", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@tanstack/react-router")>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: (() => vi.fn(async () => {})) as typeof actual.useNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
import { RestoreForm } from "../restore-form";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { 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 () => {
|
||||
const requests = mockListSnapshotFiles();
|
||||
test("shows the query root contents when display and query roots differ", async () => {
|
||||
mockListSnapshotFiles();
|
||||
|
||||
renderSnapshotTreeBrowser();
|
||||
|
||||
|
|
@ -258,19 +258,10 @@ describe("SnapshotTreeBrowser", () => {
|
|||
throw new Error("Expected expand icon for folder row");
|
||||
}
|
||||
|
||||
const initialRequestCount = requests.length;
|
||||
fireEvent.click(expandIcon);
|
||||
if (!screen.queryByRole("button", { name: "a.txt" })) {
|
||||
fireEvent.click(expandIcon);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requests.length).toBeGreaterThan(initialRequestCount);
|
||||
});
|
||||
|
||||
expect(requests.at(-1)).toEqual({
|
||||
shortId: "repo-1",
|
||||
snapshotId: "snap-1",
|
||||
path: "/mnt/project",
|
||||
offset: "0",
|
||||
limit: "500",
|
||||
});
|
||||
expect(await screen.findByRole("button", { name: "a.txt" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 { useQuery } from "@tanstack/react-query";
|
||||
import { cleanup, createTestQueryClient, render, screen } from "~/test/test-utils";
|
||||
import { useServerEvents } from "../use-server-events";
|
||||
|
||||
|
|
@ -7,7 +8,7 @@ class MockEventSource {
|
|||
static instances: MockEventSource[] = [];
|
||||
|
||||
public onerror: ((event: Event) => void) | null = null;
|
||||
public close = mock(() => {});
|
||||
public close = vi.fn(() => {});
|
||||
private listeners = new Map<string, Set<(event: Event) => void>>();
|
||||
|
||||
constructor(public url: string) {
|
||||
|
|
@ -67,12 +68,21 @@ const BackupCompletedListener = ({ scheduleId }: { scheduleId: string }) => {
|
|||
return <div>{status}</div>;
|
||||
};
|
||||
|
||||
const QueryStatusConsumer = ({ getValue }: { getValue: () => string }) => {
|
||||
const { data } = useQuery({
|
||||
queryKey: ["backup-status"],
|
||||
queryFn: async () => getValue(),
|
||||
});
|
||||
|
||||
return <div>{data}</div>;
|
||||
};
|
||||
|
||||
describe("useServerEvents", () => {
|
||||
beforeEach(() => {
|
||||
MockEventSource.reset();
|
||||
globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
|
||||
console.info = mock(() => {});
|
||||
console.error = mock(() => {});
|
||||
console.info = vi.fn(() => {});
|
||||
console.error = vi.fn(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -83,22 +93,23 @@ describe("useServerEvents", () => {
|
|||
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 invalidateQueries = mock(async () => undefined);
|
||||
const refetchQueries = mock(async () => undefined);
|
||||
queryClient.invalidateQueries = invalidateQueries as typeof queryClient.invalidateQueries;
|
||||
queryClient.refetchQueries = refetchQueries as typeof queryClient.refetchQueries;
|
||||
let queryValue = "before";
|
||||
|
||||
render(
|
||||
const view = render(
|
||||
<>
|
||||
<ConnectionConsumer />
|
||||
<BackupCompletedListener scheduleId="0b9c940b" />
|
||||
<QueryStatusConsumer getValue={() => queryValue} />
|
||||
</>,
|
||||
{ queryClient },
|
||||
);
|
||||
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
expect(await screen.findByText("before")).toBeTruthy();
|
||||
|
||||
queryValue = "after";
|
||||
|
||||
MockEventSource.instances[0]?.emit("backup:completed", {
|
||||
organizationId: "default-org",
|
||||
|
|
@ -109,10 +120,9 @@ describe("useServerEvents", () => {
|
|||
});
|
||||
|
||||
expect(await screen.findByText("success")).toBeTruthy();
|
||||
expect(invalidateQueries).toHaveBeenCalledTimes(1);
|
||||
expect(refetchQueries).not.toHaveBeenCalled();
|
||||
expect(await screen.findByText("after")).toBeTruthy();
|
||||
|
||||
cleanup();
|
||||
view.unmount();
|
||||
|
||||
expect(MockEventSource.instances[0]?.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_TIME_FORMAT,
|
||||
formatDate,
|
||||
|
|
@ -12,7 +12,7 @@ import {
|
|||
} from "../datetime";
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const sampleDate = new Date("2026-01-10T14:30:00.000Z");
|
||||
|
|
@ -56,7 +56,7 @@ describe("datetime formatters", () => {
|
|||
});
|
||||
|
||||
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");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { getVolumeMountPath } from "./volume-path";
|
||||
import { fromAny } from "@total-typescript/shoehorn";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
await mock.module("@tanstack/react-router", () => ({
|
||||
useNavigate: () => mock(() => {}),
|
||||
}));
|
||||
vi.mock("@tanstack/react-router", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@tanstack/react-router")>();
|
||||
|
||||
await mock.module("~/client/modules/sso/components/sso-login-section", () => ({
|
||||
SsoLoginSection: () => null,
|
||||
}));
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: (() => vi.fn(async () => {})) as typeof actual.useNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
import { LoginPage } from "../login";
|
||||
const inviteOnlyMessage =
|
||||
"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(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("LoginPage", () => {
|
||||
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();
|
||||
});
|
||||
|
||||
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(
|
||||
await screen.findByText(
|
||||
|
|
@ -35,7 +54,9 @@ describe("LoginPage", () => {
|
|||
});
|
||||
|
||||
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(
|
||||
await screen.findByText(
|
||||
|
|
@ -45,21 +66,35 @@ describe("LoginPage", () => {
|
|||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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(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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,18 +1,29 @@
|
|||
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 { cleanup, render, screen, waitFor } from "~/test/test-utils";
|
||||
import { fromAny } from "@total-typescript/shoehorn";
|
||||
|
||||
await mock.module("@tanstack/react-router", () => ({
|
||||
Link: ({ children }: { children?: ReactNode }) => <a href="/">{children}</a>,
|
||||
}));
|
||||
vi.mock("@tanstack/react-router", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@tanstack/react-router")>();
|
||||
|
||||
await mock.module("~/client/lib/datetime", () => ({
|
||||
useTimeFormat: () => ({
|
||||
formatDateTime: () => "2026-03-26 00:00",
|
||||
}),
|
||||
}));
|
||||
return {
|
||||
...actual,
|
||||
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";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,43 +1,61 @@
|
|||
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 { HttpResponse, http, server } from "~/test/msw/server";
|
||||
import { cleanup, render, screen } from "~/test/test-utils";
|
||||
|
||||
await mock.module("@tanstack/react-router", () => ({
|
||||
Link: ({ children }: { children?: ReactNode }) => <a href="/">{children}</a>,
|
||||
useNavigate: () => mock(() => {}),
|
||||
useSearch: () => ({}),
|
||||
}));
|
||||
vi.mock("@tanstack/react-router", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@tanstack/react-router")>();
|
||||
|
||||
await mock.module("~/client/components/backup-summary-card", () => ({
|
||||
BackupSummaryCard: () => null,
|
||||
}));
|
||||
return {
|
||||
...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", () => ({
|
||||
ScheduleSummary: () => null,
|
||||
}));
|
||||
vi.mock("~/client/lib/datetime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("~/client/lib/datetime")>();
|
||||
|
||||
await mock.module("~/client/modules/backups/components/snapshot-timeline", () => ({
|
||||
SnapshotTimeline: () => null,
|
||||
}));
|
||||
return {
|
||||
...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,
|
||||
}));
|
||||
|
||||
await mock.module("~/client/modules/backups/components/schedule-mirrors-config", () => ({
|
||||
vi.mock("~/client/modules/backups/components/schedule-mirrors-config", () => ({
|
||||
ScheduleMirrorsConfig: () => null,
|
||||
}));
|
||||
|
||||
await mock.module("~/client/lib/datetime", () => ({
|
||||
useTimeFormat: () => ({
|
||||
formatDateTime: () => "2026-03-26 00:00",
|
||||
}),
|
||||
vi.mock("~/client/modules/backups/components/schedule-summary", () => ({
|
||||
ScheduleSummary: ({ schedule }: { schedule: { name: string } }) => (
|
||||
<div>
|
||||
<h1>{schedule.name}</h1>
|
||||
<button type="button">Backup now</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
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 = {
|
||||
shortId: "backup-1",
|
||||
name: "Backup 1",
|
||||
|
|
@ -51,6 +69,10 @@ const schedule = {
|
|||
enabled: true,
|
||||
cronExpression: "0 0 * * *",
|
||||
retentionPolicy: null,
|
||||
lastBackupAt: 1711411200000,
|
||||
nextBackupAt: 1711497600000,
|
||||
lastBackupStatus: null,
|
||||
lastBackupError: null,
|
||||
includePaths: ["/project"],
|
||||
includePatterns: [],
|
||||
excludePatterns: [],
|
||||
|
|
@ -63,8 +85,26 @@ const snapshot = {
|
|||
short_id: "snap-1",
|
||||
paths: ["/mnt/project"],
|
||||
tags: ["backup-1"],
|
||||
time: "2026-03-26T00:00:00.000Z",
|
||||
summary: {},
|
||||
time: new Date("2026-03-26T00:00:00.000Z").getTime(),
|
||||
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 = () => {
|
||||
|
|
@ -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(() => {
|
||||
globalThis.EventSource = originalEventSource;
|
||||
cleanup();
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
render(
|
||||
|
|
@ -102,16 +159,21 @@ describe("ScheduleDetailsPage", () => {
|
|||
repos: [],
|
||||
scheduleNotifs: [],
|
||||
mirrors: [],
|
||||
snapshotTimelineSortOrder: "newest",
|
||||
snapshotTimelineSortOrder: "desc",
|
||||
snapshots: [snapshot],
|
||||
})}
|
||||
scheduleId="backup-1"
|
||||
initialSnapshotId="snap-1"
|
||||
initialSnapshotSortOrder={fromAny("newest")}
|
||||
initialSnapshotSortOrder="desc"
|
||||
/>,
|
||||
{ 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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 { createTestSession } from "~/test/helpers/auth";
|
||||
import { db } from "~/server/db/db";
|
||||
|
|
@ -18,9 +18,33 @@ import { eq } from "drizzle-orm";
|
|||
const app = createApp();
|
||||
|
||||
describe("multi-organization isolation", () => {
|
||||
test("should reject requests when session active organization is not a membership", async () => {
|
||||
const session = await createTestSession();
|
||||
let session1: Awaited<ReturnType<typeof 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
|
||||
const foreignOrgId = crypto.randomUUID();
|
||||
await db.insert(organization).values({
|
||||
|
|
@ -42,10 +66,10 @@ describe("multi-organization isolation", () => {
|
|||
await db
|
||||
.update(sessionsTable)
|
||||
.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", {
|
||||
headers: session.headers,
|
||||
headers: session1.headers,
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const session1 = await createTestSession();
|
||||
const session2 = await createTestSession();
|
||||
|
||||
expect(session1.organizationId).not.toBe(session2.organizationId);
|
||||
|
||||
const repoId = crypto.randomUUID();
|
||||
|
|
@ -85,9 +106,6 @@ describe("multi-organization isolation", () => {
|
|||
});
|
||||
|
||||
test("should not list repositories from another organization", async () => {
|
||||
const session1 = await createTestSession();
|
||||
const session2 = await createTestSession();
|
||||
|
||||
await db.insert(repositoriesTable).values({
|
||||
id: crypto.randomUUID(),
|
||||
shortId: generateShortId(),
|
||||
|
|
@ -123,9 +141,6 @@ describe("multi-organization isolation", () => {
|
|||
});
|
||||
|
||||
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 volumeShortId = generateShortId();
|
||||
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 () => {
|
||||
const session1 = await createTestSession();
|
||||
const session2 = await createTestSession();
|
||||
|
||||
const vol1Id = Math.floor(Math.random() * 1000000);
|
||||
await db.insert(volumesTable).values({
|
||||
id: vol1Id,
|
||||
|
|
@ -189,9 +201,6 @@ describe("multi-organization isolation", () => {
|
|||
});
|
||||
|
||||
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);
|
||||
await db.insert(volumesTable).values({
|
||||
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 () => {
|
||||
const session1 = await createTestSession();
|
||||
const session2 = await createTestSession();
|
||||
|
||||
const volId = Math.floor(Math.random() * 1000000);
|
||||
await db.insert(volumesTable).values({
|
||||
id: volId,
|
||||
|
|
|
|||
|
|
@ -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 { db } from "~/server/db/db";
|
||||
import { account, sessionsTable, usersTable } from "~/server/db/schema";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { test, describe, expect } from "bun:test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { repoMutex } from "../repository-mutex";
|
||||
|
||||
describe("RepositoryMutex", () => {
|
||||
|
|
|
|||
|
|
@ -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 { Job, Scheduler } from "../scheduler";
|
||||
|
||||
|
|
@ -9,17 +9,17 @@ const flushMicrotasks = async () => {
|
|||
const mockTimeZone = (timeZone: string) => {
|
||||
// oxlint-disable-next-line typescript/unbound-method
|
||||
const resolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions;
|
||||
return spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockImplementation(
|
||||
function (this: Intl.DateTimeFormat) {
|
||||
return vi
|
||||
.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions")
|
||||
.mockImplementation(function (this: Intl.DateTimeFormat) {
|
||||
return { ...resolvedOptions.call(this), timeZone };
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
describe("Scheduler", () => {
|
||||
afterEach(async () => {
|
||||
await Scheduler.clear();
|
||||
mock.restore();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ describe("Scheduler", () => {
|
|||
|
||||
let runCount = 0;
|
||||
let releaseRun: (() => void) | undefined;
|
||||
const warn = spyOn(logger, "warn");
|
||||
const warn = vi.spyOn(logger, "warn");
|
||||
|
||||
class TestJob extends Job {
|
||||
async run() {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { isValidUsername, normalizeUsername } from "~/lib/username";
|
||||
|
||||
describe("username helpers", () => {
|
||||
|
|
|
|||
|
|
@ -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 { db, sqlite } from "~/server/db/db";
|
||||
import { account, invitation, member, organization, usersTable } from "~/server/db/schema";
|
||||
|
|
|
|||
|
|
@ -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 { db } from "~/server/db/db";
|
||||
import { usersTable, account, organization, member } from "~/server/db/schema";
|
||||
import type { AuthMiddlewareContext } from "~/server/lib/auth";
|
||||
|
||||
describe("convertLegacyUserOnFirstLogin", () => {
|
||||
const legacyPasswordHash = "legacy-password-hash";
|
||||
const verifyPassword = vi.spyOn(Bun.password, "verify");
|
||||
|
||||
beforeEach(async () => {
|
||||
verifyPassword.mockReset();
|
||||
verifyPassword.mockImplementation(async (password) => password === "correct-password");
|
||||
|
||||
await db.delete(member);
|
||||
await db.delete(account);
|
||||
await db.delete(organization);
|
||||
|
|
@ -46,8 +57,6 @@ describe("convertLegacyUserOnFirstLogin", () => {
|
|||
});
|
||||
|
||||
test("should throw UnauthorizedError for invalid password", async () => {
|
||||
const hashedPassword = await Bun.password.hash("correct-password");
|
||||
|
||||
// Create a legacy user with a hashed password
|
||||
const userId = crypto.randomUUID();
|
||||
await db.insert(usersTable).values({
|
||||
|
|
@ -55,7 +64,7 @@ describe("convertLegacyUserOnFirstLogin", () => {
|
|||
username: "legacy-user",
|
||||
email: "legacy@test.com",
|
||||
name: "Legacy User",
|
||||
passwordHash: hashedPassword,
|
||||
passwordHash: legacyPasswordHash,
|
||||
});
|
||||
|
||||
const ctx = createContext("/sign-in/username", {
|
||||
|
|
@ -70,12 +79,11 @@ describe("convertLegacyUserOnFirstLogin", () => {
|
|||
where: { username: "legacy-user" },
|
||||
});
|
||||
expect(user).toBeDefined();
|
||||
expect(user?.passwordHash).toBe(hashedPassword);
|
||||
expect(user?.passwordHash).toBe(legacyPasswordHash);
|
||||
});
|
||||
|
||||
test("should migrate legacy user with existing organization membership", async () => {
|
||||
const password = "correct-password";
|
||||
const hashedPassword = await Bun.password.hash(password);
|
||||
|
||||
// Create legacy user
|
||||
const userId = crypto.randomUUID();
|
||||
|
|
@ -84,7 +92,7 @@ describe("convertLegacyUserOnFirstLogin", () => {
|
|||
username: "legacy-with-org",
|
||||
email: "legacy-org@test.com",
|
||||
name: "Legacy With Org",
|
||||
passwordHash: hashedPassword,
|
||||
passwordHash: legacyPasswordHash,
|
||||
role: "admin",
|
||||
});
|
||||
|
||||
|
|
@ -150,7 +158,6 @@ describe("convertLegacyUserOnFirstLogin", () => {
|
|||
|
||||
test("should migrate legacy user and create new organization when no membership exists", async () => {
|
||||
const password = "correct-password";
|
||||
const hashedPassword = await Bun.password.hash(password);
|
||||
|
||||
// Create legacy user without organization membership
|
||||
const userId = crypto.randomUUID();
|
||||
|
|
@ -159,7 +166,7 @@ describe("convertLegacyUserOnFirstLogin", () => {
|
|||
username: "legacy-no-org",
|
||||
email: "legacy-noorg@test.com",
|
||||
name: "Legacy No Org",
|
||||
passwordHash: hashedPassword,
|
||||
passwordHash: legacyPasswordHash,
|
||||
hasDownloadedResticPassword: true,
|
||||
});
|
||||
|
||||
|
|
@ -208,7 +215,6 @@ describe("convertLegacyUserOnFirstLogin", () => {
|
|||
|
||||
test("should be case-insensitive for username", async () => {
|
||||
const password = "correct-password";
|
||||
const hashedPassword = await Bun.password.hash(password);
|
||||
|
||||
const userId = crypto.randomUUID();
|
||||
await db.insert(usersTable).values({
|
||||
|
|
@ -216,7 +222,7 @@ describe("convertLegacyUserOnFirstLogin", () => {
|
|||
username: "legacy-user",
|
||||
email: "legacy@test.com",
|
||||
name: "Legacy User",
|
||||
passwordHash: hashedPassword,
|
||||
passwordHash: legacyPasswordHash,
|
||||
});
|
||||
|
||||
// Try login with uppercase username
|
||||
|
|
@ -241,7 +247,6 @@ describe("convertLegacyUserOnFirstLogin", () => {
|
|||
|
||||
test("should trim whitespace from username", async () => {
|
||||
const password = "correct-password";
|
||||
const hashedPassword = await Bun.password.hash(password);
|
||||
|
||||
const userId = crypto.randomUUID();
|
||||
await db.insert(usersTable).values({
|
||||
|
|
@ -249,7 +254,7 @@ describe("convertLegacyUserOnFirstLogin", () => {
|
|||
username: "legacy-user",
|
||||
email: "legacy@test.com",
|
||||
name: "Legacy User",
|
||||
passwordHash: hashedPassword,
|
||||
passwordHash: legacyPasswordHash,
|
||||
});
|
||||
|
||||
// Try login with whitespace
|
||||
|
|
|
|||
|
|
@ -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 { account, member, organization, sessionsTable, usersTable } from "~/server/db/schema";
|
||||
import { authService } from "../auth.service";
|
||||
|
|
|
|||
|
|
@ -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 { member, organization, sessionsTable, usersTable } from "~/server/db/schema";
|
||||
import { authService } from "../auth.service";
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
createTestSession,
|
||||
|
|
@ -7,19 +7,22 @@ import {
|
|||
createTestSessionWithRegularMember,
|
||||
getAuthHeaders,
|
||||
} 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";
|
||||
|
||||
const app = createApp();
|
||||
|
||||
describe("auth controller security", () => {
|
||||
beforeEach(async () => {
|
||||
await db.delete(member);
|
||||
await db.delete(account);
|
||||
await db.delete(invitation);
|
||||
await db.delete(ssoProvider);
|
||||
await db.delete(organization);
|
||||
await db.delete(usersTable);
|
||||
let regularUserSession: Awaited<ReturnType<typeof createTestSession>>;
|
||||
let regularMemberSession: Awaited<ReturnType<typeof createTestSessionWithRegularMember>>;
|
||||
let orgAdminSession: Awaited<ReturnType<typeof createTestSessionWithOrgAdmin>>;
|
||||
let globalAdminSession: Awaited<ReturnType<typeof createTestSessionWithGlobalAdmin>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
regularUserSession = await createTestSession();
|
||||
regularMemberSession = await createTestSessionWithRegularMember();
|
||||
orgAdminSession = await createTestSessionWithOrgAdmin();
|
||||
globalAdminSession = await createTestSessionWithGlobalAdmin();
|
||||
});
|
||||
|
||||
describe("public endpoints - no auth required", () => {
|
||||
|
|
@ -59,10 +62,9 @@ describe("auth controller security", () => {
|
|||
});
|
||||
|
||||
test(`${method} ${path} should return 403 for regular members`, async () => {
|
||||
const { headers } = await createTestSessionWithRegularMember();
|
||||
const res = await app.request(path, {
|
||||
method,
|
||||
headers,
|
||||
headers: regularMemberSession.headers,
|
||||
body: method !== "GET" && method !== "DELETE" ? JSON.stringify({}) : undefined,
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
|
|
@ -71,10 +73,9 @@ describe("auth controller security", () => {
|
|||
});
|
||||
|
||||
test(`${method} ${path} should be accessible to org admins`, async () => {
|
||||
const { headers } = await createTestSessionWithOrgAdmin();
|
||||
const res = await app.request(path, {
|
||||
method,
|
||||
headers,
|
||||
headers: orgAdminSession.headers,
|
||||
body: method !== "GET" && method !== "DELETE" ? JSON.stringify({}) : undefined,
|
||||
});
|
||||
// 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", () => {
|
||||
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", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
...headers,
|
||||
...orgAdminSession.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
|
|
@ -100,11 +100,10 @@ describe("auth controller security", () => {
|
|||
|
||||
describe("PATCH /api/v1/auth/org-members/:memberId/role specific", () => {
|
||||
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", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
...headers,
|
||||
...orgAdminSession.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
|
|
@ -130,31 +129,27 @@ describe("auth controller security", () => {
|
|||
});
|
||||
|
||||
test(`${method} ${path} should return 403 for regular users`, async () => {
|
||||
const { headers } = await createTestSession();
|
||||
const res = await app.request(path, { method, headers });
|
||||
const res = await app.request(path, { method, headers: regularUserSession.headers });
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.message).toBe("Forbidden");
|
||||
});
|
||||
|
||||
test(`${method} ${path} should return 403 for org admins`, async () => {
|
||||
const { headers } = await createTestSessionWithOrgAdmin();
|
||||
const res = await app.request(path, { method, headers });
|
||||
const res = await app.request(path, { method, headers: orgAdminSession.headers });
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.message).toBe("Forbidden");
|
||||
});
|
||||
|
||||
test(`${method} ${path} should not return 401 for global admins`, async () => {
|
||||
const { headers } = await createTestSessionWithGlobalAdmin();
|
||||
const res = await app.request(path, { method, headers });
|
||||
const res = await app.request(path, { method, headers: globalAdminSession.headers });
|
||||
// Should not be 401 - actual response depends on endpoint logic
|
||||
expect(res.status).not.toBe(401);
|
||||
});
|
||||
}
|
||||
|
||||
test("global admins can delete an account for a user outside their active organization", async () => {
|
||||
const { headers } = await createTestSessionWithGlobalAdmin();
|
||||
const target = await createTestSession();
|
||||
|
||||
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}`, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
headers: globalAdminSession.headers,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
|
|
|||
|
|
@ -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 { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
|
||||
import * as mountinfo from "../../../../utils/mountinfo";
|
||||
import { assertMounted } from "../backend-utils";
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("assertMountedFilesystem", () => {
|
||||
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(
|
||||
"Volume is not mounted",
|
||||
|
|
@ -17,8 +36,8 @@ describe("assertMountedFilesystem", () => {
|
|||
});
|
||||
|
||||
test("throws when the mount filesystem does not match", async () => {
|
||||
spyOn(fs, "access").mockResolvedValueOnce(undefined);
|
||||
spyOn(mountinfo, "getMountForPath").mockResolvedValueOnce({
|
||||
vi.mocked(fs.access).mockResolvedValueOnce(undefined);
|
||||
vi.mocked(mountinfo.getMountForPath).mockResolvedValueOnce({
|
||||
mountPoint: "/tmp/volume",
|
||||
fstype: "cifs",
|
||||
});
|
||||
|
|
@ -29,8 +48,8 @@ describe("assertMountedFilesystem", () => {
|
|||
});
|
||||
|
||||
test("accepts a matching mounted filesystem", async () => {
|
||||
spyOn(fs, "access").mockResolvedValueOnce(undefined);
|
||||
spyOn(mountinfo, "getMountForPath").mockResolvedValueOnce({
|
||||
vi.mocked(fs.access).mockResolvedValueOnce(undefined);
|
||||
vi.mocked(mountinfo.getMountForPath).mockResolvedValueOnce({
|
||||
mountPoint: "/tmp/volume",
|
||||
fstype: "nfs4",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { test, describe, expect } from "bun:test";
|
||||
import { beforeAll, describe, expect, test } from "vitest";
|
||||
import { createApp } from "~/server/app";
|
||||
import { createTestSession, getAuthHeaders } from "~/test/helpers/auth";
|
||||
import { createTestVolume } from "~/test/helpers/volume";
|
||||
|
|
@ -7,6 +7,12 @@ import { createTestBackupSchedule } from "~/test/helpers/backup";
|
|||
|
||||
const app = createApp();
|
||||
|
||||
let session: Awaited<ReturnType<typeof createTestSession>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
session = await createTestSession();
|
||||
});
|
||||
|
||||
describe("backups security", () => {
|
||||
test("should return 401 if no session cookie is provided", async () => {
|
||||
const res = await app.request("/api/v1/backups");
|
||||
|
|
@ -25,10 +31,8 @@ describe("backups security", () => {
|
|||
});
|
||||
|
||||
test("should return 200 if session is valid", async () => {
|
||||
const { headers } = await createTestSession();
|
||||
|
||||
const res = await app.request("/api/v1/backups", {
|
||||
headers,
|
||||
headers: session.headers,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
|
@ -82,17 +86,16 @@ describe("backups security", () => {
|
|||
|
||||
describe("input validation", () => {
|
||||
test("should return a schedule when queried by short id", async () => {
|
||||
const { headers, organizationId } = await createTestSession();
|
||||
const volume = await createTestVolume({ organizationId });
|
||||
const repository = await createTestRepository({ organizationId });
|
||||
const volume = await createTestVolume({ organizationId: session.organizationId });
|
||||
const repository = await createTestRepository({ organizationId: session.organizationId });
|
||||
const schedule = await createTestBackupSchedule({
|
||||
organizationId,
|
||||
organizationId: session.organizationId,
|
||||
volumeId: volume.id,
|
||||
repositoryId: repository.id,
|
||||
});
|
||||
|
||||
const res = await app.request(`/api/v1/backups/${schedule.shortId}`, {
|
||||
headers,
|
||||
headers: session.headers,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
|
@ -102,18 +105,16 @@ describe("backups security", () => {
|
|||
});
|
||||
|
||||
test("should return 404 for malformed schedule ID", async () => {
|
||||
const { headers } = await createTestSession();
|
||||
const res = await app.request("/api/v1/backups/not-a-number", {
|
||||
headers,
|
||||
headers: session.headers,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
test("should return 404 for non-existent schedule ID", async () => {
|
||||
const { headers } = await createTestSession();
|
||||
const res = await app.request("/api/v1/backups/999999", {
|
||||
headers,
|
||||
headers: session.headers,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
|
|
@ -122,11 +123,10 @@ describe("backups security", () => {
|
|||
});
|
||||
|
||||
test("should return 400 for invalid payload on create", async () => {
|
||||
const { headers } = await createTestSession();
|
||||
const res = await app.request("/api/v1/backups", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...headers,
|
||||
...session.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { backupsExecutionService } from "../backups.execution";
|
||||
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 { restic } from "~/server/core/restic";
|
||||
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 { repoMutex } from "~/server/core/repository-mutex";
|
||||
|
||||
const setup = () => {
|
||||
const resticBackupMock = mock((_: SafeSpawnParams) =>
|
||||
const resticBackupMock = vi.fn((_: SafeSpawnParams) =>
|
||||
Promise.resolve({ exitCode: 0, summary: generateBackupOutput(), error: "" }),
|
||||
);
|
||||
const resticForgetMock = mock(() => Promise.resolve({ success: true, data: null }));
|
||||
const resticCopyMock = mock(() => Promise.resolve({ success: true, output: "" }));
|
||||
const refreshStatsMock = mock(() =>
|
||||
const resticForgetMock = vi.fn(() => Promise.resolve({ success: true, data: null }));
|
||||
const resticCopyMock = vi.fn(() => Promise.resolve({ success: true, output: "" }));
|
||||
const refreshStatsMock = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
total_size: 0,
|
||||
total_uncompressed_size: 0,
|
||||
|
|
@ -35,11 +33,11 @@ const setup = () => {
|
|||
}),
|
||||
);
|
||||
|
||||
spyOn(spawnModule, "safeSpawn").mockImplementation(resticBackupMock);
|
||||
spyOn(restic, "forget").mockImplementation(resticForgetMock);
|
||||
spyOn(restic, "copy").mockImplementation(resticCopyMock);
|
||||
spyOn(repositoriesService, "refreshRepositoryStats").mockImplementation(refreshStatsMock);
|
||||
spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID);
|
||||
vi.spyOn(spawnModule, "safeSpawn").mockImplementation(resticBackupMock);
|
||||
vi.spyOn(restic, "forget").mockImplementation(resticForgetMock);
|
||||
vi.spyOn(restic, "copy").mockImplementation(resticCopyMock);
|
||||
vi.spyOn(repositoriesService, "refreshRepositoryStats").mockImplementation(refreshStatsMock);
|
||||
vi.spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID);
|
||||
|
||||
return {
|
||||
resticBackupMock,
|
||||
|
|
@ -50,7 +48,7 @@ const setup = () => {
|
|||
};
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("backup execution - validation failures", () => {
|
||||
|
|
@ -76,67 +74,6 @@ describe("backup execution - validation failures", () => {
|
|||
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 () => {
|
||||
setup();
|
||||
// act
|
||||
|
|
@ -261,7 +198,7 @@ describe("stop backup", () => {
|
|||
repositoryId: repository.id,
|
||||
});
|
||||
|
||||
spyOn(repoMutex, "acquireShared").mockImplementation((_repositoryId, _operation, signal) => {
|
||||
vi.spyOn(repoMutex, "acquireShared").mockImplementation((_repositoryId, _operation, signal) => {
|
||||
return new Promise((_, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(signal.reason instanceof Error ? signal.reason : new Error("Operation aborted"));
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { test, describe, expect } from "bun:test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import path from "node:path";
|
||||
import { fromAny } from "@total-typescript/shoehorn";
|
||||
import { createBackupOptions, processPattern } from "../backup.helpers";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { test, describe, expect } from "bun:test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { scheduleQueries } from "../backups.queries";
|
||||
import { createTestBackupSchedule } from "~/test/helpers/backup";
|
||||
import { createTestVolume } from "~/test/helpers/volume";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { backupsService } from "../backups.service";
|
||||
import { createTestVolume } from "~/test/helpers/volume";
|
||||
|
|
@ -16,8 +16,8 @@ import { backupsExecutionService } from "../backups.execution";
|
|||
import { repositoriesService } from "~/server/modules/repositories/repositories.service";
|
||||
|
||||
const setup = () => {
|
||||
const resticBackupMock = mock(() => Promise.resolve({ exitCode: 0, summary: "", error: "" }));
|
||||
const refreshStatsMock = mock(() =>
|
||||
const resticBackupMock = vi.fn(() => Promise.resolve({ exitCode: 0, summary: "", error: "" }));
|
||||
const refreshStatsMock = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
total_size: 0,
|
||||
total_uncompressed_size: 0,
|
||||
|
|
@ -27,9 +27,9 @@ const setup = () => {
|
|||
snapshots_count: 0,
|
||||
}),
|
||||
);
|
||||
spyOn(spawnModule, "safeSpawn").mockImplementation(resticBackupMock);
|
||||
spyOn(repositoriesService, "refreshRepositoryStats").mockImplementation(refreshStatsMock);
|
||||
spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID);
|
||||
vi.spyOn(spawnModule, "safeSpawn").mockImplementation(resticBackupMock);
|
||||
vi.spyOn(repositoriesService, "refreshRepositoryStats").mockImplementation(refreshStatsMock);
|
||||
vi.spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID);
|
||||
|
||||
return {
|
||||
resticBackupMock,
|
||||
|
|
@ -38,7 +38,7 @@ const setup = () => {
|
|||
};
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("execute backup", () => {
|
||||
|
|
@ -110,6 +110,9 @@ describe("execute backup", () => {
|
|||
|
||||
// assert
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { test, describe, expect } from "bun:test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { createApp } from "~/server/app";
|
||||
import { serverEvents } from "~/server/core/events";
|
||||
import { createTestSession, getAuthHeaders } from "~/test/helpers/auth";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { buildCustomShoutrrrUrl } from "../builders/custom";
|
||||
import { buildDiscordShoutrrrUrl } from "../builders/discord";
|
||||
import { buildEmailShoutrrrUrl } from "../builders/email";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { mapNotificationConfigSecrets } from "../notification-config-secrets";
|
||||
|
||||
describe("mapNotificationConfigSecrets", () => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
import { test, describe, expect } from "bun:test";
|
||||
import { beforeAll, describe, expect, test } from "vitest";
|
||||
import { createApp } from "~/server/app";
|
||||
import { createTestSession, getAuthHeaders } from "~/test/helpers/auth";
|
||||
|
||||
const app = createApp();
|
||||
|
||||
let session: Awaited<ReturnType<typeof createTestSession>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
session = await createTestSession();
|
||||
});
|
||||
|
||||
describe("notifications security", () => {
|
||||
test("should return 401 if no session cookie is provided", async () => {
|
||||
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 () => {
|
||||
const { headers } = await createTestSession();
|
||||
|
||||
const res = await app.request("/api/v1/notifications/destinations", {
|
||||
headers,
|
||||
headers: session.headers,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
|
@ -62,18 +66,16 @@ describe("notifications security", () => {
|
|||
|
||||
describe("input validation", () => {
|
||||
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", {
|
||||
headers,
|
||||
headers: session.headers,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
test("should return 404 for non-existent destination ID", async () => {
|
||||
const { headers } = await createTestSession();
|
||||
const res = await app.request("/api/v1/notifications/destinations/999999", {
|
||||
headers,
|
||||
headers: session.headers,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
|
|
@ -82,12 +84,10 @@ describe("notifications security", () => {
|
|||
});
|
||||
|
||||
test("should return 400 for invalid payload on create", async () => {
|
||||
const { headers } = await createTestSession();
|
||||
|
||||
const res = await app.request("/api/v1/notifications/destinations", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...headers,
|
||||
...session.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
|
|
|
|||
|
|
@ -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 os from "node:os";
|
||||
import * as path from "node:path";
|
||||
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 { createTestSession } from "~/test/helpers/auth";
|
||||
import { generateShortId } from "~/server/utils/id";
|
||||
import { provisionedResourcesSchema, readProvisionedResourcesFile, syncProvisionedResources } from "./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(() => {
|
||||
mock.restore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("rejects duplicate ids for the same organization", () => {
|
||||
|
|
@ -46,7 +60,7 @@ describe("provisioning", () => {
|
|||
});
|
||||
|
||||
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";
|
||||
|
||||
|
|
@ -113,7 +127,7 @@ describe("provisioning", () => {
|
|||
});
|
||||
|
||||
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 provisioningPath = path.join(tempDir, "provisioning.json");
|
||||
|
|
@ -196,7 +210,7 @@ describe("provisioning", () => {
|
|||
});
|
||||
|
||||
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 provisioningPath = path.join(tempDir, "provisioning.json");
|
||||
const provisionedVolumeId = "shared-directory";
|
||||
|
|
@ -296,7 +310,7 @@ describe("provisioning", () => {
|
|||
});
|
||||
|
||||
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 provisioningPath = path.join(tempDir, "provisioning.json");
|
||||
|
||||
|
|
@ -344,12 +358,12 @@ describe("provisioning", () => {
|
|||
});
|
||||
|
||||
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 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(
|
||||
provisioningPath,
|
||||
|
|
|
|||
|
|
@ -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 { PassThrough } from "node:stream";
|
||||
import { createApp } from "~/server/app";
|
||||
|
|
@ -7,9 +7,24 @@ import { repositoriesTable } from "~/server/db/schema";
|
|||
import { generateShortId } from "~/server/utils/id";
|
||||
import { createTestSession, getAuthHeaders } from "~/test/helpers/auth";
|
||||
import type { RepositoryConfig } from "@zerobyte/core/restic";
|
||||
import { restic } from "~/server/core/restic";
|
||||
|
||||
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 [repository] = await db
|
||||
.insert(repositoriesTable)
|
||||
|
|
@ -81,10 +96,8 @@ describe("repositories security", () => {
|
|||
});
|
||||
|
||||
test("should return 200 if session is valid", async () => {
|
||||
const { headers } = await createTestSession();
|
||||
|
||||
const res = await app.request("/api/v1/repositories", {
|
||||
headers,
|
||||
headers: session.headers,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
|
@ -150,9 +163,8 @@ describe("repositories security", () => {
|
|||
|
||||
describe("input validation", () => {
|
||||
test("should return 404 for non-existent repository", async () => {
|
||||
const { headers } = await createTestSession();
|
||||
const res = await app.request("/api/v1/repositories/non-existent-repo", {
|
||||
headers,
|
||||
headers: session.headers,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
|
|
@ -161,9 +173,8 @@ describe("repositories security", () => {
|
|||
});
|
||||
|
||||
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", {
|
||||
headers,
|
||||
headers: session.headers,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
|
|
@ -172,10 +183,9 @@ describe("repositories security", () => {
|
|||
});
|
||||
|
||||
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", {
|
||||
method: "POST",
|
||||
headers,
|
||||
headers: session.headers,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
|
|
@ -184,11 +194,10 @@ describe("repositories security", () => {
|
|||
});
|
||||
|
||||
test("should return 400 for invalid payload on create", async () => {
|
||||
const { headers } = await createTestSession();
|
||||
const res = await app.request("/api/v1/repositories", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...headers,
|
||||
...session.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
|
|
@ -200,11 +209,10 @@ describe("repositories security", () => {
|
|||
});
|
||||
|
||||
test("should accept env:// values as plain secrets on create", async () => {
|
||||
const { headers } = await createTestSession();
|
||||
const res = await app.request("/api/v1/repositories", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...headers,
|
||||
...session.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
|
|
@ -227,14 +235,13 @@ describe("repositories security", () => {
|
|||
|
||||
describe("repositories updates", () => {
|
||||
test("PATCH updates full config and metadata using shortId", async () => {
|
||||
const { headers, organizationId } = await createTestSession();
|
||||
const repository = await createRepositoryRecord(organizationId);
|
||||
const repository = await createRepositoryRecord(session.organizationId);
|
||||
const nextPath = `/tmp/updated-${crypto.randomUUID()}`;
|
||||
|
||||
const res = await app.request(`/api/v1/repositories/${repository.shortId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
...headers,
|
||||
...session.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
|
|
@ -276,13 +283,12 @@ describe("repositories updates", () => {
|
|||
});
|
||||
|
||||
test("PATCH rejects backend changes", async () => {
|
||||
const { headers, organizationId } = await createTestSession();
|
||||
const repository = await createRepositoryRecord(organizationId);
|
||||
const repository = await createRepositoryRecord(session.organizationId);
|
||||
|
||||
const res = await app.request(`/api/v1/repositories/${repository.shortId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
...headers,
|
||||
...session.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
|
|
@ -302,13 +308,12 @@ describe("repositories updates", () => {
|
|||
});
|
||||
|
||||
test("PATCH rejects invalid config payload", async () => {
|
||||
const { headers, organizationId } = await createTestSession();
|
||||
const repository = await createRepositoryRecord(organizationId);
|
||||
const repository = await createRepositoryRecord(session.organizationId);
|
||||
|
||||
const res = await app.request(`/api/v1/repositories/${repository.shortId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
...headers,
|
||||
...session.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
|
|
@ -323,20 +328,19 @@ describe("repositories updates", () => {
|
|||
|
||||
describe("delete snapshot", () => {
|
||||
test("should return 500 when restic deleteSnapshot throws ResticError", async () => {
|
||||
const { headers, organizationId } = await createTestSession();
|
||||
const repository = await createRepositoryRecord(organizationId);
|
||||
const repository = await createRepositoryRecord(session.organizationId);
|
||||
|
||||
const { restic } = await import("~/server/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");
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/snap123`, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
headers: session.headers,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
|
|
@ -351,27 +355,29 @@ describe("repositories updates", () => {
|
|||
|
||||
describe("dump snapshot", () => {
|
||||
test("continues streaming a download after the request signal aborts", async () => {
|
||||
const { headers, organizationId } = await createTestSession();
|
||||
const repository = await createRepositoryRecord(organizationId);
|
||||
const { repositoriesService } = await import("~/server/modules/repositories/repositories.service");
|
||||
|
||||
const repository = await createRepositoryRecord(session.organizationId);
|
||||
const stream = new PassThrough();
|
||||
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,
|
||||
completion: Promise.resolve(),
|
||||
abort: () => {
|
||||
stream.destroy(new Error("download aborted"));
|
||||
},
|
||||
filename: "snapshot.txt",
|
||||
contentType: "application/octet-stream",
|
||||
abort: vi.fn(),
|
||||
});
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const response = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/test-snapshot/dump`, {
|
||||
headers,
|
||||
headers: session.headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
|
|
@ -382,48 +388,56 @@ describe("repositories updates", () => {
|
|||
|
||||
await expect(response.text()).resolves.toBe(expectedContent);
|
||||
} finally {
|
||||
dumpSnapshotSpy.mockRestore();
|
||||
snapshotsSpy.mockRestore();
|
||||
dumpSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("returns a valid content-disposition header for non-ascii filenames", async () => {
|
||||
const { headers, organizationId } = await createTestSession();
|
||||
const repository = await createRepositoryRecord(organizationId);
|
||||
const { repositoriesService } = await import("~/server/modules/repositories/repositories.service");
|
||||
const repository = await createRepositoryRecord(session.organizationId);
|
||||
|
||||
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,
|
||||
completion: Promise.resolve(),
|
||||
abort: () => {
|
||||
stream.destroy(new Error("download aborted"));
|
||||
},
|
||||
filename: "möte.txt",
|
||||
contentType: "application/octet-stream",
|
||||
abort: vi.fn(),
|
||||
});
|
||||
|
||||
try {
|
||||
stream.end("downloaded snapshot contents");
|
||||
|
||||
const response = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/test-snapshot/dump`, {
|
||||
headers,
|
||||
});
|
||||
const encodedPath = encodeURIComponent("/mnt/project/möte.txt");
|
||||
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.headers.get("Content-Disposition")).toBe(
|
||||
`attachment; filename="m?te.txt"; filename*=UTF-8''m%C3%B6te.txt`,
|
||||
);
|
||||
} finally {
|
||||
dumpSnapshotSpy.mockRestore();
|
||||
snapshotsSpy.mockRestore();
|
||||
dumpSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("GET marks provisioned repositories as managed", async () => {
|
||||
const { headers, organizationId } = await createTestSession();
|
||||
const repository = await createManagedRepositoryRecord(organizationId);
|
||||
const repository = await createManagedRepositoryRecord(session.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);
|
||||
const body = await res.json();
|
||||
|
|
@ -431,13 +445,12 @@ describe("repositories updates", () => {
|
|||
});
|
||||
|
||||
test("PATCH allows updates for managed repositories", async () => {
|
||||
const { headers, organizationId } = await createTestSession();
|
||||
const repository = await createManagedRepositoryRecord(organizationId);
|
||||
const repository = await createManagedRepositoryRecord(session.organizationId);
|
||||
|
||||
const res = await app.request(`/api/v1/repositories/${repository.shortId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
...headers,
|
||||
...session.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
|
|
@ -451,12 +464,11 @@ describe("repositories updates", () => {
|
|||
});
|
||||
|
||||
test("DELETE allows managed repositories", async () => {
|
||||
const { headers, organizationId } = await createTestSession();
|
||||
const repository = await createManagedRepositoryRecord(organizationId);
|
||||
const repository = await createManagedRepositoryRecord(session.organizationId);
|
||||
|
||||
const res = await app.request(`/api/v1/repositories/${repository.shortId}`, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
headers: session.headers,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import waitForExpect from "wait-for-expect";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import nodePath from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
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 { REPOSITORY_BASE } from "~/server/core/constants";
|
||||
import { serverEvents } from "~/server/core/events";
|
||||
import { withContext } from "~/server/core/request-context";
|
||||
import { db } from "~/server/db/db";
|
||||
import { repositoriesTable } from "~/server/db/schema";
|
||||
|
|
@ -37,25 +37,30 @@ const createTestRepository = async (organizationId: string) => {
|
|||
return repository;
|
||||
};
|
||||
|
||||
let session: Awaited<ReturnType<typeof createTestSession>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
session = await createTestSession();
|
||||
});
|
||||
|
||||
describe("repositoriesService.createRepository", () => {
|
||||
const initMock = mock(() => Promise.resolve({ success: true, error: null }));
|
||||
const initMock = vi.fn(() => Promise.resolve({ success: true, error: null }));
|
||||
|
||||
beforeEach(() => {
|
||||
initMock.mockClear();
|
||||
spyOn(restic, "init").mockImplementation(initMock);
|
||||
vi.spyOn(restic, "init").mockImplementation(initMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("creates a shortId-scoped repository path when using the repository base directory", async () => {
|
||||
// arrange
|
||||
const { organizationId, user } = await createTestSession();
|
||||
const config: RepositoryConfig = { backend: "local", path: REPOSITORY_BASE };
|
||||
|
||||
// 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),
|
||||
);
|
||||
|
||||
|
|
@ -80,12 +85,11 @@ describe("repositoriesService.createRepository", () => {
|
|||
|
||||
test("creates a shortId-scoped repository path when using a custom directory", async () => {
|
||||
// arrange
|
||||
const { organizationId, user } = await createTestSession();
|
||||
const explicitPath = `${REPOSITORY_BASE}/custom-${randomUUID()}`;
|
||||
const config: RepositoryConfig = { backend: "local", path: explicitPath };
|
||||
|
||||
// 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),
|
||||
);
|
||||
|
||||
|
|
@ -109,14 +113,13 @@ describe("repositoriesService.createRepository", () => {
|
|||
|
||||
test("keeps an explicit local repository path unchanged when importing existing repository", async () => {
|
||||
// arrange
|
||||
const { organizationId, user } = await createTestSession();
|
||||
const explicitPath = `${REPOSITORY_BASE}/custom-${randomUUID()}`;
|
||||
const config: RepositoryConfig = { backend: "local", path: explicitPath, isExistingRepository: true };
|
||||
|
||||
spyOn(restic, "snapshots").mockImplementation(() => Promise.resolve([]));
|
||||
vi.spyOn(restic, "snapshots").mockImplementation(() => Promise.resolve([]));
|
||||
|
||||
// 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),
|
||||
);
|
||||
|
||||
|
|
@ -140,14 +143,13 @@ describe("repositoriesService.createRepository", () => {
|
|||
|
||||
describe("repositoriesService repository stats", () => {
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("returns empty stats when repository has not been populated yet", async () => {
|
||||
const { organizationId, user } = await createTestSession();
|
||||
const repository = await createTestRepository(organizationId);
|
||||
const repository = await createTestRepository(session.organizationId);
|
||||
|
||||
const stats = await withContext({ organizationId, userId: user.id }, () =>
|
||||
const stats = await withContext({ organizationId: session.organizationId, userId: session.user.id }, () =>
|
||||
repositoriesService.getRepositoryStats(repository.shortId),
|
||||
);
|
||||
|
||||
|
|
@ -162,8 +164,7 @@ describe("repositoriesService repository stats", () => {
|
|||
});
|
||||
|
||||
test("refreshes and persists repository stats", async () => {
|
||||
const { organizationId, user } = await createTestSession();
|
||||
const repository = await createTestRepository(organizationId);
|
||||
const repository = await createTestRepository(session.organizationId);
|
||||
const expectedStats = {
|
||||
total_size: 1024,
|
||||
total_uncompressed_size: 2048,
|
||||
|
|
@ -173,9 +174,9 @@ describe("repositoriesService repository stats", () => {
|
|||
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),
|
||||
);
|
||||
|
||||
|
|
@ -186,7 +187,7 @@ describe("repositoriesService repository stats", () => {
|
|||
expect(persistedRepository?.stats).toEqual(expectedStats);
|
||||
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),
|
||||
);
|
||||
|
||||
|
|
@ -197,15 +198,24 @@ describe("repositoriesService repository stats", () => {
|
|||
|
||||
describe("repositoriesService.dumpSnapshot", () => {
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const createDumpResult = () => ({
|
||||
stream: Readable.from([]),
|
||||
const createDumpResult = (payload: string) => ({
|
||||
stream: Readable.from([payload]),
|
||||
completion: Promise.resolve(),
|
||||
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 ({
|
||||
snapshotId,
|
||||
basePath,
|
||||
|
|
@ -215,7 +225,7 @@ describe("repositoriesService.dumpSnapshot", () => {
|
|||
basePath: string;
|
||||
snapshotPaths?: string[];
|
||||
}) => {
|
||||
const { organizationId, user } = await createTestSession();
|
||||
const organizationId = session.organizationId;
|
||||
const shortId = generateShortId();
|
||||
|
||||
await db.insert(repositoriesTable).values({
|
||||
|
|
@ -232,7 +242,7 @@ describe("repositoriesService.dumpSnapshot", () => {
|
|||
organizationId,
|
||||
});
|
||||
|
||||
spyOn(restic, "snapshots").mockResolvedValue([
|
||||
vi.spyOn(restic, "snapshots").mockResolvedValue([
|
||||
{
|
||||
id: snapshotId,
|
||||
short_id: snapshotId,
|
||||
|
|
@ -242,53 +252,55 @@ describe("repositoriesService.dumpSnapshot", () => {
|
|||
},
|
||||
]);
|
||||
|
||||
const dumpMock = mock(() => Promise.resolve(createDumpResult()));
|
||||
spyOn(restic, "dump").mockImplementation(dumpMock);
|
||||
const dumpMock = vi.fn((_config: unknown, snapshotRef: string, options: Parameters<typeof restic.dump>[2]) => {
|
||||
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 {
|
||||
organizationId,
|
||||
userId: user.id,
|
||||
userId: session.user.id,
|
||||
shortId,
|
||||
basePath,
|
||||
dumpMock,
|
||||
};
|
||||
};
|
||||
|
||||
test("calls restic.dump with common-ancestor selector and stripped path", async () => {
|
||||
const { organizationId, userId, shortId, basePath, dumpMock } = await setupDumpSnapshotScenario({
|
||||
test("returns a tar download rooted at the selected directory within the snapshot", async () => {
|
||||
const { organizationId, userId, shortId, basePath } = await setupDumpSnapshotScenario({
|
||||
snapshotId: "snapshot-123",
|
||||
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"),
|
||||
);
|
||||
|
||||
expect(dumpMock).toHaveBeenCalledTimes(1);
|
||||
expect(dumpMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
backend: "local",
|
||||
}),
|
||||
`snapshot-123:${basePath}`,
|
||||
{
|
||||
organizationId,
|
||||
path: "/documents",
|
||||
},
|
||||
);
|
||||
expect(emitSpy).toHaveBeenCalledWith(
|
||||
"dump:started",
|
||||
expect.objectContaining({
|
||||
organizationId,
|
||||
repositoryId: shortId,
|
||||
snapshotId: "snapshot-123",
|
||||
expect(result.filename).toBe("snapshot-snapshot-123.tar");
|
||||
expect(result.contentType).toBe("application/x-tar");
|
||||
expect(await readStreamText(result.stream)).toBe(
|
||||
JSON.stringify({
|
||||
snapshotRef: `snapshot-123:${basePath}`,
|
||||
path: "/documents",
|
||||
archive: true,
|
||||
}),
|
||||
);
|
||||
await expect(result.completion).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
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",
|
||||
basePath: "/var/lib/zerobyte/volumes/vol123/_data",
|
||||
});
|
||||
|
|
@ -297,31 +309,38 @@ describe("repositoriesService.dumpSnapshot", () => {
|
|||
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.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 () => {
|
||||
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",
|
||||
basePath: `${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"),
|
||||
);
|
||||
|
||||
expect(dumpMock).toHaveBeenCalledWith(expect.anything(), `snapshot-parent-dir:${parentPath}`, {
|
||||
organizationId,
|
||||
path: "/",
|
||||
});
|
||||
expect(result.filename).toBe("snapshot-snapshot-parent-dir.tar");
|
||||
expect(result.contentType).toBe("application/x-tar");
|
||||
expect(await readStreamText(result.stream)).toBe(
|
||||
JSON.stringify({
|
||||
snapshotRef: `snapshot-parent-dir:${parentPath}`,
|
||||
path: "/",
|
||||
archive: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects path downloads without a kind", async () => {
|
||||
|
|
@ -330,38 +349,45 @@ describe("repositoriesService.dumpSnapshot", () => {
|
|||
basePath: "/var/lib/zerobyte/volumes/vol123/_data",
|
||||
});
|
||||
|
||||
expect(
|
||||
await expect(
|
||||
withContext({ organizationId, userId }, () =>
|
||||
repositoriesService.dumpSnapshot(shortId, "snapshot-no-kind", `${basePath}/documents/report.txt`),
|
||||
),
|
||||
).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 () => {
|
||||
const { organizationId, userId, shortId, basePath, dumpMock } = await setupDumpSnapshotScenario({
|
||||
test("downloads the full snapshot from the common ancestor when path is omitted", async () => {
|
||||
const { organizationId, userId, shortId, basePath } = await setupDumpSnapshotScenario({
|
||||
snapshotId: "snapshot-999",
|
||||
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}`, {
|
||||
organizationId,
|
||||
path: "/",
|
||||
});
|
||||
expect(result.filename).toBe("snapshot-snapshot-999.tar");
|
||||
expect(result.contentType).toBe("application/x-tar");
|
||||
expect(await readStreamText(result.stream)).toBe(
|
||||
JSON.stringify({
|
||||
snapshotRef: `snapshot-999:${basePath}`,
|
||||
path: "/",
|
||||
archive: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("repositoriesService.restoreSnapshot", () => {
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const setupRestoreSnapshotScenario = async () => {
|
||||
const { organizationId, user } = await createTestSession();
|
||||
const organizationId = session.organizationId;
|
||||
const repository = await createTestRepository(organizationId);
|
||||
|
||||
spyOn(restic, "snapshots").mockResolvedValue([
|
||||
vi.spyOn(restic, "snapshots").mockResolvedValue([
|
||||
{
|
||||
id: "snapshot-restore",
|
||||
short_id: "snapshot-restore",
|
||||
|
|
@ -371,7 +397,7 @@ describe("repositoriesService.restoreSnapshot", () => {
|
|||
},
|
||||
]);
|
||||
|
||||
const restoreMock = mock(() =>
|
||||
const restoreMock = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
message_type: "summary" as const,
|
||||
seconds_elapsed: 1,
|
||||
|
|
@ -383,11 +409,11 @@ describe("repositoriesService.restoreSnapshot", () => {
|
|||
bytes_restored: 1,
|
||||
}),
|
||||
);
|
||||
spyOn(restic, "restore").mockImplementation(restoreMock);
|
||||
vi.spyOn(restic, "restore").mockImplementation(restoreMock);
|
||||
|
||||
return {
|
||||
organizationId,
|
||||
userId: user.id,
|
||||
userId: session.user.id,
|
||||
repositoryShortId: repository.shortId,
|
||||
restoreMock,
|
||||
};
|
||||
|
|
@ -434,11 +460,11 @@ describe("repositoriesService.restoreSnapshot", () => {
|
|||
|
||||
describe("repositoriesService.getRetentionCategories", () => {
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
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 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(newSnapshotId));
|
||||
|
||||
const firstCategories = await withContext({ organizationId, userId: user.id }, () =>
|
||||
const firstCategories = await withContext({ organizationId, userId: session.user.id }, () =>
|
||||
repositoriesService.getRetentionCategories(repository.shortId, schedule.shortId),
|
||||
);
|
||||
|
||||
|
|
@ -488,7 +514,7 @@ describe("repositoriesService.getRetentionCategories", () => {
|
|||
|
||||
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),
|
||||
);
|
||||
|
||||
|
|
@ -500,12 +526,11 @@ describe("repositoriesService.getRetentionCategories", () => {
|
|||
|
||||
describe("repositoriesService.deleteSnapshot", () => {
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("refreshes repository stats in background after successful deletion", async () => {
|
||||
const { organizationId, user } = await createTestSession();
|
||||
const repository = await createTestRepository(organizationId);
|
||||
const repository = await createTestRepository(session.organizationId);
|
||||
const expectedStats = {
|
||||
total_size: 128,
|
||||
total_uncompressed_size: 256,
|
||||
|
|
@ -515,16 +540,16 @@ describe("repositoriesService.deleteSnapshot", () => {
|
|||
snapshots_count: 1,
|
||||
};
|
||||
|
||||
spyOn(restic, "deleteSnapshot").mockResolvedValue({ success: true });
|
||||
const statsSpy = spyOn(restic, "stats").mockResolvedValue(expectedStats);
|
||||
vi.spyOn(restic, "deleteSnapshot").mockResolvedValue({ success: true });
|
||||
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"),
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
expect(statsSpy).toHaveBeenCalledTimes(1);
|
||||
await waitForExpect(() => {
|
||||
expect(statsSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const updatedRepository = await db.query.repositoriesTable.findFirst({ where: { id: repository.id } });
|
||||
expect(updatedRepository?.stats).toEqual(expectedStats);
|
||||
|
|
@ -532,15 +557,14 @@ describe("repositoriesService.deleteSnapshot", () => {
|
|||
});
|
||||
|
||||
test("should throw original error when restic deleteSnapshot fails", async () => {
|
||||
const { organizationId, user } = await createTestSession();
|
||||
const repository = await createTestRepository(organizationId);
|
||||
const repository = await createTestRepository(session.organizationId);
|
||||
|
||||
spyOn(restic, "deleteSnapshot").mockImplementation(async () => {
|
||||
vi.spyOn(restic, "deleteSnapshot").mockImplementation(async () => {
|
||||
throw new ResticError(1, "Fatal: unexpected HTTP response (403): 403 Forbidden");
|
||||
});
|
||||
|
||||
expect(
|
||||
withContext({ organizationId, userId: user.id }, () =>
|
||||
await expect(
|
||||
withContext({ organizationId: session.organizationId, userId: session.user.id }, () =>
|
||||
repositoriesService.deleteSnapshot(repository.shortId, "snap123"),
|
||||
),
|
||||
).rejects.toThrow("Fatal: unexpected HTTP response");
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { mapAuthErrorToCode } from "../sso.errors";
|
||||
|
||||
describe("mapAuthErrorToCode", () => {
|
||||
|
|
|
|||
|
|
@ -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 { eq } from "drizzle-orm";
|
||||
import { db } from "~/server/db/db";
|
||||
|
|
|
|||
|
|
@ -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 { config } from "~/server/core/config";
|
||||
import { db } from "~/server/db/db";
|
||||
import { account, invitation, member, organization, ssoProvider, usersTable, verification } from "~/server/db/schema";
|
||||
import { createTestSession, createTestSessionWithOrgAdmin } from "~/test/helpers/auth";
|
||||
|
||||
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) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -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 { config } from "~/server/core/config";
|
||||
import { account, invitation, member, organization, ssoProvider, usersTable } from "~/server/db/schema";
|
||||
import { db } from "~/server/db/db";
|
||||
|
||||
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>) {
|
||||
return app.request(ssoSignInUrl, {
|
||||
|
|
|
|||
|
|
@ -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 { account, invitation, member, organization, sessionsTable, ssoProvider, usersTable } from "~/server/db/schema";
|
||||
import { ssoService } from "../sso.service";
|
||||
|
|
|
|||
|
|
@ -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 { db } from "~/server/db/db";
|
||||
import { account, invitation, member, organization, ssoProvider, usersTable } from "~/server/db/schema";
|
||||
|
|
@ -80,7 +80,7 @@ describe("requireSsoInvitation", () => {
|
|||
await createSsoProvider("oidc-acme");
|
||||
|
||||
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 () => {
|
||||
|
|
@ -97,7 +97,7 @@ describe("requireSsoInvitation", () => {
|
|||
await createSsoProvider("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 () => {
|
||||
|
|
@ -115,7 +115,7 @@ describe("requireSsoInvitation", () => {
|
|||
});
|
||||
|
||||
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 () => {
|
||||
|
|
@ -133,11 +133,11 @@ describe("requireSsoInvitation", () => {
|
|||
});
|
||||
|
||||
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 () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 { db } from "~/server/db/db";
|
||||
import { account, member, organization, ssoProvider, usersTable } from "~/server/db/schema";
|
||||
|
|
|
|||
|
|
@ -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 { validateSsoCallbackUrls } from "../validate-callback-urls";
|
||||
|
||||
|
|
|
|||
|
|
@ -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 { validateSsoProviderId } from "../validate-provider-id";
|
||||
|
||||
|
|
|
|||
|
|
@ -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 { createTestSession, createTestSessionWithGlobalAdmin, getAuthHeaders } from "~/test/helpers/auth";
|
||||
import { systemService } from "../system.service";
|
||||
import * as authHelpers from "~/server/modules/auth/helpers";
|
||||
|
||||
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", () => {
|
||||
test("should return 401 if no session cookie is provided", async () => {
|
||||
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 () => {
|
||||
const { headers } = await createTestSession();
|
||||
|
||||
const res = await app.request("/api/v1/system/info", {
|
||||
headers,
|
||||
headers: session.headers,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
|
@ -53,19 +61,17 @@ describe("system security", () => {
|
|||
|
||||
describe("registration-status endpoint", () => {
|
||||
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 });
|
||||
const res = await app.request("/api/v1/system/registration-status", { headers: session.headers });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(typeof body.enabled).toBe("boolean");
|
||||
});
|
||||
|
||||
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", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
...headers,
|
||||
...session.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
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 () => {
|
||||
const { headers } = await createTestSessionWithGlobalAdmin();
|
||||
const res = await app.request("/api/v1/system/registration-status", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
...headers,
|
||||
...globalAdminSession.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
|
|
@ -91,8 +96,7 @@ describe("system security", () => {
|
|||
|
||||
describe("dev-panel endpoint", () => {
|
||||
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 });
|
||||
const res = await app.request("/api/v1/system/dev-panel", { headers: session.headers });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(typeof body.enabled).toBe("boolean");
|
||||
|
|
@ -101,19 +105,29 @@ describe("system security", () => {
|
|||
|
||||
describe("updates endpoint", () => {
|
||||
test("GET /api/v1/system/updates should be accessible with valid session", async () => {
|
||||
const { headers } = await createTestSession();
|
||||
const res = await app.request("/api/v1/system/updates", { headers });
|
||||
const expectedUpdates = {
|
||||
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(await res.json()).toEqual(expectedUpdates);
|
||||
});
|
||||
});
|
||||
|
||||
describe("input validation", () => {
|
||||
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", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...headers,
|
||||
...session.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
|
|
@ -123,11 +137,12 @@ describe("system security", () => {
|
|||
});
|
||||
|
||||
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", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...headers,
|
||||
...session.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
|
|
|
|||
|
|
@ -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 { volumesTable } from "~/server/db/schema";
|
||||
import { createApp } from "~/server/app";
|
||||
|
|
@ -7,6 +7,12 @@ import { generateShortId } from "~/server/utils/id";
|
|||
|
||||
const app = createApp();
|
||||
|
||||
let session: Awaited<ReturnType<typeof createTestSession>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
session = await createTestSession();
|
||||
});
|
||||
|
||||
const createManagedVolumeRecord = async (organizationId: string) => {
|
||||
const [volume] = await db
|
||||
.insert(volumesTable)
|
||||
|
|
@ -46,10 +52,8 @@ describe("volumes security", () => {
|
|||
});
|
||||
|
||||
test("should return 200 if session is valid", async () => {
|
||||
const { headers } = await createTestSession();
|
||||
|
||||
const res = await app.request("/api/v1/volumes", {
|
||||
headers,
|
||||
headers: session.headers,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
|
@ -91,9 +95,8 @@ describe("volumes security", () => {
|
|||
|
||||
describe("input validation", () => {
|
||||
test("should return 404 for non-existent volume", async () => {
|
||||
const { headers } = await createTestSession();
|
||||
const res = await app.request("/api/v1/volumes/non-existent-volume", {
|
||||
headers,
|
||||
headers: session.headers,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
|
|
@ -102,11 +105,10 @@ describe("volumes security", () => {
|
|||
});
|
||||
|
||||
test("should return 400 for invalid payload on create", async () => {
|
||||
const { headers } = await createTestSession();
|
||||
const res = await app.request("/api/v1/volumes", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...headers,
|
||||
...session.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
|
|
@ -118,10 +120,9 @@ describe("volumes security", () => {
|
|||
});
|
||||
|
||||
test("should mark provisioned volumes as managed", async () => {
|
||||
const { headers, organizationId } = await createTestSession();
|
||||
const volume = await createManagedVolumeRecord(organizationId);
|
||||
const volume = await createManagedVolumeRecord(session.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);
|
||||
const body = await res.json();
|
||||
|
|
@ -129,13 +130,12 @@ describe("volumes security", () => {
|
|||
});
|
||||
|
||||
test("should allow updates for managed volumes", async () => {
|
||||
const { headers, organizationId } = await createTestSession();
|
||||
const volume = await createManagedVolumeRecord(organizationId);
|
||||
const volume = await createManagedVolumeRecord(session.organizationId);
|
||||
|
||||
const res = await app.request(`/api/v1/volumes/${volume.shortId}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
...headers,
|
||||
...session.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
|
|
@ -149,12 +149,11 @@ describe("volumes security", () => {
|
|||
});
|
||||
|
||||
test("should allow deletion for managed volumes", async () => {
|
||||
const { headers, organizationId } = await createTestSession();
|
||||
const volume = await createManagedVolumeRecord(organizationId);
|
||||
const volume = await createManagedVolumeRecord(session.organizationId);
|
||||
|
||||
const res = await app.request(`/api/v1/volumes/${volume.shortId}`, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
headers: session.headers,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { test, describe, expect } from "bun:test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { volumeService } from "../volume.service";
|
||||
import { db } from "~/server/db/db";
|
||||
import { volumesTable } from "~/server/db/schema";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { safeExec, safeSpawn } from "@zerobyte/core/node";
|
||||
|
||||
describe("safeExec", () => {
|
||||
|
|
@ -41,7 +41,7 @@ describe("safeExec", () => {
|
|||
const result = await safeExec({
|
||||
command: "sleep",
|
||||
args: ["10"],
|
||||
timeout: 100,
|
||||
timeout: 20,
|
||||
});
|
||||
|
||||
expect(result.timedOut).toBe(true);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { findCommonAncestor } from "@zerobyte/core/utils";
|
||||
|
||||
describe("findCommonAncestor", () => {
|
||||
|
|
|
|||
25
app/test/helpers/db.ts
Normal file
25
app/test/helpers/db.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
import "./setup.ts";
|
||||
import { GlobalRegistrator } from "@happy-dom/global-registrator";
|
||||
import { afterAll, afterEach, beforeAll, mock } from "bun:test";
|
||||
import "./setup-shared";
|
||||
import { afterAll, afterEach, beforeAll, vi } from "vitest";
|
||||
import { client } from "~/client/api-client/client.gen";
|
||||
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: () => ({
|
||||
theme: "dark",
|
||||
locale: "en-US",
|
||||
|
|
@ -15,8 +14,6 @@ void mock.module("~/client/hooks/use-root-loader-data", () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
GlobalRegistrator.register({ url: "http://localhost:3000" });
|
||||
|
||||
client.setConfig({
|
||||
baseUrl: "http://localhost:3000",
|
||||
credentials: "include",
|
||||
|
|
|
|||
32
app/test/setup-shared.ts
Normal file
32
app/test/setup-shared.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -1,34 +1,5 @@
|
|||
import { beforeAll, mock } from "bun:test";
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||
import path from "node:path";
|
||||
import { cwd } from "node:process";
|
||||
import { db } from "~/server/db/db";
|
||||
import "./setup-shared";
|
||||
|
||||
import * as utils from "@zerobyte/core/node";
|
||||
const { createTestDb } = await import("~/test/helpers/db");
|
||||
|
||||
void mock.module("@zerobyte/core/node", () => {
|
||||
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;");
|
||||
});
|
||||
await createTestDb();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { isPathWithin, normalizeAbsolutePath } from "@zerobyte/core/utils";
|
||||
|
||||
describe("normalizeAbsolutePath", () => {
|
||||
|
|
|
|||
47
bun.lock
47
bun.lock
|
|
@ -110,6 +110,7 @@
|
|||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.1",
|
||||
"vite-bundle-analyzer": "^1.2.3",
|
||||
"vitest": "^4.1.1",
|
||||
"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/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/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/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@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=="],
|
||||
|
|
@ -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=="],
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
|
||||
"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=="],
|
||||
|
|
@ -1263,6 +1286,8 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||
|
||||
"ofetch": ["ofetch@2.0.0-alpha.3", "", {}, "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA=="],
|
||||
|
||||
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
|
||||
|
|
@ -1825,6 +1856,8 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
|
|
@ -1883,12 +1918,16 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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/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/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.4.2", "", { "dependencies": { "@prisma/debug": "7.4.2" } }, "sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw=="],
|
||||
|
|
|
|||
|
|
@ -22,9 +22,9 @@
|
|||
"gen:api-client": "dotenv -e .env.local -- openapi-ts",
|
||||
"gen:migrations": "dotenv -e .env.local -- drizzle-kit generate",
|
||||
"studio": "drizzle-kit studio",
|
||||
"test:server": "dotenv -e .env.test -- bun test app/server --preload ./app/test/setup.ts",
|
||||
"test:client": "dotenv -e .env.test -- bun test app/client --preload ./app/test/setup-client.ts",
|
||||
"test": "bun run test:server && bun run test:client && turbo run test",
|
||||
"test:server": "dotenv -e .env.test -- bunx --bun vitest run --project server",
|
||||
"test:client": "dotenv -e .env.test -- bunx --bun vitest run --project client",
|
||||
"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:ui": "NODE_ENV=test dotenv -e .env.local -- playwright test --ui",
|
||||
"test:codegen": "playwright codegen localhost:4096"
|
||||
|
|
@ -135,6 +135,7 @@
|
|||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.1",
|
||||
"vite-bundle-analyzer": "^1.2.3",
|
||||
"vitest": "^4.1.1",
|
||||
"wait-for-expect": "^4.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
},
|
||||
"scripts": {
|
||||
"tsc": "tsc --noEmit",
|
||||
"test": "bun test --preload ./test/setup.ts"
|
||||
"test": "bunx --bun vitest run --config ./vitest.config.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
|
|
|
|||
|
|
@ -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 spawnModule from "../../../utils/spawn";
|
||||
import { ResticError } from "../../error";
|
||||
|
|
@ -61,8 +61,8 @@ type SetupOptions = {
|
|||
const setup = ({ spawnResult = {}, onSpawnCall }: SetupOptions = {}) => {
|
||||
let capturedArgs: string[] = [];
|
||||
|
||||
const cleanupSpy = spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
|
||||
spyOn(spawnModule, "safeSpawn").mockImplementation((params) => {
|
||||
vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
|
||||
vi.spyOn(spawnModule, "safeSpawn").mockImplementation((params) => {
|
||||
capturedArgs = params.args;
|
||||
return Promise.resolve(onSpawnCall?.(params)).then(() => ({
|
||||
exitCode: 0,
|
||||
|
|
@ -73,7 +73,6 @@ const setup = ({ spawnResult = {}, onSpawnCall }: SetupOptions = {}) => {
|
|||
});
|
||||
|
||||
return {
|
||||
cleanupSpy,
|
||||
getArgs: () => capturedArgs,
|
||||
hasFlag: (flag: string) => capturedArgs.includes(flag),
|
||||
getOptionValues: (option: string): string[] => {
|
||||
|
|
@ -89,7 +88,7 @@ const setup = ({ spawnResult = {}, onSpawnCall }: SetupOptions = {}) => {
|
|||
};
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("backup command", () => {
|
||||
|
|
@ -114,27 +113,11 @@ describe("backup command", () => {
|
|||
expect(getArgs().at(-1)).toBe(source);
|
||||
});
|
||||
|
||||
test("uses --files-from instead of source path when include list is provided", 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 () => {
|
||||
test("writes include paths and patterns to separate files instead of passing the source path directly", async () => {
|
||||
const literalDir = "/mnt/data/movies [1]";
|
||||
let rawIncludeContent = "";
|
||||
let patternIncludeContent = "";
|
||||
const { hasFlag } = setup({
|
||||
const { getArgs, hasFlag } = setup({
|
||||
onSpawnCall: async (params) => {
|
||||
const rawIncludeIndex = params.args.indexOf("--files-from-raw");
|
||||
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")).toBe(true);
|
||||
expect(getArgs()).not.toContain("/mnt/data");
|
||||
expect(rawIncludeContent).toBe(`${literalDir}\0`);
|
||||
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 () => {
|
||||
const { getOptionValues } = setup();
|
||||
await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps);
|
||||
|
||||
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", () => {
|
||||
|
|
@ -427,20 +317,4 @@ describe("backup command", () => {
|
|||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 nodeModule from "../../../node";
|
||||
import { copy } from "../copy";
|
||||
|
|
@ -29,8 +29,8 @@ const destConfig = {
|
|||
const setup = () => {
|
||||
let capturedArgs: string[] = [];
|
||||
|
||||
spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
|
||||
spyOn(nodeModule, "safeExec").mockImplementation(async ({ args }) => {
|
||||
vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
|
||||
vi.spyOn(nodeModule, "safeExec").mockImplementation(async ({ args }) => {
|
||||
capturedArgs = args ?? [];
|
||||
return { exitCode: 0, stdout: "copied", stderr: "", timedOut: false };
|
||||
});
|
||||
|
|
@ -41,7 +41,7 @@ const setup = () => {
|
|||
};
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("copy command", () => {
|
||||
|
|
@ -51,17 +51,8 @@ describe("copy command", () => {
|
|||
|
||||
await copy(sourceConfig, destConfig, { organizationId: "org-1", snapshotId, tag: "daily" }, mockDeps);
|
||||
|
||||
expect(getArgs()).toEqual([
|
||||
"--repo",
|
||||
"/tmp/dest-repo",
|
||||
"copy",
|
||||
"--from-repo",
|
||||
"/tmp/source-repo",
|
||||
"--tag",
|
||||
"daily",
|
||||
"--json",
|
||||
"--",
|
||||
snapshotId,
|
||||
]);
|
||||
const separatorIndex = getArgs().indexOf("--");
|
||||
expect(separatorIndex).toBeGreaterThan(-1);
|
||||
expect(getArgs().slice(separatorIndex + 1)).toEqual([snapshotId]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 nodeModule from "../../../node";
|
||||
import { deleteSnapshots } from "../delete-snapshots";
|
||||
|
|
@ -22,8 +22,8 @@ const config = {
|
|||
const setup = () => {
|
||||
let capturedArgs: string[] = [];
|
||||
|
||||
spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
|
||||
spyOn(nodeModule, "safeExec").mockImplementation(async ({ args }) => {
|
||||
vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
|
||||
vi.spyOn(nodeModule, "safeExec").mockImplementation(async ({ args }) => {
|
||||
capturedArgs = args ?? [];
|
||||
return { exitCode: 0, stdout: "", stderr: "", timedOut: false };
|
||||
});
|
||||
|
|
@ -34,7 +34,7 @@ const setup = () => {
|
|||
};
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("deleteSnapshots command", () => {
|
||||
|
|
@ -44,6 +44,8 @@ describe("deleteSnapshots command", () => {
|
|||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { PassThrough } from "node:stream";
|
||||
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 nodeModule from "../../../node";
|
||||
import { dump } from "../dump";
|
||||
|
|
@ -24,10 +23,10 @@ const config = {
|
|||
const setup = () => {
|
||||
let capturedArgs: string[] = [];
|
||||
|
||||
spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
|
||||
spyOn(nodeModule, "safeSpawn").mockImplementation((params) => {
|
||||
vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
|
||||
vi.spyOn(nodeModule, "safeSpawn").mockImplementation((params) => {
|
||||
capturedArgs = params.args;
|
||||
const child = { stdout: new PassThrough() } as unknown as ReturnType<typeof spawn>;
|
||||
const child = spawn(process.execPath, ["-e", ""]);
|
||||
params.onSpawn?.(child);
|
||||
return Promise.resolve({ exitCode: 0, summary: "", error: "" });
|
||||
});
|
||||
|
|
@ -38,7 +37,7 @@ const setup = () => {
|
|||
};
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("dump command", () => {
|
||||
|
|
@ -48,15 +47,8 @@ describe("dump command", () => {
|
|||
const result = await dump(config, "--help", { organizationId: "org-1", path: "folder/file.txt" }, mockDeps);
|
||||
await result.completion;
|
||||
|
||||
expect(getArgs()).toEqual([
|
||||
"--repo",
|
||||
"/tmp/restic-repo",
|
||||
"dump",
|
||||
"--archive",
|
||||
"tar",
|
||||
"--",
|
||||
"--help",
|
||||
"/folder/file.txt",
|
||||
]);
|
||||
const separatorIndex = getArgs().indexOf("--");
|
||||
expect(separatorIndex).toBeGreaterThan(-1);
|
||||
expect(getArgs().slice(separatorIndex + 1)).toEqual(["--help", "/folder/file.txt"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 spawnModule from "../../../utils/spawn";
|
||||
import { ls } from "../ls";
|
||||
|
|
@ -34,8 +34,8 @@ const snapshotLine = JSON.stringify({
|
|||
const setup = () => {
|
||||
let capturedArgs: string[] = [];
|
||||
|
||||
spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
|
||||
spyOn(spawnModule, "safeSpawn").mockImplementation((params: SafeSpawnParams) => {
|
||||
vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
|
||||
vi.spyOn(spawnModule, "safeSpawn").mockImplementation((params: SafeSpawnParams) => {
|
||||
capturedArgs = params.args;
|
||||
params.onStdout?.(snapshotLine);
|
||||
return Promise.resolve({ exitCode: 0, summary: snapshotLine, error: "" });
|
||||
|
|
@ -47,7 +47,7 @@ const setup = () => {
|
|||
};
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("ls command", () => {
|
||||
|
|
@ -58,6 +58,8 @@ describe("ls command", () => {
|
|||
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 { ResticError } from "../../error";
|
||||
import { restore } from "../restore";
|
||||
import type { ResticDeps } from "../../types";
|
||||
import type { SafeSpawnParams } from "../../../utils/spawn";
|
||||
import type { SafeSpawnParams, SpawnResult } from "../../../utils/spawn";
|
||||
|
||||
const mockDeps: ResticDeps = {
|
||||
resolveSecret: async (s) => s,
|
||||
|
|
@ -14,11 +16,22 @@ const mockDeps: ResticDeps = {
|
|||
|
||||
const successfulRestoreSummary = JSON.stringify({
|
||||
message_type: "summary",
|
||||
total_files: 2,
|
||||
files_restored: 1,
|
||||
files_skipped: 0,
|
||||
files_skipped: 1,
|
||||
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 = {
|
||||
backend: "local" as const,
|
||||
path: "/tmp/restic-repo",
|
||||
|
|
@ -26,16 +39,23 @@ const config = {
|
|||
customPassword: "custom-password",
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets up the safeSpawn mock and returns helpers to inspect the restic args
|
||||
* that were built for the restore command.
|
||||
*/
|
||||
const setup = () => {
|
||||
type SetupOptions = {
|
||||
spawnResult?: Partial<SpawnResult>;
|
||||
onSpawnCall?: (params: SafeSpawnParams) => void | Promise<void>;
|
||||
};
|
||||
|
||||
const setup = ({ spawnResult = {}, onSpawnCall }: SetupOptions = {}) => {
|
||||
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;
|
||||
return Promise.resolve({ exitCode: 0, summary: successfulRestoreSummary, error: "" });
|
||||
return Promise.resolve(onSpawnCall?.(params)).then(() => ({
|
||||
exitCode: 0,
|
||||
summary: successfulRestoreSummary,
|
||||
error: "",
|
||||
...spawnResult,
|
||||
}));
|
||||
});
|
||||
|
||||
const getRestoreArg = () => {
|
||||
|
|
@ -46,7 +66,7 @@ const setup = () => {
|
|||
return capturedArgs[separatorIndex + 1]!;
|
||||
};
|
||||
|
||||
const getOptionValues = (option: string): string[] => {
|
||||
const getOptionValues = (option: string) => {
|
||||
const values: string[] = [];
|
||||
for (let i = 0; i < capturedArgs.length - 1; i++) {
|
||||
if (capturedArgs[i] === option && capturedArgs[i + 1]) {
|
||||
|
|
@ -64,127 +84,172 @@ const setup = () => {
|
|||
};
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("restore command", () => {
|
||||
test("keeps snapshot restore arg and absolute include paths when target is root", async () => {
|
||||
const { getRestoreArg, getOptionValues } = setup();
|
||||
await restore(
|
||||
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,
|
||||
);
|
||||
describe("path selection", () => {
|
||||
test("uses the common ancestor as restore root and strips includes for non-root targets", async () => {
|
||||
const { getRestoreArg, getOptionValues } = setup();
|
||||
|
||||
expect(getRestoreArg()).toBe("snapshot-123");
|
||||
expect(getOptionValues("--include")).toEqual([
|
||||
"/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf",
|
||||
"/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg",
|
||||
]);
|
||||
await restore(
|
||||
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");
|
||||
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 () => {
|
||||
const { getRestoreArg, getOptionValues } = setup();
|
||||
await restore(
|
||||
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,
|
||||
);
|
||||
describe("output handling", () => {
|
||||
test("returns a parsed restore summary on success", async () => {
|
||||
setup();
|
||||
|
||||
expect(getRestoreArg()).toBe("snapshot-456:/var/lib/zerobyte/volumes/vol123/_data");
|
||||
expect(getOptionValues("--include")).toEqual(["Documents/report.pdf", "Photos/summer.jpg"]);
|
||||
const result = await restore(
|
||||
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 () => {
|
||||
const { getRestoreArg, getOptionValues } = setup();
|
||||
await restore(
|
||||
config,
|
||||
"snapshot-789",
|
||||
"/tmp/restore-target",
|
||||
{
|
||||
organizationId: "org-1",
|
||||
basePath: "/var/lib/zerobyte/volumes/vol123/_data",
|
||||
},
|
||||
mockDeps,
|
||||
);
|
||||
describe("progress callbacks", () => {
|
||||
test("calls onProgress with parsed status updates", async () => {
|
||||
const progressUpdates: unknown[] = [];
|
||||
setup({ onSpawnCall: (params) => params.onStdout?.(validProgressLine) });
|
||||
|
||||
expect(getRestoreArg()).toBe("snapshot-789:/var/lib/zerobyte/volumes/vol123/_data");
|
||||
expect(getOptionValues("--include")).toEqual([]);
|
||||
});
|
||||
await restore(
|
||||
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 () => {
|
||||
const { getRestoreArg, getOptionValues } = setup();
|
||||
const options: Parameters<typeof restore>[3] & { selectedItemKind: "file" } = {
|
||||
organizationId: "org-1",
|
||||
include: ["/var/lib/zerobyte/volumes/vol123/_data/archive/backup.20260301-233001.7z"],
|
||||
selectedItemKind: "file",
|
||||
};
|
||||
expect(progressUpdates).toHaveLength(1);
|
||||
expect(progressUpdates[0]).toMatchObject({
|
||||
message_type: "status",
|
||||
percent_done: 0.5,
|
||||
files_restored: 1,
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
expect(getOptionValues("--include")).toEqual(["backup.20260301-233001.7z"]);
|
||||
});
|
||||
await restore(
|
||||
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 () => {
|
||||
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",
|
||||
]);
|
||||
expect(progressUpdates).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 nodeModule from "../../../node";
|
||||
import { tagSnapshots } from "../tag-snapshots";
|
||||
|
|
@ -22,8 +22,8 @@ const config = {
|
|||
const setup = () => {
|
||||
let capturedArgs: string[] = [];
|
||||
|
||||
spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
|
||||
spyOn(nodeModule, "safeExec").mockImplementation(async ({ args }) => {
|
||||
vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
|
||||
vi.spyOn(nodeModule, "safeExec").mockImplementation(async ({ args }) => {
|
||||
capturedArgs = args ?? [];
|
||||
return { exitCode: 0, stdout: "", stderr: "", timedOut: false };
|
||||
});
|
||||
|
|
@ -34,7 +34,7 @@ const setup = () => {
|
|||
};
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("tagSnapshots command", () => {
|
||||
|
|
@ -44,6 +44,8 @@ describe("tagSnapshots command", () => {
|
|||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 type { ResticDeps } from "../../types";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { buildRepoUrl } from "../build-repo-url";
|
||||
|
||||
describe("buildRepoUrl", () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { validateCustomResticParams } from "../validate-custom-params";
|
||||
|
||||
describe("validateCustomResticParams", () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
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";
|
||||
|
||||
const tempDirectories = new Set<string>();
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
|
|
|
|||
14
packages/core/vitest.config.ts
Normal file
14
packages/core/vitest.config.ts
Normal 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"],
|
||||
},
|
||||
});
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
"include": ["app/**/*"],
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"types": ["node", "vite/client"],
|
||||
"types": ["bun", "node", "vite/client"],
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
|
|
|
|||
53
vitest.config.ts
Normal file
53
vitest.config.ts
Normal 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"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue