diff --git a/qa/release-v2/README.md b/qa/release-v2/README.md index 4c521731a..f4e41cf25 100644 --- a/qa/release-v2/README.md +++ b/qa/release-v2/README.md @@ -20,6 +20,8 @@ The fixture command binds to an explicit loopback host on an automatically alloc Each populated fixture manifest keeps the human-readable persona label in `personas[].name` and exposes the non-secret credentials username in `personas[].loginUsername`. Board route names are deterministic, unique, and validation-safe in `boards[].name`; shared board names exactly match the IDs in `coverage-manifest.json` (including `qa-scroll-lab`), while the human-readable title remains in `boards[].label` and the board page/meta titles. The login username is the deterministic lowercase persona handle; the password remains available only through `QA_PASSWORD_FILE`. +`scripts/release-v2-qa/permissions.mts` explicitly maps all 45 packet persona/board combinations to `none`, `view`, `modify`, or `full`. The seeder collapses repeated assignments to the highest access required by the campaign and writes only the resulting direct fixture-board grants; administrators and board owners rely on their existing implicit full access. The fixture manifest records both `packetBoardAccess` and the collapsed `expectedBoardAccess`. Live verification compares the exact direct rows and rejects unrelated grants, Nolan protected-board access, or Vivian edit access. Any access-contract change must update the packet mapping and its focused spec together. + Generated placements are validated before the seed transaction commits. Root items and containers must fit the active main/left/right lane after gutters, nested containers must fit their parent content grid without cycles, and every item must fit its selected root or container in every layout. `qa-grid-24` includes a dedicated unguttered 24-column custom layout that preserves exact width coverage `1–24`; its guttered Base layout and all narrower layouts keep lane-valid projected widths. Use `report -- --init` to create missing agent reports. Use `report -- --refresh-placeholders` after changing manifest templates or rolling the checkout candidate. It refreshes only safe, untouched `not-reached` placeholders, reports refreshed and protected-skip counts, and preserves reports with execution or case results, widget results, findings, artifacts, measurements, limitations, or reproduction data byte-for-byte. Malformed reports are protected skips rather than overwrite candidates. diff --git a/scripts/release-v2-qa/permissions.mts b/scripts/release-v2-qa/permissions.mts new file mode 100644 index 000000000..277926d61 --- /dev/null +++ b/scripts/release-v2-qa/permissions.mts @@ -0,0 +1,252 @@ +export const releaseV2QaBoardAccessValues = ["none", "view", "modify", "full"] as const; + +export type ReleaseV2QaBoardAccess = (typeof releaseV2QaBoardAccessValues)[number]; +export type ReleaseV2QaPacketBoardAccess = Record>>; + +export interface ReleaseV2QaCoverageAccessManifest { + boards: { id: string }[]; + packets: { id: string; boards: string[]; personas: string[] }[]; + personas: string[]; +} + +export const releaseV2QaPacketBoardAccess = { + "preflight-01": {}, + "preflight-02": { + "Avery Admin": { "qa-grid-24": "full", "qa-permissions-public": "full" }, + "Rowan Owner": { "qa-grid-24": "full", "qa-permissions-public": "full" }, + "Vivian Viewer": { "qa-grid-24": "view", "qa-permissions-public": "view" }, + "Nolan Outsider": { "qa-grid-24": "none", "qa-permissions-public": "none" }, + }, + "preflight-03": {}, + "board-01": { + "Rowan Owner": { "qa-grid-24": "full" }, + }, + "board-02": { + "Eden Editor": { "qa-scroll-lab": "modify" }, + }, + "board-03": { + "Casey Chaos": { "qa-dense-collisions": "modify" }, + }, + "board-04": { + "Rowan Owner": { "qa-nested-containers": "full" }, + }, + "board-05": { + "Morgan Mobile": { "qa-layout-boundaries": "modify" }, + }, + "board-06": { + "Brooke Minimalist": { "qa-icons-bookmarks": "modify" }, + }, + "board-07": { + "Rowan Owner": { "qa-permissions-public": "full" }, + "Eden Editor": { "qa-permissions-public": "modify" }, + "Vivian Viewer": { "qa-permissions-public": "view" }, + "Nolan Outsider": { "qa-permissions-public": "none" }, + }, + "board-08": { + "Avery Admin": { "qa-download-upload": "full" }, + }, + "board-09": { + "Kira Keyboard": { "qa-grid-24": "modify" }, + }, + "widgets-01": { + "Brooke Minimalist": { "qa-widgets-01": "modify" }, + }, + "widgets-02": { + "Cora Creator": { "qa-widgets-02": "modify" }, + }, + "widgets-03": { + "Cora Creator": { "qa-widgets-03": "modify" }, + }, + "widgets-04": { + "Ingrid Infra": { "qa-widgets-04": "modify" }, + }, + "widgets-05": { + "Ingrid Infra": { "qa-widgets-05": "modify" }, + }, + "widgets-06": { + "Ingrid Infra": { "qa-widgets-06": "modify" }, + }, + "widgets-07": { + "Ingrid Infra": { "qa-widgets-07": "modify" }, + }, + "widgets-08": { + "Ingrid Infra": { "qa-widgets-08": "modify" }, + }, + "widgets-09": { + "Maya Media": { "qa-widgets-09": "modify" }, + }, + "widgets-10": { + "Maya Media": { "qa-widgets-10": "modify" }, + }, + "widgets-11": { + "Ingrid Infra": { "qa-widgets-11": "modify" }, + }, + "widgets-12": { + "Ash Assistant": { "qa-widgets-12": "modify", "qa-custom-widget-assistant": "modify" }, + "Cora Creator": { "qa-widgets-12": "modify", "qa-custom-widget-assistant": "modify" }, + }, + "core-v2-01": { + "Cora Creator": { "qa-custom-widget-assistant": "modify" }, + }, + "core-v2-02": { + "Ash Assistant": { "qa-custom-widget-assistant": "modify" }, + }, + "core-v2-03": {}, + "core-v2-04": { + "Avery Admin": { "qa-permissions-public": "full" }, + "Nolan Outsider": { "qa-permissions-public": "none" }, + }, + "core-v2-05": { + "Ingrid Infra": { "qa-widgets-07": "modify" }, + }, + "core-v2-06": { + "Kira Keyboard": { "qa-grid-24": "modify" }, + }, + "core-v2-07": { + "Vivian Viewer": { "qa-custom-widget-assistant": "view", "qa-permissions-public": "view" }, + }, + "core-v2-08": { + "Casey Chaos": { "qa-custom-widget-assistant": "modify", "qa-widgets-12": "modify" }, + }, + "whole-product-01": { + "Avery Admin": { "qa-grid-24": "full" }, + "Nora Newcomer": { "qa-grid-24": "modify" }, + }, + "whole-product-02": { + "Rowan Owner": { "qa-grid-24": "full", "qa-custom-widget-assistant": "full" }, + }, + "whole-product-03": { + "Eden Editor": { "qa-scroll-lab": "modify", "qa-layout-boundaries": "modify" }, + }, + "whole-product-04": { + "Vivian Viewer": { "qa-permissions-public": "view" }, + "Nolan Outsider": { "qa-permissions-public": "none" }, + }, + "whole-product-05": { + "Morgan Mobile": { "qa-layout-boundaries": "modify", "qa-widgets-01": "modify" }, + }, + "whole-product-06": { + "Maya Media": { "qa-widgets-04": "modify", "qa-widgets-05": "modify" }, + }, + "whole-product-07": { + "Ingrid Infra": { + "qa-widgets-07": "modify", + "qa-widgets-08": "modify", + "qa-widgets-09": "modify", + "qa-widgets-10": "modify", + "qa-widgets-11": "modify", + }, + }, + "whole-product-08": { + "Cora Creator": { "qa-custom-widget-assistant": "modify" }, + "Ash Assistant": { "qa-custom-widget-assistant": "modify" }, + }, + "whole-product-09": { + "Kira Keyboard": { + "qa-grid-24": "full", + "qa-download-upload": "full", + "qa-custom-widget-assistant": "full", + }, + "Casey Chaos": { + "qa-grid-24": "full", + "qa-download-upload": "full", + "qa-custom-widget-assistant": "full", + }, + }, + "performance-01": { + "Avery Admin": { "qa-grid-24": "full", "qa-widgets-12": "full" }, + }, + "performance-02": { + "Rowan Owner": { "qa-dense-collisions": "full", "qa-nested-containers": "full" }, + }, + "performance-03": { + "Ingrid Infra": { "qa-widgets-04": "modify", "qa-widgets-07": "modify", "qa-widgets-12": "modify" }, + "Maya Media": { "qa-widgets-04": "modify", "qa-widgets-07": "modify", "qa-widgets-12": "modify" }, + }, + "performance-04": { + "Casey Chaos": { + "qa-grid-24": "modify", + "qa-custom-widget-assistant": "modify", + "qa-widgets-12": "modify", + }, + }, +} as const satisfies ReleaseV2QaPacketBoardAccess; + +const accessRank: Record = { + none: 0, + view: 1, + modify: 2, + full: 3, +}; + +export const getReleaseV2QaExpectedBoardAccess = ( + packetAccess: ReleaseV2QaPacketBoardAccess, +): Record> => { + const expected: Record> = {}; + for (const personaAccess of Object.values(packetAccess)) { + for (const [persona, boards] of Object.entries(personaAccess)) { + const expectedBoards = (expected[persona] ??= {}); + for (const [board, access] of Object.entries(boards)) { + const current = expectedBoards[board]; + if (!current || accessRank[access] > accessRank[current]) expectedBoards[board] = access; + } + } + } + + return Object.fromEntries( + Object.entries(expected) + .toSorted(([left], [right]) => left.localeCompare(right)) + .map(([persona, boards]) => [ + persona, + Object.fromEntries(Object.entries(boards).toSorted(([left], [right]) => left.localeCompare(right))), + ]), + ); +}; + +const duplicateValues = (values: string[]) => values.filter((value, index) => values.indexOf(value) !== index); + +export const validateReleaseV2QaPacketBoardAccess = ( + manifest: ReleaseV2QaCoverageAccessManifest, + packetAccess: ReleaseV2QaPacketBoardAccess, +): string[] => { + const errors: string[] = []; + const packetIds = manifest.packets.map((packet) => packet.id); + const accessPacketIds = Object.keys(packetAccess); + for (const duplicate of new Set(duplicateValues(packetIds))) errors.push(`duplicate coverage packet ${duplicate}`); + for (const packetId of packetIds) { + if (!(packetId in packetAccess)) errors.push(`${packetId}: board access assignment is missing`); + } + for (const packetId of accessPacketIds) { + if (!packetIds.includes(packetId)) errors.push(`${packetId}: board access assignment is not a coverage packet`); + } + + const knownPersonas = new Set(manifest.personas); + const knownBoards = new Set(manifest.boards.map((board) => board.id)); + for (const packet of manifest.packets) { + const assignedAccess = packetAccess[packet.id] ?? {}; + const expectedPersonas = packet.boards.length === 0 ? [] : packet.personas; + for (const persona of expectedPersonas) { + if (!(persona in assignedAccess)) errors.push(`${packet.id}: ${persona} board access assignment is missing`); + } + for (const persona of Object.keys(assignedAccess)) { + if (!knownPersonas.has(persona)) errors.push(`${packet.id}: unknown persona ${persona}`); + if (!packet.personas.includes(persona)) errors.push(`${packet.id}: unassigned persona ${persona}`); + const assignedBoards = assignedAccess[persona] ?? {}; + for (const board of packet.boards) { + if (!(board in assignedBoards)) errors.push(`${packet.id}: ${persona} access to ${board} is missing`); + } + for (const [board, access] of Object.entries(assignedBoards)) { + if (!knownBoards.has(board)) errors.push(`${packet.id}: unknown board ${board}`); + if (!packet.boards.includes(board)) errors.push(`${packet.id}: unassigned board ${board}`); + if (!releaseV2QaBoardAccessValues.includes(access)) { + errors.push(`${packet.id}: invalid ${persona} access to ${board}: ${String(access)}`); + } + } + } + if (packet.boards.length === 0 && Object.keys(assignedAccess).length > 0) { + errors.push(`${packet.id}: board-less packet must not assign board access`); + } + } + + return errors; +}; diff --git a/scripts/release-v2-qa/permissions.spec.ts b/scripts/release-v2-qa/permissions.spec.ts new file mode 100644 index 000000000..af377f015 --- /dev/null +++ b/scripts/release-v2-qa/permissions.spec.ts @@ -0,0 +1,70 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + getReleaseV2QaExpectedBoardAccess, + releaseV2QaPacketBoardAccess, + validateReleaseV2QaPacketBoardAccess, +} from "./permissions.mts"; + +interface CoverageManifest { + boards: { id: string }[]; + packets: { id: string; boards: string[]; personas: string[] }[]; + personas: string[]; +} + +const coverageManifest = JSON.parse( + await readFile(resolve(import.meta.dirname, "../../qa/release-v2/coverage-manifest.json"), "utf8"), +) as CoverageManifest; + +describe("release-v2 QA packet board access", () => { + it("covers every assigned packet persona and board combination", () => { + expect(validateReleaseV2QaPacketBoardAccess(coverageManifest, releaseV2QaPacketBoardAccess)).toEqual([]); + expect(Object.keys(releaseV2QaPacketBoardAccess)).toHaveLength(45); + }); + + it("grants mutation access to specialized packet owners without unrelated boards", () => { + const access = getReleaseV2QaExpectedBoardAccess(releaseV2QaPacketBoardAccess); + + expect(access["Casey Chaos"]).toEqual({ + "qa-custom-widget-assistant": "full", + "qa-dense-collisions": "modify", + "qa-download-upload": "full", + "qa-grid-24": "full", + "qa-widgets-12": "modify", + }); + expect(access["Morgan Mobile"]).toEqual({ + "qa-layout-boundaries": "modify", + "qa-widgets-01": "modify", + }); + expect(access["Brooke Minimalist"]).toEqual({ + "qa-icons-bookmarks": "modify", + "qa-widgets-01": "modify", + }); + expect(access["Cora Creator"]).toEqual({ + "qa-custom-widget-assistant": "modify", + "qa-widgets-02": "modify", + "qa-widgets-03": "modify", + "qa-widgets-12": "modify", + }); + expect(access["Ash Assistant"]).toEqual({ + "qa-custom-widget-assistant": "modify", + "qa-widgets-12": "modify", + }); + }); + + it("preserves owner, editor, viewer, outsider, and administrator invariants", () => { + const access = getReleaseV2QaExpectedBoardAccess(releaseV2QaPacketBoardAccess); + + expect(Object.values(access["Avery Admin"] ?? {})).not.toContain("none"); + expect(Object.values(access["Avery Admin"] ?? {})).not.toContain("view"); + expect(Object.values(access["Avery Admin"] ?? {})).not.toContain("modify"); + expect(access["Rowan Owner"]?.["qa-permissions-public"]).toBe("full"); + expect(access["Eden Editor"]?.["qa-permissions-public"]).toBe("modify"); + expect(Object.values(access["Vivian Viewer"] ?? {})).toEqual(expect.arrayContaining(["view"])); + expect(new Set(Object.values(access["Vivian Viewer"] ?? {}))).toEqual(new Set(["view"])); + expect(new Set(Object.values(access["Nolan Outsider"] ?? {}))).toEqual(new Set(["none"])); + }); +}); diff --git a/scripts/release-v2-qa/seed.mts b/scripts/release-v2-qa/seed.mts index 5934086cd..9f9ab52d4 100644 --- a/scripts/release-v2-qa/seed.mts +++ b/scripts/release-v2-qa/seed.mts @@ -40,6 +40,12 @@ import { import { boardNameSchema } from "../../packages/validation/src/board.ts"; import { findFixtureGeometryErrors } from "./geometry.mts"; +import { + getReleaseV2QaExpectedBoardAccess, + releaseV2QaPacketBoardAccess, + validateReleaseV2QaPacketBoardAccess, +} from "./permissions.mts"; +import type { ReleaseV2QaCoverageAccessManifest } from "./permissions.mts"; import { assertReleaseV2QaProfileEnvironment, releaseV2QaProfiles, @@ -294,6 +300,7 @@ const disabledCustomDefinitionId = "qa-v2-custom-widget-disabled"; const missingSecretCustomDefinitionId = "qa-v2-custom-widget-missing-secret"; const customDefinitionIds = [customDefinitionId, disabledCustomDefinitionId, missingSecretCustomDefinitionId]; const iconRepositoryId = "qa-v2-icon-repository"; +const expectedBoardAccess = getReleaseV2QaExpectedBoardAccess(releaseV2QaPacketBoardAccess); const parseCli = (): CliOptions => { const options: CliOptions = { reset: false }; @@ -1107,20 +1114,20 @@ const seedBoardsAsync = async (optionsByKind: Map) => { await db.delete(boardUserPermissions).where(inArray(boardUserPermissions.boardId, qaBoardIds)); await db.delete(boardGroupPermissions).where(inArray(boardGroupPermissions.boardId, qaBoardIds)); const permissionRows: Array<{ boardId: string; userId: string; permission: BoardPermission }> = []; - for (const board of sharedBoards) { - permissionRows.push( - { boardId: boardId(board.handle), userId: userId("rowan-owner"), permission: "full" }, - { boardId: boardId(board.handle), userId: userId("eden-editor"), permission: "modify" }, - { boardId: boardId(board.handle), userId: userId("vivian-viewer"), permission: "view" }, - ); - for (const persona of personas) { - if (["avery-admin", "rowan-owner", "eden-editor", "vivian-viewer", "nolan-outsider"].includes(persona.handle)) { + const personaByName = new Map(personas.map((persona) => [persona.name, persona])); + const boardByName = new Map(sharedBoards.map((board) => [boardName(board.handle), board])); + for (const [personaName, boardAccess] of Object.entries(expectedBoardAccess)) { + const persona = personaByName.get(personaName); + if (!persona) throw new Error(`Unknown QA persona in board access assignment: ${personaName}`); + for (const [name, permission] of Object.entries(boardAccess)) { + const board = boardByName.get(name); + if (!board) throw new Error(`Unknown QA board in access assignment: ${name}`); + if (permission === "none" || persona.permissions.includes("admin") || board.ownerHandle === persona.handle) continue; - } - permissionRows.push({ boardId: boardId(board.handle), userId: userId(persona.handle), permission: "view" }); + permissionRows.push({ boardId: boardId(board.handle), userId: userId(persona.handle), permission }); } } - await db.insert(boardUserPermissions).values(permissionRows); + if (permissionRows.length > 0) await db.insert(boardUserPermissions).values(permissionRows); for (const persona of personas) { await db @@ -1195,6 +1202,8 @@ const createManifest = ( "vivian-viewer": "view", "nolan-outsider": "none", }, + packetBoardAccess: releaseV2QaPacketBoardAccess, + expectedBoardAccess: seeded ? expectedBoardAccess : {}, counts: { personas: seeded ? personas.length : 0, boards: boardEntries.length, @@ -1235,6 +1244,11 @@ const createManifest = ( const main = async () => { const options = parseCli(); validateQaBoardDefinitions(); + const coverageManifest = JSON.parse( + await readFile(resolve(repoRoot, "qa/release-v2/coverage-manifest.json"), "utf8"), + ) as ReleaseV2QaCoverageAccessManifest; + const accessErrors = validateReleaseV2QaPacketBoardAccess(coverageManifest, releaseV2QaPacketBoardAccess); + if (accessErrors.length > 0) throw new Error(`Invalid QA packet board access: ${accessErrors.join("; ")}`); const galleryKinds = widgetGalleryGroups.flat(); if ( galleryKinds.length !== widgetKinds.length || diff --git a/scripts/release-v2-qa/verify.mts b/scripts/release-v2-qa/verify.mts index 2c18b890c..896647307 100644 --- a/scripts/release-v2-qa/verify.mts +++ b/scripts/release-v2-qa/verify.mts @@ -6,6 +6,16 @@ import { fileURLToPath } from "node:url"; import type { LayoutRole } from "../../packages/definitions/index.ts"; import { boardNameSchema } from "../../packages/validation/src/board.ts"; import { findFixtureGeometryErrors } from "./geometry.mts"; +import { + getReleaseV2QaExpectedBoardAccess, + releaseV2QaPacketBoardAccess, + validateReleaseV2QaPacketBoardAccess, +} from "./permissions.mts"; +import type { + ReleaseV2QaBoardAccess, + ReleaseV2QaCoverageAccessManifest, + ReleaseV2QaPacketBoardAccess, +} from "./permissions.mts"; import { resolveCheckoutCandidateSha } from "./provenance.mts"; import { collectHumanWidgetStatusErrors, normalizeReportMetadata } from "./report-integrity.mts"; import { assertSafeReportPath, readSafeReportFile, validateResolvedArtifactPath } from "./report-path-integrity.mts"; @@ -137,6 +147,8 @@ interface FixtureManifest { boards: { id: string; handle: string; name: string; label: string; layouts: string[]; flags: { shared: boolean } }[]; layouts: { id: string; boardId: string; columnCount: number }[]; expectedBoardPermissions: Record; + packetBoardAccess: ReleaseV2QaPacketBoardAccess; + expectedBoardAccess: Record>; counts: Record; flags: { demoMode: boolean; @@ -201,6 +213,7 @@ const expectedProfileFlags: Record = { "onboarding-fresh": ["DEMO_MODE=false", "DEMO_READ_ONLY=false", "UNSAFE_ENABLE_MOCK_INTEGRATION=true"], degraded: ["DEMO_MODE=true", "DEMO_READ_ONLY=false", "UNSAFE_ENABLE_MOCK_INTEGRATION=true"], }; +const expectedBoardAccess = getReleaseV2QaExpectedBoardAccess(releaseV2QaPacketBoardAccess); const errors: string[] = []; const parseOptions = (): VerifyOptions => { @@ -382,6 +395,12 @@ const validateFixtureManifest = async ( if (JSON.stringify(fixture.expectedBoardPermissions) !== JSON.stringify(expectedPermissions)) { errors.push(`${label}: expected board permissions are incorrect`); } + if (JSON.stringify(fixture.packetBoardAccess) !== JSON.stringify(releaseV2QaPacketBoardAccess)) { + errors.push(`${label}: packet board access differs from the campaign assignment`); + } + if (JSON.stringify(fixture.expectedBoardAccess) !== JSON.stringify(expectedBoardAccess)) { + errors.push(`${label}: effective assigned board access differs from the campaign assignment`); + } } else { const emptyFixtureCounts = [ "personas", @@ -405,6 +424,9 @@ const validateFixtureManifest = async ( ) { errors.push(`${label}: onboarding-fresh must not contain populated QA fixtures`); } + if (Object.keys(fixture.expectedBoardAccess ?? {}).length > 0) { + errors.push(`${label}: onboarding-fresh must not declare seeded board access`); + } } let fixtureOrigin: URL | undefined; @@ -486,9 +508,12 @@ const validateSqliteState = async (runtime: RuntimeManifest, fixture: FixtureMan } } - const qaBoards = database.prepare(`select id, name from board where id like 'qa-v2-board-%'`).all() as { + const qaBoards = database + .prepare(`select id, name, creator_id as creatorId from board where id like 'qa-v2-board-%'`) + .all() as { id: string; name: string; + creatorId: string | null; }[]; const boardNameById = new Map(fixture.boards.map((board) => [board.id, board.name])); if (duplicateValues(qaBoards.map((board) => board.name)).length > 0) { @@ -593,26 +618,107 @@ const validateSqliteState = async (runtime: RuntimeManifest, fixture: FixtureMan errors.push(`${label}: widget gallery does not contain every canonical widget kind exactly once`); } - for (const board of fixture.boards.filter((candidate) => candidate.flags.shared)) { - for (const [handle, permission] of Object.entries(fixture.expectedBoardPermissions)) { - const permissionCount = count( - `select count(*) as count from boardUserPermission where board_id = ? and user_id = ? and permission = ?`, - board.id, - `qa-v2-user-${handle}`, - permission, - ); + const personaHandleByName = new Map(fixture.personas.map((persona) => [persona.name, persona.handle])); + const boardByName = new Map(fixture.boards.map((board) => [board.name, board])); + const creatorByBoardId = new Map(qaBoards.map((board) => [board.id, board.creatorId])); + const expectedDirectPermissions: string[] = []; + for (const [personaName, assignedBoards] of Object.entries(fixture.expectedBoardAccess)) { + const personaHandle = personaHandleByName.get(personaName); + if (!personaHandle) { + errors.push(`${label}: assigned board access references unknown persona ${personaName}`); + continue; + } + const currentUserId = `qa-v2-user-${personaHandle}`; + for (const [boardName, permission] of Object.entries(assignedBoards)) { + const board = boardByName.get(boardName); + if (!board) { + errors.push(`${label}: assigned board access references unknown board ${boardName}`); + continue; + } + const isAdmin = personaHandle === "avery-admin"; + const isOwner = creatorByBoardId.get(board.id) === currentUserId; if (permission === "none") { const anyPermissionCount = count( `select count(*) as count from boardUserPermission where board_id = ? and user_id = ?`, board.id, - `qa-v2-user-${handle}`, + currentUserId, ); - if (anyPermissionCount !== 0) errors.push(`${label}: ${handle} unexpectedly has access to ${board.handle}`); + if (anyPermissionCount !== 0 || isAdmin || isOwner) { + errors.push(`${label}: ${personaHandle} unexpectedly has assigned access to ${board.handle}`); + } continue; } - if (permissionCount !== 1) errors.push(`${label}: ${handle} lacks ${permission} access to ${board.handle}`); + if (isAdmin || isOwner) { + if (permission !== "full") { + errors.push( + `${label}: ${personaHandle} has implicit full access but ${permission} was declared for ${board.handle}`, + ); + } + continue; + } + expectedDirectPermissions.push(`${board.id}\t${currentUserId}\t${permission}`); } } + + const actualDirectPermissions = database + .prepare( + `select board_id as boardId, user_id as userId, permission + from boardUserPermission + where board_id like 'qa-v2-board-%' and user_id like 'qa-v2-user-%' + order by board_id, user_id, permission`, + ) + .all() + .map((row) => { + const permission = row as { boardId: string; userId: string; permission: string }; + return `${permission.boardId}\t${permission.userId}\t${permission.permission}`; + }); + if (JSON.stringify(actualDirectPermissions) !== JSON.stringify(expectedDirectPermissions.toSorted())) { + errors.push(`${label}: direct QA board permission rows differ from the packet access assignment`); + } + + const averyAdminPermissionCount = count( + `select count(*) as count + from groupMember gm + join groupPermission gp on gp.group_id = gm.group_id + where gm.user_id = ? and gp.permission = 'admin'`, + "qa-v2-user-avery-admin", + ); + if (averyAdminPermissionCount !== 1) errors.push(`${label}: Avery Admin lacks the admin fixture role`); + + const nolanSharedBoardAccess = count( + `select count(*) as count + from boardUserPermission bup + join board b on b.id = bup.board_id + where bup.user_id = ? and b.id not like 'qa-v2-board-home-%'`, + "qa-v2-user-nolan-outsider", + ); + const nolanGlobalBoardAccess = count( + `select count(*) as count + from groupMember gm + join groupPermission gp on gp.group_id = gm.group_id + where gm.user_id = ? and (gp.permission = 'admin' or gp.permission like 'board-%')`, + "qa-v2-user-nolan-outsider", + ); + if (nolanSharedBoardAccess !== 0 || nolanGlobalBoardAccess !== 0) { + errors.push(`${label}: Nolan Outsider has protected fixture board access`); + } + + const vivianEditAccess = count( + `select count(*) as count + from boardUserPermission + where user_id = ? and permission in ('modify', 'full')`, + "qa-v2-user-vivian-viewer", + ); + const vivianGlobalEditAccess = count( + `select count(*) as count + from groupMember gm + join groupPermission gp on gp.group_id = gm.group_id + where gm.user_id = ? and gp.permission in ('admin', 'board-modify-all', 'board-full-all')`, + "qa-v2-user-vivian-viewer", + ); + if (vivianEditAccess !== 0 || vivianGlobalEditAccess !== 0) { + errors.push(`${label}: Vivian Viewer has board edit access`); + } } } finally { database.close(); @@ -795,6 +901,12 @@ const validateRequestedRuntime = async ( const main = async (): Promise => { const options = parseOptions(); const manifest = await readJson(join(qaRoot, "coverage-manifest.json")); + errors.push( + ...validateReleaseV2QaPacketBoardAccess( + manifest as Manifest & ReleaseV2QaCoverageAccessManifest, + releaseV2QaPacketBoardAccess, + ), + ); const ledgerPath = join(qaRoot, "ledger.json"); await assertSafeReportPath(qaRoot, ledgerPath, "release-v2 QA ledger path"); await assertSafeReportPath(qaRoot, join(qaRoot, "master-report.md"), "release-v2 QA master report path");