refactor: extract shared test fixtures and secret helpers

This commit is contained in:
Nicolas Meienberger 2026-04-22 22:33:18 +02:00
parent 26203cca59
commit c64862f604
No known key found for this signature in database
10 changed files with 174 additions and 394 deletions

View file

@ -15,27 +15,33 @@ vi.mock("@tanstack/react-router", async (importOriginal) => {
import { EditBackupPage } from "../edit-backup"; import { EditBackupPage } from "../edit-backup";
afterEach(() => { const repository = { shortId: "repo-1", name: "Repo 1", type: "local" };
navigateMock.mockClear(); const volume = {
cleanup(); id: "volume-1",
}); shortId: "vol-1",
name: "Volume 1",
config: { backend: "directory", path: "/mnt" },
};
const volumeFilesResponse = {
files: [{ name: "project", path: "/project", type: "directory" }],
path: "/",
offset: 0,
limit: 100,
total: 1,
hasMore: false,
};
test("submits the computed cron expression when saving a daily schedule", async () => { const renderEditBackupPage = ({ enabled, cronExpression }: { enabled: boolean; cronExpression: string }) => {
const submittedBody = new Promise<Record<string, unknown>>((resolve) => { const submittedBody = new Promise<Record<string, unknown>>((resolve) => {
server.use( server.use(
http.get("/api/v1/backups/:shortId", () => { http.get("/api/v1/backups/:shortId", () => {
return HttpResponse.json({ return HttpResponse.json({
shortId: "backup-1", shortId: "backup-1",
name: "Backup 1", name: "Backup 1",
enabled: true, enabled,
repository: { shortId: "repo-1", name: "Repo 1", type: "local" }, repository,
volume: { volume,
id: "volume-1", cronExpression,
shortId: "vol-1",
name: "Volume 1",
config: { backend: "directory", path: "/mnt" },
},
cronExpression: "0 2 * * *",
retentionPolicy: null, retentionPolicy: null,
includePaths: ["/project"], includePaths: ["/project"],
includePatterns: [], includePatterns: [],
@ -46,17 +52,10 @@ test("submits the computed cron expression when saving a daily schedule", async
}); });
}), }),
http.get("/api/v1/repositories", () => { http.get("/api/v1/repositories", () => {
return HttpResponse.json([{ shortId: "repo-1", name: "Repo 1", type: "local" }]); return HttpResponse.json([repository]);
}), }),
http.get("/api/v1/volumes/:shortId/files", () => { http.get("/api/v1/volumes/:shortId/files", () => {
return HttpResponse.json({ return HttpResponse.json(volumeFilesResponse);
files: [{ name: "project", path: "/project", type: "directory" }],
path: "/",
offset: 0,
limit: 100,
total: 1,
hasMore: false,
});
}), }),
http.patch("/api/v1/backups/:shortId", async ({ request }) => { http.patch("/api/v1/backups/:shortId", async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>; const body = (await request.json()) as Record<string, unknown>;
@ -64,13 +63,8 @@ test("submits the computed cron expression when saving a daily schedule", async
return HttpResponse.json({ return HttpResponse.json({
shortId: "backup-1", shortId: "backup-1",
volume: { volume,
id: "volume-1", repository,
shortId: "vol-1",
name: "Volume 1",
config: { backend: "directory", path: "/mnt" },
},
repository: { shortId: "repo-1", name: "Repo 1", type: "local" },
...body, ...body,
}); });
}), }),
@ -79,6 +73,17 @@ test("submits the computed cron expression when saving a daily schedule", async
render(<EditBackupPage backupId="backup-1" />, { withSuspense: true }); render(<EditBackupPage backupId="backup-1" />, { withSuspense: true });
return { submittedBody };
};
afterEach(() => {
navigateMock.mockClear();
cleanup();
});
test("submits the computed cron expression when saving a daily schedule", async () => {
const { submittedBody } = renderEditBackupPage({ enabled: true, cronExpression: "0 2 * * *" });
await userEvent.click(await screen.findByRole("button", { name: "Update schedule" })); await userEvent.click(await screen.findByRole("button", { name: "Update schedule" }));
await expect(submittedBody).resolves.toMatchObject({ await expect(submittedBody).resolves.toMatchObject({
@ -89,63 +94,7 @@ test("submits the computed cron expression when saving a daily schedule", async
}); });
test("disables the schedule when switching an enabled custom cron schedule to manual only", async () => { test("disables the schedule when switching an enabled custom cron schedule to manual only", async () => {
const submittedBody = new Promise<Record<string, unknown>>((resolve) => { const { submittedBody } = renderEditBackupPage({ enabled: true, cronExpression: "*/13 * * * *" });
server.use(
http.get("/api/v1/backups/:shortId", () => {
return HttpResponse.json({
shortId: "backup-1",
name: "Backup 1",
enabled: true,
repository: { shortId: "repo-1", name: "Repo 1", type: "local" },
volume: {
id: "volume-1",
shortId: "vol-1",
name: "Volume 1",
config: { backend: "directory", path: "/mnt" },
},
cronExpression: "*/13 * * * *",
retentionPolicy: null,
includePaths: ["/project"],
includePatterns: [],
excludePatterns: [],
excludeIfPresent: [],
oneFileSystem: false,
customResticParams: [],
});
}),
http.get("/api/v1/repositories", () => {
return HttpResponse.json([{ shortId: "repo-1", name: "Repo 1", type: "local" }]);
}),
http.get("/api/v1/volumes/:shortId/files", () => {
return HttpResponse.json({
files: [{ name: "project", path: "/project", type: "directory" }],
path: "/",
offset: 0,
limit: 100,
total: 1,
hasMore: false,
});
}),
http.patch("/api/v1/backups/:shortId", async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
resolve(body);
return HttpResponse.json({
shortId: "backup-1",
volume: {
id: "volume-1",
shortId: "vol-1",
name: "Volume 1",
config: { backend: "directory", path: "/mnt" },
},
repository: { shortId: "repo-1", name: "Repo 1", type: "local" },
...body,
});
}),
);
});
render(<EditBackupPage backupId="backup-1" />, { withSuspense: true });
const nativeFrequencySelect = (await screen.findAllByRole("combobox")) const nativeFrequencySelect = (await screen.findAllByRole("combobox"))
.at(1) .at(1)
@ -165,63 +114,7 @@ test("disables the schedule when switching an enabled custom cron schedule to ma
}); });
test("preserves a disabled schedule when saving a non-manual frequency", async () => { test("preserves a disabled schedule when saving a non-manual frequency", async () => {
const submittedBody = new Promise<Record<string, unknown>>((resolve) => { const { submittedBody } = renderEditBackupPage({ enabled: false, cronExpression: "0 2 * * *" });
server.use(
http.get("/api/v1/backups/:shortId", () => {
return HttpResponse.json({
shortId: "backup-1",
name: "Backup 1",
enabled: false,
repository: { shortId: "repo-1", name: "Repo 1", type: "local" },
volume: {
id: "volume-1",
shortId: "vol-1",
name: "Volume 1",
config: { backend: "directory", path: "/mnt" },
},
cronExpression: "0 2 * * *",
retentionPolicy: null,
includePaths: ["/project"],
includePatterns: [],
excludePatterns: [],
excludeIfPresent: [],
oneFileSystem: false,
customResticParams: [],
});
}),
http.get("/api/v1/repositories", () => {
return HttpResponse.json([{ shortId: "repo-1", name: "Repo 1", type: "local" }]);
}),
http.get("/api/v1/volumes/:shortId/files", () => {
return HttpResponse.json({
files: [{ name: "project", path: "/project", type: "directory" }],
path: "/",
offset: 0,
limit: 100,
total: 1,
hasMore: false,
});
}),
http.patch("/api/v1/backups/:shortId", async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
resolve(body);
return HttpResponse.json({
shortId: "backup-1",
volume: {
id: "volume-1",
shortId: "vol-1",
name: "Volume 1",
config: { backend: "directory", path: "/mnt" },
},
repository: { shortId: "repo-1", name: "Repo 1", type: "local" },
...body,
});
}),
);
});
render(<EditBackupPage backupId="backup-1" />, { withSuspense: true });
await userEvent.click(await screen.findByRole("button", { name: "Update schedule" })); await userEvent.click(await screen.findByRole("button", { name: "Update schedule" }));

View file

@ -1,83 +1,21 @@
import { beforeEach, describe, expect, test } from "vitest"; import { beforeEach, describe, expect, test } from "vitest";
import { db, sqlite } from "~/server/db/db"; import { db, sqlite } from "~/server/db/db";
import { account, member, organization, sessionsTable, usersTable } from "~/server/db/schema"; import { account, member, organization, sessionsTable, usersTable } from "~/server/db/schema";
import {
createMembership,
createOrganization,
createSession,
createUser,
dropTrigger,
escapeSqlLiteral,
randomId,
randomSlug,
} from "~/test/helpers/user-org";
import { authService } from "../auth.service"; import { authService } from "../auth.service";
const DELETE_USER_ACCOUNT_ROLLBACK_TRIGGER = "delete_user_account_abort"; const DELETE_USER_ACCOUNT_ROLLBACK_TRIGGER = "delete_user_account_abort";
const REMOVE_ORG_MEMBER_ROLLBACK_TRIGGER = "remove_org_member_reassign_abort"; const REMOVE_ORG_MEMBER_ROLLBACK_TRIGGER = "remove_org_member_reassign_abort";
function randomId() {
return Bun.randomUUIDv7();
}
function randomSlug(prefix: string) {
return `${prefix}-${Math.random().toString(36).slice(2, 8)}`;
}
function escapeSqlLiteral(value: string) {
return value.replaceAll("'", "''");
}
function dropTrigger(name: string) {
sqlite.exec(`DROP TRIGGER IF EXISTS ${name};`);
}
async function createUser(email: string) {
const id = randomId();
await db.insert(usersTable).values({
id,
email,
name: email.split("@")[0],
username: randomSlug("user"),
});
return id;
}
async function createOrganization(name: string) {
const id = randomId();
await db.insert(organization).values({
id,
name,
slug: randomSlug("org"),
createdAt: new Date(),
});
return id;
}
async function createMembership({
userId,
organizationId,
role,
}: {
userId: string;
organizationId: string;
role: "owner" | "admin" | "member";
}) {
const id = randomId();
await db.insert(member).values({ id, userId, organizationId, role, createdAt: new Date() });
return id;
}
async function createSession({
userId,
activeOrganizationId,
}: {
userId: string;
activeOrganizationId: string | null;
}) {
await db.insert(sessionsTable).values({
id: randomId(),
userId,
token: randomSlug("token"),
expiresAt: new Date(Date.now() + 60_000),
activeOrganizationId,
});
}
async function createAccount({ userId, providerId }: { userId: string; providerId: string }) { async function createAccount({ userId, providerId }: { userId: string; providerId: string }) {
const id = randomId(); const id = randomId();

View file

@ -1,90 +1,19 @@
import { beforeEach, describe, expect, test } from "vitest"; import { beforeEach, describe, expect, test } from "vitest";
import { db, sqlite } from "~/server/db/db"; import { db, sqlite } from "~/server/db/db";
import { member, organization, sessionsTable, usersTable } from "~/server/db/schema"; import { member, organization, sessionsTable, usersTable } from "~/server/db/schema";
import {
createMembership,
createOrganization,
createSession,
createUser,
dropTrigger,
escapeSqlLiteral,
randomSlug,
} from "~/test/helpers/user-org";
import { authService } from "../auth.service"; import { authService } from "../auth.service";
const CLEANUP_USER_ORGS_ROLLBACK_TRIGGER = "cleanup_user_orgs_final_session_abort"; const CLEANUP_USER_ORGS_ROLLBACK_TRIGGER = "cleanup_user_orgs_final_session_abort";
function randomId() {
return Bun.randomUUIDv7();
}
function randomSlug(prefix: string) {
return `${prefix}-${Math.random().toString(36).slice(2, 8)}`;
}
function escapeSqlLiteral(value: string) {
return value.replaceAll("'", "''");
}
function dropTrigger(name: string) {
sqlite.exec(`DROP TRIGGER IF EXISTS ${name};`);
}
async function createUser(email: string) {
const id = randomId();
await db.insert(usersTable).values({
id,
email,
name: email.split("@")[0],
username: randomSlug("user"),
});
return id;
}
async function createOrganization(name: string) {
const id = randomId();
await db.insert(organization).values({
id,
name,
slug: randomSlug("org"),
createdAt: new Date(),
});
return id;
}
async function createMembership({
userId,
organizationId,
role,
}: {
userId: string;
organizationId: string;
role: "owner" | "admin" | "member";
}) {
await db.insert(member).values({
id: randomId(),
userId,
organizationId,
role,
createdAt: new Date(),
});
}
async function createSession({
userId,
activeOrganizationId,
}: {
userId: string;
activeOrganizationId: string | null;
}) {
const id = randomId();
await db.insert(sessionsTable).values({
id,
userId,
token: randomSlug("token"),
expiresAt: new Date(Date.now() + 60_000),
activeOrganizationId,
});
return id;
}
describe("authService.cleanupUserOrganizations", () => { describe("authService.cleanupUserOrganizations", () => {
beforeEach(async () => { beforeEach(async () => {
dropTrigger(CLEANUP_USER_ORGS_ROLLBACK_TRIGGER); dropTrigger(CLEANUP_USER_ORGS_ROLLBACK_TRIGGER);

View file

@ -1,16 +1,6 @@
import { cryptoUtils } from "~/server/utils/crypto"; import { cryptoUtils, transformOptionalSecret, type SecretTransformer } from "~/server/utils/crypto";
import type { NotificationConfig } from "~/schemas/notifications"; import type { NotificationConfig } from "~/schemas/notifications";
type SecretTransformer = (value: string) => Promise<string>;
const transformOptionalSecret = async (value: string | undefined, transformSecret: SecretTransformer) => {
if (!value) {
return value;
}
return await transformSecret(value);
};
export const mapNotificationConfigSecrets = async (config: NotificationConfig, transformSecret: SecretTransformer) => { export const mapNotificationConfigSecrets = async (config: NotificationConfig, transformSecret: SecretTransformer) => {
switch (config.type) { switch (config.type) {
case "email": case "email":

View file

@ -1,18 +1,5 @@
import type { RepositoryConfig } from "@zerobyte/core/restic"; import type { RepositoryConfig } from "@zerobyte/core/restic";
import { cryptoUtils } from "~/server/utils/crypto"; import { cryptoUtils, transformOptionalSecret, type SecretTransformer } from "~/server/utils/crypto";
type SecretTransformer = (value: string) => Promise<string>;
const transformOptionalSecret = async (
value: string | undefined,
transformSecret: SecretTransformer,
): Promise<string | undefined> => {
if (!value) {
return value;
}
return await transformSecret(value);
};
export const mapRepositoryConfigSecrets = async ( export const mapRepositoryConfigSecrets = async (
config: RepositoryConfig, config: RepositoryConfig,

View file

@ -1,61 +1,19 @@
import { beforeEach, describe, expect, test } from "vitest"; import { beforeEach, describe, expect, test } from "vitest";
import { db, sqlite } from "~/server/db/db"; import { db, sqlite } from "~/server/db/db";
import { account, invitation, member, organization, sessionsTable, ssoProvider, usersTable } from "~/server/db/schema"; import { account, invitation, member, organization, sessionsTable, ssoProvider, usersTable } from "~/server/db/schema";
import {
createOrganization,
createSession,
createUser,
dropTrigger,
escapeSqlLiteral,
randomId,
randomSlug,
} from "~/test/helpers/user-org";
import { ssoService } from "../sso.service"; import { ssoService } from "../sso.service";
const DELETE_SSO_PROVIDER_ROLLBACK_TRIGGER = "delete_sso_provider_sessions_abort"; const DELETE_SSO_PROVIDER_ROLLBACK_TRIGGER = "delete_sso_provider_sessions_abort";
function randomId() {
return Bun.randomUUIDv7();
}
function randomSlug(prefix: string) {
return `${prefix}-${Math.random().toString(36).slice(2, 8)}`;
}
function escapeSqlLiteral(value: string) {
return value.replaceAll("'", "''");
}
function dropTrigger(name: string) {
sqlite.exec(`DROP TRIGGER IF EXISTS ${name};`);
}
async function createUser(email: string) {
const id = randomId();
await db.insert(usersTable).values({
id,
email,
name: email.split("@")[0],
username: randomSlug("user"),
});
return id;
}
async function createOrganization(name: string) {
const id = randomId();
await db.insert(organization).values({
id,
name,
slug: randomSlug("org"),
createdAt: new Date(),
});
return id;
}
async function createSession(userId: string) {
await db.insert(sessionsTable).values({
id: randomId(),
userId,
token: randomSlug("token"),
expiresAt: new Date(Date.now() + 60_000),
});
}
describe("ssoService.deleteSsoProvider", () => { describe("ssoService.deleteSsoProvider", () => {
beforeEach(async () => { beforeEach(async () => {
dropTrigger(DELETE_SSO_PROVIDER_ROLLBACK_TRIGGER); dropTrigger(DELETE_SSO_PROVIDER_ROLLBACK_TRIGGER);
@ -140,8 +98,8 @@ describe("ssoService.deleteSsoProvider", () => {
userId: accountUserB, userId: accountUserB,
}, },
]); ]);
await createSession(accountUserA); await createSession({ userId: accountUserA });
await createSession(accountUserB); await createSession({ userId: accountUserB });
const deleted = await ssoService.deleteSsoProvider(providerId, org); const deleted = await ssoService.deleteSsoProvider(providerId, org);
@ -225,7 +183,7 @@ describe("ssoService.deleteSsoProvider", () => {
providerId, providerId,
userId: accountUser, userId: accountUser,
}); });
await createSession(accountUser); await createSession({ userId: accountUser });
sqlite.exec(` sqlite.exec(`
CREATE TRIGGER ${DELETE_SSO_PROVIDER_ROLLBACK_TRIGGER} CREATE TRIGGER ${DELETE_SSO_PROVIDER_ROLLBACK_TRIGGER}

View file

@ -1,18 +1,5 @@
import type { BackendConfig } from "~/schemas/volumes"; import type { BackendConfig } from "~/schemas/volumes";
import { cryptoUtils } from "~/server/utils/crypto"; import { cryptoUtils, transformOptionalSecret, type SecretTransformer } from "~/server/utils/crypto";
type SecretTransformer = (value: string) => Promise<string>;
const transformOptionalSecret = async (
value: string | undefined,
transformSecret: SecretTransformer,
): Promise<string | undefined> => {
if (!value) {
return value;
}
return await transformSecret(value);
};
export const mapVolumeConfigSecrets = async ( export const mapVolumeConfigSecrets = async (
config: BackendConfig, config: BackendConfig,

View file

@ -8,6 +8,19 @@ const algorithm = "aes-256-gcm" as const;
const keyLength = 32; const keyLength = 32;
const encryptionPrefix = "encv1:"; const encryptionPrefix = "encv1:";
export type SecretTransformer = (value: string) => Promise<string>;
export const transformOptionalSecret = async (
value: string | undefined,
transformSecret: SecretTransformer,
): Promise<string | undefined> => {
if (!value) {
return value;
}
return await transformSecret(value);
};
/** /**
* Checks if a given string is encrypted by looking for the encryption prefix. * Checks if a given string is encrypted by looking for the encryption prefix.
*/ */
@ -90,11 +103,7 @@ const sealSecret = async (value: string): Promise<string> => {
}; };
const sealOptionalSecret = async (value?: string): Promise<string | undefined> => { const sealOptionalSecret = async (value?: string): Promise<string | undefined> => {
if (typeof value === "undefined") { return transformOptionalSecret(value, sealSecret);
return value;
}
return sealSecret(value);
}; };
async function deriveSecret(label: string) { async function deriveSecret(label: string) {

View file

@ -0,0 +1,88 @@
import { db, sqlite } from "~/server/db/db";
import { member, organization, sessionsTable, usersTable } from "~/server/db/schema";
type MembershipRole = "owner" | "admin" | "member";
export function randomId() {
return Bun.randomUUIDv7();
}
export function randomSlug(prefix: string) {
return `${prefix}-${Math.random().toString(36).slice(2, 8)}`;
}
export function escapeSqlLiteral(value: string) {
return value.replaceAll("'", "''");
}
export function dropTrigger(name: string) {
sqlite.exec(`DROP TRIGGER IF EXISTS ${name};`);
}
export async function createUser(email: string) {
const id = randomId();
await db.insert(usersTable).values({
id,
email,
name: email.split("@")[0],
username: randomSlug("user"),
});
return id;
}
export async function createOrganization(name: string) {
const id = randomId();
await db.insert(organization).values({
id,
name,
slug: randomSlug("org"),
createdAt: new Date(),
});
return id;
}
export async function createMembership({
userId,
organizationId,
role,
}: {
userId: string;
organizationId: string;
role: MembershipRole;
}) {
const id = randomId();
await db.insert(member).values({
id,
userId,
organizationId,
role,
createdAt: new Date(),
});
return id;
}
export async function createSession({
userId,
activeOrganizationId = null,
}: {
userId: string;
activeOrganizationId?: string | null;
}) {
const id = randomId();
await db.insert(sessionsTable).values({
id,
userId,
token: randomSlug("token"),
expiresAt: new Date(Date.now() + 60_000),
activeOrganizationId,
});
return id;
}

View file

@ -21,6 +21,7 @@ vi.mock(import("~/server/utils/crypto"), async () => {
const cryptoModule = await vi.importActual<typeof import("~/server/utils/crypto")>("~/server/utils/crypto"); const cryptoModule = await vi.importActual<typeof import("~/server/utils/crypto")>("~/server/utils/crypto");
return { return {
...cryptoModule,
cryptoUtils: { cryptoUtils: {
...cryptoModule.cryptoUtils, ...cryptoModule.cryptoUtils,
deriveSecret: async () => "test-secret", deriveSecret: async () => "test-secret",