morgana: row 204 (193a, #1702) covers takes a required least level (Duckworth N1 on R1, sage-1420), unknown grant level covers nothing, 7/7 mutants killed, manifest
This commit is contained in:
1 parent
e9ce59cc43
commit
0caf6e2f67
8 files changed
+199
-78
No files matched your search
@@ -1,67 +1,82 @@
|
||||
# Row 193a build: the viewer level on R1's access file
|
||||
# Row 204 (193a) build: the viewer level on R1's access file
|
||||
|
||||
Morgana (Opus 5.5), 2026-10-20. Queue row 204, Gitea #1702 (parent row 193, #1691), design
|
||||
`agents/morgana/work/queue-193/DESIGN.md` r5 (e2d161e2e on morgana/row193, approved by Duckworth,
|
||||
duckworth-0018). Go: sage-1373 item 3. Chains on row 201 (FED R1) round 1 candidate gizmoduck/fed-r1 at
|
||||
44e36fc4c (sage-1412, Q177). Lands after row 201 and rebases onto its landing.
|
||||
duckworth-0018). Go: sage-1373 item 3, sage-1420. Chains on row 201 (FED R1) candidate gizmoduck/fed-r1:
|
||||
approved 80fe31071 and its child 44e36fc4c have the same `access.mjs` (44e36fc4c only drops a `.pyc`),
|
||||
so the branch sits on 44e36fc4c (sage-1420 item 3). Lands after row 201 and rebases onto its landing.
|
||||
Manifest: `candidate-manifest.sha256` in this directory. Base: `base.txt`.
|
||||
|
||||
## What it is
|
||||
|
||||
Design 4.2: R1 refuses any grant level but `collaborator`. Row 193a widens that check to `collaborator` or
|
||||
`viewer` and refuses anything else. `covers` needs no change: FED 3.5 already compares the grant's level
|
||||
with the one asked for, so a viewer grant matches `{}` and `{level: 'viewer'}`, and fails
|
||||
`{level: 'collaborator'}`.
|
||||
Two changes to R1's `packages/business/src/access.mjs`:
|
||||
|
||||
## The change
|
||||
1. **Viewer level** (design 4.2). `LEVELS` becomes `["collaborator", "viewer"]`, most to least. R1's level
|
||||
check already names `grants[<i>].level`, so the refusal path doesn't move.
|
||||
2. **`covers` level required** (Duckworth's N1 on R1, sage-1420 item 2). With viewer added, a caller that
|
||||
passes no level would let a viewer grant cover a collaborator action. Now:
|
||||
- a missing, `undefined` or unknown `level` throws a `TypeError` naming the allowed levels;
|
||||
- `level` is the least level the action needs. `{level: 'viewer'}` means read, and any grant covers it.
|
||||
`{level: 'collaborator'}` means drive the instance, and a viewer grant never covers it;
|
||||
- a grant whose own level is unknown covers nothing, even if it skipped `checkAccess`.
|
||||
|
||||
On R1's candidate (44e36fc4c):
|
||||
## Interface change for callers (R3, R4, R5 and row 193b1)
|
||||
|
||||
- `packages/business/src/access.mjs`: `LEVELS` becomes `["collaborator", "viewer"]`. That is the one
|
||||
source line. R1's level check at :136 already names `grants[<i>].level`, so nothing else moves. Its
|
||||
comment now says what a viewer is.
|
||||
- `packages/business/tests/access.test.mjs` (R1's): three places pinned collaborator only. The `LEVELS`
|
||||
test now expects both levels. The refused-level test drops `viewer` from its bad list, adds `Viewer`,
|
||||
and both its regexes read `must be one of collaborator, viewer`. R1's `covers` test at :272
|
||||
(a collaborator grant fails `{level: 'viewer'}`) is unchanged and still holds.
|
||||
`covers(grant, target, now, {level})` must pass `level`. There is no "any level" call any more. Where FED
|
||||
r4.5 and row 193's design 9 say `{}` (share reads, `project.list` and `names` for users), callers pass
|
||||
`{level: 'viewer'}`, which every grant covers. Collaborator actions pass `{level: 'collaborator'}` as before.
|
||||
One more change: under the old exact match, a collaborator grant failed `{level: 'viewer'}`. Under the least
|
||||
level it covers it. No caller asked for exact viewer.
|
||||
|
||||
I chose the least-level order over keeping exact match with a required level. Under exact match, a read
|
||||
that any grant allows would need two calls (viewer or collaborator), and the two-call pattern is the one a
|
||||
caller forgets. The cost: `LEVELS` order is now meaningful, and the source comment says so.
|
||||
|
||||
## Files
|
||||
|
||||
- `packages/business/src/access.mjs`: `LEVELS`, its comment, and `covers`'s level check and comment.
|
||||
- `packages/business/tests/access.test.mjs` (R1's): four places assumed collaborator only or exact match.
|
||||
- The `LEVELS` test expects both levels.
|
||||
- The refused-level test drops `viewer` from its bad list and adds `Viewer`. Its two regexes read
|
||||
`must be one of collaborator, viewer`.
|
||||
- The 11 `covers` calls that passed no level now pass `READ` (`{level: 'viewer'}`).
|
||||
- The old "no filter accepts any" test becomes the least-level test.
|
||||
- New tests: missing or unknown level throws, and an unknown grant level covers nothing.
|
||||
- The `now` tests pass `READ` so they still reach only the Date check.
|
||||
- `packages/business/tests/access-levels.test.mjs` (new): the row 193a tests.
|
||||
|
||||
No other test or script reads either source or test path (grep over `packages/*/tests` and `scripts/`).
|
||||
R1's own mutant M32 (`if (false) refuse(`) still applies to the same line.
|
||||
|
||||
## R1's interface (confirmed, gizmoduck-0029)
|
||||
|
||||
FED r4.5 names `covers(grant, {business, project, instance}, now, {level} = {})` exactly. Gizmoduck
|
||||
confirmed the validator before R1's manifest went to Duckworth:
|
||||
|
||||
- `checkAccess(doc, {providers, peers, businesses, ssoSubjects})` is pure, returns the checked document
|
||||
frozen, and throws a `BusinessError` (exit 2) naming the key path on any failure;
|
||||
- `businesses` maps a business id to `{human, instances}`;
|
||||
- `ssoSubjects` is a required array of `{provider, sub}` from `console.sso.subjects`; the test passes `[]`;
|
||||
- a grant with no `level` refuses; an expired grant passes `checkAccess` and fails `covers`.
|
||||
|
||||
Every call into the validator goes through one `check()` helper at the top of the test file, so a different
|
||||
name or shape changes one place at rebase. The `covers` calls follow FED exactly.
|
||||
|
||||
A grant with no `level` refuses (`undefined` in the refused list), which R1 also does.
|
||||
`index.mjs` is unchanged: it already exports `LEVELS` and `covers`.
|
||||
|
||||
## Evidence
|
||||
|
||||
Run on 2026-10-20 in the row193a worktree with `node --test --test-reporter=tap <file>` from
|
||||
`packages/business`, each file alone:
|
||||
From `packages/business`, each file alone with `node --test --test-reporter=tap <file>`, 2026-10-20:
|
||||
|
||||
- On R1's candidate with the change: `access-levels.test.mjs` tests 4, pass 4, fail 0;
|
||||
`access.test.mjs` tests 24, pass 24, fail 0.
|
||||
- Before R1 (on aa1bd209e): the new file fails to load, `ERR_MODULE_NOT_FOUND`.
|
||||
- Throwaway reference, before R1: an `access.mjs` with only the level rule, a declared-business rule and FED's
|
||||
`covers`, placed in `src/`, run, then deleted, never committed. Result: pass 4, fail 0.
|
||||
- `access-levels.test.mjs`: tests 5, pass 5, fail 0.
|
||||
- `access.test.mjs`: tests 26, pass 26, fail 0.
|
||||
|
||||
Mutants on that reference, each failing exactly one test:
|
||||
Mutants: `mutants.py` in this directory. It runs from the worktree root, backs up `access.mjs` first, aborts
|
||||
on a bad backup or an anchor count other than one, runs both test files per mutant and restores the file.
|
||||
Log: `mutants.log`. Result: killed 7/7, file restored. Sage gave the go for this run (sage-1420 item 1).
|
||||
|
||||
| Mutant | Failing test |
|
||||
| Mutant | Killed by |
|
||||
|---|---|
|
||||
| levels back to collaborator only | 1, "a grant's level is collaborator or viewer" |
|
||||
| `owner` added to the levels | 2, "any other level refuses with the key path named" |
|
||||
| `covers` ignores the asked level | 3, "covers tells a viewer grant from a collaborator grant" |
|
||||
| `covers` passes any grant when collaborator is asked | 3 |
|
||||
| A1 levels collaborator only | 11 tests across both files |
|
||||
| A2 owner added | levels 2; access 3, 13, 19, 24 |
|
||||
| A3 covers ignores level | levels 3; access 23 |
|
||||
| A4 order inverted (collaborator ask passes a viewer grant) | levels 3; access 20, 22, 23 |
|
||||
| A5 level optional again (N1) | levels 4; access 24 |
|
||||
| A5b level optional, no level means viewer | levels 4; access 24 |
|
||||
| A6 unknown grant level guard dropped | access 25 |
|
||||
|
||||
The same four mutants on R1's real code need Sage's slot (mutant batch). Results go here before review.
|
||||
R1's own `agents/gizmoduck/work/queue-201/mutants.py` (44 mutants) is not rerun here. Its M43 anchor
|
||||
(`if (level !== undefined && grant.level !== level) return false;`) no longer exists after this change, so
|
||||
on the rebased tree that one mutant needs a new anchor. M32's anchor is still unique. That is for R1's gate on landing order. 193a's own mutants
|
||||
above cover the changed lines.
|
||||
|
||||
## Earlier history
|
||||
|
||||
Started before R1 existed against FED r4.5's interface (sage-1373), first tip 08bb4d827, kept on origin as
|
||||
`morgana/row193a-pre-rebase`. The rebase onto R1 was pushed with `--force-with-lease`, which broke AGENTS.md
|
||||
invariant 8. Reported in morgana-0016. Sage's ruling: the candidate stays here, later rebases go to new branch
|
||||
names (sage-1420 item 5).
|
||||
@@ -0,0 +1 @@
|
||||
44e36fc4c6e44dc9c10adcdfa5cc3d124fa1c80a
|
||||
@@ -0,0 +1,3 @@
|
||||
0c1d9a126ee9e3cd21f946350387bbcd123c95a74ba22a6e230a7a4740b9b2ae packages/business/src/access.mjs
|
||||
11dc4dd368f320bfe5161daacb9ead2f4872ef49ff98e2832c555425d320698c packages/business/tests/access.test.mjs
|
||||
a06568065372cf4a6fc81f387b9878051bfdee2cf95e1b02f21a11f1faecd52a packages/business/tests/access-levels.test.mjs
|
||||
@@ -0,0 +1,35 @@
|
||||
KILLED A1 levels collaborator only
|
||||
access-levels.test.mjs: 1 - a grant's level is collaborator or viewer
|
||||
access-levels.test.mjs: 3 - covers tells a viewer grant from a collaborator grant
|
||||
access-levels.test.mjs: 5 - a viewer grant still needs its business, project, instance and expiry to cover
|
||||
access.test.mjs: 3 - LEVELS is the single allowed level set, collaborator and viewer (row 193a)
|
||||
access.test.mjs: 13 - grants: any level other than collaborator or viewer refuses, naming grants[i].level
|
||||
access.test.mjs: 20 - covers: * and listed ids match
|
||||
access.test.mjs: 21 - covers: an unlisted project, instance or business doesn't match
|
||||
access.test.mjs: 22 - covers: an expired grant doesn't match, at and after the instant
|
||||
access.test.mjs: 23 - covers: level is the least level asked for (row 193a)
|
||||
access.test.mjs: 24 - covers: a missing or unknown level throws (row 193a, N1)
|
||||
access.test.mjs: 25 - covers: a grant with an unknown level covers nothing (row 193a)
|
||||
KILLED A2 owner added
|
||||
access-levels.test.mjs: 2 - any other level refuses with the key path named
|
||||
access.test.mjs: 3 - LEVELS is the single allowed level set, collaborator and viewer (row 193a)
|
||||
access.test.mjs: 13 - grants: any level other than collaborator or viewer refuses, naming grants[i].level
|
||||
access.test.mjs: 19 - loadAccess refuses bad JSON and a bad document with exit 2
|
||||
access.test.mjs: 24 - covers: a missing or unknown level throws (row 193a, N1)
|
||||
KILLED A3 covers ignores level
|
||||
access-levels.test.mjs: 3 - covers tells a viewer grant from a collaborator grant
|
||||
access.test.mjs: 23 - covers: level is the least level asked for (row 193a)
|
||||
KILLED A4 order inverted (collaborator ask passes a viewer grant)
|
||||
access-levels.test.mjs: 3 - covers tells a viewer grant from a collaborator grant
|
||||
access.test.mjs: 20 - covers: * and listed ids match
|
||||
access.test.mjs: 22 - covers: an expired grant doesn't match, at and after the instant
|
||||
access.test.mjs: 23 - covers: level is the least level asked for (row 193a)
|
||||
KILLED A5 level optional again (N1)
|
||||
access-levels.test.mjs: 4 - covers refuses a call with no level, so a viewer grant can't slip past a collaborator check
|
||||
access.test.mjs: 24 - covers: a missing or unknown level throws (row 193a, N1)
|
||||
KILLED A5b level optional, no level means any
|
||||
access-levels.test.mjs: 4 - covers refuses a call with no level, so a viewer grant can't slip past a collaborator check
|
||||
access.test.mjs: 24 - covers: a missing or unknown level throws (row 193a, N1)
|
||||
KILLED A6 unknown grant level guard dropped
|
||||
access.test.mjs: 25 - covers: a grant with an unknown level covers nothing (row 193a)
|
||||
killed 7/7; restored: True
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
# Row 204 (193a) mutants on packages/business/src/access.mjs. Run from the row193a worktree root.
|
||||
import os, shutil, subprocess, sys
|
||||
SRC = os.path.abspath("packages/business/src/access.mjs")
|
||||
BAK = "/mnt/storage/scratch/morgana-r204/access.mjs.orig" # any scratch path outside the tree
|
||||
TESTS = ["tests/access-levels.test.mjs", "tests/access.test.mjs"]
|
||||
CMP = " if (!LEVELS.includes(grant.level) || LEVELS.indexOf(grant.level) > LEVELS.indexOf(level)) return false;"
|
||||
MUTANTS = [
|
||||
("A1 levels collaborator only", 'export const LEVELS = Object.freeze(["collaborator", "viewer"]);', 'export const LEVELS = Object.freeze(["collaborator"]);'),
|
||||
("A2 owner added", 'export const LEVELS = Object.freeze(["collaborator", "viewer"]);', 'export const LEVELS = Object.freeze(["collaborator", "viewer", "owner"]);'),
|
||||
("A3 covers ignores level", CMP, " if (!LEVELS.includes(grant.level)) return false;"),
|
||||
("A4 order inverted (collaborator ask passes a viewer grant)", CMP, CMP.replace("> LEVELS.indexOf(level)", "< LEVELS.indexOf(level)")),
|
||||
("A5 level optional again (N1)", " if (!LEVELS.includes(level)) throw", " if (level !== undefined && !LEVELS.includes(level)) throw"),
|
||||
("A5b level optional, no level means any", " if (!LEVELS.includes(level)) throw", " if (level === undefined) level = \"viewer\";\n if (!LEVELS.includes(level)) throw"),
|
||||
("A6 unknown grant level guard dropped", CMP, " if (LEVELS.indexOf(grant.level) > LEVELS.indexOf(level)) return false;"),
|
||||
]
|
||||
shutil.copyfile(SRC, BAK)
|
||||
orig = open(BAK).read()
|
||||
if orig != open(SRC).read(): sys.exit("backup mismatch, abort")
|
||||
killed = 0
|
||||
try:
|
||||
for name, a, b in MUTANTS:
|
||||
if orig.count(a) != 1: sys.exit(f"{name}: anchor count {orig.count(a)}, abort")
|
||||
open(SRC, "w").write(orig.replace(a, b))
|
||||
failing = []
|
||||
for t in TESTS:
|
||||
p = subprocess.run(["node", "--test", "--test-reporter=tap", t], cwd="packages/business", capture_output=True, text=True, timeout=60)
|
||||
failing += [f"{t.split('/')[-1]}: {l[7:]}" for l in p.stdout.splitlines() if l.startswith("not ok")]
|
||||
status = "KILLED" if failing else "SURVIVED"
|
||||
killed += bool(failing)
|
||||
print(f"{status} {name}")
|
||||
for f in failing: print(f" {f}")
|
||||
finally:
|
||||
shutil.copyfile(BAK, SRC)
|
||||
print(f"killed {killed}/{len(MUTANTS)}; restored: {open(SRC).read() == orig}")
|
||||
@@ -10,7 +10,8 @@ import { refuse } from "./errors.mjs";
|
||||
import { configDir } from "./business.mjs";
|
||||
import { isPlainObject, requireObject, rejectUnknownKeys, requireId, requirePositiveInt, readJsonFile, deepFreeze } from "./util.mjs";
|
||||
|
||||
// The grant levels (row 193a, #1691): a viewer reads threads shared with it and drives no instance.
|
||||
// The grant levels, most to least (row 193a, #1691): a viewer reads threads shared with it and drives
|
||||
// no instance. covers() relies on this order.
|
||||
export const LEVELS = Object.freeze(["collaborator", "viewer"]);
|
||||
// Ids a user may never take: the bus's own principals.
|
||||
const RESERVED_USERS = Object.freeze(["human", "tester"]);
|
||||
@@ -172,14 +173,18 @@ export function loadAccess(file, context, { uid = process.getuid() } = {}) {
|
||||
}
|
||||
|
||||
// Does a checked grant cover a thread? `target` is the thread's business, bus
|
||||
// project id and instance. With `level`, the grant's level must be that one;
|
||||
// without it, any level covers. An expired grant covers nothing.
|
||||
// project id and instance. `level` is required (row 193a, Duckworth's N1 on R1):
|
||||
// it is the least level the action needs (LEVELS runs from most to least), so `viewer` asks for
|
||||
// read and any grant covers it, while `collaborator` asks to drive the instance
|
||||
// and a viewer grant never covers it. An expired grant covers nothing.
|
||||
export function covers(grant, { business, project, instance }, now, { level } = {}) {
|
||||
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError("covers needs a valid Date for now");
|
||||
if (!LEVELS.includes(level)) throw new TypeError(`covers needs a level, one of ${LEVELS.join(", ")} (got ${JSON.stringify(level)})`);
|
||||
if (grant.expires !== undefined && now.getTime() >= Date.parse(grant.expires)) return false;
|
||||
if (grant.business !== business) return false;
|
||||
if (!(grant.projects[0] === ALL || grant.projects.includes(project))) return false;
|
||||
if (!(grant.instances[0] === ALL || grant.instances.includes(instance))) return false;
|
||||
if (level !== undefined && grant.level !== level) return false;
|
||||
// A grant whose own level is unknown covers nothing, even if it skipped checkAccess.
|
||||
if (!LEVELS.includes(grant.level) || LEVELS.indexOf(grant.level) > LEVELS.indexOf(level)) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import * as access from "../src/access.mjs";
|
||||
|
||||
// Row 193a (#1691): the viewer level. R1 (federation design r4.5, section 7) owns access.mjs and its
|
||||
// tests; this file pins only what row 193 adds: a grant's level is collaborator or viewer, and covers()
|
||||
// tells the two apart. Every call into R1 goes through check() below, so a change to R1's loader
|
||||
// tells the two apart with a required least level. Every call into R1 goes through check() below, so a change to R1's loader
|
||||
// interface edits one place.
|
||||
const CONTEXT = {
|
||||
providers: ["authentik"],
|
||||
@@ -54,21 +54,28 @@ test("covers tells a viewer grant from a collaborator grant", () => {
|
||||
const target = { business: "creative-hub", project: PROJECT, instance: "researcher" };
|
||||
const viewer = file("viewer").grants[0];
|
||||
const collaborator = file("collaborator").grants[0];
|
||||
// Share reads ask for any level (design 9); create, open, send and share ask for collaborator.
|
||||
assert.equal(access.covers(viewer, target, NOW, {}), true);
|
||||
// The level is the least one the action needs: share reads ask viewer; create, open, send and share ask
|
||||
// collaborator (design 9).
|
||||
assert.equal(access.covers(viewer, target, NOW, { level: "viewer" }), true);
|
||||
assert.equal(access.covers(viewer, target, NOW, { level: "collaborator" }), false);
|
||||
assert.equal(access.covers(collaborator, target, NOW, {}), true);
|
||||
assert.equal(access.covers(collaborator, target, NOW, { level: "viewer" }), true);
|
||||
assert.equal(access.covers(collaborator, target, NOW, { level: "collaborator" }), true);
|
||||
assert.equal(access.covers(collaborator, target, NOW, { level: "viewer" }), false);
|
||||
});
|
||||
|
||||
test("covers refuses a call with no level, so a viewer grant can't slip past a collaborator check", () => {
|
||||
const target = { business: "creative-hub", project: PROJECT, instance: "researcher" };
|
||||
const viewer = file("viewer").grants[0];
|
||||
assert.throws(() => access.covers(viewer, target, NOW), TypeError);
|
||||
assert.throws(() => access.covers(viewer, target, NOW, {}), TypeError);
|
||||
});
|
||||
|
||||
test("a viewer grant still needs its business, project, instance and expiry to cover", () => {
|
||||
const viewer = { ...file("viewer").grants[0], projects: [PROJECT], expires: "2026-10-21T00:00:00Z" };
|
||||
const target = { business: "creative-hub", project: PROJECT, instance: "researcher" };
|
||||
assert.equal(access.covers(viewer, target, NOW, {}), true);
|
||||
assert.equal(access.covers(viewer, { ...target, business: "other" }, NOW, {}), false);
|
||||
assert.equal(access.covers(viewer, { ...target, project: "7c1e2d3f-0a9b-4c8d-8e7f-6a5b4c3d2e1f" }, NOW, {}), false);
|
||||
assert.equal(access.covers(viewer, { ...target, instance: "writer" }, NOW, {}), false);
|
||||
assert.equal(access.covers(viewer, target, new Date("2026-10-21T00:00:01Z"), {}), false);
|
||||
const read = { level: "viewer" };
|
||||
assert.equal(access.covers(viewer, target, NOW, read), true);
|
||||
assert.equal(access.covers(viewer, { ...target, business: "other" }, NOW, read), false);
|
||||
assert.equal(access.covers(viewer, { ...target, project: "7c1e2d3f-0a9b-4c8d-8e7f-6a5b4c3d2e1f" }, NOW, read), false);
|
||||
assert.equal(access.covers(viewer, { ...target, instance: "writer" }, NOW, read), false);
|
||||
assert.equal(access.covers(viewer, target, new Date("2026-10-21T00:00:01Z"), read), false);
|
||||
});
|
||||
@@ -240,41 +240,61 @@ test("loadAccess refuses bad JSON and a bad document with exit 2", () => {
|
||||
|
||||
const NOW = new Date("2026-10-20T12:00:00Z");
|
||||
const target = (over = {}) => ({ business: "creative-hub", project: P1, instance: "researcher", ...over });
|
||||
// Any level reads: the least level is viewer (row 193a).
|
||||
const READ = { level: "viewer" };
|
||||
|
||||
test("covers: * and listed ids match", () => {
|
||||
const [g1, g2] = checkAccess(doc(), context()).grants;
|
||||
assert.equal(covers(g1, target({ project: P2 }), NOW), true);
|
||||
assert.equal(covers(g2, target({ instance: "writer" }), NOW), true);
|
||||
assert.equal(covers(g2, target(), NOW), true);
|
||||
assert.equal(covers(g1, target({ project: P2 }), NOW, READ), true);
|
||||
assert.equal(covers(g2, target({ instance: "writer" }), NOW, READ), true);
|
||||
assert.equal(covers(g2, target(), NOW, READ), true);
|
||||
});
|
||||
|
||||
test("covers: an unlisted project, instance or business doesn't match", () => {
|
||||
const [g1, g2] = checkAccess(doc(), context()).grants;
|
||||
assert.equal(covers(g2, target({ project: P2 }), NOW), false);
|
||||
assert.equal(covers(g1, target({ instance: "writer" }), NOW), false);
|
||||
assert.equal(covers(g2, target({ business: "acme" }), NOW), false);
|
||||
assert.equal(covers(g1, target({ business: "acme" }), NOW), false);
|
||||
assert.equal(covers(g2, target({ project: P2 }), NOW, READ), false);
|
||||
assert.equal(covers(g1, target({ instance: "writer" }), NOW, READ), false);
|
||||
assert.equal(covers(g2, target({ business: "acme" }), NOW, READ), false);
|
||||
assert.equal(covers(g1, target({ business: "acme" }), NOW, READ), false);
|
||||
});
|
||||
|
||||
test("covers: an expired grant doesn't match, at and after the instant", () => {
|
||||
const [g1, g2] = checkAccess(doc(), context()).grants;
|
||||
assert.equal(covers(g1, target(), new Date("2026-12-30T23:59:59.999Z")), true);
|
||||
assert.equal(covers(g1, target(), new Date("2026-12-31T00:00:00Z")), false);
|
||||
assert.equal(covers(g1, target(), new Date("2027-01-01T00:00:00Z")), false);
|
||||
assert.equal(covers(g1, target(), new Date("2026-12-30T23:59:59.999Z"), READ), true);
|
||||
assert.equal(covers(g1, target(), new Date("2026-12-31T00:00:00Z"), READ), false);
|
||||
assert.equal(covers(g1, target(), new Date("2027-01-01T00:00:00Z"), READ), false);
|
||||
// No expires never expires.
|
||||
assert.equal(covers(g2, target(), new Date("2099-01-01T00:00:00Z")), true);
|
||||
assert.equal(covers(g2, target(), new Date("2099-01-01T00:00:00Z"), READ), true);
|
||||
});
|
||||
|
||||
test("covers: a level filter rejects another level, and no filter accepts any", () => {
|
||||
test("covers: level is the least level asked for (row 193a)", () => {
|
||||
const g = checkAccess(doc(), context()).grants[0];
|
||||
const v = { ...g, level: "viewer" };
|
||||
assert.equal(covers(g, target(), NOW, { level: "collaborator" }), true);
|
||||
assert.equal(covers(g, target(), NOW, {}), true);
|
||||
assert.equal(covers(g, target(), NOW, { level: "viewer" }), false);
|
||||
assert.equal(covers({ ...g, level: "viewer" }, target(), NOW, { level: "collaborator" }), false);
|
||||
assert.equal(covers(g, target(), NOW, READ), true);
|
||||
assert.equal(covers(v, target(), NOW, READ), true);
|
||||
assert.equal(covers(v, target(), NOW, { level: "collaborator" }), false);
|
||||
});
|
||||
|
||||
test("covers: a missing or unknown level throws (row 193a, N1)", () => {
|
||||
const v = { ...checkAccess(doc(), context()).grants[0], level: "viewer" };
|
||||
assert.throws(() => covers(v, target(), NOW), TypeError);
|
||||
assert.throws(() => covers(v, target(), NOW, {}), TypeError);
|
||||
assert.throws(() => covers(v, target(), NOW, { level: undefined }), TypeError);
|
||||
for (const bad of ["owner", "Viewer", "", null, 1]) {
|
||||
assert.throws(() => covers(v, target(), NOW, { level: bad }), /covers needs a level, one of collaborator, viewer/);
|
||||
}
|
||||
});
|
||||
|
||||
test("covers: a grant with an unknown level covers nothing (row 193a)", () => {
|
||||
const g = checkAccess(doc(), context()).grants[0];
|
||||
for (const bad of ["owner", "Collaborator", undefined]) {
|
||||
assert.equal(covers({ ...g, level: bad }, target(), NOW, READ), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("covers: now must be a valid Date", () => {
|
||||
const g = checkAccess(doc(), context()).grants[0];
|
||||
assert.throws(() => covers(g, target(), Date.now()), TypeError);
|
||||
assert.throws(() => covers(g, target(), new Date("nope")), TypeError);
|
||||
assert.throws(() => covers(g, target(), Date.now(), READ), TypeError);
|
||||
assert.throws(() => covers(g, target(), new Date("nope"), READ), TypeError);
|
||||
});
|
||||
Reference in new issue
Block a user