Files
HIPCTF2/.kilo/plans/902.md
T

9.3 KiB

Implementation Plan: Admin Add/Edit Challenge — Category Validation Bug Fix (Job 902)

1. Architectural Reconnaissance

  • Codebase style & conventions:

    • Monorepo using npm workspaces (backend, frontend). TypeScript everywhere.
    • Backend: NestJS 10 with TypeORM, SQLite (better-sqlite3), class-validator-style validation through custom ZodValidationPipe.
    • Frontend: Angular 17 standalone components with OnPush change detection, Angular Signals + effect(), typed reactive forms (FormBuilder.nonNullable), RxJS for streams.
    • Stylistic choices: pure helper functions live in *.pure.ts next to the components; component classes only orchestrate state and templates.
    • Error envelopes use stable codes (VALIDATION_FAILED, CHALLENGE_NAME_TAKEN, CHALLENGE_INVALID_CATEGORY) returned from ApiError.badRequest(...) with a details[] of { path, message }.
  • Data Layer: SQLite via TypeORM (better-sqlite3). challenge.category_id is TEXT NOT NULL with ON DELETE RESTRICT FK to category.id. The category.id column is TEXT UUID v4 (see database/challenges.md).

  • Test Framework & Structure:

    • Jest 29 with ts-jest. Multi-project config in tests/jest.config.js with two projects: backend (node env) and frontend (jsdom env).
    • Frontend tests live in tests/frontend/*.spec.ts, backend tests in tests/backend/*.spec.ts. Tests NEVER live next to source.
    • Single command from the repo root: npm test (or npm run test:backend / npm run test:frontend).
    • Frontend tests focus on logic / pure functions and DOM-free component behavior (no visual confirmation). tests/frontend/admin-challenges-form.spec.ts already exercises validateChallengeForm, buildChallengeFormGroup, syncChallengeForm, etc.
  • Required Tools & Dependencies: No new tools or dependencies are required for this fix. Zod 3 is already a backend dep; UUID validation can use the standard z.string().uuid(). No new npm packages needed. setup.sh requires no changes.

2. Impacted Files

  • To Modify:

    • frontend/src/app/features/admin/challenges/challenge-form-modal.component.ts — add a dedicated early-return guard for empty categoryId and a UUID-format validator on the control; tighten onOk().
    • frontend/src/app/features/admin/challenges/challenge-form.pure.ts — add a categoryUuidPattern constant and a CATEGORY_ID_REQUIRED helper constant; tighten validateChallengeForm to flag any non-UUID value as a category error (defense in depth).
    • backend/src/modules/admin/dto/challenges.dto.ts — change categoryId in baseShape from z.string().min(1).max(64) to z.string().uuid() so an empty string, whitespace, or non-UUID payload is rejected at the validation pipe with a 400 VALIDATION_FAILED and a categoryId detail.
  • To Create:

    • tests/frontend/admin-challenges-form-job902.spec.ts — targeted unit tests covering the new client-side guards.
    • tests/backend/admin-challenges-create-category-job902.spec.ts — targeted integration test covering the new server-side Zod rejection of empty/non-UUID categoryId on POST /api/v1/admin/challenges.

3. Proposed Changes

1. Database / Schema Migration

No schema migration. The category_id column already requires a valid existing row (FK + service-level assertCategoryExists). The fix is purely validation tightening on the wire format. The RepairCategorySchemaAndSystemCategories1700000000400 and UpgradeChallengeAdminSchema1700000000500 migrations remain untouched.

2. Backend Logic & APIs (backend/src/modules/admin/dto/challenges.dto.ts)

  • Update baseShape.categoryId from z.string().min(1).max(64) to z.string().uuid({ message: 'Category is required' }).
  • This single change makes the backend reject:
    • Empty string ("") → caught at the Zod pipe → 400 VALIDATION_FAILED with details: [{ path: 'categoryId', message: 'Category is required' }].
    • Whitespace-only → still rejected by z.string().uuid() (UUIDs cannot contain whitespace).
    • Any non-UUID string (including stray placeholders) → rejected.
  • CategoryIdParamSchema for GET/PUT/DELETE /:id already uses z.string().min(1).max(64); for consistency, also tighten it to z.string().uuid() so that a missing/invalid id route produces a uniform 400.
  • The ImportChallengeSchema (used by import payloads) already passes a categorySystemKey, not categoryId, so it is unaffected.
  • mapServerFieldErrors in challenge-form.pure.ts already maps path === 'categoryId' to the per-field error, so the inline cf-error-category will render automatically once the backend returns a categoryId detail.

3. Frontend UI Integration (frontend/src/app/features/admin/challenges/challenge-form-modal.component.ts)

  • In buildChallengeFormGroup, replace the bare Validators.required on categoryId with a stronger validator set that:
    1. Rejects empty strings (Validators.required).
    2. Enforces UUID v4 format using a small inline regex (e.g. /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) wrapped in a named validator error key (uuid) so showError('categoryId') can render a meaningful message.
  • In onOk():
    • Read categoryId = this.form.controls.categoryId.value BEFORE calling validateChallengeForm.
    • Add an explicit defensive guard that returns immediately when the trimmed value is empty or does not match the UUID pattern, sets clientFieldErrors = { categoryId: 'Category is required' }, marks the control touched/dirty, switches the active tab to 'general' via firstErrorTab, and does NOT call submit.emit(...). This is in addition to validateChallengeForm (which already catches the empty case) — it exists as a belt-and-suspenders measure so the bug cannot recur even if the pure helper is bypassed.
  • Keep the existing template — <option value="">Select a category…</option> — and the existing [data-testid="cf-category"] selector untouched.

4. Frontend Pure Helper (frontend/src/app/features/admin/challenges/challenge-form.pure.ts)

  • Export a new constant CATEGORY_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.
  • In validateChallengeForm, expand the category branch:
    if (!values.categoryId) errs.categoryId = 'Category is required';
    else if (!CATEGORY_ID_PATTERN.test(values.categoryId))
      errs.categoryId = 'Category is required';
    
    (Same message on purpose: do not leak internal format expectations to the user; both states mean "you have not selected a valid category".)
  • Add firstErrorTab already returns 'general' for errors.categoryId — no change needed there.

4. Test Strategy

  • Target Unit Test File (frontend): tests/frontend/admin-challenges-form-job902.spec.ts

    • Reuse the existing makeDefaults() helper pattern from tests/frontend/admin-challenges-form.spec.ts.
    • Test 1 — validateChallengeForm({ categoryId: '' }) produces errs.categoryId === 'Category is required'.
    • Test 2 — validateChallengeForm({ categoryId: ' ' }) produces the same error (whitespace string).
    • Test 3 — validateChallengeForm({ categoryId: 'not-a-uuid' }) produces the same error (defense-in-depth against non-UUID garbage).
    • Test 4 — validateChallengeForm({ categoryId: '<valid uuid>' }) does NOT produce a categoryId error.
    • Test 5 — buildChallengeFormGroup applies a uuid validator that rejects 'abc', 'not-a-uuid', and '' once the control is touched; accepts a canonical UUID v4.
    • Test 6 — firstErrorTab({ categoryId: '...' }) returns 'general' (so the modal switches tabs to surface the error).
  • Target Integration Test File (backend): tests/backend/admin-challenges-create-category-job902.spec.ts

    • Follow the boot pattern from tests/backend/admin-challenges-api.spec.ts (register first admin, prime CSRF, login).
    • Test 1 — POST /api/v1/admin/challenges with categoryId: '' and otherwise-valid body returns 400 VALIDATION_FAILED with details containing a categoryId entry; no row is persisted (re-list and assert empty array).
    • Test 2 — POST /api/v1/admin/challenges with categoryId: 'not-a-uuid' and otherwise-valid body returns 400 VALIDATION_FAILED with a categoryId detail.
    • Test 3 — POST /api/v1/admin/challenges with a valid UUID for categoryId (matching a seeded category) returns 201 and the row appears in the list.
  • Mocking Strategy:

    • Frontend tests: No HTTP/network mocking required — they exercise pure helpers and the in-memory FormGroup from @angular/forms. The existing StubAdminService pattern from tests/frontend/admin-challenges-form.spec.ts can be referenced if needed but is not required for the new tests above.
    • Backend tests: Use the real AppModule with process.env.DATABASE_PATH = ':memory:', real GlobalExceptionFilter, real CsrfMiddleware, and real admin login flow (same pattern as tests/backend/admin-challenges-api.spec.ts). Seed at least one category row through the existing /api/v1/admin/categories POST so the "valid UUID" path can succeed; rely on category-repair-migration to materialize the canonical system rows on fresh DBs.
  • Run command (single, root-level):

    npm test
    

    This runs both projects (backend and frontend) via tests/jest.config.js. For narrower loops during development: npm run test:backend or npm run test:frontend.