bug fixes, add length limits to various user provided strings

AuthorKonata <konata@posteo.jp>
Date
Commit4b37f3c80644b5784b365d4b0820d76e1fa1e39b
Parenta1baf9a
19 files changed, 465 insertions(+), 81 deletions(-)
▾MREADME.md
@@ -70,6 +70,10 @@ All settings are environment variables:
| `HIGHLIGHT_WORKERS` | `4` | Number of syntax highlighting workers* |
| `COMMITTER_NAME` | `$OWNER_DISPLAY_NAME` | Git committer name used when merging patches or editing files through the UI |
| `COMMITTER_EMAIL` | `$OWNER_DISPLAY_NAME@<hostname>` | same as above but for email |
| `MAX_TITLE_BYTES` | `500` | Max length for titles (issues, patches, releases) |
| `MAX_TEXT_BODY_BYTES` | `100000` | Max length for text bodies (issue/patch descriptions, comments, release notes) |
| `MAX_USERNAME_BYTES` | `64` | Max length for usernames at registration |
| `MAX_PASSWORD_BYTES` | `1024` | Max length for passwords at registration and password change |
\* More workers mean more CPU cores can be used to parallelize highlighting of files.
Because of the language grammars, which can't be shared across workers, the memory usage per worker is quite high, at about 200MB.
@@ -95,4 +99,5 @@ bun run test # Run E2E and unit tests (don't use bun test directly, it
## Roadmap
- Use [git-bug](https://github.com/git-bug/git-bug) for issue tracking instead of custom implementation
- more repository manipulation through the UI, e.g. file/directory/branch creation, renaming and deletion
- more repository manipulation through the UI, e.g. file/directory/branch creation, renaming and deletion
- remove test retry logic when bun doesn't randomly get stuck anymore
▾Mscripts/test.ts
@@ -1,5 +1,5 @@
import { readdirSync } from 'fs';
import { execFileSync } from 'child_process';
import { spawn } from 'child_process';
import path from 'path';
const testsDir = path.resolve('tests');
@@ -7,19 +7,61 @@ const files = readdirSync(testsDir)
.filter(f => f.endsWith('.test.ts'))
.sort();
const STALL_TIMEOUT = 20_000; // kill if no output for 20s
const MAX_RETRIES = 2;
// retry logic needed because tests get randomly get stuck on startup with bun
// strace shows bun completely spinning in futex and not doing anything else
function runTest(filePath: string): Promise<boolean> {
return new Promise((resolve) => {
const child = spawn('bun', ['test', '--bail=1', '--timeout', '30000', filePath], {
stdio: ['ignore', 'pipe', 'pipe'],
});
let timer = setTimeout(onStall, STALL_TIMEOUT);
function onStall() {
console.error(`\n[test-runner] stall detected, killing ${path.basename(filePath)} (no output for ${STALL_TIMEOUT / 1000}s)`);
child.kill('SIGKILL');
}
function resetTimer() {
clearTimeout(timer);
timer = setTimeout(onStall, STALL_TIMEOUT);
}
child.stdout!.on('data', (chunk: Buffer) => {
process.stdout.write(chunk);
resetTimer();
});
child.stderr!.on('data', (chunk: Buffer) => {
process.stderr.write(chunk);
resetTimer();
});
child.on('close', (code) => {
clearTimeout(timer);
resolve(code === 0);
});
});
}
let passed = 0;
let failed = 0;
for (const file of files) {
const filePath = path.join(testsDir, file);
try {
execFileSync('bun', ['test', '--bail=1', '--timeout', '30000', filePath], {
stdio: 'inherit',
});
passed++;
} catch {
failed++;
let ok = false;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
if (attempt > 1) {
console.log(`[test-runner] retrying ${file} (attempt ${attempt}/${MAX_RETRIES})`);
}
ok = await runTest(filePath);
if (ok) break;
}
if (ok) passed++;
else failed++;
}
console.log(`\n${passed + failed} test files: ${passed} passed${failed ? `, ${failed} failed` : ''}`);
▾Msrc/config.ts
@@ -27,6 +27,10 @@ const config = {
COMMITTER_NAME: env.COMMITTER_NAME ?? env.OWNER_DISPLAY_NAME ?? "Admin",
COMMITTER_EMAIL: "",
EXTRA_ALLOWED_SIGNERS_PATH: env.EXTRA_ALLOWED_SIGNERS_PATH ?? null,
MAX_TITLE_BYTES: parseInt(env.MAX_TITLE_BYTES ?? "", 10) || 500,
MAX_TEXT_BODY_BYTES: parseInt(env.MAX_TEXT_BODY_BYTES ?? "", 10) || 100_000,
MAX_USERNAME_BYTES: parseInt(env.MAX_USERNAME_BYTES ?? "", 10) || 64,
MAX_PASSWORD_BYTES: parseInt(env.MAX_PASSWORD_BYTES ?? "", 10) || 1024,
};
// Derived values that depend on other config fields
▾Asrc/db/helpers.ts
@@ -0,0 +1,28 @@
import type { ExpressionBuilder } from "kysely";
import type { Database } from "./index.ts";
export function issuesLabelFilter(
eb: ExpressionBuilder<Database, "issues">,
labelIds: number[],
) {
return eb.exists(
eb
.selectFrom("issue_labels")
.select("issue_labels.issue_id")
.whereRef("issue_labels.issue_id", "=", "issues.id")
.where("issue_labels.label_id", "in", labelIds),
);
}
export function patchesLabelFilter(
eb: ExpressionBuilder<Database, "patches">,
labelIds: number[],
) {
return eb.exists(
eb
.selectFrom("patch_labels")
.select("patch_labels.patch_id")
.whereRef("patch_labels.patch_id", "=", "patches.id")
.where("patch_labels.label_id", "in", labelIds),
);
}
▾Msrc/routes/auth.tsx
@@ -242,9 +242,13 @@ export const authRoutes = new Elysia()
},
{
body: t.Object({
username: t.String(),
password: t.Optional(t.String()),
password2: t.Optional(t.String()),
username: t.String({ maxLength: config.MAX_USERNAME_BYTES }),
password: t.Optional(
t.String({ maxLength: config.MAX_PASSWORD_BYTES }),
),
password2: t.Optional(
t.String({ maxLength: config.MAX_PASSWORD_BYTES }),
),
application: t.Optional(t.String()),
}),
},
▾Msrc/routes/issues.tsx
@@ -1,6 +1,8 @@
import { Elysia, t } from "elysia";
import { sql } from "kysely";
import config from "../config.ts";
import { ALLOWED_REACTIONS, ISSUES_PER_PAGE } from "../constants.ts";
import { issuesLabelFilter } from "../db/helpers.ts";
import { db, getRepo, type LabelRow } from "../db/index.ts";
import {
requireAdmin,
@@ -57,13 +59,8 @@ export const issueRoutes = new Elysia()
.select(["issues.status", db.fn.countAll<number>().as("count")])
.where("issues.repo_id", "=", repo.id);
if (labelIds.length > 0) {
countQuery = countQuery.where(({ exists, selectFrom }) =>
exists(
selectFrom("issue_labels")
.select("issue_labels.issue_id")
.whereRef("issue_labels.issue_id", "=", "issues.id")
.where("issue_labels.label_id", "in", labelIds),
),
countQuery = countQuery.where((eb) =>
issuesLabelFilter(eb, labelIds),
);
}
const allCounts = await countQuery
@@ -99,13 +96,8 @@ export const issueRoutes = new Elysia()
.where("issues.repo_id", "=", repo.id)
.where("issues.status", "=", status);
if (labelIds.length > 0) {
listQuery = listQuery.where(({ exists, selectFrom }) =>
exists(
selectFrom("issue_labels")
.select("issue_labels.issue_id")
.whereRef("issue_labels.issue_id", "=", "issues.id")
.where("issue_labels.label_id", "in", labelIds),
),
listQuery = listQuery.where((eb) =>
issuesLabelFilter(eb, labelIds),
);
}
const issues = await listQuery
@@ -295,8 +287,10 @@ export const issueRoutes = new Elysia()
},
{
body: t.Object({
title: t.String(),
body: t.Optional(t.String()),
title: t.String({ maxLength: config.MAX_TITLE_BYTES }),
body: t.Optional(
t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }),
),
label_ids: t.Optional(
t.Union([t.String(), t.Array(t.String())]),
),
@@ -474,7 +468,9 @@ export const issueRoutes = new Elysia()
});
},
{
body: t.Object({ body: t.String() }),
body: t.Object({
body: t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }),
}),
},
)
@@ -681,8 +677,10 @@ export const issueRoutes = new Elysia()
},
{
body: t.Object({
title: t.String(),
edit_body: t.Optional(t.String()),
title: t.String({ maxLength: config.MAX_TITLE_BYTES }),
edit_body: t.Optional(
t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }),
),
}),
},
)
@@ -733,7 +731,9 @@ export const issueRoutes = new Elysia()
number: t.String(),
id: t.Numeric(),
}),
body: t.Object({ edit_body: t.String() }),
body: t.Object({
edit_body: t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }),
}),
},
)
▾Msrc/routes/patches.tsx
@@ -2,6 +2,7 @@ import { Elysia, t } from "elysia";
import { sql } from "kysely";
import config from "../config.ts";
import { ALLOWED_REACTIONS, PATCHES_PER_PAGE } from "../constants.ts";
import { patchesLabelFilter } from "../db/helpers.ts";
import { db, getRepo, type LabelRow } from "../db/index.ts";
import {
requireAdmin,
@@ -89,17 +90,8 @@ export const patchRoutes = new Elysia()
])
.where("patches.repo_id", "=", repo.id);
if (labelIds.length > 0) {
countQuery = countQuery.where(({ exists, selectFrom }) =>
exists(
selectFrom("patch_labels")
.select("patch_labels.patch_id")
.whereRef(
"patch_labels.patch_id",
"=",
"patches.id",
)
.where("patch_labels.label_id", "in", labelIds),
),
countQuery = countQuery.where((eb) =>
patchesLabelFilter(eb, labelIds),
);
}
const allCounts = await countQuery
@@ -139,17 +131,8 @@ export const patchRoutes = new Elysia()
.where("patches.repo_id", "=", repo.id)
.where("patches.status", "=", status);
if (labelIds.length > 0) {
listQuery = listQuery.where(({ exists, selectFrom }) =>
exists(
selectFrom("patch_labels")
.select("patch_labels.patch_id")
.whereRef(
"patch_labels.patch_id",
"=",
"patches.id",
)
.where("patch_labels.label_id", "in", labelIds),
),
listQuery = listQuery.where((eb) =>
patchesLabelFilter(eb, labelIds),
);
}
const patches = await listQuery
@@ -426,8 +409,12 @@ export const patchRoutes = new Elysia()
},
{
body: t.Object({
title: t.Optional(t.String()),
description: t.Optional(t.String()),
title: t.Optional(
t.String({ maxLength: config.MAX_TITLE_BYTES }),
),
description: t.Optional(
t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }),
),
patch_file: t.Optional(t.File()),
label_ids: t.Optional(
t.Union([t.String(), t.Array(t.String())]),
@@ -854,7 +841,9 @@ export const patchRoutes = new Elysia()
});
},
{
body: t.Object({ body: t.String() }),
body: t.Object({
body: t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }),
}),
},
)
@@ -981,7 +970,9 @@ export const patchRoutes = new Elysia()
number: t.String(),
id: t.Numeric(),
}),
body: t.Object({ edit_body: t.String() }),
body: t.Object({
edit_body: t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }),
}),
},
)
@@ -1025,8 +1016,10 @@ export const patchRoutes = new Elysia()
},
{
body: t.Object({
title: t.String(),
edit_description: t.Optional(t.String()),
title: t.String({ maxLength: config.MAX_TITLE_BYTES }),
edit_description: t.Optional(
t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }),
),
}),
},
)
▾Msrc/routes/releases.tsx
@@ -153,12 +153,12 @@ export const releasesRoutes = new Elysia()
/>,
);
}
if (tagName.includes("/")) {
if (!/^[a-zA-Z0-9._\-+]+$/.test(tagName)) {
return html(
<NewRelease
user={user!}
repo={repo}
error="Tag name must not contain slashes"
error="Tag name may only contain letters, digits, dots, hyphens, underscores, and plus signs"
values={formValues}
/>,
);
@@ -359,10 +359,16 @@ export const releasesRoutes = new Elysia()
{
body: t.Object({
create_tag: t.Optional(t.String()),
tag_name: t.Optional(t.String()),
tag_name: t.Optional(
t.String({ maxLength: config.MAX_TITLE_BYTES }),
),
revision: t.Optional(t.String()),
name: t.Optional(t.String()),
notes: t.Optional(t.String()),
name: t.Optional(
t.String({ maxLength: config.MAX_TITLE_BYTES }),
),
notes: t.Optional(
t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }),
),
include_source_code: t.Optional(t.String()),
files: t.Optional(t.Union([t.File(), t.Array(t.File())])),
}),
▾Msrc/routes/repos.tsx
@@ -2,6 +2,7 @@ import { readFileSync, rmSync } from "node:fs";
import path from "node:path";
import { Elysia, t } from "elysia";
import { fileTypeFromBuffer } from "file-type";
import { sql } from "kysely";
import config from "../config.ts";
import {
COMMITS_PER_PAGE,
@@ -109,6 +110,10 @@ export const repoRoutes = new Elysia()
const isAdmin = user?.isAdmin ?? false;
const searchPattern = search
? `%${search.replace(/[\\%_]/g, "\\$&")}%`
: undefined;
const countResult = await db
.selectFrom("repositories")
.select(db.fn.countAll<number>().as("count"))
@@ -120,12 +125,9 @@ export const repoRoutes = new Elysia()
])
: eb("is_private", "=", 0),
)
.$if(!!search, (qb) =>
qb.where((eb) =>
eb.or([
eb("name", "like", `%${search}%`),
eb("description", "like", `%${search}%`),
]),
.$if(!!searchPattern, (qb) =>
qb.where(
sql<boolean>`("name" LIKE ${searchPattern} ESCAPE '\\' OR "description" LIKE ${searchPattern} ESCAPE '\\')`,
),
)
.executeTakeFirst();
@@ -148,12 +150,9 @@ export const repoRoutes = new Elysia()
])
: eb("is_private", "=", 0),
)
.$if(!!search, (qb) =>
qb.where((eb) =>
eb.or([
eb("name", "like", `%${search}%`),
eb("description", "like", `%${search}%`),
]),
.$if(!!searchPattern, (qb) =>
qb.where(
sql<boolean>`("name" LIKE ${searchPattern} ESCAPE '\\' OR "description" LIKE ${searchPattern} ESCAPE '\\')`,
),
)
.orderBy("is_pinned", "desc")
▾Msrc/routes/settings.tsx
@@ -147,9 +147,15 @@ export const settingsRoutes = new Elysia()
},
{
body: t.Object({
current_password: t.Optional(t.String()),
new_password: t.String(),
confirm_password: t.String(),
current_password: t.Optional(
t.String({ maxLength: config.MAX_PASSWORD_BYTES }),
),
new_password: t.String({
maxLength: config.MAX_PASSWORD_BYTES,
}),
confirm_password: t.String({
maxLength: config.MAX_PASSWORD_BYTES,
}),
}),
},
)
▾Msrc/styles/components.css
@@ -510,6 +510,7 @@
text-decoration: none;
color: var(--color-text);
flex: 1;
word-wrap: anywhere;
}
.issue-title:hover {
color: var(--color-link);
▾Msrc/views/Settings.tsx
@@ -1,3 +1,4 @@
import config from "../config.ts";
import { formatDate, formatDateTime } from "../lib/formatDate.ts";
import type { SessionUser } from "../middleware/session.ts";
import { Avatar } from "./Avatar.tsx";
@@ -177,6 +178,7 @@ export function Settings({
id="current_password"
name="current_password"
autocomplete="current-password"
maxlength={config.MAX_PASSWORD_BYTES}
/>
</div>
)}
@@ -190,6 +192,7 @@ export function Settings({
id="new_password"
name="new_password"
autocomplete="new-password"
maxlength={config.MAX_PASSWORD_BYTES}
/>
</div>
<div class="form-group">
@@ -202,6 +205,7 @@ export function Settings({
id="confirm_password"
name="confirm_password"
autocomplete="new-password"
maxlength={config.MAX_PASSWORD_BYTES}
/>
</div>
<div class="form-actions">
▾Msrc/views/auth/Register.tsx
@@ -1,3 +1,4 @@
import config from "../../config.ts";
import { Layout } from "../layout.tsx";
interface RegisterProps {
@@ -38,6 +39,7 @@ export function Register({ error, question, pending }: RegisterProps) {
type="text"
required
autocomplete="username"
maxlength={config.MAX_USERNAME_BYTES}
pattern="[a-zA-Z0-9_-]+"
title="Letters, numbers, hyphens and underscores only"
/>
@@ -74,6 +76,7 @@ export function Register({ error, question, pending }: RegisterProps) {
type="password"
autocomplete="new-password"
minlength="8"
maxlength={config.MAX_PASSWORD_BYTES}
/>
</div>
<div class="form-group">
@@ -84,6 +87,7 @@ export function Register({ error, question, pending }: RegisterProps) {
type="password"
autocomplete="new-password"
minlength="8"
maxlength={config.MAX_PASSWORD_BYTES}
/>
</div>
<button type="submit" class="btn btn-primary btn-block">
▾Msrc/views/issues/IssueDetail.tsx
@@ -1,3 +1,4 @@
import config from "../../config.ts";
import type {
IssueCommentRow,
IssueRow,
@@ -78,6 +79,7 @@ export function IssueDetail({
name="title"
value={issue.title}
required
maxlength={config.MAX_TITLE_BYTES}
/>
<input
type="hidden"
@@ -290,6 +292,7 @@ export function IssueDetail({
id="edit-issue-body"
name="edit_body"
rows="6"
maxlength={config.MAX_TEXT_BODY_BYTES}
>
{issue.body}
</textarea>
@@ -361,6 +364,7 @@ export function IssueDetail({
class="form-input"
name="edit_body"
rows="6"
maxlength={config.MAX_TEXT_BODY_BYTES}
>
{comment.body}
</textarea>
@@ -401,6 +405,7 @@ export function IssueDetail({
<textarea
name="body"
rows="6"
maxlength={config.MAX_TEXT_BODY_BYTES}
placeholder="Leave a comment (Markdown supported)"
required
/>
▾Msrc/views/issues/NewIssue.tsx
@@ -1,3 +1,4 @@
import config from "../../config.ts";
import type { LabelRow, RepositoryRow } from "../../db/index.ts";
import { labelTextColor } from "../../lib/labelColor.ts";
import type { SessionUser } from "../../middleware/session.ts";
@@ -39,6 +40,7 @@ export function NewIssue({
name="title"
type="text"
required
maxlength={config.MAX_TITLE_BYTES}
placeholder="Short, descriptive title"
/>
</div>
@@ -53,6 +55,7 @@ export function NewIssue({
id="body"
name="body"
rows="10"
maxlength={config.MAX_TEXT_BODY_BYTES}
placeholder="Describe the issue..."
>
{template ?? ""}
▾Msrc/views/patches/NewPatch.tsx
@@ -1,3 +1,4 @@
import config from "../../config.ts";
import type { LabelRow, RepositoryRow } from "../../db/index.ts";
import { labelTextColor } from "../../lib/labelColor.ts";
import type { SessionUser } from "../../middleware/session.ts";
@@ -40,6 +41,7 @@ export function NewPatch({
name="title"
type="text"
required
maxlength={config.MAX_TITLE_BYTES}
placeholder="What does this patch do?"
/>
</div>
@@ -50,7 +52,7 @@ export function NewPatch({
(Markdown supported, optional)
</span>
</label>
<textarea id="description" name="description" rows="5">
<textarea id="description" name="description" rows="5" maxlength={config.MAX_TEXT_BODY_BYTES}>
{template ?? ""}
</textarea>
</div>
▾Msrc/views/patches/PatchDetail.tsx
@@ -1,4 +1,5 @@
import { escapeHtml } from "@kitajs/html";
import config from "../../config.ts";
import type {
LabelRow,
PatchCommentRow,
@@ -99,6 +100,7 @@ export function PatchDetail({
name="title"
value={patch.title}
required
maxlength={config.MAX_TITLE_BYTES}
/>
<input
type="hidden"
@@ -372,6 +374,7 @@ export function PatchDetail({
id="edit-patch-desc"
name="edit_description"
rows="6"
maxlength={config.MAX_TEXT_BODY_BYTES}
>
{patch.description}
</textarea>
@@ -471,6 +474,7 @@ export function PatchDetail({
class="form-input"
name="edit_body"
rows="6"
maxlength={config.MAX_TEXT_BODY_BYTES}
>
{comment.body}
</textarea>
@@ -511,6 +515,7 @@ export function PatchDetail({
<textarea
name="body"
rows="6"
maxlength={config.MAX_TEXT_BODY_BYTES}
placeholder="Leave a comment (Markdown supported)"
required
/>
▾Msrc/views/releases/NewRelease.tsx
@@ -1,3 +1,4 @@
import config from "../../config.ts";
import type { RepositoryRow } from "../../db/index.ts";
import type { SessionUser } from "../../middleware/session.ts";
import { Layout } from "../layout.tsx";
@@ -47,6 +48,7 @@ export function NewRelease({ user, repo, error, values }: NewReleaseProps) {
name="name"
class="form-input"
required
maxlength={config.MAX_TITLE_BYTES}
value={values?.name ?? ""}
placeholder="e.g. Version 1.0 — Initial Release"
/>
@@ -77,6 +79,9 @@ export function NewRelease({ user, repo, error, values }: NewReleaseProps) {
id="tag_name"
name="tag_name"
class="form-input"
maxlength={config.MAX_TITLE_BYTES}
pattern="[a-zA-Z0-9._\-+]+"
title="Letters, digits, dots, hyphens, underscores, and plus signs only"
value={values?.tag_name ?? ""}
placeholder="v1.0.0"
/>
@@ -108,6 +113,7 @@ export function NewRelease({ user, repo, error, values }: NewReleaseProps) {
name="notes"
class="form-input form-textarea"
rows="8"
maxlength={config.MAX_TEXT_BODY_BYTES}
>
{values?.notes ?? ""}
</textarea>
▾Atests/e2e.validation.test.ts
@@ -0,0 +1,267 @@
/**
* 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';
import config from '../src/config.ts';
let server: Awaited<ReturnType<typeof spawnServer>>;
let sessionCookie = '';
let issueUrl = '';
async function adminLogin(): Promise<string> {
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=<hex>"
}
async function post(path: string, body: Record<string, string>): Promise<Response> {
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');
});
});