/**
 * What posthog-js is allowed to put on the wire from a browser.
 *
 * The server sanitizer never sees this traffic. Session replay, `identify` and
 * every manual capture go straight from the shopper's browser to the ingest
 * proxy, and posthog-js attaches its own page metadata to all of them —
 * `$current_url`, `$referrer`, `$pathname`, and the same values again under
 * `$set` / `$set_once` as "initial" properties.
 *
 * `capture_pageview: false` does not help. It stops the automatic *pageview
 * event*; it does not stop the URL metadata riding along on everything else. So
 * a storefront URL carrying a discount code, a search term or a newsletter
 * link's email, or an embedded admin URL carrying `host`, `id_token` and
 * `session`, would reach PostHog in full — against a product rule that says
 * URLs are reduced to origin + pathname everywhere.
 *
 * `before_send` is the supported hook that sees every outgoing event, including
 * `$snapshot`. This is the one place that can enforce the rule in the browser.
 */
import { describe, it, expect } from 'vitest';
import { sanitizeBrowserEvent, reduceUrlValue } from './browserSanitize';

describe('reducing one value', () => {
  it('keeps origin and pathname and drops everything after', () => {
    expect(reduceUrlValue('https://shop.example.com/products/tee?discount=SUMMER&q=blue#frag')).toBe(
      'https://shop.example.com/products/tee'
    );
  });

  it('drops credentials embedded in the authority', () => {
    expect(reduceUrlValue('https://user:secret@shop.example.com/a')).toBe('https://shop.example.com/a');
  });

  it('reduces an embedded admin URL, which is where the tokens are', () => {
    expect(
      reduceUrlValue(
        'https://admin.shopify.com/store/demo/apps/aisthetix?host=YWRtaW4&id_token=eyJhbGciOi&session=abc'
      )
    ).toBe('https://admin.shopify.com/store/demo/apps/aisthetix');
  });

  it('strips a query from a bare pathname too', () => {
    // `$pathname` is normally just a path, but a path with a query is still a
    // query, and the rule is about the value not the property name.
    expect(reduceUrlValue('/products/tee?utm_source=email#x')).toBe('/products/tee');
  });

  it('leaves values that are not URLs alone', () => {
    expect(reduceUrlValue('$direct')).toBe('$direct');
    expect(reduceUrlValue('button_block')).toBe('button_block');
    expect(reduceUrlValue('')).toBe('');
  });

  it('refuses a non-http scheme rather than passing it through', () => {
    // A data: or javascript: URL is not a page address; it is a payload.
    expect(reduceUrlValue('data:text/html;base64,PHNjcmlwdD4=')).toBe('');
    expect(reduceUrlValue('javascript:alert(1)')).toBe('');
  });
});

describe('sanitizing a whole outgoing event', () => {
  it('reduces the URL metadata on an ordinary capture', () => {
    const event = sanitizeBrowserEvent({
      event: 'tryon_modal_opened',
      properties: {
        $current_url: 'https://shop.example.com/products/tee?discount=SUMMER#f',
        $referrer: 'https://mail.example.com/inbox?to=shopper%40example.com',
        $pathname: '/products/tee?discount=SUMMER',
        productId: '555',
      },
    });

    const properties = event!.properties as Record<string, unknown>;
    expect(properties.$current_url).toBe('https://shop.example.com/products/tee');
    expect(properties.$referrer).toBe('https://mail.example.com/inbox');
    expect(properties.$pathname).toBe('/products/tee');
    expect(properties.productId, 'ordinary properties are untouched').toBe('555');
  });

  it('reduces the "initial" copies under $set and $set_once', () => {
    // These are the ones that survive on the person, so a leak here outlives
    // the event that carried it.
    const event = sanitizeBrowserEvent({
      event: '$identify',
      properties: { $current_url: 'https://a.example.com/x?y=1' },
      $set: { $initial_current_url: 'https://a.example.com/x?y=1' },
      $set_once: { $initial_referrer: 'https://b.example.com/z?token=abc' },
    });

    expect((event!.$set as Record<string, unknown>).$initial_current_url).toBe('https://a.example.com/x');
    expect((event!.$set_once as Record<string, unknown>).$initial_referrer).toBe('https://b.example.com/z');
  });

  it('reduces the page address recorded inside a replay snapshot', () => {
    // rrweb's meta event carries the page href. It is the same URL, in a
    // different envelope, and it is the one a replay of the modal would leak.
    const event = sanitizeBrowserEvent({
      event: '$snapshot',
      properties: {
        $session_id: 's1',
        $current_url: 'https://shop.example.com/p?utm=x',
        $snapshot_data: [
          { type: 4, data: { href: 'https://shop.example.com/p?utm=x&email=a%40b.com', width: 400 } },
          { type: 3, data: { source: 2 } },
        ],
      },
    });

    const properties = event!.properties as Record<string, unknown>;
    const snapshot = properties.$snapshot_data as Array<{ type: number; data: Record<string, unknown> }>;
    expect(properties.$current_url).toBe('https://shop.example.com/p');
    expect(snapshot[0].data.href).toBe('https://shop.example.com/p');
    expect(snapshot[0].data.width, 'the rest of the meta event is untouched').toBe(400);
    expect(snapshot[1].data.source, 'and so is the recording payload').toBe(2);
  });

  it('never throws, whatever it is handed', () => {
    expect(() => sanitizeBrowserEvent(null)).not.toThrow();
    expect(() => sanitizeBrowserEvent(undefined as never)).not.toThrow();
    expect(() => sanitizeBrowserEvent({ event: 'x' } as never)).not.toThrow();
    expect(() => sanitizeBrowserEvent({ event: 'x', properties: null } as never)).not.toThrow();
  });

  it('survives a self-referential event without hanging', () => {
    // posthog-js does not produce one, but a `before_send` that can be made to
    // loop is a page that freezes, and a frozen page is a worse outcome than a
    // missing event.
    const properties: Record<string, unknown> = { $current_url: 'https://a.example.com/x?y=1' };
    properties.self = properties;
    expect(() => sanitizeBrowserEvent({ event: 'x', properties })).not.toThrow();
  });

  it('drops the event rather than passing an unsanitizable one through', () => {
    // Fail closed: an event we could not clean is an event we do not send.
    const hostile = {
      event: 'x',
      get properties(): never {
        throw new Error('nope');
      },
    };
    expect(sanitizeBrowserEvent(hostile as never)).toBeNull();
  });
});
