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