196 lines
7.0 KiB
Go
196 lines
7.0 KiB
Go
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)
|
|
}
|
|
}
|