Replace entire codebase with homarr-labs/homarr

This commit is contained in:
Thomas Camlong
2026-01-15 21:54:44 +01:00
parent c5bc3b1559
commit 4fdd1fe351
4666 changed files with 409577 additions and 147434 deletions

46
e2e/health-checks.spec.ts Normal file
View File

@@ -0,0 +1,46 @@
import { describe, expect, test } from "vitest";
import { createHomarrContainer } from "./shared/create-homarr-container";
import { createRedisContainer } from "./shared/redis-container";
describe("Health checks", () => {
test("ready and live should return 200 OK with normal image and no extra configuration", async () => {
// Arrange
const homarrContainer = await createHomarrContainer().start();
// Act
const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`);
const liveResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/live`);
// Assert
expect(readyResponse.status).toBe(200);
expect(liveResponse.status).toBe(200);
}, 20_000);
test("ready and live should return 200 OK with external redis", async () => {
// Arrange
const redisContainer = await createRedisContainer().start();
const homarrContainer = await createHomarrContainer({
environment: {
REDIS_IS_EXTERNAL: "true",
REDIS_HOST: "host.docker.internal",
REDIS_PORT: redisContainer.getMappedPort(6379).toString(),
REDIS_PASSWORD: redisContainer.getPassword(),
},
}).start();
// Act
const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`);
const liveResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/live`);
// Assert
expect(
readyResponse.status,
`Expected ready to return OK statusCode=${readyResponse.status} content=${await readyResponse.text()}`,
).toBe(200);
expect(
liveResponse.status,
`Expected live to return OK statusCode=${liveResponse.status} content=${await liveResponse.text()}`,
).toBe(200);
}, 20_000);
});

16
e2e/home.spec.ts Normal file
View File

