package httpserver_test import ( "context" "net/http" "testing" ) // Completing an AI interview. // // The endpoint is unchanged — POST /api/v1/ai-interviews, the one the modal // already calls — but finishing an interview is two writes, and the second one // is a write the caller who most often makes the request may not perform. The // tests below are about that seam: the interview and the link land together, // they land for a talent user, and nothing about talent's own permissions has // widened to make it possible. // interviewCount counts the organization's interview rows. func interviewCount(t *testing.T, r *rbac) int { t.Helper() return countRows(t, r, "ai_interviews") } // talentApplication files an application through the API as the talent user, so // its email is whatever the server derived rather than what a test asked for. func talentApplication(t *testing.T, r *rbac, who actor) string { t.Helper() return mustCreate(t, r, who, "/api/v1/job-applications", map[string]any{ "job_posting_id": r.activePosting, "applicant_name": who.name, }) } func interviewBody(applicationID, postingID string, score any) map[string]any { body := map[string]any{ "application_id": applicationID, "job_posting_id": postingID, "job_title": "Open Role", "candidate_name": "Candidate", "messages": []map[string]any{ {"role": "assistant", "content": "Tell me about a difficult shift."}, {"role": "user", "content": "We were two people short and I re-planned the passes."}, }, "verdict": "hire", "hire_recommendation": "Hire", "summary": "Composed under pressure.", } if score != nil { body["overall_interview_score"] = score } return body } /* ── The RBAC break this fixes ──────────────────────────────────────────── */ // A talent user completing their own interview is the whole talent flow, and it // could not finish: ai-interviews:Create is open to everyone, job-applications: // Update is operators only, so the interview was written and the application // never learned about it. Both writes now happen server-side, in one // transaction, on the row the interview already names. func TestTalentCompletesTheirOwnInterview(t *testing.T) { r := newRBAC(t) app := talentApplication(t, r, r.talA) got := r.as(r.talA, "POST", "/api/v1/ai-interviews", interviewBody(app, r.activePosting, 88)) if got.code != http.StatusCreated { t.Fatalf("talent interview: got %d, want 201 (%v)", got.code, got.body) } interview := got.body["data"].(map[string]any) interviewID, _ := interview["id"].(string) if interviewID == "" { t.Fatalf("the response carries no interview id: %v", got.body) } // The response is still the interview record, unchanged. if interview["application_id"] != app { t.Errorf("data.application_id = %v, want %s", interview["application_id"], app) } stored := applicationByID(t, r, app) if stored["status"] != "interview" { t.Errorf("application.status = %v, want interview — the analytics count "+ "status === 'interview' || interview_id", stored["status"]) } if stored["interview_id"] != interviewID { t.Errorf("application.interview_id = %v, want %s", stored["interview_id"], interviewID) } if score, ok := stored["ai_score"].(float64); !ok || int(score) != 88 { t.Errorf("application.ai_score = %v, want the interview's 88", stored["ai_score"]) } } // And the permission itself has NOT widened. The server writes that one row on // the caller's behalf; the caller still cannot patch an application. func TestCompletingAnInterviewDoesNotWidenApplicationUpdate(t *testing.T) { r := newRBAC(t) app := talentApplication(t, r, r.talA) if got := r.as(r.talA, "POST", "/api/v1/ai-interviews", interviewBody(app, r.activePosting, 70)); got.code != http.StatusCreated { t.Fatalf("talent interview: got %d, want 201 (%v)", got.code, got.body) } if got := r.as(r.talA, "PATCH", "/api/v1/job-applications/"+app, map[string]any{"status": "hired"}); got.code != http.StatusForbidden { t.Fatalf("talent PATCH of their own application: got %d, want 403 (%v)", got.code, got.body) } } // An operator's interview links the same way. The atomicity half of the fix is // not talent-specific: a failure between the two writes left an interview // attached to an application that did not know about it, whoever ran it. func TestOperatorCompletingAnInterviewLinksTheApplication(t *testing.T) { r := newRBAC(t) app := applicationFor(t, r, r.activePosting, "Operator Candidate", "opcand@example.test") got := r.as(r.empA, "POST", "/api/v1/ai-interviews", interviewBody(app, r.activePosting, 64)) if got.code != http.StatusCreated { t.Fatalf("operator interview: got %d, want 201 (%v)", got.code, got.body) } interviewID := got.body["data"].(map[string]any)["id"].(string) stored := applicationByID(t, r, app) if stored["status"] != "interview" || stored["interview_id"] != interviewID { t.Errorf("application = status %v, interview_id %v; want interview / %s", stored["status"], stored["interview_id"], interviewID) } } /* ── What the link must not do ──────────────────────────────────────────── */ // A body that says nothing about the score must not overwrite the screening // score with the interview column's default of 0. The field the caller never // mentioned is not a value they asked to store. func TestInterviewWithoutAScoreLeavesTheApplicationScore(t *testing.T) { r := newRBAC(t) app := applicationFor(t, r, r.activePosting, "Scored", "scored@example.test") // ai_score 77 got := r.as(r.admin, "POST", "/api/v1/ai-interviews", interviewBody(app, r.activePosting, nil)) if got.code != http.StatusCreated { t.Fatalf("interview: got %d, want 201 (%v)", got.code, got.body) } stored := applicationByID(t, r, app) if score, ok := stored["ai_score"].(float64); !ok || int(score) != 77 { t.Errorf("application.ai_score = %v, want the screening score 77 left alone", stored["ai_score"]) } // The status and the link still move — those are what completing an // interview means. if stored["status"] != "interview" || stored["interview_id"] == nil { t.Errorf("application = status %v, interview_id %v; want interview and a link", stored["status"], stored["interview_id"]) } } // Somebody else's application is not a subject a talent user may interview for, // and the refusal must leave nothing behind — not the interview, and not a // changed application. func TestInterviewForAnotherPersonsApplicationWritesNothing(t *testing.T) { r := newRBAC(t) app := talentApplication(t, r, r.talA) before := interviewCount(t, r) got := r.as(r.talB, "POST", "/api/v1/ai-interviews", interviewBody(app, r.activePosting, 95)) if got.code != http.StatusNotFound { t.Fatalf("interview for another person's application: got %d, want 404 (%v)", got.code, got.body) } if after := interviewCount(t, r); after != before { t.Errorf("ai_interviews: %d -> %d, want no row", before, after) } stored := applicationByID(t, r, app) if stored["status"] != "applied" || stored["interview_id"] != nil { t.Errorf("application = status %v, interview_id %v; want it untouched", stored["status"], stored["interview_id"]) } } // An interview that cannot be written must not move the application either. // Both writes are in one transaction, so a refusal at the first is the whole // request rolled back rather than a partial completion. func TestARefusedInterviewLeavesTheApplicationAlone(t *testing.T) { r := newRBAC(t) app := applicationFor(t, r, r.activePosting, "Unfinished", "unfinished@example.test") before := interviewCount(t, r) body := interviewBody(app, r.activePosting, 80) body["verdict"] = "definitely" // outside the interview_verdict enum got := r.as(r.admin, "POST", "/api/v1/ai-interviews", body) if got.code == http.StatusCreated { t.Fatalf("an invalid verdict was accepted: %v", got.body) } if after := interviewCount(t, r); after != before { t.Errorf("ai_interviews: %d -> %d, want no row", before, after) } stored := applicationByID(t, r, app) if stored["status"] != "shortlisted" || stored["interview_id"] != nil { t.Errorf("application = status %v, interview_id %v; want it untouched", stored["status"], stored["interview_id"]) } } // Cross-tenant: the application is in another organization, so it is absent // rather than forbidden, and no interview is written for it. func TestInterviewCannotReachAnotherOrganizationsApplication(t *testing.T) { r := newRBAC(t) app := applicationFor(t, r, r.activePosting, "Ours", "ours-interview@example.test") var before int if err := r.h.Pool.QueryRow(context.Background(), `SELECT count(*) FROM ai_interviews`).Scan(&before); err != nil { t.Fatalf("count interviews: %v", err) } got := r.as(r.outsider, "POST", "/api/v1/ai-interviews", interviewBody(app, r.activePosting, 90)) if got.code == http.StatusCreated { t.Fatalf("an outsider wrote an interview for our application: %v", got.body) } var after int if err := r.h.Pool.QueryRow(context.Background(), `SELECT count(*) FROM ai_interviews`).Scan(&after); err != nil { t.Fatalf("count interviews: %v", err) } if after != before { t.Errorf("ai_interviews: %d -> %d, want no row", before, after) } if stored := applicationByID(t, r, app); stored["interview_id"] != nil { t.Errorf("application.interview_id = %v, want it untouched", stored["interview_id"]) } }