Files
krow_backend/go-api/internal/httpserver/rbac_test.go
Suriyakumarvijayanayagam dc785b917c
Some checks failed
CI / test (push) Failing after 4m41s
CI / fixture (push) Failing after 8s
Separate what a worker does from what a company needs filled
Owliver could offer neither create. The Create Position flow worked and no chip
anywhere suggested it, because the chip row is entirely the backend's static
catalogue and no intent in it wrote anything. The gap was never in the
frontend's trigger matching — every phrasing already routed.

`employee_roles` is the supply side of `job_postings`. A posting is what the
ORGANIZATION needs filled; this is what a WORKER says they do. They share a
vocabulary and almost nothing else: "3 years" on a posting is a minimum an
applicant must clear, and the same words here are what the person has. There is
deliberately no foreign key between them — supply and demand already meet
through `job_applications`, which carries the funnel, the interview and the
outcome, and a second weaker link would disagree with it the first time
somebody withdrew.

NO NEW COMPANY ENTITY, AND THAT IS THE LOAD-BEARING DECISION. "Create a company
position" reads like it needs a client record. `organizations` is the TENANT —
absent from the resource table, absent from the policy map, written only by the
seeder — so creating a row there from a chat flow would provision a new tenant,
and the position would carry an org_id the operator's session cannot see. The
operator could never view the record they just created. That breaks I5 and I1
to add a feature nobody asked for. The client stays free text on the posting,
per blueprint decision D2, and the flow simply offers the clients this
organization already staffs for as chips. No schema change, no endpoint change.

Create is operators-only, and that is an I1 decision rather than a deferral.
The worker is named explicitly on the row and is deliberately NOT derived from
the session, because an operator recording a role on somebody's behalf is the
whole point of the flow. Granting talent the same Create would let a talent
caller write a role under any worker_email in the tenant — the attribution hole
Phase 3D closed elsewhere. Talent reads its own via a ScopeEmail predicate,
which is in place now so the grant is one line when a talent console exists.

`created_by` is in gen_resources.py's SERVER_OWNED as well as the policy's
Derived list. Both are required and the pairing is easy to miss: Derived fills
the column from the session, SERVER_OWNED is what makes the descriptor ReadOnly
so a request body cannot set it in the first place. Without it,
TestDerivedColumnsAreReadOnlyOrTalentScoped fails — verified by mutation, not
by reading.

The two catalogue intents carry PHRASE terms only. A bare "position" or "role"
term scores 10, the same as every reading on that page, and wins the tie on
declaration order — so a create chip would have arrived by evicting
`positions-attention` from the exact ordered result TestPositionsSuggestions
asserts. An offer to create something must not displace the reading a person
actually asked for. Neither declares a Subject, on the precedent of
`position-spec-steps`: a Subject would let the bare query "summarize" match
through matchShape and survive filterOnTopic. Neither declares a Signal, so an
empty composer still reports what the organization needs rather than proposing
paperwork.

Chip text is the coupling with nothing else holding it together: no page
context declares `capabilities`, so every server suggestion dispatches as its
own TEXT and is answered by whichever skill's trigger that text matches. A
renamed chip would open nothing, silently. Asserted on the frontend side.

The down migration drops `employee_role_status` and keeps `english_level`,
which is shared with job_postings.english_required and
job_applications.english_level. Rolled back and re-applied against the
database to prove it, not asserted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
2026-09-02 15:29:25 +05:30

785 lines
32 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},
// What a worker declares they do. Operators maintain them; talent may
// read (scoped to their own by policy) but never write — a talent
// caller who could POST here would name any worker_email in the tenant.
{call{"GET", "/api/v1/employee-roles", nil}, nil},
{call{"GET", "/api/v1/employee-roles/" + zeroUUID, nil}, nil},
{call{"POST", "/api/v1/employee-roles", map[string]any{
"worker_email": "w@example.test", "role_category": "Bartender"}}, []string{"talent"}},
{call{"PATCH", "/api/v1/employee-roles/" + zeroUUID, map[string]any{
"notes": "n"}}, []string{"talent"}},
{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())
}
}