fix(qa): grant packet personas fixture access
This commit is contained in:
1 parent
d3e7cd45a3
commit
b435230739
5 files changed
+473
-23
No files matched your search
@@ -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.
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
export const releaseV2QaBoardAccessValues = ["none", "view", "modify", "full"] as const;
|
||||
|
||||
export type ReleaseV2QaBoardAccess = (typeof releaseV2QaBoardAccessValues)[number];
|
||||
export type ReleaseV2QaPacketBoardAccess = Record<string, Record<string, Record<string, ReleaseV2QaBoardAccess>>>;
|
||||
|
||||
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<ReleaseV2QaBoardAccess, number> = {
|
||||
none: 0,
|
||||
view: 1,
|
||||
modify: 2,
|
||||
full: 3,
|
||||
};
|
||||
|
||||
export const getReleaseV2QaExpectedBoardAccess = (
|
||||
packetAccess: ReleaseV2QaPacketBoardAccess,
|
||||
): Record<string, Record<string, ReleaseV2QaBoardAccess>> => {
|
||||
const expected: Record<string, Record<string, ReleaseV2QaBoardAccess>> = {};
|
||||
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;
|
||||
};
|
||||
@@ -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"]));
|
||||
});
|
||||
});
|
||||
@@ -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<WidgetKind, string>) => {
|
||||
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 ||
|
||||
|
||||
@@ -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<string, string>;
|
||||
packetBoardAccess: ReleaseV2QaPacketBoardAccess;
|
||||
expectedBoardAccess: Record<string, Record<string, ReleaseV2QaBoardAccess>>;
|
||||
counts: Record<string, number>;
|
||||
flags: {
|
||||
demoMode: boolean;
|
||||
@@ -201,6 +213,7 @@ const expectedProfileFlags: Record<string, string[]> = {
|
||||
"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<void> => {
|
||||
const options = parseOptions();
|
||||
const manifest = await readJson<Manifest>(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");
|
||||
|
||||
Reference in new issue
Block a user