package httpserver_test import ( "net/http" "testing" ) // Employee roles: what a worker declares they do. // // The properties here are the ones the conversational flow depends on and that // no amount of frontend testing can establish, because they are decided by a // SQL predicate and a derived column: // // THE OPERATOR IS NOT THE WORKER. An employer records a role for somebody // else. If the subject were derived from the session — as created_by // legitimately is — every role would be filed against whoever was signed in. // // A WORKER HOLDS MANY ROLES. There is deliberately no uniqueness on the // worker, so a second declaration is a second row and a role already marked // `placed` survives the worker declaring the same category again. The panel // promises exactly this in its review step: "The worker can hold more than one // role — recording this does not replace an existing one." func createEmployeeRole(t *testing.T, r *rbac, act actor, body map[string]any) map[string]any { t.Helper() got := r.as(act, "POST", "/api/v1/employee-roles", body) if got.code != http.StatusCreated { t.Fatalf("%s create employee role: %d (%v)", act.name, got.code, got.body) } return got.body["data"].(map[string]any) } // The subject comes from the request; only the audit column comes from the // session. This is the test that fails if anyone ever derives worker_email the // way job-applications derives it for a talent caller. func TestEmployeeRoleRecordsTheWorkerNotTheOperator(t *testing.T) { r := newRBAC(t) rec := createEmployeeRole(t, r, r.empA, map[string]any{ "worker_email": "someone-else@example.test", "worker_name": "Someone Else", "role_category": "Bartender", }) if rec["worker_email"] == r.empA.email { t.Fatal("the operator became the worker") } if got := rec["worker_email"]; got != "someone-else@example.test" { t.Errorf("worker_email = %v, want the worker's", got) } if got := rec["created_by"]; got != r.empA.id { t.Errorf("created_by = %v, want the operator %v", got, r.empA.id) } } // created_by is ReadOnly in the descriptor, so a caller cannot attribute a role // to somebody else. The body's value is dropped, not honoured. func TestEmployeeRoleCreatedByIsNotClientSettable(t *testing.T) { r := newRBAC(t) rec := createEmployeeRole(t, r, r.empA, map[string]any{ "worker_email": "worker@example.test", "role_category": "Server", "created_by": r.admin.id, }) if got := rec["created_by"]; got != r.empA.id { t.Errorf("created_by = %v, want the caller %v — the body must not set it", got, r.empA.id) } } // The promise the review step makes, tested against the database. func TestAWorkerHoldsManyRolesAndNoneReplaceAnother(t *testing.T) { r := newRBAC(t) const worker = "many-roles@example.test" first := createEmployeeRole(t, r, r.empA, map[string]any{ "worker_email": worker, "worker_name": "Many Roles", "role_category": "Bartender", "status": "placed", }) second := createEmployeeRole(t, r, r.empA, map[string]any{ "worker_email": worker, "worker_name": "Many Roles", "role_category": "Server", }) // The same category again while the first is still placed: a worker who // finished a Bartender placement and is seeking Bartender work again. third := createEmployeeRole(t, r, r.empA, map[string]any{ "worker_email": worker, "worker_name": "Many Roles", "role_category": "Bartender", }) ids := map[string]bool{} for _, rec := range []map[string]any{first, second, third} { id := rec["id"].(string) if ids[id] { t.Fatalf("duplicate id %s — a role replaced another", id) } ids[id] = true } got := r.ids(t, r.empA, "/api/v1/employee-roles?worker_email="+worker) for id := range ids { if !got[id] { t.Errorf("role %s is missing — it was overwritten or filtered away", id) } } if len(got) != 3 { t.Errorf("%d roles for one worker, want 3", len(got)) } if first["status"] != "placed" { t.Errorf("the first role's status = %v, want placed to survive", first["status"]) } } // Every field the conversation collects survives the round trip. Named from the // payload the panel actually sends, so a column the flow fills and the API drops // fails here rather than silently arriving empty. func TestEmployeeRoleKeepsEveryCollectedField(t *testing.T) { r := newRBAC(t) rec := createEmployeeRole(t, r, r.admin, map[string]any{ "worker_email": "full@example.test", "worker_name": "Full Record", "role_category": "Picker", "experience_years": 3, "english_level": "native", "certifications": []string{"TIPS Certified"}, "desired_pay_min": 30, "desired_pay_max": 40, "availability": []string{"Weekdays"}, "notes": "recorded by the panel", "status": "seeking", }) for _, tc := range []struct { field string want any }{ {"role_category", "Picker"}, {"experience_years", float64(3)}, {"english_level", "native"}, {"desired_pay_min", float64(30)}, {"desired_pay_max", float64(40)}, {"notes", "recorded by the panel"}, {"status", "seeking"}, } { if got := rec[tc.field]; got != tc.want { t.Errorf("%s = %#v, want %#v", tc.field, got, tc.want) } } for _, tc := range []struct { field string want string }{{"certifications", "TIPS Certified"}, {"availability", "Weekdays"}} { list, _ := rec[tc.field].([]any) if len(list) != 1 || list[0] != tc.want { t.Errorf("%s = %#v, want [%q]", tc.field, rec[tc.field], tc.want) } } } // Cross-tenant isolation stands on its own: an ADMIN in another organization // gets 404, not 403, and never sees the row in a listing. func TestEmployeeRolesAreInvisibleAcrossOrganizations(t *testing.T) { r := newRBAC(t) rec := createEmployeeRole(t, r, r.admin, map[string]any{ "worker_email": "inside@example.test", "role_category": "Bartender", }) id := rec["id"].(string) if got := r.as(r.outsider, "GET", "/api/v1/employee-roles/"+id, nil); got.code != http.StatusNotFound { t.Errorf("outside admin GET = %d, want 404", got.code) } if r.ids(t, r.outsider, "/api/v1/employee-roles")[id] { t.Error("a role leaked into another organization's listing") } if got := r.as(r.outsider, "PATCH", "/api/v1/employee-roles/"+id, map[string]any{"notes": "n"}); got.code != http.StatusNotFound { t.Errorf("outside admin PATCH = %d, want 404", got.code) } } // A talent caller reads only their own declared roles, and cannot create. func TestTalentSeesOnlyItsOwnEmployeeRoles(t *testing.T) { r := newRBAC(t) mine := createEmployeeRole(t, r, r.empA, map[string]any{ "worker_email": r.talA.email, "role_category": "Bartender", }) theirs := createEmployeeRole(t, r, r.empA, map[string]any{ "worker_email": r.talB.email, "role_category": "Server", }) seen := r.ids(t, r.talA, "/api/v1/employee-roles") if !seen[mine["id"].(string)] { t.Error("talent cannot see its own declared role") } if seen[theirs["id"].(string)] { t.Error("talent A can see talent B's declared role") } if got := r.as(r.talA, "GET", "/api/v1/employee-roles/"+theirs["id"].(string), nil); got.code != http.StatusNotFound { t.Errorf("GET another talent's role = %d, want 404 — absent, not refused", got.code) } }