Skip to content

Commit

Permalink
test(e2e): add for onboarding and lldap authorization (#1834)
Browse files Browse the repository at this point in the history
* test(e2e): add for onboarding and lldap authorization

* ci: add playwright chrome installation to e2e test

* fix(e2e): timeout between lldap login redirect to short

* test(e2e): add oidc azure test

* fix(e2e): lldap test fails

* wip: add temporary error log for failed ldap server connection

* fix(e2e): github actions don't support host.docker.internal

* chore: address pull request feedback

* refactor(e2e): move onboarding steps to onboarding actions and assertions

* fix(e2e): increase timeout for navigating back from azure login

* fix: wait for url network changed error

* fix: revert to wait for url

* fix(e2e): remove oidc test

* refactor(e2e): remove env validation

* ci: remove azure oidc env variables
  • Loading branch information
Meierschlumpf authored Jan 3, 2025
1 parent 6305c74 commit 4ead238
Show file tree
Hide file tree
Showing 11 changed files with 422 additions and 33 deletions.
3 changes: 2 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ README.md
.next
.git
dev
.build
.build
e2e
2 changes: 2 additions & 0 deletions .github/workflows/code-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ jobs:
network: host
env:
SKIP_ENV_VALIDATION: true
- name: Install playwright browsers
run: pnpm exec playwright install chromium
- name: Run E2E Tests
shell: bash
run: pnpm test:e2e
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ apps/websocket/wssServer.cjs
apps/nextjs/.million/
packages/cli/cli.cjs

# e2e mounts
e2e/shared/tmp

#personal backgrounds
apps/nextjs/public/images/background.png
92 changes: 92 additions & 0 deletions e2e/lldap.spec.ts
Original file line number Diff line number Diff line change
@@ -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: "[email protected]",
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 changes: 85 additions & 0 deletions e2e/onboarding.spec.ts
Original file line number Diff line number Diff line change
@@ -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: "",
AUTH_LDAP_BIND_DN: "",
AUTH_LDAP_BIND_PASSWORD: "",
},
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);
});
53 changes: 53 additions & 0 deletions e2e/shared/actions/onboarding-actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
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,
});
}
}

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();
}
}
62 changes: 62 additions & 0 deletions e2e/shared/assertions/onboarding-assertions.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
43 changes: 34 additions & 9 deletions e2e/shared/create-homarr-container.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,43 @@
import { GenericContainer, Wait } from "testcontainers";
import { Environment } from "testcontainers/build/types";

export const createHomarrContainer = () => {
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'");
}

return withLogs(
new GenericContainer("homarr-e2e")
.withExposedPorts(7575)
.withEnvironment({
SECRET_ENCRYPTION_KEY: "0".repeat(64),
})
.withWaitStrategy(Wait.forHttp("/api/health/ready", 7575)),
);
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) => {
Expand Down
Loading

0 comments on commit 4ead238

Please sign in to comment.