feat: add local group support for OIDC users (#5952)

This commit is contained in:
Louis Bailleau authored and GitHub committed 2026-06-16 13:45:44 +02:00
1 parent 4692e7ce3a
commit fb18b975b2
15 files changed
+540 -191

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
{
"typescript.tsdk": "node_modules\\typescript\\lib",
"js/ts.tsdk.path": "node_modules\\typescript\\lib",
"js/ts.implicitProjectConfig.experimentalDecorators": true,
"cSpell.words": [
"ajnart",
@@ -124,6 +124,8 @@ Homarr supports multiple authentication options, from internal userbase (credent
Roles are fetched from group claims. This can also be changed to roles for example added to a azure app registration by using the `AUTH_OIDC_GROUPS_ATTRIBUTE`.
If you'd rather manage group memberships for OIDC users locally (instead of via the IdP), set `AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT=true`. While enabled, Homarr stops syncing OIDC users' group memberships from the groups claim, and admins can add/remove OIDC users on a group's members page. If you turn this back to `false`, the next login of each user resumes the regular sync and reconciles their groups with the IdP's claim again, which may remove memberships that were added manually while local management was enabled.
<details>
<summary>
Example Setup
@@ -197,6 +199,7 @@ Homarr supports multiple authentication options, from internal userbase (credent
| ``AUTH_OIDC_AUTO_LOGIN`` | Automatically redirect to OIDC login | false |
| ``AUTH_OIDC_SCOPE_OVERWRITE`` | Overwrite default scopes (openid, profile, email) | openid email profile groups |
| ``AUTH_OIDC_GROUPS_ATTRIBUTE`` | Attribute used for groups (roles) claim | groups |
| ``AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT`` | Stop syncing group memberships of OIDC users from the groups claim and manage them locally via the group members page instead | false |
| ``AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE`` | Overwrite name attribute. By default it will use preferred_username if it does not contain a `@` and otherwise name. | --- |
| ``AUTH_OIDC_FORCE_USERINFO`` | Force userinfo endpoint to be used for user information. | false |
| ``AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING`` | Enable account linking for OIDC provider. This will link the OIDC accounts by email. Be sure that you have verified emails to prevent stealing accounts. | false |
@@ -4,6 +4,7 @@ import { useCallback } from "react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
@@ -13,9 +14,10 @@ import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
interface AddGroupMemberProps {
groupId: string;
presentUserIds: string[];
allowedProviders: SupportedAuthProvider[];
}
export const AddGroupMember = ({ groupId, presentUserIds }: AddGroupMemberProps) => {
export const AddGroupMember = ({ groupId, presentUserIds, allowedProviders }: AddGroupMemberProps) => {
const tMembersAdd = useScopedI18n("group.action.addMember");
const { mutateAsync } = clientApi.group.addMember.useMutation();
const { openModal } = useModalAction(UserSelectModal);
@@ -32,13 +34,13 @@ export const AddGroupMember = ({ groupId, presentUserIds }: AddGroupMemberProps)
await revalidatePathActionAsync(`/manage/users/groups/${groupId}}/members`);
},
presentUserIds,
excludeExternalProviders: true,
allowedProviders,
},
{
title: tMembersAdd("label"),
},
);
}, [openModal, presentUserIds, groupId, mutateAsync, tMembersAdd]);
}, [openModal, presentUserIds, groupId, mutateAsync, tMembersAdd, allowedProviders]);
return <MobileAffixButton onClick={handleAddMember}>{tMembersAdd("label")}</MobileAffixButton>;
};
@@ -1,12 +1,27 @@
import { notFound } from "next/navigation";
import { Alert, Anchor, Center, Group, Stack, Table, TableTbody, TableTd, TableTr, Text, Title } from "@mantine/core";
import {
Alert,
Anchor,
Center,
Group,
Stack,
Table,
TableTbody,
TableTd,
TableTr,
Text,
Title,
} from "@mantine/core";
import { IconExclamationCircle } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import {
getGroupMemberManagementType,
getLocallyManageableProviders,
isGroupMembershipManagedLocally,
} from "@homarr/auth/server";
import { everyoneGroup } from "@homarr/definitions";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { Link, SearchInput, UserAvatar } from "@homarr/ui";
@@ -40,14 +55,17 @@ export default async function GroupsDetailPage(props: GroupsDetailPageProps) {
const filteredMembers = searchParams.search
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
group.members.filter((member) => member.name?.toLowerCase().includes(searchParams.search!.trim().toLowerCase()))
group.members.filter((member) =>
member.name
?.toLowerCase()
.includes(searchParams.search!.trim().toLowerCase()),
)
: group.members;
const providerTypes = isProviderEnabled("credentials")
? env.AUTH_PROVIDERS.length > 1
? "mixed"
: "credentials"
: "external";
// "local" = every enabled provider managed locally, "external" = none, "mixed" = some.
const managementType = getGroupMemberManagementType();
const canManageMembers = managementType !== "external";
const allowedProviders = getLocallyManageableProviders();
return (
<Stack>
@@ -56,17 +74,28 @@ export default async function GroupsDetailPage(props: GroupsDetailPageProps) {
{isReserved ? (
<ReservedGroupAlert />
) : (
providerTypes !== "credentials" && (
<Alert variant="light" color="yellow" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
{t(`group.memberNotice.${providerTypes}`)}
managementType !== "local" && (
<Alert
variant="light"
color="yellow"
icon={<IconExclamationCircle size="1rem" stroke={1.5} />}
>
{t(`group.memberNotice.${managementType}`)}
</Alert>
)
)}
<Group justify="space-between">
<SearchInput placeholder={`${tMembers("search")}...`} defaultValue={searchParams.search} />
{isProviderEnabled("credentials") && !isReserved && (
<AddGroupMember groupId={group.id} presentUserIds={group.members.map((member) => member.id)} />
<SearchInput
placeholder={`${tMembers("search")}...`}
defaultValue={searchParams.search}
/>
{canManageMembers && !isReserved && (
<AddGroupMember
groupId={group.id}
presentUserIds={group.members.map((member) => member.id)}
allowedProviders={allowedProviders}
/>
)}
</Group>
{filteredMembers.length === 0 && (
@@ -79,7 +108,12 @@ export default async function GroupsDetailPage(props: GroupsDetailPageProps) {
<Table striped highlightOnHover>
<TableTbody>
{filteredMembers.map((member) => (
<Row key={group.id} member={member} groupId={group.id} disabled={isReserved} />
<Row
key={group.id}
member={member}
groupId={group.id}
disabled={isReserved}
/>
))}
</TableTbody>
</Table>
@@ -94,6 +128,8 @@ interface RowProps {
}
const Row = ({ member, groupId, disabled }: RowProps) => {
const canBeRemoved = isGroupMembershipManagedLocally(member.provider);
return (
<TableTr>
<TableTd>
@@ -105,7 +141,9 @@ const Row = ({ member, groupId, disabled }: RowProps) => {
</Group>
</TableTd>
<TableTd w={100}>
{member.provider === "credentials" && !disabled && <RemoveGroupMember user={member} groupId={groupId} />}
{canBeRemoved && !disabled && (
<RemoveGroupMember user={member} groupId={groupId} />
)}
</TableTd>
</TableTr>
);
@@ -5,6 +5,7 @@ import { IconCheck } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
@@ -12,7 +13,7 @@ import { UserAvatar } from "@homarr/ui";
interface InnerProps {
presentUserIds: string[];
excludeExternalProviders?: boolean;
allowedProviders?: SupportedAuthProvider[];
onSelect: (props: { id: string; name: string; image: string; email: string | null }) => void | Promise<void>;
confirmLabel?: string;
}
@@ -24,7 +25,7 @@ interface UserSelectFormType {
export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const { data: users, isPending } = clientApi.user.selectable.useQuery({
excludeExternalProviders: innerProps.excludeExternalProviders,
providers: innerProps.allowedProviders,
});
const [loading, setLoading] = useState(false);
const form = useForm<UserSelectFormType>();
+21 -5
View File
@@ -1,11 +1,12 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { canManageGroupMembersLocally, isGroupMembershipManagedLocally } from "@homarr/auth/server";
import { createId } from "@homarr/common";
import type { Database } from "@homarr/db";
import { and, eq, handleTransactionsAsync, like, not } from "@homarr/db";
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema";
import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema";
import { everyoneGroup } from "@homarr/definitions";
import { byIdSchema, paginatedSchema } from "@homarr/validation/common";
import {
@@ -18,7 +19,6 @@ import {
} from "@homarr/validation/group";
import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, protectedProcedure } from "../trpc";
import { throwIfCredentialsDisabled } from "./invite/checks";
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
export const groupRouter = createTRPCRouter({
@@ -315,10 +315,10 @@ export const groupRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId);
throwIfCredentialsDisabled();
throwIfGroupMembersCannotBeManagedLocally();
const user = await ctx.db.query.users.findFirst({
where: eq(groups.id, input.userId),
where: eq(users.id, input.userId),
});
if (!user) {
@@ -328,6 +328,13 @@ export const groupRouter = createTRPCRouter({
});
}
if (!isGroupMembershipManagedLocally(user.provider)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User's provider is not managed locally",
});
}
await ctx.db.insert(groupMembers).values({
groupId: input.groupId,
userId: input.userId,
@@ -339,7 +346,7 @@ export const groupRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId);
throwIfCredentialsDisabled();
throwIfGroupMembersCannotBeManagedLocally();
await ctx.db
.delete(groupMembers)
@@ -371,6 +378,15 @@ const throwIfGroupNameIsReservedAsync = async (db: Database, id: string) => {
}
};
const throwIfGroupMembersCannotBeManagedLocally = () => {
if (!canManageGroupMembersLocally()) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Group members cannot be managed locally",
});
}
};
const throwIfGroupNotFoundAsync = async (db: Database, id: string) => {
const group = await db.query.groups.findFirst({
where: eq(groups.id, id),
+40 -2
View File
@@ -748,7 +748,45 @@ describe("addMember should add member to group", () => {
});
// Assert
await expect(actAsync()).rejects.toThrow("Credentials provider is disabled");
await expect(actAsync()).rejects.toThrow("Group members cannot be managed locally");
});
test("with a user from an externally managed provider it should throw FORBIDDEN error", async () => {
// Arrange
const db = createDb();
const spy = vi.spyOn(env, "env", "get");
spy.mockReturnValue({ AUTH_PROVIDERS: ["credentials", "ldap"] } as never);
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
const userId = createId();
await db.insert(users).values([
{
id: userId,
name: "User",
provider: "ldap",
},
{
id: defaultOwnerId,
name: "Creator",
},
]);
await db.insert(groups).values({
id: groupId,
name: "Group",
ownerId: defaultOwnerId,
position: 1,
});
// Act
const actAsync = async () =>
await caller.addMember({
groupId,
userId,
});
// Assert
await expect(actAsync()).rejects.toThrow("User's provider is not managed locally");
});
});
@@ -872,7 +910,7 @@ describe("removeMember should remove member from group", () => {
});
// Assert
await expect(actAsync()).rejects.toThrow("Credentials provider is disabled");
await expect(actAsync()).rejects.toThrow("Group members cannot be managed locally");
});
});
+4 -4
View File
@@ -7,12 +7,12 @@ import { comparePasswordsAsync, hashPasswordAsync } from "@homarr/auth";
import { createId } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { Database } from "@homarr/db";
import { and, eq, handleTransactionsAsync, like } from "@homarr/db";
import { and, eq, handleTransactionsAsync, inArray, like } from "@homarr/db";
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
import { boards, groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema";
import { selectUserSchema } from "@homarr/db/validationSchemas";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { credentialsAdminGroup } from "@homarr/definitions";
import { credentialsAdminGroup, supportedAuthProviders } from "@homarr/definitions";
import { byIdSchema } from "@homarr/validation/common";
import type { userBaseCreateSchema } from "@homarr/validation/user";
import {
@@ -257,7 +257,7 @@ export const userRouter = createTRPCRouter({
}),
// Is protected because also used in board access / integration access forms
selectable: protectedProcedure
.input(z.object({ excludeExternalProviders: z.boolean().default(false) }).optional())
.input(z.object({ providers: z.array(z.enum(supportedAuthProviders)).optional() }).optional())
.output(
z.array(
selectUserSchema.pick({
@@ -284,7 +284,7 @@ export const userRouter = createTRPCRouter({
image: true,
email: true,
},
where: input?.excludeExternalProviders ? eq(users.provider, "credentials") : undefined,
where: input?.providers ? inArray(users.provider, input.providers) : undefined,
});
}),
search: permissionRequiredProcedure
+1
View File
@@ -37,6 +37,7 @@ export const env = createEnv({
AUTH_OIDC_AUTO_LOGIN: createBooleanSchema(false),
AUTH_OIDC_SCOPE_OVERWRITE: z.string().min(1).default("openid email profile groups"),
AUTH_OIDC_GROUPS_ATTRIBUTE: z.string().default("groups"), // Is used in the signIn event to assign the correct groups, key is from object of decoded id_token
AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT: createBooleanSchema(false), // When enabled, group memberships of oidc users are managed locally instead of synced from the groups claim
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: z.string().optional(),
AUTH_OIDC_FORCE_USERINFO: createBooleanSchema(false),
AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING: createBooleanSchema(false),
+6 -1
View File
@@ -31,7 +31,12 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
const groupsKey = env.AUTH_OIDC_GROUPS_ATTRIBUTE;
// Groups from oidc provider are provided from the profile, it's not typed.
if (profile && groupsKey in profile && Array.isArray(profile[groupsKey])) {
if (
!env.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT &&
profile &&
groupsKey in profile &&
Array.isArray(profile[groupsKey])
) {
logger.debug(`Using profile groups (${groupsKey}): ${JSON.stringify(profile[groupsKey])}`);
await synchronizeGroupsWithExternalForUserAsync(db, user.id, profile[groupsKey] as string[]);
}
+48
View File
@@ -1,9 +1,57 @@
import type { SupportedAuthProvider } from "@homarr/definitions";
import { supportedAuthProviders } from "@homarr/definitions";
import { env } from "../env";
export type GroupMemberManagementType = "local" | "mixed" | "external";
export const isProviderEnabled = (provider: SupportedAuthProvider) => {
// The question mark is placed there because isProviderEnabled is called during static build of about page
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return env.AUTH_PROVIDERS?.includes(provider);
};
/**
* Whether a single provider's group memberships are managed locally (manually via UI/API).
* True for credentials users, and for oidc users when AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT is enabled.
*/
export const isGroupMembershipManagedLocally = (provider: SupportedAuthProvider): boolean => {
switch (provider) {
case "credentials":
return true;
case "oidc":
return env.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT;
default:
// ldap + any provider added later: externally managed (synced, not editable
// here) until explicitly given a case above. New providers safe-by-default.
return false;
}
};
export const getEnabledProviders = (): SupportedAuthProvider[] => supportedAuthProviders.filter(isProviderEnabled);
/**
* The enabled providers whose group memberships can be managed locally.
*/
export const getLocallyManageableProviders = (): SupportedAuthProvider[] =>
getEnabledProviders().filter(isGroupMembershipManagedLocally);
/**
* Classifies group-member management across all enabled providers:
* - "local": every enabled provider is managed locally (no member is synced externally)
* - "external": no enabled provider is managed locally (every member is synced externally)
* - "mixed": some are managed locally, some externally (only a subset of members is editable here)
*/
export const getGroupMemberManagementType = (): GroupMemberManagementType => {
const enabled = getEnabledProviders();
const manageable = enabled.filter(isGroupMembershipManagedLocally);
if (manageable.length === enabled.length) return "local";
if (manageable.length === 0) return "external";
return "mixed";
};
/**
* Whether group memberships can be managed locally for at least one enabled provider.
*/
export const canManageGroupMembersLocally = (): boolean => getLocallyManageableProviders().length > 0;
@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { SupportedAuthProvider } from "@homarr/definitions";
import * as env from "../../env";
import {
canManageGroupMembersLocally,
getGroupMemberManagementType,
getLocallyManageableProviders,
isGroupMembershipManagedLocally,
} from "../check-provider";
const mockEnv = (providers: SupportedAuthProvider[], oidcLocalManagement = false) => {
vi.spyOn(env, "env", "get").mockReturnValue({
AUTH_PROVIDERS: providers,
AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT: oidcLocalManagement,
} as never);
};
beforeEach(() => {
vi.restoreAllMocks();
});
describe("isGroupMembershipManagedLocally", () => {
test("credentials is always managed locally", () => {
mockEnv(["credentials"]);
expect(isGroupMembershipManagedLocally("credentials")).toBe(true);
});
test("oidc follows AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT", () => {
mockEnv(["oidc"], true);
expect(isGroupMembershipManagedLocally("oidc")).toBe(true);
mockEnv(["oidc"], false);
expect(isGroupMembershipManagedLocally("oidc")).toBe(false);
});
test("ldap is never managed locally", () => {
mockEnv(["ldap"]);
expect(isGroupMembershipManagedLocally("ldap")).toBe(false);
});
});
describe("getGroupMemberManagementType", () => {
test("credentials only is local", () => {
mockEnv(["credentials"]);
expect(getGroupMemberManagementType()).toBe("local");
});
test("credentials + oidc with local management is local", () => {
mockEnv(["credentials", "oidc"], true);
expect(getGroupMemberManagementType()).toBe("local");
});
test("credentials + oidc without local management is mixed", () => {
mockEnv(["credentials", "oidc"], false);
expect(getGroupMemberManagementType()).toBe("mixed");
});
test("credentials + ldap is mixed", () => {
mockEnv(["credentials", "ldap"]);
expect(getGroupMemberManagementType()).toBe("mixed");
});
test("ldap only is external", () => {
mockEnv(["ldap"]);
expect(getGroupMemberManagementType()).toBe("external");
});
test("oidc only without local management is external", () => {
mockEnv(["oidc"], false);
expect(getGroupMemberManagementType()).toBe("external");
});
});
describe("getLocallyManageableProviders", () => {
test("returns only the enabled, locally managed providers", () => {
mockEnv(["credentials", "oidc", "ldap"], true);
expect(getLocallyManageableProviders()).toEqual(["credentials", "oidc"]);
mockEnv(["credentials", "oidc", "ldap"], false);
expect(getLocallyManageableProviders()).toEqual(["credentials"]);
mockEnv(["ldap"]);
expect(getLocallyManageableProviders()).toEqual([]);
});
});
describe("canManageGroupMembersLocally", () => {
test("is true when at least one enabled provider is managed locally", () => {
mockEnv(["credentials", "ldap"]);
expect(canManageGroupMembersLocally()).toBe(true);
mockEnv(["oidc"], true);
expect(canManageGroupMembersLocally()).toBe(true);
});
test("is false when every enabled provider is external", () => {
mockEnv(["ldap"]);
expect(canManageGroupMembersLocally()).toBe(false);
mockEnv(["oidc"], false);
expect(canManageGroupMembersLocally()).toBe(false);
});
});
+8 -1
View File
@@ -1,4 +1,11 @@
export { hasQueryAccessToIntegrationsAsync } from "./permissions/integration-query-permissions";
export { getIntegrationsWithPermissionsAsync } from "./permissions/integrations-with-permissions";
export { isProviderEnabled } from "./providers/check-provider";
export {
isProviderEnabled,
isGroupMembershipManagedLocally,
getLocallyManageableProviders,
getGroupMemberManagementType,
canManageGroupMembersLocally,
} from "./providers/check-provider";
export type { GroupMemberManagementType } from "./providers/check-provider";
export { createSessionCallback, createSessionAsync } from "./callbacks";
+29 -3
View File
@@ -12,11 +12,13 @@ import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
import { createSignInEventHandler } from "../events";
vi.mock("next-auth", () => ({}));
const mockEnv = vi.hoisted(() => ({
AUTH_OIDC_GROUPS_ATTRIBUTE: "someRandomGroupsKey",
AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT: false,
}));
vi.mock("../env", () => {
return {
env: {
AUTH_OIDC_GROUPS_ATTRIBUTE: "someRandomGroupsKey",
},
env: mockEnv,
};
});
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
@@ -198,6 +200,30 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
});
expect(dbGroupMembers?.groupId).toBe("1");
});
test("should not synchronize groups when AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT is enabled", async () => {
// Arrange
mockEnv.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT = true;
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).toBeUndefined();
// Cleanup
mockEnv.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT = false;
});
});
test.each([
["ldap" as const, { name: "test-new" }, undefined],
+211 -152
View File
@@ -2,7 +2,15 @@
import type { StartedTestContainer } from "testcontainers";
import { GenericContainer, Wait } from "testcontainers";
import { beforeAll, beforeEach, afterAll, describe, expect, test, vi } from "vitest";
import {
beforeAll,
beforeEach,
afterAll,
describe,
expect,
test,
vi,
} from "vitest";
import { createDb } from "@homarr/db/test";
@@ -23,10 +31,15 @@ vi.mock("@homarr/db", async (importActual) => {
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const actual = await importActual<typeof import("@homarr/core/infrastructure/certificates")>();
const actual =
await importActual<
typeof import("@homarr/core/infrastructure/certificates")
>();
return {
...actual,
getTrustedCertificateHostnamesAsync: vi.fn().mockImplementation(() => Promise.resolve([])),
getTrustedCertificateHostnamesAsync: vi
.fn()
.mockImplementation(() => Promise.resolve([])),
};
});
@@ -77,7 +90,9 @@ const INTEGRATION_ID = "test-technitium";
const makeInput = (
baseUrl: string,
auth: { kind: "credentials"; password?: string } | { kind: "apiKey"; value: string },
auth:
| { kind: "credentials"; password?: string }
| { kind: "apiKey"; value: string },
): IntegrationInput => ({
id: INTEGRATION_ID,
name: "Technitium DNS",
@@ -94,7 +109,10 @@ const makeInput = (
// Fetch an API token appropriate for the server version.
// v15 has a dedicated createToken endpoint; legacy reuses the session token from login.
async function fetchApiToken(baseUrl: string, apiVersion: TechnitiumVersion): Promise<string> {
async function fetchApiToken(
baseUrl: string,
apiVersion: TechnitiumVersion,
): Promise<string> {
if (apiVersion === "v15") {
// v15 exposes a dedicated endpoint for non-expiring API tokens.
const res = await fetch(
@@ -104,7 +122,9 @@ async function fetchApiToken(baseUrl: string, apiVersion: TechnitiumVersion): Pr
}
// Legacy: /api/user/login exists on all tested versions (v11–v14) and returns a session
// token that can be used as a long-lived API key via the ?token= query parameter.
const res = await fetch(`${baseUrl}/api/user/login?user=${DEFAULT_USERNAME}&pass=${DEFAULT_PASSWORD}`);
const res = await fetch(
`${baseUrl}/api/user/login?user=${DEFAULT_USERNAME}&pass=${DEFAULT_PASSWORD}`,
);
return ((await res.json()) as { token: string }).token;
}
@@ -124,12 +144,16 @@ const pool: Record<string, ContainerEntry> = {};
beforeAll(async () => {
await Promise.all(
VERSIONS.map(async ({ tag, apiVersion }) => {
const container = await new GenericContainer(`technitium/dns-server:${tag}`)
const container = await new GenericContainer(
`technitium/dns-server:${tag}`,
)
.withExposedPorts(5380)
// Explicitly set the admin password so all versions use the same credentials.
// Without this, older versions may generate a random password on first run.
.withEnvironment({ DNS_SERVER_ADMIN_PASSWORD: DEFAULT_PASSWORD })
.withWaitStrategy(Wait.forLogMessage("Technitium DNS Server was started successfully."))
.withWaitStrategy(
Wait.forLogMessage("Technitium DNS Server was started successfully."),
)
.start();
const baseUrl = `http://${container.getHost()}:${container.getMappedPort(5380)}`;
const apiToken = await fetchApiToken(baseUrl, apiVersion);
@@ -139,203 +163,238 @@ beforeAll(async () => {
}, 120_000);
afterAll(async () => {
await Promise.all(Object.values(pool).map(({ container }) => container.stop()));
await Promise.all(
Object.values(pool).map(({ container }) => container.stop()),
);
});
// ─── version-parameterised suites ────────────────────────────────────────────
describe.each(VERSIONS)("Technitium DNS $tag ($apiVersion)", ({ tag, apiVersion }) => {
let baseUrl: string;
let apiToken: string;
describe.each(VERSIONS)(
"Technitium DNS $tag ($apiVersion)",
({ tag, apiVersion }) => {
let baseUrl: string;
let apiToken: string;
beforeAll(() => {
const entry = pool[tag];
if (!entry) throw new Error(`Container pool entry for ${tag} was not initialized`);
({ baseUrl, apiToken } = entry);
});
beforeAll(() => {
const entry = pool[tag];
if (!entry)
throw new Error(`Container pool entry for ${tag} was not initialized`);
({ baseUrl, apiToken } = entry);
});
beforeEach(() => {
sessionState[INTEGRATION_ID] = null;
});
beforeEach(() => {
sessionState[INTEGRATION_ID] = null;
});
// ── factory ────────────────────────────────────────────────────────────────
// ── factory ────────────────────────────────────────────────────────────────
describe("createTechnitiumDnsIntegrationAsync (factory)", () => {
test("factory creates a working integration (auto-detects version)", async () => {
const integration = await createTechnitiumDnsIntegrationAsync(makeInput(baseUrl, { kind: "credentials" }));
describe("createTechnitiumDnsIntegrationAsync (factory)", () => {
test("factory creates a working integration (auto-detects version)", async () => {
const integration = await createTechnitiumDnsIntegrationAsync(
makeInput(baseUrl, { kind: "credentials" }),
);
const result = await integration.getSummaryAsync();
const result = await integration.getSummaryAsync();
expect(result.status).toBe("enabled");
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
}, 20_000);
});
expect(result.status).toBe("enabled");
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
}, 20_000);
});
// ── getSummaryAsync ────────────────────────────────────────────────────────
// ── getSummaryAsync ────────────────────────────────────────────────────────
describe("getSummaryAsync", () => {
test("returns valid summary with credentials", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
describe("getSummaryAsync", () => {
test("returns valid summary with credentials", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
const result = await integration.getSummaryAsync();
const result = await integration.getSummaryAsync();
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
expect(result.adsBlockedToday).toBeGreaterThanOrEqual(0);
expect(result.adsBlockedTodayPercentage).toBeGreaterThanOrEqual(0);
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
expect(result.status).toBe("enabled");
}, 20_000);
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
expect(result.adsBlockedToday).toBeGreaterThanOrEqual(0);
expect(result.adsBlockedTodayPercentage).toBeGreaterThanOrEqual(0);
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
expect(result.status).toBe("enabled");
}, 20_000);
test("returns valid summary with API key", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
apiVersion,
);
test("returns valid summary with API key", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
apiVersion,
);
const result = await integration.getSummaryAsync();
const result = await integration.getSummaryAsync();
expect(result.status).toBe("enabled");
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
}, 20_000);
expect(result.status).toBe("enabled");
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
}, 20_000);
test("domainsBeingBlocked is a non-negative number (blockedZones + blockListZones)", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
test("domainsBeingBlocked is a non-negative number (blockedZones + blockListZones)", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
const result = await integration.getSummaryAsync();
const result = await integration.getSummaryAsync();
expect(typeof result.domainsBeingBlocked).toBe("number");
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
}, 20_000);
});
expect(typeof result.domainsBeingBlocked).toBe("number");
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
}, 20_000);
});
// ── session caching ────────────────────────────────────────────────────────
// ── session caching ────────────────────────────────────────────────────────
describe("session caching", () => {
test("login is performed once and token is cached for subsequent calls", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
describe("session caching", () => {
test("login is performed once and token is cached for subsequent calls", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
await integration.getSummaryAsync();
const tokenAfterFirst = sessionState[INTEGRATION_ID]?.token;
expect(tokenAfterFirst).toBeDefined();
await integration.getSummaryAsync();
const tokenAfterFirst = sessionState[INTEGRATION_ID]?.token;
expect(tokenAfterFirst).toBeDefined();
await integration.getSummaryAsync();
const tokenAfterSecond = sessionState[INTEGRATION_ID]?.token;
await integration.getSummaryAsync();
const tokenAfterSecond = sessionState[INTEGRATION_ID]?.token;
expect(tokenAfterSecond).toBe(tokenAfterFirst);
}, 20_000);
expect(tokenAfterSecond).toBe(tokenAfterFirst);
}, 20_000);
test("recovers from an expired/invalid stored token by re-logging in", async () => {
// Plant a bad token with the correct version so it hits the right API path before failing
sessionState[INTEGRATION_ID] = { token: "deliberately-invalid-token", version: apiVersion };
test("recovers from an expired/invalid stored token by re-logging in", async () => {
// Plant a bad token with the correct version so it hits the right API path before failing
sessionState[INTEGRATION_ID] = {
token: "deliberately-invalid-token",
version: apiVersion,
};
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }), apiVersion);
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
apiVersion,
);
const result = await integration.getSummaryAsync();
const result = await integration.getSummaryAsync();
expect(result.status).toBe("enabled");
expect(sessionState[INTEGRATION_ID]).not.toBeNull();
expect(sessionState[INTEGRATION_ID]?.token).not.toBe("deliberately-invalid-token");
}, 20_000);
});
expect(result.status).toBe("enabled");
expect(sessionState[INTEGRATION_ID]).not.toBeNull();
expect(sessionState[INTEGRATION_ID]?.token).not.toBe(
"deliberately-invalid-token",
);
}, 20_000);
});
// ── blocking state changes ─────────────────────────────────────────────────
// ── blocking state changes ─────────────────────────────────────────────────
describe("blocking state changes", () => {
test("disableAsync permanently disables blocking", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
describe("blocking state changes", () => {
test("disableAsync permanently disables blocking", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
await integration.disableAsync();
const result = await integration.getSummaryAsync();
await integration.disableAsync();
const result = await integration.getSummaryAsync();
expect(result.status).toBe("disabled");
expect(result.status).toBe("disabled");
await integration.enableAsync(); // restore
}, 20_000);
await integration.enableAsync(); // restore
}, 20_000);
test("enableAsync re-enables blocking after permanent disable", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
test("enableAsync re-enables blocking after permanent disable", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
await integration.disableAsync();
await integration.enableAsync();
const result = await integration.getSummaryAsync();
await integration.disableAsync();
await integration.enableAsync();
const result = await integration.getSummaryAsync();
expect(result.status).toBe("enabled");
}, 20_000);
expect(result.status).toBe("enabled");
}, 20_000);
test("disableAsync with duration temporarily disables blocking", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
test("disableAsync with duration temporarily disables blocking", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
await integration.disableAsync(120); // 120s → 2 min
const result = await integration.getSummaryAsync();
await integration.disableAsync(120); // 120s → 2 min
const result = await integration.getSummaryAsync();
expect(result.status).toBe("disabled");
expect(result.status).toBe("disabled");
await integration.enableAsync(); // restore
}, 20_000);
await integration.enableAsync(); // restore
}, 20_000);
test("disableAsync with small duration (1s) rounds up to 1 minute and disables", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
test("disableAsync with small duration (1s) rounds up to 1 minute and disables", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
await integration.disableAsync(1);
const result = await integration.getSummaryAsync();
await integration.disableAsync(1);
const result = await integration.getSummaryAsync();
expect(result.status).toBe("disabled");
expect(result.status).toBe("disabled");
await integration.enableAsync(); // restore
}, 20_000);
await integration.enableAsync(); // restore
}, 20_000);
test("enableAsync cancels an active timed disable immediately", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
test("enableAsync cancels an active timed disable immediately", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
await integration.disableAsync(600); // 10 minutes
await integration.enableAsync();
const result = await integration.getSummaryAsync();
await integration.disableAsync(600); // 10 minutes
await integration.enableAsync();
const result = await integration.getSummaryAsync();
expect(result.status).toBe("enabled");
}, 20_000);
});
expect(result.status).toBe("enabled");
}, 20_000);
});
// ── testConnectionAsync ────────────────────────────────────────────────────
// ── testConnectionAsync ────────────────────────────────────────────────────
describe("testConnectionAsync", () => {
test("succeeds with valid credentials", async () => {
const result = await new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
).testConnectionAsync();
expect(result.success).toBe(true);
}, 20_000);
describe("testConnectionAsync", () => {
test("succeeds with valid credentials", async () => {
const result = await new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
).testConnectionAsync();
expect(result.success).toBe(true);
}, 20_000);
test("succeeds with valid API key", async () => {
const result = await new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
apiVersion,
).testConnectionAsync();
expect(result.success).toBe(true);
}, 20_000);
test("succeeds with valid API key", async () => {
const result = await new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
apiVersion,
).testConnectionAsync();
expect(result.success).toBe(true);
}, 20_000);
test("fails with wrong password — returns authorization error", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials", password: "wrong-password" }),
);
test("fails with wrong password — returns authorization error", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, {
kind: "credentials",
password: "wrong-password",
}),
);
const result = await integration.testConnectionAsync();
const result = await integration.testConnectionAsync();
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error).toBeInstanceOf(TestConnectionError);
expect(result.error.type).toBe("authorization");
}, 20_000);
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error).toBeInstanceOf(TestConnectionError);
expect(result.error.type).toBe("authorization");
}, 20_000);
test("fails with invalid API key — returns authorization error", async () => {
const result = await new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "apiKey", value: "invalid-api-key-xyz" }),
apiVersion,
).testConnectionAsync();
test("fails with invalid API key — returns authorization error", async () => {
const result = await new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "apiKey", value: "invalid-api-key-xyz" }),
apiVersion,
).testConnectionAsync();
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error).toBeInstanceOf(TestConnectionError);
expect(result.error.type).toBe("authorization");
}, 20_000);
});
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error).toBeInstanceOf(TestConnectionError);
expect(result.error.type).toBe("authorization");
}, 20_000);
});
},
);