first commit
This commit is contained in:
730
go-api/internal/httpserver/rbac_test.go
Normal file
730
go-api/internal/httpserver/rbac_test.go
Normal file
@@ -0,0 +1,730 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user