Skip to content

Commit 5729d31

Browse files
authored
feat(apptest): personal (my) surface contract - manifest metadata + the runner's my flow (#6343)
The <name>.test manifest now carries a 'personal' block for every entity with a personal: true relation (the scoped MyController path, the owner relation, the sensitive field list) - emitted by AppTestIntentGenerator from the intent, unit-tested. The runner gains flows/my.js driving the wire contract of that surface: create through the scoped controller (the owner FK is forced server-side - the payload never sends it), every sensitive field asserted null on the personal wire (the allow-list is the security boundary), the own row present in the personal list, a foreign (ownerless) row 404 through the personal controller and absent from the personal list (exercised when the owner relation is optional), own-row delete through the personal controller. No identity mapping -> the flow SKIPS with a pointer (the personal surface is empty by design). Personal UI parity (resolved labels, chat layout, calendar views on the My shell) is deliberately not asserted yet - it tracks the known personal-template parity gaps; this flow pins the wire contract those pages consume. Verified live against a running instance via hand-injected manifest blocks matching the new emission: a required-owner document entity (sensitive aggregate stripped) and an optional-owner entity (foreign-row 404 branch) both green.
1 parent 60687ad commit 5729d31

5 files changed

Lines changed: 148 additions & 0 deletions

File tree

