Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions dashboard/src/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
const BASE = "";

/** A 401 from the API: an answer, not a failure to obtain one. Distinct from a
* network or timeout error so callers can tell "not signed in" from "no reply
* yet" and decline to retry the first. */
export class UnauthorizedError extends Error {
constructor() {
super("Unauthorized");
this.name = "UnauthorizedError";
}
}

/** Must match the route in App.tsx. */
const LOGIN_PATH = "/login";

/** Trailing slashes trimmed: the router matches `/login/` to the same route, so
* comparing the raw pathname would send a caller who arrived that way through
* one more reload before the guard below started holding. */
function onLoginPage() {
return window.location.pathname.replace(/\/+$/, "") === LOGIN_PATH;
}

async function request(path: string, opts: any = {}) {
const controller = new AbortController();
const timeoutMs = path === "/api/auth/me" ? 30000 : 10000;
Expand All @@ -13,8 +33,13 @@ async function request(path: string, opts: any = {}) {
});
clearTimeout(timer);
if (res.status === 401) {
window.location.href = "/login";
throw new Error("Unauthorized");
// Not when already on the login page. Assigning the same URL reloads it,
// the reload re-runs this request, and its 401 assigns it again; where
// nothing can mint a session that does not terminate.
if (!onLoginPage()) {
window.location.href = LOGIN_PATH;
}
throw new UnauthorizedError();
}
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
Expand Down
8 changes: 5 additions & 3 deletions dashboard/src/context/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createContext, useContext, useState, useEffect } from "react";
import { api } from "../api/client";
import { api, UnauthorizedError } from "../api/client";

const AuthContext = createContext(null);

Expand All @@ -17,8 +17,10 @@ export function AuthProvider({ children }) {
if (!cancelled) { setUser(u); setLoading(false); }
return;
} catch (e) {
// client.js already redirects to /login on 401 — here we only
// land when there's a network/timeout error (server busy).
// A 401 is settled, so stop: the retries exist for a busy server, and
// repeating an unauthenticated call only holds the login card behind
// a loading state for the length of the backoff.
if (e instanceof UnauthorizedError) break;
if (attempt < 3) {
await new Promise((r) => setTimeout(r, 1500 * (attempt + 1)));
}
Expand Down
79 changes: 79 additions & 0 deletions dashboard/src/test/authUnauthorized.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { api, UnauthorizedError } from "../api/client";
import { AuthProvider, useAuth } from "../context/AuthContext";

/**
* A 401 is an answer, not a failure to get one.
*
* The client used to navigate to /login on every 401. On /login that is a
* same-URL assignment, which reloads; the reload re-runs the auth call, which
* 401s again. Where nothing can mint a session the page never settles, and the
* login card only flashes between reloads.
*/

const realLocation = window.location;

function stubLocation(pathname: string) {
const loc = { pathname, href: `https://dash.retina.fm${pathname}` };
Object.defineProperty(window, "location", { value: loc, writable: true, configurable: true });
return loc;
}

describe("the API client on a 401", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 401 })));
});

afterEach(() => {
vi.unstubAllGlobals();
Object.defineProperty(window, "location", {
value: realLocation,
writable: true,
configurable: true,
});
});

it("sends a caller elsewhere in the app to the login page", async () => {
const loc = stubLocation("/nodes");
await expect(api.me()).rejects.toBeInstanceOf(UnauthorizedError);
expect(loc.href).toBe("/login");
});

it.each(["/login", "/login/"])(
"does not navigate when the caller is already on the login page (%s)",
async (pathname) => {
// Both spellings, because the router matches them to one route and only
// the raw string comparison would tell them apart.
const loc = stubLocation(pathname);
const untouched = loc.href;
await expect(api.me()).rejects.toBeInstanceOf(UnauthorizedError);
expect(loc.href).toBe(untouched);
}
);
});

function Probe() {
const { user, loading } = useAuth();
if (loading) return <span>deciding</span>;
return <span>{user ? "signed in" : "signed out"}</span>;
}

describe("AuthProvider on a 401", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it("settles as signed out rather than retrying", async () => {
// The retries exist for a busy server. Spending them on a 401 leaves the
// login card behind a loading state for ~9s, which reads as a hung page.
vi.spyOn(api, "me").mockRejectedValue(new UnauthorizedError());
render(
<AuthProvider>
<Probe />
</AuthProvider>
);
await waitFor(() => expect(screen.getByText("signed out")).toBeInTheDocument());
expect(api.me).toHaveBeenCalledTimes(1);
});
});
Loading