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

View File

@@ -0,0 +1,67 @@
import { describe, expect, test } from "vitest";
import { users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { createAdapter } from "../adapter";
describe("createAdapter should create drizzle adapter", () => {
test.each([["credentials" as const], ["ldap" as const], ["oidc" as const]])(
"createAdapter getUserByEmail should return user for provider %s when this provider provided",
async (provider) => {
// Arrange
const db = createDb();
const adapter = createAdapter(db, provider);
const email = "test@example.com";
await db.insert(users).values({ id: "1", name: "test", email, provider });
// Act
const user = await adapter.getUserByEmail?.(email);
// Assert
expect(user).toEqual({
id: "1",
name: "test",
email,
emailVerified: null,
image: null,
});
},
);
test.each([
["credentials", ["ldap", "oidc"]],
["ldap", ["credentials", "oidc"]],
["oidc", ["credentials", "ldap"]],
] as const)(
"createAdapter getUserByEmail should return null if only for other providers than %s exist",
async (requestedProvider, existingProviders) => {
// Arrange
const db = createDb();
const adapter = createAdapter(db, requestedProvider);
const email = "test@example.com";
for (const provider of existingProviders) {
await db.insert(users).values({ id: provider, name: `test-${provider}`, email, provider });
}
// Act
const user = await adapter.getUserByEmail?.(email);
// Assert
expect(user).toBeNull();
},
);
test("createAdapter getUserByEmail should throw error if provider is unknown", async () => {
// Arrange
const db = createDb();
const adapter = createAdapter(db, "unknown");
const email = "test@example.com";
// Act
const actAsync = async () => await adapter.getUserByEmail?.(email);
// Assert
await expect(actAsync()).rejects.toThrow("Unable to get user by email for unknown provider");
});
});

View File

@@ -0,0 +1,144 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import type { AdapterUser } from "@auth/core/adapters";
import type { JWT } from "next-auth/jwt";
import { describe, expect, test, vi } from "vitest";
import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import * as definitions from "@homarr/definitions";
import { createSessionCallback, getCurrentUserPermissionsAsync } from "../callbacks";
// This one is placed here because it's used in multiple tests and needs to be the same reference
const setCookies = vi.fn();
vi.mock("next/headers", () => ({
cookies: () => ({
set: setCookies,
}),
}));
describe("getCurrentUserPermissions", () => {
test("should return empty permissions when non existing user requested", async () => {
// Arrange
const db = createDb();
await db.insert(groups).values({
id: "2",
name: "test",
position: 1,
});
await db.insert(groupPermissions).values({
groupId: "2",
permission: "admin",
});
await db.insert(users).values({
id: "2",
});
const userId = "1";
// Act
const result = await getCurrentUserPermissionsAsync(db, userId);
// Assert
expect(result).toEqual([]);
});
test("should return empty permissions when user has no groups", async () => {
// Arrange
const db = createDb();
const userId = "1";
await db.insert(groups).values({
id: "2",
name: "test",
position: 1,
});
await db.insert(groupPermissions).values({
groupId: "2",
permission: "admin",
});
await db.insert(users).values({
id: userId,
});
// Act
const result = await getCurrentUserPermissionsAsync(db, userId);
// Assert
expect(result).toEqual([]);
});
test("should return permissions for user", async () => {
// Arrange
const db = createDb();
const getPermissionsWithChildrenMock = vi
.spyOn(definitions, "getPermissionsWithChildren")
.mockReturnValue(["board-create"]);
const mockId = "1";
await db.insert(users).values({
id: mockId,
});
await db.insert(groups).values({
id: mockId,
name: "test",
position: 1,
});
await db.insert(groupMembers).values({
userId: mockId,
groupId: mockId,
});
await db.insert(groupPermissions).values({
groupId: mockId,
permission: "admin",
});
// Act
const result = await getCurrentUserPermissionsAsync(db, mockId);
// Assert
expect(result).toEqual(["board-create"]);
expect(getPermissionsWithChildrenMock).toHaveBeenCalledWith(["admin"]);
});
});
describe("session callback", () => {
test("should add id and name to session user", async () => {
// Arrange
const user: AdapterUser = {
id: "id",
name: "name",
email: "email",
emailVerified: new Date("2023-01-13"),
};
const token: JWT = {};
const db = createDb();
const callback = createSessionCallback(db);
// Act
const result = await callback({
session: {
user: {
id: "no-id",
email: "no-email",
emailVerified: new Date("2023-01-13"),
permissions: [],
colorScheme: "dark",
},
expires: "2023-01-13" as Date & string,
sessionToken: "token",
userId: "no-id",
},
user,
token,
trigger: "update",
newSession: {},
});
// Assert
expect(result.user).toBeDefined();
expect(result.user!.id).toEqual(user.id);
expect(result.user!.name).toEqual(user.name);
});
});

View File

@@ -0,0 +1,264 @@
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 { describe, expect, test, vi } from "vitest";
import { eq } from "@homarr/db";
import type { Database } from "@homarr/db";
import { groupMembers, groups, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
import { createSignInEventHandler } from "../events";
vi.mock("next-auth", () => ({}));
vi.mock("../env", () => {
return {
env: {
AUTH_OIDC_GROUPS_ATTRIBUTE: "someRandomGroupsKey",
},
};
});
// 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 = () => Promise.resolve(result);
return { ...mod, cookies } satisfies HeadersExport;
});
describe("createSignInEventHandler should create signInEventHandler", () => {
describe("signInEventHandler should add users to everyone group", () => {
test("should add user to everyone group if he isn't already", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createGroupAsync(db, everyoneGroup);
const eventHandler = createSignInEventHandler(db);
// Act
await eventHandler?.({
user: { id: "1", name: "test" },
profile: undefined,
account: null,
});
// Assert
const dbGroupMembers = await db.query.groupMembers.findFirst({
where: eq(groupMembers.userId, "1"),
});
expect(dbGroupMembers?.groupId).toBe("1");
});
});
describe("signInEventHandler should synchronize ldap groups", () => {
test("should add missing group membership", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createGroupAsync(db);
const eventHandler = createSignInEventHandler(db);
// Act
await eventHandler?.({
user: { id: "1", name: "test", groups: ["test"] } as never,
profile: undefined,
account: null,
});
// Assert
const dbGroupMembers = await db.query.groupMembers.findFirst({
where: eq(groupMembers.userId, "1"),
});
expect(dbGroupMembers?.groupId).toBe("1");
});
test("should remove group membership", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createGroupAsync(db);
await db.insert(groupMembers).values({
userId: "1",
groupId: "1",
});
const eventHandler = createSignInEventHandler(db);
// Act
await eventHandler?.({
user: { id: "1", name: "test", groups: [] } as never,
profile: undefined,
account: null,
});
// Assert
const dbGroupMembers = await db.query.groupMembers.findFirst({
where: eq(groupMembers.userId, "1"),
});
expect(dbGroupMembers).toBeUndefined();
});
test("should not remove group membership for everyone group", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createGroupAsync(db, everyoneGroup);
await db.insert(groupMembers).values({
userId: "1",
groupId: "1",
});
const eventHandler = createSignInEventHandler(db);
// Act
await eventHandler?.({
user: { id: "1", name: "test", groups: [] } as never,
profile: undefined,
account: null,
});
// Assert
const dbGroupMembers = await db.query.groupMembers.findFirst({
where: eq(groupMembers.userId, "1"),
});
expect(dbGroupMembers?.groupId).toBe("1");
});
});
describe("signInEventHandler should synchronize oidc groups", () => {
test("should add missing group membership", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createGroupAsync(db);
const eventHandler = createSignInEventHandler(db);
// Act
await eventHandler?.({
user: { id: "1", name: "test" },
profile: { preferred_username: "test", someRandomGroupsKey: ["test"] },
account: null,
});
// Assert
const dbGroupMembers = await db.query.groupMembers.findFirst({
where: eq(groupMembers.userId, "1"),
});
expect(dbGroupMembers?.groupId).toBe("1");
});
test("should remove group membership", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createGroupAsync(db);
await db.insert(groupMembers).values({
userId: "1",
groupId: "1",
});
const eventHandler = createSignInEventHandler(db);
// Act
await eventHandler?.({
user: { id: "1", name: "test" },
profile: { preferred_username: "test", someRandomGroupsKey: [] },
account: null,
});
// Assert
const dbGroupMembers = await db.query.groupMembers.findFirst({
where: eq(groupMembers.userId, "1"),
});
expect(dbGroupMembers).toBeUndefined();
});
test("should not remove group membership for everyone group", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createGroupAsync(db, everyoneGroup);
await db.insert(groupMembers).values({
userId: "1",
groupId: "1",
});
const eventHandler = createSignInEventHandler(db);
// Act
await eventHandler?.({
user: { id: "1", name: "test" },
profile: { preferred_username: "test", someRandomGroupsKey: [] },
account: null,
});
// Assert
const dbGroupMembers = await db.query.groupMembers.findFirst({
where: eq(groupMembers.userId, "1"),
});
expect(dbGroupMembers?.groupId).toBe("1");
});
});
test.each([
["ldap" as const, { name: "test-new" }, undefined],
["oidc" as const, { name: "test" }, { preferred_username: "test-new" }],
["oidc" as const, { name: "test" }, { preferred_username: "test@example.com", name: "test-new" }],
])("signInEventHandler should update username for %s provider", async (_provider, user, profile) => {
// Arrange
const db = createDb();
await createUserAsync(db);
const eventHandler = createSignInEventHandler(db);
// Act
await eventHandler?.({
user: { id: "1", ...user },
profile,
account: null,
});
// Assert
const dbUser = await db.query.users.findFirst({
where: eq(users.id, "1"),
columns: {
name: true,
},
});
expect(dbUser?.name).toBe("test-new");
});
test("signInEventHandler should set color-scheme cookie", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
const eventHandler = createSignInEventHandler(db);
// Act
await eventHandler?.({
user: { id: "1", name: "test" },
profile: undefined,
account: null,
});
// Assert
expect((await cookies()).set).toHaveBeenCalledWith(
colorSchemeCookieKey,
"dark",
expect.objectContaining({
path: "/",
}),
);
});
});
const createUserAsync = async (db: Database) =>
await db.insert(users).values({
id: "1",
name: "test",
colorScheme: "dark",
});
const createGroupAsync = async (db: Database, name = "test") =>
await db.insert(groups).values({
id: "1",
name,
position: 1,
});

