mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(admin): restrict login overview to auth events
The Login Overview page promised "authentication and admin events" in its subtitle but the underlying audit-log query never set `filter=auth`, so the table mixed insights.* / admin.* / data.* rows in with passkey + password logins. Default the page request to `filter=auth` (an explicit action picker still overrides), and restrict the action dropdown to actions starting with `auth.` so the filter UI stays in sync with what the page actually shows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
180f46c24e
commit
dc75fe846a
2 files changed
+126
-5
No files matched your search
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.19 A8 / F-02 — `/admin/login-overview` was rendering insights events
|
||||
* (`insights.weight-status.en`, `insights.bmi-status.en`, …) because the
|
||||
* underlying fetch never restricted the `audit-log` API to authentication
|
||||
* actions. The page subtitle promises "authentication and admin events" so
|
||||
* everything else is data we don't want to leak into the login viewer.
|
||||
*
|
||||
* The fix: the section must call the audit-log endpoint with
|
||||
* `filter=auth` whenever no explicit action filter is set. This test pins
|
||||
* the request URL so the regression cannot recur.
|
||||
*/
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/admin/login-overview",
|
||||
}));
|
||||
|
||||
const fetchCalls: string[] = [];
|
||||
|
||||
vi.mock("@tanstack/react-query", async () => {
|
||||
// Implement a lightweight stub that records the URL passed to queryFn so
|
||||
// we can assert on the request the section would issue.
|
||||
return {
|
||||
useQuery: ({
|
||||
queryFn,
|
||||
enabled,
|
||||
}: {
|
||||
queryFn?: () => Promise<unknown>;
|
||||
enabled?: boolean;
|
||||
}) => {
|
||||
if (enabled !== false && queryFn) {
|
||||
// Best-effort fire-and-forget — the static-markup render only needs
|
||||
// the side-effect, not the result.
|
||||
try {
|
||||
void queryFn();
|
||||
} catch {
|
||||
/* ignored */
|
||||
}
|
||||
}
|
||||
return {
|
||||
data: { entries: [], meta: { total: 0, limit: 50, offset: 0, page: 1, perPage: 50 } },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
},
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "u1",
|
||||
username: "marc",
|
||||
email: "marc@example.com",
|
||||
role: "ADMIN",
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { LoginOverviewSection } from "../login-overview-section";
|
||||
|
||||
beforeEach(() => {
|
||||
fetchCalls.length = 0;
|
||||
globalThis.fetch = vi.fn(async (url: RequestInfo | URL) => {
|
||||
fetchCalls.push(typeof url === "string" ? url : url.toString());
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
entries: [],
|
||||
meta: { total: 0, limit: 50, offset: 0, page: 1, perPage: 50 },
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("LoginOverviewSection — restricts to auth events", () => {
|
||||
it("requests /api/admin/audit-log with filter=auth by default", () => {
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<LoginOverviewSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
const auditCalls = fetchCalls.filter((u) =>
|
||||
u.includes("/api/admin/audit-log?"),
|
||||
);
|
||||
expect(auditCalls.length).toBeGreaterThan(0);
|
||||
for (const url of auditCalls) {
|
||||
expect(url).toMatch(/[?&]filter=auth(&|$)/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -84,6 +84,13 @@ export function LoginOverviewSection() {
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", String(page));
|
||||
params.set("perPage", String(perPage));
|
||||
// F-02 (v1.4.19): The page is the auth/admin "Login Overview" — restrict
|
||||
// to `auth.*` actions so insights/admin/data events don't leak into the
|
||||
// viewer (the previous behaviour rendered rows like
|
||||
// `insights.weight-status.en` next to "Passkey login"). When the user
|
||||
// picks a specific action below we trust their explicit choice and let
|
||||
// it overwrite this default.
|
||||
params.set("filter", "auth");
|
||||
if (actor.trim()) params.set("actor", actor.trim());
|
||||
if (actionFilter && actionFilter !== "__all__") {
|
||||
params.set("action", actionFilter);
|
||||
@@ -242,11 +249,13 @@ export function LoginOverviewSection() {
|
||||
<SelectItem value="__all__">
|
||||
{t("admin.section.auditLog.filterActionAll")}
|
||||
</SelectItem>
|
||||
{(actionsData?.actions ?? []).map((a) => (
|
||||
<SelectItem key={a} value={a}>
|
||||
{AUTH_ACTION_LABELS[a] ?? a}
|
||||
</SelectItem>
|
||||
))}
|
||||
{(actionsData?.actions ?? [])
|
||||
.filter((a) => a.startsWith("auth."))
|
||||
.map((a) => (
|
||||
<SelectItem key={a} value={a}>
|
||||
{AUTH_ACTION_LABELS[a] ?? a}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
|
||||
Reference in new issue
Block a user