package lua import ( "strings" "testing" ) // The two functions this file replaces had OPPOSITE defaults: // // audienceAllows: "" / "all" → everybody // HTTPHandler.allows: "" → admin ONLY // // Both are correct for their surface. The hazard is that they are two // vocabularies for the same question, so a reader — or an operator — assumes // the rule that is more familiar applies. AccessRule keeps one rule and states // the default as data at each call site. // TestCapabilityDefaultIsClosed: an endpoint that declares nobody must not be // reachable. This is the property that makes "I forgot" fail safe. func TestCapabilityDefaultIsClosed(t *testing.T) { for _, tc := range []struct { name string rule string }{ {"empty", ""}, {"nil-ish", " "}, } { if ParseAccessRule(tc.rule).AllowsCapability("user", "alice") { t.Errorf("%s: an undeclared capability is reachable by a user", tc.name) } if !ParseAccessRule(tc.rule).AllowsCapability("admin", "alice") { t.Errorf("%s: admin locked out of a capability they must be able to inspect", tc.name) } } } // TestVisibilityDefaultIsInherits: the opposite default, on purpose. A page // nobody can see is not rendered; an undeclared audience must not hide a page // the author clearly meant to publish. func TestVisibilityDefaultIsInherits(t *testing.T) { if !ParseAccessRule("").AllowsVisibility("user") { t.Error("an undeclared audience hides a page") } if ParseAccessRule("admin").AllowsVisibility("user") { t.Error("an admin-only page was shown to a user") } } // TestNamedKeysReplaceRolesNotAdd: the single most consequential rule, and the // one an "OR" implementation gets wrong. "allow these two keys" must not also // admit every user key. func TestNamedKeysReplaceRolesNotAdd(t *testing.T) { r := ParseAccessRule("@alice, @bob") if !r.AllowsCapability("user", "alice") { t.Error("a named key was not admitted") } if r.AllowsCapability("user", "carol") { t.Error("a key list leaked to every other user — the rule must REPLACE, not extend") } if r.AllowsCapability("user", "") { t.Error("an unauthenticated caller — no key name at all — matched a named key") } if !r.AllowsCapability("admin", "") { t.Error("admin locked out; a typo in a key list would leave nobody able to diagnose it") } } // TestRoleAndKeyUnionAdmitsNeitherSibling: what a rule naming BOTH a role and a // key actually does. // // Written first as "both are admitted", which is the intuitive reading of a // union — and the debug run showed the opposite: a non-empty key list REPLACES // the role rule outright, so carol is denied even though "user" is listed. // // That surprise is exactly why the rule is named, commented and now tested. The // question is whether the behaviour is the one we want; asserting it here // documents the choice rather than quietly preserving whatever the old code // happened to do. The behaviour matches the pre-existing HTTPHandler.allows, so // nothing deployed changes. func TestRoleAndKeyUnionAdmitsNeitherSibling(t *testing.T) { r := ParseAccessRule("user, @alice") if !r.AllowsCapability("user", "alice") { t.Error("the named key was not admitted") } if r.AllowsCapability("user", "carol") { t.Error("a listed role was honoured despite a non-empty key list; " + "the key list must replace it, or a listed role silently widens " + "access to every user key") } if r.AllowsCapability("user", "mallory") { t.Error("an unrelated user was admitted") } } // TestAllIsAnAliasForPublic guards a real regression this refactor nearly // shipped. "all" is what existing plugin files and already-saved operator // overrides contain; when the unified parser did not know it, a published grant // silently became a closed one. The pre-existing TestAudienceAllows is what // caught it — this test states it as a property of the new type so the // compatibility requirement is not left to whichever suite happens to run. func TestAllIsAnAliasForPublic(t *testing.T) { r := ParseAccessRule("all") if !r.Public { t.Fatal(`"all" did not parse as public`) } if !r.AllowsCapability("user", "carol") { t.Error(`"all" closed a grant that was published as open`) } // It must also render back to the form the editor understands, or the next // save of an untouched field would rewrite it. if !strings.Contains(strings.ToLower(r.Rule()), "public") { t.Errorf("Rule() = %q, want it to render as public", r.Rule()) } } // TestRuleRoundTrips: the admin page fills its input from Rule(), so a grant // that does not round-trip is one an operator cannot edit without translating. func TestRuleRoundTrips(t *testing.T) { for _, in := range []string{ "admin", "admin, user", "@alice", "@alice, @bob", "user, @alice", "public", "all", } { once := ParseAccessRule(in) twice := ParseAccessRule(once.Rule()) if once.Rule() != twice.Rule() { t.Errorf("%q -> %q -> %q, not stable", in, once.Rule(), twice.Rule()) } if len(twice.Roles) != len(once.Roles) || len(twice.Keys) != len(once.Keys) { t.Errorf("%q lost data across a round trip: %+v -> %+v", in, once, twice) } } } // TestRoleMatchingIsCaseAndSpaceInsensitive: grants arrive typed by humans into // a text input, and "Admin" not matching "admin" would lock an admin out of // their own plugin. func TestRoleMatchingIsCaseAndSpaceInsensitive(t *testing.T) { if !ParseAccessRule(" Admin , User ").AllowsCapability("admin", "x") { t.Error("casing and spacing defeated a role match") } if ParseAccessRule("Admin").AllowsCapability("user", "x") { t.Error("a user was admitted by a case variant") } if !ParseAccessRule(" @Alice ").AllowsCapability("user", "alice") { t.Error("casing defeated a named-key match") } }