diff --git a/app/client/modules/backups/routes/__tests__/edit-backup.test.tsx b/app/client/modules/backups/routes/__tests__/edit-backup.test.tsx index 9cc66b98..3d61bdb2 100644 --- a/app/client/modules/backups/routes/__tests__/edit-backup.test.tsx +++ b/app/client/modules/backups/routes/__tests__/edit-backup.test.tsx @@ -15,27 +15,33 @@ vi.mock("@tanstack/react-router", async (importOriginal) => { import { EditBackupPage } from "../edit-backup"; -afterEach(() => { - navigateMock.mockClear(); - cleanup(); -}); +const repository = { shortId: "repo-1", name: "Repo 1", type: "local" }; +const volume = { + 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>((resolve) => { 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: "0 2 * * *", + enabled, + repository, + volume, + cronExpression, retentionPolicy: null, includePaths: ["/project"], includePatterns: [], @@ -46,17 +52,10 @@ test("submits the computed cron expression when saving a daily schedule", async }); }), 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", () => { - return HttpResponse.json({ - files: [{ name: "project", path: "/project", type: "directory" }], - path: "/", - offset: 0, - limit: 100, - total: 1, - hasMore: false, - }); + return HttpResponse.json(volumeFilesResponse); }), http.patch("/api/v1/backups/:shortId", async ({ request }) => { const body = (await request.json()) as Record; @@ -64,13 +63,8 @@ test("submits the computed cron expression when saving a daily schedule", async 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" }, + volume, + repository, ...body, }); }), @@ -79,6 +73,17 @@ test("submits the computed cron expression when saving a daily schedule", async render(, { 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 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 () => { - const submittedBody = new Promise>((resolve) => { - 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; - 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(, { withSuspense: true }); + const { submittedBody } = renderEditBackupPage({ enabled: true, cronExpression: "*/13 * * * *" }); const nativeFrequencySelect = (await screen.findAllByRole("combobox")) .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 () => { - const submittedBody = new Promise>((resolve) => { - 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; - 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(, { withSuspense: true }); + const { submittedBody } = renderEditBackupPage({ enabled: false, cronExpression: "0 2 * * *" }); await userEvent.click(await screen.findByRole("button", { name: "Update schedule" })); diff --git a/app/server/modules/auth/__tests__/auth.account-and-membership.test.ts b/app/server/modules/auth/__tests__/auth.account-and-membership.test.ts index 541f1979..98dee015 100644 --- a/app/server/modules/auth/__tests__/auth.account-and-membership.test.ts +++ b/app/server/modules/auth/__tests__/auth.account-and-membership.test.ts @@ -1,83 +1,21 @@ import { beforeEach, describe, expect, test } from "vitest"; import { db, sqlite } from "~/server/db/db"; import { account, member, organization, sessionsTable, usersTable } from "~/server/db/schema"; +import { + createMembership, + createOrganization, + createSession, + createUser, + dropTrigger, + escapeSqlLiteral, + randomId, + randomSlug, +} from "~/test/helpers/user-org"; import { authService } from "../auth.service"; const DELETE_USER_ACCOUNT_ROLLBACK_TRIGGER = "delete_user_account_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 }) { const id = randomId(); diff --git a/app/server/modules/auth/__tests__/auth.cleanup-user-organizations.test.ts b/app/server/modules/auth/__tests__/auth.cleanup-user-organizations.test.ts index 6c8f58ad..4d10fc79 100644 --- a/app/server/modules/auth/__tests__/auth.cleanup-user-organizations.test.ts +++ b/app/server/modules/auth/__tests__/auth.cleanup-user-organizations.test.ts @@ -1,90 +1,19 @@ import { beforeEach, describe, expect, test } from "vitest"; import { db, sqlite } from "~/server/db/db"; 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"; 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", () => { beforeEach(async () => { dropTrigger(CLEANUP_USER_ORGS_ROLLBACK_TRIGGER); diff --git a/app/server/modules/notifications/notification-config-secrets.ts b/app/server/modules/notifications/notification-config-secrets.ts index 83ddfb1a..372cd0f1 100644 --- a/app/server/modules/notifications/notification-config-secrets.ts +++ b/app/server/modules/notifications/notification-config-secrets.ts @@ -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"; -type SecretTransformer = (value: string) => Promise; - -const transformOptionalSecret = async (value: string | undefined, transformSecret: SecretTransformer) => { - if (!value) { - return value; - } - - return await transformSecret(value); -}; - export const mapNotificationConfigSecrets = async (config: NotificationConfig, transformSecret: SecretTransformer) => { switch (config.type) { case "email": diff --git a/app/server/modules/repositories/repository-config-secrets.ts b/app/server/modules/repositories/repository-config-secrets.ts index 823ef5cc..d33c9647 100644 --- a/app/server/modules/repositories/repository-config-secrets.ts +++ b/app/server/modules/repositories/repository-config-secrets.ts @@ -1,18 +1,5 @@ import type { RepositoryConfig } from "@zerobyte/core/restic"; -import { cryptoUtils } from "~/server/utils/crypto"; - -type SecretTransformer = (value: string) => Promise; - -const transformOptionalSecret = async ( - value: string | undefined, - transformSecret: SecretTransformer, -): Promise => { - if (!value) { - return value; - } - - return await transformSecret(value); -}; +import { cryptoUtils, transformOptionalSecret, type SecretTransformer } from "~/server/utils/crypto"; export const mapRepositoryConfigSecrets = async ( config: RepositoryConfig, diff --git a/app/server/modules/sso/__tests__/sso.service.test.ts b/app/server/modules/sso/__tests__/sso.service.test.ts index 652bd94c..9ee839df 100644 --- a/app/server/modules/sso/__tests__/sso.service.test.ts +++ b/app/server/modules/sso/__tests__/sso.service.test.ts @@ -1,61 +1,19 @@ import { beforeEach, describe, expect, test } from "vitest"; import { db, sqlite } from "~/server/db/db"; import { account, invitation, member, organization, sessionsTable, ssoProvider, usersTable } from "~/server/db/schema"; +import { + createOrganization, + createSession, + createUser, + dropTrigger, + escapeSqlLiteral, + randomId, + randomSlug, +} from "~/test/helpers/user-org"; import { ssoService } from "../sso.service"; 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", () => { beforeEach(async () => { dropTrigger(DELETE_SSO_PROVIDER_ROLLBACK_TRIGGER); @@ -140,8 +98,8 @@ describe("ssoService.deleteSsoProvider", () => { userId: accountUserB, }, ]); - await createSession(accountUserA); - await createSession(accountUserB); + await createSession({ userId: accountUserA }); + await createSession({ userId: accountUserB }); const deleted = await ssoService.deleteSsoProvider(providerId, org); @@ -225,7 +183,7 @@ describe("ssoService.deleteSsoProvider", () => { providerId, userId: accountUser, }); - await createSession(accountUser); + await createSession({ userId: accountUser }); sqlite.exec(` CREATE TRIGGER ${DELETE_SSO_PROVIDER_ROLLBACK_TRIGGER} diff --git a/app/server/modules/volumes/volume-config-secrets.ts b/app/server/modules/volumes/volume-config-secrets.ts index b8bf25fa..2f9bf1c7 100644 --- a/app/server/modules/volumes/volume-config-secrets.ts +++ b/app/server/modules/volumes/volume-config-secrets.ts @@ -1,18 +1,5 @@ import type { BackendConfig } from "~/schemas/volumes"; -import { cryptoUtils } from "~/server/utils/crypto"; - -type SecretTransformer = (value: string) => Promise; - -const transformOptionalSecret = async ( - value: string | undefined, - transformSecret: SecretTransformer, -): Promise => { - if (!value) { - return value; - } - - return await transformSecret(value); -}; +import { cryptoUtils, transformOptionalSecret, type SecretTransformer } from "~/server/utils/crypto"; export const mapVolumeConfigSecrets = async ( config: BackendConfig, diff --git a/app/server/utils/crypto.ts b/app/server/utils/crypto.ts index a8aa74ad..97e87e85 100644 --- a/app/server/utils/crypto.ts +++ b/app/server/utils/crypto.ts @@ -8,6 +8,19 @@ const algorithm = "aes-256-gcm" as const; const keyLength = 32; const encryptionPrefix = "encv1:"; +export type SecretTransformer = (value: string) => Promise; + +export const transformOptionalSecret = async ( + value: string | undefined, + transformSecret: SecretTransformer, +): Promise => { + if (!value) { + return value; + } + + return await transformSecret(value); +}; + /** * Checks if a given string is encrypted by looking for the encryption prefix. */ @@ -90,11 +103,7 @@ const sealSecret = async (value: string): Promise => { }; const sealOptionalSecret = async (value?: string): Promise => { - if (typeof value === "undefined") { - return value; - } - - return sealSecret(value); + return transformOptionalSecret(value, sealSecret); }; async function deriveSecret(label: string) { diff --git a/app/test/helpers/user-org.ts b/app/test/helpers/user-org.ts new file mode 100644 index 00000000..368655c4 --- /dev/null +++ b/app/test/helpers/user-org.ts @@ -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; +} diff --git a/app/test/setup-shared.ts b/app/test/setup-shared.ts index d9ec50de..b9e12c55 100644 --- a/app/test/setup-shared.ts +++ b/app/test/setup-shared.ts @@ -21,6 +21,7 @@ vi.mock(import("~/server/utils/crypto"), async () => { const cryptoModule = await vi.importActual("~/server/utils/crypto"); return { + ...cryptoModule, cryptoUtils: { ...cryptoModule.cryptoUtils, deriveSecret: async () => "test-secret",