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:
Meier Lukas
2024-02-10 19:00:08 +01:00
committed by GitHub
parent 13aae82790
commit f070a0cb0a
34 changed files with 3014 additions and 129 deletions

View File

@@ -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: {

View File

@@ -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,
});
};

View 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,
};
};

View 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");
});
});

View 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");
});
});