View File

@@ -0,0 +1,59 @@
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
import { describe, expect, test } from "vitest";
import { createRedirectUri } from "../redirect";
describe("redirect", () => {
test("Callback should return http url when not defining protocol", () => {
// Arrange
const headers = new Map<string, string>([["x-forwarded-host", "localhost:3000"]]) as unknown as ReadonlyHeaders;
// Act
const result = createRedirectUri(headers, "/api/auth/callback/oidc");
// Assert
expect(result).toBe("http://localhost:3000/api/auth/callback/oidc");
});
test("Callback should return https url when defining protocol", () => {
// Arrange
const headers = new Map<string, string>([
["x-forwarded-proto", "https"],
["x-forwarded-host", "localhost:3000"],
]) as unknown as ReadonlyHeaders;
// Act
const result = createRedirectUri(headers, "/api/auth/callback/oidc");
// Assert
expect(result).toBe("https://localhost:3000/api/auth/callback/oidc");
});
test("Callback should return https url when defining protocol and host", () => {
// Arrange
const headers = new Map<string, string>([
["x-forwarded-proto", "https"],
["host", "something.else"],
]) as unknown as ReadonlyHeaders;
// Act
const result = createRedirectUri(headers, "/api/auth/callback/oidc");
// Assert
expect(result).toBe("https://something.else/api/auth/callback/oidc");
});
test("Callback should return https url when defining protocol as http,https and host", () => {
// Arrange
const headers = new Map<string, string>([
["x-forwarded-proto", "http,https"],
["x-forwarded-host", "hello.world"],
]) as unknown as ReadonlyHeaders;
// Act
const result = createRedirectUri(headers, "/api/auth/callback/oidc");
// Assert
expect(result).toBe("https://hello.world/api/auth/callback/oidc");
});
});

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { createSaltAsync, hashPasswordAsync } from "../security";
describe("createSalt should return a salt", () => {
it("should return a salt", async () => {
const result = await createSaltAsync();
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(25);
});
it("should return a different salt each time", async () => {
const result1 = await createSaltAsync();
const result2 = await createSaltAsync();
expect(result1).not.toEqual(result2);
});
});
describe("hashPassword should return a hash", () => {
it("should return a hash", async () => {
const password = "password";
const salt = await createSaltAsync();
const result = await hashPasswordAsync(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 createSaltAsync();
const result1 = await hashPasswordAsync(password, salt);
const result2 = await hashPasswordAsync(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 createSaltAsync();
const salt2 = await createSaltAsync();
const result1 = await hashPasswordAsync(password, salt1);
const result2 = await hashPasswordAsync(password, salt2);
expect(result1).not.toEqual(result2);
});
});

View File

@@ -0,0 +1,44 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod/v4";
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()
.regex(/^[a-f0-9]+$/)
.safeParse(result).success,
).toBe(true);
});
it("should return a different token each time", () => {
const result1 = generateSessionToken();
const result2 = generateSessionToken();
expect(result1).not.toEqual(result2);
});
});