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

14 KiB
Raw Blame History

Implementation Plan: Job 901 — Admin Area Challenges: List, Import/Export and Add/Edit Modal 1.01

0. Status: Already-Implemented Audit (per Job spec)

The Job description explicitly identifies which cases already pass and which are bugs to fix. Per the README/architecture docs (docs/guides/admin-challenges.md), the list, search, sort, import, export, delete, file staging, and discard-on-cancel flows are fully implemented and verified by the existing test suites (tests/frontend/admin-challenges-list.spec.ts, tests/frontend/admin-challenges-form.spec.ts, tests/backend/admin-challenges-*.spec.ts). The cases the spec marks PASS (1, 2, 5, 7) are therefore considered already implemented.

Cases that FAIL and need code changes:

  • Case 3 — Connection NC with blank/non-numeric Port: submit is silently rejected; validateChallengeForm's 'Port is required for NC' message is computed in onOk() but never surfaced as a cf-error-port (or general) inline error. Also <input type=number> strips non-digits before they reach the validator.
  • Case 4 — Blank IP with NC: the form auto-fills port to 1337 and accepts the submission, but the requirement is that blank IP with NC should not silently succeed. (Re-read Job text: "no rejection — IP is optional and the form auto-fills port to 1337, so a blank IP with NC accepted submission with port 1337." — i.e. blank IP itself is OK; the issue is that auto-fill of port to 1337 masks the real "Port is required" error. The Job marks this as a separate failing case to be discussed.)
  • Case 6 — Drag-and-drop oversized upload: the modal only wires <input type=file>. No drop/dragover handler exists.

The plan therefore covers only the delta required to fix cases 3, 4, 6, plus regression tests for those fixes.

