From f070a0cb0aa0fcc82af1cd0e7fdb3005704c7cde Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 10 Feb 2024 19:00:08 +0100 Subject: [PATCH] test: add initial unit tests (#56) * chore: add initial db migration * test: add unit tests for packages auth, common, widgets * fix: deep source issues * fix: format issues * wip: add unit tests for api routers * fix: deep source issues * test: add missing unit tests for integration router * wip: board tests * test: add unit tests for board router * fix: remove unnecessary null assertions * fix: deepsource issues * fix: formatting * fix: pnpm lock * fix: lint and typecheck issues * chore: address pull request feedback * fix: non-null assertions * fix: lockfile broken --- .../boards/[name]/@headeractions/page.tsx | 8 +- .../boards/[name]/settings/_general.tsx | 7 +- package.json | 3 + packages/api/src/router/board.ts | 53 +- packages/api/src/router/integration.ts | 74 +- packages/api/src/router/test/board.spec.ts | 648 ++++++++++++++++ .../api/src/router/test/integration.spec.ts | 503 +++++++++++++ packages/api/src/router/test/user.spec.ts | 94 +++ packages/auth/callbacks.ts | 61 ++ packages/auth/configuration.ts | 52 +- packages/auth/package.json | 6 + packages/auth/providers/credentials.ts | 85 ++- .../auth/providers/test/credentials.spec.ts | 66 ++ packages/auth/session.ts | 3 + packages/auth/test/callbacks.spec.ts | 153 ++++ packages/auth/test/security.spec.ts | 47 ++ packages/auth/test/session.spec.ts | 43 ++ packages/common/src/test/object.spec.ts | 24 +- packages/db/drizzle.config.ts | 1 + packages/db/index.ts | 2 +- packages/db/migrations/0000_true_red_wolf.sql | 112 +++ .../db/migrations/meta/0000_snapshot.json | 696 ++++++++++++++++++ packages/db/migrations/meta/_journal.json | 13 + packages/db/package.json | 4 +- packages/db/test/db-mock.ts | 14 + packages/db/test/index.ts | 1 + .../definitions/src/test/integration.spec.ts | 14 + packages/translation/src/index.ts | 1 + packages/validation/src/board.ts | 3 +- packages/widgets/src/test/translation.spec.ts | 42 ++ pnpm-lock.yaml | 297 +++++++- tooling/typescript/base.json | 8 +- vitest.config.ts | 2 + vitest.setup.ts | 3 + 34 files changed, 3014 insertions(+), 129 deletions(-) create mode 100644 packages/api/src/router/test/board.spec.ts create mode 100644 packages/api/src/router/test/integration.spec.ts create mode 100644 packages/api/src/router/test/user.spec.ts create mode 100644 packages/auth/callbacks.ts create mode 100644 packages/auth/providers/test/credentials.spec.ts create mode 100644 packages/auth/test/callbacks.spec.ts create mode 100644 packages/auth/test/security.spec.ts create mode 100644 packages/auth/test/session.spec.ts create mode 100644 packages/db/migrations/0000_true_red_wolf.sql create mode 100644 packages/db/migrations/meta/0000_snapshot.json create mode 100644 packages/db/migrations/meta/_journal.json create mode 100644 packages/db/test/db-mock.ts create mode 100644 packages/db/test/index.ts create mode 100644 packages/definitions/src/test/integration.spec.ts create mode 100644 packages/widgets/src/test/translation.spec.ts create mode 100644 vitest.setup.ts diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/@headeractions/page.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/@headeractions/page.tsx index 4388f9c7a..ae4d5cfa1 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/@headeractions/page.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/@headeractions/page.tsx @@ -108,7 +108,7 @@ const EditModeMenu = () => { const [isEditMode, setEditMode] = useAtom(editModeAtom); const board = useRequiredBoard(); const t = useScopedI18n("board.action.edit"); - const { mutate, isPending } = clientApi.board.save.useMutation({ + const { mutate: saveBoard, isPending } = clientApi.board.save.useMutation({ onSuccess() { showSuccessNotification({ title: t("notification.success.title"), @@ -125,7 +125,11 @@ const EditModeMenu = () => { }); const toggle = () => { - if (isEditMode) return mutate(board); + if (isEditMode) + return saveBoard({ + boardId: board.id, + ...board, + }); setEditMode(true); }; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx index 57c589dcd..ae9b65e9d 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx @@ -22,7 +22,7 @@ interface Props { export const GeneralSettingsContent = ({ board }: Props) => { const t = useI18n(); const { updateBoard } = useUpdateBoard(); - const { mutate, isPending } = + const { mutate: saveGeneralSettings, isPending } = clientApi.board.saveGeneralSettings.useMutation(); const form = useForm({ initialValues: { @@ -46,7 +46,10 @@ export const GeneralSettingsContent = ({ board }: Props) => { return (
{ - mutate(values); + saveGeneralSettings({ + boardId: board.id, + ...values, + }); })} > diff --git a/package.json b/package.json index 8891b0530..99b4b20fd 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "clean:workspaces": "turbo clean", "db:push": "pnpm -F db push", "db:studio": "pnpm -F db studio", + "db:migration:generate": "pnpm -F db migration:generate", "dev": "node start.js", "format": "turbo format --continue -- --cache --cache-location node_modules/.cache/.prettiercache", "format:fix": "turbo format --continue -- --write --cache --cache-location node_modules/.cache/.prettiercache", @@ -24,11 +25,13 @@ }, "devDependencies": { "@homarr/prettier-config": "workspace:^0.1.0", + "@testing-library/react-hooks": "^8.0.1", "@turbo/gen": "^1.12.3", "@vitejs/plugin-react": "^4.2.1", "@vitest/coverage-v8": "^1.2.2", "@vitest/ui": "^1.2.2", "cross-env": "^7.0.3", + "jsdom": "^24.0.0", "prettier": "^3.2.5", "turbo": "^1.12.3", "typescript": "^5.3.3", diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 246b78201..a0eeb6f59 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -1,8 +1,8 @@ import { TRPCError } from "@trpc/server"; import superjson from "superjson"; -import type { Database } from "@homarr/db"; -import { and, createId, db, eq, inArray } from "@homarr/db"; +import type { Database, SQL } from "@homarr/db"; +import { and, createId, eq, inArray } from "@homarr/db"; import { boards, integrationItems, @@ -47,8 +47,8 @@ const filterUpdatedItems = ( ); export const boardRouter = createTRPCRouter({ - getAll: publicProcedure.query(async () => { - return await db.query.boards.findMany({ + getAll: publicProcedure.query(async ({ ctx }) => { + return await ctx.db.query.boards.findMany({ columns: { id: true, name: true, @@ -85,23 +85,45 @@ export const boardRouter = createTRPCRouter({ await ctx.db.delete(boards).where(eq(boards.id, input.id)); }), default: publicProcedure.query(async ({ ctx }) => { - return await getFullBoardByName(ctx.db, "default"); + return await getFullBoardWithWhere(ctx.db, eq(boards.name, "default")); }), byName: publicProcedure .input(validation.board.byName) .query(async ({ input, ctx }) => { - return await getFullBoardByName(ctx.db, input.name); + return await getFullBoardWithWhere(ctx.db, eq(boards.name, input.name)); }), saveGeneralSettings: publicProcedure .input(validation.board.saveGeneralSettings) - .mutation(async ({ input }) => { - await db.update(boards).set(input).where(eq(boards.name, "default")); + .mutation(async ({ ctx, input }) => { + const board = await ctx.db.query.boards.findFirst({ + where: eq(boards.id, input.boardId), + }); + + if (!board) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Board not found", + }); + } + + await ctx.db + .update(boards) + .set({ + pageTitle: input.pageTitle, + metaTitle: input.metaTitle, + logoImageUrl: input.logoImageUrl, + faviconImageUrl: input.faviconImageUrl, + }) + .where(eq(boards.id, input.boardId)); }), save: publicProcedure .input(validation.board.save) .mutation(async ({ input, ctx }) => { await ctx.db.transaction(async (tx) => { - const dbBoard = await getFullBoardByName(tx, input.name); + const dbBoard = await getFullBoardWithWhere( + tx, + eq(boards.id, input.boardId), + ); const addedSections = filterAddedItems( input.sections, @@ -199,12 +221,17 @@ export const boardRouter = createTRPCRouter({ ); for (const section of updatedSections) { + const prev = dbBoard.sections.find( + (dbSection) => dbSection.id === section.id, + ); await tx .update(sections) .set({ - kind: section.kind, position: section.position, - name: "name" in section ? section.name : null, + name: + prev?.kind === "category" && "name" in section + ? section.name + : null, }) .where(eq(sections.id, section.id)); } @@ -249,9 +276,9 @@ export const boardRouter = createTRPCRouter({ }), }); -const getFullBoardByName = async (db: Database, name: string) => { +const getFullBoardWithWhere = async (db: Database, where: SQL) => { const board = await db.query.boards.findFirst({ - where: eq(boards.name, name), + where, with: { sections: { with: { diff --git a/packages/api/src/router/integration.ts b/packages/api/src/router/integration.ts index 16018aec3..1be00335a 100644 --- a/packages/api/src/router/integration.ts +++ b/packages/api/src/router/integration.ts @@ -1,6 +1,7 @@ import crypto from "crypto"; import { TRPCError } from "@trpc/server"; +import type { Database } from "@homarr/db"; import { and, createId, eq } from "@homarr/db"; import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite"; import type { IntegrationSecretKind } from "@homarr/definitions"; @@ -128,18 +129,20 @@ export const integrationRouter = createTRPCRouter({ if (changedSecrets.length > 0) { for (const changedSecret of changedSecrets) { - await ctx.db - .update(integrationSecrets) - .set({ - value: encryptSecret(changedSecret.value), - updatedAt: new Date(), - }) - .where( - and( - eq(integrationSecrets.integrationId, input.id), - eq(integrationSecrets.kind, changedSecret.kind), - ), - ); + const secretInput = { + integrationId: input.id, + value: changedSecret.value, + kind: changedSecret.kind, + }; + if ( + !decryptedSecrets.some( + (secret) => secret.kind === changedSecret.kind, + ) + ) { + await addSecret(ctx.db, secretInput); + } else { + await updateSecret(ctx.db, secretInput); + } } } }), @@ -204,6 +207,17 @@ export const integrationRouter = createTRPCRouter({ }); } } + + const everySecretDefined = secretKinds.every((secretKind) => + secrets.some((secret) => secret.kind === secretKind), + ); + + if (!everySecretDefined) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "SECRETS_NOT_DEFINED", + }); + } } // TODO: actually test the connection @@ -223,7 +237,7 @@ const key = Buffer.from( ); // TODO: generate with const data = crypto.randomBytes(32).toString('hex') //Encrypting text -function encryptSecret(text: string): `${string}.${string}` { +export function encryptSecret(text: string): `${string}.${string}` { const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv(algorithm, Buffer.from(key), iv); let encrypted = cipher.update(text); @@ -241,3 +255,37 @@ function decryptSecret(value: `${string}.${string}`) { decrypted = Buffer.concat([decrypted, decipher.final()]); return decrypted.toString(); } + +interface UpdateSecretInput { + integrationId: string; + value: string; + kind: IntegrationSecretKind; +} +const updateSecret = async (db: Database, input: UpdateSecretInput) => { + await db + .update(integrationSecrets) + .set({ + value: encryptSecret(input.value), + updatedAt: new Date(), + }) + .where( + and( + eq(integrationSecrets.integrationId, input.integrationId), + eq(integrationSecrets.kind, input.kind), + ), + ); +}; + +interface AddSecretInput { + integrationId: string; + value: string; + kind: IntegrationSecretKind; +} +const addSecret = async (db: Database, input: AddSecretInput) => { + await db.insert(integrationSecrets).values({ + kind: input.kind, + value: encryptSecret(input.value), + updatedAt: new Date(), + integrationId: input.integrationId, + }); +}; diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts new file mode 100644 index 000000000..079fb4ccc --- /dev/null +++ b/packages/api/src/router/test/board.spec.ts @@ -0,0 +1,648 @@ +import SuperJSON from "superjson"; +import { describe, expect, it, vi } from "vitest"; + +import type { Session } from "@homarr/auth"; +import type { Database } from "@homarr/db"; +import { createId, eq } from "@homarr/db"; +import { + boards, + integrationItems, + integrations, + items, + sections, +} from "@homarr/db/schema/sqlite"; +import { createDb } from "@homarr/db/test"; + +import type { RouterOutputs } from "../../.."; +import { boardRouter } from "../board"; + +// Mock the auth module to return an empty session +vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session })); + +export const expectToBeDefined = (value: T) => { + if (value === undefined) { + expect(value).toBeDefined(); + } + if (value === null) { + expect(value).not.toBeNull(); + } + return value as Exclude; +}; + +describe("default should return default board", () => { + it("should return default board", async () => { + const db = createDb(); + const caller = boardRouter.createCaller({ db, session: null }); + + const fullBoardProps = await createFullBoardAsync(db, "default"); + + const result = await caller.default(); + + expectInputToBeFullBoardWithName(result, { + name: "default", + ...fullBoardProps, + }); + }); +}); + +describe("byName should return board by name", () => { + it.each([["default"], ["something"]])( + "should return board by name %s when present", + async (name) => { + const db = createDb(); + const caller = boardRouter.createCaller({ db, session: null }); + + const fullBoardProps = await createFullBoardAsync(db, name); + + const result = await caller.byName({ name }); + + expectInputToBeFullBoardWithName(result, { + name, + ...fullBoardProps, + }); + }, + ); + + it("should throw error when not present"); +}); + +describe("saveGeneralSettings should save general settings", () => { + it("should save general settings", async () => { + const db = createDb(); + const caller = boardRouter.createCaller({ db, session: null }); + + const newPageTitle = "newPageTitle"; + const newMetaTitle = "newMetaTitle"; + const newLogoImageUrl = "http://logo.image/url.png"; + const newFaviconImageUrl = "http://favicon.image/url.png"; + + const { boardId } = await createFullBoardAsync(db, "default"); + + await caller.saveGeneralSettings({ + pageTitle: newPageTitle, + metaTitle: newMetaTitle, + logoImageUrl: newLogoImageUrl, + faviconImageUrl: newFaviconImageUrl, + boardId, + }); + }); + + it("should throw error when board not found", async () => { + const db = createDb(); + const caller = boardRouter.createCaller({ db, session: null }); + + const act = async () => + await caller.saveGeneralSettings({ + pageTitle: "newPageTitle", + metaTitle: "newMetaTitle", + logoImageUrl: "http://logo.image/url.png", + faviconImageUrl: "http://favicon.image/url.png", + boardId: "nonExistentBoardId", + }); + + await expect(act()).rejects.toThrowError("Board not found"); + }); +}); + +describe("save should save full board", () => { + it("should remove section when not present in input", async () => { + const db = createDb(); + const caller = boardRouter.createCaller({ db, session: null }); + + const { boardId, sectionId } = await createFullBoardAsync(db, "default"); + + await caller.save({ + boardId, + sections: [ + { + id: createId(), + kind: "empty", + position: 0, + items: [], + }, + ], + }); + + const board = await db.query.boards.findFirst({ + where: eq(boards.id, boardId), + with: { + sections: true, + }, + }); + + const section = await db.query.boards.findFirst({ + where: eq(sections.id, sectionId), + }); + + const definedBoard = expectToBeDefined(board); + expect(definedBoard.sections.length).toBe(1); + expect(definedBoard.sections[0]?.id).not.toBe(sectionId); + expect(section).toBeUndefined(); + }); + it("should remove item when not present in input", async () => { + const db = createDb(); + const caller = boardRouter.createCaller({ db, session: null }); + + const { boardId, itemId, sectionId } = await createFullBoardAsync( + db, + "default", + ); + + await caller.save({ + boardId, + sections: [ + { + id: sectionId, + kind: "empty", + position: 0, + items: [ + { + id: createId(), + kind: "clock", + options: { is24HourFormat: true }, + integrations: [], + height: 1, + width: 1, + xOffset: 0, + yOffset: 0, + }, + ], + }, + ], + }); + + const board = await db.query.boards.findFirst({ + where: eq(boards.id, boardId), + with: { + sections: { + with: { + items: true, + }, + }, + }, + }); + + const item = await db.query.items.findFirst({ + where: eq(items.id, itemId), + }); + + const definedBoard = expectToBeDefined(board); + expect(definedBoard.sections.length).toBe(1); + const firstSection = expectToBeDefined(definedBoard.sections[0]); + expect(firstSection.items.length).toBe(1); + expect(firstSection.items[0]?.id).not.toBe(itemId); + expect(item).toBeUndefined(); + }); + it("should remove integration reference when not present in input", async () => { + const db = createDb(); + const caller = boardRouter.createCaller({ db, session: null }); + const anotherIntegration = { + id: createId(), + kind: "adGuardHome", + name: "AdGuard Home", + url: "http://localhost:3000", + } as const; + + const { boardId, itemId, integrationId, sectionId } = + await createFullBoardAsync(db, "default"); + await db.insert(integrations).values(anotherIntegration); + + await caller.save({ + boardId, + sections: [ + { + id: sectionId, + kind: "empty", + position: 0, + items: [ + { + id: itemId, + kind: "clock", + options: { is24HourFormat: true }, + integrations: [anotherIntegration], + height: 1, + width: 1, + xOffset: 0, + yOffset: 0, + }, + ], + }, + ], + }); + + const board = await db.query.boards.findFirst({ + where: eq(boards.id, boardId), + with: { + sections: { + with: { + items: { + with: { + integrations: true, + }, + }, + }, + }, + }, + }); + + const integration = await db.query.integrationItems.findFirst({ + where: eq(integrationItems.integrationId, integrationId), + }); + + const definedBoard = expectToBeDefined(board); + expect(definedBoard.sections.length).toBe(1); + const firstSection = expectToBeDefined(definedBoard.sections[0]); + expect(firstSection.items.length).toBe(1); + const firstItem = expectToBeDefined(firstSection.items[0]); + expect(firstItem.integrations.length).toBe(1); + expect(firstItem.integrations[0]?.integrationId).not.toBe(integrationId); + expect(integration).toBeUndefined(); + }); + it.each([ + [{ kind: "empty" as const }], + [{ kind: "category" as const, name: "My first category" }], + ])("should add section when present in input", async (partialSection) => { + const db = createDb(); + const caller = boardRouter.createCaller({ db, session: null }); + + const { boardId, sectionId } = await createFullBoardAsync(db, "default"); + + const newSectionId = createId(); + await caller.save({ + boardId, + sections: [ + { + id: newSectionId, + position: 1, + items: [], + ...partialSection, + }, + { + id: sectionId, + kind: "empty", + position: 0, + items: [], + }, + ], + }); + + const board = await db.query.boards.findFirst({ + where: eq(boards.id, boardId), + with: { + sections: true, + }, + }); + + const section = await db.query.sections.findFirst({ + where: eq(sections.id, newSectionId), + }); + + const definedBoard = expectToBeDefined(board); + expect(definedBoard.sections.length).toBe(2); + const addedSection = expectToBeDefined( + definedBoard.sections.find((section) => section.id === newSectionId), + ); + expect(addedSection).toBeDefined(); + expect(addedSection.id).toBe(newSectionId); + expect(addedSection.kind).toBe(partialSection.kind); + expect(addedSection.position).toBe(1); + if ("name" in partialSection) { + expect(addedSection.name).toBe(partialSection.name); + } + expect(section).toBeDefined(); + }); + it("should add item when present in input", async () => { + const db = createDb(); + const caller = boardRouter.createCaller({ db, session: null }); + + const { boardId, sectionId } = await createFullBoardAsync(db, "default"); + + const newItemId = createId(); + await caller.save({ + boardId, + sections: [ + { + id: sectionId, + kind: "empty", + position: 0, + items: [ + { + id: newItemId, + kind: "clock", + options: { is24HourFormat: true }, + integrations: [], + height: 1, + width: 1, + xOffset: 3, + yOffset: 2, + }, + ], + }, + ], + }); + + const board = await db.query.boards.findFirst({ + where: eq(boards.id, boardId), + with: { + sections: { + with: { + items: true, + }, + }, + }, + }); + + const item = await db.query.items.findFirst({ + where: eq(items.id, newItemId), + }); + + const definedBoard = expectToBeDefined(board); + expect(definedBoard.sections.length).toBe(1); + const firstSection = expectToBeDefined(definedBoard.sections[0]); + expect(firstSection.items.length).toBe(1); + const addedItem = expectToBeDefined( + firstSection.items.find((item) => item.id === newItemId), + ); + expect(addedItem).toBeDefined(); + expect(addedItem.id).toBe(newItemId); + expect(addedItem.kind).toBe("clock"); + expect(addedItem.options).toBe( + SuperJSON.stringify({ is24HourFormat: true }), + ); + expect(addedItem.height).toBe(1); + expect(addedItem.width).toBe(1); + expect(addedItem.xOffset).toBe(3); + expect(addedItem.yOffset).toBe(2); + expect(item).toBeDefined(); + }); + it("should add integration reference when present in input", async () => { + const db = createDb(); + const caller = boardRouter.createCaller({ db, session: null }); + const integration = { + id: createId(), + kind: "plex", + name: "Plex", + url: "http://plex.local", + } as const; + + const { boardId, itemId, sectionId } = await createFullBoardAsync( + db, + "default", + ); + await db.insert(integrations).values(integration); + + await caller.save({ + boardId, + sections: [ + { + id: sectionId, + kind: "empty", + position: 0, + items: [ + { + id: itemId, + kind: "clock", + options: { is24HourFormat: true }, + integrations: [integration], + height: 1, + width: 1, + xOffset: 0, + yOffset: 0, + }, + ], + }, + ], + }); + + const board = await db.query.boards.findFirst({ + where: eq(boards.id, boardId), + with: { + sections: { + with: { + items: { + with: { + integrations: true, + }, + }, + }, + }, + }, + }); + + const integrationItem = await db.query.integrationItems.findFirst({ + where: eq(integrationItems.integrationId, integration.id), + }); + + const definedBoard = expectToBeDefined(board); + expect(definedBoard.sections.length).toBe(1); + const firstSection = expectToBeDefined(definedBoard.sections[0]); + expect(firstSection.items.length).toBe(1); + const firstItem = expectToBeDefined( + firstSection.items.find((item) => item.id === itemId), + ); + expect(firstItem.integrations.length).toBe(1); + expect(firstItem.integrations[0]?.integrationId).toBe(integration.id); + expect(integrationItem).toBeDefined(); + }); + it("should update section when present in input", async () => { + const db = createDb(); + const caller = boardRouter.createCaller({ db, session: null }); + + const { boardId, sectionId } = await createFullBoardAsync(db, "default"); + const newSectionId = createId(); + await db.insert(sections).values({ + id: newSectionId, + kind: "category", + name: "Before", + position: 1, + boardId, + }); + + await caller.save({ + boardId, + sections: [ + { + id: sectionId, + kind: "category", + position: 1, + name: "Test", + items: [], + }, + { + id: newSectionId, + kind: "category", + name: "After", + position: 0, + items: [], + }, + ], + }); + + const board = await db.query.boards.findFirst({ + where: eq(boards.id, boardId), + with: { + sections: true, + }, + }); + + const definedBoard = expectToBeDefined(board); + expect(definedBoard.sections.length).toBe(2); + const firstSection = expectToBeDefined( + definedBoard.sections.find((section) => section.id === sectionId), + ); + expect(firstSection.id).toBe(sectionId); + expect(firstSection.kind).toBe("empty"); + expect(firstSection.position).toBe(1); + expect(firstSection.name).toBe(null); + const secondSection = expectToBeDefined( + definedBoard.sections.find((section) => section.id === newSectionId), + ); + expect(secondSection.id).toBe(newSectionId); + expect(secondSection.kind).toBe("category"); + expect(secondSection.position).toBe(0); + expect(secondSection.name).toBe("After"); + }); + it("should update item when present in input", async () => { + const db = createDb(); + const caller = boardRouter.createCaller({ db, session: null }); + + const { boardId, itemId, sectionId } = await createFullBoardAsync( + db, + "default", + ); + + await caller.save({ + boardId, + sections: [ + { + id: sectionId, + kind: "empty", + position: 0, + items: [ + { + id: itemId, + kind: "clock", + options: { is24HourFormat: false }, + integrations: [], + height: 3, + width: 2, + xOffset: 7, + yOffset: 5, + }, + ], + }, + ], + }); + + const board = await db.query.boards.findFirst({ + where: eq(boards.id, boardId), + with: { + sections: { + with: { + items: true, + }, + }, + }, + }); + + const definedBoard = expectToBeDefined(board); + expect(definedBoard.sections.length).toBe(1); + const firstSection = expectToBeDefined(definedBoard.sections[0]); + expect(firstSection.items.length).toBe(1); + const firstItem = expectToBeDefined( + firstSection.items.find((item) => item.id === itemId), + ); + expect(firstItem.id).toBe(itemId); + expect(firstItem.kind).toBe("clock"); + expect( + SuperJSON.parse<{ is24HourFormat: boolean }>(firstItem.options) + .is24HourFormat, + ).toBe(false); + expect(firstItem.height).toBe(3); + expect(firstItem.width).toBe(2); + expect(firstItem.xOffset).toBe(7); + expect(firstItem.yOffset).toBe(5); + }); + it("should fail when board not found", async () => { + const db = createDb(); + const caller = boardRouter.createCaller({ db, session: null }); + + const act = async () => + await caller.save({ + boardId: "nonExistentBoardId", + sections: [], + }); + + await expect(act()).rejects.toThrowError("Board not found"); + }); +}); + +const expectInputToBeFullBoardWithName = ( + input: RouterOutputs["board"]["default"], + props: { name: string } & Awaited>, +) => { + expect(input.id).toBe(props.boardId); + expect(input.name).toBe(props.name); + expect(input.sections.length).toBe(1); + const firstSection = expectToBeDefined(input.sections[0]); + expect(firstSection.id).toBe(props.sectionId); + expect(firstSection.items.length).toBe(1); + const firstItem = expectToBeDefined(firstSection.items[0]); + expect(firstItem.id).toBe(props.itemId); + expect(firstItem.kind).toBe("clock"); + if (firstItem.kind === "clock") { + expect(firstItem.options.is24HourFormat).toBe(true); + } + expect(firstItem.integrations.length).toBe(1); + const firstIntegration = expectToBeDefined(firstItem.integrations[0]); + expect(firstIntegration.id).toBe(props.integrationId); + expect(firstIntegration.kind).toBe("adGuardHome"); +}; + +const createFullBoardAsync = async (db: Database, name: string) => { + const boardId = createId(); + await db.insert(boards).values({ + id: boardId, + name, + }); + + const sectionId = createId(); + await db.insert(sections).values({ + id: sectionId, + kind: "empty", + position: 0, + boardId, + }); + + const itemId = createId(); + await db.insert(items).values({ + id: itemId, + kind: "clock", + height: 1, + width: 1, + xOffset: 0, + yOffset: 0, + sectionId, + options: SuperJSON.stringify({ is24HourFormat: true }), + }); + + const integrationId = createId(); + await db.insert(integrations).values({ + id: integrationId, + kind: "adGuardHome", + name: "AdGuard Home", + url: "http://localhost:3000", + }); + + await db.insert(integrationItems).values({ + integrationId, + itemId, + }); + + return { + boardId, + sectionId, + itemId, + integrationId, + }; +}; diff --git a/packages/api/src/router/test/integration.spec.ts b/packages/api/src/router/test/integration.spec.ts new file mode 100644 index 000000000..11c14de18 --- /dev/null +++ b/packages/api/src/router/test/integration.spec.ts @@ -0,0 +1,503 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { Session } from "@homarr/auth"; +import { createId } from "@homarr/db"; +import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite"; +import { createDb } from "@homarr/db/test"; + +import type { RouterInputs } from "../../.."; +import { encryptSecret, integrationRouter } from "../integration"; +import { expectToBeDefined } from "./board.spec"; + +// Mock the auth module to return an empty session +vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session })); + +describe("all should return all integrations", () => { + it("should return all integrations", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + session: null, + }); + + await db.insert(integrations).values([ + { + id: "1", + name: "Home assistant", + kind: "homeAssistant", + url: "http://homeassist.local", + }, + { + id: "2", + name: "Home plex server", + kind: "plex", + url: "http://plex.local", + }, + ]); + + const result = await caller.all(); + expect(result.length).toBe(2); + expect(result[0]!.kind).toBe("plex"); + expect(result[1]!.kind).toBe("homeAssistant"); + }); +}); + +describe("byId should return an integration by id", () => { + it("should return an integration by id", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + session: null, + }); + + await db.insert(integrations).values([ + { + id: "1", + name: "Home assistant", + kind: "homeAssistant", + url: "http://homeassist.local", + }, + { + id: "2", + name: "Home plex server", + kind: "plex", + url: "http://plex.local", + }, + ]); + + const result = await caller.byId({ id: "2" }); + expect(result.kind).toBe("plex"); + }); + + it("should throw an error if the integration does not exist", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + session: null, + }); + + const act = async () => await caller.byId({ id: "2" }); + await expect(act()).rejects.toThrow("Integration not found"); + }); + + it("should only return the public secret values", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + session: null, + }); + + await db.insert(integrations).values([ + { + id: "1", + name: "Home assistant", + kind: "homeAssistant", + url: "http://homeassist.local", + }, + ]); + await db.insert(integrationSecrets).values([ + { + kind: "username", + value: encryptSecret("musterUser"), + integrationId: "1", + updatedAt: new Date(), + }, + { + kind: "password", + value: encryptSecret("Password123!"), + integrationId: "1", + updatedAt: new Date(), + }, + { + kind: "apiKey", + value: encryptSecret("1234567890"), + integrationId: "1", + updatedAt: new Date(), + }, + ]); + + const result = await caller.byId({ id: "1" }); + expect(result.secrets.length).toBe(3); + const username = expectToBeDefined( + result.secrets.find((secret) => secret.kind === "username"), + ); + expect(username.value).not.toBeNull(); + const password = expectToBeDefined( + result.secrets.find((secret) => secret.kind === "password"), + ); + expect(password.value).toBeNull(); + const apiKey = expectToBeDefined( + result.secrets.find((secret) => secret.kind === "apiKey"), + ); + expect(apiKey.value).toBeNull(); + }); +}); + +describe("create should create a new integration", () => { + it("should create a new integration", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + session: null, + }); + const input = { + name: "Jellyfin", + kind: "jellyfin" as const, + url: "http://jellyfin.local", + secrets: [{ kind: "apiKey" as const, value: "1234567890" }], + }; + + const fakeNow = new Date("2023-07-01T00:00:00Z"); + vi.useFakeTimers(); + vi.setSystemTime(fakeNow); + await caller.create(input); + vi.useRealTimers(); + + const dbIntegration = await db.query.integrations.findFirst(); + const dbSecret = await db.query.integrationSecrets.findFirst(); + expect(dbIntegration).toBeDefined(); + expect(dbIntegration!.name).toBe(input.name); + expect(dbIntegration!.kind).toBe(input.kind); + expect(dbIntegration!.url).toBe(input.url); + + expect(dbSecret!.integrationId).toBe(dbIntegration!.id); + expect(dbSecret).toBeDefined(); + expect(dbSecret!.kind).toBe(input.secrets[0]!.kind); + expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/); + expect(dbSecret!.updatedAt).toEqual(fakeNow); + }); +}); + +describe("update should update an integration", () => { + it("should update an integration", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + session: null, + }); + + const lastWeek = new Date("2023-06-24T00:00:00Z"); + const integrationId = createId(); + const toInsert = { + id: integrationId, + name: "Pi Hole", + kind: "piHole" as const, + url: "http://hole.local", + }; + + await db.insert(integrations).values(toInsert); + + const usernameToInsert = { + kind: "username" as const, + value: encryptSecret("musterUser"), + integrationId, + updatedAt: lastWeek, + }; + + const passwordToInsert = { + kind: "password" as const, + value: encryptSecret("Password123!"), + integrationId, + updatedAt: lastWeek, + }; + await db + .insert(integrationSecrets) + .values([usernameToInsert, passwordToInsert]); + + const input = { + id: integrationId, + name: "Milky Way Pi Hole", + kind: "piHole" as const, + url: "http://milkyway.local", + secrets: [ + { kind: "username" as const, value: "newUser" }, + { kind: "password" as const, value: null }, + { kind: "apiKey" as const, value: "1234567890" }, + ], + }; + + const fakeNow = new Date("2023-07-01T00:00:00Z"); + vi.useFakeTimers(); + vi.setSystemTime(fakeNow); + await caller.update(input); + vi.useRealTimers(); + + const dbIntegration = await db.query.integrations.findFirst(); + const dbSecrets = await db.query.integrationSecrets.findMany(); + + expect(dbIntegration).toBeDefined(); + expect(dbIntegration!.name).toBe(input.name); + expect(dbIntegration!.kind).toBe(input.kind); + expect(dbIntegration!.url).toBe(input.url); + + expect(dbSecrets.length).toBe(3); + const username = expectToBeDefined( + dbSecrets.find((secret) => secret.kind === "username"), + ); + const password = expectToBeDefined( + dbSecrets.find((secret) => secret.kind === "password"), + ); + const apiKey = expectToBeDefined( + dbSecrets.find((secret) => secret.kind === "apiKey"), + ); + expect(username.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/); + expect(password.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/); + expect(apiKey.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/); + expect(username.updatedAt).toEqual(fakeNow); + expect(password.updatedAt).toEqual(lastWeek); + expect(apiKey.updatedAt).toEqual(fakeNow); + expect(username.value).not.toEqual(usernameToInsert.value); + expect(password.value).toEqual(passwordToInsert.value); + expect(apiKey.value).not.toEqual(input.secrets[2]!.value); + }); + + it("should throw an error if the integration does not exist", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + session: null, + }); + + const act = async () => + await caller.update({ + id: createId(), + name: "Pi Hole", + url: "http://hole.local", + secrets: [], + }); + await expect(act()).rejects.toThrow("Integration not found"); + }); +}); + +describe("delete should delete an integration", () => { + it("should delete an integration", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + session: null, + }); + + const integrationId = createId(); + await db.insert(integrations).values({ + id: integrationId, + name: "Home assistant", + kind: "homeAssistant", + url: "http://homeassist.local", + }); + + await db.insert(integrationSecrets).values([ + { + kind: "username", + value: encryptSecret("example"), + integrationId, + updatedAt: new Date(), + }, + ]); + + await caller.delete({ id: integrationId }); + + const dbIntegration = await db.query.integrations.findFirst(); + expect(dbIntegration).toBeUndefined(); + const dbSecrets = await db.query.integrationSecrets.findMany(); + expect(dbSecrets.length).toBe(0); + }); +}); + +describe("testConnection should test the connection to an integration", () => { + it.each([ + [ + "nzbGet" as const, + [ + { kind: "username" as const, value: null }, + { kind: "password" as const, value: "Password123!" }, + ], + ], + [ + "nzbGet" as const, + [ + { kind: "username" as const, value: "exampleUser" }, + { kind: "password" as const, value: null }, + ], + ], + ["sabNzbd" as const, [{ kind: "apiKey" as const, value: null }]], + [ + "sabNzbd" as const, + [ + { kind: "username" as const, value: "exampleUser" }, + { kind: "password" as const, value: "Password123!" }, + ], + ], + ])( + "should fail when a required secret is missing when creating %s integration", + async (kind, secrets) => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + session: null, + }); + + const input: RouterInputs["integration"]["testConnection"] = { + id: null, + kind, + url: `http://${kind}.local`, + secrets, + }; + + const act = async () => await caller.testConnection(input); + await expect(act()).rejects.toThrow("SECRETS_NOT_DEFINED"); + }, + ); + + it.each([ + [ + "nzbGet" as const, + [ + { kind: "username" as const, value: "exampleUser" }, + { kind: "password" as const, value: "Password123!" }, + ], + ], + ["sabNzbd" as const, [{ kind: "apiKey" as const, value: "1234567890" }]], + ])( + "should be successful when all required secrets are defined for creation of %s integration", + async (kind, secrets) => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + session: null, + }); + + const input: RouterInputs["integration"]["testConnection"] = { + id: null, + kind, + url: `http://${kind}.local`, + secrets, + }; + + const act = async () => await caller.testConnection(input); + await expect(act()).resolves.toBeUndefined(); + }, + ); + + it("should be successful when all required secrets are defined for updating an nzbGet integration", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + session: null, + }); + + const input: RouterInputs["integration"]["testConnection"] = { + id: createId(), + kind: "nzbGet", + url: "http://nzbGet.local", + secrets: [ + { kind: "username", value: "exampleUser" }, + { kind: "password", value: "Password123!" }, + ], + }; + + const act = async () => await caller.testConnection(input); + await expect(act()).resolves.toBeUndefined(); + }); + + it("should be successful when overriding one of the secrets for an existing nzbGet integration", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + session: null, + }); + + const integrationId = createId(); + await db.insert(integrations).values({ + id: integrationId, + name: "NZBGet", + kind: "nzbGet", + url: "http://nzbGet.local", + }); + + await db.insert(integrationSecrets).values([ + { + kind: "username", + value: encryptSecret("exampleUser"), + integrationId, + updatedAt: new Date(), + }, + { + kind: "password", + value: encryptSecret("Password123!"), + integrationId, + updatedAt: new Date(), + }, + ]); + + const input: RouterInputs["integration"]["testConnection"] = { + id: integrationId, + kind: "nzbGet", + url: "http://nzbGet.local", + secrets: [ + { kind: "username", value: "newUser" }, + { kind: "password", value: null }, + ], + }; + + const act = async () => await caller.testConnection(input); + await expect(act()).resolves.toBeUndefined(); + }); + + it("should fail when a required secret is missing for an existing nzbGet integration", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + session: null, + }); + + const integrationId = createId(); + await db.insert(integrations).values({ + id: integrationId, + name: "NZBGet", + kind: "nzbGet", + url: "http://nzbGet.local", + }); + + await db.insert(integrationSecrets).values([ + { + kind: "username", + value: encryptSecret("exampleUser"), + integrationId, + updatedAt: new Date(), + }, + ]); + + const input: RouterInputs["integration"]["testConnection"] = { + id: integrationId, + kind: "nzbGet", + url: "http://nzbGet.local", + secrets: [ + { kind: "username", value: "newUser" }, + { kind: "apiKey", value: "1234567890" }, + ], + }; + + const act = async () => await caller.testConnection(input); + await expect(act()).rejects.toThrow("SECRETS_NOT_DEFINED"); + }); + + it("should fail when the updating integration does not exist", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + session: null, + }); + + const act = async () => + await caller.testConnection({ + id: createId(), + kind: "nzbGet", + url: "http://nzbGet.local", + secrets: [ + { kind: "username", value: null }, + { kind: "password", value: "Password123!" }, + ], + }); + await expect(act()).rejects.toThrow("SECRETS_NOT_DEFINED"); + }); +}); diff --git a/packages/api/src/router/test/user.spec.ts b/packages/api/src/router/test/user.spec.ts new file mode 100644 index 000000000..29ce6e349 --- /dev/null +++ b/packages/api/src/router/test/user.spec.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { Session } from "@homarr/auth"; +import { schema } from "@homarr/db"; +import { createDb } from "@homarr/db/test"; + +import { userRouter } from "../user"; + +// Mock the auth module to return an empty session +vi.mock("@homarr/auth", async () => { + const mod = await import("@homarr/auth/security"); + return { ...mod, auth: () => ({}) as Session }; +}); + +describe("initUser should initialize the first user", () => { + it("should throw an error if a user already exists", async () => { + const db = createDb(); + const caller = userRouter.createCaller({ + db, + session: null, + }); + + await db.insert(schema.users).values({ + id: "test", + name: "test", + password: "test", + }); + + const act = async () => + await caller.initUser({ + username: "test", + password: "12345678", + confirmPassword: "12345678", + }); + + await expect(act()).rejects.toThrow("User already exists"); + }); + + it("should create a user if none exists", async () => { + const db = createDb(); + const caller = userRouter.createCaller({ + db, + session: null, + }); + + await caller.initUser({ + username: "test", + password: "12345678", + confirmPassword: "12345678", + }); + + const user = await db.query.users.findFirst({ + columns: { + id: true, + }, + }); + + expect(user).toBeDefined(); + }); + + it("should not create a user if the password and confirmPassword do not match", async () => { + const db = createDb(); + const caller = userRouter.createCaller({ + db, + session: null, + }); + + const act = async () => + await caller.initUser({ + username: "test", + password: "12345678", + confirmPassword: "12345679", + }); + + await expect(act()).rejects.toThrow("Passwords do not match"); + }); + + it("should not create a user if the password is too short", async () => { + const db = createDb(); + const caller = userRouter.createCaller({ + db, + session: null, + }); + + const act = async () => + await caller.initUser({ + username: "test", + password: "1234567", + confirmPassword: "1234567", + }); + + await expect(act()).rejects.toThrow("too_small"); + }); +}); diff --git a/packages/auth/callbacks.ts b/packages/auth/callbacks.ts new file mode 100644 index 000000000..05a298290 --- /dev/null +++ b/packages/auth/callbacks.ts @@ -0,0 +1,61 @@ +import { cookies } from "next/headers"; +import type { Adapter } from "@auth/core/adapters"; +import type { NextAuthConfig } from "next-auth"; + +import { + expireDateAfter, + generateSessionToken, + sessionMaxAgeInSeconds, + sessionTokenCookieName, +} from "./session"; + +export const sessionCallback: NextAuthCallbackOf<"session"> = ({ + session, + user, +}) => ({ + ...session, + user: { + ...session.user, + id: user.id, + name: user.name, + }, +}); + +export const createSignInCallback = + ( + adapter: Adapter, + isCredentialsRequest: boolean, + ): NextAuthCallbackOf<"signIn"> => + async ({ user }) => { + if (!isCredentialsRequest) return true; + + if (!user) return true; + + // https://github.com/nextauthjs/next-auth/issues/6106 + if (!adapter?.createSession) { + return false; + } + + const sessionToken = generateSessionToken(); + const sessionExpiry = expireDateAfter(sessionMaxAgeInSeconds); + + await adapter.createSession({ + sessionToken, + userId: user.id!, + expires: sessionExpiry, + }); + + cookies().set(sessionTokenCookieName, sessionToken, { + path: "/", + expires: sessionExpiry, + httpOnly: true, + sameSite: "lax", + secure: true, + }); + + return true; + }; + +type NextAuthCallbackRecord = Exclude; +export type NextAuthCallbackOf = + Exclude; diff --git a/packages/auth/configuration.ts b/packages/auth/configuration.ts index f1810df44..32289da46 100644 --- a/packages/auth/configuration.ts +++ b/packages/auth/configuration.ts @@ -5,55 +5,23 @@ import Credentials from "next-auth/providers/credentials"; import { db } from "@homarr/db"; -import { credentialsConfiguration } from "./providers/credentials"; +import { createSignInCallback, sessionCallback } from "./callbacks"; +import { createCredentialsConfiguration } from "./providers/credentials"; import { EmptyNextAuthProvider } from "./providers/empty"; -import { expireDateAfter, generateSessionToken } from "./session"; +import { sessionMaxAgeInSeconds, sessionTokenCookieName } from "./session"; const adapter = DrizzleAdapter(db); -const sessionMaxAgeInSeconds = 30 * 24 * 60 * 60; // 30 days export const createConfiguration = (isCredentialsRequest: boolean) => NextAuth({ adapter, - providers: [Credentials(credentialsConfiguration), EmptyNextAuthProvider()], + providers: [ + Credentials(createCredentialsConfiguration(db)), + EmptyNextAuthProvider(), + ], callbacks: { - session: ({ session, user }) => ({ - ...session, - user: { - ...session.user, - id: user.id, - name: user.name, - }, - }), - signIn: async ({ user }) => { - if (!isCredentialsRequest) return true; - - if (!user) return true; - - const sessionToken = generateSessionToken(); - const sessionExpiry = expireDateAfter(sessionMaxAgeInSeconds); - - // https://github.com/nextauthjs/next-auth/issues/6106 - if (!adapter?.createSession) { - return false; - } - - await adapter.createSession({ - sessionToken: sessionToken, - userId: user.id!, - expires: sessionExpiry, - }); - - cookies().set("next-auth.session-token", sessionToken, { - path: "/", - expires: sessionExpiry, - httpOnly: true, - sameSite: "lax", - secure: true, - }); - - return true; - }, + session: sessionCallback, + signIn: createSignInCallback(adapter, isCredentialsRequest), }, session: { strategy: "database", @@ -65,7 +33,7 @@ export const createConfiguration = (isCredentialsRequest: boolean) => }, jwt: { encode() { - const cookie = cookies().get("next-auth.session-token")?.value; + const cookie = cookies().get(sessionTokenCookieName)?.value; return cookie ?? ""; }, diff --git a/packages/auth/package.json b/packages/auth/package.json index 8ac6c5a3b..745f0c7c5 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -1,6 +1,12 @@ { "name": "@homarr/auth", "version": "0.1.0", + "exports": { + ".": "./index.ts", + "./security": "./security.ts", + "./client": "./client.ts", + "./env.mjs": "./env.mjs" + }, "private": true, "main": "./index.ts", "types": "./index.ts", diff --git a/packages/auth/providers/credentials.ts b/packages/auth/providers/credentials.ts index 114c8ecba..04470dbfa 100644 --- a/packages/auth/providers/credentials.ts +++ b/packages/auth/providers/credentials.ts @@ -1,49 +1,56 @@ import type Credentials from "@auth/core/providers/credentials"; import bcrypt from "bcrypt"; -import { db, eq } from "@homarr/db"; +import type { Database } from "@homarr/db"; +import { eq } from "@homarr/db"; import { users } from "@homarr/db/schema/sqlite"; import { validation } from "@homarr/validation"; type CredentialsConfiguration = Parameters[0]; -export const credentialsConfiguration = { - type: "credentials", - name: "Credentials", - credentials: { - name: { - label: "Username", - type: "text", +export const createCredentialsConfiguration = (db: Database) => + ({ + type: "credentials", + name: "Credentials", + credentials: { + name: { + label: "Username", + type: "text", + }, + password: { + label: "Password", + type: "password", + }, }, - password: { - label: "Password", - type: "password", + async authorize(credentials) { + const data = await validation.user.signIn.parseAsync(credentials); + + const user = await db.query.users.findFirst({ + where: eq(users.name, data.name), + }); + + if (!user?.password) { + return null; + } + + console.log( + `user ${user.name} is trying to log in. checking password...`, + ); + const isValidPassword = await bcrypt.compare( + data.password, + user.password, + ); + + if (!isValidPassword) { + console.log(`password for user ${user.name} was incorrect`); + return null; + } + + console.log(`user ${user.name} successfully authorized`); + + return { + id: user.id, + name: user.name, + }; }, - }, - async authorize(credentials) { - const data = await validation.user.signIn.parseAsync(credentials); - - const user = await db.query.users.findFirst({ - where: eq(users.name, data.name), - }); - - if (!user?.password) { - return null; - } - - console.log(`user ${user.name} is trying to log in. checking password...`); - const isValidPassword = await bcrypt.compare(data.password, user.password); - - if (!isValidPassword) { - console.log(`password for user ${user.name} was incorrect`); - return null; - } - - console.log(`user ${user.name} successfully authorized`); - - return { - id: user.id, - name: user.name, - }; - }, -} satisfies CredentialsConfiguration; + }) satisfies CredentialsConfiguration; diff --git a/packages/auth/providers/test/credentials.spec.ts b/packages/auth/providers/test/credentials.spec.ts new file mode 100644 index 000000000..3feb4a919 --- /dev/null +++ b/packages/auth/providers/test/credentials.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; + +import { createId } from "@homarr/db"; +import { users } from "@homarr/db/schema/sqlite"; +import { createDb } from "@homarr/db/test"; + +import { createSalt, hashPassword } from "../../security"; +import { createCredentialsConfiguration } from "../credentials"; + +describe("Credentials authorization", () => { + it("should authorize user with correct credentials", async () => { + const db = createDb(); + const userId = createId(); + const salt = await createSalt(); + await db.insert(users).values({ + id: userId, + name: "test", + password: await hashPassword("test", salt), + salt, + }); + const result = await createCredentialsConfiguration(db).authorize({ + name: "test", + password: "test", + }); + + expect(result).toEqual({ id: userId, name: "test" }); + }); + + const passwordsThatShouldNotAuthorize = [ + "wrong", + "Test", + "test ", + " test", + " test ", + ]; + + passwordsThatShouldNotAuthorize.forEach((password) => { + it(`should not authorize user with incorrect credentials (${password})`, async () => { + const db = createDb(); + const userId = createId(); + const salt = await createSalt(); + await db.insert(users).values({ + id: userId, + name: "test", + password: await hashPassword("test", salt), + salt, + }); + const result = await createCredentialsConfiguration(db).authorize({ + name: "test", + password, + }); + + expect(result).toBeNull(); + }); + }); + + it("should not authorize user for not existing user", async () => { + const db = createDb(); + const result = await createCredentialsConfiguration(db).authorize({ + name: "test", + password: "test", + }); + + expect(result).toBeNull(); + }); +}); diff --git a/packages/auth/session.ts b/packages/auth/session.ts index 3329a0ffa..356401439 100644 --- a/packages/auth/session.ts +++ b/packages/auth/session.ts @@ -1,5 +1,8 @@ import { randomUUID } from "crypto"; +export const sessionMaxAgeInSeconds = 30 * 24 * 60 * 60; // 30 days +export const sessionTokenCookieName = "next-auth.session-token"; + export const expireDateAfter = (seconds: number) => { return new Date(Date.now() + seconds * 1000); }; diff --git a/packages/auth/test/callbacks.spec.ts b/packages/auth/test/callbacks.spec.ts new file mode 100644 index 000000000..ab24e2c1b --- /dev/null +++ b/packages/auth/test/callbacks.spec.ts @@ -0,0 +1,153 @@ +import type { ResponseCookie } from "next/dist/compiled/@edge-runtime/cookies"; +import type { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies"; +import { cookies } from "next/headers"; +import type { Adapter, AdapterUser } from "@auth/core/adapters"; +import type { Account, User } from "next-auth"; +import type { JWT } from "next-auth/jwt"; +import { describe, expect, it, vi } from "vitest"; + +import { createSignInCallback, sessionCallback } from "../callbacks"; + +describe("session callback", () => { + it("should add id and name to session user", async () => { + const user: AdapterUser = { + id: "id", + name: "name", + email: "email", + emailVerified: new Date("2023-01-13"), + }; + const token: JWT = {}; + const result = await sessionCallback({ + session: { + user: { + id: "no-id", + email: "no-email", + emailVerified: new Date("2023-01-13"), + }, + expires: "2023-01-13" as Date & string, + sessionToken: "token", + userId: "no-id", + }, + user, + token, + trigger: "update", + newSession: {}, + }); + expect(result.user).toBeDefined(); + expect(result.user!.id).toEqual(user.id); + expect(result.user!.name).toEqual(user.name); + }); +}); + +type AdapterSessionInput = Parameters< + Exclude +>[0]; + +const createAdapter = () => { + const result = { + createSession: (input: AdapterSessionInput) => input, + }; + + vi.spyOn(result, "createSession"); + return result; +}; + +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +type SessionExport = typeof import("../session"); +const mockSessionToken = "e9ef3010-6981-4a81-b9d6-8495d09cf3b5" as const; +const mockSessionExpiry = new Date("2023-07-01"); +vi.mock("../session", async (importOriginal) => { + const mod = await importOriginal(); + + const generateSessionToken = () => mockSessionToken; + const expireDateAfter = (_seconds: number) => mockSessionExpiry; + + return { + ...mod, + generateSessionToken, + expireDateAfter, + } satisfies SessionExport; +}); +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +type HeadersExport = typeof import("next/headers"); +vi.mock("next/headers", async (importOriginal) => { + const mod = await importOriginal(); + + const result = { + set: (name: string, value: string, options: Partial) => + options as ResponseCookie, + } as unknown as ReadonlyRequestCookies; + + vi.spyOn(result, "set"); + + const cookies = () => result; + + return { ...mod, cookies } satisfies HeadersExport; +}); + +describe("createSignInCallback", () => { + it("should return true if not credentials request", async () => { + const isCredentialsRequest = false; + const signInCallback = createSignInCallback( + createAdapter(), + isCredentialsRequest, + ); + const result = await signInCallback({ + user: { id: "1", emailVerified: new Date("2023-01-13") }, + account: {} as Account, + }); + expect(result).toBe(true); + }); + + it("should return true if no user", async () => { + const isCredentialsRequest = true; + const signInCallback = createSignInCallback( + createAdapter(), + isCredentialsRequest, + ); + const result = await signInCallback({ + user: undefined as unknown as User, + account: {} as Account, + }); + expect(result).toBe(true); + }); + + it("should return false if no adapter.createSession", async () => { + const isCredentialsRequest = true; + const signInCallback = createSignInCallback( + // https://github.com/nextauthjs/next-auth/issues/6106 + undefined as unknown as Adapter, + isCredentialsRequest, + ); + const result = await signInCallback({ + user: { id: "1", emailVerified: new Date("2023-01-13") }, + account: {} as Account, + }); + expect(result).toBe(false); + }); + + it("should call adapter.createSession with correct input", async () => { + const adapter = createAdapter(); + const isCredentialsRequest = true; + const signInCallback = createSignInCallback(adapter, isCredentialsRequest); + const user = { id: "1", emailVerified: new Date("2023-01-13") }; + const account = {} as Account; + await signInCallback({ user, account }); + expect(adapter.createSession).toHaveBeenCalledWith({ + sessionToken: mockSessionToken, + userId: user.id, + expires: mockSessionExpiry, + }); + expect(cookies().set).toHaveBeenCalledWith( + "next-auth.session-token", + mockSessionToken, + { + path: "/", + expires: mockSessionExpiry, + httpOnly: true, + sameSite: "lax", + secure: true, + }, + ); + }); +}); diff --git a/packages/auth/test/security.spec.ts b/packages/auth/test/security.spec.ts new file mode 100644 index 000000000..f0f186966 --- /dev/null +++ b/packages/auth/test/security.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { createSalt, hashPassword } from "../security"; + +describe("createSalt should return a salt", () => { + it("should return a salt", async () => { + const result = await createSalt(); + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(25); + }); + it("should return a different salt each time", async () => { + const result1 = await createSalt(); + const result2 = await createSalt(); + expect(result1).not.toEqual(result2); + }); +}); + +describe("hashPassword should return a hash", () => { + it("should return a hash", async () => { + const password = "password"; + const salt = await createSalt(); + const result = await hashPassword(password, salt); + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(55); + expect(result).not.toEqual(password); + }); + it("should return a different hash each time", async () => { + const password = "password"; + const password2 = "another password"; + const salt = await createSalt(); + + const result1 = await hashPassword(password, salt); + const result2 = await hashPassword(password2, salt); + + expect(result1).not.toEqual(result2); + }); + it("should return a different hash for the same password with different salts", async () => { + const password = "password"; + const salt1 = await createSalt(); + const salt2 = await createSalt(); + + const result1 = await hashPassword(password, salt1); + const result2 = await hashPassword(password, salt2); + + expect(result1).not.toEqual(result2); + }); +}); diff --git a/packages/auth/test/session.spec.ts b/packages/auth/test/session.spec.ts new file mode 100644 index 000000000..2d2ced596 --- /dev/null +++ b/packages/auth/test/session.spec.ts @@ -0,0 +1,43 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { z } from "@homarr/validation"; + +import { expireDateAfter, generateSessionToken } from "../session"; + +describe("expireDateAfter should calculate date after specified seconds", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it.each([ + ["2023-07-01T00:00:00Z", 60, "2023-07-01T00:01:00Z"], // 1 minute + ["2023-07-01T00:00:00Z", 60 * 60, "2023-07-01T01:00:00Z"], // 1 hour + ["2023-07-01T00:00:00Z", 60 * 60 * 24, "2023-07-02T00:00:00Z"], // 1 day + ["2023-07-01T00:00:00Z", 60 * 60 * 24 * 30, "2023-07-31T00:00:00Z"], // 30 days + ["2023-07-01T00:00:00Z", 60 * 60 * 24 * 365, "2024-06-30T00:00:00Z"], // 1 year + ["2023-07-01T00:00:00Z", 60 * 60 * 24 * 365 * 10, "2033-06-28T00:00:00Z"], // 10 years + ])( + "should calculate date %s and after %i seconds to equal %s", + (initialDate, seconds, expectedDate) => { + vi.setSystemTime(new Date(initialDate)); + const result = expireDateAfter(seconds); + expect(result).toEqual(new Date(expectedDate)); + }, + ); +}); + +describe("generateSessionToken should return a random UUID", () => { + it("should return a random UUID", () => { + const result = generateSessionToken(); + expect(z.string().uuid().safeParse(result).success).toBe(true); + }); + it("should return a different token each time", () => { + const result1 = generateSessionToken(); + const result2 = generateSessionToken(); + expect(result1).not.toEqual(result2); + }); +}); diff --git a/packages/common/src/test/object.spec.ts b/packages/common/src/test/object.spec.ts index 00cdfc02c..02a1ed241 100644 --- a/packages/common/src/test/object.spec.ts +++ b/packages/common/src/test/object.spec.ts @@ -1,10 +1,26 @@ import { describe, expect, it } from "vitest"; -import { objectKeys } from "../object"; +import { objectEntries, objectKeys } from "../object"; + +const testObjects = [ + { a: 1, c: 3, b: 2 }, + { a: 1, b: 2 }, + { a: 1 }, + {}, +] as const; describe("objectKeys should return all keys of an object", () => { - it("should return all keys of an object", () => { - const obj = { a: 1, b: 2, c: 3 }; - expect(objectKeys(obj)).toEqual(["a", "b", "c"]); + testObjects.forEach((obj) => { + it(`should return all keys of the object ${JSON.stringify(obj)}`, () => { + expect(objectKeys(obj)).toEqual(Object.keys(obj)); + }); + }); +}); + +describe("objectEntries should return all entries of an object", () => { + testObjects.forEach((obj) => { + it(`should return all entries of the object ${JSON.stringify(obj)}`, () => { + expect(objectEntries(obj)).toEqual(Object.entries(obj)); + }); }); }); diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts index 61d448cde..65212ebd1 100644 --- a/packages/db/drizzle.config.ts +++ b/packages/db/drizzle.config.ts @@ -7,4 +7,5 @@ export default { schema: "./schema", driver: "better-sqlite", dbCredentials: { url: process.env.DB_URL! }, + out: "./migrations", } satisfies Config; diff --git a/packages/db/index.ts b/packages/db/index.ts index 7f1a29baf..54ce46b92 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -8,7 +8,7 @@ export const schema = sqliteSchema; export * from "drizzle-orm"; -const sqlite = new Database(process.env.DB_URL); +export const sqlite = new Database(process.env.DB_URL); export const db = drizzle(sqlite, { schema }); diff --git a/packages/db/migrations/0000_true_red_wolf.sql b/packages/db/migrations/0000_true_red_wolf.sql new file mode 100644 index 000000000..3a9c02707 --- /dev/null +++ b/packages/db/migrations/0000_true_red_wolf.sql @@ -0,0 +1,112 @@ +CREATE TABLE `account` ( + `userId` text NOT NULL, + `type` text NOT NULL, + `provider` text NOT NULL, + `providerAccountId` text NOT NULL, + `refresh_token` text, + `access_token` text, + `expires_at` integer, + `token_type` text, + `scope` text, + `id_token` text, + `session_state` text, + PRIMARY KEY(`provider`, `providerAccountId`), + FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `board` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `is_public` integer DEFAULT false NOT NULL, + `page_title` text, + `meta_title` text, + `logo_image_url` text, + `favicon_image_url` text, + `background_image_url` text, + `background_image_attachment` text DEFAULT 'fixed' NOT NULL, + `background_image_repeat` text DEFAULT 'no-repeat' NOT NULL, + `background_image_size` text DEFAULT 'cover' NOT NULL, + `primary_color` text DEFAULT 'red' NOT NULL, + `secondary_color` text DEFAULT 'orange' NOT NULL, + `primary_shade` integer DEFAULT 6 NOT NULL, + `app_opacity` integer DEFAULT 100 NOT NULL, + `custom_css` text, + `show_right_sidebar` integer DEFAULT false NOT NULL, + `show_left_sidebar` integer DEFAULT false NOT NULL, + `column_count` integer DEFAULT 10 NOT NULL +); +--> statement-breakpoint +CREATE TABLE `integration_item` ( + `item_id` text NOT NULL, + `integration_id` text NOT NULL, + PRIMARY KEY(`integration_id`, `item_id`), + FOREIGN KEY (`item_id`) REFERENCES `item`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `integrationSecret` ( + `kind` text NOT NULL, + `value` text NOT NULL, + `updated_at` integer NOT NULL, + `integration_id` text NOT NULL, + PRIMARY KEY(`integration_id`, `kind`), + FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `integration` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `url` text NOT NULL, + `kind` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `item` ( + `id` text PRIMARY KEY NOT NULL, + `section_id` text NOT NULL, + `kind` text NOT NULL, + `x_offset` integer NOT NULL, + `y_offset` integer NOT NULL, + `width` integer NOT NULL, + `height` integer NOT NULL, + `options` text DEFAULT '{"json": {}}' NOT NULL, + FOREIGN KEY (`section_id`) REFERENCES `section`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `section` ( + `id` text PRIMARY KEY NOT NULL, + `board_id` text NOT NULL, + `kind` text NOT NULL, + `position` integer NOT NULL, + `name` text, + FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `session` ( + `sessionToken` text PRIMARY KEY NOT NULL, + `userId` text NOT NULL, + `expires` integer NOT NULL, + FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `user` ( + `id` text PRIMARY KEY NOT NULL, + `name` text, + `email` text, + `emailVerified` integer, + `image` text, + `password` text, + `salt` text +); +--> statement-breakpoint +CREATE TABLE `verificationToken` ( + `identifier` text NOT NULL, + `token` text NOT NULL, + `expires` integer NOT NULL, + PRIMARY KEY(`identifier`, `token`) +); +--> statement-breakpoint +CREATE INDEX `userId_idx` ON `account` (`userId`);--> statement-breakpoint +CREATE INDEX `integration_secret__kind_idx` ON `integrationSecret` (`kind`);--> statement-breakpoint +CREATE INDEX `integration_secret__updated_at_idx` ON `integrationSecret` (`updated_at`);--> statement-breakpoint +CREATE INDEX `integration__kind_idx` ON `integration` (`kind`);--> statement-breakpoint +CREATE INDEX `user_id_idx` ON `session` (`userId`); \ No newline at end of file diff --git a/packages/db/migrations/meta/0000_snapshot.json b/packages/db/migrations/meta/0000_snapshot.json new file mode 100644 index 000000000..2a8c974ab --- /dev/null +++ b/packages/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,696 @@ +{ + "version": "5", + "dialect": "sqlite", + "id": "c9ea435a-5bbf-4439-84a1-55d3e2581b13", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "account": { + "name": "account", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "userId_idx": { + "name": "userId_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "columns": ["provider", "providerAccountId"], + "name": "account_provider_providerAccountId_pk" + } + }, + "uniqueConstraints": {} + }, + "board": { + "name": "board", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_public": { + "name": "is_public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "page_title": { + "name": "page_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta_title": { + "name": "meta_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo_image_url": { + "name": "logo_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon_image_url": { + "name": "favicon_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_url": { + "name": "background_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_attachment": { + "name": "background_image_attachment", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'fixed'" + }, + "background_image_repeat": { + "name": "background_image_repeat", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'no-repeat'" + }, + "background_image_size": { + "name": "background_image_size", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'cover'" + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'red'" + }, + "secondary_color": { + "name": "secondary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'orange'" + }, + "primary_shade": { + "name": "primary_shade", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 6 + }, + "app_opacity": { + "name": "app_opacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 100 + }, + "custom_css": { + "name": "custom_css", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_right_sidebar": { + "name": "show_right_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_left_sidebar": { + "name": "show_left_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "column_count": { + "name": "column_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 10 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "integration_item": { + "name": "integration_item", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integration_item_item_id_item_id_fk": { + "name": "integration_item_item_id_item_id_fk", + "tableFrom": "integration_item", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_item_integration_id_integration_id_fk": { + "name": "integration_item_integration_id_integration_id_fk", + "tableFrom": "integration_item", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_item_item_id_integration_id_pk": { + "columns": ["integration_id", "item_id"], + "name": "integration_item_item_id_integration_id_pk" + } + }, + "uniqueConstraints": {} + }, + "integrationSecret": { + "name": "integrationSecret", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration_secret__kind_idx": { + "name": "integration_secret__kind_idx", + "columns": ["kind"], + "isUnique": false + }, + "integration_secret__updated_at_idx": { + "name": "integration_secret__updated_at_idx", + "columns": ["updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "integrationSecret_integration_id_integration_id_fk": { + "name": "integrationSecret_integration_id_integration_id_fk", + "tableFrom": "integrationSecret", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationSecret_integration_id_kind_pk": { + "columns": ["integration_id", "kind"], + "name": "integrationSecret_integration_id_kind_pk" + } + }, + "uniqueConstraints": {} + }, + "integration": { + "name": "integration", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration__kind_idx": { + "name": "integration__kind_idx", + "columns": ["kind"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "item": { + "name": "item", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"json\": {}}'" + } + }, + "indexes": {}, + "foreignKeys": { + "item_section_id_section_id_fk": { + "name": "item_section_id_section_id_fk", + "tableFrom": "item", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "section": { + "name": "section", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_board_id_board_id_fk": { + "name": "section_board_id_board_id_fk", + "tableFrom": "section", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "columns": ["identifier", "token"], + "name": "verificationToken_identifier_token_pk" + } + }, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json new file mode 100644 index 000000000..843dad674 --- /dev/null +++ b/packages/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "5", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "5", + "when": 1707511343363, + "tag": "0000_true_red_wolf", + "breakpoints": true + } + ] +} diff --git a/packages/db/package.json b/packages/db/package.json index 2529991c8..84e74d777 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -4,7 +4,8 @@ "exports": { ".": "./index.ts", "./client": "./client.ts", - "./schema/sqlite": "./schema/sqlite.ts" + "./schema/sqlite": "./schema/sqlite.ts", + "./test": "./test/index.ts" }, "private": true, "main": "./index.ts", @@ -14,6 +15,7 @@ "clean": "rm -rf .turbo node_modules", "lint": "eslint .", "format": "prettier --check . --ignore-path ../../.gitignore", + "migration:generate": "drizzle-kit generate:sqlite", "push": "drizzle-kit push:sqlite", "studio": "drizzle-kit studio", "typecheck": "tsc --noEmit" diff --git a/packages/db/test/db-mock.ts b/packages/db/test/db-mock.ts new file mode 100644 index 000000000..659e1e21b --- /dev/null +++ b/packages/db/test/db-mock.ts @@ -0,0 +1,14 @@ +import Database from "better-sqlite3"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; + +import { schema } from ".."; + +export const createDb = () => { + const sqlite = new Database(":memory:"); + const db = drizzle(sqlite, { schema }); + migrate(db, { + migrationsFolder: "./packages/db/migrations", + }); + return db; +}; diff --git a/packages/db/test/index.ts b/packages/db/test/index.ts new file mode 100644 index 000000000..31046067d --- /dev/null +++ b/packages/db/test/index.ts @@ -0,0 +1 @@ +export * from "./db-mock"; diff --git a/packages/definitions/src/test/integration.spec.ts b/packages/definitions/src/test/integration.spec.ts new file mode 100644 index 000000000..bdb5194fd --- /dev/null +++ b/packages/definitions/src/test/integration.spec.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; + +import { objectEntries } from "@homarr/common"; + +import { integrationDefs } from "../integration"; + +describe("Icon url's of integrations should be valid and return 200", () => { + objectEntries(integrationDefs).forEach(([integration, { iconUrl }]) => { + it.concurrent(`should return 200 for ${integration}`, async () => { + const res = await fetch(iconUrl); + expect(res.status).toBe(200); + }); + }); +}); diff --git a/packages/translation/src/index.ts b/packages/translation/src/index.ts index c49f69163..9fb09f4f6 100644 --- a/packages/translation/src/index.ts +++ b/packages/translation/src/index.ts @@ -2,3 +2,4 @@ export const supportedLanguages = ["en", "de"] as const; export type SupportedLanguage = (typeof supportedLanguages)[number]; export const defaultLocale = "en"; +export { languageMapping } from "./lang"; diff --git a/packages/validation/src/board.ts b/packages/validation/src/board.ts index 6b56666fb..96d0e4f9e 100644 --- a/packages/validation/src/board.ts +++ b/packages/validation/src/board.ts @@ -29,10 +29,11 @@ const saveGeneralSettingsSchema = z.object({ .string() .nullable() .transform((value) => (value?.trim().length === 0 ? null : value)), + boardId: z.string(), }); const saveSchema = z.object({ - name: boardNameSchema, + boardId: z.string(), sections: z.array(createSectionSchema(commonItemSchema)), }); diff --git a/packages/widgets/src/test/translation.spec.ts b/packages/widgets/src/test/translation.spec.ts new file mode 100644 index 000000000..5415fefcc --- /dev/null +++ b/packages/widgets/src/test/translation.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { objectEntries } from "@homarr/common"; +import { languageMapping } from "@homarr/translation"; + +import { widgetImports } from ".."; + +describe("Widget properties with description should have matching translations", async () => { + const enTranslation = await languageMapping().en(); + objectEntries(widgetImports).forEach(([key, value]) => { + Object.entries(value.definition.options).forEach( + ([optionKey, optionValue]: [string, { withDescription?: boolean }]) => { + it(`should have matching translations for ${optionKey} option description of ${key} widget`, () => { + const option = enTranslation.default.widget[key].option; + if (!(optionKey in option)) { + throw new Error(`Option ${optionKey} not found in translation`); + } + const value = option[optionKey as keyof typeof option]; + + expect("description" in value).toBe(optionValue.withDescription); + }); + }, + ); + }); +}); + +describe("Widget properties should have matching name translations", async () => { + const enTranslation = await languageMapping().en(); + objectEntries(widgetImports).forEach(([key, value]) => { + Object.keys(value.definition.options).forEach((optionKey) => { + it(`should have matching translations for ${optionKey} option name of ${key} widget`, () => { + const option = enTranslation.default.widget[key].option; + if (!(optionKey in option)) { + throw new Error(`Option ${optionKey} not found in translation`); + } + const value = option[optionKey as keyof typeof option]; + + expect("label" in value).toBe(true); + }); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da8fc9bcc..872c56913 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,9 @@ importers: '@homarr/prettier-config': specifier: workspace:^0.1.0 version: link:tooling/prettier + '@testing-library/react-hooks': + specifier: ^8.0.1 + version: 8.0.1(react@17.0.2) '@turbo/gen': specifier: ^1.12.3 version: 1.12.3(@types/node@20.11.17)(typescript@5.3.3) @@ -30,6 +33,9 @@ importers: cross-env: specifier: ^7.0.3 version: 7.0.3 + jsdom: + specifier: ^24.0.0 + version: 24.0.0 prettier: specifier: ^3.2.5 version: 3.2.5 @@ -44,7 +50,7 @@ importers: version: 4.3.1(typescript@5.3.3)(vite@5.0.12) vitest: specifier: ^1.2.2 - version: 1.2.2(@types/node@20.11.17)(@vitest/ui@1.2.2) + version: 1.2.2(@types/node@20.11.17)(@vitest/ui@1.2.2)(jsdom@24.0.0) apps/nextjs: dependencies: @@ -326,7 +332,7 @@ importers: version: 9.4.0 drizzle-orm: specifier: ^0.29.3 - version: 0.29.3(@types/better-sqlite3@7.6.9)(better-sqlite3@9.4.0) + version: 0.29.3(@types/better-sqlite3@7.6.9)(better-sqlite3@9.4.0)(react@17.0.2) devDependencies: '@homarr/eslint-config': specifier: workspace:^0.2.0 @@ -961,7 +967,6 @@ packages: engines: {node: '>=6.9.0'} dependencies: regenerator-runtime: 0.14.0 - dev: false /@babel/template@7.22.15: resolution: {integrity: sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==} @@ -2150,6 +2155,27 @@ packages: react: 18.2.0 dev: false + /@testing-library/react-hooks@8.0.1(react@17.0.2): + resolution: {integrity: sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==} + engines: {node: '>=12'} + peerDependencies: + '@types/react': ^16.9.0 || ^17.0.0 + react: ^16.9.0 || ^17.0.0 + react-dom: ^16.9.0 || ^17.0.0 + react-test-renderer: ^16.9.0 || ^17.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + react-dom: + optional: true + react-test-renderer: + optional: true + dependencies: + '@babel/runtime': 7.23.2 + react: 17.0.2 + react-error-boundary: 3.1.4(react@17.0.2) + dev: true + /@tiptap/core@2.2.2(@tiptap/pm@2.2.2): resolution: {integrity: sha512-fec26LtNgYFGhKzEA9+Of+qLKIKUxDL/XZQofoPcxP71NffcmpZ+ZjAx9NjnvuYtvylUSySZiPauY6WhN3aprw==} peerDependencies: @@ -2914,7 +2940,7 @@ packages: std-env: 3.7.0 test-exclude: 6.0.0 v8-to-istanbul: 9.2.0 - vitest: 1.2.2(@types/node@20.11.17)(@vitest/ui@1.2.2) + vitest: 1.2.2(@types/node@20.11.17)(@vitest/ui@1.2.2)(jsdom@24.0.0) transitivePeerDependencies: - supports-color dev: true @@ -2961,7 +2987,7 @@ packages: pathe: 1.1.2 picocolors: 1.0.0 sirv: 2.0.4 - vitest: 1.2.2(@types/node@20.11.17)(@vitest/ui@1.2.2) + vitest: 1.2.2(@types/node@20.11.17)(@vitest/ui@1.2.2)(jsdom@24.0.0) dev: true /@vitest/utils@1.2.2: @@ -3232,6 +3258,10 @@ packages: has-symbols: 1.0.3 dev: false + /asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + dev: true + /available-typed-arrays@1.0.5: resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==} engines: {node: '>= 0.4'} @@ -3589,6 +3619,13 @@ packages: text-hex: 1.0.0 dev: false + /combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + dependencies: + delayed-stream: 1.0.0 + dev: true + /commander@10.0.1: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} @@ -3670,6 +3707,13 @@ packages: hasBin: true dev: false + /cssstyle@4.0.1: + resolution: {integrity: sha512-8ZYiJ3A/3OkDd093CBT/0UKDWry7ak4BdPTFP2+QEP7cmhouyq/Up709ASSj2cK02BbZiMgk7kYjZNS4QP5qrQ==} + engines: {node: '>=18'} + dependencies: + rrweb-cssom: 0.6.0 + dev: true + /csstype@3.1.2: resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==} @@ -3693,6 +3737,14 @@ packages: engines: {node: '>= 14'} dev: true + /data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.0.0 + dev: true + /dayjs@1.11.10: resolution: {integrity: sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==} dev: false @@ -3719,6 +3771,10 @@ packages: dependencies: ms: 2.1.2 + /decimal.js@10.4.3: + resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} + dev: true + /decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -3800,6 +3856,11 @@ packages: slash: 3.0.0 dev: true + /delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + dev: true + /delegates@1.0.0: resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} dev: false @@ -3924,7 +3985,7 @@ packages: - supports-color dev: true - /drizzle-orm@0.29.3(@types/better-sqlite3@7.6.9)(better-sqlite3@9.4.0): + /drizzle-orm@0.29.3(@types/better-sqlite3@7.6.9)(better-sqlite3@9.4.0)(react@17.0.2): resolution: {integrity: sha512-uSE027csliGSGYD0pqtM+SAQATMREb3eSM/U8s6r+Y0RFwTKwftnwwSkqx3oS65UBgqDOM0gMTl5UGNpt6lW0A==} peerDependencies: '@aws-sdk/client-rds-data': '>=3' @@ -3997,6 +4058,7 @@ packages: dependencies: '@types/better-sqlite3': 7.6.9 better-sqlite3: 9.4.0 + react: 17.0.2 dev: false /eastasianwidth@0.2.0: @@ -4030,7 +4092,6 @@ packages: /entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} - dev: false /env-paths@3.0.0: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} @@ -4681,6 +4742,15 @@ packages: signal-exit: 4.1.0 dev: false + /form-data@4.0.0: + resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} + engines: {node: '>= 6'} + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + dev: true + /fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} dev: false @@ -5009,6 +5079,13 @@ packages: resolution: {integrity: sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==} dev: true + /html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + dependencies: + whatwg-encoding: 3.1.1 + dev: true + /html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} dev: true @@ -5060,6 +5137,13 @@ packages: safer-buffer: 2.1.2 dev: true + /iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + dependencies: + safer-buffer: 2.1.2 + dev: true + /ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -5304,6 +5388,10 @@ packages: isobject: 3.0.1 dev: false + /is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + dev: true + /is-promise@2.2.2: resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} dev: true @@ -5499,6 +5587,42 @@ packages: dependencies: argparse: 2.0.1 + /jsdom@24.0.0: + resolution: {integrity: sha512-UDS2NayCvmXSXVP6mpTj+73JnNQadZlr9N68189xib2tx5Mls7swlTNao26IoHv46BZJFvXygyRtyXd1feAk1A==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + dependencies: + cssstyle: 4.0.1 + data-urls: 5.0.0 + decimal.js: 10.4.3 + form-data: 4.0.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.0 + https-proxy-agent: 7.0.2 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.7 + parse5: 7.1.2 + rrweb-cssom: 0.6.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.3 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.0.0 + ws: 8.16.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: true + /jsesc@2.5.2: resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} engines: {node: '>=4'} @@ -5669,7 +5793,6 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} dependencies: js-tokens: 4.0.0 - dev: false /loupe@2.3.7: resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} @@ -5804,6 +5927,18 @@ packages: braces: 3.0.2 picomatch: 2.3.1 + /mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + dev: true + + /mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + dependencies: + mime-db: 1.52.0 + dev: true + /mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -6091,6 +6226,10 @@ packages: set-blocking: 2.0.0 dev: false + /nwsapi@2.2.7: + resolution: {integrity: sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==} + dev: true + /oauth4webapi@2.10.3: resolution: {integrity: sha512-9FkXEXfzVKzH63GUOZz1zMr3wBaICSzk6DLXx+CGdrQ10ItNk2ePWzYYc1fdmKq1ayGFb2aX97sRCoZ2s0mkDw==} dev: false @@ -6098,7 +6237,6 @@ packages: /object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} - dev: false /object-inspect@1.12.3: resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==} @@ -6340,6 +6478,12 @@ packages: dependencies: callsites: 3.1.0 + /parse5@7.1.2: + resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==} + dependencies: + entities: 4.5.0 + dev: true + /pascal-case@2.0.1: resolution: {integrity: sha512-qjS4s8rBOJa2Xm0jmxXiyh1+OFf6ekCWOvUaRgAQSktzlTbMotS0nmG9gyYAybCWBcuP4fsBeRCKNwGBnMe2OQ==} dependencies: @@ -6720,6 +6864,10 @@ packages: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} dev: true + /psl@1.9.0: + resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} + dev: true + /pump@3.0.0: resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==} dependencies: @@ -6736,6 +6884,15 @@ packages: resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==} engines: {node: '>=6'} + /punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + dev: true + + /querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + dev: true + /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -6758,6 +6915,16 @@ packages: scheduler: 0.23.0 dev: false + /react-error-boundary@3.1.4(react@17.0.2): + resolution: {integrity: sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==} + engines: {node: '>=10', npm: '>=6'} + peerDependencies: + react: '>=16.13.1' + dependencies: + '@babel/runtime': 7.23.2 + react: 17.0.2 + dev: true + /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} dev: false @@ -6870,6 +7037,13 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /react@17.0.2: + resolution: {integrity: sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==} + engines: {node: '>=0.10.0'} + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + /react@18.2.0: resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} engines: {node: '>=0.10.0'} @@ -6942,6 +7116,10 @@ packages: rc: 1.2.8 dev: true + /requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + dev: true + /resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -7010,6 +7188,10 @@ packages: resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} dev: false + /rrweb-cssom@0.6.0: + resolution: {integrity: sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==} + dev: true + /run-async@2.4.1: resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} engines: {node: '>=0.12.0'} @@ -7083,6 +7265,13 @@ packages: source-map-js: 1.0.2 dev: false + /saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + dependencies: + xmlchars: 2.2.0 + dev: true + /scheduler@0.23.0: resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==} dependencies: @@ -7408,6 +7597,10 @@ packages: upper-case: 1.1.3 dev: true + /symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + dev: true + /tabbable@6.2.0: resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} dev: false @@ -7536,10 +7729,27 @@ packages: engines: {node: '>=6'} dev: true + /tough-cookie@4.1.3: + resolution: {integrity: sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==} + engines: {node: '>=6'} + dependencies: + psl: 1.9.0 + punycode: 2.3.0 + universalify: 0.2.0 + url-parse: 1.5.10 + dev: true + /tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} dev: false + /tr46@5.0.0: + resolution: {integrity: sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==} + engines: {node: '>=18'} + dependencies: + punycode: 2.3.1 + dev: true + /triple-beam@1.4.1: resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} engines: {node: '>= 14.0.0'} @@ -7798,6 +8008,11 @@ packages: engines: {node: '>= 4.0.0'} dev: true + /universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + dev: true + /universalify@2.0.0: resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==} engines: {node: '>= 10.0.0'} @@ -7846,6 +8061,13 @@ packages: dependencies: punycode: 2.3.0 + /url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + dev: true + /use-callback-ref@1.3.0(@types/react@18.2.55)(react@18.2.0): resolution: {integrity: sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w==} engines: {node: '>=10'} @@ -8009,7 +8231,7 @@ packages: fsevents: 2.3.3 dev: true - /vitest@1.2.2(@types/node@20.11.17)(@vitest/ui@1.2.2): + /vitest@1.2.2(@types/node@20.11.17)(@vitest/ui@1.2.2)(jsdom@24.0.0): resolution: {integrity: sha512-d5Ouvrnms3GD9USIK36KG8OZ5bEvKEkITFtnGv56HFaSlbItJuYr7hv2Lkn903+AvRAgSixiamozUVfORUekjw==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true @@ -8046,6 +8268,7 @@ packages: chai: 4.4.1 debug: 4.3.4 execa: 8.0.1 + jsdom: 24.0.0 local-pkg: 0.5.0 magic-string: 0.30.6 pathe: 1.1.2 @@ -8071,6 +8294,13 @@ packages: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} dev: false + /w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + dependencies: + xml-name-validator: 5.0.0 + dev: true + /wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} dependencies: @@ -8081,6 +8311,31 @@ packages: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} dev: false + /webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + dev: true + + /whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + dependencies: + iconv-lite: 0.6.3 + dev: true + + /whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + dev: true + + /whatwg-url@14.0.0: + resolution: {integrity: sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==} + engines: {node: '>=18'} + dependencies: + tr46: 5.0.0 + webidl-conversions: 7.0.0 + dev: true + /whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} dependencies: @@ -8217,6 +8472,28 @@ packages: /wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + /ws@8.16.0: + resolution: {integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: true + + /xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + dev: true + + /xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + dev: true + /yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} diff --git a/tooling/typescript/base.json b/tooling/typescript/base.json index 8ab2a746f..944bb0a3c 100644 --- a/tooling/typescript/base.json +++ b/tooling/typescript/base.json @@ -15,7 +15,13 @@ "moduleDetection": "force", "jsx": "preserve", "incremental": true, - "noUncheckedIndexedAccess": true + "noUncheckedIndexedAccess": true, + "baseUrl": ".", + "paths": { + "*": [ + "node_modules/*" + ] + } }, "exclude": ["node_modules", "build", "dist", ".next"] } diff --git a/vitest.config.ts b/vitest.config.ts index 11cbd664b..9d5aec73d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,8 @@ import { configDefaults, defineConfig } from "vitest/config"; export default defineConfig({ plugins: [react(), tsconfigPaths()], test: { + setupFiles: ["./vitest.setup.ts"], + environment: "jsdom", include: ["**/*.spec.ts"], poolOptions: { threads: { diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 000000000..9c01f0b66 --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1,3 @@ +import { vi } from "vitest"; + +vi.mock("server-only", () => ({ default: undefined }));