@@ -0,0 +1,16 @@
import { describe, expect, test } from "vitest";
import { createHomarrContainer } from "./shared/create-homarr-container";
describe("Home", () => {
test("should open with status code 200", async () => {
// Arrange
const homarrContainer = await createHomarrContainer().start();
// Act
const homeResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/`);
// Assert
expect(homeResponse.status).toBe(200);
}, 20_000);
});

92
e2e/lldap.spec.ts Normal file
View File

@@ -0,0 +1,92 @@
import { chromium } from "playwright";
import { GenericContainer } from "testcontainers";
import { describe, expect, test } from "vitest";
import { OnboardingActions } from "./shared/actions/onboarding-actions";
import { createHomarrContainer, withLogs } from "./shared/create-homarr-container";
import { createSqliteDbFileAsync } from "./shared/e2e-db";
const defaultCredentials = {
username: "admin",
password: "password",
email: "admin@homarr.dev",
group: "lldap_admin",
};
const ldapBase = "dc=example,dc=com";
describe("LLDAP authorization", () => {
test("Authorize with LLDAP successfully", async () => {
// Arrange
const lldapContainer = await createLldapContainer().start();
const { db, localMountPath } = await createSqliteDbFileAsync();
const homarrContainer = await createHomarrContainer({
environment: {
AUTH_PROVIDERS: "ldap",
AUTH_LDAP_URI: `ldap://host.docker.internal:${lldapContainer.getMappedPort(3890)}`,
AUTH_LDAP_BASE: ldapBase,
AUTH_LDAP_BIND_DN: `uid=${defaultCredentials.username},ou=People,${ldapBase}`,
AUTH_LDAP_BIND_PASSWORD: defaultCredentials.password,
},
mounts: {
"/appdata": localMountPath,
},
}).start();
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
const onboardingActions = new OnboardingActions(page, db);
await onboardingActions.skipOnboardingAsync({
group: defaultCredentials.group,
});
// Act
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/auth/login`);
await page.getByLabel("Username").fill(defaultCredentials.username);
await page.getByLabel("Password").fill(defaultCredentials.password);
await page.locator("css=button[type='submit']").click();
// Assert
await page.waitForURL(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
const users = await db.query.users.findMany({
with: {
groups: {
with: {
group: true,
},
},
},
});
expect(users).toHaveLength(1);
const user = users[0]!;
expect(user).toEqual(
expect.objectContaining({
name: defaultCredentials.username,
email: defaultCredentials.email,
provider: "ldap",
}),
);
const groups = user.groups.map((g) => g.group.name);
expect(groups).toContain(defaultCredentials.group);
// Cleanup
await browser.close();
await homarrContainer.stop();
await lldapContainer.stop();
}, 120_000);
});
const createLldapContainer = () => {
return withLogs(
new GenericContainer("lldap/lldap:stable").withExposedPorts(3890).withEnvironment({
LLDAP_JWT_SECRET: "REPLACE_WITH_RANDOM",
LLDAP_KEY_SEED: "REPLACE_WITH_RANDOM",
LLDAP_LDAP_BASE_DN: ldapBase,
LLDAP_LDAP_USER_PASS: defaultCredentials.password,
LLDAP_LDAP_USER_EMAIL: defaultCredentials.email,
}),
);
};

85
e2e/onboarding.spec.ts Normal file
View File

@@ -0,0 +1,85 @@
import { chromium } from "playwright";
import { describe, test } from "vitest";
import { OnboardingActions } from "./shared/actions/onboarding-actions";
import { OnboardingAssertions } from "./shared/assertions/onboarding-assertions";
import { createHomarrContainer } from "./shared/create-homarr-container";
import { createSqliteDbFileAsync } from "./shared/e2e-db";
describe("Onboarding", () => {
test("Credentials onboarding should be successful", async () => {
// Arrange
const { db, localMountPath } = await createSqliteDbFileAsync();
const homarrContainer = await createHomarrContainer({
mounts: {
"/appdata": localMountPath,
},
}).start();
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
const actions = new OnboardingActions(page, db);
const assertions = new OnboardingAssertions(page, db);
// Act
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
await actions.startOnboardingAsync("scratch");
await actions.processUserStepAsync({
username: "admin",
password: "Comp(exP4sswOrd",
confirmPassword: "Comp(exP4sswOrd",
});
await actions.processSettingsStepAsync();
// Assert
await assertions.assertFinishStepVisibleAsync();
await assertions.assertUserAndAdminGroupInsertedAsync("admin");
await assertions.assertDbOnboardingStepAsync("finish");
// Cleanup
await browser.close();
await homarrContainer.stop();
}, 60_000);
test("External provider onboarding setup should be successful", async () => {
// Arrange
const { db, localMountPath } = await createSqliteDbFileAsync();
const homarrContainer = await createHomarrContainer({
environment: {
AUTH_PROVIDERS: "ldap",
AUTH_LDAP_URI: "ldap://host.docker.internal:3890",
AUTH_LDAP_BASE: "not-used",
AUTH_LDAP_BIND_DN: "not-used",
AUTH_LDAP_BIND_PASSWORD: "not-used",
},
mounts: {
"/appdata": localMountPath,
},
}).start();
const externalGroupName = "oidc-admins";
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
const actions = new OnboardingActions(page, db);
const assertions = new OnboardingAssertions(page, db);
// Act
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
await actions.startOnboardingAsync("scratch");
await actions.processExternalGroupStepAsync({
name: externalGroupName,
});
await actions.processSettingsStepAsync();
// Assert
await assertions.assertFinishStepVisibleAsync();
await assertions.assertExternalGroupInsertedAsync(externalGroupName);
await assertions.assertDbOnboardingStepAsync("finish");
// Cleanup
await browser.close();
await homarrContainer.stop();
}, 60_000);
});

View File

@@ -0,0 +1,54 @@
import { createId } from "@paralleldrive/cuid2";
import type { Page } from "playwright";
import * as sqliteSchema from "../../../packages/db/schema/sqlite";
import type { SqliteDatabase } from "../e2e-db";
export class OnboardingActions {
private readonly page: Page;
private readonly db: SqliteDatabase;
constructor(page: Page, db: SqliteDatabase) {
this.page = page;
this.db = db;
}
public async skipOnboardingAsync(input?: { group?: string }) {
await this.db.update(sqliteSchema.onboarding).set({
step: "finish",
});
if (input?.group) {
await this.db.insert(sqliteSchema.groups).values({
id: createId(),
name: input.group,
position: 1,
});
}
}
public async startOnboardingAsync(type: "scratch" | "before 1.0") {
await this.page.locator("button", { hasText: type }).click();
}
public async processUserStepAsync(input: { username: string; password: string; confirmPassword: string }) {
await this.page.waitForSelector("text=administrator user");
await this.page.getByLabel("Username").fill(input.username);
await this.page.getByLabel("Password", { exact: true }).fill(input.password);
await this.page.getByLabel("Confirm password").fill(input.confirmPassword);
await this.page.locator("css=button[type='submit']").click();
}
public async processExternalGroupStepAsync(input: { name: string }) {
await this.page.waitForSelector("text=external provider");
await this.page.locator("input").fill(input.name);
await this.page.locator("css=button[type='submit']").click();
}
public async processSettingsStepAsync() {
await this.page.waitForSelector("text=Analytics");
await this.page.locator("css=button[type='submit']").click();
}
}

View File

@@ -0,0 +1,62 @@
import { eq } from "drizzle-orm";
import type { Page } from "playwright";
import { expect } from "vitest";
import * as sqliteSchema from "../../../packages/db/schema/sqlite";
import { OnboardingStep } from "../../../packages/definitions/src";
import { credentialsAdminGroup } from "../../../packages/definitions/src/group";
import type { SqliteDatabase } from "../e2e-db";
export class OnboardingAssertions {
private readonly page: Page;
private readonly db: SqliteDatabase;
constructor(page: Page, db: SqliteDatabase) {
this.page = page;
this.db = db;
}
public async assertDbOnboardingStepAsync(expectedStep: OnboardingStep) {
const onboarding = await this.db.query.onboarding.findFirst();
expect(onboarding?.step).toEqual(expectedStep);
}
public async assertUserAndAdminGroupInsertedAsync(expectedUsername: string) {
const users = await this.db.query.users.findMany({
with: {
groups: {
with: {
group: {
with: {
permissions: true,
},
},
},
},
},
});
const user = users.find((u) => u.name === expectedUsername);
expect(user).toBeDefined();
const adminGroup = user!.groups.find((g) => g.group.name === credentialsAdminGroup);
expect(adminGroup).toBeDefined();
expect(adminGroup!.group.permissions).toEqual([expect.objectContaining({ permission: "admin" })]);
}
public async assertExternalGroupInsertedAsync(expectedGroupName: string) {
const group = await this.db.query.groups.findFirst({
where: eq(sqliteSchema.groups.name, expectedGroupName),
with: {
permissions: true,
},
});
expect(group).toBeDefined();
expect(group!.permissions).toEqual([expect.objectContaining({ permission: "admin" })]);
}
public async assertFinishStepVisibleAsync() {
await this.page.waitForSelector("text=completed the setup", { timeout: 5000 });
}
}

View File

@@ -0,0 +1,51 @@
import { GenericContainer, Wait } from "testcontainers";
import { Environment } from "testcontainers/build/types";
export const createHomarrContainer = (
options: {
environment?: Environment;
mounts?: {
"/appdata"?: string;
"/var/run/docker.sock"?: string;
};
} = {},
) => {
if (!process.env.CI) {
throw new Error("This test should only be run in CI or with a homarr image named 'homarr-e2e'");
}
const container = new GenericContainer("homarr-e2e")
.withExposedPorts(7575)
.withEnvironment({
...options.environment,
SECRET_ENCRYPTION_KEY: "0".repeat(64),
})
.withBindMounts(
Object.entries(options.mounts ?? {})
.filter((item) => item?.[0] !== undefined)
.map(([container, local]) => ({
source: local,
target: container,
})),
)
.withWaitStrategy(Wait.forHttp("/api/health/ready", 7575))
.withExtraHosts([
{
// This enabled the usage of host.docker.internal as hostname in the container
host: "host.docker.internal",
ipAddress: "host-gateway",
},
]);
return withLogs(container);
};
export const withLogs = (container: GenericContainer) => {
container.withLogConsumer((stream) =>
stream
.on("data", (line) => console.log(line))
.on("err", (line) => console.error(line))
.on("end", () => console.log("Stream closed")),
);
return container;
};

33
e2e/shared/e2e-db.ts Normal file
View File

@@ -0,0 +1,33 @@
import { mkdir } from "fs/promises";
import path from "path";
import { createId } from "@paralleldrive/cuid2";
import Database from "better-sqlite3";
import { BetterSQLite3Database, drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { DB_CASING } from "../../packages/core/src/infrastructure/db/constants";
import * as sqliteSchema from "../../packages/db/schema/sqlite";
export const createSqliteDbFileAsync = async () => {
const localMountPath = path.join(__dirname, "tmp", createId());
await mkdir(path.join(localMountPath, "db"), { recursive: true });
const localDbUrl = path.join(localMountPath, "db", "db.sqlite");
const connection = new Database(localDbUrl);
const db = drizzle(connection, {
schema: sqliteSchema,
casing: DB_CASING,
});
await migrate(db, {
migrationsFolder: path.join(__dirname, "..", "..", "packages", "db", "migrations", "sqlite"),
});
return {
db,
localMountPath,
};
};
export type SqliteDatabase = BetterSQLite3Database<typeof sqliteSchema>;

View File

@@ -0,0 +1,5 @@
import { RedisContainer } from "@testcontainers/redis";
export const createRedisContainer = () => {
return new RedisContainer("redis:latest").withPassword("homarr");
};