IKHSAN
ENID Baca dalam Bahasa Indonesia

homeio

Public

Forked from doctor-io/homeio

Branch
main
Latest commit
958d74616 October 2026

proxy.test.ts

Open raw
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);
  });
});
bytes
9,512
lines
291
Type
ts
Latest commit
16 October 2026