package matching import ( "encoding/json" "strings" "testing" ) // Normalize is what stands between stored user input and a query, so it is exercised with the values a // person or an attacker actually supplies. func TestNormalizeBoundsEverything(t *testing.T) { huge := strings.Repeat("x", 5000) terms := make([]string, 200) for i := range terms { terms[i] = "skill" + string(rune('a'+i%26)) + string(rune('a'+i/26)) } q := CandidateQuery{ Text: huge, Location: huge, Skills: terms, ClearanceLevels: terms, Limit: 100000, Offset: -5, } q.Normalize() if len(q.Text) > 400 { t.Errorf("text not bounded: %d", len(q.Text)) } if len(q.Location) > 120 { t.Errorf("location not bounded: %d", len(q.Location)) } if len(q.Skills) > maxSearchTerms { t.Errorf("skills not bounded: %d, cap is %d", len(q.Skills), maxSearchTerms) } if len(q.ClearanceLevels) > maxSearchTerms { t.Errorf("clearance levels not bounded: %d", len(q.ClearanceLevels)) } if q.Limit != maxSearchLimit { t.Errorf("limit not clamped: %d", q.Limit) } if q.Offset != 0 { t.Errorf("negative offset not corrected: %d", q.Offset) } // An empty query gets a workable default rather than zero, which would return nothing and read as an // empty bench. var empty CandidateQuery empty.Normalize() if empty.Limit != defaultSearchLimit { t.Errorf("default limit is %d, expected %d", empty.Limit, defaultSearchLimit) } } func TestNormalizeDedupesAndTrims(t *testing.T) { q := CandidateQuery{Skills: []string{" Go ", "go", "GO", "", " ", "Rust"}} q.Normalize() if len(q.Skills) != 2 { t.Fatalf("expected Go and Rust after dedupe, got %v", q.Skills) } if q.Skills[0] != "Go" { t.Errorf("expected the trimmed first spelling to survive, got %q", q.Skills[0]) } // Blanks must never survive: an empty skill term would match nothing and silently empty the result. for _, s := range q.Skills { if strings.TrimSpace(s) == "" { t.Error("a blank term survived normalization") } } } func TestNegativeNumbersCorrected(t *testing.T) { neg, negDays := -5, -30 q := CandidateQuery{MinYears: &neg, ClearanceExpiringDays: &negDays} q.Normalize() if q.MinYears == nil || *q.MinYears != 0 { t.Errorf("negative min years should clamp to zero, got %v", q.MinYears) } if q.ClearanceExpiringDays != nil { t.Error("a negative expiry window is meaningless and should be dropped, not queried") } } // A saved search round trips through JSON and must come back the same, RE NORMALIZED. Stored criteria are // user input that has been sitting in a table. func TestCriteriaRoundTrip(t *testing.T) { yes := true min := 5 in := CandidateQuery{ Text: "network engineer", ClearanceLevels: []string{"TS/SCI"}, Skills: []string{"Cisco"}, Location: "Columbia, MD", Available: &yes, MinYears: &min, Limit: 77, Offset: 33, } b, err := MarshalCriteria(in) if err != nil { t.Fatalf("marshal: %v", err) } // PAGING IS NOT PART OF WHAT WAS SAVED. A search saved on page four should not reopen on page four. if strings.Contains(string(b), `"limit"`) || strings.Contains(string(b), `"offset"`) { t.Errorf("paging leaked into the saved criteria: %s", b) } out, err := UnmarshalCriteria(b) if err != nil { t.Fatalf("unmarshal: %v", err) } if out.Text != in.Text || out.Location != in.Location { t.Errorf("round trip lost fields: %+v", out) } if out.Available == nil || !*out.Available { t.Error("round trip lost the available filter") } if out.MinYears == nil || *out.MinYears != 5 { t.Error("round trip lost min years") } // It comes back normalized, so a limit is present and workable. if out.Limit != defaultSearchLimit { t.Errorf("criteria came back with limit %d, it must be re normalized", out.Limit) } } // A corrupt blob must be an error the caller can handle, never a panic and never a silent empty query that // would return the whole bench. func TestCorruptCriteriaRefused(t *testing.T) { for _, bad := range []string{`{not json`, `[1,2,3]`, `"a string"`} { if _, err := UnmarshalCriteria([]byte(bad)); err == nil { t.Errorf("corrupt criteria %q should be refused", bad) } } // Empty is not corrupt, it is a search with no filters, and it must still come back normalized. q, err := UnmarshalCriteria(nil) if err != nil { t.Errorf("empty criteria should be accepted: %v", err) } if q.Limit != 0 { t.Errorf("a nil blob short circuits before normalize, got limit %d", q.Limit) } } // The struct must serialise without the empty fields, so a saved search stays small and readable. func TestCriteriaOmitsEmptyFields(t *testing.T) { b, _ := MarshalCriteria(CandidateQuery{Text: "cleared"}) var m map[string]any if err := json.Unmarshal(b, &m); err != nil { t.Fatalf("unmarshal: %v", err) } if len(m) != 1 { t.Errorf("expected only the text field, got %v", m) } } func TestParseUUID(t *testing.T) { if _, ok := parseUUID(""); ok { t.Error("an empty string is not an identifier") } if _, ok := parseUUID(" "); ok { t.Error("whitespace is not an identifier") } if _, ok := parseUUID("'; DROP TABLE candidates--"); ok { t.Error("an injection attempt must not parse as an identifier") } if _, ok := parseUUID("6ba7b810-9dad-11d1-80b4-00c04fd430c8"); !ok { t.Error("a valid identifier should parse") } } // THE DEFECT THE SEARCH FOUND, locked so it cannot come back. // // The persisted value is the CODE, ts_sci; the interface shows the LABEL, TS/SCI. Storing accepted only the // code and silently degraded anything else to "unknown", and searching compared raw input against the column. // So a candidate saved with the label lost their clearance, and a recruiter searching with the label got an // empty bench. Neither raised an error. func TestClearanceLabelsAndCodesBothResolve(t *testing.T) { cases := map[string]string{ "ts_sci": "ts_sci", "TS/SCI": "ts_sci", "ts/sci": "ts_sci", "TS SCI": "ts_sci", "tssci": "ts_sci", "top secret/sci": "ts_sci", "top_secret": "top_secret", "Top Secret": "top_secret", "TS": "top_secret", "secret": "secret", "Secret": "secret", "S": "secret", "confidential": "confidential", "Confidential": "confidential", "none": "none", "None": "none", } for in, want := range cases { got, ok := clearanceLevelCode(in) if !ok { t.Errorf("clearanceLevelCode(%q) was not recognised, it must resolve to %q", in, want) continue } if got != want { t.Errorf("clearanceLevelCode(%q) = %q, want %q", in, got, want) } } // STORING AND SEARCHING SHARE THE TRANSLATION, so what can be saved and what can be found are the same // vocabulary by construction rather than by two lists somebody keeps in step. for in, want := range cases { if got := validLevelCode(in); got != want { t.Errorf("validLevelCode(%q) = %q, want %q, storing and searching have drifted", in, got, want) } } } // Anything unrecognised is DROPPED from a filter rather than passed through, because an unmatchable term // silently empties the result set, which looks exactly like a real answer. func TestUnrecognisedClearanceDropped(t *testing.T) { got := canonicalClearanceLevels([]string{"TS/SCI", "nonsense", "", " ", "Secret", "ts_sci"}) if len(got) != 2 { t.Fatalf("expected ts_sci and secret after canonicalisation and dedupe, got %v", got) } if got[0] != "ts_sci" || got[1] != "secret" { t.Errorf("unexpected canonical set: %v", got) } if len(canonicalClearanceLevels([]string{"nonsense", "invalid"})) != 0 { t.Error("a filter of only unrecognised terms must produce no predicate at all") } // Status has the same contract. if s := canonicalClearanceStatus([]string{"active", "ACTIVE", "bogus"}); len(s) != 1 || s[0] != "active" { t.Errorf("status canonicalisation wrong: %v", s) } }