Issue #2: The profile route used a custom role-based check instead of the permission-based pattern used everywhere else. Changes: - Add MANAGE_OWN_PROFILE permission to backend Permission enum - Add permission to ROLE_REGULAR role definition - Update profile routes to use require_permission(MANAGE_OWN_PROFILE) - Remove custom require_regular_user dependency - Update frontend Permission constant and profile page - Update invites page to use permission instead of role check - Update profile tests with proper permission mocking This ensures consistent authorization patterns across all routes.
567 lines
16 KiB
TypeScript
567 lines
16 KiB
TypeScript
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react";
|
|
import { expect, test, vi, beforeEach, afterEach, describe } from "vitest";
|
|
import ProfilePage from "./page";
|
|
|
|
// Mock next/navigation
|
|
const mockPush = vi.fn();
|
|
vi.mock("next/navigation", () => ({
|
|
useRouter: () => ({
|
|
push: mockPush,
|
|
}),
|
|
}));
|
|
|
|
// Default mock values
|
|
let mockUser: { id: number; email: string; roles: string[]; permissions: string[] } | null = {
|
|
id: 1,
|
|
email: "test@example.com",
|
|
roles: ["regular"],
|
|
permissions: ["view_counter", "increment_counter", "use_sum", "manage_own_profile"],
|
|
};
|
|
let mockIsLoading = false;
|
|
const mockLogout = vi.fn();
|
|
const mockHasRole = vi.fn((role: string) => mockUser?.roles.includes(role) ?? false);
|
|
const mockHasPermission = vi.fn(
|
|
(permission: string) => mockUser?.permissions.includes(permission) ?? false
|
|
);
|
|
|
|
vi.mock("../auth-context", () => ({
|
|
useAuth: () => ({
|
|
user: mockUser,
|
|
isLoading: mockIsLoading,
|
|
logout: mockLogout,
|
|
hasRole: mockHasRole,
|
|
hasPermission: mockHasPermission,
|
|
}),
|
|
Permission: {
|
|
VIEW_COUNTER: "view_counter",
|
|
INCREMENT_COUNTER: "increment_counter",
|
|
USE_SUM: "use_sum",
|
|
VIEW_AUDIT: "view_audit",
|
|
MANAGE_OWN_PROFILE: "manage_own_profile",
|
|
MANAGE_INVITES: "manage_invites",
|
|
VIEW_OWN_INVITES: "view_own_invites",
|
|
BOOK_APPOINTMENT: "book_appointment",
|
|
VIEW_OWN_APPOINTMENTS: "view_own_appointments",
|
|
CANCEL_OWN_APPOINTMENT: "cancel_own_appointment",
|
|
MANAGE_AVAILABILITY: "manage_availability",
|
|
VIEW_ALL_APPOINTMENTS: "view_all_appointments",
|
|
CANCEL_ANY_APPOINTMENT: "cancel_any_appointment",
|
|
},
|
|
}));
|
|
|
|
// Mock profile data
|
|
const mockProfileData = {
|
|
contact_email: "contact@example.com",
|
|
telegram: "@testuser",
|
|
signal: "signal.42",
|
|
nostr_npub: "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqzqujme",
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Reset to authenticated regular user
|
|
mockUser = {
|
|
id: 1,
|
|
email: "test@example.com",
|
|
roles: ["regular"],
|
|
permissions: ["view_counter", "increment_counter", "use_sum", "manage_own_profile"],
|
|
};
|
|
mockIsLoading = false;
|
|
mockHasRole.mockImplementation((role: string) => mockUser?.roles.includes(role) ?? false);
|
|
mockHasPermission.mockImplementation(
|
|
(permission: string) => mockUser?.permissions.includes(permission) ?? false
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
describe("ProfilePage - Display", () => {
|
|
test("renders loading state initially", () => {
|
|
mockIsLoading = true;
|
|
vi.spyOn(global, "fetch").mockImplementation(() => new Promise(() => {}));
|
|
|
|
render(<ProfilePage />);
|
|
expect(screen.getByText("Loading...")).toBeDefined();
|
|
});
|
|
|
|
test("renders profile page title", async () => {
|
|
vi.spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockProfileData),
|
|
} as Response);
|
|
|
|
render(<ProfilePage />);
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("displays login email as read-only", async () => {
|
|
vi.spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockProfileData),
|
|
} as Response);
|
|
|
|
render(<ProfilePage />);
|
|
await waitFor(() => {
|
|
const loginEmailInput = screen.getByDisplayValue("test@example.com");
|
|
expect(loginEmailInput).toBeDefined();
|
|
expect(loginEmailInput).toHaveProperty("disabled", true);
|
|
});
|
|
});
|
|
|
|
test("shows read-only badge for login email", async () => {
|
|
vi.spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockProfileData),
|
|
} as Response);
|
|
|
|
render(<ProfilePage />);
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Read only")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("shows hint about login email", async () => {
|
|
vi.spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockProfileData),
|
|
} as Response);
|
|
|
|
render(<ProfilePage />);
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/cannot be changed/i)).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("displays contact details section hint", async () => {
|
|
vi.spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockProfileData),
|
|
} as Response);
|
|
|
|
render(<ProfilePage />);
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/communication purposes only/i)).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("displays fetched profile data", async () => {
|
|
vi.spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockProfileData),
|
|
} as Response);
|
|
|
|
render(<ProfilePage />);
|
|
await waitFor(() => {
|
|
expect(screen.getByDisplayValue("contact@example.com")).toBeDefined();
|
|
expect(screen.getByDisplayValue("@testuser")).toBeDefined();
|
|
expect(screen.getByDisplayValue("signal.42")).toBeDefined();
|
|
expect(screen.getByDisplayValue(mockProfileData.nostr_npub)).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("fetches profile with credentials", async () => {
|
|
const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockProfileData),
|
|
} as Response);
|
|
|
|
render(<ProfilePage />);
|
|
|
|
await waitFor(() => {
|
|
expect(fetchSpy).toHaveBeenCalledWith(
|
|
"http://localhost:8000/api/profile",
|
|
expect.objectContaining({
|
|
credentials: "include",
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
test("displays empty fields when profile has null values", async () => {
|
|
vi.spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
contact_email: null,
|
|
telegram: null,
|
|
signal: null,
|
|
nostr_npub: null,
|
|
}),
|
|
} as Response);
|
|
|
|
render(<ProfilePage />);
|
|
await waitFor(() => {
|
|
// Check that inputs exist with empty values (placeholders shown)
|
|
const telegramInput = document.getElementById("telegram") as HTMLInputElement;
|
|
expect(telegramInput.value).toBe("");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("ProfilePage - Navigation", () => {
|
|
test("shows nav links for regular user", async () => {
|
|
vi.spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockProfileData),
|
|
} as Response);
|
|
|
|
render(<ProfilePage />);
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Counter")).toBeDefined();
|
|
expect(screen.getByText("Sum")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("highlights My Profile in nav", async () => {
|
|
vi.spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockProfileData),
|
|
} as Response);
|
|
|
|
render(<ProfilePage />);
|
|
await waitFor(() => {
|
|
// My Profile should be visible (as current page indicator)
|
|
const navItems = screen.getAllByText("My Profile");
|
|
expect(navItems.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("ProfilePage - Access Control", () => {
|
|
test("redirects to login when not authenticated", async () => {
|
|
mockUser = null;
|
|
|
|
render(<ProfilePage />);
|
|
|
|
await waitFor(() => {
|
|
expect(mockPush).toHaveBeenCalledWith("/login");
|
|
});
|
|
});
|
|
|
|
test("redirects admin to audit page", async () => {
|
|
mockUser = {
|
|
id: 1,
|
|
email: "admin@example.com",
|
|
roles: ["admin"],
|
|
permissions: ["view_audit"],
|
|
};
|
|
|
|
render(<ProfilePage />);
|
|
|
|
await waitFor(() => {
|
|
expect(mockPush).toHaveBeenCalledWith("/audit");
|
|
});
|
|
});
|
|
|
|
test("does not fetch profile for admin user", async () => {
|
|
mockUser = {
|
|
id: 1,
|
|
email: "admin@example.com",
|
|
roles: ["admin"],
|
|
permissions: ["view_audit"],
|
|
};
|
|
const fetchSpy = vi.spyOn(global, "fetch");
|
|
|
|
render(<ProfilePage />);
|
|
|
|
// Give it a moment to potentially fetch
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("ProfilePage - Loading State", () => {
|
|
test("does not redirect while loading", () => {
|
|
mockIsLoading = true;
|
|
mockUser = null;
|
|
|
|
render(<ProfilePage />);
|
|
|
|
expect(mockPush).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("shows loading indicator while loading", () => {
|
|
mockIsLoading = true;
|
|
|
|
render(<ProfilePage />);
|
|
|
|
expect(screen.getByText("Loading...")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("ProfilePage - Form Behavior", () => {
|
|
test("submit button is disabled when no changes", async () => {
|
|
vi.spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockProfileData),
|
|
} as Response);
|
|
|
|
render(<ProfilePage />);
|
|
await waitFor(() => {
|
|
const submitButton = screen.getByRole("button", { name: /save changes/i });
|
|
expect(submitButton).toHaveProperty("disabled", true);
|
|
});
|
|
});
|
|
|
|
test("submit button is enabled after field changes", async () => {
|
|
vi.spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockProfileData),
|
|
} as Response);
|
|
|
|
render(<ProfilePage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByDisplayValue("@testuser")).toBeDefined();
|
|
});
|
|
|
|
const telegramInput = screen.getByDisplayValue("@testuser");
|
|
fireEvent.change(telegramInput, { target: { value: "@newhandle" } });
|
|
|
|
await waitFor(() => {
|
|
const submitButton = screen.getByRole("button", { name: /save changes/i });
|
|
expect(submitButton).toHaveProperty("disabled", false);
|
|
});
|
|
});
|
|
|
|
test("auto-prepends @ to telegram when user starts with letter", async () => {
|
|
vi.spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
contact_email: null,
|
|
telegram: null,
|
|
signal: null,
|
|
nostr_npub: null,
|
|
}),
|
|
} as Response);
|
|
|
|
render(<ProfilePage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
|
});
|
|
|
|
const telegramInput = document.getElementById("telegram") as HTMLInputElement;
|
|
|
|
// Type a letter without @ - should auto-prepend @
|
|
fireEvent.change(telegramInput, { target: { value: "myhandle" } });
|
|
|
|
expect(telegramInput.value).toBe("@myhandle");
|
|
});
|
|
|
|
test("does not auto-prepend @ if user types @ first", async () => {
|
|
vi.spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
contact_email: null,
|
|
telegram: null,
|
|
signal: null,
|
|
nostr_npub: null,
|
|
}),
|
|
} as Response);
|
|
|
|
render(<ProfilePage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
|
});
|
|
|
|
const telegramInput = document.getElementById("telegram") as HTMLInputElement;
|
|
|
|
// User types @ first - no auto-prepend
|
|
fireEvent.change(telegramInput, { target: { value: "@myhandle" } });
|
|
|
|
expect(telegramInput.value).toBe("@myhandle");
|
|
});
|
|
});
|
|
|
|
describe("ProfilePage - Form Submission", () => {
|
|
test("shows success toast after successful save", async () => {
|
|
const fetchSpy = vi
|
|
.spyOn(global, "fetch")
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
contact_email: null,
|
|
telegram: null,
|
|
signal: null,
|
|
nostr_npub: null,
|
|
}),
|
|
} as Response)
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
contact_email: "new@example.com",
|
|
telegram: null,
|
|
signal: null,
|
|
nostr_npub: null,
|
|
}),
|
|
} as Response);
|
|
|
|
render(<ProfilePage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
|
});
|
|
|
|
// Change email
|
|
const emailInput = document.getElementById("contact_email") as HTMLInputElement;
|
|
fireEvent.change(emailInput, { target: { value: "new@example.com" } });
|
|
|
|
// Submit
|
|
const submitButton = screen.getByRole("button", { name: /save changes/i });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/saved successfully/i)).toBeDefined();
|
|
});
|
|
|
|
// Verify PUT was called
|
|
expect(fetchSpy).toHaveBeenCalledWith(
|
|
"http://localhost:8000/api/profile",
|
|
expect.objectContaining({
|
|
method: "PUT",
|
|
credentials: "include",
|
|
})
|
|
);
|
|
});
|
|
|
|
test("shows inline errors from backend validation", async () => {
|
|
vi.spyOn(global, "fetch")
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
contact_email: null,
|
|
telegram: null,
|
|
signal: null,
|
|
nostr_npub: null,
|
|
}),
|
|
} as Response)
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 422,
|
|
json: () =>
|
|
Promise.resolve({
|
|
detail: {
|
|
field_errors: {
|
|
telegram: "Backend error: invalid handle",
|
|
},
|
|
},
|
|
}),
|
|
} as Response);
|
|
|
|
render(<ProfilePage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
|
});
|
|
|
|
// Enter a value that passes frontend validation but fails backend
|
|
const telegramInput = document.getElementById("telegram") as HTMLInputElement;
|
|
fireEvent.change(telegramInput, { target: { value: "@validfrontend" } });
|
|
|
|
// Submit
|
|
const submitButton = screen.getByRole("button", { name: /save changes/i });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/backend error/i)).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("shows error toast on network failure", async () => {
|
|
vi.spyOn(global, "fetch")
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
contact_email: null,
|
|
telegram: null,
|
|
signal: null,
|
|
nostr_npub: null,
|
|
}),
|
|
} as Response)
|
|
.mockRejectedValueOnce(new Error("Network error"));
|
|
|
|
render(<ProfilePage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
|
});
|
|
|
|
// Change something
|
|
const emailInput = document.getElementById("contact_email") as HTMLInputElement;
|
|
fireEvent.change(emailInput, { target: { value: "new@example.com" } });
|
|
|
|
// Submit
|
|
const submitButton = screen.getByRole("button", { name: /save changes/i });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/network error/i)).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("submit button shows 'Saving...' while submitting", async () => {
|
|
let resolveSubmit: (value: Response) => void;
|
|
const submitPromise = new Promise<Response>((resolve) => {
|
|
resolveSubmit = resolve;
|
|
});
|
|
|
|
vi.spyOn(global, "fetch")
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
contact_email: null,
|
|
telegram: null,
|
|
signal: null,
|
|
nostr_npub: null,
|
|
}),
|
|
} as Response)
|
|
.mockReturnValueOnce(submitPromise as Promise<Response>);
|
|
|
|
render(<ProfilePage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
|
});
|
|
|
|
// Change something
|
|
const emailInput = document.getElementById("contact_email") as HTMLInputElement;
|
|
fireEvent.change(emailInput, { target: { value: "new@example.com" } });
|
|
|
|
// Submit
|
|
const submitButton = screen.getByRole("button", { name: /save changes/i });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Saving...")).toBeDefined();
|
|
});
|
|
|
|
// Resolve the promise
|
|
resolveSubmit!({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
contact_email: "new@example.com",
|
|
telegram: null,
|
|
signal: null,
|
|
nostr_npub: null,
|
|
}),
|
|
} as Response);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("button", { name: /save changes/i })).toBeDefined();
|
|
});
|
|
});
|
|
});
|