Files
krow_backend/go-api/internal/httpserver/rbac_test.go
2026-08-24 13:06:29 +05:30

731 lines
29 KiB
Go

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 ─────────────────────────────────────────────────── */
// 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())
}
}