Ctrl K
ComunidadGuías…

proxy.test.ts

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

Archivo original ↗Preguntar a KiBot
import { NextRequest } from 'next/server';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { proxy } from './proxy';
import { generalRateLimiter } from '@/utils/rateLimit';
import { localeCookieName } from '@/i18n/config';

const BASE = 'http://localhost:3000';

interface FetchState {
  session?: { ok?: boolean; user: object | null; sessionExpiresAt?: string };
  admin?: { ok?: boolean; initialized: boolean };
  register?: { ok?: boolean; registration: boolean };
}

function stubFetch({ session, admin, register }: FetchState = {}) {
  const fetchMock = vi.fn(async (url: string) => {
    const reply = (state: { ok?: boolean } | undefined) =>
      ({ ok: state?.ok ?? true, json: async () => state }) as Response;
    if (url.includes('/auth/session')) return reply(session ?? { user: { id: 1 } });
    if (url.includes('/auth/admin')) return reply(admin ?? { initialized: true });
    return reply(register ?? { registration: true });
  });
  vi.stubGlobal('fetch', fetchMock);
  return fetchMock;
}

let ipCounter = 0;

function makeRequest(
  path: string,
  { method = 'GET', headers = {} }: { method?: string; headers?: Record<string, string> } = {},
) {
  return new NextRequest(`${BASE}${path}`, {
    method,
    headers: {
      'x-forwarded-for': `10.0.0.${++ipCounter}`,
      cookie: `${localeCookieName}=en; session=tok`,
      ...headers,
    },
  });
}

function location(response: Response) {
  const { pathname, search } = new URL(response.headers.get('location') ?? '');
  return pathname + search;
}