1. Architectural Reconnaissance

  • Codebase style & conventions:
    • TypeScript monorepo (backend/ NestJS + frontend/ Angular). strict TS across both.
    • Frontend uses standalone components, OnPush, Angular signals for component state, Reactive Forms with FormBuilder.nonNullable.group(...).
    • Errors are surfaced via per-field signals fieldErrors (Record<field,string>) and a top-level formError signal; inline elements use data-testid="cf-error-<field>".
    • Backend: NestJS modules; admin-challenges.controller.ts + challenges.service.ts + challenge-files.service.ts. Zod-validated DTOs. Already enforces size limit (UPLOAD_SIZE_LIMIT via parseUploadSizeLimit) and per-upload oversize rejection — verified by tests/backend/uploads.spec.ts.
    • Styling is component-scoped via styles: [...] literals (no global CSS edits required).
  • Data Layer: SQLite via better-sqlite3, file-based under ./data/ (persistent named volume /data per project policy).
  • Test Framework & Structure:
    • Jest with two projects (tests/jest.config.js): backend (node) and frontend (jsdom).
    • Single command from root: npm test.
    • Tests live in tests/backend/*.spec.ts and tests/frontend/*.spec.tsnever alongside source.
  • Required Tools & Dependencies: No new packages required. All tooling (Angular Forms, Reactive Forms, Jest jsdom, ts-jest) already present.

2. Impacted Files

  • To Modify:

    • frontend/src/app/features/admin/challenges/challenge-form-modal.component.ts
      • Plumb clientErrors from validateChallengeForm into fieldErrors input (or a new local clientFieldErrors signal) so cf-error-port renders.
      • Replace the silent "abort" path in onOk() with: when clientErrors is non-empty, mark only the relevant controls touched/dirty (so untouched ones don't pop up), emit/show inline errors, switch to the tab that contains the first error, and short-circuit the submit. Clear these client errors when the user edits a field.
      • Change <input id="cf-port" type="number" ...> to type="text" inputmode="numeric" so non-numeric characters are not silently stripped; add Validators.pattern(/^\d+$/) and an integer validator (Validators.min(1), Validators.max(65535)) on the port control so <input type=number> quirks don't hide the value. Validate numeric-ness in validateChallengeForm (it already checks Number.isInteger).
      • Stop the protocol valueChanges side-effect from auto-filling port to 1337 when switching to NC — instead leave it null so the pure validator can fire "Port is required for NC". (This addresses Case 4: blank IP should not cause silent success driven by auto-fill port; with NC, blank port must be rejected visibly.)
      • Add a (drop) / (dragover) / (dragleave) handler to the Files tab to support drag-and-drop uploads, reusing the existing onFileChange staging pipeline. Reject oversize files using the same globalFileLimit check.
      • Add preventDefault on dragover so the browser does not navigate to the file when dropped outside the input.
    • frontend/src/app/features/admin/challenges/challenge-form-modal.component.ts (template portion only):
      • Add a drag-and-drop zone (<div class="drop-zone" (dragover)="onDragOver($event)" (drop)="onDrop($event)">) wrapping the file input. Show visual state on dragover.
    • frontend/src/app/features/admin/challenges/challenge-form.pure.ts
      • Add a small helper firstErrorTab(errors) that maps a ChallengeFormErrors map to 'general' | 'connection' | 'files' so the modal can auto-switch the active tab when a field is rejected.
      • Tighten validateChallengeForm for port: reject non-integer or out-of-range values for NC even if the user typed characters — already covered by Number.isInteger/min/max. (No logic change needed; just rely on it.)
    • tests/frontend/admin-challenges-form.spec.ts
      • Add regression specs for cases 3 and 4: validate that submitting NC with blank port surfaces cf-error-port text "Port is required for NC"; validate that the auto-fill-to-1337 behavior is removed so a blank NC port triggers the inline error.
      • Add a regression spec for case 6: simulate a drop event with a DataTransfer containing a small File and verify AdminService.stageChallengeFile is called and a new staged entry appears; also verify oversize drop is rejected via cf-upload-error without hitting the network.
  • To Create:

    • None. All edits go into existing files. (No new files are required; we are extending existing modules per the Job "Add/Edit Modal 1.01" patch.)

3. Proposed Changes

3.1 Backend — no changes required

  • The server already returns 400 VALIDATION_FAILED with details[].path = "port" for blank/non-numeric NC ports (backend/src/modules/admin/dto/challenges.dto.ts Zod schema enforces port: z.number().int().min(1).max(65535).optional() with conditional refinement for NC). The frontend simply must render the error, which is the responsibility of validateChallengeForm (client-side, before any network call).
  • The file-size limit is already enforced by challenge-files.service.ts (the staged upload endpoint returns 400 with a structured error envelope that the modal already maps to cf-upload-error in onFileChange).

3.2 Frontend — challenge-form-modal.component.ts

  1. Stop auto-filling port on protocol change.

    • In the protocol.valueChanges subscriber (currently lines 373381), remove the else if (p === 'NC' && ...) branch that sets port to 1337. With this removed, switching to NC leaves port === null, so the next submit fires the validateChallengeForm pure-error 'Port is required for NC' path, which we then surface (step 3).
  2. Surface validateChallengeForm errors inline.

    • Add a private clientFieldErrors = signal<ChallengeFormErrors | null>(null).
    • In onOk():
      const clientErrors = validateChallengeForm(values);
      if (Object.keys(clientErrors).length > 0) {
        this.clientFieldErrors.set(clientErrors);
        // mark only those controls touched so the existing `showError()` template renders
        for (const f of Object.keys(clientErrors) as (keyof ChallengeFormErrors)[]) {
          const ctrl = this.form.controls[f as keyof ChallengeFormControls];
          if (ctrl) { ctrl.markAsTouched(); ctrl.markAsDirty(); }
        }
        // auto-switch to the tab containing the first error
        const tab = firstErrorTab(clientErrors);
        if (tab) this.tab.set(tab);
        return;
      }
      this.clientFieldErrors.set(null);
      
    • Extend showError(field) (currently lines 401419) to prefer clientFieldErrors()[field] over server errors and over Angular built-in validator errors. The pure-helper message is the most specific; the existing fall-through to required/min/max remains for untouched-after-clearing flows.
    • Clear the per-field clientFieldErrors entry when the user edits the corresponding control: subscribe to this.form.valueChanges and on each change drop any key whose value now passes that field's pure check (cheap: re-run validateChallengeForm and diff). This keeps the error visible until the user actually fixes the input.
  3. Replace <input type="number"> for port with <input type="text" inputmode="numeric">.

    • Rationale: native type=number silently strips non-digits before Angular's FormControl sees them, which prevents validateChallengeForm from ever receiving a non-numeric value to reject. Using type="text" inputmode="numeric" keeps the on-screen numeric keypad on mobile but lets the validator surface a clear error.
    • Add to the port control: Validators.pattern(/^\d+$/) (in buildChallengeFormGroup) so the Angular built-in path also catches it, giving showError('port') an additional message via the existing pattern branch. (Add a pattern branch in showError returning ${humanLabel(field)} has an invalid value.)
  4. Add drag-and-drop support to the Files tab.

    • Template change (inside the tab() === 'files' block):
      <div class="drop-zone"
           [class.dragover]="dragOver()"
           (dragover)="onDragOver($event)"
           (dragleave)="onDragLeave($event)"
           (drop)="onDrop($event)"
           data-testid="cf-drop-zone">
        <p>Drop files here or use the picker below</p>
        <input id="cf-upload" type="file" multiple (change)="onFileChange($event)" data-testid="cf-upload" />
      </div>
      
    • Component:
      readonly dragOver = signal(false);
      onDragOver(ev: DragEvent): void { ev.preventDefault(); this.dragOver.set(true); }
      onDragLeave(ev: DragEvent): void { this.dragOver.set(false); }
      onDrop(ev: DragEvent): void {
        ev.preventDefault();
        this.dragOver.set(false);
        const files = ev.dataTransfer?.files ? Array.from(ev.dataTransfer.files) : [];
        if (files.length === 0) return;
        // Reuse the same staging pipeline as the picker:
        void this.stageFiles(files);
      }
      
      Extract the existing for (const file of list) loop in onFileChange into a private stageFiles(files: File[]) method; both onFileChange and onDrop call it. Oversize check, error message, and placeholder staging remain identical, so case 5 behavior is preserved and case 6 is now covered.

3.3 Frontend — challenge-form.pure.ts

  • Add firstErrorTab(errors) mapping name|descriptionMd|categoryId|difficulty|initialPoints|minimumPoints|decaySolves|flag → 'general', protocol|port|ipAddress → 'connection', empty otherwise.

3.4 Tests (regression only, in dedicated folder)

Add cases to tests/frontend/admin-challenges-form.spec.ts:

  1. Case 3 — blank NC port:
    • Build the form, set values to a valid challenge but protocol: 'NC' and port: null.
    • Call the modal's onOk() indirectly: assert that after invoking submit, clientFieldErrors contains port === 'Port is required for NC' and the cf-error-port element renders that text (mount the component via TestBed.createComponent).
    • Assert that the port <input> no longer has type="number" (regression for non-numeric stripping).
  2. Case 3 — non-numeric NC port:
    • Simulate user typing "12a3" into the port field. Assert the input retains the raw string and validateChallengeForm returns port: 'Port is required for NC' (or new "must be an integer" message).
  3. Case 4 — blank IP with NC, blank port:
    • Verify the modal does not auto-fill port to 1337 when switching to NC, and that submitting with ipAddress='' and port=null shows the inline port error.
  4. Case 6 — drag-and-drop:
    • Stub AdminService.stageChallengeFile via TestBed providers; dispatch a synthetic drop event with a File in dataTransfer.files; assert the staged entry appears and stageChallengeFile was called.
    • Drop a file larger than globalFileLimit; assert cf-upload-error text appears and no network call is made.

(For new tests the existing tests/frontend/admin-challenges-form.spec.ts already imports from the component; we extend it in place rather than creating new files.)

4. Test Strategy

  • Target Unit Test File:
    • tests/frontend/admin-challenges-form.spec.ts (existing; extend with the four cases above).
  • Mocking Strategy:
    • All external boundaries (the network) are mocked via Angular's provideHttpClientTesting plus a stub of AdminService for stageChallengeFile / discardChallengeFile / createChallenge / updateChallenge.
    • The drag-and-drop test stubs AdminService.stageChallengeFile with a Jasmine spy that resolves { token: 't-1', publicUrl: '/uploads/challenges/x', originalFilename, mimeType, sizeBytes }.
    • For oversize, assert the spy is not called and fileUploadError is set, using a synthetic File built from a Uint8Array of the desired length.
    • Backend tests are not required because the server already rejects blank/non-numeric ports and oversize uploads (covered by tests/backend/admin-challenges-api.spec.ts and tests/backend/uploads.spec.ts).

5. Out of Scope / Non-changes

  • No backend changes (server already returns the right error envelopes).
  • No new dependencies, no setup.sh edits, no migration scripts, no DB schema changes.
  • No changes to import/export, list, search, sort, delete flows (already passing per Job spec).
  • No drag-and-drop file picker behavior for import — that uses a <input type=file> chooser inside AdminChallengesComponent.pickImportFile, which the Job does not flag.