e2e.validation.test.ts
Raw
1/**
2 * Tests for input validation: body size limits, username/password limits,
3 * tag name validation, and LIKE search wildcard escaping.
4 */
5import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
6import {
7 BASE,
8 ADMIN_PASS,
9 setupTestEnv,
10 spawnServer,
11 killServer,
12 seedRepo,
13} from './helpers.ts';
14import config from '../src/config.ts';
15
16let server: Awaited<ReturnType<typeof spawnServer>>;
17let sessionCookie = '';
18let issueUrl = '';
19
20async function adminLogin(): Promise<string> {
21 const res = await fetch(`${BASE}/login`, {
22 method: 'POST',
23 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
24 body: new URLSearchParams({ username: 'admin', password: ADMIN_PASS }),
25 redirect: 'manual',
26 });
27 const raw = res.headers.get('set-cookie') ?? '';
28 return raw.split(';')[0]!; // "session=<hex>"
29}
30
31async function post(path: string, body: Record<string, string>): Promise<Response> {
32 return fetch(`${BASE}${path}`, {
33 method: 'POST',
34 headers: {
35 'Content-Type': 'application/x-www-form-urlencoded',
36 Cookie: sessionCookie,
37 },
38 body: new URLSearchParams(body),
39 redirect: 'manual',
40 });
41}
42
43beforeAll(async () => {
44 await setupTestEnv();
45 server = await spawnServer();
46 sessionCookie = await adminLogin();
47
48 // Create a repo and seed it so issues/patches can be submitted
49 const res = await post('/new', { name: 'val-repo' });
50 expect(res.status).toBe(302);
51 await seedRepo('val-repo');
52
53 // Create a baseline issue so we have an issue URL for comment tests
54 const issueRes = await post('/val-repo/issues', { title: 'Baseline issue', body: 'ok' });
55 expect(issueRes.status).toBe(302);
56 issueUrl = issueRes.headers.get('location') ?? '/val-repo/issues/1';
57});
58
59afterAll(async () => {
60 await killServer(server);
61});
62
63// ─── Body size limits ─────────────────────────────────────────────────────────
64
65describe('body size limits', () => {
66 test('issue body at limit is accepted', async () => {
67 const res = await post('/val-repo/issues', {
68 title: 'Body at limit',
69 body: 'x'.repeat(config.MAX_TEXT_BODY_BYTES),
70 });
71 expect(res.status).toBe(302);
72 });
73
74 test('issue body over limit is rejected', async () => {
75 const res = await post('/val-repo/issues', {
76 title: 'Body over limit',
77 body: 'x'.repeat(config.MAX_TEXT_BODY_BYTES + 1),
78 });
79 expect(res.status).toBe(422);
80 });
81
82 test('issue title at limit is accepted', async () => {
83 const res = await post('/val-repo/issues', {
84 title: 'x'.repeat(config.MAX_TITLE_BYTES),
85 body: 'ok',
86 });
87 expect(res.status).toBe(302);
88 });
89
90 test('issue title over limit is rejected', async () => {
91 const res = await post('/val-repo/issues', {
92 title: 'x'.repeat(config.MAX_TITLE_BYTES + 1),
93 body: 'ok',
94 });
95 expect(res.status).toBe(422);
96 });
97
98 test('issue comment body at limit is accepted', async () => {
99 const res = await post(`${issueUrl}/comments`, {
100 body: 'x'.repeat(config.MAX_TEXT_BODY_BYTES),
101 });
102 expect(res.status).toBe(302);
103 });
104
105 test('issue comment body over limit is rejected', async () => {
106 const res = await post(`${issueUrl}/comments`, {
107 body: 'x'.repeat(config.MAX_TEXT_BODY_BYTES + 1),
108 });
109 expect(res.status).toBe(422);
110 });
111
112 test('patch description at limit is accepted', async () => {
113 const res = await post('/val-repo/patches', {
114 title: 'Patch ok',
115 description: 'x'.repeat(config.MAX_TEXT_BODY_BYTES),
116 });
117 // No patch_file provided → will fail business logic, but schema passes → 302 or 200, not 422
118 expect(res.status).not.toBe(422);
119 });
120
121 test('patch description over limit is rejected', async () => {
122 const res = await post('/val-repo/patches', {
123 title: 'Patch bad',
124 description: 'x'.repeat(config.MAX_TEXT_BODY_BYTES + 1),
125 });
126 expect(res.status).toBe(422);
127 });
128
129 test('patch title over limit is rejected', async () => {
130 const res = await post('/val-repo/patches', {
131 title: 'x'.repeat(config.MAX_TITLE_BYTES + 1),
132 });
133 expect(res.status).toBe(422);
134 });
135});
136
137// ─── Auth limits ──────────────────────────────────────────────────────────────
138
139describe('auth limits', () => {
140 test('username over limit is rejected at registration', async () => {
141 const res = await fetch(`${BASE}/register`, {
142 method: 'POST',
143 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
144 body: new URLSearchParams({
145 username: 'u'.repeat(config.MAX_USERNAME_BYTES + 1),
146 password: 'validpass1',
147 password2: 'validpass1',
148 }),
149 redirect: 'manual',
150 });
151 expect(res.status).toBe(422);
152 });
153
154 test('username at limit is not schema-rejected', async () => {
155 // A username at exactly the limit passes schema (may fail business logic due to uniqueness/format)
156 const res = await fetch(`${BASE}/register`, {
157 method: 'POST',
158 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
159 body: new URLSearchParams({
160 username: 'a'.repeat(config.MAX_USERNAME_BYTES),
161 password: 'validpass1',
162 password2: 'validpass1',
163 }),
164 redirect: 'manual',
165 });
166 // 302 (registered) or 200 (form error like invalid chars), but not 422
167 expect(res.status).not.toBe(422);
168 });
169
170 test('password over limit is rejected at registration', async () => {
171 const res = await fetch(`${BASE}/register`, {
172 method: 'POST',
173 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
174 body: new URLSearchParams({
175 username: 'newuser',
176 password: 'p'.repeat(config.MAX_PASSWORD_BYTES + 1),
177 password2: 'p'.repeat(config.MAX_PASSWORD_BYTES + 1),
178 }),
179 redirect: 'manual',
180 });
181 expect(res.status).toBe(422);
182 });
183
184 test('new_password over limit is rejected at settings/password', async () => {
185 const res = await post('/settings/password', {
186 current_password: ADMIN_PASS,
187 new_password: 'p'.repeat(config.MAX_PASSWORD_BYTES + 1),
188 confirm_password: 'p'.repeat(config.MAX_PASSWORD_BYTES + 1),
189 });
190 expect(res.status).toBe(422);
191 });
192});
193
194// ─── Tag name validation ──────────────────────────────────────────────────────
195
196describe('tag name validation', () => {
197 const validTags = ['v1.0.0', 'release-2', '1.0+build.1', 'v1_alpha'];
198 const invalidTags = ['v1.0~1', 'tag with space', 'v1:2', 'v1^2', 'ref/head', 'v1?', 'v1*'];
199
200 for (const tag of validTags) {
201 test(`valid tag "${tag}" is accepted`, async () => {
202 const res = await post('/val-repo/releases', {
203 create_tag: 'on',
204 tag_name: tag,
205 revision: 'main',
206 name: `Release ${tag}`,
207 });
208 // 302 = success redirect, or 200 = form with error (e.g. tag already exists) — either is fine
209 // What's NOT acceptable is a 422 schema error
210 expect(res.status).not.toBe(422);
211 });
212 }
213
214 for (const tag of invalidTags) {
215 test(`invalid tag "${tag}" is rejected`, async () => {
216 const res = await post('/val-repo/releases', {
217 create_tag: 'on',
218 tag_name: tag,
219 revision: 'main',
220 name: `Release ${tag}`,
221 });
222 // Should get a 200 with an inline form error (business-logic validation)
223 expect(res.status).toBe(200);
224 const body = await res.text();
225 expect(body).toContain('may only contain');
226 });
227 }
228});
229
230// ─── LIKE wildcard escaping in repo search ────────────────────────────────────
231
232describe('repo search LIKE escaping', () => {
233 beforeAll(async () => {
234 // Create repos with and without underscore/special chars to verify search behavior
235 await post('/new', { name: 'search-under_score' });
236 await post('/new', { name: 'search-nodash' });
237 });
238
239 test('search for "_" returns only repos with literal underscore', async () => {
240 const res = await fetch(`${BASE}/?q=${encodeURIComponent('_')}`, {
241 headers: { Cookie: sessionCookie },
242 });
243 const body = await res.text();
244 expect(body).toContain('search-under_score');
245 expect(body).not.toContain('search-nodash');
246 expect(body).not.toContain('val-repo');
247 });
248
249 test('search for "%" returns no repos (no repo has literal % in name)', async () => {
250 const res = await fetch(`${BASE}/?q=${encodeURIComponent('%')}`, {
251 headers: { Cookie: sessionCookie },
252 });
253 const body = await res.text();
254 expect(body).not.toContain('search-under_score');
255 expect(body).not.toContain('search-nodash');
256 expect(body).not.toContain('val-repo');
257 });
258
259 test('normal substring search still works', async () => {
260 const res = await fetch(`${BASE}/?q=search-under`, {
261 headers: { Cookie: sessionCookie },
262 });
263 const body = await res.text();
264 expect(body).toContain('search-under_score');
265 expect(body).not.toContain('search-nodash');
266 });
267});
268