/** * Tests for input validation: body size limits, username/password limits, * tag name validation, and LIKE search wildcard escaping. */ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { BASE, ADMIN_PASS, setupTestEnv, spawnServer, killServer, seedRepo, } from './helpers.ts'; // The server runs out of process, so its limits cannot be imported. These // mirror the defaults in internal/config/config.go; the tests never override // the matching env vars. const config = { MAX_TITLE_BYTES: 500, MAX_TEXT_BODY_BYTES: 100_000, MAX_USERNAME_BYTES: 64, MAX_PASSWORD_BYTES: 1024, }; let server: Awaited>; let sessionCookie = ''; let issueUrl = ''; async function adminLogin(): Promise { const res = await fetch(`${BASE}/login`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ username: 'admin', password: ADMIN_PASS }), redirect: 'manual', }); const raw = res.headers.get('set-cookie') ?? ''; return raw.split(';')[0]!; // "session=" } async function post(path: string, body: Record): Promise { return fetch(`${BASE}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: sessionCookie, }, body: new URLSearchParams(body), redirect: 'manual', }); } beforeAll(async () => { await setupTestEnv(); server = await spawnServer(); sessionCookie = await adminLogin(); // Create a repo and seed it so issues/patches can be submitted const res = await post('/new', { name: 'val-repo' }); expect(res.status).toBe(302); await seedRepo('val-repo'); // Create a baseline issue so we have an issue URL for comment tests const issueRes = await post('/val-repo/issues', { title: 'Baseline issue', body: 'ok' }); expect(issueRes.status).toBe(302); issueUrl = issueRes.headers.get('location') ?? '/val-repo/issues/1'; }); afterAll(async () => { await killServer(server); }); // ─── Body size limits ───────────────────────────────────────────────────────── describe('body size limits', () => { test('issue body at limit is accepted', async () => { const res = await post('/val-repo/issues', { title: 'Body at limit', body: 'x'.repeat(config.MAX_TEXT_BODY_BYTES), }); expect(res.status).toBe(302); }); test('issue body over limit is rejected', async () => { const res = await post('/val-repo/issues', { title: 'Body over limit', body: 'x'.repeat(config.MAX_TEXT_BODY_BYTES + 1), }); expect(res.status).toBe(422); }); test('issue title at limit is accepted', async () => { const res = await post('/val-repo/issues', { title: 'x'.repeat(config.MAX_TITLE_BYTES), body: 'ok', }); expect(res.status).toBe(302); }); test('issue title over limit is rejected', async () => { const res = await post('/val-repo/issues', { title: 'x'.repeat(config.MAX_TITLE_BYTES + 1), body: 'ok', }); expect(res.status).toBe(422); }); test('issue comment body at limit is accepted', async () => { const res = await post(`${issueUrl}/comments`, { body: 'x'.repeat(config.MAX_TEXT_BODY_BYTES), }); expect(res.status).toBe(302); }); test('issue comment body over limit is rejected', async () => { const res = await post(`${issueUrl}/comments`, { body: 'x'.repeat(config.MAX_TEXT_BODY_BYTES + 1), }); expect(res.status).toBe(422); }); test('patch description at limit is accepted', async () => { const res = await post('/val-repo/patches', { title: 'Patch ok', description: 'x'.repeat(config.MAX_TEXT_BODY_BYTES), }); // No patch_file provided → will fail business logic, but schema passes → 302 or 200, not 422 expect(res.status).not.toBe(422); }); test('patch description over limit is rejected', async () => { const res = await post('/val-repo/patches', { title: 'Patch bad', description: 'x'.repeat(config.MAX_TEXT_BODY_BYTES + 1), }); expect(res.status).toBe(422); }); test('patch title over limit is rejected', async () => { const res = await post('/val-repo/patches', { title: 'x'.repeat(config.MAX_TITLE_BYTES + 1), }); expect(res.status).toBe(422); }); }); // ─── Auth limits ────────────────────────────────────────────────────────────── describe('auth limits', () => { test('username over limit is rejected at registration', async () => { const res = await fetch(`${BASE}/register`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ username: 'u'.repeat(config.MAX_USERNAME_BYTES + 1), password: 'validpass1', password2: 'validpass1', }), redirect: 'manual', }); expect(res.status).toBe(422); }); test('username at limit is not schema-rejected', async () => { // A username at exactly the limit passes schema (may fail business logic due to uniqueness/format) const res = await fetch(`${BASE}/register`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ username: 'a'.repeat(config.MAX_USERNAME_BYTES), password: 'validpass1', password2: 'validpass1', }), redirect: 'manual', }); // 302 (registered) or 200 (form error like invalid chars), but not 422 expect(res.status).not.toBe(422); }); test('password over limit is rejected at registration', async () => { const res = await fetch(`${BASE}/register`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ username: 'newuser', password: 'p'.repeat(config.MAX_PASSWORD_BYTES + 1), password2: 'p'.repeat(config.MAX_PASSWORD_BYTES + 1), }), redirect: 'manual', }); expect(res.status).toBe(422); }); test('new_password over limit is rejected at settings/password', async () => { const res = await post('/settings/password', { current_password: ADMIN_PASS, new_password: 'p'.repeat(config.MAX_PASSWORD_BYTES + 1), confirm_password: 'p'.repeat(config.MAX_PASSWORD_BYTES + 1), }); expect(res.status).toBe(422); }); }); // ─── Tag name validation ────────────────────────────────────────────────────── describe('tag name validation', () => { const validTags = ['v1.0.0', 'release-2', '1.0+build.1', 'v1_alpha']; const invalidTags = ['v1.0~1', 'tag with space', 'v1:2', 'v1^2', 'ref/head', 'v1?', 'v1*']; for (const tag of validTags) { test(`valid tag "${tag}" is accepted`, async () => { const res = await post('/val-repo/releases', { create_tag: 'on', tag_name: tag, revision: 'main', name: `Release ${tag}`, }); // 302 = success redirect, or 200 = form with error (e.g. tag already exists) — either is fine // What's NOT acceptable is a 422 schema error expect(res.status).not.toBe(422); }); } for (const tag of invalidTags) { test(`invalid tag "${tag}" is rejected`, async () => { const res = await post('/val-repo/releases', { create_tag: 'on', tag_name: tag, revision: 'main', name: `Release ${tag}`, }); // Should get a 200 with an inline form error (business-logic validation) expect(res.status).toBe(200); const body = await res.text(); expect(body).toContain('may only contain'); }); } }); // ─── LIKE wildcard escaping in repo search ──────────────────────────────────── describe('repo search LIKE escaping', () => { beforeAll(async () => { // Create repos with and without underscore/special chars to verify search behavior await post('/new', { name: 'search-under_score' }); await post('/new', { name: 'search-nodash' }); }); test('search for "_" returns only repos with literal underscore', async () => { const res = await fetch(`${BASE}/?q=${encodeURIComponent('_')}`, { headers: { Cookie: sessionCookie }, }); const body = await res.text(); expect(body).toContain('search-under_score'); expect(body).not.toContain('search-nodash'); expect(body).not.toContain('val-repo'); }); test('search for "%" returns no repos (no repo has literal % in name)', async () => { const res = await fetch(`${BASE}/?q=${encodeURIComponent('%')}`, { headers: { Cookie: sessionCookie }, }); const body = await res.text(); expect(body).not.toContain('search-under_score'); expect(body).not.toContain('search-nodash'); expect(body).not.toContain('val-repo'); }); test('normal substring search still works', async () => { const res = await fetch(`${BASE}/?q=search-under`, { headers: { Cookie: sessionCookie }, }); const body = await res.text(); expect(body).toContain('search-under_score'); expect(body).not.toContain('search-nodash'); }); });