mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-30 23:34:27 +00:00
fix(ui): return 401 for unauthenticated report routes
- Preserve redirects for server-rendered authentication flows - Return explicit unauthorized responses from report route handlers - Cover invalid sessions and callback fallback behavior
This commit is contained in:
6 files changed
+155
-21
No files matched your search
@@ -2,8 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { GET } from "./route";
|
||||
|
||||
const { getAuthHeadersMock } = vi.hoisted(() => ({
|
||||
getAuthHeadersMock: vi.fn(),
|
||||
const { getRouteAuthHeadersMock } = vi.hoisted(() => ({
|
||||
getRouteAuthHeadersMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
@@ -11,7 +11,7 @@ vi.mock("@/lib", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
getRouteAuthHeaders: getRouteAuthHeadersMock,
|
||||
}));
|
||||
|
||||
describe("GET /api/scans/[scanId]/report", () => {
|
||||
@@ -20,6 +20,24 @@ describe("GET /api/scans/[scanId]/report", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns 401 without fetching upstream when authentication is invalid", async () => {
|
||||
// Given
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getRouteAuthHeadersMock.mockResolvedValue(null);
|
||||
|
||||
// When
|
||||
const response = await GET(new Request("http://localhost/api"), {
|
||||
params: Promise.resolve({ scanId: "scan-123" }),
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(response.status).toBe(401);
|
||||
await expect(response.json()).resolves.toEqual({ error: "Unauthorized." });
|
||||
expect(response.headers.get("location")).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("streams the upstream report body without buffering it", async () => {
|
||||
const upstreamBody = new ReadableStream({
|
||||
start(controller) {
|
||||
@@ -37,7 +55,9 @@ describe("GET /api/scans/[scanId]/report", () => {
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
getRouteAuthHeadersMock.mockResolvedValue({
|
||||
Authorization: "Bearer token",
|
||||
});
|
||||
|
||||
const response = await GET(new Request("http://localhost/api"), {
|
||||
params: Promise.resolve({ scanId: "scan-123" }),
|
||||
@@ -72,7 +92,9 @@ describe("GET /api/scans/[scanId]/report", () => {
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(new Response(upstreamBody, { status: 200 })),
|
||||
);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
getRouteAuthHeadersMock.mockResolvedValue({
|
||||
Authorization: "Bearer token",
|
||||
});
|
||||
|
||||
const response = await GET(
|
||||
new Request("http://localhost/api?preflight=1"),
|
||||
@@ -95,7 +117,9 @@ describe("GET /api/scans/[scanId]/report", () => {
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
getRouteAuthHeadersMock.mockResolvedValue({
|
||||
Authorization: "Bearer token",
|
||||
});
|
||||
|
||||
const response = await GET(new Request("http://localhost/api"), {
|
||||
params: Promise.resolve({ scanId: "scan-123" }),
|
||||
@@ -122,7 +146,9 @@ describe("GET /api/scans/[scanId]/report", () => {
|
||||
}),
|
||||
),
|
||||
);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
getRouteAuthHeadersMock.mockResolvedValue({
|
||||
Authorization: "Bearer token",
|
||||
});
|
||||
|
||||
const response = await GET(
|
||||
new Request("http://localhost/api?preflight=1"),
|
||||
@@ -145,7 +171,9 @@ describe("GET /api/scans/[scanId]/report", () => {
|
||||
Response.json({ data: { id: "task-1" } }, { status: 202 }),
|
||||
),
|
||||
);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
getRouteAuthHeadersMock.mockResolvedValue({
|
||||
Authorization: "Bearer token",
|
||||
});
|
||||
|
||||
const response = await GET(new Request("http://localhost/api"), {
|
||||
params: Promise.resolve({ scanId: "scan-123" }),
|
||||
@@ -160,7 +188,9 @@ describe("GET /api/scans/[scanId]/report", () => {
|
||||
"fetch",
|
||||
vi.fn().mockRejectedValue(new DOMException("Timed out", "TimeoutError")),
|
||||
);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
getRouteAuthHeadersMock.mockResolvedValue({
|
||||
Authorization: "Bearer token",
|
||||
});
|
||||
|
||||
const response = await GET(
|
||||
new Request("http://localhost/api?preflight=1"),
|
||||
@@ -187,7 +217,9 @@ describe("GET /api/scans/[scanId]/report", () => {
|
||||
),
|
||||
),
|
||||
);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
getRouteAuthHeadersMock.mockResolvedValue({
|
||||
Authorization: "Bearer token",
|
||||
});
|
||||
|
||||
const response = await GET(
|
||||
new Request("http://localhost/api?preflight=1"),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { getRouteAuthHeaders } from "@/lib/auth-headers";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@@ -64,7 +64,14 @@ export async function GET(
|
||||
{ params }: ScanReportRouteContext,
|
||||
) {
|
||||
const { scanId } = await params;
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const headers = await getRouteAuthHeaders({ contentType: false });
|
||||
if (!headers) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized." },
|
||||
{ status: 401, headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
|
||||
const upstreamUrl = `${apiBaseUrl}/scans/${encodeURIComponent(scanId)}/report`;
|
||||
const isPreflight =
|
||||
new URL(request.url).searchParams.get("preflight") === "1";
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { authMock, getRequiredAuthHeadersMock } = vi.hoisted(() => ({
|
||||
authMock: vi.fn(),
|
||||
getRequiredAuthHeadersMock: vi.fn(),
|
||||
}));
|
||||
const { authMock, getAuthHeadersIfAvailableMock, getRequiredAuthHeadersMock } =
|
||||
vi.hoisted(() => ({
|
||||
authMock: vi.fn(),
|
||||
getAuthHeadersIfAvailableMock: vi.fn(),
|
||||
getRequiredAuthHeadersMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
@@ -12,10 +14,11 @@ vi.mock("@/auth.config", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./server-auth", () => ({
|
||||
getAuthHeadersIfAvailable: getAuthHeadersIfAvailableMock,
|
||||
getRequiredAuthHeaders: getRequiredAuthHeadersMock,
|
||||
}));
|
||||
|
||||
import { getAuthHeaders } from "./auth-headers";
|
||||
import { getAuthHeaders, getRouteAuthHeaders } from "./auth-headers";
|
||||
|
||||
describe("getAuthHeaders", () => {
|
||||
beforeEach(() => {
|
||||
@@ -40,4 +43,25 @@ describe("getAuthHeaders", () => {
|
||||
"RefreshAccessTokenError",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns no route headers for a terminal session", async () => {
|
||||
// Given
|
||||
authMock.mockResolvedValue({
|
||||
accessToken: "stale-access-token",
|
||||
error: "RefreshAccessTokenError",
|
||||
});
|
||||
getAuthHeadersIfAvailableMock.mockReturnValue(null);
|
||||
|
||||
// When
|
||||
const result = await getRouteAuthHeaders();
|
||||
|
||||
// Then
|
||||
expect(result).toBeNull();
|
||||
expect(getAuthHeadersIfAvailableMock).toHaveBeenCalledWith(
|
||||
"stale-access-token",
|
||||
undefined,
|
||||
"RefreshAccessTokenError",
|
||||
);
|
||||
expect(getRequiredAuthHeadersMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+16
-1
@@ -2,10 +2,25 @@ import "server-only";
|
||||
|
||||
import { auth } from "@/auth.config";
|
||||
|
||||
import { getRequiredAuthHeaders } from "./server-auth";
|
||||
import {
|
||||
getAuthHeadersIfAvailable,
|
||||
getRequiredAuthHeaders,
|
||||
} from "./server-auth";
|
||||
|
||||
export const getAuthHeaders = async (options?: { contentType?: boolean }) => {
|
||||
const session = await auth();
|
||||
|
||||
return getRequiredAuthHeaders(session?.accessToken, options, session?.error);
|
||||
};
|
||||
|
||||
export const getRouteAuthHeaders = async (options?: {
|
||||
contentType?: boolean;
|
||||
}) => {
|
||||
const session = await auth();
|
||||
|
||||
return getAuthHeadersIfAvailable(
|
||||
session?.accessToken,
|
||||
options,
|
||||
session?.error,
|
||||
);
|
||||
};
|
||||
@@ -17,7 +17,11 @@ vi.mock("next/navigation", () => ({
|
||||
redirect: redirectMock,
|
||||
}));
|
||||
|
||||
import { getRequiredAuthHeaders, redirectToSignIn } from "./server-auth";
|
||||
import {
|
||||
getAuthHeadersIfAvailable,
|
||||
getRequiredAuthHeaders,
|
||||
redirectToSignIn,
|
||||
} from "./server-auth";
|
||||
|
||||
describe("server authentication", () => {
|
||||
beforeEach(() => {
|
||||
@@ -51,6 +55,31 @@ describe("server authentication", () => {
|
||||
expect(redirectMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns no headers for a terminal session without redirecting", () => {
|
||||
// Given
|
||||
const accessToken = "stale-access-token";
|
||||
|
||||
// When
|
||||
const result = getAuthHeadersIfAvailable(
|
||||
accessToken,
|
||||
undefined,
|
||||
"RefreshAccessTokenError",
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(result).toBeNull();
|
||||
expect(redirectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns no headers for a missing session without redirecting", () => {
|
||||
// When
|
||||
const result = getAuthHeadersIfAvailable(undefined);
|
||||
|
||||
// Then
|
||||
expect(result).toBeNull();
|
||||
expect(redirectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates bearer headers only when an access token is present", async () => {
|
||||
// Given
|
||||
const accessToken = "access-token";
|
||||
@@ -75,4 +104,14 @@ describe("server authentication", () => {
|
||||
"NEXT_REDIRECT:/sign-in?callbackUrl=%2Fproviders%3Fpage%3D2",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the root callback when the current path is unavailable", async () => {
|
||||
// Given
|
||||
headersMock.mockResolvedValue(new Headers());
|
||||
|
||||
// When / Then
|
||||
await expect(redirectToSignIn()).rejects.toThrow(
|
||||
"NEXT_REDIRECT:/sign-in?callbackUrl=%2F",
|
||||
);
|
||||
});
|
||||
});
|
||||
+19
-2
@@ -17,13 +17,13 @@ export const redirectToSignIn = async (): Promise<never> => {
|
||||
|
||||
type SessionError = "RefreshAccessTokenError" | (string & {});
|
||||
|
||||
export const getRequiredAuthHeaders = async (
|
||||
export const getAuthHeadersIfAvailable = (
|
||||
accessToken: string | undefined,
|
||||
options?: { contentType?: boolean },
|
||||
sessionError?: SessionError,
|
||||
) => {
|
||||
if (!accessToken || sessionError === "RefreshAccessTokenError") {
|
||||
return redirectToSignIn();
|
||||
return null;
|
||||
}
|
||||
|
||||
const authHeaders: Record<string, string> = {
|
||||
@@ -37,3 +37,20 @@ export const getRequiredAuthHeaders = async (
|
||||
|
||||
return authHeaders;
|
||||
};
|
||||
|
||||
export const getRequiredAuthHeaders = async (
|
||||
accessToken: string | undefined,
|
||||
options?: { contentType?: boolean },
|
||||
sessionError?: SessionError,
|
||||
) => {
|
||||
const authHeaders = getAuthHeadersIfAvailable(
|
||||
accessToken,
|
||||
options,
|
||||
sessionError,
|
||||
);
|
||||
if (!authHeaders) {
|
||||
return redirectToSignIn();
|
||||
}
|
||||
|
||||
return authHeaders;
|
||||
};
|
||||
Reference in new issue
Block a user