tests passing

This commit is contained in:
counterweight 2025-12-18 23:33:32 +01:00
parent 322bdd3e6e
commit b173b47925
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
18 changed files with 1414 additions and 93 deletions

View file

@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "../auth-context";
import { useAuth, Permission } from "../auth-context";
import { API_URL } from "../config";
interface CounterRecord {
@ -35,26 +35,32 @@ export default function AuditPage() {
const [sumData, setSumData] = useState<PaginatedResponse<SumRecord> | null>(null);
const [counterPage, setCounterPage] = useState(1);
const [sumPage, setSumPage] = useState(1);
const { user, isLoading, logout } = useAuth();
const { user, isLoading, logout, hasPermission } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading && !user) {
router.push("/login");
}
}, [isLoading, user, router]);
const canViewAudit = hasPermission(Permission.VIEW_AUDIT);
useEffect(() => {
if (user) {
if (!isLoading) {
if (!user) {
router.push("/login");
} else if (!canViewAudit) {
router.push("/");
}
}
}, [isLoading, user, router, canViewAudit]);
useEffect(() => {
if (user && canViewAudit) {
fetchCounterRecords(counterPage);
}
}, [user, counterPage]);
}, [user, counterPage, canViewAudit]);
useEffect(() => {
if (user) {
if (user && canViewAudit) {
fetchSumRecords(sumPage);
}
}, [user, sumPage]);
}, [user, sumPage, canViewAudit]);
const fetchCounterRecords = async (page: number) => {
try {
@ -97,7 +103,7 @@ export default function AuditPage() {
);
}
if (!user) {
if (!user || !canViewAudit) {
return null;
}
@ -105,10 +111,6 @@ export default function AuditPage() {
<main style={styles.main}>
<div style={styles.header}>
<div style={styles.nav}>
<a href="/" style={styles.navLink}>Counter</a>
<span style={styles.navDivider}></span>
<a href="/sum" style={styles.navLink}>Sum</a>
<span style={styles.navDivider}></span>
<span style={styles.navCurrent}>Audit</span>
</div>
<div style={styles.userInfo}>

View file

@ -4,9 +4,21 @@ import { createContext, useContext, useState, useEffect, ReactNode } from "react
import { API_URL } from "./config";
// Permission constants matching backend
export const Permission = {
VIEW_COUNTER: "view_counter",
INCREMENT_COUNTER: "increment_counter",
USE_SUM: "use_sum",
VIEW_AUDIT: "view_audit",
} as const;
export type PermissionType = typeof Permission[keyof typeof Permission];
interface User {
id: number;
email: string;
roles: string[];
permissions: string[];
}
interface AuthContextType {
@ -15,6 +27,9 @@ interface AuthContextType {
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
hasPermission: (permission: PermissionType) => boolean;
hasAnyPermission: (...permissions: PermissionType[]) => boolean;
hasRole: (role: string) => boolean;
}
const AuthContext = createContext<AuthContextType | null>(null);
@ -85,8 +100,31 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(null);
};
const hasPermission = (permission: PermissionType): boolean => {
return user?.permissions.includes(permission) ?? false;
};
const hasAnyPermission = (...permissions: PermissionType[]): boolean => {
return permissions.some((p) => user?.permissions.includes(p) ?? false);
};
const hasRole = (role: string): boolean => {
return user?.roles.includes(role) ?? false;
};
return (
<AuthContext.Provider value={{ user, isLoading, login, register, logout }}>
<AuthContext.Provider
value={{
user,
isLoading,
login,
register,
logout,
hasPermission,
hasAnyPermission,
hasRole,
}}
>
{children}
</AuthContext.Provider>
);

View file

@ -11,23 +11,46 @@ vi.mock("next/navigation", () => ({
}));
// Default mock values
let mockUser: { id: number; email: string } | null = { id: 1, email: "test@example.com" };
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"],
};
let mockIsLoading = false;
const mockLogout = vi.fn();
const mockHasPermission = vi.fn((permission: string) =>
mockUser?.permissions.includes(permission) ?? false
);
vi.mock("./auth-context", () => ({
useAuth: () => ({
user: mockUser,
isLoading: mockIsLoading,
logout: mockLogout,
hasPermission: mockHasPermission,
}),
Permission: {
VIEW_COUNTER: "view_counter",
INCREMENT_COUNTER: "increment_counter",
USE_SUM: "use_sum",
VIEW_AUDIT: "view_audit",
},
}));
beforeEach(() => {
vi.clearAllMocks();
// Reset to authenticated state
mockUser = { id: 1, email: "test@example.com" };
mockUser = {
id: 1,
email: "test@example.com",
roles: ["regular"],
permissions: ["view_counter", "increment_counter", "use_sum"],
};
mockIsLoading = false;
mockHasPermission.mockImplementation((permission: string) =>
mockUser?.permissions.includes(permission) ?? false
);
});
afterEach(() => {

View file

@ -2,19 +2,26 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "./auth-context";
import { useAuth, Permission } from "./auth-context";
import { API_URL } from "./config";
export default function Home() {
const [count, setCount] = useState<number | null>(null);
const { user, isLoading, logout } = useAuth();
const { user, isLoading, logout, hasPermission } = useAuth();
const router = useRouter();
const canViewCounter = hasPermission(Permission.VIEW_COUNTER);
useEffect(() => {
if (!isLoading && !user) {
router.push("/login");
if (!isLoading) {
if (!user) {
router.push("/login");
} else if (!canViewCounter) {
// Redirect to audit if user has audit permission, otherwise to login
router.push(hasPermission(Permission.VIEW_AUDIT) ? "/audit" : "/login");
}
}
}, [isLoading, user, router]);
}, [isLoading, user, router, canViewCounter, hasPermission]);
useEffect(() => {
if (user) {
@ -49,7 +56,7 @@ export default function Home() {
);
}
if (!user) {
if (!user || !canViewCounter) {
return null;
}
@ -60,8 +67,6 @@ export default function Home() {
<span style={styles.navCurrent}>Counter</span>
<span style={styles.navDivider}></span>
<a href="/sum" style={styles.navLink}>Sum</a>
<span style={styles.navDivider}></span>
<a href="/audit" style={styles.navLink}>Audit</a>
</div>
<div style={styles.userInfo}>
<span style={styles.userEmail}>{user.email}</span>

View file

@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "../auth-context";
import { useAuth, Permission } from "../auth-context";
import { API_URL } from "../config";
export default function SumPage() {
@ -10,14 +10,20 @@ export default function SumPage() {
const [b, setB] = useState("");
const [result, setResult] = useState<number | null>(null);
const [showResult, setShowResult] = useState(false);
const { user, isLoading, logout } = useAuth();
const { user, isLoading, logout, hasPermission } = useAuth();
const router = useRouter();
const canUseSum = hasPermission(Permission.USE_SUM);
useEffect(() => {
if (!isLoading && !user) {
router.push("/login");
if (!isLoading) {
if (!user) {
router.push("/login");
} else if (!canUseSum) {
router.push(hasPermission(Permission.VIEW_AUDIT) ? "/audit" : "/login");
}
}
}, [isLoading, user, router]);
}, [isLoading, user, router, canUseSum, hasPermission]);
const handleSum = async () => {
const numA = parseFloat(a) || 0;
@ -60,7 +66,7 @@ export default function SumPage() {
);
}
if (!user) {
if (!user || !canUseSum) {
return null;
}
@ -71,8 +77,6 @@ export default function SumPage() {
<a href="/" style={styles.navLink}>Counter</a>
<span style={styles.navDivider}></span>
<span style={styles.navCurrent}>Sum</span>
<span style={styles.navDivider}></span>
<a href="/audit" style={styles.navLink}>Audit</a>
</div>
<div style={styles.userInfo}>
<span style={styles.userEmail}>{user.email}</span>