mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-26 08:20:56 +01:00
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
This commit is contained in:
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
mutate(values);
|
||||
saveGeneralSettings({
|
||||
boardId: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = <TInput extends { id: string }>(
|
||||
);
|
||||
|
||||
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<unknown>) => {
|
||||
const board = await db.query.boards.findFirst({
|
||||
where: eq(boards.name, name),
|
||||
where,
|
||||
with: {
|
||||
sections: {
|
||||
with: {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
648
packages/api/src/router/test/board.spec.ts
Normal file
648
packages/api/src/router/test/board.spec.ts
Normal file
@@ -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 = <T>(value: T) => {
|
||||
if (value === undefined) {
|
||||
expect(value).toBeDefined();
|
||||
}
|
||||
if (value === null) {
|
||||
expect(value).not.toBeNull();
|
||||
}
|
||||
return value as Exclude<T, undefined | null>;
|
||||
};
|
||||
|
||||
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<ReturnType<typeof createFullBoardAsync>>,
|
||||
) => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
503
packages/api/src/router/test/integration.spec.ts
Normal file
503
packages/api/src/router/test/integration.spec.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
94
packages/api/src/router/test/user.spec.ts
Normal file
94
packages/api/src/router/test/user.spec.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
61
packages/auth/callbacks.ts
Normal file
61
packages/auth/callbacks.ts
Normal file
@@ -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<NextAuthConfig["callbacks"], undefined>;
|
||||
export type NextAuthCallbackOf<TKey extends keyof NextAuthCallbackRecord> =
|
||||
Exclude<NextAuthCallbackRecord[TKey], undefined>;
|
||||
@@ -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 ?? "";
|
||||
},
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof Credentials>[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;
|
||||
|
||||
66
packages/auth/providers/test/credentials.spec.ts
Normal file
66
packages/auth/providers/test/credentials.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
153
packages/auth/test/callbacks.spec.ts
Normal file
153
packages/auth/test/callbacks.spec.ts
Normal file
@@ -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<Adapter["createSession"], undefined>
|
||||
>[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<SessionExport>();
|
||||
|
||||
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<HeadersExport>();
|
||||
|
||||
const result = {
|
||||
set: (name: string, value: string, options: Partial<ResponseCookie>) =>
|
||||
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,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
47
packages/auth/test/security.spec.ts
Normal file
47
packages/auth/test/security.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
43
packages/auth/test/session.spec.ts
Normal file
43
packages/auth/test/session.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,4 +7,5 @@ export default {
|
||||
schema: "./schema",
|
||||
driver: "better-sqlite",
|
||||
dbCredentials: { url: process.env.DB_URL! },
|
||||
out: "./migrations",
|
||||
} satisfies Config;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
112
packages/db/migrations/0000_true_red_wolf.sql
Normal file
112
packages/db/migrations/0000_true_red_wolf.sql
Normal file
@@ -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`);
|
||||
696
packages/db/migrations/meta/0000_snapshot.json
Normal file
696
packages/db/migrations/meta/0000_snapshot.json
Normal file
@@ -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": {}
|
||||
}
|
||||
}
|
||||
13
packages/db/migrations/meta/_journal.json
Normal file
13
packages/db/migrations/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "5",
|
||||
"when": 1707511343363,
|
||||
"tag": "0000_true_red_wolf",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
14
packages/db/test/db-mock.ts
Normal file
14
packages/db/test/db-mock.ts
Normal file
@@ -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;
|
||||
};
|
||||
1
packages/db/test/index.ts
Normal file
1
packages/db/test/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./db-mock";
|
||||
14
packages/definitions/src/test/integration.spec.ts
Normal file
14
packages/definitions/src/test/integration.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
|
||||
|
||||
42
packages/widgets/src/test/translation.spec.ts
Normal file
42
packages/widgets/src/test/translation.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
297
pnpm-lock.yaml
generated
297
pnpm-lock.yaml
generated
@@ -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==}
|
||||
|
||||
|
||||
@@ -15,7 +15,13 @@
|
||||
"moduleDetection": "force",
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"noUncheckedIndexedAccess": true
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"*": [
|
||||
"node_modules/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "build", "dist", ".next"]
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
3
vitest.setup.ts
Normal file
3
vitest.setup.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({ default: undefined }));
|
||||
Reference in New Issue
Block a user