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

This commit is contained in:
Nicolas Meienberger 2026-04-01 19:36:33 +02:00
parent 36e8c0de75
commit 0c43320fed
17 changed files with 259 additions and 227 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
import { describe, expect, test, vi } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import crypto from "node:crypto"; import crypto from "node:crypto";
import { PassThrough } from "node:stream"; import { PassThrough } from "node:stream";
import { createApp } from "~/server/app"; import { createApp } from "~/server/app";
@ -7,9 +7,24 @@ import { repositoriesTable } from "~/server/db/schema";
import { generateShortId } from "~/server/utils/id"; import { generateShortId } from "~/server/utils/id";
import { createTestSession, getAuthHeaders } from "~/test/helpers/auth"; import { createTestSession, getAuthHeaders } from "~/test/helpers/auth";
import type { RepositoryConfig } from "@zerobyte/core/restic"; import type { RepositoryConfig } from "@zerobyte/core/restic";
import { restic } from "~/server/core/restic";
const app = createApp(); const app = createApp();
let session: Awaited<ReturnType<typeof createTestSession>>;
beforeAll(async () => {
session = await createTestSession();
});
beforeEach(() => {
vi.spyOn(restic, "init").mockResolvedValue({ success: true, error: null });
});
afterEach(() => {
vi.restoreAllMocks();
});
const createRepositoryRecord = async (organizationId: string) => { const createRepositoryRecord = async (organizationId: string) => {
const [repository] = await db const [repository] = await db
.insert(repositoriesTable) .insert(repositoriesTable)
@ -81,10 +96,8 @@ describe("repositories security", () => {
}); });
test("should return 200 if session is valid", async () => { test("should return 200 if session is valid", async () => {
const { headers } = await createTestSession();
const res = await app.request("/api/v1/repositories", { const res = await app.request("/api/v1/repositories", {
headers, headers: session.headers,
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
@ -150,9 +163,8 @@ describe("repositories security", () => {
describe("input validation", () => { describe("input validation", () => {
test("should return 404 for non-existent repository", async () => { test("should return 404 for non-existent repository", async () => {
const { headers } = await createTestSession();
const res = await app.request("/api/v1/repositories/non-existent-repo", { const res = await app.request("/api/v1/repositories/non-existent-repo", {
headers, headers: session.headers,
}); });
expect(res.status).toBe(404); expect(res.status).toBe(404);
@ -161,9 +173,8 @@ describe("repositories security", () => {
}); });
test("should return 404 for stats of non-existent repository", async () => { test("should return 404 for stats of non-existent repository", async () => {
const { headers } = await createTestSession();
const res = await app.request("/api/v1/repositories/non-existent-repo/stats", { const res = await app.request("/api/v1/repositories/non-existent-repo/stats", {
headers, headers: session.headers,
}); });
expect(res.status).toBe(404); expect(res.status).toBe(404);
@ -172,10 +183,9 @@ describe("repositories security", () => {
}); });
test("should return 404 for stats refresh of non-existent repository", async () => { test("should return 404 for stats refresh of non-existent repository", async () => {
const { headers } = await createTestSession();
const res = await app.request("/api/v1/repositories/non-existent-repo/stats/refresh", { const res = await app.request("/api/v1/repositories/non-existent-repo/stats/refresh", {
method: "POST", method: "POST",
headers, headers: session.headers,
}); });
expect(res.status).toBe(404); expect(res.status).toBe(404);
@ -184,11 +194,10 @@ describe("repositories security", () => {
}); });
test("should return 400 for invalid payload on create", async () => { test("should return 400 for invalid payload on create", async () => {
const { headers } = await createTestSession();
const res = await app.request("/api/v1/repositories", { const res = await app.request("/api/v1/repositories", {
method: "POST", method: "POST",
headers: { headers: {
...headers, ...session.headers,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
@ -200,11 +209,10 @@ describe("repositories security", () => {
}); });
test("should accept env:// values as plain secrets on create", async () => { test("should accept env:// values as plain secrets on create", async () => {
const { headers } = await createTestSession();
const res = await app.request("/api/v1/repositories", { const res = await app.request("/api/v1/repositories", {
method: "POST", method: "POST",
headers: { headers: {
...headers, ...session.headers,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
@ -227,14 +235,13 @@ describe("repositories security", () => {
describe("repositories updates", () => { describe("repositories updates", () => {
test("PATCH updates full config and metadata using shortId", async () => { test("PATCH updates full config and metadata using shortId", async () => {
const { headers, organizationId } = await createTestSession(); const repository = await createRepositoryRecord(session.organizationId);
const repository = await createRepositoryRecord(organizationId);
const nextPath = `/tmp/updated-${crypto.randomUUID()}`; const nextPath = `/tmp/updated-${crypto.randomUUID()}`;
const res = await app.request(`/api/v1/repositories/${repository.shortId}`, { const res = await app.request(`/api/v1/repositories/${repository.shortId}`, {
method: "PATCH", method: "PATCH",
headers: { headers: {
...headers, ...session.headers,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
@ -276,13 +283,12 @@ describe("repositories updates", () => {
}); });
test("PATCH rejects backend changes", async () => { test("PATCH rejects backend changes", async () => {
const { headers, organizationId } = await createTestSession(); const repository = await createRepositoryRecord(session.organizationId);
const repository = await createRepositoryRecord(organizationId);
const res = await app.request(`/api/v1/repositories/${repository.shortId}`, { const res = await app.request(`/api/v1/repositories/${repository.shortId}`, {
method: "PATCH", method: "PATCH",
headers: { headers: {
...headers, ...session.headers,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
@ -302,13 +308,12 @@ describe("repositories updates", () => {
}); });
test("PATCH rejects invalid config payload", async () => { test("PATCH rejects invalid config payload", async () => {
const { headers, organizationId } = await createTestSession(); const repository = await createRepositoryRecord(session.organizationId);
const repository = await createRepositoryRecord(organizationId);
const res = await app.request(`/api/v1/repositories/${repository.shortId}`, { const res = await app.request(`/api/v1/repositories/${repository.shortId}`, {
method: "PATCH", method: "PATCH",
headers: { headers: {
...headers, ...session.headers,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
@ -323,8 +328,7 @@ describe("repositories updates", () => {
describe("delete snapshot", () => { describe("delete snapshot", () => {
test("should return 500 when restic deleteSnapshot throws ResticError", async () => { test("should return 500 when restic deleteSnapshot throws ResticError", async () => {
const { headers, organizationId } = await createTestSession(); const repository = await createRepositoryRecord(session.organizationId);
const repository = await createRepositoryRecord(organizationId);
const { restic } = await import("~/server/core/restic"); const { restic } = await import("~/server/core/restic");
const { ResticError } = await import("@zerobyte/core/restic"); const { ResticError } = await import("@zerobyte/core/restic");
@ -336,7 +340,7 @@ describe("repositories updates", () => {
try { try {
const res = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/snap123`, { const res = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/snap123`, {
method: "DELETE", method: "DELETE",
headers, headers: session.headers,
}); });
expect(res.status).toBe(500); expect(res.status).toBe(500);
@ -351,8 +355,7 @@ describe("repositories updates", () => {
describe("dump snapshot", () => { describe("dump snapshot", () => {
test("continues streaming a download after the request signal aborts", async () => { test("continues streaming a download after the request signal aborts", async () => {
const { headers, organizationId } = await createTestSession(); const repository = await createRepositoryRecord(session.organizationId);
const repository = await createRepositoryRecord(organizationId);
const { repositoriesService } = await import("~/server/modules/repositories/repositories.service"); const { repositoriesService } = await import("~/server/modules/repositories/repositories.service");
const stream = new PassThrough(); const stream = new PassThrough();
@ -371,7 +374,7 @@ describe("repositories updates", () => {
try { try {
const controller = new AbortController(); const controller = new AbortController();
const response = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/test-snapshot/dump`, { const response = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/test-snapshot/dump`, {
headers, headers: session.headers,
signal: controller.signal, signal: controller.signal,
}); });
@ -387,8 +390,7 @@ describe("repositories updates", () => {
}); });
test("returns a valid content-disposition header for non-ascii filenames", async () => { test("returns a valid content-disposition header for non-ascii filenames", async () => {
const { headers, organizationId } = await createTestSession(); const repository = await createRepositoryRecord(session.organizationId);
const repository = await createRepositoryRecord(organizationId);
const { repositoriesService } = await import("~/server/modules/repositories/repositories.service"); const { repositoriesService } = await import("~/server/modules/repositories/repositories.service");
const stream = new PassThrough(); const stream = new PassThrough();
@ -406,7 +408,7 @@ describe("repositories updates", () => {
stream.end("downloaded snapshot contents"); stream.end("downloaded snapshot contents");
const response = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/test-snapshot/dump`, { const response = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/test-snapshot/dump`, {
headers, headers: session.headers,
}); });
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -420,10 +422,9 @@ describe("repositories updates", () => {
}); });
test("GET marks provisioned repositories as managed", async () => { test("GET marks provisioned repositories as managed", async () => {
const { headers, organizationId } = await createTestSession(); const repository = await createManagedRepositoryRecord(session.organizationId);
const repository = await createManagedRepositoryRecord(organizationId);
const res = await app.request(`/api/v1/repositories/${repository.shortId}`, { headers }); const res = await app.request(`/api/v1/repositories/${repository.shortId}`, { headers: session.headers });
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = await res.json(); const body = await res.json();
@ -431,13 +432,12 @@ describe("repositories updates", () => {
}); });
test("PATCH allows updates for managed repositories", async () => { test("PATCH allows updates for managed repositories", async () => {
const { headers, organizationId } = await createTestSession(); const repository = await createManagedRepositoryRecord(session.organizationId);
const repository = await createManagedRepositoryRecord(organizationId);
const res = await app.request(`/api/v1/repositories/${repository.shortId}`, { const res = await app.request(`/api/v1/repositories/${repository.shortId}`, {
method: "PATCH", method: "PATCH",
headers: { headers: {
...headers, ...session.headers,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
@ -451,12 +451,11 @@ describe("repositories updates", () => {
}); });
test("DELETE allows managed repositories", async () => { test("DELETE allows managed repositories", async () => {
const { headers, organizationId } = await createTestSession(); const repository = await createManagedRepositoryRecord(session.organizationId);
const repository = await createManagedRepositoryRecord(organizationId);
const res = await app.request(`/api/v1/repositories/${repository.shortId}`, { const res = await app.request(`/api/v1/repositories/${repository.shortId}`, {
method: "DELETE", method: "DELETE",
headers, headers: session.headers,
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);

View file

@ -3,7 +3,7 @@ import * as os from "node:os";
import nodePath from "node:path"; import nodePath from "node:path";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import type { RepositoryConfig } from "@zerobyte/core/restic"; import type { RepositoryConfig } from "@zerobyte/core/restic";
import { REPOSITORY_BASE } from "~/server/core/constants"; import { REPOSITORY_BASE } from "~/server/core/constants";
import { serverEvents } from "~/server/core/events"; import { serverEvents } from "~/server/core/events";
@ -37,6 +37,12 @@ const createTestRepository = async (organizationId: string) => {
return repository; return repository;
}; };
let session: Awaited<ReturnType<typeof createTestSession>>;
beforeAll(async () => {
session = await createTestSession();
});
describe("repositoriesService.createRepository", () => { describe("repositoriesService.createRepository", () => {
const initMock = vi.fn(() => Promise.resolve({ success: true, error: null })); const initMock = vi.fn(() => Promise.resolve({ success: true, error: null }));
@ -51,11 +57,10 @@ describe("repositoriesService.createRepository", () => {
test("creates a shortId-scoped repository path when using the repository base directory", async () => { test("creates a shortId-scoped repository path when using the repository base directory", async () => {
// arrange // arrange
const { organizationId, user } = await createTestSession();
const config: RepositoryConfig = { backend: "local", path: REPOSITORY_BASE }; const config: RepositoryConfig = { backend: "local", path: REPOSITORY_BASE };
// act // act
const result = await withContext({ organizationId, userId: user.id }, () => const result = await withContext({ organizationId: session.organizationId, userId: session.user.id }, () =>
repositoriesService.createRepository("main repo", config), repositoriesService.createRepository("main repo", config),
); );
@ -80,12 +85,11 @@ describe("repositoriesService.createRepository", () => {
test("creates a shortId-scoped repository path when using a custom directory", async () => { test("creates a shortId-scoped repository path when using a custom directory", async () => {
// arrange // arrange
const { organizationId, user } = await createTestSession();
const explicitPath = `${REPOSITORY_BASE}/custom-${randomUUID()}`; const explicitPath = `${REPOSITORY_BASE}/custom-${randomUUID()}`;
const config: RepositoryConfig = { backend: "local", path: explicitPath }; const config: RepositoryConfig = { backend: "local", path: explicitPath };
// act // act
const result = await withContext({ organizationId, userId: user.id }, () => const result = await withContext({ organizationId: session.organizationId, userId: session.user.id }, () =>
repositoriesService.createRepository("custom repo", config), repositoriesService.createRepository("custom repo", config),
); );
@ -109,14 +113,13 @@ describe("repositoriesService.createRepository", () => {
test("keeps an explicit local repository path unchanged when importing existing repository", async () => { test("keeps an explicit local repository path unchanged when importing existing repository", async () => {
// arrange // arrange
const { organizationId, user } = await createTestSession();
const explicitPath = `${REPOSITORY_BASE}/custom-${randomUUID()}`; const explicitPath = `${REPOSITORY_BASE}/custom-${randomUUID()}`;
const config: RepositoryConfig = { backend: "local", path: explicitPath, isExistingRepository: true }; const config: RepositoryConfig = { backend: "local", path: explicitPath, isExistingRepository: true };
vi.spyOn(restic, "snapshots").mockImplementation(() => Promise.resolve([])); vi.spyOn(restic, "snapshots").mockImplementation(() => Promise.resolve([]));
// act // act
const result = await withContext({ organizationId, userId: user.id }, () => const result = await withContext({ organizationId: session.organizationId, userId: session.user.id }, () =>
repositoriesService.createRepository("existing repo", config), repositoriesService.createRepository("existing repo", config),
); );
@ -144,10 +147,9 @@ describe("repositoriesService repository stats", () => {
}); });
test("returns empty stats when repository has not been populated yet", async () => { test("returns empty stats when repository has not been populated yet", async () => {
const { organizationId, user } = await createTestSession(); const repository = await createTestRepository(session.organizationId);
const repository = await createTestRepository(organizationId);
const stats = await withContext({ organizationId, userId: user.id }, () => const stats = await withContext({ organizationId: session.organizationId, userId: session.user.id }, () =>
repositoriesService.getRepositoryStats(repository.shortId), repositoriesService.getRepositoryStats(repository.shortId),
); );
@ -162,8 +164,7 @@ describe("repositoriesService repository stats", () => {
}); });
test("refreshes and persists repository stats", async () => { test("refreshes and persists repository stats", async () => {
const { organizationId, user } = await createTestSession(); const repository = await createTestRepository(session.organizationId);
const repository = await createTestRepository(organizationId);
const expectedStats = { const expectedStats = {
total_size: 1024, total_size: 1024,
total_uncompressed_size: 2048, total_uncompressed_size: 2048,
@ -175,7 +176,7 @@ describe("repositoriesService repository stats", () => {
const statsSpy = vi.spyOn(restic, "stats").mockResolvedValue(expectedStats); const statsSpy = vi.spyOn(restic, "stats").mockResolvedValue(expectedStats);
const refreshed = await withContext({ organizationId, userId: user.id }, () => const refreshed = await withContext({ organizationId: session.organizationId, userId: session.user.id }, () =>
repositoriesService.refreshRepositoryStats(repository.shortId), repositoriesService.refreshRepositoryStats(repository.shortId),
); );
@ -186,7 +187,7 @@ describe("repositoriesService repository stats", () => {
expect(persistedRepository?.stats).toEqual(expectedStats); expect(persistedRepository?.stats).toEqual(expectedStats);
expect(typeof persistedRepository?.statsUpdatedAt).toBe("number"); expect(typeof persistedRepository?.statsUpdatedAt).toBe("number");
const loaded = await withContext({ organizationId, userId: user.id }, () => const loaded = await withContext({ organizationId: session.organizationId, userId: session.user.id }, () =>
repositoriesService.getRepositoryStats(repository.shortId), repositoriesService.getRepositoryStats(repository.shortId),
); );
@ -215,7 +216,7 @@ describe("repositoriesService.dumpSnapshot", () => {
basePath: string; basePath: string;
snapshotPaths?: string[]; snapshotPaths?: string[];
}) => { }) => {
const { organizationId, user } = await createTestSession(); const organizationId = session.organizationId;
const shortId = generateShortId(); const shortId = generateShortId();
await db.insert(repositoriesTable).values({ await db.insert(repositoriesTable).values({
@ -247,7 +248,7 @@ describe("repositoriesService.dumpSnapshot", () => {
return { return {
organizationId, organizationId,
userId: user.id, userId: session.user.id,
shortId, shortId,
basePath, basePath,
dumpMock, dumpMock,
@ -358,7 +359,7 @@ describe("repositoriesService.restoreSnapshot", () => {
}); });
const setupRestoreSnapshotScenario = async () => { const setupRestoreSnapshotScenario = async () => {
const { organizationId, user } = await createTestSession(); const organizationId = session.organizationId;
const repository = await createTestRepository(organizationId); const repository = await createTestRepository(organizationId);
vi.spyOn(restic, "snapshots").mockResolvedValue([ vi.spyOn(restic, "snapshots").mockResolvedValue([
@ -387,7 +388,7 @@ describe("repositoriesService.restoreSnapshot", () => {
return { return {
organizationId, organizationId,
userId: user.id, userId: session.user.id,
repositoryShortId: repository.shortId, repositoryShortId: repository.shortId,
restoreMock, restoreMock,
}; };
@ -438,7 +439,7 @@ describe("repositoriesService.getRetentionCategories", () => {
}); });
test("recomputes retention categories after repository cache invalidation", async () => { test("recomputes retention categories after repository cache invalidation", async () => {
const { organizationId, user } = await createTestSession(); const organizationId = session.organizationId;
const schedule = await createTestBackupSchedule({ organizationId, retentionPolicy: { keepLast: 1 } }); const schedule = await createTestBackupSchedule({ organizationId, retentionPolicy: { keepLast: 1 } });
const repository = await db.query.repositoriesTable.findFirst({ where: { id: schedule.repositoryId } }); const repository = await db.query.repositoriesTable.findFirst({ where: { id: schedule.repositoryId } });
@ -480,7 +481,7 @@ describe("repositoriesService.getRetentionCategories", () => {
forgetSpy.mockResolvedValueOnce(buildForgetResponse(oldSnapshotId)); forgetSpy.mockResolvedValueOnce(buildForgetResponse(oldSnapshotId));
forgetSpy.mockResolvedValueOnce(buildForgetResponse(newSnapshotId)); forgetSpy.mockResolvedValueOnce(buildForgetResponse(newSnapshotId));
const firstCategories = await withContext({ organizationId, userId: user.id }, () => const firstCategories = await withContext({ organizationId, userId: session.user.id }, () =>
repositoriesService.getRetentionCategories(repository.shortId, schedule.shortId), repositoriesService.getRetentionCategories(repository.shortId, schedule.shortId),
); );
@ -488,7 +489,7 @@ describe("repositoriesService.getRetentionCategories", () => {
cache.delByPrefix(cacheKeys.repository.all(repository.id)); cache.delByPrefix(cacheKeys.repository.all(repository.id));
const secondCategories = await withContext({ organizationId, userId: user.id }, () => const secondCategories = await withContext({ organizationId, userId: session.user.id }, () =>
repositoriesService.getRetentionCategories(repository.shortId, schedule.shortId), repositoriesService.getRetentionCategories(repository.shortId, schedule.shortId),
); );
@ -504,8 +505,7 @@ describe("repositoriesService.deleteSnapshot", () => {
}); });
test("refreshes repository stats in background after successful deletion", async () => { test("refreshes repository stats in background after successful deletion", async () => {
const { organizationId, user } = await createTestSession(); const repository = await createTestRepository(session.organizationId);
const repository = await createTestRepository(organizationId);
const expectedStats = { const expectedStats = {
total_size: 128, total_size: 128,
total_uncompressed_size: 256, total_uncompressed_size: 256,
@ -518,7 +518,7 @@ describe("repositoriesService.deleteSnapshot", () => {
vi.spyOn(restic, "deleteSnapshot").mockResolvedValue({ success: true }); vi.spyOn(restic, "deleteSnapshot").mockResolvedValue({ success: true });
const statsSpy = vi.spyOn(restic, "stats").mockResolvedValue(expectedStats); const statsSpy = vi.spyOn(restic, "stats").mockResolvedValue(expectedStats);
await withContext({ organizationId, userId: user.id }, () => await withContext({ organizationId: session.organizationId, userId: session.user.id }, () =>
repositoriesService.deleteSnapshot(repository.shortId, "snap-1"), repositoriesService.deleteSnapshot(repository.shortId, "snap-1"),
); );
@ -532,15 +532,14 @@ describe("repositoriesService.deleteSnapshot", () => {
}); });
test("should throw original error when restic deleteSnapshot fails", async () => { test("should throw original error when restic deleteSnapshot fails", async () => {
const { organizationId, user } = await createTestSession(); const repository = await createTestRepository(session.organizationId);
const repository = await createTestRepository(organizationId);
vi.spyOn(restic, "deleteSnapshot").mockImplementation(async () => { vi.spyOn(restic, "deleteSnapshot").mockImplementation(async () => {
throw new ResticError(1, "Fatal: unexpected HTTP response (403): 403 Forbidden"); throw new ResticError(1, "Fatal: unexpected HTTP response (403): 403 Forbidden");
}); });
await expect( await expect(
withContext({ organizationId, userId: user.id }, () => withContext({ organizationId: session.organizationId, userId: session.user.id }, () =>
repositoriesService.deleteSnapshot(repository.shortId, "snap123"), repositoriesService.deleteSnapshot(repository.shortId, "snap123"),
), ),
).rejects.toThrow("Fatal: unexpected HTTP response"); ).rejects.toThrow("Fatal: unexpected HTTP response");

View file

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

View file

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

View file

@ -41,7 +41,7 @@ describe("safeExec", () => {
const result = await safeExec({ const result = await safeExec({
command: "sleep", command: "sleep",
args: ["10"], args: ["10"],
timeout: 100, timeout: 20,
}); });
expect(result.timedOut).toBe(true); expect(result.timedOut).toBe(true);

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

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

View file

@ -1,12 +1,5 @@
import "./setup-shared"; import "./setup-shared";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import path from "node:path";
import { cwd } from "node:process";
import { beforeAll } from "vitest";
import { db } from "~/server/db/db";
beforeAll(async () => { const { createTestDb } = await import("~/test/helpers/db");
const migrationsFolder = path.join(cwd(), "app", "drizzle");
migrate(db, { migrationsFolder }); await createTestDb();
db.run("PRAGMA foreign_keys = ON;");
});

View file

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

View file

@ -3,8 +3,6 @@ import { defineConfig } from "vitest/config";
export default defineConfig({ export default defineConfig({
test: { test: {
environment: "node", environment: "node",
pool: "forks",
maxWorkers: 4,
server: { server: {
deps: { deps: {
inline: ["zod"], inline: ["zod"],

View file

@ -8,8 +8,6 @@ export default defineConfig({
}, },
}, },
test: { test: {
pool: "forks",
maxWorkers: 4,
server: { server: {
deps: { deps: {
inline: ["zod"], inline: ["zod"],