add tests on refactor
This commit is contained in:
@@ -14,6 +14,7 @@ describe("Auth Enabled Toggle Authorization", () => {
|
|||||||
let csrfHeaderName: string;
|
let csrfHeaderName: string;
|
||||||
let csrfToken: string;
|
let csrfToken: string;
|
||||||
let regularUserToken: string;
|
let regularUserToken: string;
|
||||||
|
let adminUserToken: string;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
setupTestDb();
|
setupTestDb();
|
||||||
@@ -58,6 +59,26 @@ describe("Auth Enabled Toggle Authorization", () => {
|
|||||||
signOptions
|
signOptions
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const admin = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: "admin-user@test.local",
|
||||||
|
passwordHash,
|
||||||
|
name: "Admin User",
|
||||||
|
role: "ADMIN",
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
adminUserToken = jwt.sign(
|
||||||
|
{ userId: admin.id, email: admin.email, type: "access" },
|
||||||
|
config.jwtSecret,
|
||||||
|
signOptions
|
||||||
|
);
|
||||||
|
|
||||||
const agent = request.agent(app);
|
const agent = request.agent(app);
|
||||||
const csrfRes = await agent
|
const csrfRes = await agent
|
||||||
.get("/csrf-token")
|
.get("/csrf-token")
|
||||||
@@ -91,4 +112,27 @@ describe("Auth Enabled Toggle Authorization", () => {
|
|||||||
expect(response.status).toBe(403);
|
expect(response.status).toBe(403);
|
||||||
expect(response.body?.message).toContain("Admin access required");
|
expect(response.body?.message).toContain("Admin access required");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies auth mode change immediately for subsequent requests", async () => {
|
||||||
|
const warmStatusResponse = await request(app)
|
||||||
|
.get("/auth/status")
|
||||||
|
.set("User-Agent", userAgent);
|
||||||
|
expect(warmStatusResponse.status).toBe(200);
|
||||||
|
expect(warmStatusResponse.body?.authEnabled).toBe(true);
|
||||||
|
|
||||||
|
const toggleResponse = await request(app)
|
||||||
|
.post("/auth/auth-enabled")
|
||||||
|
.set("User-Agent", userAgent)
|
||||||
|
.set("Authorization", `Bearer ${adminUserToken}`)
|
||||||
|
.set(csrfHeaderName, csrfToken)
|
||||||
|
.send({ enabled: false });
|
||||||
|
expect(toggleResponse.status).toBe(200);
|
||||||
|
expect(toggleResponse.body?.authEnabled).toBe(false);
|
||||||
|
|
||||||
|
const drawingsResponse = await request(app)
|
||||||
|
.get("/drawings")
|
||||||
|
.set("User-Agent", userAgent);
|
||||||
|
expect(drawingsResponse.status).toBe(200);
|
||||||
|
expect(Array.isArray(drawingsResponse.body?.drawings)).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -342,6 +342,7 @@ export const createAuthRouter = (deps: CreateAuthRouterDeps): express.Router =>
|
|||||||
isMissingRefreshTokenTableError,
|
isMissingRefreshTokenTableError,
|
||||||
bootstrapUserId: BOOTSTRAP_USER_ID,
|
bootstrapUserId: BOOTSTRAP_USER_ID,
|
||||||
defaultSystemConfigId: DEFAULT_SYSTEM_CONFIG_ID,
|
defaultSystemConfigId: DEFAULT_SYSTEM_CONFIG_ID,
|
||||||
|
clearAuthEnabledCache: authModeService.clearAuthEnabledCache,
|
||||||
});
|
});
|
||||||
|
|
||||||
registerAdminRoutes({
|
registerAdminRoutes({
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { PrismaClient } from "../generated/client";
|
||||||
|
import {
|
||||||
|
BOOTSTRAP_USER_ID,
|
||||||
|
DEFAULT_SYSTEM_CONFIG_ID,
|
||||||
|
createAuthModeService,
|
||||||
|
} from "./authMode";
|
||||||
|
|
||||||
|
const createPrismaMock = () =>
|
||||||
|
({
|
||||||
|
systemConfig: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
upsert: vi.fn(),
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
upsert: vi.fn(),
|
||||||
|
},
|
||||||
|
}) as unknown as PrismaClient;
|
||||||
|
|
||||||
|
describe("authMode service", () => {
|
||||||
|
let now = 1_000_000;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caches authEnabled reads within TTL", async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const findUnique = prisma.systemConfig.findUnique as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
const upsert = prisma.systemConfig.upsert as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
findUnique
|
||||||
|
.mockResolvedValueOnce({ authEnabled: true })
|
||||||
|
.mockResolvedValueOnce({ authEnabled: false });
|
||||||
|
|
||||||
|
const service = createAuthModeService(prisma, { authEnabledTtlMs: 5000 });
|
||||||
|
|
||||||
|
await expect(service.getAuthEnabled()).resolves.toBe(true);
|
||||||
|
|
||||||
|
now += 1000;
|
||||||
|
await expect(service.getAuthEnabled()).resolves.toBe(true);
|
||||||
|
expect(findUnique).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
now += 6000;
|
||||||
|
await expect(service.getAuthEnabled()).resolves.toBe(false);
|
||||||
|
expect(findUnique).toHaveBeenCalledTimes(2);
|
||||||
|
expect(upsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears auth cache when requested", async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const findUnique = prisma.systemConfig.findUnique as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
const upsert = prisma.systemConfig.upsert as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
findUnique.mockResolvedValue({ authEnabled: true });
|
||||||
|
|
||||||
|
const service = createAuthModeService(prisma);
|
||||||
|
await service.getAuthEnabled();
|
||||||
|
service.clearAuthEnabledCache();
|
||||||
|
await service.getAuthEnabled();
|
||||||
|
|
||||||
|
expect(findUnique).toHaveBeenCalledTimes(2);
|
||||||
|
expect(upsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to upsert when system config row is missing", async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const findUnique = prisma.systemConfig.findUnique as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
const upsert = prisma.systemConfig.upsert as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
findUnique.mockResolvedValue(null);
|
||||||
|
upsert.mockResolvedValue({ authEnabled: false });
|
||||||
|
|
||||||
|
const service = createAuthModeService(prisma);
|
||||||
|
await expect(service.getAuthEnabled()).resolves.toBe(false);
|
||||||
|
|
||||||
|
expect(findUnique).toHaveBeenCalledTimes(1);
|
||||||
|
expect(upsert).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates/bootstrap user via upsert", async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const userUpsert = prisma.user.upsert as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
userUpsert.mockResolvedValue({
|
||||||
|
id: BOOTSTRAP_USER_ID,
|
||||||
|
email: "bootstrap@excalidash.local",
|
||||||
|
name: "Bootstrap Admin",
|
||||||
|
role: "ADMIN",
|
||||||
|
isActive: false,
|
||||||
|
mustResetPassword: true,
|
||||||
|
username: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = createAuthModeService(prisma);
|
||||||
|
const bootstrapUser = await service.getBootstrapActingUser();
|
||||||
|
|
||||||
|
expect(bootstrapUser.id).toBe(BOOTSTRAP_USER_ID);
|
||||||
|
expect(userUpsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { id: BOOTSTRAP_USER_ID },
|
||||||
|
create: expect.objectContaining({
|
||||||
|
id: BOOTSTRAP_USER_ID,
|
||||||
|
email: "bootstrap@excalidash.local",
|
||||||
|
role: "ADMIN",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ensures system config defaults", async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const upsert = prisma.systemConfig.upsert as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
upsert.mockResolvedValue({ authEnabled: false });
|
||||||
|
|
||||||
|
const service = createAuthModeService(prisma);
|
||||||
|
await service.ensureSystemConfig();
|
||||||
|
|
||||||
|
expect(upsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
|
||||||
|
create: expect.objectContaining({
|
||||||
|
id: DEFAULT_SYSTEM_CONFIG_ID,
|
||||||
|
authEnabled: false,
|
||||||
|
registrationEnabled: false,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,13 @@ export const createAuthModeService = (
|
|||||||
const authEnabledTtlMs = options?.authEnabledTtlMs ?? 5000;
|
const authEnabledTtlMs = options?.authEnabledTtlMs ?? 5000;
|
||||||
let authEnabledCache: AuthEnabledCache | null = null;
|
let authEnabledCache: AuthEnabledCache | null = null;
|
||||||
|
|
||||||
|
const getSystemConfigAuthEnabled = async () => {
|
||||||
|
return prisma.systemConfig.findUnique({
|
||||||
|
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
|
||||||
|
select: { authEnabled: true },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const ensureSystemConfig = async () => {
|
const ensureSystemConfig = async () => {
|
||||||
return prisma.systemConfig.upsert({
|
return prisma.systemConfig.upsert({
|
||||||
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
|
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
|
||||||
@@ -38,6 +45,12 @@ export const createAuthModeService = (
|
|||||||
return authEnabledCache.value;
|
return authEnabledCache.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingSystemConfig = await getSystemConfigAuthEnabled();
|
||||||
|
if (existingSystemConfig) {
|
||||||
|
authEnabledCache = { value: existingSystemConfig.authEnabled, fetchedAt: now };
|
||||||
|
return existingSystemConfig.authEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
const systemConfig = await ensureSystemConfig();
|
const systemConfig = await ensureSystemConfig();
|
||||||
authEnabledCache = { value: systemConfig.authEnabled, fetchedAt: now };
|
authEnabledCache = { value: systemConfig.authEnabled, fetchedAt: now };
|
||||||
return systemConfig.authEnabled;
|
return systemConfig.authEnabled;
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ type RegisterCoreRoutesDeps = {
|
|||||||
isMissingRefreshTokenTableError: (error: unknown) => boolean;
|
isMissingRefreshTokenTableError: (error: unknown) => boolean;
|
||||||
bootstrapUserId: string;
|
bootstrapUserId: string;
|
||||||
defaultSystemConfigId: string;
|
defaultSystemConfigId: string;
|
||||||
|
clearAuthEnabledCache: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
class HttpError extends Error {
|
class HttpError extends Error {
|
||||||
@@ -86,6 +87,7 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
|
|||||||
isMissingRefreshTokenTableError,
|
isMissingRefreshTokenTableError,
|
||||||
bootstrapUserId,
|
bootstrapUserId,
|
||||||
defaultSystemConfigId,
|
defaultSystemConfigId,
|
||||||
|
clearAuthEnabledCache,
|
||||||
} = deps;
|
} = deps;
|
||||||
const getUserTrashCollectionId = (userId: string): string => `trash:${userId}`;
|
const getUserTrashCollectionId = (userId: string): string => `trash:${userId}`;
|
||||||
|
|
||||||
@@ -713,6 +715,7 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
|
|||||||
registrationEnabled: systemConfig.registrationEnabled,
|
registrationEnabled: systemConfig.registrationEnabled,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
clearAuthEnabledCache();
|
||||||
|
|
||||||
const bootstrapUser = await prisma.user.findUnique({
|
const bootstrapUser = await prisma.user.findUnique({
|
||||||
where: { id: bootstrapUserId },
|
where: { id: bootstrapUserId },
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { config } from "../config";
|
||||||
|
import { createAuthMiddleware } from "./auth";
|
||||||
|
|
||||||
|
const createRequest = (overrides?: Partial<Request>): Request =>
|
||||||
|
({
|
||||||
|
method: "GET",
|
||||||
|
originalUrl: "/drawings",
|
||||||
|
url: "/drawings",
|
||||||
|
headers: {},
|
||||||
|
...overrides,
|
||||||
|
}) as Request;
|
||||||
|
|
||||||
|
const createResponse = (): Response =>
|
||||||
|
({
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
json: vi.fn().mockReturnThis(),
|
||||||
|
}) as unknown as Response;
|
||||||
|
|
||||||
|
const createDeps = () => {
|
||||||
|
const prisma = {
|
||||||
|
user: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const authModeService = {
|
||||||
|
getAuthEnabled: vi.fn(),
|
||||||
|
getBootstrapActingUser: vi.fn(),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
return { prisma, authModeService };
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeAccessToken = (payload?: { userId?: string; email?: string; impersonatorId?: string }) =>
|
||||||
|
jwt.sign(
|
||||||
|
{
|
||||||
|
userId: payload?.userId ?? "user-1",
|
||||||
|
email: payload?.email ?? "user-1@test.local",
|
||||||
|
type: "access",
|
||||||
|
impersonatorId: payload?.impersonatorId,
|
||||||
|
},
|
||||||
|
config.jwtSecret
|
||||||
|
);
|
||||||
|
|
||||||
|
const makeRefreshToken = () =>
|
||||||
|
jwt.sign(
|
||||||
|
{
|
||||||
|
userId: "user-1",
|
||||||
|
email: "user-1@test.local",
|
||||||
|
type: "refresh",
|
||||||
|
},
|
||||||
|
config.jwtSecret
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("auth middleware", () => {
|
||||||
|
it("treats requests as bootstrap user when auth is disabled", async () => {
|
||||||
|
const { prisma, authModeService } = createDeps();
|
||||||
|
authModeService.getAuthEnabled.mockResolvedValue(false);
|
||||||
|
authModeService.getBootstrapActingUser.mockResolvedValue({
|
||||||
|
id: "bootstrap-admin",
|
||||||
|
username: null,
|
||||||
|
email: "bootstrap@excalidash.local",
|
||||||
|
name: "Bootstrap Admin",
|
||||||
|
role: "ADMIN",
|
||||||
|
mustResetPassword: true,
|
||||||
|
});
|
||||||
|
const { requireAuth } = createAuthMiddleware({ prisma, authModeService });
|
||||||
|
|
||||||
|
const req = createRequest();
|
||||||
|
const res = createResponse();
|
||||||
|
const next = vi.fn() as NextFunction;
|
||||||
|
|
||||||
|
await requireAuth(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledTimes(1);
|
||||||
|
expect(req.user).toMatchObject({
|
||||||
|
id: "bootstrap-admin",
|
||||||
|
role: "ADMIN",
|
||||||
|
});
|
||||||
|
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 401 when token is missing and auth is enabled", async () => {
|
||||||
|
const { prisma, authModeService } = createDeps();
|
||||||
|
authModeService.getAuthEnabled.mockResolvedValue(true);
|
||||||
|
const { requireAuth } = createAuthMiddleware({ prisma, authModeService });
|
||||||
|
|
||||||
|
const req = createRequest();
|
||||||
|
const res = createResponse();
|
||||||
|
const next = vi.fn() as NextFunction;
|
||||||
|
|
||||||
|
await requireAuth(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ message: "Authentication token required" })
|
||||||
|
);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-access JWT payloads", async () => {
|
||||||
|
const { prisma, authModeService } = createDeps();
|
||||||
|
authModeService.getAuthEnabled.mockResolvedValue(true);
|
||||||
|
const { requireAuth } = createAuthMiddleware({ prisma, authModeService });
|
||||||
|
|
||||||
|
const req = createRequest({
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${makeRefreshToken()}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const res = createResponse();
|
||||||
|
const next = vi.fn() as NextFunction;
|
||||||
|
|
||||||
|
await requireAuth(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ message: "Invalid or expired token" })
|
||||||
|
);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches active user for valid access token", async () => {
|
||||||
|
const { prisma, authModeService } = createDeps();
|
||||||
|
authModeService.getAuthEnabled.mockResolvedValue(true);
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
id: "user-1",
|
||||||
|
username: "user1",
|
||||||
|
email: "user-1@test.local",
|
||||||
|
name: "User One",
|
||||||
|
role: "USER",
|
||||||
|
mustResetPassword: false,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
const { requireAuth } = createAuthMiddleware({ prisma, authModeService });
|
||||||
|
|
||||||
|
const req = createRequest({
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${makeAccessToken({ impersonatorId: "admin-1" })}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const res = createResponse();
|
||||||
|
const next = vi.fn() as NextFunction;
|
||||||
|
|
||||||
|
await requireAuth(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledTimes(1);
|
||||||
|
expect(req.user).toMatchObject({
|
||||||
|
id: "user-1",
|
||||||
|
email: "user-1@test.local",
|
||||||
|
impersonatorId: "admin-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks non-auth routes when password reset is required", async () => {
|
||||||
|
const { prisma, authModeService } = createDeps();
|
||||||
|
authModeService.getAuthEnabled.mockResolvedValue(true);
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
id: "user-1",
|
||||||
|
username: "user1",
|
||||||
|
email: "user-1@test.local",
|
||||||
|
name: "User One",
|
||||||
|
role: "USER",
|
||||||
|
mustResetPassword: true,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
const { requireAuth } = createAuthMiddleware({ prisma, authModeService });
|
||||||
|
|
||||||
|
const req = createRequest({
|
||||||
|
method: "GET",
|
||||||
|
originalUrl: "/drawings",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${makeAccessToken()}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const res = createResponse();
|
||||||
|
const next = vi.fn() as NextFunction;
|
||||||
|
|
||||||
|
await requireAuth(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ code: "MUST_RESET_PASSWORD" })
|
||||||
|
);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows /api/auth/me when password reset is required", async () => {
|
||||||
|
const { prisma, authModeService } = createDeps();
|
||||||
|
authModeService.getAuthEnabled.mockResolvedValue(true);
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
id: "user-1",
|
||||||
|
username: "user1",
|
||||||
|
email: "user-1@test.local",
|
||||||
|
name: "User One",
|
||||||
|
role: "USER",
|
||||||
|
mustResetPassword: true,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
const { requireAuth } = createAuthMiddleware({ prisma, authModeService });
|
||||||
|
|
||||||
|
const req = createRequest({
|
||||||
|
method: "GET",
|
||||||
|
originalUrl: "/api/auth/me?include=roles",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${makeAccessToken()}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const res = createResponse();
|
||||||
|
const next = vi.fn() as NextFunction;
|
||||||
|
|
||||||
|
await requireAuth(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledTimes(1);
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Request } from "express";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
CSRF_CLIENT_COOKIE_NAME,
|
||||||
|
getCsrfClientCookieValue,
|
||||||
|
getCsrfValidationClientIds,
|
||||||
|
getLegacyClientId,
|
||||||
|
parseCookies,
|
||||||
|
} from "./csrfClient";
|
||||||
|
|
||||||
|
const makeRequest = (overrides?: Partial<Request>): Request =>
|
||||||
|
({
|
||||||
|
headers: {
|
||||||
|
cookie: "",
|
||||||
|
"user-agent": "UnitTestAgent/1.0",
|
||||||
|
},
|
||||||
|
ip: "203.0.113.10",
|
||||||
|
connection: { remoteAddress: "198.51.100.8" },
|
||||||
|
...overrides,
|
||||||
|
}) as unknown as Request;
|
||||||
|
|
||||||
|
describe("csrfClient helpers", () => {
|
||||||
|
it("parses cookies and tolerates bad encoding", () => {
|
||||||
|
const parsed = parseCookies("a=1; b=hello%20world; c=%E0%A4%A");
|
||||||
|
expect(parsed).toEqual({
|
||||||
|
a: "1",
|
||||||
|
b: "hello world",
|
||||||
|
c: "%E0%A4%A",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads only valid csrf cookie values", () => {
|
||||||
|
const validReq = makeRequest({
|
||||||
|
headers: {
|
||||||
|
cookie: `${CSRF_CLIENT_COOKIE_NAME}=abcDEF1234567890_-`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(getCsrfClientCookieValue(validReq)).toBe("abcDEF1234567890_-");
|
||||||
|
|
||||||
|
const invalidReq = makeRequest({
|
||||||
|
headers: {
|
||||||
|
cookie: `${CSRF_CLIENT_COOKIE_NAME}=bad!`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(getCsrfClientCookieValue(invalidReq)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds legacy client id from IP + user agent and truncates", () => {
|
||||||
|
const longAgent = "x".repeat(600);
|
||||||
|
const req = makeRequest({
|
||||||
|
headers: {
|
||||||
|
"user-agent": longAgent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const id = getLegacyClientId(req);
|
||||||
|
expect(id.startsWith("203.0.113.10:")).toBe(true);
|
||||||
|
expect(id.length).toBeLessThanOrEqual(256);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns validation candidates with cookie id first when present", () => {
|
||||||
|
const req = makeRequest({
|
||||||
|
headers: {
|
||||||
|
cookie: `${CSRF_CLIENT_COOKIE_NAME}=cookieToken123456`,
|
||||||
|
"user-agent": "Agent",
|
||||||
|
},
|
||||||
|
ip: "10.0.0.1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const candidates = getCsrfValidationClientIds(req);
|
||||||
|
expect(candidates[0]).toBe("cookie:cookieToken123456");
|
||||||
|
expect(candidates[1]).toContain("10.0.0.1:Agent");
|
||||||
|
expect(new Set(candidates).size).toBe(candidates.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createDrawingsCacheStore } from "./drawingsCache";
|
||||||
|
|
||||||
|
describe("drawings cache store", () => {
|
||||||
|
let now = 0;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds deterministic cache keys", () => {
|
||||||
|
const { buildDrawingsCacheKey } = createDrawingsCacheStore(5000);
|
||||||
|
const keyA = buildDrawingsCacheKey({
|
||||||
|
userId: "u1",
|
||||||
|
searchTerm: "roadmap",
|
||||||
|
collectionFilter: "default",
|
||||||
|
includeData: false,
|
||||||
|
sortField: "updatedAt",
|
||||||
|
sortDirection: "desc",
|
||||||
|
});
|
||||||
|
const keyB = buildDrawingsCacheKey({
|
||||||
|
userId: "u1",
|
||||||
|
searchTerm: "roadmap",
|
||||||
|
collectionFilter: "default",
|
||||||
|
includeData: false,
|
||||||
|
sortField: "updatedAt",
|
||||||
|
sortDirection: "desc",
|
||||||
|
});
|
||||||
|
const keyC = buildDrawingsCacheKey({
|
||||||
|
userId: "u1",
|
||||||
|
searchTerm: "roadmap",
|
||||||
|
collectionFilter: "default",
|
||||||
|
includeData: true,
|
||||||
|
sortField: "updatedAt",
|
||||||
|
sortDirection: "desc",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(keyA).toBe(keyB);
|
||||||
|
expect(keyA).not.toBe(keyC);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caches payloads and expires by TTL", () => {
|
||||||
|
const { cacheDrawingsResponse, getCachedDrawingsBody } = createDrawingsCacheStore(1000);
|
||||||
|
const key = "drawings:key:1";
|
||||||
|
const payload = { drawings: [{ id: "d1" }], totalCount: 1 };
|
||||||
|
|
||||||
|
const body = cacheDrawingsResponse(key, payload);
|
||||||
|
expect(body.toString("utf8")).toContain("\"totalCount\":1");
|
||||||
|
|
||||||
|
now = 800;
|
||||||
|
expect(getCachedDrawingsBody(key)?.toString("utf8")).toContain("\"d1\"");
|
||||||
|
|
||||||
|
now = 1200;
|
||||||
|
expect(getCachedDrawingsBody(key)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports manual invalidation", () => {
|
||||||
|
const { cacheDrawingsResponse, getCachedDrawingsBody, invalidateDrawingsCache } =
|
||||||
|
createDrawingsCacheStore(10_000);
|
||||||
|
const key = "drawings:key:2";
|
||||||
|
|
||||||
|
cacheDrawingsResponse(key, { drawings: [], totalCount: 0 });
|
||||||
|
expect(getCachedDrawingsBody(key)).not.toBeNull();
|
||||||
|
|
||||||
|
invalidateDrawingsCache();
|
||||||
|
expect(getCachedDrawingsBody(key)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import * as api from "../../api";
|
||||||
|
import { useDashboardData } from "./useDashboardData";
|
||||||
|
|
||||||
|
vi.mock("../../api", () => ({
|
||||||
|
getDrawings: vi.fn(),
|
||||||
|
getCollections: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type Deferred<T> = {
|
||||||
|
promise: Promise<T>;
|
||||||
|
resolve: (value: T) => void;
|
||||||
|
reject: (reason?: unknown) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deferred = <T,>(): Deferred<T> => {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeDrawing = (id: string) => ({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
collectionId: null,
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
version: 1,
|
||||||
|
preview: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeCollection = (id: string) => ({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
createdAt: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useDashboardData", () => {
|
||||||
|
const getDrawingsMock = vi.mocked(api.getDrawings);
|
||||||
|
const getCollectionsMock = vi.mocked(api.getCollections);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads drawings and collections on mount", async () => {
|
||||||
|
getDrawingsMock.mockResolvedValue({
|
||||||
|
drawings: [makeDrawing("d1")],
|
||||||
|
totalCount: 1,
|
||||||
|
limit: 24,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
getCollectionsMock.mockResolvedValue([makeCollection("c1")]);
|
||||||
|
|
||||||
|
const onRefreshSuccess = vi.fn();
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useDashboardData({
|
||||||
|
debouncedSearch: "",
|
||||||
|
selectedCollectionId: undefined,
|
||||||
|
sortField: "updatedAt",
|
||||||
|
sortDirection: "desc",
|
||||||
|
pageSize: 24,
|
||||||
|
onRefreshSuccess,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getDrawingsMock).toHaveBeenCalledWith("", undefined, {
|
||||||
|
limit: 24,
|
||||||
|
offset: 0,
|
||||||
|
sortField: "updatedAt",
|
||||||
|
sortDirection: "desc",
|
||||||
|
});
|
||||||
|
expect(getCollectionsMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.current.drawings.map((drawing) => drawing.id)).toEqual(["d1"]);
|
||||||
|
expect(result.current.collections.map((collection) => collection.id)).toEqual(["c1"]);
|
||||||
|
expect(result.current.totalCount).toBe(1);
|
||||||
|
expect(onRefreshSuccess).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches more drawings and merges unique results", async () => {
|
||||||
|
getDrawingsMock
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
drawings: [makeDrawing("d1"), makeDrawing("d2")],
|
||||||
|
totalCount: 3,
|
||||||
|
limit: 24,
|
||||||
|
offset: 0,
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
drawings: [makeDrawing("d2"), makeDrawing("d3")],
|
||||||
|
totalCount: 3,
|
||||||
|
limit: 24,
|
||||||
|
offset: 2,
|
||||||
|
});
|
||||||
|
getCollectionsMock.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useDashboardData({
|
||||||
|
debouncedSearch: "",
|
||||||
|
selectedCollectionId: undefined,
|
||||||
|
sortField: "updatedAt",
|
||||||
|
sortDirection: "desc",
|
||||||
|
pageSize: 24,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.fetchMore();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getDrawingsMock).toHaveBeenNthCalledWith(2, "", undefined, {
|
||||||
|
limit: 24,
|
||||||
|
offset: 2,
|
||||||
|
sortField: "updatedAt",
|
||||||
|
sortDirection: "desc",
|
||||||
|
});
|
||||||
|
expect(result.current.drawings.map((drawing) => drawing.id)).toEqual(["d1", "d2", "d3"]);
|
||||||
|
expect(result.current.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores stale refresh responses from older requests", async () => {
|
||||||
|
const firstDrawings = deferred<{
|
||||||
|
drawings: ReturnType<typeof makeDrawing>[];
|
||||||
|
totalCount: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}>();
|
||||||
|
const secondDrawings = deferred<{
|
||||||
|
drawings: ReturnType<typeof makeDrawing>[];
|
||||||
|
totalCount: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}>();
|
||||||
|
const firstCollections = deferred<ReturnType<typeof makeCollection>[]>();
|
||||||
|
const secondCollections = deferred<ReturnType<typeof makeCollection>[]>();
|
||||||
|
|
||||||
|
getDrawingsMock
|
||||||
|
.mockReturnValueOnce(firstDrawings.promise as ReturnType<typeof api.getDrawings>)
|
||||||
|
.mockReturnValueOnce(secondDrawings.promise as ReturnType<typeof api.getDrawings>);
|
||||||
|
getCollectionsMock
|
||||||
|
.mockReturnValueOnce(firstCollections.promise as ReturnType<typeof api.getCollections>)
|
||||||
|
.mockReturnValueOnce(secondCollections.promise as ReturnType<typeof api.getCollections>);
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
(search: string) =>
|
||||||
|
useDashboardData({
|
||||||
|
debouncedSearch: search,
|
||||||
|
selectedCollectionId: undefined,
|
||||||
|
sortField: "updatedAt",
|
||||||
|
sortDirection: "desc",
|
||||||
|
pageSize: 24,
|
||||||
|
}),
|
||||||
|
{ initialProps: "first" }
|
||||||
|
);
|
||||||
|
|
||||||
|
rerender("second");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
secondDrawings.resolve({
|
||||||
|
drawings: [makeDrawing("new")],
|
||||||
|
totalCount: 1,
|
||||||
|
limit: 24,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
secondCollections.resolve([makeCollection("new-collection")]);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.drawings.map((drawing) => drawing.id)).toEqual(["new"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
firstDrawings.resolve({
|
||||||
|
drawings: [makeDrawing("old")],
|
||||||
|
totalCount: 1,
|
||||||
|
limit: 24,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
firstCollections.resolve([makeCollection("old-collection")]);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.drawings.map((drawing) => drawing.id)).toEqual(["new"]);
|
||||||
|
expect(result.current.collections.map((collection) => collection.id)).toEqual([
|
||||||
|
"new-collection",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useEditorChrome } from "./useEditorChrome";
|
||||||
|
|
||||||
|
vi.mock("lodash/throttle", () => ({
|
||||||
|
default: (fn: (...args: unknown[]) => unknown) => fn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("useEditorChrome", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
document.title = "Original Title";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates document title and restores app title on unmount", () => {
|
||||||
|
const { rerender, unmount } = renderHook(
|
||||||
|
({ drawingName }) =>
|
||||||
|
useEditorChrome({
|
||||||
|
drawingName,
|
||||||
|
autoHideEnabled: false,
|
||||||
|
isRenaming: false,
|
||||||
|
}),
|
||||||
|
{ initialProps: { drawingName: "Roadmap" } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(document.title).toBe("Roadmap - ExcaliDash");
|
||||||
|
|
||||||
|
rerender({ drawingName: "Architecture" });
|
||||||
|
expect(document.title).toBe("Architecture - ExcaliDash");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
expect(document.title).toBe("ExcaliDash");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps header visible when auto-hide is disabled", () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useEditorChrome({
|
||||||
|
drawingName: "Test",
|
||||||
|
autoHideEnabled: false,
|
||||||
|
isRenaming: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isHeaderVisible).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-hides header after inactivity timeout", () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useEditorChrome({
|
||||||
|
drawingName: "Test",
|
||||||
|
autoHideEnabled: true,
|
||||||
|
isRenaming: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.isHeaderVisible).toBe(true);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(3001);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isHeaderVisible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows header in trigger zone and hides it again after leaving", () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useEditorChrome({
|
||||||
|
drawingName: "Test",
|
||||||
|
autoHideEnabled: true,
|
||||||
|
isRenaming: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(3001);
|
||||||
|
});
|
||||||
|
expect(result.current.isHeaderVisible).toBe(false);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new MouseEvent("mousemove", { clientY: 0 }));
|
||||||
|
});
|
||||||
|
expect(result.current.isHeaderVisible).toBe(true);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new MouseEvent("mousemove", { clientY: 40 }));
|
||||||
|
vi.advanceTimersByTime(1999);
|
||||||
|
});
|
||||||
|
expect(result.current.isHeaderVisible).toBe(true);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
});
|
||||||
|
expect(result.current.isHeaderVisible).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useEditorIdentity } from "./useEditorIdentity";
|
||||||
|
import { getColorFromString } from "./shared";
|
||||||
|
import { getUserIdentity } from "../../utils/identity";
|
||||||
|
|
||||||
|
vi.mock("../../utils/identity", () => ({
|
||||||
|
getUserIdentity: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("useEditorIdentity", () => {
|
||||||
|
const getUserIdentityMock = vi.mocked(getUserIdentity);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds identity from authenticated user", () => {
|
||||||
|
getUserIdentityMock.mockReturnValue({
|
||||||
|
id: "anon",
|
||||||
|
name: "Anonymous",
|
||||||
|
initials: "AN",
|
||||||
|
color: "#999",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useEditorIdentity({
|
||||||
|
id: "u-1",
|
||||||
|
name: "Jane Doe",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current).toEqual({
|
||||||
|
id: "u-1",
|
||||||
|
name: "Jane Doe",
|
||||||
|
initials: "JD",
|
||||||
|
color: getColorFromString("u-1"),
|
||||||
|
});
|
||||||
|
expect(getUserIdentityMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to generated identity when user is missing", () => {
|
||||||
|
getUserIdentityMock.mockReturnValue({
|
||||||
|
id: "guest-1",
|
||||||
|
name: "Guest User",
|
||||||
|
initials: "GU",
|
||||||
|
color: "#123456",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useEditorIdentity(null));
|
||||||
|
|
||||||
|
expect(getUserIdentityMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.current).toEqual({
|
||||||
|
id: "guest-1",
|
||||||
|
name: "Guest User",
|
||||||
|
initials: "GU",
|
||||||
|
color: "#123456",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user