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
194 lines
6.3 KiB
Go
194 lines
6.3 KiB
Go
package domain
|
|
|
|
import "testing"
|
|
|
|
// Invariants of the policy table itself. No database: these catch the mistakes
|
|
// that would otherwise only show up as a missing 403 in an integration test, or
|
|
// not at all.
|
|
|
|
// Every resource must say who may reach it. A resource added to the schema and
|
|
// left out of policies.go is unreachable — which is the safe direction, and
|
|
// still a mistake worth failing on rather than discovering in production.
|
|
func TestEveryResourceHasAPolicy(t *testing.T) {
|
|
for _, r := range AllResources {
|
|
if r.Policy == nil {
|
|
t.Errorf("resource %q (%s) has no policy: it permits nothing, which is safe but almost certainly unintended",
|
|
r.Name, r.Path)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A nil policy denies everything. This is the property the test above relies on
|
|
// being true, so it is asserted rather than assumed.
|
|
func TestNilPolicyDeniesEverything(t *testing.T) {
|
|
var p *Policy
|
|
for _, op := range []Op{OpList, OpGet, OpCreate, OpUpdate, OpDelete} {
|
|
for _, role := range []Role{RoleAdmin, RoleEmployer, RoleTalent} {
|
|
if p.Allows(op, role) {
|
|
t.Errorf("a nil policy allowed op %d for %s", op, role)
|
|
}
|
|
}
|
|
}
|
|
if got := p.ScopeFor(RoleTalent); got.Kind != ScopeNone {
|
|
t.Error("a nil policy returned a scope")
|
|
}
|
|
}
|
|
|
|
// A policy must not grant an operation the resource does not expose. Such a
|
|
// grant is dead — no route is registered — but it reads as permission and would
|
|
// become real the moment the operation is added.
|
|
func TestPolicyGrantsNothingWithoutARoute(t *testing.T) {
|
|
ops := []struct {
|
|
op Op
|
|
name string
|
|
}{
|
|
{OpList, "List"}, {OpGet, "Get"}, {OpCreate, "Create"},
|
|
{OpUpdate, "Update"}, {OpDelete, "Delete"},
|
|
}
|
|
for _, r := range AllResources {
|
|
if r.Policy == nil {
|
|
continue
|
|
}
|
|
for _, o := range ops {
|
|
granted := len(r.Policy.rolesFor(o.op)) > 0
|
|
if granted && !r.Supports(o.op) {
|
|
t.Errorf("%s: policy grants %s but the resource has no such route", r.Path, o.name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// An unrecognised role authorizes nothing, whatever the policy says.
|
|
func TestUnknownRoleIsDenied(t *testing.T) {
|
|
if _, ok := ParseRole("superuser"); ok {
|
|
t.Fatal("ParseRole accepted a role outside the users_role_check constraint")
|
|
}
|
|
if _, ok := ParseRole(""); ok {
|
|
t.Fatal("ParseRole accepted an empty role")
|
|
}
|
|
for _, r := range AllResources {
|
|
if r.Policy.Allows(OpList, Role("superuser")) {
|
|
t.Errorf("%s allows an unknown role", r.Path)
|
|
}
|
|
}
|
|
// The three real ones parse.
|
|
for _, want := range []Role{RoleAdmin, RoleEmployer, RoleTalent} {
|
|
if got, ok := ParseRole(string(want)); !ok || got != want {
|
|
t.Errorf("ParseRole(%q) = %q, %v", want, got, ok)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Every column the server derives must also be ReadOnly, or a request body
|
|
// could still set it on a path the derivation does not cover.
|
|
func TestDerivedColumnsAreReadOnlyOrTalentScoped(t *testing.T) {
|
|
for _, r := range AllResources {
|
|
if r.Policy == nil {
|
|
continue
|
|
}
|
|
for _, d := range r.Policy.Derived {
|
|
col, ok := r.Column(d.Column)
|
|
if !ok {
|
|
t.Errorf("%s: derives %q, which is not a column", r.Path, d.Column)
|
|
continue
|
|
}
|
|
// A TalentOnly derivation intentionally leaves the column writable
|
|
// for operators — an admin filing a candidate's application must be
|
|
// able to say whose it is. The unconditional ones must be sealed.
|
|
if !d.TalentOnly && !col.ReadOnly {
|
|
t.Errorf("%s.%s is derived unconditionally but is not ReadOnly: a request body could still set it",
|
|
r.Path, d.Column)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The columns Phase 3D closed, plus every one added on the same rule since.
|
|
// Named explicitly, so that regenerating the descriptors without the
|
|
// SERVER_OWNED map in gen_resources.py fails loudly rather than silently
|
|
// reopening the holes.
|
|
func TestServerOwnedColumnsAreReadOnly(t *testing.T) {
|
|
sealed := map[string][]string{
|
|
"worker-profiles": {"user_id"},
|
|
"user-activity": {"user_id", "user_email", "user_name", "account_type"},
|
|
"job-postings": {"created_by"},
|
|
"employee-roles": {"created_by"},
|
|
}
|
|
for path, cols := range sealed {
|
|
res, ok := ResourceByPath[path]
|
|
if !ok {
|
|
t.Fatalf("resource %s is missing", path)
|
|
}
|
|
for _, name := range cols {
|
|
col, ok := res.Column(name)
|
|
if !ok {
|
|
t.Errorf("%s has no column %s", path, name)
|
|
continue
|
|
}
|
|
if !col.ReadOnly {
|
|
t.Errorf("%s.%s is not ReadOnly — a client could supply it", path, name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// And org_id everywhere, which predates Phase 3D and must stay that way.
|
|
for _, r := range AllResources {
|
|
if col, ok := r.Column("org_id"); ok && !col.ReadOnly {
|
|
t.Errorf("%s.org_id is not ReadOnly", r.Path)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Talent is the only scoped role. If a scope ever applied to an operator the
|
|
// admin console would start losing rows, which is a failure mode worth pinning.
|
|
func TestOnlyTalentIsRowScoped(t *testing.T) {
|
|
for _, r := range AllResources {
|
|
for _, role := range []Role{RoleAdmin, RoleEmployer} {
|
|
if got := r.Policy.ScopeFor(role); got.Kind != ScopeNone {
|
|
t.Errorf("%s scopes rows for %s: operators see the whole organization", r.Path, role)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Every talent scope must name a column the resource actually has.
|
|
func TestTalentScopesNameRealColumns(t *testing.T) {
|
|
for _, r := range AllResources {
|
|
scope := r.Policy.ScopeFor(RoleTalent)
|
|
if scope.Kind == ScopeNone {
|
|
continue
|
|
}
|
|
if scope.Column == "" {
|
|
t.Errorf("%s has a talent scope with no column", r.Path)
|
|
continue
|
|
}
|
|
if _, ok := r.Column(scope.Column); !ok {
|
|
t.Errorf("%s scopes on %q, which is not one of its columns", r.Path, scope.Column)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Talent must not reach an operator resource by having a scope but no grant,
|
|
// or a grant but no scope where one is required. This pins the shape of the
|
|
// contract: wherever talent may list a resource that also holds other people's
|
|
// rows, a scope must narrow it.
|
|
func TestTalentGrantsHaveScopesWhereRowsAreShared(t *testing.T) {
|
|
// Resources whose rows are the organization's rather than any one person's:
|
|
// a talent grant here is deliberate and needs no ownership predicate.
|
|
shared := map[string]bool{
|
|
"courses": true, "learning-paths": true,
|
|
"role-categories": true, "certifications": true,
|
|
}
|
|
for _, r := range AllResources {
|
|
if !r.Policy.Allows(OpList, RoleTalent) {
|
|
continue
|
|
}
|
|
if shared[r.Path] {
|
|
continue
|
|
}
|
|
if r.Policy.ScopeFor(RoleTalent).Kind == ScopeNone {
|
|
t.Errorf("%s: talent may list it but no ownership scope narrows the rows", r.Path)
|
|
}
|
|
}
|
|
}
|