Files
krow_backend/go-api/internal/domain/policy_test.go
2026-08-24 13:06:29 +05:30

192 lines
6.2 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 six columns Phase 3D closed. 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"},
}
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)
}
}
}