Ctrl K
ComunidadGuías…

tests/integration/login-rate-limit.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { test } from "bun:test";
import assert from "node:assert/strict";
import { newOperatorAccount } from "../../src/core/accounts";
import { createApp } from "../../src/core/app";
import { organizationStatement } from "../../src/core/auth";
import { migrate } from "../../src/core/migrations";
import { createSqliteDb, openDatabase } from "../../src/runtime/bun/sqlite";

test("the edge guard rejects a login before account lookup and password verification", async () => {
  const handle = openDatabase(":memory:");
  const db = createSqliteDb(handle);
  try {
    await migrate(db);
    const inputs: Array<{ identityHash: string; request: Request }> = [];
    const app = createApp({
      db,
      runtime: "test",
      loginRateLimit: {
        async limit(input) {
          inputs.push(input);
          return { success: false };
        },
      },
    });
    const response = await app(new Request("http://titen.test/v1/dashboard-sessions", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ username: "EDGE-OWNER", password: "incorrect horse battery staple" }),
    }));
    assert.equal(response.status, 429);
    assert.equal((await response.json() as any).error.code, "LOGIN_RATE_LIMITED");
    assert.equal(inputs.length, 1);
    assert.match(inputs[0]!.identityHash, /^[a-f0-9]{64}$/);
    assert.notEqual(inputs[0]!.identityHash, "edge-owner");
    assert.equal(inputs[0]!.request.headers.get("content-type"), "application/json");
    const rows = await db.all<{ failures: number }>("SELECT failures FROM login_throttles");
    assert.deepEqual(rows, [], "an edge denial must not create canonical failure state");
  } finally {
    handle.close();
  }
});

test("a progressive account throttle survives app reconstruction and clears after success", async () => {
  const handle = openDatabase(":memory:");
  const db = createSqliteDb(handle);
  let now = new Date("2026-08-30T00:00:00.000Z");
  try {
    await migrate(db);
    const account = await newOperatorAccount({
      orgId: "org_persistent_throttle",
      createdBy: "owner_persistent_throttle",
      username: "persistent-owner",
      role: "owner",
      scopes: ["*"],
      maxTrust: "policy_approved",
      now,
    });
    await db.batch([
      organizationStatement("org_persistent_throttle", "Persistent Throttle Test", now),
      ...account.statements,
    ]);
    const login = async (app: ReturnType<typeof createApp>, password: string) => {
      const response = await app(new Request("http://titen.test/v1/dashboard-sessions", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ username: "persistent-owner", password }),
      }));
      return { response, body: await response.json() as any };
    };
    let app = createApp({ db, runtime: "test", now: () => now });
    for (let attempt = 0; attempt < 5; attempt += 1) {
      const failure = await login(app, "incorrect horse battery staple");
      assert.equal(failure.response.status, 401);
      assert.equal(failure.body.error.code, "INVALID_LOGIN");
    }
    const [stored] = await db.all<{
      identity_hash: string;
      failures: number;
      blocked_until_ms: number;
    }>("SELECT identity_hash, failures, blocked_until_ms FROM login_throttles WHERE identity_hash LIKE '%.%'");
    assert.match(stored!.identity_hash, /^[a-f0-9]{64}\.[a-f0-9]{64}$/);
    assert.equal(stored!.failures, 5);
    assert.equal(stored!.blocked_until_ms, now.getTime() + 30_000);

    app = createApp({ db, runtime: "test", now: () => now });
    const blocked = await login(app, account.temporaryPassword);
    assert.equal(blocked.response.status, 401);
    assert.equal(blocked.body.error.code, "INVALID_LOGIN");
    assert.equal(blocked.body.error.message, "Username or password is invalid.");

    now = new Date(now.getTime() + 30_000);
    const success = await login(app, account.temporaryPassword);
    assert.equal(success.response.status, 201);
    assert.equal(success.body.data.password_change_required, true);
    assert.deepEqual(await db.all("SELECT * FROM login_throttles"), []);
  } finally {
    handle.close();
  }
});


test("concurrent password admission rejects work beyond the configured limit", async () => {
  const handle = openDatabase(":memory:");
  const db = createSqliteDb(handle);
  try {
    await migrate(db);
    const app = createApp({ db, runtime: "test", passwordCheckLimit: 1 });
    const attempts = await Promise.all(Array.from({ length: 8 }, (_, index) => app(new Request("http://titen.test/v1/dashboard-sessions", {
      method: "POST", headers: { "content-type": "application/json" },
      body: JSON.stringify({ username: `concurrent-${index}`, password: "incorrect horse battery staple" }),
    }))));
    assert.equal(attempts.filter((response) => response.status === 401).length, 1);
    assert.equal(attempts.filter((response) => response.status === 429).length, 7);
  } finally { handle.close(); }
});

