192 lines
6.2 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|