describe('proxy', () => {
  beforeEach(() => {
    delete process.env.INTERNAL_APP_URL;
  });

  afterEach(() => {
    vi.unstubAllGlobals();
    vi.restoreAllMocks();
  });

  it('returns 429 when the rate limit is exceeded', async () => {
    stubFetch();
    vi.spyOn(generalRateLimiter, 'isAllowed').mockReturnValue({
      allowed: false,
      resetTime: Date.now() + 1000,
      remaining: 0,
    });

    const response = await proxy(makeRequest('/'));

    expect(response.status).toBe(429);
    expect(response.headers.get('Content-Type')).toBe('text/plain');
  });

  it('sets the locale cookie when missing or different from the resolved one', async () => {
    stubFetch();
    const response = await proxy(
      makeRequest('/', { headers: { cookie: 'session=tok', 'accept-language': 'es' } }),
    );

    expect(response.cookies.get(localeCookieName)?.value).toBe('es');
  });

  it.each(['/_next/x.js', '/api/v1/auth/session', '/api/v1/locale', '/static/a', '/favicon.ico', '/logo.png'])(
    'lets %s through without auth checks',
    async (path) => {
      const fetchMock = stubFetch();
      const response = await proxy(makeRequest(path));

      expect(response.status).toBe(200);
      expect(fetchMock).not.toHaveBeenCalled();
    },
  );

  describe('CORS on non-GET requests', () => {
    it.each([
      ['no Origin', { host: 'localhost:3000' }],
      ['no Host', { origin: BASE }],
      ['invalid Origin', { origin: 'not a url', host: 'localhost:3000' }],
      ['foreign Origin', { origin: 'https://evil.test', host: 'localhost:3000' }],
    ])('rejects %s with 403', async (_label, headers) => {
      stubFetch();
      const request = new NextRequest(`${BASE}/boards`, {
        method: 'POST',
        headers: { 'x-forwarded-for': `10.1.0.${++ipCounter}`, ...headers },
      });

      expect((await proxy(request)).status).toBe(403);
    });

    it('adds CORS headers for an allowed origin', async () => {
      stubFetch();
      const response = await proxy(
        makeRequest('/api/v1/auth/session', {
          method: 'POST',
          headers: { origin: BASE, host: 'localhost:3000' },
        }),
      );

      expect(response.headers.get('Access-Control-Allow-Origin')).toBe(BASE);
      expect(response.headers.get('Access-Control-Allow-Methods')).toContain('POST');
    });

    it('accepts a same-host origin that is not in the allowlist', async () => {
      stubFetch();
      const response = await proxy(
        makeRequest('/api/v1/auth/session', {
          method: 'POST',
          headers: { origin: 'https://app.test', host: 'app.test' },
        }),
      );

      expect(response.status).toBe(200);
      expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull();
    });
  });

  describe('authentication', () => {
    it('redirects to /login with callbackUrl when the session is invalid', async () => {
      stubFetch({ session: { user: null } });
      const response = await proxy(makeRequest('/boards'));

      expect(location(response)).toBe('/login?callbackUrl=%2Fboards');
    });

    it('redirects when the session endpoint fails', async () => {
      stubFetch({ session: { ok: false, user: null } });
      const response = await proxy(makeRequest('/boards'));

      expect(location(response)).toBe('/login?callbackUrl=%2Fboards');
    });

    it('uses INTERNAL_APP_URL and refreshes the session cookie', async () => {
      process.env.INTERNAL_APP_URL = 'http://internal:3000';
      const fetchMock = stubFetch({
        session: { user: { id: 1 }, sessionExpiresAt: '2030-01-01T00:00:00.000Z' },
      });
      const response = await proxy(makeRequest('/boards'));

      expect(fetchMock.mock.calls[0][0]).toBe('http://internal:3000/api/v1/auth/session?token=tok');
      expect(response.cookies.get('session')?.value).toBe('tok');
    });

    it('skips the session check for public paths', async () => {
      const fetchMock = stubFetch();
      await proxy(makeRequest('/login/reset/abc'));

      expect(fetchMock.mock.calls.some(([url]) => url.includes('/auth/session'))).toBe(false);
    });
  });

  describe('admin initialization', () => {
    it('redirects to /register/init when no admin exists', async () => {
      stubFetch({ admin: { initialized: false } });
      const response = await proxy(makeRequest('/login'));

      expect(location(response)).toBe('/register/init');
    });

    it('redirects /register/init to /login once initialized', async () => {
      stubFetch({ admin: { initialized: true } });
      const response = await proxy(makeRequest('/register/init'));

      expect(location(response)).toBe('/login');
    });

    it('lets /register/init through while no admin exists', async () => {
      stubFetch({ admin: { initialized: false } });
      expect((await proxy(makeRequest('/register/init'))).status).toBe(200);
    });

    it.each(['/register/init', '/register/terms', '/boards'])(
      'falls through when the admin endpoint fails on %s',
      async (path) => {
        stubFetch({ admin: { ok: false, initialized: false } });
        expect((await proxy(makeRequest(path))).status).toBe(200);
      },
    );
  });

  describe('/login', () => {
    it('skips the session lookup without a usable token', async () => {
      const fetchMock = stubFetch();
      const response = await proxy(
        makeRequest('/login', { headers: { cookie: `${localeCookieName}=en; session=undefined` } }),
      );

      expect(response.status).toBe(200);
      expect(fetchMock).toHaveBeenCalledTimes(1); // admin check only
    });

    it('redirects logged-in users to a safe callbackUrl', async () => {
      stubFetch();
      const response = await proxy(makeRequest('/login?callbackUrl=/notes'));

      expect(location(response)).toBe('/notes');
    });

    it('ignores external callbackUrl values', async () => {
      stubFetch();
      const response = await proxy(makeRequest('/login?callbackUrl=https://evil.test'));

      expect(location(response)).toBe('/');
    });

    it('stays on /login when the session is not valid', async () => {
      stubFetch({ session: { user: null } });
      expect((await proxy(makeRequest('/login'))).status).toBe(200);
    });

    it('stays on /login when the session endpoint fails', async () => {
      stubFetch({ session: { ok: false, user: null } });
      expect((await proxy(makeRequest('/login'))).status).toBe(200);
    });
  });

  describe('/register', () => {
    it('allows access when registration is enabled', async () => {
      stubFetch({ register: { registration: true } });
      expect((await proxy(makeRequest('/register'))).status).toBe(200);
    });

    it('redirects to /login when registration is disabled', async () => {
      stubFetch({ register: { registration: false } });
      const response = await proxy(makeRequest('/register'));

      expect(location(response)).toBe('/login?callbackUrl=%2Fregister');
    });

    it('allows access when the registration endpoint fails', async () => {
      stubFetch({ register: { ok: false, registration: false } });
      expect((await proxy(makeRequest('/register'))).status).toBe(200);
    });
  });

  it('allows authenticated requests to protected pages', async () => {
    stubFetch();
    expect((await proxy(makeRequest('/boards'))).status).toBe(200);
  });
});