feat: test integration connection (#669)

* feat: add test integration for pi-hole

* refactor: test integration for pi-hole

* fix: multiple secrets of same type could be used for integration creation

* fix: remove integration test connection test and add mock for test-connection function

* fix: add missing onUpdateFn to mysql integration secrets

* fix: format issues

* feat: add home assistant test connection

* fix: deepsource issues

* test: add system integration tests for test connection

* fix: add before all for pulling home assistant image

* test: add unit tests for handleTestConnectionResponseAsync

* test: add unit test for testConnectionAsync

* test: add mroe tests to integration-test-connection

* fix: deepsource issues

* fix: deepsource issue

* chore: address pull request feedback
This commit is contained in:
Meier Lukas authored and GitHub committed 2024-06-22 21:02:04 +02:00
1 parent 92afd82d22
commit f92aeba403
30 files changed
+1138 -550

No files matched your search

@@ -0,0 +1,294 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { describe, expect, it, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { encryptSecret } from "@homarr/common";
import { createId } from "@homarr/db";
import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
import { integrationRouter } from "../../integration/integration-router";
import { expectToBeDefined } from "../helper";
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
vi.mock("../../integration/integration-test-connection", () => ({
testConnectionAsync: async () => await Promise.resolve(undefined),
}));
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 actAsync = async () => await caller.byId({ id: "2" });
await expect(actAsync()).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 actAsync = async () =>
await caller.update({
id: createId(),
name: "Pi Hole",
url: "http://hole.local",
secrets: [],
});
await expect(actAsync()).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);
});
});
@@ -0,0 +1,253 @@
import { describe, expect, test, vi } from "vitest";
import * as homarrDefinitions from "@homarr/definitions";
import * as homarrIntegrations from "@homarr/integrations";
import { testConnectionAsync } from "../../integration/integration-test-connection";
vi.mock("@homarr/common", async (importActual) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const actual = await importActual<typeof import("@homarr/common")>();
return {
...actual,
decryptSecret: (value: string) => value.split(".")[0],
};
});
describe("testConnectionAsync should run test connection of integration", () => {
test("with input of only form secrets matching api key kind it should use form apiKey", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreatorByKind");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegration);
optionsSpy.mockReturnValue([["apiKey"]]);
const integration = {
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
kind: "piHole" as const,
secrets: [
{
kind: "apiKey" as const,
value: "secret",
},
],
};
// Act
await testConnectionAsync(integration);
// Assert
expect(factorySpy).toHaveBeenCalledWith("piHole", {
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
decryptedSecrets: [
expect.objectContaining({
kind: "apiKey",
value: "secret",
}),
],
});
});
test("with input of only null form secrets and the required db secrets matching api key kind it should use db apiKey", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreatorByKind");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegration);
optionsSpy.mockReturnValue([["apiKey"]]);
const integration = {
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
kind: "piHole" as const,
secrets: [
{
kind: "apiKey" as const,
value: null,
},
],
};
const dbSecrets = [
{
kind: "apiKey" as const,
value: "dbSecret.encrypted" as const,
},
];
// Act
await testConnectionAsync(integration, dbSecrets);
// Assert
expect(factorySpy).toHaveBeenCalledWith("piHole", {
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
decryptedSecrets: [
expect.objectContaining({
kind: "apiKey",
value: "dbSecret",
}),
],
});
});
test("with input of form and db secrets matching api key kind it should use form apiKey", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreatorByKind");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegration);
optionsSpy.mockReturnValue([["apiKey"]]);
const integration = {
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
kind: "piHole" as const,
secrets: [
{
kind: "apiKey" as const,
value: "secret",
},
],
};
const dbSecrets = [
{
kind: "apiKey" as const,
value: "dbSecret.encrypted" as const,
},
];
// Act
await testConnectionAsync(integration, dbSecrets);
// Assert
expect(factorySpy).toHaveBeenCalledWith("piHole", {
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
decryptedSecrets: [
expect.objectContaining({
kind: "apiKey",
value: "secret",
}),
],
});
});
test("with input of form apiKey and db secrets for username and password it should use form apiKey when both is allowed", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreatorByKind");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegration);
optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]);
const integration = {
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
kind: "piHole" as const,
secrets: [
{
kind: "apiKey" as const,
value: "secret",
},
],
};
const dbSecrets = [
{
kind: "username" as const,
value: "dbUsername.encrypted" as const,
},
{
kind: "password" as const,
value: "dbPassword.encrypted" as const,
},
];
// Act
await testConnectionAsync(integration, dbSecrets);
// Assert
expect(factorySpy).toHaveBeenCalledWith("piHole", {
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
decryptedSecrets: [
expect.objectContaining({
kind: "apiKey",
value: "secret",
}),
],
});
});
test("with input of null form apiKey and db secrets for username and password it should use db username and password when both is allowed", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreatorByKind");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegration);
optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]);
const integration = {
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
kind: "piHole" as const,
secrets: [
{
kind: "apiKey" as const,
value: null,
},
],
};
const dbSecrets = [
{
kind: "username" as const,
value: "dbUsername.encrypted" as const,
},
{
kind: "password" as const,
value: "dbPassword.encrypted" as const,
},
];
// Act
await testConnectionAsync(integration, dbSecrets);
// Assert
expect(factorySpy).toHaveBeenCalledWith("piHole", {
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
decryptedSecrets: [
expect.objectContaining({
kind: "username",
value: "dbUsername",
}),
expect.objectContaining({
kind: "password",
value: "dbPassword",
}),
],
});
});
});