test: speed up some suites by sharing sessions and mocking expensive non-tested actions
This commit is contained in:
parent
36e8c0de75
commit
0c43320fed
17 changed files with 259 additions and 227 deletions
|
|
@ -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,4 +1,4 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
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,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 { 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 "vitest";
|
||||
import { beforeAll, describe, expect, test } from "vitest";
|
||||
import { createApp } from "~/server/app";
|
||||
import {
|
||||
createTestSession,
|
||||
|
|
@ -13,13 +13,16 @@ 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,4 +1,4 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
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,9 +1,15 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
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,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 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(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
|
@ -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,7 +358,7 @@ 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 = vi.fn(() => Promise.resolve({ success: true, error: null }));
|
||||
|
|
|
|||
|
|
@ -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 { 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,8 +328,7 @@ 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");
|
||||
|
|
@ -336,7 +340,7 @@ describe("repositories updates", () => {
|
|||
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,8 +355,7 @@ 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 repository = await createRepositoryRecord(session.organizationId);
|
||||
const { repositoriesService } = await import("~/server/modules/repositories/repositories.service");
|
||||
|
||||
const stream = new PassThrough();
|
||||
|
|
@ -371,7 +374,7 @@ describe("repositories updates", () => {
|
|||
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,
|
||||
});
|
||||
|
||||
|
|
@ -387,8 +390,7 @@ describe("repositories updates", () => {
|
|||
});
|
||||
|
||||
test("returns a valid content-disposition header for non-ascii filenames", async () => {
|
||||
const { headers, organizationId } = await createTestSession();
|
||||
const repository = await createRepositoryRecord(organizationId);
|
||||
const repository = await createRepositoryRecord(session.organizationId);
|
||||
const { repositoriesService } = await import("~/server/modules/repositories/repositories.service");
|
||||
|
||||
const stream = new PassThrough();
|
||||
|
|
@ -406,7 +408,7 @@ describe("repositories updates", () => {
|
|||
stream.end("downloaded snapshot contents");
|
||||
|
||||
const response = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/test-snapshot/dump`, {
|
||||
headers,
|
||||
headers: session.headers,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
|
@ -420,10 +422,9 @@ describe("repositories updates", () => {
|
|||
});
|
||||
|
||||
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 +432,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 +451,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);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ 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, test, vi } from "vitest";
|
||||
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";
|
||||
|
|
@ -37,6 +37,12 @@ const createTestRepository = async (organizationId: string) => {
|
|||
return repository;
|
||||
};
|
||||
|
||||
let session: Awaited<ReturnType<typeof createTestSession>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
session = await createTestSession();
|
||||
});
|
||||
|
||||
describe("repositoriesService.createRepository", () => {
|
||||
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 () => {
|
||||
// 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 };
|
||||
|
||||
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),
|
||||
);
|
||||
|
||||
|
|
@ -144,10 +147,9 @@ describe("repositoriesService repository stats", () => {
|
|||
});
|
||||
|
||||
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,
|
||||
|
|
@ -175,7 +176,7 @@ describe("repositoriesService repository stats", () => {
|
|||
|
||||
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),
|
||||
);
|
||||
|
||||
|
|
@ -215,7 +216,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({
|
||||
|
|
@ -247,7 +248,7 @@ describe("repositoriesService.dumpSnapshot", () => {
|
|||
|
||||
return {
|
||||
organizationId,
|
||||
userId: user.id,
|
||||
userId: session.user.id,
|
||||
shortId,
|
||||
basePath,
|
||||
dumpMock,
|
||||
|
|
@ -358,7 +359,7 @@ describe("repositoriesService.restoreSnapshot", () => {
|
|||
});
|
||||
|
||||
const setupRestoreSnapshotScenario = async () => {
|
||||
const { organizationId, user } = await createTestSession();
|
||||
const organizationId = session.organizationId;
|
||||
const repository = await createTestRepository(organizationId);
|
||||
|
||||
vi.spyOn(restic, "snapshots").mockResolvedValue([
|
||||
|
|
@ -387,7 +388,7 @@ describe("repositoriesService.restoreSnapshot", () => {
|
|||
|
||||
return {
|
||||
organizationId,
|
||||
userId: user.id,
|
||||
userId: session.user.id,
|
||||
repositoryShortId: repository.shortId,
|
||||
restoreMock,
|
||||
};
|
||||
|
|
@ -438,7 +439,7 @@ describe("repositoriesService.getRetentionCategories", () => {
|
|||
});
|
||||
|
||||
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 } });
|
||||
|
|
@ -480,7 +481,7 @@ describe("repositoriesService.getRetentionCategories", () => {
|
|||
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 +489,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),
|
||||
);
|
||||
|
||||
|
|
@ -504,8 +505,7 @@ describe("repositoriesService.deleteSnapshot", () => {
|
|||
});
|
||||
|
||||
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,
|
||||
|
|
@ -518,7 +518,7 @@ describe("repositoriesService.deleteSnapshot", () => {
|
|||
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"),
|
||||
);
|
||||
|
||||
|
|
@ -532,15 +532,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);
|
||||
|
||||
vi.spyOn(restic, "deleteSnapshot").mockImplementation(async () => {
|
||||
throw new ResticError(1, "Fatal: unexpected HTTP response (403): 403 Forbidden");
|
||||
});
|
||||
|
||||
await expect(
|
||||
withContext({ organizationId, userId: user.id }, () =>
|
||||
withContext({ organizationId: session.organizationId, userId: session.user.id }, () =>
|
||||
repositoriesService.deleteSnapshot(repository.shortId, "snap123"),
|
||||
),
|
||||
).rejects.toThrow("Fatal: unexpected HTTP response");
|
||||
|
|
|
|||
|
|
@ -1,9 +1,19 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
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,24 @@ 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 });
|
||||
vi.spyOn(systemService, "getUpdates").mockResolvedValue({
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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 +132,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 { describe, expect, test } from "vitest";
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ describe("safeExec", () => {
|
|||
const result = await safeExec({
|
||||
command: "sleep",
|
||||
args: ["10"],
|
||||
timeout: 100,
|
||||
timeout: 20,
|
||||
});
|
||||
|
||||
expect(result.timedOut).toBe(true);
|
||||
|
|
|
|||
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,12 +1,5 @@
|
|||
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 migrationsFolder = path.join(cwd(), "app", "drizzle");
|
||||
migrate(db, { migrationsFolder });
|
||||
db.run("PRAGMA foreign_keys = ON;");
|
||||
});
|
||||
const { createTestDb } = await import("~/test/helpers/db");
|
||||
|
||||
await createTestDb();
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
},
|
||||
"scripts": {
|
||||
"tsc": "tsc --noEmit",
|
||||
"test": "bunx vitest run --config ./vitest.config.ts"
|
||||
"test": "bunx --bun vitest run --config ./vitest.config.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ import { defineConfig } from "vitest/config";
|
|||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
maxWorkers: 4,
|
||||
server: {
|
||||
deps: {
|
||||
inline: ["zod"],
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@ export default defineConfig({
|
|||
},
|
||||
},
|
||||
test: {
|
||||
pool: "forks",
|
||||
maxWorkers: 4,
|
||||
server: {
|
||||
deps: {
|
||||
inline: ["zod"],
|
||||
|
|
|
|||
Loading…
Reference in a new issue