test("a distinct-username flood from many clients does not lock out a correct sign-in", async () => {
  const handle = openDatabase(":memory:");
  const db = createSqliteDb(handle);
  const now = new Date("2026-11-03T00:00:00.000Z");
  try {
    await migrate(db);
    const account = await newOperatorAccount({ orgId: "org_throttle_flood", createdBy: "owner_throttle_flood",
      username: "real-operator", role: "owner", scopes: ["*"], maxTrust: "asserted", now });
    await db.batch([organizationStatement("org_throttle_flood", "Throttle Flood Test", now), ...account.statements]);
    const app = createApp({ db, runtime: "test", now: () => now, loginClient: (request) => request.headers.get("test-client") ?? "unknown" });
    const attempt = (username: string, password: string, client: string) => app(new Request("http://titen.test/v1/dashboard-sessions", {
      method: "POST", headers: { "content-type": "application/json", "test-client": client },
      body: JSON.stringify({ username, password }),
    }));
    let next = 0;
    await Promise.all(Array.from({ length: 32 }, async () => {
      while (next < 2100) {
        const i = next++;
        await attempt(`ghost-${i}`, "not-the-password-123", `2001:db8::${Math.floor(i / 10).toString(16)}`);
      }
    }));
    const [stored] = await db.all<{ count: number }>("SELECT COUNT(*) AS count FROM login_throttles");
    assert.ok(stored!.count <= 4096);
    assert.equal((await attempt("real-operator", account.temporaryPassword, "203.0.113.7")).status, 201);
  } finally { handle.close(); }
}, 60_000);

test("the Bun client limiter evicts the oldest client when full", async () => {
  const { createClientLoginLimit } = await import("../../src/runtime/bun/login-rate-limit");
  const limiter = createClientLoginLimit((request) => request.headers.get("test-client")!, () => 0);
  const hit = (client: string) => limiter.limit({ request: new Request("http://titen.test", { headers: { "test-client": client } }) });
  for (let i = 0; i < 4096; i++) assert.equal((await hit(`client-${i}`)).success, true);
  assert.equal((await hit("client-new")).success, true, "a full limiter admits a new client");
  for (let i = 0; i < 19; i++) assert.equal((await hit("client-new")).success, true);
  assert.equal((await hit("client-new")).success, false, "the per-client limit still applies");
  assert.equal((await hit("client-0")).success, true, "the evicted oldest client starts a new bucket");
});

test("raw paths reject dot segments and encoded separators before routing", async () => {
  const handle = openDatabase(":memory:");
  try {
    const app = createApp({ db: createSqliteDb(handle), runtime: "test" });
    for (const path of ["/v1/../healthz", "/./healthz", "/v1%2fhealthz", "/v1%252fhealthz", "/v1%5chealthz"]) {
      const request = new Request("http://titen.test/healthz");
      Object.defineProperty(request, "url", { value: `http://titen.test${path}` });
      assert.equal((await app(request)).status, 400, path);
    }
  } finally { handle.close(); }
});

test("the Bun listener limits a client before password work", async () => {
  const { serve } = await import("../../src/runtime/bun/server");
  const running = await serve({ dbPath: ":memory:", hostname: "127.0.0.1", port: 0, maintenanceIntervalMs: 0, quiet: true,
    loginClientIpHeader: "x-test-client-ip" });
  try {
    for (let i = 0; i < 20; i++) {
      const response = await fetch(`${running.url}/v1/dashboard-sessions`, { method: "POST", headers: { "content-type": "application/json", "x-test-client-ip": "192.0.2.1" }, body: JSON.stringify({ username: `rate-${i}`, password: "incorrect horse battery staple" }) });
      assert.equal(response.status, 401);
    }
    const attempt = (ip: string) => fetch(`${running.url}/v1/dashboard-sessions`, { method: "POST", headers: { "content-type": "application/json", "x-test-client-ip": ip }, body: JSON.stringify({ username: "rate-other", password: "incorrect horse battery staple" }) });
    assert.equal((await attempt("192.0.2.1")).status, 429);
    assert.equal((await attempt("192.0.2.2")).status, 401);
  } finally { await running.stop(); }
});


test("the Bun listener rejects raw dot segments and encoded slash paths", async () => {
  const { serve } = await import("../../src/runtime/bun/server");
  const { createConnection } = await import("node:net");
  const running = await serve({ dbPath: ":memory:", hostname: "127.0.0.1", port: 0, maintenanceIntervalMs: 0, quiet: true });
  try {
    for (const path of ["/v1/../healthz", "/./healthz", "/v1%2fhealthz", "/%252e%252e/healthz"]) {
      const status = await new Promise<number>((resolve, reject) => {
        const url = new URL(running.url);
        const socket = createConnection({ host: url.hostname, port: Number(url.port) });
        let response = "";
        socket.on("connect", () => socket.write(`GET ${path} HTTP/1.1\r\nHost: ${url.host}\r\nConnection: close\r\n\r\n`));
        socket.on("data", (chunk) => { response += chunk.toString(); });
        socket.on("end", () => resolve(Number(response.match(/^HTTP\/1.1 (\d+)/)?.[1])));
        socket.on("error", reject);
      });
      assert.equal(status, 400, path);
    }
  } finally { await running.stop(); }
});

test("untrusted client headers cannot replace direct socket identity", async () => {
  const { loginClientAddress } = await import("../../src/runtime/bun/login-rate-limit");
  const request = new Request("http://test", { headers: { "x-client-ip": "192.0.2.1" } });
  assert.equal(loginClientAddress("198.51.100.1", request, "x-client-ip"), "198.51.100.1");
  assert.equal(loginClientAddress("127.0.0.1", request), "127.0.0.1");
  assert.equal(loginClientAddress("127.0.0.1", request, "x-client-ip"), "192.0.2.1");
});