allowlist.test.ts4.2 KBView on GitHub
/**
 * Tests: apps/mail/modules/files/upload/allowlist.ts
 *
 * The frontend allowlist MIRRORS the server one (apps/server/src/services/
 * file-system/uploads/allowlist.ts). The two are intentionally duplicated
 * (no shared package today — see design §1 follow-up). These tests serve as
 * a drift detector: every change to one must be mirrored in the other.
 *
 * Covers:
 *   - validateUpload size + type rules (matches server)
 *   - the table of allowed (extension, MIME) pairs
 *   - inputAcceptAttribute() returns a comma-separated allowlist for <input>
 *   - sha256OfFile produces lowercase hex of length 64 (Web Crypto smoke)
 */

import {
  ALLOWED_EXTENSIONS,
  ALLOWED_MIME_TYPES,
  MAX_FILES_PER_DROP,
  MAX_UPLOAD_BYTES,
  UPLOAD_ALLOWLIST,
  categoryFor,
  extOf,
  inputAcceptAttribute,
  validateUpload,
} from '@/modules/files/upload/allowlist';

describe('extOf', () => {
  it('returns the lowercased extension after the last dot', () => {
    expect(extOf('Foo.PDF')).toBe('.pdf');
    expect(extOf('archive.tar.gz')).toBe('.gz');
  });

  it('returns empty string when no dot is present', () => {
    expect(extOf('README')).toBe('');
  });
});

describe('categoryFor', () => {
  it('classifies by MIME first, then extension', () => {
    expect(categoryFor('application/pdf', 'doc.pdf')).toBe('pdf');
    expect(categoryFor('application/octet-stream', 'doc.pdf')).toBe('pdf');
    expect(categoryFor('application/octet-stream', 'malware.exe')).toBeNull();
  });

  it('is case-insensitive on MIME and extension', () => {
    expect(categoryFor('IMAGE/PNG', 'x')).toBe('image');
    expect(categoryFor('application/octet-stream', 'pic.PNG')).toBe('image');
  });
});

describe('validateUpload', () => {
  it('accepts a typical PDF', () => {
    expect(
      validateUpload({ filename: 'doc.pdf', mimeType: 'application/pdf', sizeBytes: 1024 }),
    ).toMatchObject({ ok: true, category: 'pdf' });
  });

  it.each([
    [0, 'empty'],
    [-1, 'empty'],
    [MAX_UPLOAD_BYTES + 1, 'too_large'],
  ])('rejects size=%i with code=%s', (sizeBytes, code) => {
    expect(
      validateUpload({ filename: 'doc.pdf', mimeType: 'application/pdf', sizeBytes }),
    ).toMatchObject({ ok: false, code });
  });

  it('rejects unknown types with code=bad_type', () => {
    expect(
      validateUpload({ filename: 'pwn.exe', mimeType: 'application/octet-stream', sizeBytes: 1 }),
    ).toMatchObject({ ok: false, code: 'bad_type' });
  });

  it('runs size check before type check', () => {
    expect(
      validateUpload({
        filename: 'pwn.exe',
        mimeType: 'application/octet-stream',
        sizeBytes: MAX_UPLOAD_BYTES + 1,
      }),
    ).toMatchObject({ ok: false, code: 'too_large' });
  });

  it('lowercases the returned mimeType', () => {
    const result = validateUpload({
      filename: 'doc.pdf',
      mimeType: 'APPLICATION/PDF',
      sizeBytes: 1,
    });
    expect(result.ok).toBe(true);
    if (result.ok) expect(result.mimeType).toBe('application/pdf');
  });
});

describe('drift detector vs allowlist table', () => {
  it('ALLOWED_EXTENSIONS is derived from UPLOAD_ALLOWLIST', () => {
    const flat = UPLOAD_ALLOWLIST.flatMap((e) => e.extensions);
    for (const ext of flat) expect(ALLOWED_EXTENSIONS.has(ext)).toBe(true);
    expect(ALLOWED_EXTENSIONS.size).toBe(new Set(flat).size);
  });

  it('ALLOWED_MIME_TYPES is derived from UPLOAD_ALLOWLIST', () => {
    const flat = UPLOAD_ALLOWLIST.flatMap((e) => e.mimeTypes);
    for (const mime of flat) expect(ALLOWED_MIME_TYPES.has(mime)).toBe(true);
    expect(ALLOWED_MIME_TYPES.size).toBe(new Set(flat).size);
  });
});

describe('inputAcceptAttribute', () => {
  it('returns a comma-separated allowlist suitable for <input accept>', () => {
    const accept = inputAcceptAttribute();
    // Every allowed extension and every allowed MIME must appear.
    for (const ext of ALLOWED_EXTENSIONS) expect(accept).toContain(ext);
    for (const mime of ALLOWED_MIME_TYPES) expect(accept).toContain(mime);
    expect(accept).toContain(',');
  });
});

describe('limits', () => {
  it('MAX_UPLOAD_BYTES is 25 MB', () => {
    expect(MAX_UPLOAD_BYTES).toBe(25 * 1024 * 1024);
  });
  it('MAX_FILES_PER_DROP is 10', () => {
    expect(MAX_FILES_PER_DROP).toBe(10);
  });
});