1dcd333fde
* feat: add account deletion feature - Add DELETE /auth/me endpoint to delete user account and all data - Add deleteAccount() method to AuthContext - Add Delete Account button with confirmation modal in UserProfile - Add danger zone styling (.btn-danger, .profile-danger-zone) - Add i18n translations for EN and DE - Add backend tests for account deletion endpoint - Add timeout settings to frontend vitest.config.ts - Reduce CI timeout for frontend tests (10min -> 5min) * fix: improve delete account section layout - Make profile modal scrollable with max-height - Add proper horizontal margin to danger zone - Align delete section with form content * fix: use ConfirmModal component for delete account dialog - Replace inline modal with existing ConfirmModal component - Ensures consistent button styling across all modals - Add UI consistency rule to AGENTS.md and copilot-instructions.md * fix: consistent styling for delete account section - Remove warning text (users know what delete means) - Remove border-bottom from danger zone title (section has border-top) - Update copilot-instructions and AGENTS.md with stricter UI consistency rules - Remove unused deleteAccountHint i18n keys * chore: remove pre-push test hook (CI handles tests) Tests were running twice - in pre-push hook and GitHub CI. Removing local pre-push tests since CI provides authoritative test results. Use 'npm test' manually before pushing if you want local feedback.
744 lines
18 KiB
TypeScript
744 lines
18 KiB
TypeScript
/**
|
|
* E2E Tests for auth routes with AUTH_ENABLED=true
|
|
*/
|
|
|
|
import cookie from "@fastify/cookie";
|
|
import jwt from "@fastify/jwt";
|
|
import sensible from "@fastify/sensible";
|
|
import type { Client } from "@libsql/client";
|
|
import Fastify, { type FastifyInstance } from "fastify";
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
// Use vi.hoisted to create the db BEFORE mocks are set up
|
|
const { testClient, testDb } = vi.hoisted(() => {
|
|
const { createClient } = require("@libsql/client");
|
|
const { drizzle } = require("drizzle-orm/libsql");
|
|
const client = createClient({ url: ":memory:" });
|
|
const db = drizzle(client);
|
|
return { testClient: client, testDb: db };
|
|
});
|
|
|
|
// Mock modules using the hoisted db
|
|
vi.mock("../db/client.js", () => ({
|
|
db: testDb,
|
|
migrationsReady: Promise.resolve(),
|
|
}));
|
|
|
|
// Enable auth for these tests
|
|
vi.mock("../plugins/env.js", () => ({
|
|
env: {
|
|
AUTH_ENABLED: true,
|
|
LOCAL_AUTH_ENABLED: true,
|
|
REGISTRATION_ENABLED: true,
|
|
OIDC_ENABLED: false,
|
|
NODE_ENV: "test",
|
|
LOG_LEVEL: "silent",
|
|
PORT: 3000,
|
|
CORS_ORIGINS: "*",
|
|
JWT_SECRET: "test-jwt-secret-12345",
|
|
REFRESH_SECRET: "test-refresh-secret-12345",
|
|
COOKIE_SECRET: "test-cookie-secret-12345",
|
|
ACCESS_TOKEN_TTL_MINUTES: 15,
|
|
REFRESH_TOKEN_TTL_DAYS: 7,
|
|
},
|
|
}));
|
|
|
|
// Import real auth plugin and routes
|
|
const { authRoutes } = await import("../routes/auth.js");
|
|
|
|
// =============================================================================
|
|
// Test Setup
|
|
// =============================================================================
|
|
|
|
async function createSchema(client: Client) {
|
|
const tableCreations = [
|
|
`CREATE TABLE IF NOT EXISTS users (
|
|
id integer PRIMARY KEY AUTOINCREMENT,
|
|
username text NOT NULL UNIQUE,
|
|
password_hash text,
|
|
avatar_url text,
|
|
auth_provider text NOT NULL DEFAULT 'local',
|
|
oidc_subject text,
|
|
is_active integer NOT NULL DEFAULT 1,
|
|
last_login_at integer,
|
|
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
|
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS refresh_tokens (
|
|
id integer PRIMARY KEY AUTOINCREMENT,
|
|
user_id integer NOT NULL,
|
|
token_id text NOT NULL UNIQUE,
|
|
expires_at integer NOT NULL,
|
|
revoked integer NOT NULL DEFAULT 0,
|
|
rotated_at integer,
|
|
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)`,
|
|
];
|
|
|
|
for (const sql of tableCreations) {
|
|
await client.execute(sql);
|
|
}
|
|
}
|
|
|
|
async function clearData(client: Client) {
|
|
await client.execute("DELETE FROM refresh_tokens");
|
|
await client.execute("DELETE FROM users");
|
|
await client.execute("DELETE FROM sqlite_sequence");
|
|
}
|
|
|
|
// =============================================================================
|
|
// Tests
|
|
// =============================================================================
|
|
|
|
describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
|
let app: FastifyInstance;
|
|
|
|
beforeAll(async () => {
|
|
await createSchema(testClient);
|
|
|
|
app = Fastify({ logger: false });
|
|
|
|
await app.register(sensible);
|
|
await app.register(cookie, { secret: "test-cookie-secret-12345" });
|
|
await app.register(jwt, {
|
|
secret: "test-jwt-secret-12345",
|
|
cookie: { cookieName: "access_token", signed: false },
|
|
});
|
|
|
|
// Decorate with config needed by auth routes
|
|
app.decorate("config", {
|
|
accessSecret: "test-jwt-secret-12345",
|
|
refreshSecret: "test-refresh-secret-12345",
|
|
accessTtl: 15,
|
|
refreshTtl: 7,
|
|
cookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/", maxAge: 15 * 60 },
|
|
refreshCookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/auth", maxAge: 7 * 24 * 60 * 60 },
|
|
});
|
|
|
|
await app.register(authRoutes);
|
|
await app.ready();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
testClient.close();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await clearData(testClient);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Auth State Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("GET /auth/state", () => {
|
|
it("should return auth state", async () => {
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: "/auth/state",
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
const data = response.json();
|
|
expect(data.authEnabled).toBe(true);
|
|
expect(data.registrationEnabled).toBe(true);
|
|
expect(data.localAuthEnabled).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Registration Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("POST /auth/register", () => {
|
|
it("should register a new user", async () => {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/register",
|
|
payload: {
|
|
username: "testuser",
|
|
password: "TestPassword123",
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(201);
|
|
const data = response.json();
|
|
expect(data.ok).toBe(true);
|
|
expect(data.user.username).toBe("testuser");
|
|
});
|
|
|
|
it("should reject duplicate username", async () => {
|
|
// First registration
|
|
await app.inject({
|
|
method: "POST",
|
|
url: "/auth/register",
|
|
payload: {
|
|
username: "duplicate",
|
|
password: "TestPassword123",
|
|
},
|
|
});
|
|
|
|
// Second registration with same username
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/register",
|
|
payload: {
|
|
username: "duplicate",
|
|
password: "AnotherPassword123",
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(409);
|
|
expect(response.json().code).toBe("USERNAME_EXISTS");
|
|
});
|
|
|
|
it("should reject short password", async () => {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/register",
|
|
payload: {
|
|
username: "testuser",
|
|
password: "short",
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json().code).toBe("VALIDATION_ERROR");
|
|
});
|
|
|
|
it("should reject short username", async () => {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/register",
|
|
payload: {
|
|
username: "ab",
|
|
password: "ValidPassword123",
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json().code).toBe("VALIDATION_ERROR");
|
|
});
|
|
|
|
it("should reject invalid username characters", async () => {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/register",
|
|
payload: {
|
|
username: "test@user",
|
|
password: "ValidPassword123",
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json().code).toBe("VALIDATION_ERROR");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Login Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("POST /auth/login", () => {
|
|
beforeEach(async () => {
|
|
// Create a test user
|
|
await app.inject({
|
|
method: "POST",
|
|
url: "/auth/register",
|
|
payload: {
|
|
username: "loginuser",
|
|
password: "TestPassword123",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should login with valid credentials", async () => {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/login",
|
|
payload: {
|
|
username: "loginuser",
|
|
password: "TestPassword123",
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
const data = response.json();
|
|
expect(data.ok).toBe(true);
|
|
expect(data.user.username).toBe("loginuser");
|
|
|
|
// Should set cookies
|
|
const cookies = response.cookies;
|
|
expect(cookies.find((c: any) => c.name === "access_token")).toBeDefined();
|
|
expect(cookies.find((c: any) => c.name === "refresh_token")).toBeDefined();
|
|
});
|
|
|
|
it("should reject invalid password", async () => {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/login",
|
|
payload: {
|
|
username: "loginuser",
|
|
password: "WrongPassword",
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(401);
|
|
expect(response.json().code).toBe("INVALID_CREDENTIALS");
|
|
});
|
|
|
|
it("should reject non-existent user", async () => {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/login",
|
|
payload: {
|
|
username: "nonexistent",
|
|
password: "TestPassword123",
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(401);
|
|
expect(response.json().code).toBe("INVALID_CREDENTIALS");
|
|
});
|
|
|
|
it("should support rememberMe option", async () => {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/login",
|
|
payload: {
|
|
username: "loginuser",
|
|
password: "TestPassword123",
|
|
rememberMe: true,
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
const data = response.json();
|
|
expect(data.ok).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Token Refresh Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("POST /auth/refresh", () => {
|
|
it("should refresh access token with valid refresh token", async () => {
|
|
// Login first to get tokens
|
|
const _loginResponse = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/login",
|
|
payload: {
|
|
username: "loginuser",
|
|
password: "TestPassword123",
|
|
},
|
|
});
|
|
|
|
// Need to create user first
|
|
await app.inject({
|
|
method: "POST",
|
|
url: "/auth/register",
|
|
payload: {
|
|
username: "refreshuser",
|
|
password: "TestPassword123",
|
|
},
|
|
});
|
|
|
|
const login = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/login",
|
|
payload: {
|
|
username: "refreshuser",
|
|
password: "TestPassword123",
|
|
},
|
|
});
|
|
|
|
const refreshToken = login.cookies.find((c: any) => c.name === "refresh_token");
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/refresh",
|
|
cookies: {
|
|
refresh_token: refreshToken?.value ?? "",
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().ok).toBe(true);
|
|
});
|
|
|
|
it("should reject without refresh token", async () => {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/refresh",
|
|
});
|
|
|
|
expect(response.statusCode).toBe(401);
|
|
expect(response.json().code).toBe("NO_REFRESH_TOKEN");
|
|
});
|
|
|
|
it("should reject invalid refresh token", async () => {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/refresh",
|
|
cookies: {
|
|
refresh_token: "invalid-token",
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(401);
|
|
expect(response.json().code).toBe("INVALID_REFRESH_TOKEN");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Logout Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("POST /auth/logout", () => {
|
|
it("should logout and clear cookies", async () => {
|
|
// Register and login first
|
|
await app.inject({
|
|
method: "POST",
|
|
url: "/auth/register",
|
|
payload: {
|
|
username: "logoutuser",
|
|
password: "TestPassword123",
|
|
},
|
|
});
|
|
|
|
const login = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/login",
|
|
payload: {
|
|
username: "logoutuser",
|
|
password: "TestPassword123",
|
|
},
|
|
});
|
|
|
|
const refreshToken = login.cookies.find((c: any) => c.name === "refresh_token");
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/logout",
|
|
cookies: {
|
|
refresh_token: refreshToken?.value ?? "",
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().ok).toBe(true);
|
|
});
|
|
|
|
it("should succeed even without refresh token", async () => {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/logout",
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().ok).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Me Endpoint Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("GET /auth/me", () => {
|
|
it("should return user info with valid access token", async () => {
|
|
// Register and login
|
|
await app.inject({
|
|
method: "POST",
|
|
url: "/auth/register",
|
|
payload: {
|
|
username: "meuser",
|
|
password: "TestPassword123",
|
|
},
|
|
});
|
|
|
|
const login = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/login",
|
|
payload: {
|
|
username: "meuser",
|
|
password: "TestPassword123",
|
|
},
|
|
});
|
|
|
|
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
|
|
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: "/auth/me",
|
|
cookies: {
|
|
access_token: accessToken?.value ?? "",
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
const data = response.json();
|
|
expect(data.username).toBe("meuser");
|
|
});
|
|
|
|
it("should reject without access token", async () => {
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: "/auth/me",
|
|
});
|
|
|
|
expect(response.statusCode).toBe(401);
|
|
});
|
|
|
|
it("should reject with invalid access token", async () => {
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: "/auth/me",
|
|
cookies: {
|
|
access_token: "invalid.jwt.token",
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(401);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Inactive User Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("Inactive user handling", () => {
|
|
it("should reject login for inactive user", async () => {
|
|
// Create user
|
|
await app.inject({
|
|
method: "POST",
|
|
url: "/auth/register",
|
|
payload: {
|
|
username: "inactiveuser",
|
|
password: "TestPassword123",
|
|
},
|
|
});
|
|
|
|
// Manually deactivate user in DB
|
|
await testClient.execute({
|
|
sql: "UPDATE users SET is_active = 0 WHERE username = ?",
|
|
args: ["inactiveuser"],
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/login",
|
|
payload: {
|
|
username: "inactiveuser",
|
|
password: "TestPassword123",
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(401);
|
|
expect(response.json().code).toBe("ACCOUNT_DISABLED");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Profile Update Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("PUT /auth/me (profile update)", () => {
|
|
it("should update password with valid current password", async () => {
|
|
// Register and login
|
|
await app.inject({
|
|
method: "POST",
|
|
url: "/auth/register",
|
|
payload: {
|
|
username: "profileuser",
|
|
password: "TestPassword123",
|
|
},
|
|
});
|
|
|
|
const login = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/login",
|
|
payload: {
|
|
username: "profileuser",
|
|
password: "TestPassword123",
|
|
},
|
|
});
|
|
|
|
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
|
|
|
|
const response = await app.inject({
|
|
method: "PUT",
|
|
url: "/auth/me",
|
|
cookies: {
|
|
access_token: accessToken?.value ?? "",
|
|
},
|
|
payload: {
|
|
currentPassword: "TestPassword123",
|
|
newPassword: "NewPassword456",
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().ok).toBe(true);
|
|
|
|
// Verify can login with new password
|
|
const newLogin = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/login",
|
|
payload: {
|
|
username: "profileuser",
|
|
password: "NewPassword456",
|
|
},
|
|
});
|
|
|
|
expect(newLogin.statusCode).toBe(200);
|
|
});
|
|
|
|
it("should reject password change without current password", async () => {
|
|
await app.inject({
|
|
method: "POST",
|
|
url: "/auth/register",
|
|
payload: {
|
|
username: "profileuser2",
|
|
password: "TestPassword123",
|
|
},
|
|
});
|
|
|
|
const login = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/login",
|
|
payload: {
|
|
username: "profileuser2",
|
|
password: "TestPassword123",
|
|
},
|
|
});
|
|
|
|
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
|
|
|
|
const response = await app.inject({
|
|
method: "PUT",
|
|
url: "/auth/me",
|
|
cookies: {
|
|
access_token: accessToken?.value ?? "",
|
|
},
|
|
payload: {
|
|
newPassword: "NewPassword456",
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json().code).toBe("CURRENT_PASSWORD_REQUIRED");
|
|
});
|
|
|
|
it("should reject password change with wrong current password", async () => {
|
|
await app.inject({
|
|
method: "POST",
|
|
url: "/auth/register",
|
|
payload: {
|
|
username: "profileuser3",
|
|
password: "TestPassword123",
|
|
},
|
|
});
|
|
|
|
const login = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/login",
|
|
payload: {
|
|
username: "profileuser3",
|
|
password: "TestPassword123",
|
|
},
|
|
});
|
|
|
|
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
|
|
|
|
const response = await app.inject({
|
|
method: "PUT",
|
|
url: "/auth/me",
|
|
cookies: {
|
|
access_token: accessToken?.value ?? "",
|
|
},
|
|
payload: {
|
|
currentPassword: "WrongPassword",
|
|
newPassword: "NewPassword456",
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(401);
|
|
expect(response.json().code).toBe("INVALID_PASSWORD");
|
|
});
|
|
|
|
it("should reject profile update without auth", async () => {
|
|
const response = await app.inject({
|
|
method: "PUT",
|
|
url: "/auth/me",
|
|
payload: {
|
|
currentPassword: "Test123",
|
|
newPassword: "NewPassword456",
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe("DELETE /auth/me - Delete Account", () => {
|
|
it("should delete user account and all data", async () => {
|
|
// Register and login
|
|
await app.inject({
|
|
method: "POST",
|
|
url: "/auth/register",
|
|
payload: {
|
|
username: "deleteuser",
|
|
password: "TestPassword123",
|
|
},
|
|
});
|
|
|
|
const login = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/login",
|
|
payload: {
|
|
username: "deleteuser",
|
|
password: "TestPassword123",
|
|
},
|
|
});
|
|
|
|
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
|
|
|
|
// Delete account
|
|
const response = await app.inject({
|
|
method: "DELETE",
|
|
url: "/auth/me",
|
|
cookies: {
|
|
access_token: accessToken?.value ?? "",
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().ok).toBe(true);
|
|
|
|
// Verify can't login anymore
|
|
const loginAgain = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/login",
|
|
payload: {
|
|
username: "deleteuser",
|
|
password: "TestPassword123",
|
|
},
|
|
});
|
|
|
|
expect(loginAgain.statusCode).toBe(401);
|
|
});
|
|
|
|
it("should reject delete without auth", async () => {
|
|
const response = await app.inject({
|
|
method: "DELETE",
|
|
url: "/auth/me",
|
|
});
|
|
|
|
expect(response.statusCode).toBe(401);
|
|
});
|
|
});
|
|
});
|