package httpserver_test import ( "context" "fmt" "net/http" "testing" "time" "github.com/krow/krow-backend/go-api/internal/httpserver" ) // Phase 3D authorization tests. // // Two questions are under test and they are deliberately kept apart, because // conflating them is how authorization bugs hide: // // MAY THIS ROLE CALL THIS ENDPOINT AT ALL? → checked in the handler, 403. // WHICH ROWS DOES THIS CALLER SEE? → a SQL predicate, so a row that // is not theirs is absent, 404. // // The row question is tested against the database rather than against a mock, // because the answer lives in a WHERE clause. A test that stubbed the // repository would prove the policy table is well-formed and nothing about // whether talent B can read talent A's application. /* ── Fixture ────────────────────────────────────────────────────────────── */ // rbac is one organization holding one of each role, a second employer and a // second talent to test isolation between peers, and a user in another // organization entirely. type rbac struct { *api admin, empA, empB, talA, talB actor otherOrgID string outsider actor // admin in another organization activePosting string draftPosting string } func newRBAC(t *testing.T) *rbac { t.Helper() a := newAPI(t) // signs in as the seeded user, whose role is admin ctx := context.Background() r := &rbac{api: a} r.admin = actor{name: "admin", id: a.userID, email: a.email, role: "admin", cookie: a.cookie} r.empA = signInAs(t, a.handler, a.h.Pool, a.orgID, "employerA", "employer-a@example.test", "employer") r.empB = signInAs(t, a.handler, a.h.Pool, a.orgID, "employerB", "employer-b@example.test", "employer") r.talA = signInAs(t, a.handler, a.h.Pool, a.orgID, "talentA", "talent-a@example.test", "talent") r.talB = signInAs(t, a.handler, a.h.Pool, a.orgID, "talentB", "talent-b@example.test", "talent") if err := a.h.Pool.QueryRow(ctx, `INSERT INTO organizations (name, slug) VALUES ('Other Tenant','other-tenant') RETURNING id::text`). Scan(&r.otherOrgID); err != nil { t.Fatalf("create the second organization: %v", err) } // An ADMIN in the other organization: cross-organization isolation must // hold on its own, without a role restriction doing the work for it. r.outsider = signInAs(t, a.handler, a.h.Pool, r.otherOrgID, "outsider", "outsider@example.test", "admin") // One active posting and one draft, for the talent visibility rule. r.activePosting = createPosting(t, r, "Open Role", "active") r.draftPosting = createPosting(t, r, "Unannounced Role", "draft") return r } func createPosting(t *testing.T, r *rbac, title, status string) string { t.Helper() got := r.as(r.admin, "POST", "/api/v1/job-postings", map[string]any{ "title": title, "status": status, }) if got.code != http.StatusCreated { t.Fatalf("create %s posting: %d (%v)", status, got.code, got.body) } return got.body["data"].(map[string]any)["id"].(string) } func (r *rbac) ids(t *testing.T, act actor, path string) map[string]bool { t.Helper() got := r.as(act, "GET", path, nil) if got.code != http.StatusOK { t.Fatalf("%s GET %s = %d (%v)", act.name, path, got.code, got.body) } out := map[string]bool{} for _, rec := range got.records(t) { if id, ok := rec["id"].(string); ok { out[id] = true } } return out } /* ── 1. The role matrix ─────────────────────────────────────────────────── */ // Every endpoint against every role. The assertion is only about the role gate: // 403 means refused, anything else means the gate let the request through to be // judged on its merits. A 422 from a deliberately thin body still proves the // caller was allowed in, which is what this test is about. func TestRoleMatrix(t *testing.T) { r := newRBAC(t) type call struct { method, path string body any } // forbidden lists the roles that must be refused. Every other role must get // past the gate. cases := []struct { call forbidden []string }{ {call{"GET", "/api/v1/job-postings", nil}, nil}, {call{"GET", "/api/v1/job-postings/" + r.activePosting, nil}, nil}, {call{"POST", "/api/v1/job-postings", map[string]any{"title": "X"}}, []string{"talent"}}, {call{"PATCH", "/api/v1/job-postings/" + r.activePosting, map[string]any{"location": "Here"}}, []string{"talent"}}, {call{"GET", "/api/v1/job-applications", nil}, nil}, {call{"POST", "/api/v1/job-applications", map[string]any{ "job_posting_id": r.activePosting, "applicant_name": "A", "email": "someone@example.test"}}, nil}, {call{"PATCH", "/api/v1/job-applications/" + zeroUUID, map[string]any{"phone": "1"}}, []string{"talent"}}, {call{"DELETE", "/api/v1/job-applications/" + zeroUUID, nil}, []string{"talent"}}, {call{"GET", "/api/v1/ai-interviews", nil}, nil}, {call{"POST", "/api/v1/ai-interviews", map[string]any{ "application_id": zeroUUID, "job_posting_id": r.activePosting}}, nil}, {call{"GET", "/api/v1/staff", nil}, []string{"talent"}}, {call{"POST", "/api/v1/staff", map[string]any{ "name": "N", "email": "s@example.test", "hire_date": "2026-01-01"}}, []string{"talent"}}, {call{"PATCH", "/api/v1/staff/" + zeroUUID, map[string]any{"phone": "1"}}, []string{"talent"}}, {call{"GET", "/api/v1/worker-profiles", nil}, nil}, {call{"POST", "/api/v1/worker-profiles", map[string]any{ "full_name": "W", "email": "w@example.test"}}, nil}, {call{"PATCH", "/api/v1/worker-profiles/" + zeroUUID, map[string]any{"phone": "1"}}, nil}, {call{"GET", "/api/v1/assignments", nil}, nil}, {call{"POST", "/api/v1/assignments", map[string]any{ "job_posting_id": r.activePosting, "worker_email": "w@example.test", "starts_at": "2026-01-01T00:00:00.000Z"}}, []string{"talent"}}, {call{"GET", "/api/v1/shift-records", nil}, nil}, {call{"GET", "/api/v1/courses", nil}, nil}, {call{"POST", "/api/v1/courses", map[string]any{"title": "C"}}, []string{"employer", "talent"}}, {call{"PATCH", "/api/v1/courses/" + zeroUUID, map[string]any{"title": "C2"}}, []string{"employer", "talent"}}, {call{"GET", "/api/v1/learning-paths", nil}, nil}, {call{"GET", "/api/v1/role-categories", nil}, nil}, {call{"POST", "/api/v1/role-categories", map[string]any{"name": "RC"}}, []string{"talent"}}, {call{"GET", "/api/v1/certifications", nil}, nil}, {call{"POST", "/api/v1/certifications", map[string]any{"name": "Cert"}}, []string{"talent"}}, {call{"DELETE", "/api/v1/certifications/" + zeroUUID, nil}, []string{"employer", "talent"}}, {call{"GET", "/api/v1/user-activity", nil}, nil}, {call{"POST", "/api/v1/user-activity", map[string]any{"event_type": "test"}}, nil}, {call{"GET", "/api/v1/evidence", nil}, nil}, {call{"POST", "/api/v1/evidence", map[string]any{"type": "photo_identify", "worker_email": "w@example.test"}}, nil}, {call{"PATCH", "/api/v1/evidence/" + zeroUUID, map[string]any{"notes": "n"}}, []string{"talent"}}, // /me is every authenticated role's own business. {call{"GET", "/api/v1/me", nil}, nil}, {call{"PATCH", "/api/v1/me", map[string]any{"full_name": "Renamed"}}, nil}, {call{"GET", "/api/v1/me/preferences", nil}, nil}, {call{"PATCH", "/api/v1/me/preferences", map[string]any{"emailDigest": true}}, nil}, } actors := map[string]actor{"admin": r.admin, "employer": r.empA, "talent": r.talA} for _, tc := range cases { for role, act := range actors { name := fmt.Sprintf("%s %s as %s", tc.method, tc.path, role) t.Run(name, func(t *testing.T) { got := r.as(act, tc.method, tc.path, tc.body) denied := listsRole(tc.forbidden, role) if denied { if got.code != http.StatusForbidden { t.Errorf("= %d (%s), want 403 forbidden", got.code, got.codeOrEmpty()) } return } if got.code == http.StatusForbidden { t.Errorf("= 403, but %s should be allowed through the role gate", role) } if got.code == http.StatusUnauthorized { t.Errorf("= 401 — the session was rejected, which is not what this tests") } }) } } } const zeroUUID = "00000000-0000-0000-0000-000000000000" func listsRole(set []string, v string) bool { for _, s := range set { if s == v { return true } } return false } /* ── 2. Ownership isolation between two talent users ────────────────────── */ // Talent A's records are invisible to talent B across every owned resource, // and visible to the organization's operators. func TestTalentSeesOnlyTheirOwnRecords(t *testing.T) { r := newRBAC(t) ctx := context.Background() own := map[string]string{} // resource path → the id talent A owns // Created through the API by talent A, so the ownership column is whatever // the server derived — not what the test asked for. own["worker-profiles"] = mustCreate(t, r, r.talA, "/api/v1/worker-profiles", map[string]any{"full_name": "Talent A", "email": r.talA.email}) own["job-applications"] = mustCreate(t, r, r.talA, "/api/v1/job-applications", map[string]any{"job_posting_id": r.activePosting, "applicant_name": "Talent A"}) own["evidence"] = mustCreate(t, r, r.talA, "/api/v1/evidence", map[string]any{"type": "photo_identify"}) own["user-activity"] = mustCreate(t, r, r.talA, "/api/v1/user-activity", map[string]any{"event_type": "viewed_something"}) own["ai-interviews"] = mustCreate(t, r, r.talA, "/api/v1/ai-interviews", map[string]any{"application_id": own["job-applications"], "job_posting_id": r.activePosting}) // Assignments are created by operators; shift records only by the seeder. own["assignments"] = mustCreate(t, r, r.admin, "/api/v1/assignments", map[string]any{ "job_posting_id": r.activePosting, "worker_email": r.talA.email, "starts_at": "2026-01-01T00:00:00.000Z"}) var shiftID string if err := r.h.Pool.QueryRow(ctx, `INSERT INTO shift_records (org_id, worker_email, shift_date, scheduled_start, scheduled_end, scheduled_hours, created_date) VALUES ($1::uuid, $2::citext, '2026-01-02', '2026-01-02T09:00:00Z', '2026-01-02T17:00:00Z', 8, now()) RETURNING id::text`, r.orgID, r.talA.email).Scan(&shiftID); err != nil { t.Fatalf("insert a shift record: %v", err) } own["shift-records"] = shiftID // Talent B also has records of their own, so "B sees nothing" cannot pass // by the endpoint simply being broken. mustCreate(t, r, r.talB, "/api/v1/worker-profiles", map[string]any{"full_name": "Talent B", "email": r.talB.email}) mustCreate(t, r, r.talB, "/api/v1/user-activity", map[string]any{"event_type": "b_event"}) for path, id := range own { t.Run(path, func(t *testing.T) { if !r.ids(t, r.talA, "/api/v1/"+path+"?limit=500")[id] { t.Errorf("talent A cannot see their own %s record", path) } if r.ids(t, r.talB, "/api/v1/"+path+"?limit=500")[id] { t.Errorf("talent B can see talent A's %s record", path) } if !r.ids(t, r.admin, "/api/v1/"+path+"?limit=500")[id] { t.Errorf("the organization's admin cannot see the %s record", path) } if !r.ids(t, r.empA, "/api/v1/"+path+"?limit=500")[id] { t.Errorf("the organization's employer cannot see the %s record", path) } }) } // The count must respect ownership too. A total computed over the whole // organization would leak how many records exist even with the rows hidden. t.Run("meta total respects ownership", func(t *testing.T) { got := r.as(r.talB, "GET", "/api/v1/worker-profiles?limit=500", nil) meta := got.meta(t) if n, _ := meta["total"].(float64); n != 1 { t.Errorf("talent B's worker-profiles total = %v, want 1 (their own)", meta["total"]) } }) // Talent A cannot reach talent B's profile by PATCHing its id either: the // ownership predicate is in the UPDATE's WHERE clause, so the row is not // found rather than refused. t.Run("PATCH another talent's profile is 404", func(t *testing.T) { var bProfile string if err := r.h.Pool.QueryRow(ctx, `SELECT id::text FROM worker_profiles WHERE user_id = $1::uuid`, r.talB.id).Scan(&bProfile); err != nil { t.Fatalf("find talent B's profile: %v", err) } got := r.as(r.talA, "PATCH", "/api/v1/worker-profiles/"+bProfile, map[string]any{"phone": "hijacked"}) if got.code != http.StatusNotFound { t.Errorf("= %d, want 404 (absent, not forbidden — existence must not leak)", got.code) } var phone string if err := r.h.Pool.QueryRow(ctx, `SELECT phone FROM worker_profiles WHERE id = $1::uuid`, bProfile).Scan(&phone); err != nil { t.Fatalf("re-read talent B's profile: %v", err) } if phone == "hijacked" { t.Fatal("talent A modified talent B's worker profile") } }) } func mustCreate(t *testing.T, r *rbac, act actor, path string, body map[string]any) string { t.Helper() got := r.as(act, "POST", path, body) if got.code != http.StatusCreated { t.Fatalf("%s POST %s = %d (%v)", act.name, path, got.code, got.body) } return got.body["data"].(map[string]any)["id"].(string) } /* ── 3. Mass assignment ─────────────────────────────────────────────────── */ // The other half of a talent-only derivation: what an OPERATOR must supply. // // The server fills these columns from the session for a talent caller and for // nobody else — an operator filing an application or logging evidence is // writing about somebody who is not them. Treating the column as // server-supplied for every role let an operator's request past validation and // into SQL, where it came back as a not-null violation instead of the // required-field message the contract promises. The two halves have to agree: // what the repository will derive, and what validation stops asking for. func TestTalentOnlyDerivedFieldsAreRequiredOfOperators(t *testing.T) { r := newRBAC(t) cases := []struct { name, path, column string body map[string]any }{ {"job_applications.email", "/api/v1/job-applications", "email", map[string]any{"job_posting_id": r.activePosting, "applicant_name": "Nameless"}}, {"evidence.worker_email", "/api/v1/evidence", "worker_email", map[string]any{"type": "photo_identify"}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := r.as(r.admin, "POST", tc.path, tc.body) if got.code != http.StatusUnprocessableEntity { t.Fatalf("operator create without %s: got %d, want 422 (%v)", tc.column, got.code, got.body) } details, _ := got.body["error"].(map[string]any)["details"].(map[string]any) if details[tc.column] != "required" { t.Errorf("details = %v, want %s: required", details, tc.column) } // The same body from a talent caller is complete, because the // server is about to fill the column in from their session. if got := r.as(r.talA, "POST", tc.path, tc.body); got.code != http.StatusCreated { t.Errorf("talent create without %s: got %d, want 201 (%v)", tc.column, got.code, got.body) } }) } } // Identity a caller supplies is ignored; identity the server derives wins. // // This is the test that makes the ownership predicates above mean anything. If // a talent user could name someone else in the ownership column, every "own // records only" rule would be bypassable by the same request it constrains. func TestServerOwnedIdentityCannotBeSupplied(t *testing.T) { r := newRBAC(t) ctx := context.Background() t.Run("worker_profiles.user_id", func(t *testing.T) { id := mustCreate(t, r, r.talA, "/api/v1/worker-profiles", map[string]any{ "full_name": "Claimed", "email": r.talA.email, "user_id": r.talB.id, // naming somebody else }) var owner string if err := r.h.Pool.QueryRow(ctx, `SELECT COALESCE(user_id::text,'') FROM worker_profiles WHERE id = $1::uuid`, id).Scan(&owner); err != nil { t.Fatalf("read the profile: %v", err) } if owner != r.talA.id { t.Errorf("user_id = %q, want the creating talent %q", owner, r.talA.id) } }) t.Run("worker_profiles.user_id is NOT the admin when an operator creates one", func(t *testing.T) { // The subject of an operator-created profile is a candidate, not the // operator. Deriving it unconditionally would file every candidate's // record under whoever typed it in. id := mustCreate(t, r, r.admin, "/api/v1/worker-profiles", map[string]any{ "full_name": "Candidate", "email": "candidate@example.test", }) var owner string if err := r.h.Pool.QueryRow(ctx, `SELECT COALESCE(user_id::text,'') FROM worker_profiles WHERE id = $1::uuid`, id).Scan(&owner); err != nil { t.Fatalf("read the profile: %v", err) } if owner != "" { t.Errorf("user_id = %q, want empty — an operator-created profile has no claimant yet", owner) } }) t.Run("job_applications.email", func(t *testing.T) { id := mustCreate(t, r, r.talA, "/api/v1/job-applications", map[string]any{ "job_posting_id": r.activePosting, "applicant_name": "A", "email": r.talB.email, // applying as somebody else }) var email string if err := r.h.Pool.QueryRow(ctx, `SELECT email::text FROM job_applications WHERE id = $1::uuid`, id).Scan(&email); err != nil { t.Fatalf("read the application: %v", err) } if email != r.talA.email { t.Errorf("email = %q, want the applying talent %q", email, r.talA.email) } }) t.Run("evidence.worker_email", func(t *testing.T) { id := mustCreate(t, r, r.talA, "/api/v1/evidence", map[string]any{ "type": "photo_identify", "worker_email": r.talB.email, }) var email string if err := r.h.Pool.QueryRow(ctx, `SELECT worker_email::text FROM evidence WHERE id = $1::uuid`, id).Scan(&email); err != nil { t.Fatalf("read the evidence: %v", err) } if email != r.talA.email { t.Errorf("worker_email = %q, want %q", email, r.talA.email) } }) t.Run("user_activity identity is entirely server-derived", func(t *testing.T) { id := mustCreate(t, r, r.talA, "/api/v1/user-activity", map[string]any{ "event_type": "forged", "user_id": r.admin.id, "user_email": r.admin.email, "user_name": "The Administrator", "account_type": "admin", }) var uid, email, name, acct string if err := r.h.Pool.QueryRow(ctx, `SELECT COALESCE(user_id::text,''), user_email::text, user_name, account_type FROM user_activity WHERE id::text = $1`, id).Scan(&uid, &email, &name, &acct); err != nil { t.Fatalf("read the activity row: %v", err) } if uid != r.talA.id || email != r.talA.email { t.Errorf("activity attributed to %s/%s, want talent A %s/%s", uid, email, r.talA.id, r.talA.email) } if name == "The Administrator" || acct == "admin" { t.Errorf("client-supplied user_name/account_type were stored: %q / %q", name, acct) } }) t.Run("job_postings.created_by", func(t *testing.T) { got := r.as(r.empA, "POST", "/api/v1/job-postings", map[string]any{ "title": "Attributed", "created_by": r.admin.id, }) if got.code != http.StatusCreated { t.Fatalf("create = %d (%v)", got.code, got.body) } id := got.body["data"].(map[string]any)["id"].(string) var by string if err := r.h.Pool.QueryRow(ctx, `SELECT COALESCE(created_by::text,'') FROM job_postings WHERE id = $1::uuid`, id).Scan(&by); err != nil { t.Fatalf("read the posting: %v", err) } if by != r.empA.id { t.Errorf("created_by = %q, want the actual creator %q", by, r.empA.id) } }) t.Run("org_id and role still cannot be supplied", func(t *testing.T) { id := mustCreate(t, r, r.empA, "/api/v1/job-postings", map[string]any{ "title": "Tenancy", "org_id": r.otherOrgID, }) var org string if err := r.h.Pool.QueryRow(ctx, `SELECT org_id::text FROM job_postings WHERE id = $1::uuid`, id).Scan(&org); err != nil { t.Fatalf("read the posting: %v", err) } if org != r.orgID { t.Errorf("org_id = %q, want the session's organization %q", org, r.orgID) } // And a talent cannot promote themselves through /me. if got := r.as(r.talA, "PATCH", "/api/v1/me", map[string]any{"role": "admin"}); got.code != http.StatusOK { t.Fatalf("PATCH /me = %d", got.code) } var role string if err := r.h.Pool.QueryRow(ctx, `SELECT role FROM users WHERE id = $1::uuid`, r.talA.id).Scan(&role); err != nil { t.Fatalf("read the user: %v", err) } if role != "talent" { t.Fatalf("role = %q — a talent user promoted themselves", role) } }) } // A talent user cannot attach an interview to somebody else's application. // Ownership here is by reference, so it is checked against the application. func TestTalentCannotInterviewForAnotherApplication(t *testing.T) { r := newRBAC(t) othersApplication := mustCreate(t, r, r.talB, "/api/v1/job-applications", map[string]any{"job_posting_id": r.activePosting, "applicant_name": "Talent B"}) got := r.as(r.talA, "POST", "/api/v1/ai-interviews", map[string]any{ "application_id": othersApplication, "job_posting_id": r.activePosting, }) if got.code != http.StatusNotFound { t.Errorf("= %d (%s), want 404 — the same answer an application that does not exist gives", got.code, got.codeOrEmpty()) } // Their own application is accepted, so the guard is not simply refusing // everything. mine := mustCreate(t, r, r.talA, "/api/v1/job-applications", map[string]any{"job_posting_id": r.activePosting, "applicant_name": "Talent A"}) if ok := r.as(r.talA, "POST", "/api/v1/ai-interviews", map[string]any{ "application_id": mine, "job_posting_id": r.activePosting, }); ok.code != http.StatusCreated { t.Errorf("interviewing for their own application = %d (%v)", ok.code, ok.body) } } /* ── 4. Talent posting visibility ───────────────────────────────────────── */ func TestTalentSeesOnlyActivePostings(t *testing.T) { r := newRBAC(t) talent := r.ids(t, r.talA, "/api/v1/job-postings?limit=200") if !talent[r.activePosting] { t.Error("talent cannot see an active posting") } if talent[r.draftPosting] { t.Error("talent can see a draft posting") } for _, act := range []actor{r.admin, r.empA} { seen := r.ids(t, act, "/api/v1/job-postings?limit=200") if !seen[r.draftPosting] { t.Errorf("%s cannot see the organization's draft posting", act.name) } } // By id, too — and as a 404, so the draft's existence is not disclosed. if got := r.as(r.talA, "GET", "/api/v1/job-postings/"+r.draftPosting, nil); got.code != http.StatusNotFound { t.Errorf("talent GET of a draft posting = %d, want 404", got.code) } if got := r.as(r.talA, "GET", "/api/v1/job-postings/"+r.activePosting, nil); got.code != http.StatusOK { t.Errorf("talent GET of an active posting = %d, want 200", got.code) } } /* ── 5. Cross-organization isolation ────────────────────────────────────── */ // The outsider is an ADMIN in another organization, so nothing here is being // done by a role restriction. func TestCrossOrganizationIsolation(t *testing.T) { r := newRBAC(t) ctx := context.Background() appID := mustCreate(t, r, r.admin, "/api/v1/job-applications", map[string]any{ "job_posting_id": r.activePosting, "applicant_name": "Insider", "email": "insider@example.test"}) t.Run("cannot read", func(t *testing.T) { if r.ids(t, r.outsider, "/api/v1/job-postings?limit=200")[r.activePosting] { t.Error("an outsider can list another organization's posting") } if got := r.as(r.outsider, "GET", "/api/v1/job-postings/"+r.activePosting, nil); got.code != http.StatusNotFound { t.Errorf("GET by id = %d, want 404", got.code) } if n := len(r.ids(t, r.outsider, "/api/v1/job-applications?limit=200")); n != 0 { t.Errorf("an outsider sees %d applications from another organization", n) } }) t.Run("cannot update", func(t *testing.T) { got := r.as(r.outsider, "PATCH", "/api/v1/job-postings/"+r.activePosting, map[string]any{"title": "Hijacked"}) if got.code != http.StatusNotFound { t.Errorf("= %d, want 404", got.code) } var title string if err := r.h.Pool.QueryRow(ctx, `SELECT title FROM job_postings WHERE id = $1::uuid`, r.activePosting).Scan(&title); err != nil { t.Fatalf("re-read: %v", err) } if title == "Hijacked" { t.Fatal("an outsider modified another organization's posting") } }) t.Run("cannot delete", func(t *testing.T) { // DELETE reports success whether or not a row matched — a deliberate // contract choice (§12.7) that reveals nothing. What matters is that // the row survives. r.as(r.outsider, "DELETE", "/api/v1/job-applications/"+appID, nil) var alive int if err := r.h.Pool.QueryRow(ctx, `SELECT count(*)::int FROM job_applications WHERE id = $1::uuid`, appID).Scan(&alive); err != nil { t.Fatalf("count: %v", err) } if alive != 1 { t.Fatal("an outsider deleted another organization's application") } }) } /* ── 6. 403 versus 404 ──────────────────────────────────────────────────── */ // The discipline: a refused ROLE is 403; a row outside the caller's visibility // is 404, whether it is another tenant's or another person's. func TestForbiddenVersusNotFound(t *testing.T) { r := newRBAC(t) t.Run("role refused is 403", func(t *testing.T) { got := r.as(r.talA, "GET", "/api/v1/staff", nil) if got.code != http.StatusForbidden || got.codeOrEmpty() != "forbidden" { t.Errorf("= %d (%s), want 403 forbidden", got.code, got.codeOrEmpty()) } // And the message must not name the roles that would have worked. body, _ := got.body["error"].(map[string]any) msg, _ := body["message"].(string) for _, leak := range []string{"admin", "employer", "talent", "role"} { if containsFold(msg, leak) { t.Errorf("the 403 message names %q: %q", leak, msg) } } }) t.Run("another tenant's row is 404", func(t *testing.T) { if got := r.as(r.outsider, "GET", "/api/v1/job-postings/"+r.activePosting, nil); got.code != http.StatusNotFound { t.Errorf("= %d, want 404", got.code) } }) t.Run("another person's row is 404", func(t *testing.T) { bProfile := mustCreate(t, r, r.talB, "/api/v1/worker-profiles", map[string]any{"full_name": "B", "email": r.talB.email}) if got := r.as(r.talA, "PATCH", "/api/v1/worker-profiles/"+bProfile, map[string]any{"phone": "x"}); got.code != http.StatusNotFound { t.Errorf("= %d, want 404", got.code) } }) t.Run("unauthenticated is still 401", func(t *testing.T) { if got := r.doAnon("GET", "/api/v1/staff", nil); got.code != http.StatusUnauthorized { t.Errorf("= %d, want 401", got.code) } }) } func containsFold(haystack, needle string) bool { h, n := []rune(haystack), []rune(needle) lower := func(r rune) rune { if r >= 'A' && r <= 'Z' { return r + 32 } return r } for i := 0; i+len(n) <= len(h); i++ { ok := true for j := range n { if lower(h[i+j]) != lower(n[j]) { ok = false break } } if ok { return true } } return false } /* ── 7. Admin regression ────────────────────────────────────────────────── */ // Everything the admin console does today must still work. The endpoints below // are the ones the frontend actually calls, taken from the Phase 3D audit's // call-site inventory. func TestAdminRegression(t *testing.T) { r := newRBAC(t) for _, path := range []string{ "job-postings", "job-applications", "ai-interviews", "staff", "worker-profiles", "courses", "learning-paths", "certifications", "role-categories", "user-activity", "evidence", "assignments", "shift-records", } { if got := r.as(r.admin, "GET", "/api/v1/"+path+"?limit=5", nil); got.code != http.StatusOK { t.Errorf("admin GET /api/v1/%s = %d (%v)", path, got.code, got.body) } } // The seeded dataset is still fully visible to an admin: ownership scoping // must not have narrowed the operator view. if n := len(r.ids(t, r.admin, "/api/v1/job-postings?limit=200")); n < 8 { t.Errorf("admin sees %d job postings, want at least the 8 seeded", n) } // A representative write of each shape. posting := mustCreate(t, r, r.admin, "/api/v1/job-postings", map[string]any{"title": "Admin Wrote This"}) if got := r.as(r.admin, "PATCH", "/api/v1/job-postings/"+posting, map[string]any{"location": "Somewhere"}); got.code != http.StatusOK { t.Errorf("admin PATCH = %d (%v)", got.code, got.body) } app := mustCreate(t, r, r.admin, "/api/v1/job-applications", map[string]any{ "job_posting_id": posting, "applicant_name": "C", "email": "c@example.test"}) if got := r.as(r.admin, "DELETE", "/api/v1/job-applications/"+app, nil); got.code != http.StatusOK { t.Errorf("admin DELETE = %d", got.code) } if got := r.as(r.admin, "GET", "/api/v1/me", nil); got.code != http.StatusOK { t.Errorf("admin GET /me = %d", got.code) } if got := r.doAnon("GET", "/health", nil); got.code != http.StatusOK { t.Errorf("GET /health = %d, want 200 and still public", got.code) } } /* ── 8. Employer boundaries ─────────────────────────────────────────────── */ func TestEmployerBoundaries(t *testing.T) { r := newRBAC(t) // Employer runs the organization's hiring: the operator surface works. for _, path := range []string{"job-postings", "job-applications", "staff", "worker-profiles", "user-activity"} { if got := r.as(r.empA, "GET", "/api/v1/"+path+"?limit=5", nil); got.code != http.StatusOK { t.Errorf("employer GET /api/v1/%s = %d", path, got.code) } } // Admin-only operations are refused. Course authoring is admin's because a // NULL-org course is the shared platform library and reaches every tenant. for _, tc := range []struct{ method, path string }{ {"POST", "/api/v1/courses"}, {"PATCH", "/api/v1/courses/" + zeroUUID}, {"DELETE", "/api/v1/certifications/" + zeroUUID}, } { got := r.as(r.empA, tc.method, tc.path, map[string]any{"title": "X"}) if got.code != http.StatusForbidden { t.Errorf("employer %s %s = %d, want 403", tc.method, tc.path, got.code) } } // Two employers in one organization see the same rows: the ownership // predicate must not have leaked onto the operator roles. posting := mustCreate(t, r, r.empA, "/api/v1/job-postings", map[string]any{"title": "By A"}) if !r.ids(t, r.empB, "/api/v1/job-postings?limit=200")[posting] { t.Error("employer B cannot see employer A's posting — operators share the organization") } if got := r.as(r.empB, "PATCH", "/api/v1/job-postings/"+posting, map[string]any{"location": "Edited by B"}); got.code != http.StatusOK { t.Errorf("employer B editing employer A's posting = %d, want 200", got.code) } } /* ── 9. Session expiry still governs everything ─────────────────────────── */ // Authorization does not replace authentication: an expired session is refused // before any role is consulted. func TestExpiredSessionIsRefusedBeforeRoleCheck(t *testing.T) { now := time.Date(2026, 8, 22, 9, 0, 0, 0, time.UTC) a := newAPI(t, httpserver.WithClock(func() time.Time { return now }), httpserver.WithSessionPolicy(shortSessions)) if got := a.do("GET", "/api/v1/job-postings", nil); got.code != http.StatusOK { t.Fatalf("while live = %d", got.code) } now = now.Add(shortSessions.IdleLifetime + time.Minute) got := a.do("GET", "/api/v1/job-postings", nil) if got.code != http.StatusUnauthorized { t.Errorf("= %d (%s), want 401 — not 403", got.code, got.codeOrEmpty()) } }