275 lines
12 KiB
TypeScript
275 lines
12 KiB
TypeScript
import {describe, it, expect, vi, beforeEach, afterEach} from "vitest";
|
|
import request from "supertest";
|
|
import express from "express";
|
|
import {RestAuth} from "../../src/rest/auth";
|
|
import {JwtAuthenticator} from "../../src/utils/jwtAuthenticator";
|
|
import {PasswordUtils} from "../../src/utils/passwordUtils";
|
|
import {createMockJwtAuthenticator, createMockUserService, createPublicTestApp} from "../helpers/testSetup";
|
|
import crypto from "crypto";
|
|
|
|
vi.mock("../../src/db/services/db/UserService", () => ({
|
|
UserService: {
|
|
create: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../src/utils/passwordUtils", () => ({
|
|
PasswordUtils: {
|
|
validatePassword: vi.fn(),
|
|
hashPassword: vi.fn(),
|
|
comparePassword: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../src/utils/jwtAuthenticator");
|
|
vi.mock("crypto", () => ({
|
|
default: {
|
|
randomUUID: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
describe("RestAuth", () => {
|
|
let app: express.Application;
|
|
let mockUserService: any;
|
|
let mockPasswordUtils: any;
|
|
let mockJwtAuthenticator: any;
|
|
let mockCrypto: any;
|
|
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
|
|
mockUserService = createMockUserService();
|
|
|
|
mockPasswordUtils = vi.mocked(PasswordUtils);
|
|
mockCrypto = vi.mocked(crypto);
|
|
|
|
mockJwtAuthenticator = createMockJwtAuthenticator();
|
|
vi.mocked(JwtAuthenticator).mockImplementation(() => mockJwtAuthenticator);
|
|
|
|
const restAuth = new RestAuth(mockUserService, mockJwtAuthenticator);
|
|
app = createPublicTestApp(restAuth.createRouter(), "/auth");
|
|
|
|
process.env.SECRET_KEY = "test-secret-key";
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.resetAllMocks();
|
|
delete process.env.SECRET_KEY;
|
|
});
|
|
|
|
describe("POST /register", () => {
|
|
const validRegistrationData = {
|
|
username: "testuser",
|
|
password: "TestPassword123!",
|
|
timezone: "Europe/Berlin",
|
|
location: "Berlin, Germany",
|
|
};
|
|
|
|
it("should register a new user successfully", async () => {
|
|
const mockUUID = "test-uuid-123";
|
|
const hashedPassword = "hashed-password-123";
|
|
const createdUser = {
|
|
name: "testuser",
|
|
uuid: mockUUID,
|
|
timezone: "Europe/Berlin",
|
|
location: "Berlin, Germany",
|
|
config: {isVisible: false, isAdmin: false, canBeModified: false},
|
|
};
|
|
|
|
mockUserService.existsUserByName.mockResolvedValue(false);
|
|
mockPasswordUtils.validatePassword.mockReturnValue({valid: true});
|
|
mockPasswordUtils.hashPassword.mockResolvedValue(hashedPassword);
|
|
mockCrypto.randomUUID.mockReturnValue(mockUUID);
|
|
mockUserService.createUser.mockResolvedValue(createdUser);
|
|
|
|
const response = await request(app).post("/auth/register").send(validRegistrationData).expect(201);
|
|
|
|
expect(response.body.ok).toBe(true);
|
|
expect(response.body.data.user).toEqual(createdUser);
|
|
expect(mockUserService.createUser).toHaveBeenCalledWith({
|
|
name: "testuser",
|
|
password: hashedPassword,
|
|
uuid: mockUUID,
|
|
config: {isVisible: false, isAdmin: false, canBeModified: false},
|
|
timezone: "Europe/Berlin",
|
|
location: "Berlin, Germany",
|
|
});
|
|
});
|
|
|
|
it("should return conflict when username already exists", async () => {
|
|
mockUserService.existsUserByName.mockResolvedValue(true);
|
|
const response = await request(app).post("/auth/register").send(validRegistrationData).expect(409);
|
|
expect(response.body.ok).toBe(false);
|
|
expect(response.body.data.message).toBe("Username already exists");
|
|
expect(response.body.data.details.field).toBe("username");
|
|
expect(response.body.data.details.code).toBe("USERNAME_TAKEN");
|
|
});
|
|
|
|
it("should return bad request for invalid password", async () => {
|
|
mockUserService.existsUserByName.mockResolvedValue(false);
|
|
mockPasswordUtils.validatePassword.mockReturnValue({
|
|
valid: false,
|
|
message: "Password is not valid.",
|
|
});
|
|
const response = await request(app).post("/auth/register").send(validRegistrationData).expect(400);
|
|
expect(response.body.ok).toBe(false);
|
|
expect(response.body.data.message).toBe("Password is not valid.");
|
|
expect(mockPasswordUtils.hashPassword).not.toHaveBeenCalled();
|
|
expect(response.body.data.details.field).toBe("password");
|
|
expect(response.body.data.details.code).toBe("INVALID_PASSWORD_FORMAT");
|
|
|
|
});
|
|
|
|
it.each([
|
|
{field: "username"},
|
|
{field: "password"},
|
|
{field: "timezone"},
|
|
{field: "location"},
|
|
])("should return bad request when $field is missing", async ({field}) => {
|
|
const invalidData = {...validRegistrationData};
|
|
delete (invalidData as any)[field];
|
|
|
|
const response = await request(app).post("/auth/register").send(invalidData).expect(400);
|
|
expect(response.body.ok).toBe(false);
|
|
expect(response.body.data.details[0]).toContain(field);
|
|
});
|
|
|
|
it.each([
|
|
{field: "username", value: "", message: "username"},
|
|
{field: "username", value: "ab", message: "username"},
|
|
{field: "password", value: "", message: "password"},
|
|
{field: "password", value: "short", message: "password"},
|
|
{field: "timezone", value: "", message: "timezone"},
|
|
{field: "location", value: "", message: "location"},
|
|
])("should return bad request for invalid value in $field", async ({field, value, message}) => {
|
|
const response = await request(app)
|
|
.post("/auth/register")
|
|
.send({...validRegistrationData, [field]: value})
|
|
.expect(400);
|
|
expect(response.body.ok).toBe(false);
|
|
expect(response.body.data.details[0]).toContain(message);
|
|
});
|
|
});
|
|
|
|
describe("POST /login", () => {
|
|
const validLoginData = {username: "testuser", password: "TestPassword123!"};
|
|
|
|
it("should login successfully with valid credentials", async () => {
|
|
const mockUser = {name: "testuser", password: "hashed", uuid: "uuid-123", id: "user-id-123"};
|
|
const mockToken = "jwt-token-123";
|
|
|
|
mockUserService.getUserAuthByName.mockResolvedValue(mockUser);
|
|
mockPasswordUtils.comparePassword.mockResolvedValue(true);
|
|
mockJwtAuthenticator.generateToken.mockReturnValue(mockToken);
|
|
|
|
const response = await request(app).post("/auth/login").send(validLoginData).expect(200);
|
|
|
|
expect(response.body.ok).toBe(true);
|
|
expect(response.body.data.token).toBe(mockToken);
|
|
expect(mockJwtAuthenticator.generateToken).toHaveBeenCalledWith({
|
|
username: "testuser",
|
|
id: "user-id-123",
|
|
uuid: "uuid-123",
|
|
}, 24 * 60 * 60 * 1000);
|
|
|
|
const cookieHeader = response.headers['set-cookie'];
|
|
expect(cookieHeader).toBeDefined();
|
|
|
|
const cookies = Array.isArray(cookieHeader) ? cookieHeader : [cookieHeader!];
|
|
|
|
const authTokenCookie = cookies.find((cookie: string) => cookie.startsWith("auth-token="));
|
|
expect(authTokenCookie).toBeDefined();
|
|
expect(authTokenCookie).toContain(`auth-token=${mockToken}`);
|
|
expect(authTokenCookie).toContain("HttpOnly");
|
|
expect(authTokenCookie).toContain("Path=/");
|
|
expect(authTokenCookie).toContain("SameSite=Lax");
|
|
});
|
|
|
|
it("should login with longer token validity when stayLoggedIn is true", async () => {
|
|
const mockUser = {name: "testuser", password: "hashed", uuid: "uuid-123", id: "user-id-123"};
|
|
const mockToken = "jwt-token-123";
|
|
const loginDataWithStayLoggedIn = { ...validLoginData, stayLoggedIn: true };
|
|
|
|
mockUserService.getUserAuthByName.mockResolvedValue(mockUser);
|
|
mockPasswordUtils.comparePassword.mockResolvedValue(true);
|
|
mockJwtAuthenticator.generateToken.mockReturnValue(mockToken);
|
|
|
|
const response = await request(app).post("/auth/login").send(loginDataWithStayLoggedIn).expect(200);
|
|
|
|
expect(response.body.ok).toBe(true);
|
|
expect(response.body.data.token).toBe(mockToken);
|
|
expect(mockJwtAuthenticator.generateToken).toHaveBeenCalledWith({
|
|
username: "testuser",
|
|
id: "user-id-123",
|
|
uuid: "uuid-123",
|
|
}, 30 * 24 * 60 * 60 * 1000);
|
|
|
|
const cookieHeader = response.headers['set-cookie'];
|
|
expect(cookieHeader).toBeDefined();
|
|
|
|
const cookies = Array.isArray(cookieHeader) ? cookieHeader : [cookieHeader!];
|
|
const authTokenCookie = cookies.find((cookie: string) => cookie.startsWith("auth-token="));
|
|
expect(authTokenCookie).toBeDefined();
|
|
expect(authTokenCookie).toContain(`auth-token=${mockToken}`);
|
|
});
|
|
|
|
describe("POST /logout", () => {
|
|
it("should clear the auth-token cookie and return a success message", async () => {
|
|
const response = await request(app).post("/auth/logout").send().expect(200);
|
|
|
|
expect(response.body.ok).toBe(true);
|
|
expect(response.body.data.message).toBe("Successfully logged out");
|
|
|
|
const cookieHeader = response.headers['set-cookie'];
|
|
expect(cookieHeader).toBeDefined();
|
|
const cookies = Array.isArray(cookieHeader) ? cookieHeader : [cookieHeader!];
|
|
|
|
const authTokenCookie = cookies.find((cookie: string) => cookie.startsWith("auth-token="));
|
|
expect(authTokenCookie).toBeDefined();
|
|
expect(authTokenCookie).toContain("auth-token=;");
|
|
expect(authTokenCookie).toContain("Expires=Thu, 01 Jan 1970 00:00:00 GMT");
|
|
});
|
|
|
|
it("should return not found when user does not exist", async () => {
|
|
mockUserService.getUserAuthByName.mockResolvedValue(null);
|
|
const response = await request(app).post("/auth/login").send(validLoginData).expect(404);
|
|
expect(response.body.ok).toBe(false);
|
|
expect(response.body.data.message).toBe("User not found");
|
|
expect(response.body.data.details.field).toBe("username")
|
|
expect(response.body.data.details.code).toBe("INVALID_USER")
|
|
|
|
});
|
|
|
|
it("should return unauthorized for invalid password", async () => {
|
|
const mockUser = {name: "testuser", password: "hashed"};
|
|
mockUserService.getUserAuthByName.mockResolvedValue(mockUser);
|
|
mockPasswordUtils.comparePassword.mockResolvedValue(false);
|
|
const response = await request(app).post("/auth/login").send(validLoginData).expect(401);
|
|
expect(response.body.ok).toBe(false);
|
|
expect(response.body.data.message).toBe("Invalid password");
|
|
expect(response.body.data.details.field).toBe("password")
|
|
expect(response.body.data.details.code).toBe("INVALID_PASSWORD")
|
|
});
|
|
|
|
it.each([
|
|
{field: "username", value: ""},
|
|
{field: "password", value: ""},
|
|
{field: "username", value: undefined},
|
|
{field: "password", value: undefined},
|
|
])("should return bad request if $field is '$value'", async ({field, value}) => {
|
|
const invalidData = {...validLoginData};
|
|
if (value === undefined) {
|
|
delete (invalidData as any)[field];
|
|
} else {
|
|
(invalidData as any)[field] = value;
|
|
}
|
|
|
|
const response = await request(app).post("/auth/login").send(invalidData).expect(400);
|
|
|
|
expect(response.body.ok).toBe(false);
|
|
expect(response.body.data.details[0]).toContain(field);
|
|
});
|
|
});
|
|
});
|
|
}); |