test: add initial unit tests (#56)

* chore: add initial db migration

* test: add unit tests for packages auth, common, widgets

* fix: deep source issues

* fix: format issues

* wip: add unit tests for api routers

* fix: deep source issues

* test:  add missing unit tests for integration router

* wip: board tests

* test: add unit tests for board router

* fix: remove unnecessary null assertions

* fix: deepsource issues

* fix: formatting

* fix: pnpm lock

* fix: lint and typecheck issues

* chore: address pull request feedback

* fix: non-null assertions

* fix: lockfile broken
This commit is contained in:
Meier Lukas
2024-02-10 19:00:08 +01:00
committed by GitHub
parent 13aae82790
commit f070a0cb0a
34 changed files with 3014 additions and 129 deletions

View File

@@ -0,0 +1,61 @@
import { cookies } from "next/headers";
import type { Adapter } from "@auth/core/adapters";
import type { NextAuthConfig } from "next-auth";
import {
expireDateAfter,
generateSessionToken,
sessionMaxAgeInSeconds,
sessionTokenCookieName,
} from "./session";
export const sessionCallback: NextAuthCallbackOf<"session"> = ({
session,
user,
}) => ({
...session,
user: {
...session.user,
id: user.id,
name: user.name,
},
});
export const createSignInCallback =
(
adapter: Adapter,
isCredentialsRequest: boolean,
): NextAuthCallbackOf<"signIn"> =>
async ({ user }) => {
if (!isCredentialsRequest) return true;
if (!user) return true;
// https://github.com/nextauthjs/next-auth/issues/6106
if (!adapter?.createSession) {
return false;
}
const sessionToken = generateSessionToken();
const sessionExpiry = expireDateAfter(sessionMaxAgeInSeconds);
await adapter.createSession({
sessionToken,
userId: user.id!,
expires: sessionExpiry,
});
cookies().set(sessionTokenCookieName, sessionToken, {
path: "/",
expires: sessionExpiry,
httpOnly: true,
sameSite: "lax",
secure: true,
});
return true;
};
type NextAuthCallbackRecord = Exclude<NextAuthConfig["callbacks"], undefined>;
export type NextAuthCallbackOf<TKey extends keyof NextAuthCallbackRecord> =
Exclude<NextAuthCallbackRecord[TKey], undefined>;

View File

@@ -5,55 +5,23 @@ import Credentials from "next-auth/providers/credentials";
import { db } from "@homarr/db";
import { credentialsConfiguration } from "./providers/credentials";
import { createSignInCallback, sessionCallback } from "./callbacks";
import { createCredentialsConfiguration } from "./providers/credentials";
import { EmptyNextAuthProvider } from "./providers/empty";
import { expireDateAfter, generateSessionToken } from "./session";
import { sessionMaxAgeInSeconds, sessionTokenCookieName } from "./session";
const adapter = DrizzleAdapter(db);
const sessionMaxAgeInSeconds = 30 * 24 * 60 * 60; // 30 days
export const createConfiguration = (isCredentialsRequest: boolean) =>
NextAuth({
adapter,
providers: [Credentials(credentialsConfiguration), EmptyNextAuthProvider()],
providers: [
Credentials(createCredentialsConfiguration(db)),
EmptyNextAuthProvider(),
],
callbacks: {
session: ({ session, user }) => ({
...session,
user: {
...session.user,
id: user.id,
name: user.name,
},
}),
signIn: async ({ user }) => {
if (!isCredentialsRequest) return true;
if (!user) return true;
const sessionToken = generateSessionToken();
const sessionExpiry = expireDateAfter(sessionMaxAgeInSeconds);
// https://github.com/nextauthjs/next-auth/issues/6106
if (!adapter?.createSession) {
return false;
}
await adapter.createSession({
sessionToken: sessionToken,
userId: user.id!,
expires: sessionExpiry,
});
cookies().set("next-auth.session-token", sessionToken, {
path: "/",
expires: sessionExpiry,
httpOnly: true,
sameSite: "lax",
secure: true,
});
return true;
},
session: sessionCallback,
signIn: createSignInCallback(adapter, isCredentialsRequest),
},
session: {
strategy: "database",
@@ -65,7 +33,7 @@ export const createConfiguration = (isCredentialsRequest: boolean) =>
},
jwt: {
encode() {
const cookie = cookies().get("next-auth.session-token")?.value;
const cookie = cookies().get(sessionTokenCookieName)?.value;
return cookie ?? "";
},

View File

@@ -1,6 +1,12 @@
{
"name": "@homarr/auth",
"version": "0.1.0",
"exports": {
".": "./index.ts",
"./security": "./security.ts",
"./client": "./client.ts",
"./env.mjs": "./env.mjs"
},
"private": true,
"main": "./index.ts",
"types": "./index.ts",

View File

@@ -1,49 +1,56 @@
import type Credentials from "@auth/core/providers/credentials";
import bcrypt from "bcrypt";
import { db, eq } from "@homarr/db";
import type { Database } from "@homarr/db";
import { eq } from "@homarr/db";
import { users } from "@homarr/db/schema/sqlite";
import { validation } from "@homarr/validation";
type CredentialsConfiguration = Parameters<typeof Credentials>[0];
export const credentialsConfiguration = {
type: "credentials",
name: "Credentials",
credentials: {
name: {
label: "Username",
type: "text",
export const createCredentialsConfiguration = (db: Database) =>
({
type: "credentials",
name: "Credentials",
credentials: {
name: {
label: "Username",
type: "text",
},
password: {
label: "Password",
type: "password",
},
},
password: {
label: "Password",
type: "password",
async authorize(credentials) {
const data = await validation.user.signIn.parseAsync(credentials);
const user = await db.query.users.findFirst({
where: eq(users.name, data.name),
});
if (!user?.password) {
return null;
}
console.log(
`user ${user.name} is trying to log in. checking password...`,
);
const isValidPassword = await bcrypt.compare(
data.password,
user.password,
);
if (!isValidPassword) {
console.log(`password for user ${user.name} was incorrect`);
return null;
}
console.log(`user ${user.name} successfully authorized`);
return {
id: user.id,
name: user.name,
};
},
},
async authorize(credentials) {
const data = await validation.user.signIn.parseAsync(credentials);
const user = await db.query.users.findFirst({
where: eq(users.name, data.name),
});
if (!user?.password) {
return null;
}
console.log(`user ${user.name} is trying to log in. checking password...`);
const isValidPassword = await bcrypt.compare(data.password, user.password);
if (!isValidPassword) {
console.log(`password for user ${user.name} was incorrect`);
return null;
}
console.log(`user ${user.name} successfully authorized`);
return {
id: user.id,
name: user.name,
};
},
} satisfies CredentialsConfiguration;
}) satisfies CredentialsConfiguration;

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { createId } from "@homarr/db";
import { users } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
import { createSalt, hashPassword } from "../../security";
import { createCredentialsConfiguration } from "../credentials";
describe("Credentials authorization", () => {
it("should authorize user with correct credentials", async () => {
const db = createDb();
const userId = createId();
const salt = await createSalt();
await db.insert(users).values({
id: userId,
name: "test",
password: await hashPassword("test", salt),
salt,
});
const result = await createCredentialsConfiguration(db).authorize({
name: "test",
password: "test",
});
expect(result).toEqual({ id: userId, name: "test" });
});
const passwordsThatShouldNotAuthorize = [
"wrong",
"Test",
"test ",
" test",
" test ",
];
passwordsThatShouldNotAuthorize.forEach((password) => {
it(`should not authorize user with incorrect credentials (${password})`, async () => {
const db = createDb();
const userId = createId();
const salt = await createSalt();
await db.insert(users).values({
id: userId,
name: "test",
password: await hashPassword("test", salt),
salt,
});
const result = await createCredentialsConfiguration(db).authorize({
name: "test",
password,
});
expect(result).toBeNull();
});
});
it("should not authorize user for not existing user", async () => {
const db = createDb();
const result = await createCredentialsConfiguration(db).authorize({
name: "test",
password: "test",
});
expect(result).toBeNull();
});
});

View File

@@ -1,5 +1,8 @@
import { randomUUID } from "crypto";
export const sessionMaxAgeInSeconds = 30 * 24 * 60 * 60; // 30 days
export const sessionTokenCookieName = "next-auth.session-token";
export const expireDateAfter = (seconds: number) => {
return new Date(Date.now() + seconds * 1000);
};

View File

@@ -0,0 +1,153 @@
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 type { Adapter, AdapterUser } from "@auth/core/adapters";
import type { Account, User } from "next-auth";
import type { JWT } from "next-auth/jwt";
import { describe, expect, it, vi } from "vitest";
import { createSignInCallback, sessionCallback } from "../callbacks";
describe("session callback", () => {
it("should add id and name to session user", async () => {
const user: AdapterUser = {
id: "id",
name: "name",
email: "email",
emailVerified: new Date("2023-01-13"),
};
const token: JWT = {};
const result = await sessionCallback({
session: {
user: {
id: "no-id",
email: "no-email",
emailVerified: new Date("2023-01-13"),
},
expires: "2023-01-13" as Date & string,
sessionToken: "token",
userId: "no-id",
},
user,
token,
trigger: "update",
newSession: {},
});
expect(result.user).toBeDefined();
expect(result.user!.id).toEqual(user.id);
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" as const;
const mockSessionExpiry = new Date("2023-07-01");
vi.mock("../session", async (importOriginal) => {
const mod = await importOriginal<SessionExport>();
const generateSessionToken = () => mockSessionToken;
const expireDateAfter = (_seconds: number) => mockSessionExpiry;
return {
...mod,
generateSessionToken,
expireDateAfter,
} satisfies SessionExport;
});
// 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("createSignInCallback", () => {
it("should return true if not credentials request", async () => {
const isCredentialsRequest = false;
const signInCallback = createSignInCallback(
createAdapter(),
isCredentialsRequest,
);
const result = await signInCallback({
user: { id: "1", emailVerified: new Date("2023-01-13") },
account: {} as Account,
});
expect(result).toBe(true);
});
it("should return true if no user", async () => {
const isCredentialsRequest = true;
const signInCallback = createSignInCallback(
createAdapter(),
isCredentialsRequest,
);
const result = await signInCallback({
user: undefined as unknown as User,
account: {} as Account,
});
expect(result).toBe(true);
});
it("should return false if no adapter.createSession", async () => {
const isCredentialsRequest = true;
const signInCallback = createSignInCallback(
// https://github.com/nextauthjs/next-auth/issues/6106
undefined as unknown as Adapter,
isCredentialsRequest,
);
const result = await signInCallback({
user: { id: "1", emailVerified: new Date("2023-01-13") },
account: {} as Account,
});
expect(result).toBe(false);
});
it("should call adapter.createSession with correct input", async () => {
const adapter = createAdapter();
const isCredentialsRequest = true;
const signInCallback = createSignInCallback(adapter, isCredentialsRequest);
const user = { id: "1", emailVerified: new Date("2023-01-13") };
const account = {} as Account;
await signInCallback({ user, account });
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,
},
);
});
});

View File

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

View File

@@ -0,0 +1,43 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "@homarr/validation";
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().uuid().safeParse(result).success).toBe(true);
});
it("should return a different token each time", () => {
const result1 = generateSessionToken();
const result2 = generateSessionToken();
expect(result1).not.toEqual(result2);
});
});