diff --git a/.github/scripts/test_scripts.py b/.github/scripts/test_scripts.py new file mode 100644 index 0000000..976b767 --- /dev/null +++ b/.github/scripts/test_scripts.py @@ -0,0 +1,90 @@ +"""Unit tests for the hackathon automation scripts. + +Run with: python -m unittest discover -s .github/scripts -p 'test_*.py' +Covers the core parsing/validation helpers with happy-path + malformed input. +""" + +import unittest +from datetime import date + +import util +import contribution_approved as ca +import auto_extract as ax + + +class ParseDeadlineDate(unittest.TestCase): + def test_iso_and_us_formats(self): + self.assertEqual(util.parse_deadline_date("2026-07-04"), date(2026, 7, 4)) + self.assertEqual(util.parse_deadline_date("07/04/2026"), date(2026, 7, 4)) + + def test_invalid_raises(self): + with self.assertRaises(ValueError): + util.parse_deadline_date("July 4th") + with self.assertRaises(ValueError): + util.parse_deadline_date("2026/07/04") + + +class SanitizeField(unittest.TestCase): + def test_strips_control_chars_and_collapses_ws(self): + self.assertEqual(util.sanitize_field('X";sh\nY'), 'X";sh Y') + self.assertEqual(util.sanitize_field(" a b "), "a b") + + def test_handles_none_and_truncates(self): + self.assertEqual(util.sanitize_field(None), "") + self.assertEqual(len(util.sanitize_field("a" * 500, max_len=10)), 10) + + +class Email(unittest.TestCase): + def test_valid(self): + self.assertTrue(util.is_valid_email("me@example.com")) + + def test_invalid(self): + self.assertFalse(util.is_valid_email("me@example.com\nx")) + self.assertFalse(util.is_valid_email("not-an-email")) + self.assertFalse(util.is_valid_email("")) + + +class EscapeAttr(unittest.TestCase): + def test_escapes_quotes_and_angles(self): + self.assertEqual(util.escape_attr('">'), ""><b>") + + +class ParseIssueBody(unittest.TestCase): + def test_parses_field_sections(self): + body = ( + "### Hackathon Name\nHackMIT 2026\n\n" + "### Link to Hackathon Page\nhttps://hackmit.org\n" + ) + data = ca.parse_issue_body(body, []) + self.assertEqual(data["hackathon_name"], "HackMIT 2026") + self.assertEqual(data["link_to_hackathon_page"], "https://hackmit.org") + + def test_skips_no_response_placeholder(self): + data = ca.parse_issue_body("### Prize Pool (optional)\n_No response_\n", []) + self.assertFalse(data.get("prize_pool_(optional)")) + + +class ParseStateAndDeadline(unittest.TestCase): + def test_state(self): + self.assertEqual(ca.parse_state({"status": "Opens soon"}), "opens_soon") + self.assertEqual(ca.parse_state({"status": "Open now"}), "open") + self.assertEqual(ca.parse_state({}), "open") + + def test_deadline(self): + self.assertEqual(ca.parse_deadline({"deadline": "2026-07-04"}), "2026-07-04") + self.assertIsNone(ca.parse_deadline({})) + + +class SsrfGuard(unittest.TestCase): + def test_blocks_internal_hosts(self): + for host in ("127.0.0.1", "localhost", "169.254.169.254", "10.0.0.1"): + ok, _ = ax._resolved_ips_are_public(host) + self.assertFalse(ok, f"{host} should be blocked") + + def test_rejects_non_http_scheme(self): + ok, _ = ax._validate_fetch_url("file:///etc/passwd") + self.assertFalse(ok) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml new file mode 100644 index 0000000..bc33e3c --- /dev/null +++ b/.github/workflows/web-ci.yml @@ -0,0 +1,49 @@ +name: Web CI + +on: + push: + branches: [main] + paths: + - 'web/**' + - '.github/scripts/**' + - '.github/workflows/web-ci.yml' + pull_request: + paths: + - 'web/**' + - '.github/scripts/**' + - '.github/workflows/web-ci.yml' + +permissions: + contents: read + +jobs: + web: + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '24' + cache: npm + cache-dependency-path: web/package-lock.json + - run: npm ci + - run: npm run lint + - run: npm run test + - run: npm run build + # tsc runs after build: next build generates next-env.d.ts and + # .next/types (both gitignored, so absent on a fresh checkout). + - run: npx tsc --noEmit + + scripts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.11' + - run: pip install -r .github/scripts/requirements.txt + - name: Run Python unit tests + run: python -m unittest discover -s .github/scripts -p 'test_*.py' diff --git a/web/app/error.tsx b/web/app/error.tsx index d75d5e0..face67a 100644 --- a/web/app/error.tsx +++ b/web/app/error.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { useEffect } from "react"; // Route-level error boundary: catches thrown errors from the page/segment and @@ -33,12 +34,12 @@ export default function Error({ > Try again - Back to HackHQ - + ); diff --git a/web/components/hq/deck.tsx b/web/components/hq/deck.tsx index cd1fc11..cc6957d 100644 --- a/web/components/hq/deck.tsx +++ b/web/components/hq/deck.tsx @@ -175,7 +175,8 @@ function SaveHeart({ h, dark }: { h: Hackathon; dark?: boolean }) {