components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/apptest/AppTestIntentGenerator.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,29 @@ private static Map<String, Object> entityManifest(EntityIntent entity, Map<Strin
168168
if (hasSeed(model, name)) {
169169
out.put("expectSeedData", true);
170170
}
171+
// personal (my) surface: a `personal: true` to-one relation makes the entity a personal
172+
// root - the generator emits an ADDITIONAL scoped <Entity>MyController whose contract the
173+
// runner's my flow drives: reads filtered to the identity-mapped user, the owner FK forced
174+
// server-side, sensitive fields stripped from the wire, foreign rows 404.
175+
for (RelationIntent relation : entity.getRelations()) {
176+
if (!relation.isPersonal()) {
177+
continue;
178+
}
179+
Map<String, Object> personal = new LinkedHashMap<>();
180+
personal.put("api", "/" + sanitizeJavaIdentifier(string(edm.get("perspectiveName"))) + "/" + name + "MyController");
181+
personal.put("owner", IntentNaming.pascalCase(relation.getName()));
182+
List<String> sensitive = new ArrayList<>();
183+
for (FieldIntent field : entity.getFields()) {
184+
if (field.isSensitive()) {
185+
sensitive.add(IntentNaming.pascalCase(field.getName()));
186+
}
187+
}
188+
if (!sensitive.isEmpty()) {
189+
personal.put("sensitive", sensitive);
190+
}
191+
out.put("personal", personal);
192+
break;
193+
}
171194
// exactlyOne checks: exactly one of the named fields may be non-null - a sample record
172195
// filling all of them is rejected with 400, so the runner keeps only the first
173196
List<List<String>> exactlyOne = new ArrayList<>();

components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/apptest/AppTestIntentGeneratorTest.java

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,43 @@ void skipsProjectionAndDetailEntities() {
219219
assertNull(entityOrNull(manifest, "Extra"));
220220
}
221221

222+
@SuppressWarnings("unchecked")
223+
@Test
224+
void emitsThePersonalSurfaceContract() {
225+
String intent = """
226+
name: kf-mod-claims
227+
entities:
228+
- name: Person
229+
identity: email
230+
fields:
231+
- { name: id, type: integer, primaryKey: true, generated: true }
232+
- { name: name, type: string, required: true, length: 200 }
233+
- { name: email, type: string, required: true, unique: true, length: 320 }
234+
- name: Claim
235+
fields:
236+
- { name: id, type: integer, primaryKey: true, generated: true }
237+
- { name: note, type: string, length: 200 }
238+
- { name: rate, type: decimal, sensitive: true }
239+
relations:
240+
- { name: Person, kind: manyToOne, to: Person, required: true, personal: true }
241+
""";
242+
Map<String, Map<String, Object>> edm = new LinkedHashMap<>();
243+
edm.put("Person", edmEntity("Person", "Person", "Persons", "MANAGE_LIST", "People", "hr", "KF_MOD_CLAIMS_PERSON", false));
244+
edm.put("Claim", edmEntity("Claim", "Claim", "Claims", "MANAGE_LIST", "Claims", "hr", "KF_MOD_CLAIMS_CLAIM", false));
245+
246+
Map<String, Object> manifest =
247+
AppTestIntentGenerator.buildManifest("kf-mod-claims", "kf-mod-claims", IntentParser.parse(intent), edm);
248+
249+
Map<String, Object> claim = entity(manifest, "Claim");
250+
Map<String, Object> personal = (Map<String, Object>) claim.get("personal");
251+
assertTrue(personal != null, "a personal: relation must emit the personal surface contract");
252+
assertEquals("/claims/ClaimMyController", personal.get("api"));
253+
assertEquals("Person", personal.get("owner"));
254+
assertEquals(List.of("Rate"), personal.get("sensitive"));
255+
// the identity entity itself has no personal relation - no personal block
256+
assertNull(entity(manifest, "Person").get("personal"));
257+
}
258+
222259
// ---- helpers: a minimal .model-shaped metadata map -------------------------------------------
223260

224261
private static Map<String, Map<String, Object>> edm() {

npm/test/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ Env: `BASE_URL` (default `http://localhost:8080`), `APPTEST_USERNAME`/`APPTEST_P
5353
- **rest** — the same CRUD over the generated Java controllers via `APIRequestContext` (isolates
5454
backend vs UI failures), asserting the manifest's field names bind and delete yields 404.
5555
- **multilingual** — switch the shared language key, reload, a seeded row shows its translated name.
56+
- **my** — the personal (my) surface WIRE contract, when the manifest marks an entity `personal`:
57+
create through the scoped `<Entity>MyController` (owner FK forced server-side), every `sensitive`
58+
field null on the personal wire, the own row in the personal list, foreign rows 404 (when the
59+
owner relation is optional), own-row delete. Skips with a pointer when the test user has no
60+
identity mapping (seed the dev identity row). Personal *UI* parity (resolved labels, chat,
61+
calendar on the My shell) is deliberately not asserted yet — it tracks the personal-template
62+
parity fixes.
5663
- **shell** (opt-in) — the shared application shell's nav item opens the module SPA in its iframe.
5764

5865
Test records carry an `APPTEST-` prefix and are removed in teardown; seed data is never mutated.

npm/test/src/flows/my.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { makeApi } from '../api.js';
2+
import { expect, test } from '../fixtures.js';
3+
import { resolveRelationSamples } from '../form.js';
4+
import { sampleRecord } from '../sample-values.js';
5+
6+
// The personal (my) surface WIRE contract, driven when the manifest marks an entity `personal`:
7+
// the scoped <Entity>MyController filters reads to the identity-mapped user, forces the owner FK
8+
// server-side on create (whatever the client sends is ignored), strips every `sensitive` field
9+
// from its responses (the allow-list is the security boundary - UI hiding alone is cosmetic),
10+
// serves foreign rows as 404, and deletes only own rows.
11+
//
12+
// Prerequisite: the DEV IDENTITY mapping - a row of the owner relation's target whose identity
13+
// field equals the test user (e.g. an Employee with email 'admin'). Without it the personal
14+
// surface is EMPTY by design (never an error), so the flow SKIPS with a pointer instead of
15+
// failing. The personal UI parity assertions (resolved labels, chat layout, calendar views on
16+
// the My shell) are deliberately NOT asserted yet - they track the platform's personal-template
17+
// parity fixes; this flow pins down the wire contract those pages consume.
18+
export function myFlow(manifest, entity, opts = {}) {
19+
const cfg = opts.extend?.entities?.[entity.name] ?? {};
20+
if (!entity.personal || new Set(cfg.skip ?? []).has('my')) return;
21+
const idProperty = manifest.idProperty ?? 'Id';
22+
// the same entity through the scoped controller
23+
const mine = { ...entity, api: entity.personal.api };
24+
25+
async function buildPayload(api) {
26+
const payload = sampleRecord(entity);
27+
for (const sample of await resolveRelationSamples(api, manifest, entity)) {
28+
payload[sample.relation.name] = sample.id;
29+
}
30+
// the owner FK is server-forced on the personal surface; sending a value must be pointless
31+
delete payload[entity.personal.owner];
32+
return payload;
33+
}
34+
35+
test(`${entity.name}: personal (my) surface scopes rows, forces the owner and strips sensitive fields`, async ({ api }) => {
36+
const client = makeApi(api, manifest);
37+
38+
const createdResponse = await api.post(manifest.restBase + mine.api, { data: await buildPayload(api) });
39+
test.skip(
40+
!createdResponse.ok(),
41+
`personal create returned ${createdResponse.status()} - the test user has no identity mapping; ` +
42+
`seed the dev identity row (an owner-target record whose identity field equals the login user)`
43+
);
44+
const created = JSON.parse(await createdResponse.text());
45+
const id = created?.[idProperty];
46+
expect(id, 'personal create carries the generated id').toBeTruthy();
47+
let foreignId;
48+
try {
49+
const own = await client.get(mine, id);
50+
expect(own[entity.personal.owner], 'the owner FK is forced server-side on a personal create').toBeTruthy();
51+
for (const field of entity.personal.sensitive ?? []) {
52+
expect(own[field], `sensitive field ${field} must never reach the personal wire`).toBeFalsy();
53+
}
54+
const rows = await client.list(mine, 1000);
55+
expect(rows.some((row) => row[idProperty] === id), 'the own row appears in the personal list').toBe(true);
56+
57+
// the power surface still serves the full record
58+
const power = await client.get(entity, id);
59+
expect(power[idProperty]).toBe(id);
60+
61+
// a row with no owner is foreign to everyone: the personal controller must 404 it and the
62+
// personal list must not include it (only checkable when the owner relation is optional)
63+
const ownerRelation = (entity.relations ?? []).find((relation) => relation.name === entity.personal.owner);
64+
if (ownerRelation && !ownerRelation.required) {
65+
const foreign = await client.create(entity, await buildPayload(api));
66+
foreignId = foreign?.[idProperty];
67+
const foreignThroughMine = await api.get(manifest.restBase + mine.api + '/' + foreignId);
68+
expect(foreignThroughMine.status(), 'a foreign row must 404 through the personal controller').toBe(404);
69+
const rowsAfter = await client.list(mine, 1000);
70+
expect(rowsAfter.some((row) => row[idProperty] === foreignId), 'a foreign row must not appear in the personal list').toBe(false);
71+
}
72+
} finally {
73+
await client.remove(mine, id); // deleting the OWN row through the personal controller works
74+
if (foreignId) await client.remove(entity, foreignId);
75+
}
76+
const gone = await api.get(manifest.restBase + mine.api + '/' + id);
77+
expect(gone.status()).toBe(404);
78+
});
79+
}

npm/test/src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { test } from './fixtures.js';
33
import { crudFlow } from './flows/crud.js';
44
import { listFlow } from './flows/list.js';
55
import { multilingualFlow } from './flows/multilingual.js';
6+
import { myFlow } from './flows/my.js';
67
import { restFlow } from './flows/rest.js';
78
import { shellFlow } from './flows/shell.js';
89

@@ -16,6 +17,7 @@ export function runTest(manifestRef, opts = {}) {
1617
listFlow(manifest, entity, opts);
1718
crudFlow(manifest, entity, opts);
1819
restFlow(manifest, entity, opts);
20+
myFlow(manifest, entity, opts);
1921
multilingualFlow(manifest, entity, opts);
2022
shellFlow(manifest, entity, opts);
2123
});

0 commit comments

Comments
 (0)