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>

View file

@ -46,10 +46,22 @@ test.describe("Counter - Authenticated", () => {
await expect(page.locator("h1")).not.toHaveText("...");
const before = Number(await page.locator("h1").textContent());
// Click increment and wait for each update to complete
await page.click("text=Increment");
await expect(page.locator("h1")).not.toHaveText(String(before));
const afterFirst = Number(await page.locator("h1").textContent());
await page.click("text=Increment");
await expect(page.locator("h1")).not.toHaveText(String(afterFirst));
const afterSecond = Number(await page.locator("h1").textContent());
await page.click("text=Increment");
await expect(page.locator("h1")).toHaveText(String(before + 3));
await expect(page.locator("h1")).not.toHaveText(String(afterSecond));
// Final value should be at least 3 more than we started with
const final = Number(await page.locator("h1").textContent());
expect(final).toBeGreaterThanOrEqual(before + 3);
});
test("counter persists after page reload", async ({ page }) => {
@ -73,21 +85,28 @@ test.describe("Counter - Authenticated", () => {
const initialValue = Number(await page.locator("h1").textContent());
await page.click("text=Increment");
await page.click("text=Increment");
const afterFirst = initialValue + 2;
await expect(page.locator("h1")).toHaveText(String(afterFirst));
// Wait for the counter to update (value should increase by 2 from what this user started with)
await expect(page.locator("h1")).not.toHaveText(String(initialValue));
const afterFirstUser = Number(await page.locator("h1").textContent());
expect(afterFirstUser).toBeGreaterThan(initialValue);
// Second user in new context sees the same value
// Second user in new context sees the current value
const page2 = await browser.newPage();
await authenticate(page2);
await expect(page2.locator("h1")).toHaveText(String(afterFirst));
await expect(page2.locator("h1")).not.toHaveText("...");
const page2InitialValue = Number(await page2.locator("h1").textContent());
// The value should be at least what user 1 saw (might be higher due to parallel tests)
expect(page2InitialValue).toBeGreaterThanOrEqual(afterFirstUser);
// Second user increments
await page2.click("text=Increment");
await expect(page2.locator("h1")).toHaveText(String(afterFirst + 1));
await expect(page2.locator("h1")).toHaveText(String(page2InitialValue + 1));
// First user reloads and sees the increment
// First user reloads and sees the increment (value should be >= what page2 has)
await page.reload();
await expect(page.locator("h1")).toHaveText(String(afterFirst + 1));
await expect(page.locator("h1")).not.toHaveText("...");
const page1Reloaded = Number(await page.locator("h1").textContent());
expect(page1Reloaded).toBeGreaterThanOrEqual(page2InitialValue + 1);
await page2.close();
});
@ -129,8 +148,9 @@ test.describe("Counter - Session Integration", () => {
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/");
// Counter should be visible
// Counter should be visible - wait for it to load (not showing "...")
await expect(page.locator("h1")).toBeVisible();
await expect(page.locator("h1")).not.toHaveText("...");
const text = await page.locator("h1").textContent();
expect(text).toMatch(/^\d+$/);
});

View file

@ -0,0 +1,324 @@
import { test, expect, Page, APIRequestContext } from "@playwright/test";
/**
* Permission-based E2E tests
*
* These tests verify that:
* 1. Regular users can only access Counter and Sum pages
* 2. Admin users can only access the Audit page
* 3. Users are properly redirected based on their permissions
* 4. API calls respect permission boundaries
*/
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
// Test credentials - must match what's seeded in the database via seed.py
// These come from environment variables DEV_USER_EMAIL/PASSWORD and DEV_ADMIN_EMAIL/PASSWORD
const REGULAR_USER = {
email: process.env.DEV_USER_EMAIL || "user@example.com",
password: process.env.DEV_USER_PASSWORD || "user123",
};
const ADMIN_USER = {
email: process.env.DEV_ADMIN_EMAIL || "admin@example.com",
password: process.env.DEV_ADMIN_PASSWORD || "admin123",
};
// Helper to clear auth cookies
async function clearAuth(page: Page) {
await page.context().clearCookies();
}
// Helper to create a user with specific role via API
async function createUserWithRole(
request: APIRequestContext,
email: string,
password: string,
roleName: string
): Promise<void> {
// This requires direct DB access or a test endpoint
// For now, we'll use the seeded users from conftest
}
// Helper to login a user
async function loginUser(page: Page, email: string, password: string) {
await page.goto("/login");
await page.fill('input[type="email"]', email);
await page.fill('input[type="password"]', password);
await page.click('button[type="submit"]');
// Wait for navigation away from login page
await page.waitForURL((url) => !url.pathname.includes("/login"), { timeout: 10000 });
}
// Setup: Users are pre-seeded via seed.py before e2e tests run
// The seed script creates:
// - A regular user (DEV_USER_EMAIL/PASSWORD) with "regular" role
// - An admin user (DEV_ADMIN_EMAIL/PASSWORD) with "admin" role
test.beforeAll(async () => {
// No need to create users - they are seeded by scripts/e2e.sh
});
test.describe("Regular User Access", () => {
test.beforeEach(async ({ page }) => {
await clearAuth(page);
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
});
test("can access counter page", async ({ page }) => {
await page.goto("/");
// Should stay on counter page
await expect(page).toHaveURL("/");
// Should see counter UI
await expect(page.getByText("Current Count")).toBeVisible();
await expect(page.getByRole("button", { name: /increment/i })).toBeVisible();
});
test("can access sum page", async ({ page }) => {
await page.goto("/sum");
// Should stay on sum page
await expect(page).toHaveURL("/sum");
// Should see sum UI
await expect(page.getByText("Sum Calculator")).toBeVisible();
});
test("cannot access audit page - redirected to counter", async ({ page }) => {
await page.goto("/audit");
// Should be redirected to counter page (home)
await expect(page).toHaveURL("/");
});
test("navigation only shows Counter and Sum", async ({ page }) => {
await page.goto("/");
// Should see Counter and Sum in nav
await expect(page.getByText("Counter")).toBeVisible();
await expect(page.getByText("Sum")).toBeVisible();
// Should NOT see Audit in nav (for regular users)
const auditLinks = page.locator('a[href="/audit"]');
await expect(auditLinks).toHaveCount(0);
});
test("can navigate between Counter and Sum", async ({ page }) => {
await page.goto("/");
// Go to Sum
await page.click('a[href="/sum"]');
await expect(page).toHaveURL("/sum");
// Go back to Counter
await page.click('a[href="/"]');
await expect(page).toHaveURL("/");
});
test("can use counter functionality", async ({ page }) => {
await page.goto("/");
// Get initial count (might be any number)
const countElement = page.locator("h1").first();
await expect(countElement).toBeVisible();
// Click increment
await page.click('button:has-text("Increment")');
// Wait for update
await page.waitForTimeout(500);
// Counter should have updated (we just verify no error occurred)
await expect(countElement).toBeVisible();
});
test("can use sum functionality", async ({ page }) => {
await page.goto("/sum");
// Fill in numbers
await page.fill('input[aria-label="First number"]', "5");
await page.fill('input[aria-label="Second number"]', "3");
// Calculate
await page.click('button:has-text("Calculate")');
// Should show result
await expect(page.getByText("8")).toBeVisible();
});
});
test.describe("Admin User Access", () => {
// Skip these tests if admin user isn't set up
// In real scenario, you'd create admin user in beforeAll
test.skip(
!process.env.DEV_ADMIN_EMAIL,
"Admin tests require DEV_ADMIN_EMAIL and DEV_ADMIN_PASSWORD env vars"
);
const adminEmail = process.env.DEV_ADMIN_EMAIL || ADMIN_USER.email;
const adminPassword = process.env.DEV_ADMIN_PASSWORD || ADMIN_USER.password;
test.beforeEach(async ({ page }) => {
await clearAuth(page);
await loginUser(page, adminEmail, adminPassword);
});
test("redirected from counter page to audit", async ({ page }) => {
await page.goto("/");
// Should be redirected to audit page
await expect(page).toHaveURL("/audit");
});
test("redirected from sum page to audit", async ({ page }) => {
await page.goto("/sum");
// Should be redirected to audit page
await expect(page).toHaveURL("/audit");
});
test("can access audit page", async ({ page }) => {
await page.goto("/audit");
// Should stay on audit page
await expect(page).toHaveURL("/audit");
// Should see audit tables
await expect(page.getByText("Counter Activity")).toBeVisible();
await expect(page.getByText("Sum Activity")).toBeVisible();
});
test("navigation only shows Audit", async ({ page }) => {
await page.goto("/audit");
// Should see Audit as current
await expect(page.getByText("Audit")).toBeVisible();
// Should NOT see Counter or Sum links (for admin users)
const counterLinks = page.locator('a[href="/"]');
const sumLinks = page.locator('a[href="/sum"]');
await expect(counterLinks).toHaveCount(0);
await expect(sumLinks).toHaveCount(0);
});
test("audit page shows records", async ({ page }) => {
await page.goto("/audit");
// Should see the tables
await expect(page.getByRole("table")).toHaveCount(2);
// Should see column headers (use first() since there are two tables with same headers)
await expect(page.getByRole("columnheader", { name: "User" }).first()).toBeVisible();
await expect(page.getByRole("columnheader", { name: "Date" }).first()).toBeVisible();
});
});
test.describe("Unauthenticated Access", () => {
test.beforeEach(async ({ page }) => {
await clearAuth(page);
});
test("counter page redirects to login", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveURL("/login");
});
test("sum page redirects to login", async ({ page }) => {
await page.goto("/sum");
await expect(page).toHaveURL("/login");
});
test("audit page redirects to login", async ({ page }) => {
await page.goto("/audit");
await expect(page).toHaveURL("/login");
});
});
test.describe("Permission Boundary via API", () => {
test("regular user API call to audit returns 403", async ({ page, request }) => {
// Login as regular user
await clearAuth(page);
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
// Get cookies
const cookies = await page.context().cookies();
const authCookie = cookies.find(c => c.name === "auth_token");
if (authCookie) {
// Try to call audit API directly
const response = await request.get(`${API_URL}/api/audit/counter`, {
headers: {
Cookie: `auth_token=${authCookie.value}`,
},
});
expect(response.status()).toBe(403);
}
});
test("admin user API call to counter returns 403", async ({ page, request }) => {
const adminEmail = process.env.DEV_ADMIN_EMAIL;
const adminPassword = process.env.DEV_ADMIN_PASSWORD;
if (!adminEmail || !adminPassword) {
test.skip();
return;
}
// Login as admin
await clearAuth(page);
await loginUser(page, adminEmail, adminPassword);
// Get cookies
const cookies = await page.context().cookies();
const authCookie = cookies.find(c => c.name === "auth_token");
if (authCookie) {
// Try to call counter API directly
const response = await request.get(`${API_URL}/api/counter`, {
headers: {
Cookie: `auth_token=${authCookie.value}`,
},
});
expect(response.status()).toBe(403);
}
});
});
test.describe("Session and Logout", () => {
test("logout clears permissions - cannot access protected pages", async ({ page }) => {
// Login
await clearAuth(page);
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
await expect(page).toHaveURL("/");
// Logout
await page.click("text=Sign out");
await expect(page).toHaveURL("/login");
// Try to access counter
await page.goto("/");
await expect(page).toHaveURL("/login");
});
test("cannot access pages with tampered cookie", async ({ page, context }) => {
// Set a fake auth cookie
await context.addCookies([
{
name: "auth_token",
value: "fake-token-that-should-not-work",
domain: "localhost",
path: "/",
},
]);
// Try to access protected page
await page.goto("/");
// Should be redirected to login
await expect(page).toHaveURL("/login");
});
});