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
785 lines
32 KiB
Go
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())
|
|
}
|
|
}
|