package httpserver_test import ( "bytes" "context" "encoding/json" "io" "net/http" "net/http/httptest" "sort" "strings" "testing" "time" "github.com/krow/krow-backend/go-api/internal/db" "github.com/krow/krow-backend/go-api/internal/httpserver" "github.com/krow/krow-backend/go-api/internal/testutil" ) type api struct { t *testing.T handler http.Handler orgID string h *testutil.Harness srv *httpserver.Server // The signed-in session every do() call carries, and who it belongs to. cookie *http.Cookie userID string email string } // newAPI builds a server and signs in as the seeded user. // // The sign-in is part of the harness rather than part of each test because // every endpoint below now requires one: without it the thirty-odd existing // tests in this file would all assert 401 instead of what they were written to // check. They are unchanged; the cookie travels in do(). func newAPI(t *testing.T, opts ...httpserver.Option) *api { t.Helper() h := testutil.New(t) srv := newServer(t, h, nil, opts...) a := &api{t: t, handler: srv.Handler(), orgID: h.OrgID, h: h, srv: srv} a.userID, a.email = seededUser(t, h.Pool) setPassword(t, h.Pool, a.userID) result := signIn(t, a.handler, a.email, harnessPassword, false) if result.code != http.StatusOK || result.cookie == nil { t.Fatalf("the harness could not sign in: status %d, cookie %v", result.code, result.cookie) } a.cookie = result.cookie return a } type response struct { code int body map[string]any } func (a *api) do(method, path string, payload any) response { a.t.Helper() var body io.Reader if payload != nil { raw, err := json.Marshal(payload) if err != nil { a.t.Fatalf("encode payload: %v", err) } body = bytes.NewReader(raw) } req := httptest.NewRequest(method, path, body) if a.cookie != nil { req.AddCookie(a.cookie) } rec := httptest.NewRecorder() a.handler.ServeHTTP(rec, req) out := response{code: rec.Code} if rec.Body.Len() > 0 { if err := json.Unmarshal(rec.Body.Bytes(), &out.body); err != nil { a.t.Fatalf("%s %s: response is not JSON: %s", method, path, rec.Body.String()) } } return out } // doAnon is do() without the session cookie: the request a signed-out browser, // or anyone who has never signed in, actually sends. func (a *api) doAnon(method, path string, payload any) response { a.t.Helper() var body io.Reader if payload != nil { raw, err := json.Marshal(payload) if err != nil { a.t.Fatalf("encode payload: %v", err) } body = bytes.NewReader(raw) } req := httptest.NewRequest(method, path, body) rec := httptest.NewRecorder() a.handler.ServeHTTP(rec, req) out := response{code: rec.Code} if rec.Body.Len() > 0 { _ = json.Unmarshal(rec.Body.Bytes(), &out.body) } return out } // as is do() performed by a specific actor, for the role and ownership tests. func (a *api) as(act actor, method, path string, payload any) response { a.t.Helper() var body io.Reader if payload != nil { raw, err := json.Marshal(payload) if err != nil { a.t.Fatalf("encode payload: %v", err) } body = bytes.NewReader(raw) } req := httptest.NewRequest(method, path, body) if act.cookie != nil { req.AddCookie(act.cookie) } rec := httptest.NewRecorder() a.handler.ServeHTTP(rec, req) out := response{code: rec.Code} if rec.Body.Len() > 0 { _ = json.Unmarshal(rec.Body.Bytes(), &out.body) } return out } // codeOrEmpty reads the contract's error code, or "" when the response carried // no error envelope. Distinct from errCode, which fails the test when there is // no error: the role matrix needs to look at successes and refusals alike. func (r response) codeOrEmpty() string { body, _ := r.body["error"].(map[string]any) if body == nil { return "" } code, _ := body["code"].(string) return code } // doWith is do() with a caller-supplied cookie, for tests that hold more than // one session. func (a *api) doWith(cookie *http.Cookie, method, path string) response { a.t.Helper() req := httptest.NewRequest(method, path, nil) if cookie != nil { req.AddCookie(cookie) } rec := httptest.NewRecorder() a.handler.ServeHTTP(rec, req) out := response{code: rec.Code} if rec.Body.Len() > 0 { _ = json.Unmarshal(rec.Body.Bytes(), &out.body) } return out } func (r response) records(t *testing.T) []map[string]any { t.Helper() raw, ok := r.body["data"].([]any) if !ok { t.Fatalf("expected a data array, got %#v", r.body) } out := make([]map[string]any, 0, len(raw)) for _, e := range raw { out = append(out, e.(map[string]any)) } return out } func (r response) record(t *testing.T) map[string]any { t.Helper() rec, ok := r.body["data"].(map[string]any) if !ok { t.Fatalf("expected a data object, got %#v", r.body) } return rec } func (r response) meta(t *testing.T) map[string]any { t.Helper() m, ok := r.body["meta"].(map[string]any) if !ok { t.Fatalf("expected meta, got %#v", r.body) } return m } func (r response) errCode(t *testing.T) string { t.Helper() e, ok := r.body["error"].(map[string]any) if !ok { t.Fatalf("expected an error envelope, got %#v", r.body) } return e["code"].(string) } /* ── Collections ────────────────────────────────────────────────────────── */ func TestListEveryResource(t *testing.T) { a := newAPI(t) // Every collection endpoint answers 200 with an envelope, seeded or not. for _, path := range []string{ "job-postings", "job-applications", "ai-interviews", "staff", "worker-profiles", "courses", "learning-paths", "role-categories", "certifications", "user-activity", "evidence", "assignments", "shift-records", "employee-roles", } { r := a.do("GET", "/api/v1/"+path, nil) if r.code != http.StatusOK { t.Errorf("GET %s = %d, want 200", path, r.code) continue } r.records(t) r.meta(t) } } // An empty result is 200 with an empty array, never a 404. api-contract.md §8. // // Asked as a filter that matches nothing, rather than as a collection that // happens to be empty. This used to read /assignments on the strength of the // fixture shipping none, so seeding a single assignment broke a test about // status codes. The contract is about the empty result, not about which // collection is empty this week. func TestEmptyCollectionIs200(t *testing.T) { a := newAPI(t) r := a.do("GET", "/api/v1/assignments?status=cancelled", nil) if r.code != http.StatusOK { t.Fatalf("code = %d, want 200", r.code) } if got := r.records(t); len(got) != 0 { t.Fatalf("expected no assignments, got %d", len(got)) } if total := r.meta(t)["total"].(float64); total != 0 { t.Errorf("meta.total = %v, want 0", total) } } // The default limit is the resource's own, taken from the frontend call site. // job-applications is 200 sorted -ai_score; shift-records is 500. func TestEndpointSpecificDefaults(t *testing.T) { a := newAPI(t) for _, tc := range []struct { path string limit float64 }{ {"job-postings", 100}, {"job-applications", 200}, {"shift-records", 500}, {"worker-profiles", 500}, {"courses", 200}, {"user-activity", 500}, {"ai-interviews", 100}, {"staff", 100}, {"role-categories", 100}, {"certifications", 200}, {"evidence", 200}, {"assignments", 500}, {"learning-paths", 100}, {"employee-roles", 200}, } { m := a.do("GET", "/api/v1/"+tc.path, nil).meta(t) if m["limit"] != tc.limit { t.Errorf("%s default limit = %v, want %v", tc.path, m["limit"], tc.limit) } } } func TestLimitAndTruncationMeta(t *testing.T) { a := newAPI(t) r := a.do("GET", "/api/v1/job-applications?limit=5", nil) recs, m := r.records(t), r.meta(t) if len(recs) != 5 { t.Fatalf("returned %d records, want 5", len(recs)) } if m["returned"] != float64(5) { t.Errorf("meta.returned = %v, want 5", m["returned"]) } if m["total"] != float64(24) { t.Errorf("meta.total = %v, want 24 (the total ignores the limit)", m["total"]) } if m["truncated"] != true { t.Error("meta.truncated should be true when the page does not reach the total") } full := a.do("GET", "/api/v1/job-applications", nil) if full.meta(t)["truncated"] != false { t.Error("meta.truncated should be false when everything fits") } } func TestOffsetPaging(t *testing.T) { a := newAPI(t) first := a.do("GET", "/api/v1/job-applications?limit=10", nil).records(t) second := a.do("GET", "/api/v1/job-applications?limit=10&offset=10", nil).records(t) if len(first) != 10 || len(second) != 10 { t.Fatalf("page sizes = %d, %d", len(first), len(second)) } seen := map[string]bool{} for _, r := range first { seen[r["id"].(string)] = true } for _, r := range second { if seen[r["id"].(string)] { t.Fatalf("record %s appeared on both pages", r["id"]) } } } /* ── Sorting ────────────────────────────────────────────────────────────── */ // The default sort is the resource's own: -ai_score for applications. func TestDefaultSortIsResourceSpecific(t *testing.T) { a := newAPI(t) recs := a.do("GET", "/api/v1/job-applications", nil).records(t) prev := 101.0 for _, r := range recs { score := r["ai_score"].(float64) if score > prev { t.Fatalf("applications are not sorted by -ai_score: %v after %v", score, prev) } prev = score } profiles := a.do("GET", "/api/v1/worker-profiles", nil).records(t) prev = 1e9 for _, r := range profiles { score := r["krow_score"].(float64) if score > prev { t.Fatalf("profiles are not sorted by -krow_score: %v after %v", score, prev) } prev = score } } // NULLS LAST in BOTH directions. store.js returns before applying the // descending negation, so a null is greater than everything either way. // PostgreSQL's default is NULLS FIRST on DESC, so the descending case is the // one that breaks if the ordering is left implicit. api-contract.md §7.1. func TestNullsSortLastInBothDirections(t *testing.T) { a := newAPI(t) // Every seeded posting has a null start_date, so a deliberate mix is built // here — otherwise the assertion passes trivially and proves nothing. for _, d := range []any{"2026-09-01", nil, "2026-07-15", nil, "2026-08-20"} { payload := map[string]any{"title": "Nulls Test"} if d != nil { payload["start_date"] = d } if r := a.do("POST", "/api/v1/job-postings", payload); r.code != http.StatusCreated { t.Fatalf("setup create = %d: %#v", r.code, r.body) } } for _, sortSpec := range []string{"start_date", "-start_date"} { recs := a.do("GET", "/api/v1/job-postings?sort="+sortSpec+"&limit=500", nil).records(t) var values []any for _, r := range recs { values = append(values, r["start_date"]) } firstNull := -1 for i, v := range values { if v == nil { firstNull = i break } } if firstNull == -1 { t.Fatalf("sort=%s: no nulls present, the test is not exercising anything", sortSpec) } for i := firstNull; i < len(values); i++ { if values[i] != nil { t.Fatalf("sort=%s: %v appears at position %d, after a null at %d — NULLS LAST is not applied", sortSpec, values[i], i, firstNull) } } if firstNull < 3 { t.Fatalf("sort=%s: only %d non-null values sorted before the nulls, expected 3", sortSpec, firstNull) } // And the non-null values are genuinely ordered. for i := 1; i < firstNull; i++ { prev, cur := values[i-1].(string), values[i].(string) if sortSpec == "start_date" && cur < prev { t.Errorf("ascending order broken: %s after %s", cur, prev) } if sortSpec == "-start_date" && cur > prev { t.Errorf("descending order broken: %s after %s", cur, prev) } } } } // PostgreSQL does not guarantee a stable sort. Every ORDER BY appends `, id` // so repeated identical requests return the same order. api-contract.md §7.3. func TestStableSortWithIDTiebreaker(t *testing.T) { a := newAPI(t) // Many applications share ai_score 0, so the tiebreaker decides their order. var first []string for attempt := 0; attempt < 5; attempt++ { recs := a.do("GET", "/api/v1/job-applications?sort=-ai_score", nil).records(t) ids := make([]string, 0, len(recs)) for _, r := range recs { ids = append(ids, r["id"].(string)) } if attempt == 0 { first = ids continue } for i := range ids { if ids[i] != first[i] { t.Fatalf("order changed between identical requests at position %d", i) } } } // And the tiebreaker really is id: within a score group, ids ascend. recs := a.do("GET", "/api/v1/job-applications?sort=-ai_score", nil).records(t) for i := 1; i < len(recs); i++ { if recs[i]["ai_score"] != recs[i-1]["ai_score"] { continue } if recs[i]["id"].(string) < recs[i-1]["id"].(string) { t.Fatalf("ids do not ascend within an equal-score group: %s after %s", recs[i]["id"], recs[i-1]["id"]) } } } func TestSortAscendingAndUnknownField(t *testing.T) { a := newAPI(t) recs := a.do("GET", "/api/v1/job-applications?sort=ai_score", nil).records(t) prev := -1.0 for _, r := range recs { if score := r["ai_score"].(float64); score < prev { t.Fatalf("ascending sort broken: %v after %v", score, prev) } else { prev = score } } r := a.do("GET", "/api/v1/job-applications?sort=-nonsense", nil) if r.code != http.StatusBadRequest { t.Errorf("unknown sort field = %d, want 400", r.code) } if code := r.errCode(t); code != "invalid_query" { t.Errorf("error code = %q, want invalid_query", code) } } /* ── Filtering ──────────────────────────────────────────────────────────── */ func TestFilterEquality(t *testing.T) { a := newAPI(t) postings := a.do("GET", "/api/v1/job-postings", nil).records(t) var target string for _, p := range postings { if p["legacy_id"] == "job_security" { target = p["id"].(string) } } if target == "" { t.Fatal("job_security posting not found") } recs := a.do("GET", "/api/v1/job-applications?job_posting_id="+target, nil).records(t) if len(recs) != 6 { t.Errorf("applications for job_security = %d, want 6", len(recs)) } for _, r := range recs { if r["job_posting_id"] != target { t.Errorf("filter leaked a record from posting %v", r["job_posting_id"]) } } } // An array-valued query parameter means membership, matching store.js's // `Array.isArray(want) ? want.includes(got)`. api-contract.md §6. func TestFilterArrayMeansIN(t *testing.T) { a := newAPI(t) // `assigned` is asked for deliberately: the fixture now carries one, and this // filter is literal — it matches the stored value, not the product's rule // that an assigned candidate also counts as hired. recs := a.do("GET", "/api/v1/job-applications?status=hired&status=interview&status=assigned", nil).records(t) if len(recs) != 8 { t.Errorf("hired+interview+assigned = %d, want 8 (2 hired, 5 interview, 1 assigned)", len(recs)) } for _, r := range recs { if s := r["status"].(string); s != "hired" && s != "interview" && s != "assigned" { t.Errorf("membership filter leaked status %q", s) } } } // Email columns are citext, so matching is case-insensitive server-side. // api-contract.md §6.1. func TestFilterEmailIsCaseInsensitive(t *testing.T) { a := newAPI(t) lower := a.do("GET", "/api/v1/worker-profiles?email=maria.gonzalez@example.com", nil).records(t) upper := a.do("GET", "/api/v1/worker-profiles?email=MARIA.GONZALEZ@EXAMPLE.COM", nil).records(t) if len(lower) != 1 { t.Fatalf("expected exactly one profile, got %d", len(lower)) } if len(upper) != len(lower) { t.Errorf("case-insensitive match failed: %d vs %d", len(upper), len(lower)) } } func TestFilterRejectsUnknownAndUnfilterableFields(t *testing.T) { a := newAPI(t) if r := a.do("GET", "/api/v1/job-postings?nonsense=1", nil); r.code != http.StatusBadRequest { t.Errorf("unknown filter field = %d, want 400", r.code) } // Arrays are not filterable: store.js compares with === and matches nothing, // so supporting containment here would be a silent behaviour change. if r := a.do("GET", "/api/v1/job-postings?responsibilities=x", nil); r.code != http.StatusBadRequest { t.Errorf("array filter = %d, want 400", r.code) } if r := a.do("GET", "/api/v1/job-postings?vetting_criteria=x", nil); r.code != http.StatusBadRequest { t.Errorf("jsonb filter = %d, want 400", r.code) } } /* ── Get ────────────────────────────────────────────────────────────────── */ func TestGetAndNotFound(t *testing.T) { a := newAPI(t) postings := a.do("GET", "/api/v1/job-postings", nil).records(t) id := postings[0]["id"].(string) r := a.do("GET", "/api/v1/job-postings/"+id, nil) if r.code != http.StatusOK { t.Fatalf("get = %d, want 200", r.code) } if r.record(t)["id"] != id { t.Error("returned the wrong record") } missing := a.do("GET", "/api/v1/job-postings/00000000-0000-0000-0000-000000000000", nil) if missing.code != http.StatusNotFound { t.Errorf("missing record = %d, want 404", missing.code) } if code := missing.errCode(t); code != "not_found" { t.Errorf("error code = %q, want not_found", code) } // store.js throws " not found" using the frontend entity name. msg := missing.body["error"].(map[string]any)["message"].(string) if want := "JobPosting 00000000-0000-0000-0000-000000000000 not found"; msg != want { t.Errorf("message = %q, want %q", msg, want) } // A malformed id is simply an id that cannot be found. if r := a.do("GET", "/api/v1/job-postings/not-a-uuid", nil); r.code != http.StatusNotFound { t.Errorf("malformed id = %d, want 404", r.code) } } /* ── Create ─────────────────────────────────────────────────────────────── */ func TestCreateAppliesDefaultsAndReturnsWholeRecord(t *testing.T) { a := newAPI(t) r := a.do("POST", "/api/v1/job-postings", map[string]any{"title": "Test Bartender"}) if r.code != http.StatusCreated { t.Fatalf("create = %d, want 201: %#v", r.code, r.body) } rec := r.record(t) if rec["title"] != "Test Bartender" { t.Errorf("title = %v", rec["title"]) } // The response is the complete record, defaults included. for _, field := range []string{"id", "created_date", "updated_date", "status", "vetting_criteria", "responsibilities"} { if _, ok := rec[field]; !ok { t.Errorf("created record is missing %s", field) } } if rec["status"] != "draft" { t.Errorf("default status = %v, want draft", rec["status"]) } if rec["headcount"] != float64(1) { t.Errorf("default headcount = %v, want 1", rec["headcount"]) } } func TestCreateRejectsMissingRequiredAndBlank(t *testing.T) { a := newAPI(t) r := a.do("POST", "/api/v1/job-postings", map[string]any{}) if r.code != http.StatusUnprocessableEntity { t.Fatalf("missing title = %d, want 422", r.code) } if code := r.errCode(t); code != "validation_failed" { t.Errorf("code = %q, want validation_failed", code) } if r := a.do("POST", "/api/v1/job-postings", map[string]any{"title": " "}); r.code != http.StatusUnprocessableEntity { t.Errorf("blank title = %d, want 422", r.code) } } // Unknown fields are rejected, not ignored. Silently dropping them is exactly // how interview_id, training_outline and score_breakdown would have been lost. func TestCreateRejectsUnknownFields(t *testing.T) { a := newAPI(t) r := a.do("POST", "/api/v1/job-postings", map[string]any{"title": "X", "not_a_column": 1}) if r.code != http.StatusUnprocessableEntity { t.Fatalf("unknown field = %d, want 422", r.code) } details := r.body["error"].(map[string]any)["details"].(map[string]any) if details["not_a_column"] == nil { t.Errorf("the offending field is not named in details: %#v", details) } } // Server-owned fields are ignored rather than rejected. api-contract.md §3.1. func TestCreateIgnoresServerOwnedFields(t *testing.T) { a := newAPI(t) r := a.do("POST", "/api/v1/job-postings", map[string]any{ "title": "Ignore Me", "id": "11111111-1111-1111-1111-111111111111", "created_date": "2001-01-01T00:00:00.000Z", }) if r.code != http.StatusCreated { t.Fatalf("create = %d, want 201: %#v", r.code, r.body) } rec := r.record(t) if rec["id"] == "11111111-1111-1111-1111-111111111111" { t.Error("a client-supplied id was honoured") } if rec["created_date"] == "2001-01-01T00:00:00.000Z" { t.Error("a client-supplied created_date was honoured") } } func TestCreateRejectsInvalidEnum(t *testing.T) { a := newAPI(t) r := a.do("POST", "/api/v1/job-postings", map[string]any{"title": "X", "status": "archived"}) if r.code != http.StatusUnprocessableEntity { t.Fatalf("invalid enum = %d, want 422", r.code) } details := r.body["error"].(map[string]any)["details"].(map[string]any) if details["status"] == nil { t.Error("details should name the status field") } } func TestCreateEnforcesForeignKeys(t *testing.T) { a := newAPI(t) r := a.do("POST", "/api/v1/job-applications", map[string]any{ "job_posting_id": "00000000-0000-0000-0000-000000000000", "applicant_name": "Nobody", "email": "nobody@example.com", }) if r.code != http.StatusUnprocessableEntity { t.Fatalf("dangling foreign key = %d, want 422: %#v", r.code, r.body) } } func TestCreateEnforcesUniqueness(t *testing.T) { a := newAPI(t) apps := a.do("GET", "/api/v1/job-applications", nil).records(t) existing := apps[0] r := a.do("POST", "/api/v1/job-applications", map[string]any{ "job_posting_id": existing["job_posting_id"], "applicant_name": "Duplicate", "email": existing["email"], }) if r.code != http.StatusConflict { t.Fatalf("duplicate (job_posting_id, email) = %d, want 409: %#v", r.code, r.body) } if code := r.errCode(t); code != "conflict" { t.Errorf("code = %q, want conflict", code) } } /* ── Update ─────────────────────────────────────────────────────────────── */ // PATCH is a shallow merge: absent keys are untouched, and a supplied object // REPLACES rather than merging into the stored one. api-contract.md §3.2. func TestPatchIsShallowMerge(t *testing.T) { a := newAPI(t) created := a.do("POST", "/api/v1/job-postings", map[string]any{ "title": "Shallow", "company": "Acme", "location": "Nowhere", "responsibilities": []string{"a", "b"}, }).record(t) id := created["id"].(string) patched := a.do("PATCH", "/api/v1/job-postings/"+id, map[string]any{"location": "Somewhere"}).record(t) if patched["location"] != "Somewhere" { t.Errorf("location = %v, want Somewhere", patched["location"]) } if patched["company"] != "Acme" { t.Errorf("an untouched field changed: company = %v", patched["company"]) } if patched["title"] != "Shallow" { t.Errorf("an untouched field changed: title = %v", patched["title"]) } // A nested object is replaced wholesale, not deep-merged. useSubmitChallenge // depends on whole arrays being replaced rather than appended to. withCriteria := a.do("PATCH", "/api/v1/job-postings/"+id, map[string]any{"vetting_criteria": map[string]any{"experience": 30}}).record(t) vc := withCriteria["vetting_criteria"].(map[string]any) if len(vc) != 1 || vc["experience"] != float64(30) { t.Errorf("vetting_criteria was deep-merged, not replaced: %#v", vc) } // Same for arrays. withArray := a.do("PATCH", "/api/v1/job-postings/"+id, map[string]any{"responsibilities": []string{"z"}}).record(t) resp := withArray["responsibilities"].([]any) if len(resp) != 1 || resp[0] != "z" { t.Errorf("responsibilities were appended rather than replaced: %#v", resp) } } func TestPatchUpdatesTimestampAndMissingIs404(t *testing.T) { a := newAPI(t) created := a.do("POST", "/api/v1/job-postings", map[string]any{"title": "Stamped"}).record(t) id := created["id"].(string) time.Sleep(5 * time.Millisecond) patched := a.do("PATCH", "/api/v1/job-postings/"+id, map[string]any{"title": "Restamped"}).record(t) if patched["updated_date"] == created["updated_date"] { t.Error("updated_date did not move on PATCH") } if patched["created_date"] != created["created_date"] { t.Error("created_date changed on PATCH") } missing := a.do("PATCH", "/api/v1/job-postings/00000000-0000-0000-0000-000000000000", map[string]any{"title": "Ghost"}) if missing.code != http.StatusNotFound { t.Errorf("patch on a missing record = %d, want 404", missing.code) } } // The interview_id round trip: the exact write AIInterviewModal.jsx:180 makes. func TestPatchApplicationInterviewID(t *testing.T) { a := newAPI(t) apps := a.do("GET", "/api/v1/job-applications?limit=1", nil).records(t) interviews := a.do("GET", "/api/v1/ai-interviews", nil).records(t) if len(apps) == 0 || len(interviews) == 0 { t.Fatal("need a seeded application and interview") } id := apps[0]["id"].(string) interviewID := interviews[0]["id"].(string) rec := a.do("PATCH", "/api/v1/job-applications/"+id, map[string]any{ "status": "interview", "interview_id": interviewID, "ai_score": 81, }).record(t) if rec["interview_id"] != interviewID { t.Errorf("interview_id = %v, want %v", rec["interview_id"], interviewID) } if rec["status"] != "interview" { t.Errorf("status = %v", rec["status"]) } if rec["ai_score"] != float64(81) { t.Errorf("ai_score = %v", rec["ai_score"]) } } // The two other reconciliation columns, round-tripped. func TestPatchTrainingOutlineAndProfileScoreBreakdown(t *testing.T) { a := newAPI(t) courses := a.do("GET", "/api/v1/courses?limit=1", nil).records(t) course := a.do("PATCH", "/api/v1/courses/"+courses[0]["id"].(string), map[string]any{ "training_outline": []string{"Mise en place", "Service", "Close down"}, }).record(t) outline, ok := course["training_outline"].([]any) if !ok || len(outline) != 3 || outline[0] != "Mise en place" { t.Errorf("training_outline did not round-trip: %#v", course["training_outline"]) } profiles := a.do("GET", "/api/v1/worker-profiles?limit=1", nil).records(t) profile := a.do("PATCH", "/api/v1/worker-profiles/"+profiles[0]["id"].(string), map[string]any{ "score_breakdown": map[string]any{"reliability": 88, "experience": 71}, }).record(t) sb, ok := profile["score_breakdown"].(map[string]any) if !ok || sb["reliability"] != float64(88) { t.Errorf("score_breakdown did not round-trip: %#v", profile["score_breakdown"]) } } /* ── Delete ─────────────────────────────────────────────────────────────── */ // DELETE is idempotent and returns { id } whether or not a row went, because // store.js never throws and both live callers delete inside loops without // checking. api-contract.md §12.7. func TestDeleteIsIdempotent(t *testing.T) { a := newAPI(t) apps := a.do("GET", "/api/v1/job-applications?limit=1", nil).records(t) id := apps[0]["id"].(string) first := a.do("DELETE", "/api/v1/job-applications/"+id, nil) if first.code != http.StatusOK { t.Fatalf("delete = %d, want 200", first.code) } if first.record(t)["id"] != id { t.Error("delete did not return the id") } // Gone, and deleting again still succeeds. if r := a.do("GET", "/api/v1/job-applications?limit=500", nil); len(r.records(t)) != 23 { t.Errorf("after delete there are %d applications, want 23", len(r.records(t))) } second := a.do("DELETE", "/api/v1/job-applications/"+id, nil) if second.code != http.StatusOK { t.Errorf("second delete = %d, want 200 (idempotent)", second.code) } missing := a.do("DELETE", "/api/v1/job-applications/00000000-0000-0000-0000-000000000000", nil) if missing.code != http.StatusOK { t.Errorf("delete of a never-existing record = %d, want 200", missing.code) } malformed := a.do("DELETE", "/api/v1/job-applications/not-a-uuid", nil) if malformed.code != http.StatusOK { t.Errorf("delete with a malformed id = %d, want 200", malformed.code) } } /* ── Route surface ──────────────────────────────────────────────────────── */ // The database having a table is never a reason for an endpoint to exist. func TestUnsupportedOperationsAreNotRouted(t *testing.T) { a := newAPI(t) postings := a.do("GET", "/api/v1/job-postings", nil).records(t) id := postings[0]["id"].(string) // Nothing in the frontend deletes a job posting. if r := a.do("DELETE", "/api/v1/job-postings/"+id, nil); r.code != http.StatusMethodNotAllowed { t.Errorf("DELETE job-postings = %d, want 405", r.code) } // Shift records are read-only: U1 is unresolved, so there is no write path. if r := a.do("POST", "/api/v1/shift-records", map[string]any{}); r.code != http.StatusMethodNotAllowed { t.Errorf("POST shift-records = %d, want 405", r.code) } // Assignments are listed and created, never fetched by id or updated — so // no item route exists for them at all, and a wrong method is a 404 rather // than a 405 (405 needs the path pattern to exist under another method). if r := a.do("PATCH", "/api/v1/assignments/"+id, map[string]any{}); r.code != http.StatusNotFound { t.Errorf("PATCH assignments = %d, want 404", r.code) } // Badge has a table and is seeded, but useBadges has zero consumers. if r := a.do("GET", "/api/v1/badges", nil); r.code != http.StatusNotFound { t.Errorf("GET badges = %d, want 404 (no route registered)", r.code) } // The mux's own 404/405 replies are rewritten into the error envelope, so // every response from the API is JSON. if code := a.do("DELETE", "/api/v1/job-postings/"+id, nil).errCode(t); code != "method_not_allowed" { t.Errorf("405 error code = %q, want method_not_allowed", code) } if code := a.do("GET", "/api/v1/badges", nil).errCode(t); code != "not_found" { t.Errorf("404 error code = %q, want not_found", code) } // Job postings have no filter call site but are still gettable by id. if r := a.do("GET", "/api/v1/job-postings/"+id, nil); r.code != http.StatusOK { t.Errorf("GET job-postings/{id} = %d, want 200", r.code) } } /* ── Current user ───────────────────────────────────────────────────────── */ func TestCurrentUserAndPreferences(t *testing.T) { a := newAPI(t) me := a.do("GET", "/api/v1/me", nil) if me.code != http.StatusOK { t.Fatalf("GET /me = %d", me.code) } user := me.record(t) if user["email"] != "demo@krow.app" { t.Errorf("email = %v, want demo@krow.app", user["email"]) } // krowHooks.js:42 reads user?.preferences straight off this object. prefs, ok := user["preferences"].(map[string]any) if !ok { t.Fatalf("preferences are not embedded in the user: %#v", user) } if prefs["owliverDefault"] != true { t.Errorf("owliverDefault = %v, want true", prefs["owliverDefault"]) } updated := a.do("PATCH", "/api/v1/me", map[string]any{"full_name": "Alex R."}).record(t) if updated["full_name"] != "Alex R." { t.Errorf("full_name = %v", updated["full_name"]) } // Preferences shallow-merge, and unknown keys land in the extra blob — // which is where customSkills and customAgents live. merged := a.do("PATCH", "/api/v1/me/preferences", map[string]any{ "compactDensity": true, "customSkills": []any{map[string]any{"id": "s1"}}, }).record(t) if merged["compactDensity"] != true { t.Errorf("compactDensity = %v, want true", merged["compactDensity"]) } if merged["owliverDefault"] != true { t.Errorf("an untouched preference changed: owliverDefault = %v", merged["owliverDefault"]) } if merged["customSkills"] == nil { t.Error("an arbitrary preference key was not preserved") } reread := a.do("GET", "/api/v1/me/preferences", nil).record(t) if reread["compactDensity"] != true || reread["customSkills"] == nil { t.Errorf("preferences did not survive a re-read: %#v", reread) } if r := a.do("PATCH", "/api/v1/me/preferences", map[string]any{"emailDigest": "yes"}); r.code != http.StatusUnprocessableEntity { t.Errorf("non-boolean preference = %d, want 422", r.code) } } /* ── Organization scoping ───────────────────────────────────────────────── */ // Reads are scoped: a record belonging to another organization is invisible, // and is a 404 by id rather than a leak. func TestOrganizationScoping(t *testing.T) { a := newAPI(t) ctx := t.Context() var otherOrg string if err := a.h.Pool.QueryRow(ctx, `INSERT INTO organizations (name, slug) VALUES ('Other Co', 'other-co') RETURNING id::text`). Scan(&otherOrg); err != nil { t.Fatalf("create second organization: %v", err) } var hidden string if err := a.h.Pool.QueryRow(ctx, `INSERT INTO job_postings (org_id, title) VALUES ($1::uuid, 'Hidden Role') RETURNING id::text`, otherOrg).Scan(&hidden); err != nil { t.Fatalf("create foreign posting: %v", err) } for _, r := range a.do("GET", "/api/v1/job-postings?limit=500", nil).records(t) { if r["id"] == hidden { t.Fatal("a posting from another organization appeared in the list") } } if r := a.do("GET", "/api/v1/job-postings/"+hidden, nil); r.code != http.StatusNotFound { t.Errorf("foreign record by id = %d, want 404", r.code) } if r := a.do("PATCH", "/api/v1/job-postings/"+hidden, map[string]any{"title": "Stolen"}); r.code != http.StatusNotFound { t.Errorf("patching a foreign record = %d, want 404", r.code) } // It is still there — scoping hid it, it did not delete it. var still int if err := a.h.Pool.QueryRow(ctx, `SELECT count(*) FROM job_postings WHERE id = $1::uuid AND title = 'Hidden Role'`, hidden). Scan(&still); err != nil { t.Fatal(err) } if still != 1 { t.Error("the foreign record was modified or removed") } } // Courses with a NULL org_id are the shared platform library and must be // visible to every organization. func TestPlatformLibraryIsVisible(t *testing.T) { a := newAPI(t) var shared string if err := a.h.Pool.QueryRow(t.Context(), `INSERT INTO courses (org_id, title) VALUES (NULL, 'Platform Course') RETURNING id::text`). Scan(&shared); err != nil { t.Fatalf("insert shared course: %v", err) } found := false for _, r := range a.do("GET", "/api/v1/courses?limit=500", nil).records(t) { if r["id"] == shared { found = true } } if !found { t.Error("a NULL-org course was not visible to the organization") } } /* ── Representation ─────────────────────────────────────────────────────── */ // Field names and value shapes must match what the frontend has always seen. func TestRecordRepresentation(t *testing.T) { a := newAPI(t) rec := a.do("GET", "/api/v1/job-applications?limit=1", nil).records(t)[0] if _, ok := rec["id"].(string); !ok { t.Errorf("id is %T, want a string", rec["id"]) } created, ok := rec["created_date"].(string) if !ok || len(created) != 24 || created[len(created)-1] != 'Z' { t.Errorf("created_date = %v; want ISO-8601 with milliseconds", rec["created_date"]) } if _, ok := rec["ai_score"].(float64); !ok { t.Errorf("ai_score is %T, want a number", rec["ai_score"]) } if _, ok := rec["skills"].([]any); !ok { t.Errorf("skills is %T, want an array", rec["skills"]) } if _, ok := rec["score_breakdown"].(map[string]any); !ok { t.Errorf("score_breakdown is %T, want an object", rec["score_breakdown"]) } if _, ok := rec["client_rating"].(float64); !ok { t.Errorf("client_rating is %T, want a number", rec["client_rating"]) } staff := a.do("GET", "/api/v1/staff?limit=1", nil).records(t)[0] if hire, ok := staff["hire_date"].(string); !ok || len(hire) != 10 { t.Errorf("hire_date = %v, want YYYY-MM-DD", staff["hire_date"]) } } func TestHealthEndpoint(t *testing.T) { a := newAPI(t) r := a.do("GET", "/health", nil) if r.code != http.StatusOK { t.Fatalf("health = %d, want 200", r.code) } if r.body["status"] != "ok" { t.Errorf("status = %v, want ok", r.body["status"]) } // The body is exactly one field. /health is unauthenticated, so anything // added here is added to the public internet. if len(r.body) != 1 { t.Errorf("the health body has %d fields (%v), want exactly 1", len(r.body), keysOf(r.body)) } } // TestHealthLeaksNoInfrastructure is the assertion that has to survive future // edits to the handler: whatever else /health says, it must not describe the // machine it is running on. // // It checks the rendered body rather than the struct, because the leak that // matters is the one a caller can read — a field added to an embedded type, or // a struct swapped in wholesale, would pass a field-by-field test on // healthResponse and fail this one. func TestHealthLeaksNoInfrastructure(t *testing.T) { a := newAPI(t) rec := httptest.NewRecorder() a.handler.ServeHTTP(rec, httptest.NewRequest("GET", "/health", nil)) body := rec.Body.String() if rec.Code != http.StatusOK { t.Fatalf("health = %d, want 200", rec.Code) } // Field names that would each be a disclosure on their own. for _, key := range []string{ "version", "postgres", "database", "schema", "table_count", "migration", "applied_migration", "dirty", "error", "env", "host", "port", "dsn", "user", "password", "latency", } { if strings.Contains(strings.ToLower(body), key) { t.Errorf("the health response mentions %q:\n%s", key, body) } } // And the values themselves, taken from the live check rather than // hardcoded, so this keeps working on a different server or database. health := (&db.DB{Pool: a.h.Pool, Schema: "public"}).Check(context.Background()) if health.Database == "" || health.Version == "" { t.Fatal("the internal check returned nothing to compare against") } for name, secret := range map[string]string{ "database name": health.Database, "PostgreSQL version": health.Version, "schema name": health.Schema, } { if strings.Contains(body, secret) { t.Errorf("the health response contains the %s:\n%s", name, body) } } // The internal check still gathers all of it — this change moved the // audience, it did not remove the diagnostic. if !health.Reachable || !health.SchemaPresent || health.TableCount == 0 { t.Error("db.Check no longer reports the database detail it used to") } } // An unreachable database must be reported as unserviceable without saying why: // the connection error text names the host, port, user and database. func TestHealthUnavailableSaysNothingAboutWhy(t *testing.T) { h := testutil.New(t) srv := newServer(t, h, nil) // Closing the pool is the fastest honest way to make the database // unreachable: every Acquire fails immediately, with no network involved. // The harness drops its database over a separate admin connection, so // cleanup is unaffected. h.Pool.Close() rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/health", nil)) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("health with a dead database = %d, want 503", rec.Code) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("response is not JSON: %s", rec.Body.String()) } if body["status"] != "unavailable" { t.Errorf("status = %v, want unavailable", body["status"]) } if len(body) != 1 { t.Errorf("the unhealthy body has %d fields (%v), want exactly 1", len(body), keysOf(body)) } if strings.Contains(strings.ToLower(rec.Body.String()), "error") { t.Errorf("the unhealthy response carries the connection error:\n%s", rec.Body.String()) } } func keysOf(m map[string]any) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } sort.Strings(out) return out } // The build identifier has to be reachable, or "did my deploy land?" has no // answer. It was reported nowhere: the Dockerfile declared a VERSION arg, // compose passed it, and it reached no linker flag — so every deployment // described itself as nothing at all. // // Under /api/v1 rather than on /health on purpose: /health is public and // deliberately withholds its detail from the internet, and a build identifier // tells an unauthenticated reader exactly which source to go and read. func TestVersionEndpointReportsTheBuild(t *testing.T) { a := newAPI(t) r := a.do("GET", "/api/v1/version", nil) if r.code != http.StatusOK { t.Fatalf("code = %d, want 200", r.code) } data, _ := r.body["data"].(map[string]any) if data == nil { t.Fatalf("no data envelope: %v", r.body) } if v, _ := data["version"].(string); v == "" { t.Errorf("version is empty; an unstamped build should still say \"dev\": %v", data) } if e, _ := data["env"].(string); e == "" { t.Errorf("env is empty: %v", data) } if n, _ := data["endpoints"].(float64); n < 1 { t.Errorf("endpoints = %v, want the served route count", data["endpoints"]) } } // It is behind the session like every other /api/v1 route. func TestVersionEndpointNeedsASession(t *testing.T) { a := newAPI(t) r := a.doAnon("GET", "/api/v1/version", nil) if r.code != http.StatusUnauthorized && r.code != http.StatusForbidden { t.Errorf("anonymous GET /api/v1/version = %d, want 401/403", r.code) } } // An agent author picks capabilities from the real tool set, not a copy of it // kept in the frontend. A second list would drift, and the failure is silent: // the author picks a tool that no longer exists and gets an agent that quietly // cannot do the thing they picked. func TestToolsCatalogueIsServed(t *testing.T) { a := newAPI(t) r := a.do("GET", "/api/v1/tools", nil) if r.code != http.StatusOK { t.Fatalf("code = %d, want 200", r.code) } list, _ := r.body["data"].([]any) if len(list) == 0 { t.Fatalf("no tools served: %v", r.body) } seenWrite := false for _, raw := range list { tool, _ := raw.(map[string]any) name, _ := tool["name"].(string) desc, _ := tool["description"].(string) effect, _ := tool["effect"].(string) if name == "" || desc == "" { t.Errorf("a tool has no name or description: %v", tool) } if effect != "read" && effect != "write" { t.Errorf("%s has effect %q, want read or write", name, effect) } if effect == "write" { seenWrite = true // An author must be able to see that this one proposes changes. if confirm, _ := tool["requiresConfirmation"].(bool); !confirm { t.Errorf("%s writes but does not report requiring confirmation", name) } } } if !seenWrite { t.Error("no write tool in the catalogue; the effect distinction is untested") } } func TestToolsCatalogueNeedsASession(t *testing.T) { a := newAPI(t) if r := a.doAnon("GET", "/api/v1/tools", nil); r.code != http.StatusUnauthorized && r.code != http.StatusForbidden { t.Errorf("anonymous GET /api/v1/tools = %d, want 401/403", r.code) } }