mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 03:20:46 +02:00
Explicitly test for console warnings & errors (#6502)
# Description of Changes Disallow warnings and errors from being thrown in the browser console during tests unless explicitly expected in the test. Also adds a Playwright test to prod around some main UI areas and checks that no warnings/errors have been thrown.
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
} from "@app/auth/springAuthClient";
|
||||
import { startOAuthNavigation } from "@app/extensions/oauthNavigation";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { allowConsole, expectConsole } from "@app/tests/failOnConsole";
|
||||
import {
|
||||
AxiosError,
|
||||
type AxiosResponse,
|
||||
@@ -89,9 +90,18 @@ describe("SpringAuthClient", () => {
|
||||
);
|
||||
|
||||
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
|
||||
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
|
||||
|
||||
const result = await springAuth.getSession();
|
||||
|
||||
// A 401 from /me triggers an explicit refresh attempt before treating
|
||||
// the session as invalid; lock that recovery contract in so future
|
||||
// refactors can't quietly skip it.
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/api/v1/auth/refresh",
|
||||
null,
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(localStorage.getItem("stirling_jwt")).toBeNull();
|
||||
expect(result.data.session).toBeNull();
|
||||
// 401 is handled gracefully, so error should be null
|
||||
@@ -117,9 +127,18 @@ describe("SpringAuthClient", () => {
|
||||
);
|
||||
|
||||
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
|
||||
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
|
||||
|
||||
const result = await springAuth.getSession();
|
||||
|
||||
// A 403 from /me triggers an explicit refresh attempt before treating
|
||||
// the session as invalid; lock that recovery contract in so future
|
||||
// refactors can't quietly skip it.
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/api/v1/auth/refresh",
|
||||
null,
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(localStorage.getItem("stirling_jwt")).toBeNull();
|
||||
expect(result.data.session).toBeNull();
|
||||
// 403 is handled gracefully, so error should be null
|
||||
@@ -129,6 +148,9 @@ describe("SpringAuthClient", () => {
|
||||
|
||||
describe("signInWithPassword", () => {
|
||||
it("should successfully sign in with email and password", async () => {
|
||||
// The fake token isn't a real JWT, so calculateAdaptiveIntervals warns
|
||||
// about defaults - incidental to what this test verifies.
|
||||
allowConsole.warn(/Cannot decode token for adaptive intervals/);
|
||||
const credentials = {
|
||||
email: "[email protected]",
|
||||
password: "password123",
|
||||
@@ -176,6 +198,7 @@ describe("SpringAuthClient", () => {
|
||||
});
|
||||
|
||||
it("should return error on failed login", async () => {
|
||||
expectConsole.error(/\[SpringAuth\] signInWithPassword error/);
|
||||
const credentials = {
|
||||
email: "[email protected]",
|
||||
password: "wrongpassword",
|
||||
@@ -236,6 +259,7 @@ describe("SpringAuthClient", () => {
|
||||
});
|
||||
|
||||
it("should return error on failed registration", async () => {
|
||||
expectConsole.error(/\[SpringAuth\] signUp error/);
|
||||
const credentials = {
|
||||
email: "[email protected]",
|
||||
password: "password123",
|
||||
@@ -283,6 +307,7 @@ describe("SpringAuthClient", () => {
|
||||
});
|
||||
|
||||
it("should clear JWT even if logout request fails", async () => {
|
||||
expectConsole.error(/\[SpringAuth\] signOut error/);
|
||||
const mockToken = "jwt-to-clear";
|
||||
localStorage.setItem("stirling_jwt", mockToken);
|
||||
|
||||
@@ -301,6 +326,9 @@ describe("SpringAuthClient", () => {
|
||||
|
||||
describe("refreshSession", () => {
|
||||
it("should refresh JWT token successfully", async () => {
|
||||
// The fake token isn't a real JWT, so calculateAdaptiveIntervals warns
|
||||
// about defaults - incidental to what this test verifies.
|
||||
allowConsole.warn(/Cannot decode token for adaptive intervals/);
|
||||
const newToken = "refreshed-jwt-token";
|
||||
const mockUser = {
|
||||
id: "123",
|
||||
|
||||
@@ -397,22 +397,19 @@ class SpringAuthClient {
|
||||
// console.debug('[SpringAuth] getSession: Session retrieved successfully');
|
||||
return { data: { session }, error: null };
|
||||
} catch (error: unknown) {
|
||||
console.error("[SpringAuth] getSession error:", error);
|
||||
|
||||
// If 401/403, token is invalid - try explicit refresh
|
||||
// 401/403 during getSession is the normal "token expired or invalid"
|
||||
// path - handled via refresh + JWT clear.
|
||||
const status = getHttpStatus(error);
|
||||
if (status === 401 || status === 403) {
|
||||
// A 401 during startup can be a race with a concurrent refresh. Try one
|
||||
// explicit refresh before treating the session as invalid.
|
||||
const refreshResult = await this.refreshSession();
|
||||
if (!refreshResult.error && refreshResult.data.session) {
|
||||
return refreshResult;
|
||||
}
|
||||
localStorage.removeItem("stirling_jwt");
|
||||
console.debug("[SpringAuth] getSession: Not authenticated");
|
||||
return { data: { session: null }, error: null };
|
||||
}
|
||||
|
||||
console.error("[SpringAuth] getSession error:", error);
|
||||
// Don't clear token for other errors (e.g., backend not ready, network issues)
|
||||
// The token is still valid, just can't verify it right now
|
||||
return {
|
||||
@@ -739,10 +736,11 @@ class SpringAuthClient {
|
||||
|
||||
return { data: { session }, error: null };
|
||||
} catch (error: unknown) {
|
||||
console.error("[SpringAuth] refreshSession error:", error);
|
||||
localStorage.removeItem("stirling_jwt");
|
||||
|
||||
// Handle different error statuses
|
||||
// 401/403 means the refresh token is no longer valid - normal expired
|
||||
// state, not an error worth surfacing. Other statuses (network, backend
|
||||
// down) ARE worth logging.
|
||||
const status = getHttpStatus(error);
|
||||
if (status === 401 || status === 403) {
|
||||
return {
|
||||
@@ -751,6 +749,7 @@ class SpringAuthClient {
|
||||
};
|
||||
}
|
||||
|
||||
console.error("[SpringAuth] refreshSession error:", error);
|
||||
return {
|
||||
data: { session: null },
|
||||
error: { message: getErrorMessage(error, "Token refresh failed") },
|
||||
|
||||
+9
-3
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { Stack, Text, Loader } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
@@ -8,9 +8,7 @@ import {
|
||||
} from "@stripe/react-stripe-js";
|
||||
import { PlanTier } from "@app/services/licenseService";
|
||||
|
||||
// Load Stripe once
|
||||
const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
|
||||
const stripePromise = STRIPE_KEY ? loadStripe(STRIPE_KEY) : null;
|
||||
|
||||
interface PaymentStageProps {
|
||||
clientSecret: string | null;
|
||||
@@ -24,6 +22,14 @@ export const PaymentStage: React.FC<PaymentStageProps> = ({
|
||||
onPaymentComplete,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// Load Stripe.js lazily, only when PaymentStage mounts. Loading at module
|
||||
// scope pulled the Stripe script into every page (CheckoutContext is in
|
||||
// AppProviders), which triggered the dev "HTTPS required" warning on every
|
||||
// non-payment route.
|
||||
const stripePromise = useMemo(
|
||||
() => (STRIPE_KEY ? loadStripe(STRIPE_KEY) : null),
|
||||
[],
|
||||
);
|
||||
|
||||
// Show loading while creating checkout session
|
||||
if (!clientSecret || !selectedPlan) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
POST_LOGIN_REDIRECT_STORAGE_KEY,
|
||||
springAuth,
|
||||
} from "@app/auth/springAuthClient";
|
||||
import { expectConsole } from "@app/tests/failOnConsole";
|
||||
|
||||
// Mock springAuth; keep the real redirect-path helpers.
|
||||
vi.mock("@app/auth/springAuthClient", async () => {
|
||||
@@ -90,6 +91,7 @@ describe("AuthCallback", () => {
|
||||
});
|
||||
|
||||
it("should redirect to login when no access token in hash", async () => {
|
||||
expectConsole.error(/\[AuthCallback\] No access_token in URL fragment/);
|
||||
// No hash or empty hash
|
||||
window.location.hash = "";
|
||||
|
||||
@@ -109,6 +111,7 @@ describe("AuthCallback", () => {
|
||||
});
|
||||
|
||||
it("should redirect to login when token validation fails", async () => {
|
||||
expectConsole.error(/\[AuthCallback\] Failed to validate token/);
|
||||
const invalidToken = "invalid-oauth-token";
|
||||
window.location.hash = `#access_token=${invalidToken}`;
|
||||
|
||||
@@ -137,6 +140,7 @@ describe("AuthCallback", () => {
|
||||
});
|
||||
|
||||
it("should handle errors gracefully", async () => {
|
||||
expectConsole.error(/\[AuthCallback\] Authentication failed/);
|
||||
const mockToken = "error-token";
|
||||
window.location.hash = `#access_token=${mockToken}`;
|
||||
|
||||
|
||||
@@ -18,42 +18,16 @@ export default function AuthCallback() {
|
||||
const navigate = useNavigate();
|
||||
const processingRef = useRef(false);
|
||||
|
||||
// Log component lifecycle
|
||||
useEffect(() => {
|
||||
const mountId = Math.random().toString(36).substring(7);
|
||||
console.log(`[AuthCallback:${mountId}] 🔵 Component mounted`);
|
||||
return () => {
|
||||
console.log(`[AuthCallback:${mountId}] 🔴 Component unmounting`);
|
||||
};
|
||||
}, []);
|
||||
const startedAt = performance.now();
|
||||
const elapsed = () => `${(performance.now() - startedAt).toFixed(0)}ms`;
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
const startTime = performance.now();
|
||||
const executionId = Math.random().toString(36).substring(7);
|
||||
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] ════════════════════════════════════`,
|
||||
);
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Starting authentication callback`,
|
||||
);
|
||||
console.log(`[AuthCallback:${executionId}] URL: ${window.location.href}`);
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Hash: ${window.location.hash}`,
|
||||
);
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Document readyState: ${document.readyState}`,
|
||||
);
|
||||
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
window.sessionStorage.getItem("stirling_sso_auto_login_logged_out") ===
|
||||
"1"
|
||||
) {
|
||||
console.warn(
|
||||
`[AuthCallback:${executionId}] ⚠️ Logout block active, skipping token processing`,
|
||||
);
|
||||
navigate("/login", {
|
||||
replace: true,
|
||||
state: { error: "You have been signed out. Please sign in again." },
|
||||
@@ -62,30 +36,16 @@ export default function AuthCallback() {
|
||||
}
|
||||
|
||||
// Prevent double execution (React 18 Strict Mode + navigate dependency)
|
||||
if (processingRef.current) {
|
||||
console.warn(
|
||||
`[AuthCallback:${executionId}] ⚠️ Already processing, skipping duplicate execution`,
|
||||
);
|
||||
console.warn(
|
||||
`[AuthCallback:${executionId}] This is expected in React Strict Mode (development)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (processingRef.current) return;
|
||||
processingRef.current = true;
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Step 1: Extracting token from URL fragment`,
|
||||
);
|
||||
|
||||
// Extract JWT from URL fragment (#access_token=...)
|
||||
const hash = window.location.hash.substring(1); // Remove '#'
|
||||
const params = new URLSearchParams(hash);
|
||||
const token = params.get("access_token");
|
||||
const hash = window.location.hash.substring(1);
|
||||
const token = new URLSearchParams(hash).get("access_token");
|
||||
|
||||
if (!token) {
|
||||
console.error(
|
||||
`[AuthCallback:${executionId}] ❌ No access_token in URL fragment`,
|
||||
`[AuthCallback] No access_token in URL fragment (${elapsed()})`,
|
||||
);
|
||||
navigate("/login", {
|
||||
replace: true,
|
||||
@@ -94,39 +54,13 @@ export default function AuthCallback() {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] ✓ Token extracted (length: ${token.length})`,
|
||||
);
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Step 2: Storing JWT in localStorage`,
|
||||
);
|
||||
|
||||
// Store JWT in localStorage
|
||||
localStorage.setItem("stirling_jwt", token);
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] ✓ JWT stored in localStorage`,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Step 3: Dispatching 'jwt-available' event`,
|
||||
);
|
||||
// Dispatch custom event for other components to react to JWT availability
|
||||
window.dispatchEvent(new CustomEvent("jwt-available"));
|
||||
console.log(`[AuthCallback:${executionId}] ✓ Event dispatched`);
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Elapsed after jwt-available: ${(performance.now() - startTime).toFixed(2)}ms`,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Step 4: Validating token with backend`,
|
||||
);
|
||||
// Validate the token and load user info
|
||||
// This calls /api/v1/auth/me with the JWT to get user details
|
||||
const { data, error } = await springAuth.getSession();
|
||||
|
||||
if (error || !data.session) {
|
||||
console.error(
|
||||
`[AuthCallback:${executionId}] ❌ Failed to validate token:`,
|
||||
`[AuthCallback] Failed to validate token (${elapsed()}):`,
|
||||
error,
|
||||
);
|
||||
localStorage.removeItem("stirling_jwt");
|
||||
@@ -137,68 +71,21 @@ export default function AuthCallback() {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] ✓ Token validated, user: ${data.session.user.username}`,
|
||||
);
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Step 5: Running platform-specific callback handlers`,
|
||||
);
|
||||
|
||||
await handleAuthCallbackSuccess(token);
|
||||
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] ✓ Callback handlers complete`,
|
||||
);
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Step 6: Waiting for context stabilization`,
|
||||
);
|
||||
|
||||
// Wait for all context providers to process jwt-available event
|
||||
// This prevents infinite render loop when coming from cross-domain SAML redirect
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Elapsed after stabilization wait: ${(performance.now() - startTime).toFixed(2)}ms`,
|
||||
);
|
||||
|
||||
const target = consumePostLoginRedirectPath() ?? "/";
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Step 7: Navigating to ${target}`,
|
||||
console.info(
|
||||
`[AuthCallback] Authenticated ${data.session.user.username} in ${elapsed()}, navigating to ${target}`,
|
||||
);
|
||||
navigate(target, { replace: true });
|
||||
|
||||
const duration = performance.now() - startTime;
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] ✓ Authentication complete (${duration.toFixed(2)}ms)`,
|
||||
);
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] ════════════════════════════════════`,
|
||||
);
|
||||
} catch (error) {
|
||||
const duration = performance.now() - startTime;
|
||||
console.error(
|
||||
`[AuthCallback:${executionId}] ════════════════════════════════════`,
|
||||
);
|
||||
console.error(
|
||||
`[AuthCallback:${executionId}] ❌ FATAL ERROR during authentication`,
|
||||
);
|
||||
console.error(`[AuthCallback:${executionId}] Error:`, error);
|
||||
console.error(
|
||||
`[AuthCallback:${executionId}] Error name:`,
|
||||
(error as Error)?.name,
|
||||
);
|
||||
console.error(
|
||||
`[AuthCallback:${executionId}] Error message:`,
|
||||
(error as Error)?.message,
|
||||
);
|
||||
console.error(
|
||||
`[AuthCallback:${executionId}] Error stack:`,
|
||||
(error as Error)?.stack,
|
||||
);
|
||||
console.error(
|
||||
`[AuthCallback:${executionId}] Duration before failure: ${duration.toFixed(2)}ms`,
|
||||
);
|
||||
console.error(
|
||||
`[AuthCallback:${executionId}] ════════════════════════════════════`,
|
||||
`[AuthCallback] Authentication failed (${elapsed()}):`,
|
||||
error,
|
||||
);
|
||||
navigate("/login", {
|
||||
replace: true,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { BrowserRouter, MemoryRouter } from "react-router-dom";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
@@ -176,7 +176,7 @@ describe("Login", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should show loading state while auth is loading", () => {
|
||||
it("should show loading state while auth is loading", async () => {
|
||||
vi.mocked(useAuth).mockReturnValue({
|
||||
session: null,
|
||||
user: null,
|
||||
@@ -187,13 +187,15 @@ describe("Login", () => {
|
||||
refreshSession: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<BrowserRouter>
|
||||
<Login />
|
||||
</BrowserRouter>
|
||||
</TestWrapper>,
|
||||
);
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<BrowserRouter>
|
||||
<Login />
|
||||
</BrowserRouter>
|
||||
</TestWrapper>,
|
||||
);
|
||||
});
|
||||
|
||||
// Component shouldn't redirect or show form while loading
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
@@ -510,26 +512,30 @@ describe("Login", () => {
|
||||
expect(springAuth.signInWithPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should display session expired message from URL param", () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MemoryRouter initialEntries={["/login?expired=true"]}>
|
||||
<Login />
|
||||
</MemoryRouter>
|
||||
</TestWrapper>,
|
||||
);
|
||||
it("should display session expired message from URL param", async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MemoryRouter initialEntries={["/login?expired=true"]}>
|
||||
<Login />
|
||||
</MemoryRouter>
|
||||
</TestWrapper>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByText(/session.*expired/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display account created success message", () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MemoryRouter initialEntries={["/login?messageType=accountCreated"]}>
|
||||
<Login />
|
||||
</MemoryRouter>
|
||||
</TestWrapper>,
|
||||
);
|
||||
it("should display account created success message", async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MemoryRouter initialEntries={["/login?messageType=accountCreated"]}>
|
||||
<Login />
|
||||
</MemoryRouter>
|
||||
</TestWrapper>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByText(/account created/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -287,8 +287,6 @@ export default function Login() {
|
||||
setPostLoginRedirectPath(returnPath);
|
||||
}
|
||||
|
||||
console.log(`[Login] Signing in with provider: ${provider}`);
|
||||
|
||||
// Redirect to Spring OAuth2 endpoint using the actual provider ID from backend
|
||||
// The backend returns the correct registration ID (e.g., 'authentik', 'oidc', 'keycloak')
|
||||
const { error } = await springAuth.signInWithOAuth({
|
||||
@@ -520,8 +518,6 @@ export default function Login() {
|
||||
setError(null);
|
||||
clearLogoutBlock();
|
||||
|
||||
console.log("[Login] Signing in with email:", email);
|
||||
|
||||
const { user, session, error } = await springAuth.signInWithPassword({
|
||||
email: email.trim(),
|
||||
password: password,
|
||||
@@ -529,13 +525,11 @@ export default function Login() {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("[Login] Email sign in error:", error);
|
||||
setError(error.message);
|
||||
if (error.mfaRequired || error.code === "invalid_mfa_code") {
|
||||
setRequiresMfa(true);
|
||||
}
|
||||
} else if (user && session) {
|
||||
console.log("[Login] Email sign in successful");
|
||||
clearLogoutBlock();
|
||||
setRequiresMfa(false);
|
||||
setMfaCode("");
|
||||
|
||||
Reference in New Issue
Block a user