Skip to content

Commit e20baf1

Browse files
committed
Add project lifecycle contract tests for GameFoundryStudio - PR_26152_067-project-contract-tests
1 parent 8f560a9 commit e20baf1

4 files changed

Lines changed: 822 additions & 0 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Project Contract Tests Validation
2+
3+
PR: PR_26152_067-project-contract-tests
4+
5+
Date: 2026-06-02
6+
7+
## Scope
8+
9+
Added a reusable Project contract that builds on the approved identity/permissions contract.
10+
11+
No database implementation, authentication implementation, UI/page changes, CSS changes, HTML changes, or dependency changes were made.
12+
13+
## Files Validated
14+
15+
- `src/shared/contracts/projectContract.js`
16+
- `tests/fixtures/projects/project-scenarios.json`
17+
- `tests/shared/ProjectContract.test.mjs`
18+
- `src/shared/contracts/identityPermissionsContract.js`
19+
- `tests/shared/IdentityPermissionsContract.test.mjs`
20+
21+
## Validation Lanes
22+
23+
- Lanes executed: contract - project lifecycle state, role, visibility, relationship, and access-rule validation.
24+
- Compatibility executed: identity permissions contract - confirms the project contract continues to use the approved identity permission vocabulary.
25+
- Lanes skipped: runtime, integration, engine, samples, recovery/UAT - this PR does not change runtime behavior, handoff contracts, engine code, samples, or UAT surfaces.
26+
- Samples decision: SKIP because this PR does not touch samples or sample JSON.
27+
- Playwright impacted: No. This PR adds shared contract data, fixtures, and targeted node tests only.
28+
29+
## Commands
30+
31+
```text
32+
node ./scripts/run-node-test-files.mjs tests/shared/ProjectContract.test.mjs tests/shared/IdentityPermissionsContract.test.mjs
33+
```
34+
35+
Result:
36+
37+
```text
38+
PASS tests/shared/ProjectContract.test.mjs
39+
PASS tests/shared/IdentityPermissionsContract.test.mjs
40+
41+
2/2 targeted node test file(s) passed.
42+
```
43+
44+
```text
45+
git diff --check -- src/shared/contracts/projectContract.js tests/shared/ProjectContract.test.mjs tests/fixtures/projects/project-scenarios.json
46+
```
47+
48+
Result:
49+
50+
```text
51+
PASS - no whitespace errors reported.
52+
```
53+
54+
## Contract Coverage
55+
56+
- Project requires owner: PASS.
57+
- Project requires visibility: PASS.
58+
- Project state must be valid: PASS.
59+
- Project role must be valid: PASS.
60+
- Private project is visible only to owner/granted users: PASS.
61+
- Collaborator can edit granted project scope: PASS.
62+
- Viewer can view but not edit: PASS.
63+
- Published/public project visibility is distinct from marketplace state: PASS.
64+
- Archived/retired projects cannot be edited unless explicitly allowed by policy: PASS.
65+
66+
## Scope Guard
67+
68+
- No database files changed.
69+
- No authentication files changed.
70+
- No UI/page files changed.
71+
- No CSS files changed.
72+
- No HTML files changed.
73+
- No dependencies were added.
74+
- No samples tests were run.
75+
- No repo-wide tests were run.
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
/*
2+
Toolbox Aid
3+
David Quesenberry
4+
06/02/2026
5+
projectContract.js
6+
*/
7+
import {
8+
IDENTITY_PERMISSION_SCOPES,
9+
IDENTITY_PERMISSIONS,
10+
IDENTITY_ROLES,
11+
IDENTITY_VISIBILITY_STATES,
12+
canActorPerformPermission,
13+
isIdentityPermission,
14+
} from "./identityPermissionsContract.js";
15+
16+
export const PROJECT_CONTRACT_ID = "gamefoundrystudio.project.lifecycle";
17+
export const PROJECT_CONTRACT_VERSION = "1.0.0";
18+
19+
export const PROJECT_STATES = Object.freeze({
20+
DRAFT: "draft",
21+
ACTIVE: "active",
22+
ARCHIVED: "archived",
23+
PUBLISHED: "published",
24+
MARKETPLACE: "marketplace",
25+
RETIRED: "retired",
26+
});
27+
28+
export const PROJECT_STATE_LIST = Object.freeze([
29+
PROJECT_STATES.DRAFT,
30+
PROJECT_STATES.ACTIVE,
31+
PROJECT_STATES.ARCHIVED,
32+
PROJECT_STATES.PUBLISHED,
33+
PROJECT_STATES.MARKETPLACE,
34+
PROJECT_STATES.RETIRED,
35+
]);
36+
37+
export const PROJECT_ROLES = Object.freeze({
38+
OWNER: "owner",
39+
COLLABORATOR: "collaborator",
40+
VIEWER: "viewer",
41+
});
42+
43+
export const PROJECT_ROLE_LIST = Object.freeze([
44+
PROJECT_ROLES.OWNER,
45+
PROJECT_ROLES.COLLABORATOR,
46+
PROJECT_ROLES.VIEWER,
47+
]);
48+
49+
export const PROJECT_VISIBILITY_STATES = Object.freeze({
50+
PRIVATE: "private",
51+
PROJECT: "project",
52+
UNLISTED: "unlisted",
53+
PUBLIC: "public",
54+
});
55+
56+
export const PROJECT_VISIBILITY_LIST = Object.freeze([
57+
PROJECT_VISIBILITY_STATES.PRIVATE,
58+
PROJECT_VISIBILITY_STATES.PROJECT,
59+
PROJECT_VISIBILITY_STATES.UNLISTED,
60+
PROJECT_VISIBILITY_STATES.PUBLIC,
61+
]);
62+
63+
export const PROJECT_RELATIONSHIPS = Object.freeze({
64+
TOOL_STATES: "tool-states",
65+
ASSETS: "assets",
66+
PALETTES: "palettes",
67+
GAME_MANIFEST: "game-manifest",
68+
RELEASES: "releases",
69+
MARKETPLACE_ITEMS: "marketplace-items",
70+
});
71+
72+
export const PROJECT_RELATIONSHIP_LIST = Object.freeze([
73+
PROJECT_RELATIONSHIPS.TOOL_STATES,
74+
PROJECT_RELATIONSHIPS.ASSETS,
75+
PROJECT_RELATIONSHIPS.PALETTES,
76+
PROJECT_RELATIONSHIPS.GAME_MANIFEST,
77+
PROJECT_RELATIONSHIPS.RELEASES,
78+
PROJECT_RELATIONSHIPS.MARKETPLACE_ITEMS,
79+
]);
80+
81+
export const PROJECT_ROLE_PERMISSION_GRANTS = Object.freeze({
82+
[PROJECT_ROLES.OWNER]: Object.freeze([
83+
IDENTITY_PERMISSIONS.VIEW,
84+
IDENTITY_PERMISSIONS.CREATE,
85+
IDENTITY_PERMISSIONS.EDIT,
86+
IDENTITY_PERMISSIONS.DELETE,
87+
IDENTITY_PERMISSIONS.SHARE,
88+
IDENTITY_PERMISSIONS.PUBLISH,
89+
]),
90+
[PROJECT_ROLES.COLLABORATOR]: Object.freeze([
91+
IDENTITY_PERMISSIONS.VIEW,
92+
IDENTITY_PERMISSIONS.EDIT,
93+
]),
94+
[PROJECT_ROLES.VIEWER]: Object.freeze([
95+
IDENTITY_PERMISSIONS.VIEW,
96+
]),
97+
});
98+
99+
export const PROJECT_INACTIVE_STATES = Object.freeze([
100+
PROJECT_STATES.ARCHIVED,
101+
PROJECT_STATES.RETIRED,
102+
]);
103+
104+
export const PROJECT_CONTRACT_ERRORS = Object.freeze({
105+
OWNER_REQUIRED: "PROJECT_OWNER_REQUIRED",
106+
VISIBILITY_REQUIRED: "PROJECT_VISIBILITY_REQUIRED",
107+
VISIBILITY_INVALID: "PROJECT_VISIBILITY_INVALID",
108+
STATE_REQUIRED: "PROJECT_STATE_REQUIRED",
109+
STATE_INVALID: "PROJECT_STATE_INVALID",
110+
ROLE_INVALID: "PROJECT_ROLE_INVALID",
111+
RELATIONSHIP_INVALID: "PROJECT_RELATIONSHIP_INVALID",
112+
});
113+
114+
export function isProjectState(value) {
115+
return PROJECT_STATE_LIST.includes(value);
116+
}
117+
118+
export function isProjectRole(value) {
119+
return PROJECT_ROLE_LIST.includes(value);
120+
}
121+
122+
export function isProjectVisibility(value) {
123+
return PROJECT_VISIBILITY_LIST.includes(value);
124+
}
125+
126+
export function isProjectRelationship(value) {
127+
return PROJECT_RELATIONSHIP_LIST.includes(value);
128+
}
129+
130+
export function validateProjectContract(project) {
131+
const errors = [];
132+
133+
if (!hasNonEmptyString(project?.ownerId)) {
134+
errors.push(createContractError(
135+
PROJECT_CONTRACT_ERRORS.OWNER_REQUIRED,
136+
"Project records require ownerId.",
137+
"ownerId"
138+
));
139+
}
140+
141+
if (!hasNonEmptyString(project?.visibility)) {
142+
errors.push(createContractError(
143+
PROJECT_CONTRACT_ERRORS.VISIBILITY_REQUIRED,
144+
"Project records require explicit visibility.",
145+
"visibility"
146+
));
147+
} else if (!isProjectVisibility(project.visibility)) {
148+
errors.push(createContractError(
149+
PROJECT_CONTRACT_ERRORS.VISIBILITY_INVALID,
150+
"Project visibility must be an allowed project visibility state.",
151+
"visibility"
152+
));
153+
}
154+
155+
if (!hasNonEmptyString(project?.state)) {
156+
errors.push(createContractError(
157+
PROJECT_CONTRACT_ERRORS.STATE_REQUIRED,
158+
"Project records require explicit state.",
159+
"state"
160+
));
161+
} else if (!isProjectState(project.state)) {
162+
errors.push(createContractError(
163+
PROJECT_CONTRACT_ERRORS.STATE_INVALID,
164+
"Project state must be an allowed project lifecycle state.",
165+
"state"
166+
));
167+
}
168+
169+
if (Array.isArray(project?.members)) {
170+
project.members.forEach((member, index) => {
171+
if (!isProjectRole(member?.role)) {
172+
errors.push(createContractError(
173+
PROJECT_CONTRACT_ERRORS.ROLE_INVALID,
174+
"Project member role must be an allowed project role.",
175+
`members[${index}].role`
176+
));
177+
}
178+
});
179+
}
180+
181+
if (Array.isArray(project?.relationships)) {
182+
project.relationships.forEach((relationship, index) => {
183+
if (!isProjectRelationship(relationship)) {
184+
errors.push(createContractError(
185+
PROJECT_CONTRACT_ERRORS.RELATIONSHIP_INVALID,
186+
"Project relationship must be an allowed project relationship.",
187+
`relationships[${index}]`
188+
));
189+
}
190+
});
191+
}
192+
193+
return Object.freeze({
194+
valid: errors.length === 0,
195+
errors: Object.freeze(errors),
196+
});
197+
}
198+
199+
export function isMarketplaceProject(project) {
200+
return project?.state === PROJECT_STATES.MARKETPLACE;
201+
}
202+
203+
export function canEditProjectState(project, policy = {}) {
204+
if (project?.state === PROJECT_STATES.ARCHIVED) {
205+
return policy.allowArchivedProjectEdit === true;
206+
}
207+
208+
if (project?.state === PROJECT_STATES.RETIRED) {
209+
return policy.allowRetiredProjectEdit === true;
210+
}
211+
212+
return isProjectState(project?.state);
213+
}
214+
215+
export function canActorAccessProject({
216+
actorId,
217+
projectRole,
218+
permission,
219+
project,
220+
grantedProjectIds = [],
221+
grantedScopes = [],
222+
policy = {},
223+
} = {}) {
224+
if (!isProjectRole(projectRole) || !isIdentityPermission(permission) || !isProjectVisibleToActor(actorId, project, grantedProjectIds)) {
225+
return false;
226+
}
227+
228+
if (permission === IDENTITY_PERMISSIONS.EDIT && !canEditProjectState(project, policy)) {
229+
return false;
230+
}
231+
232+
if (projectRole === PROJECT_ROLES.OWNER) {
233+
return actorId === project?.ownerId
234+
&& PROJECT_ROLE_PERMISSION_GRANTS[PROJECT_ROLES.OWNER].includes(permission);
235+
}
236+
237+
if (projectRole === PROJECT_ROLES.VIEWER) {
238+
return permission === IDENTITY_PERMISSIONS.VIEW;
239+
}
240+
241+
if (projectRole === PROJECT_ROLES.COLLABORATOR && permission === IDENTITY_PERMISSIONS.EDIT) {
242+
return canActorPerformPermission({
243+
actorId,
244+
role: IDENTITY_ROLES.CONTRIBUTOR,
245+
permission,
246+
scope: IDENTITY_PERMISSION_SCOPES.PROJECT,
247+
grantedScopes,
248+
object: {
249+
ownerId: project?.ownerId,
250+
visibility: mapProjectVisibilityToIdentityVisibility(project?.visibility),
251+
},
252+
});
253+
}
254+
255+
return PROJECT_ROLE_PERMISSION_GRANTS[projectRole].includes(permission);
256+
}
257+
258+
export function isProjectVisibleToActor(actorId, project, grantedProjectIds = []) {
259+
if (!hasNonEmptyString(actorId) || !project) {
260+
return false;
261+
}
262+
263+
if (actorId === project.ownerId) {
264+
return true;
265+
}
266+
267+
if (project.visibility === PROJECT_VISIBILITY_STATES.PUBLIC || project.visibility === PROJECT_VISIBILITY_STATES.UNLISTED) {
268+
return true;
269+
}
270+
271+
return Array.isArray(grantedProjectIds) && grantedProjectIds.includes(project.id);
272+
}
273+
274+
function mapProjectVisibilityToIdentityVisibility(visibility) {
275+
if (visibility === PROJECT_VISIBILITY_STATES.PROJECT) {
276+
return IDENTITY_VISIBILITY_STATES.PROJECT;
277+
}
278+
279+
if (visibility === PROJECT_VISIBILITY_STATES.UNLISTED) {
280+
return IDENTITY_VISIBILITY_STATES.UNLISTED;
281+
}
282+
283+
if (visibility === PROJECT_VISIBILITY_STATES.PUBLIC) {
284+
return IDENTITY_VISIBILITY_STATES.PUBLIC;
285+
}
286+
287+
return IDENTITY_VISIBILITY_STATES.PRIVATE;
288+
}
289+
290+
function hasNonEmptyString(value) {
291+
return typeof value === "string" && value.trim().length > 0;
292+
}
293+
294+
function createContractError(code, message, path) {
295+
return Object.freeze({ code, message, path });
296+
}

0 commit comments

Comments
 (0)