refactor: replace signIn callback with signIn event, adjust getUserByEmail in adapter to check provider (#1223)
* refactor: replace signIn callback with signIn event, adjust getUserByEmail in adapter to check provider * test: adjusting tests for adapter and events * docs: add comments for unknown auth provider * fix: missing dayjs import
This commit is contained in:
67
packages/auth/test/adapter.spec.ts
Normal file
67
packages/auth/test/adapter.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { users } from "@homarr/db/schema/sqlite";
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { cookies } from "next/headers";
|
||||
import type { Adapter, AdapterUser } from "@auth/core/adapters";
|
||||
import type { Account } from "next-auth";
|
||||
import type { AdapterUser } from "@auth/core/adapters";
|
||||
import type { JWT } from "next-auth/jwt";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
@@ -9,7 +7,7 @@ import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import * as definitions from "@homarr/definitions";
|
||||
|
||||
import { createSessionCallback, createSignInCallback, getCurrentUserPermissionsAsync } from "../callbacks";
|
||||
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();
|
||||
@@ -141,151 +139,3 @@ describe("session callback", () => {
|
||||
expect(result.user!.name).toEqual(user.name);
|
||||
});
|
||||
});
|
||||
|
||||
type AdapterSessionInput = Parameters<Exclude<Adapter["createSession"], undefined>>[0];
|
||||
|
||||
const createAdapter = () => {
|
||||
const result = {
|
||||
createSession: (input: AdapterSessionInput) => input,
|
||||
};
|
||||
|
||||
vi.spyOn(result, "createSession");
|
||||
return result;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
type SessionExport = typeof import("../session");
|
||||
const mockSessionToken = "e9ef3010-6981-4a81-b9d6-8495d09cf3b5";
|
||||
const mockSessionExpiry = new Date("2023-07-01");
|
||||
vi.mock("../env.mjs", () => {
|
||||
return {
|
||||
env: {
|
||||
AUTH_SESSION_EXPIRY_TIME: 60 * 60 * 24 * 7,
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../session", async (importOriginal) => {
|
||||
const mod = await importOriginal<SessionExport>();
|
||||
|
||||
const generateSessionToken = (): typeof mockSessionToken => mockSessionToken;
|
||||
const expireDateAfter = (_seconds: number) => mockSessionExpiry;
|
||||
|
||||
return {
|
||||
...mod,
|
||||
generateSessionToken,
|
||||
expireDateAfter,
|
||||
} satisfies SessionExport;
|
||||
});
|
||||
|
||||
describe("createSignInCallback", () => {
|
||||
test("should return true if not credentials request and set colorScheme & sessionToken cookie", async () => {
|
||||
// Arrange
|
||||
const isCredentialsRequest = false;
|
||||
const db = await prepareDbForSigninAsync("1");
|
||||
const signInCallback = createSignInCallback(createAdapter(), db, isCredentialsRequest);
|
||||
|
||||
// Act
|
||||
const result = await signInCallback({
|
||||
user: { id: "1", emailVerified: new Date("2023-01-13") },
|
||||
account: {} as Account,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if no adapter.createSession", async () => {
|
||||
// Arrange
|
||||
const isCredentialsRequest = true;
|
||||
const db = await prepareDbForSigninAsync("1");
|
||||
const signInCallback = createSignInCallback(
|
||||
// https://github.com/nextauthjs/next-auth/issues/6106
|
||||
{ createSession: undefined } as unknown as Adapter,
|
||||
db,
|
||||
isCredentialsRequest,
|
||||
);
|
||||
|
||||
// Act
|
||||
const result = await signInCallback({
|
||||
user: { id: "1", emailVerified: new Date("2023-01-13") },
|
||||
account: {} as Account,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("should call adapter.createSession with correct input", async () => {
|
||||
// Arrange
|
||||
const adapter = createAdapter();
|
||||
const isCredentialsRequest = true;
|
||||
const db = await prepareDbForSigninAsync("1");
|
||||
const signInCallback = createSignInCallback(adapter, db, isCredentialsRequest);
|
||||
const user = { id: "1", emailVerified: new Date("2023-01-13") };
|
||||
const account = {} as Account;
|
||||
// Act
|
||||
await signInCallback({ user, account });
|
||||
|
||||
// Assert
|
||||
expect(adapter.createSession).toHaveBeenCalledWith({
|
||||
sessionToken: mockSessionToken,
|
||||
userId: user.id,
|
||||
expires: mockSessionExpiry,
|
||||
});
|
||||
expect(cookies().set).toHaveBeenCalledWith("next-auth.session-token", mockSessionToken, {
|
||||
path: "/",
|
||||
expires: mockSessionExpiry,
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("should set colorScheme from db as cookie", async () => {
|
||||
// Arrange
|
||||
const isCredentialsRequest = true;
|
||||
const db = await prepareDbForSigninAsync("1");
|
||||
const signInCallback = createSignInCallback(createAdapter(), db, isCredentialsRequest);
|
||||
|
||||
// Act
|
||||
const result = await signInCallback({
|
||||
user: { id: "1", emailVerified: new Date("2023-01-13") },
|
||||
account: {} as Account,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(true);
|
||||
expect(cookies().set).toHaveBeenCalledWith(
|
||||
"homarr-color-scheme",
|
||||
"dark",
|
||||
expect.objectContaining({
|
||||
path: "/",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("should return false if user not found in db", async () => {
|
||||
// Arrange
|
||||
const isCredentialsRequest = true;
|
||||
const db = await prepareDbForSigninAsync("other-id");
|
||||
const signInCallback = createSignInCallback(createAdapter(), db, isCredentialsRequest);
|
||||
|
||||
// Act
|
||||
const result = await signInCallback({
|
||||
user: { id: "1", emailVerified: new Date("2023-01-13") },
|
||||
account: {} as Account,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const prepareDbForSigninAsync = async (userId: string) => {
|
||||
const db = createDb();
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
colorScheme: "dark",
|
||||
});
|
||||
return db;
|
||||
};
|
||||
|
||||
190
packages/auth/test/events.spec.ts
Normal file
190
packages/auth/test/events.spec.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
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/sqlite";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { createSignInEventHandler } from "../events";
|
||||
|
||||
vi.mock("../env.mjs", () => {
|
||||
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 = () => result;
|
||||
|
||||
return { ...mod, cookies } satisfies HeadersExport;
|
||||
});
|
||||
|
||||
describe("createSignInEventHandler should create signInEventHandler", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
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.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 homarr-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(cookies().set).toHaveBeenCalledWith(
|
||||
"homarr-color-scheme",
|
||||
"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) =>
|
||||
await db.insert(groups).values({
|
||||
id: "1",
|
||||
name: "test",
|
||||
});
|
||||
Reference in New Issue
Block a user