homeio
PublicForked from doctor-io/homeio
proxy.test.ts
import {
proxy,
resetAuthStatusCacheForTests,
verifySessionTokenInMiddleware,
} from "@/proxy";
import { NextRequest } from "next/server";
import { createHmac } from "node:crypto";
import { beforeEach, describe, expect, it, vi } from "vitest";
function createToken(
sessionId: string,
expiresAtEpochSeconds: number,
secret: string,
) {
const payload = `${sessionId}.${expiresAtEpochSeconds}`;
const signature = createHmac("sha256", secret).update(payload).digest("hex");
return `${payload}.${signature}`;
}
describe("middleware auth guard", () => {
const secret = "test-session-secret-123456";
beforeEach(() => {
vi.restoreAllMocks();
resetAuthStatusCacheForTests();
});
it("verifies a valid token", async () => {
const token = createToken(
"session-1",
Math.floor(Date.now() / 1000) + 3600,
secret,
);
await expect(verifySessionTokenInMiddleware(token, secret)).resolves.toBe(
true,
);
});
it("redirects unauthenticated users to login", async () => {
process.env.AUTH_SESSION_SECRET = secret;
vi.spyOn(global, "fetch").mockResolvedValueOnce(
new Response(JSON.stringify({ data: { hasUsers: true } }), {
status: 200,
headers: {
"Content-Type": "application/json",
},
}),
);
const request = new NextRequest("http://localhost/");
const response = await proxy(request);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toContain("/login");
});
it("redirects unauthenticated users to register when no users exist", async () => {
process.env.AUTH_SESSION_SECRET = secret;
vi.spyOn(global, "fetch").mockResolvedValueOnce(
new Response(JSON.stringify({ data: { hasUsers: false } }), {
status: 200,
headers: {
"Content-Type": "application/json",
},
}),
);
const request = new NextRequest("http://localhost/");
const response = await proxy(request);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toContain("/register");
});
it("allows authenticated users to root path", async () => {
process.env.AUTH_SESSION_SECRET = secret;
vi.spyOn(global, "fetch").mockResolvedValueOnce(
new Response(JSON.stringify({ data: { hasUsers: true } }), {
status: 200,
headers: {
"Content-Type": "application/json",
},
}),
);
const token = createToken(
"session-1",
Math.floor(Date.now() / 1000) + 3600,
secret,
);
const request = new NextRequest("http://localhost/", {
headers: {
cookie: `homeio_session=${token}`,
},
});
const response = await proxy(request);
expect(response.status).toBe(200);
});
it("redirects stale authenticated users to register when no users exist", async () => {
process.env.AUTH_SESSION_SECRET = secret;
vi.spyOn(global, "fetch").mockResolvedValueOnce(
new Response(JSON.stringify({ data: { hasUsers: false } }), {
status: 200,
headers: {
"Content-Type": "application/json",
},
}),
);
const token = createToken(
"session-1",
Math.floor(Date.now() / 1000) + 3600,
secret,
);
const request = new NextRequest("http://localhost/", {
headers: {
cookie: `homeio_session=${token}`,
},
});
const response = await proxy(request);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toContain("/register");
expect(response.cookies.get("homeio_session")?.value).toBe("");
});
it("allows recovery route access without redirecting authenticated users", async () => {
process.env.AUTH_SESSION_SECRET = secret;
const token = createToken(
"session-1",
Math.floor(Date.now() / 1000) + 3600,
secret,
);
const request = new NextRequest("http://localhost/updating", {
headers: {
cookie: `homeio_session=${token}`,
},
});
const response = await proxy(request);
expect(response.status).toBe(200);
});
it("returns auth entry hint for unauthenticated /api/auth/me when users exist", async () => {
process.env.AUTH_SESSION_SECRET = secret;
vi.spyOn(global, "fetch").mockResolvedValueOnce(
new Response(JSON.stringify({ data: { hasUsers: true } }), {
status: 200,
headers: {
"Content-Type": "application/json",
},
}),
);
const request = new NextRequest("http://localhost/api/auth/me");
const response = await proxy(request);
expect(response.status).toBe(401);
expect(response.headers.get("x-auth-entry")).toBe("/login");
});
it("returns auth entry hint for unauthenticated /api/auth/me when no users exist", async () => {
process.env.AUTH_SESSION_SECRET = secret;
vi.spyOn(global, "fetch").mockResolvedValueOnce(
new Response(JSON.stringify({ data: { hasUsers: false } }), {
status: 200,
headers: {
"Content-Type": "application/json",
},
}),
);
const request = new NextRequest("http://localhost/api/auth/me");
const response = await proxy(request);
expect(response.status).toBe(401);
expect(response.headers.get("x-auth-entry")).toBe("/register");
});
it("shares one auth status lookup between visitors arriving together", async () => {
const fetchMock = vi.spyOn(global, "fetch").mockImplementation(
async () =>
new Response(JSON.stringify({ data: { hasUsers: true } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
await Promise.all(Array.from({ length: 5 }, () => proxy(new NextRequest("http://localhost/"))));
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("remembers that accounts exist for a minute, but that none exist only briefly", async () => {
const now = vi.spyOn(Date, "now").mockReturnValue(1_000_000);
const answer = (hasUsers: boolean) =>
new Response(JSON.stringify({ data: { hasUsers } }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
const fetchMock = vi
.spyOn(global, "fetch")
.mockResolvedValueOnce(answer(false))
.mockResolvedValueOnce(answer(true))
.mockResolvedValueOnce(answer(true));
await proxy(new NextRequest("http://localhost/"));
// The first account may be registered any moment: "none" is re-checked soon.
now.mockReturnValue(1_000_000 + 5_000);
await proxy(new NextRequest("http://localhost/"));
expect(fetchMock).toHaveBeenCalledTimes(2);
now.mockReturnValue(1_000_000 + 5_000 + 59_000);
await proxy(new NextRequest("http://localhost/"));
expect(fetchMock).toHaveBeenCalledTimes(2);
now.mockReturnValue(1_000_000 + 5_000 + 60_000);
await proxy(new NextRequest("http://localhost/"));
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it("sends you to login, not register, when the auth status lookup fails", async () => {
// This used to answer /register. Not knowing whether accounts exist is not
// the same as knowing there are none, and treating it as "fresh install"
// offers a running machine up for registration and throws away the session
// of anyone signed in. One redirect to /login is the cheaper wrong answer.
process.env.AUTH_SESSION_SECRET = secret;
vi.spyOn(global, "fetch").mockRejectedValueOnce(new Error("network failure"));
const request = new NextRequest("http://localhost/");
const response = await proxy(request);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toContain("/login");
expect(response.headers.get("location")).not.toContain("/register");
});
it("forbids a shared cache from keeping any answer it gives", async () => {
// A tunnel or CDN in front that caches redirects serves one visitor's
// answer to everybody: an install that was briefly empty answers
// 307 -> /register, and once that is cached every later visitor is sent to
// registration however many accounts exist. Incognito and a second device
// do not help, because the cache sits upstream of both.
process.env.AUTH_SESSION_SECRET = secret;
vi.spyOn(global, "fetch").mockResolvedValueOnce(
new Response(JSON.stringify({ data: { hasUsers: true } }), {
headers: { "Content-Type": "application/json" },
}),
);
const response = await proxy(new NextRequest("http://localhost/"));
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
});
it("leaves immutable assets cacheable", async () => {
process.env.AUTH_SESSION_SECRET = secret;
const response = await proxy(
new NextRequest("http://localhost/_next/static/chunks/main.js"),
);
expect(response.headers.get("Cache-Control")).toBeNull();
});
it("allows unauthenticated calls to the TOTP login route through", async () => {
// Second leg of TOTP login: the caller has no session cookie yet, only a
// short-lived partial-auth token. The proxy must NOT short-circuit with
// a 401 — the route handler validates the token itself.
process.env.AUTH_SESSION_SECRET = secret;
const request = new NextRequest(
"http://localhost/api/v1/auth/login/totp",
{ method: "POST" },
);
const response = await proxy(request);
expect(response.status).toBe(200);
});
});