Skip to content

Commit e9f826b

Browse files
committed
fix(V2): align join-table ownership detection with resolution
hasOwnershipFields did not honor the join-table cross-reference FK exclusions that collectOwnerOrgUids applies via getExcludedFieldsForTable. For a join-table UPDATE payload carrying only an excluded cross-ref FK (e.g. cad_trust_stakeholder_id on stakeholder_projects), detection reported an owner while resolution skipped it, producing a spurious "cannot determine the owner" rejection of a legitimate edit. Mirror the same exclusions in hasOwnershipFields so detection and resolution agree. Add staging regression tests for the allowed non-owning-reference edit and the rejected owning-FK retarget.
1 parent fd141bb commit e9f826b

2 files changed

Lines changed: 125 additions & 1 deletion

File tree

src/models/v2/staging-v2.model.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,11 +211,15 @@ class StagingV2 extends Model {
211211
static hasOwnershipFields(record, table) {
212212
const primaryKeyField = getV2PrimaryKeyField(table);
213213
const primaryKeyApiField = primaryKeyField?.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
214+
// Mirror the FK exclusions collectOwnerOrgUids applies so that ownership
215+
// detection and resolution agree; otherwise a join-table payload carrying
216+
// only an excluded cross-ref FK reports an owner that resolution skips.
217+
const excludedFields = StagingV2.getExcludedFieldsForTable(table);
214218

215219
return Boolean(
216220
StagingV2.getRecordField(record, 'orgUid') ||
217221
StagingV2.getOwnershipParentModels()
218-
.filter(([fieldName]) => fieldName !== primaryKeyApiField)
222+
.filter(([fieldName]) => fieldName !== primaryKeyApiField && !excludedFields.has(fieldName))
219223
.some(([fieldName]) => (
220224
StagingV2.getRecordField(record, fieldName) != null
221225
)),

tests/v2/integration/staging-v2.spec.js

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
IssuanceV2,
1515
UnitV2,
1616
LocationV2,
17+
StakeholderV2,
18+
StakeholderProjectV2,
1719
} from '../../../src/models/v2/index.js';
1820
import { Staging } from '../../../src/models/index.js';
1921
import TaskManager from '../../../src/tasks/index.js';
@@ -1002,6 +1004,124 @@ describe('V2 Staging Integration Tests', function () {
10021004
expect(parsedData[0].cad_trust_project_id).to.equal(projectId);
10031005
});
10041006

1007+
it('should allow editRecord for a join table update that changes only the non-owning reference', async function () {
1008+
const homeOrgId = await getV2HomeOrgId();
1009+
const stakeholder = await StakeholderV2.create({
1010+
cadTrustStakeholderId: uuidv4(),
1011+
stakeholderName: 'Home Stakeholder',
1012+
stakeholderType: 'Owner',
1013+
orgUid: homeOrgId,
1014+
});
1015+
// Replacement stakeholder is owned by another org on purpose: ownership for
1016+
// this join table is anchored to the project, so swapping the stakeholder
1017+
// cross-reference must be allowed even when it points at a foreign stakeholder.
1018+
const replacementStakeholder = await StakeholderV2.create({
1019+
cadTrustStakeholderId: uuidv4(),
1020+
stakeholderName: 'Replacement Foreign Stakeholder',
1021+
stakeholderType: 'Owner',
1022+
orgUid: 'other-org-uid-12345',
1023+
});
1024+
const joinRecord = await StakeholderProjectV2.create({
1025+
cadTrustStakeholderProjectId: uuidv4(),
1026+
cadTrustStakeholderId: stakeholder.cadTrustStakeholderId,
1027+
cadTrustProjectId: projectId,
1028+
});
1029+
1030+
const stagingUuid = uuidv4();
1031+
await StagingV2.create({
1032+
uuid: stagingUuid,
1033+
table: 'stakeholder_projects',
1034+
action: 'UPDATE',
1035+
data: JSON.stringify([{
1036+
cad_trust_stakeholder_project_id: joinRecord.cadTrustStakeholderProjectId,
1037+
cad_trust_project_id: projectId,
1038+
cad_trust_stakeholder_id: stakeholder.cadTrustStakeholderId,
1039+
}]),
1040+
committed: false,
1041+
failed_commit: false,
1042+
is_transfer: false,
1043+
});
1044+
1045+
// Ownership for the join table is resolved through the project (owning side),
1046+
// so editing only the stakeholder cross-reference must not be rejected.
1047+
await supertest(app)
1048+
.put('/v2/staging')
1049+
.send({
1050+
uuid: stagingUuid,
1051+
data: [{
1052+
cad_trust_stakeholder_project_id: joinRecord.cadTrustStakeholderProjectId,
1053+
cad_trust_stakeholder_id: replacementStakeholder.cadTrustStakeholderId,
1054+
}],
1055+
})
1056+
.expect(200);
1057+
1058+
const updatedRecord = await StagingV2.findOne({
1059+
where: { uuid: stagingUuid },
1060+
});
1061+
const parsedData = JSON.parse(updatedRecord.data);
1062+
expect(parsedData[0].cad_trust_stakeholder_id).to.equal(replacementStakeholder.cadTrustStakeholderId);
1063+
});
1064+
1065+
it('should reject editRecord that retargets a join table to another organization owning record', async function () {
1066+
const homeOrgId = await getV2HomeOrgId();
1067+
const stakeholder = await StakeholderV2.create({
1068+
cadTrustStakeholderId: uuidv4(),
1069+
stakeholderName: 'Home Stakeholder For Retarget',
1070+
stakeholderType: 'Owner',
1071+
orgUid: homeOrgId,
1072+
});
1073+
const foreignProject = await ProjectV2.create({
1074+
cadTrustProjectId: uuidv4(),
1075+
orgUid: 'other-org-uid-12345',
1076+
projectRegistryName: 'Other Registry',
1077+
projectId: 'OTHER-JOIN-RETARGET-001',
1078+
projectName: 'Other Org Project',
1079+
});
1080+
const joinRecord = await StakeholderProjectV2.create({
1081+
cadTrustStakeholderProjectId: uuidv4(),
1082+
cadTrustStakeholderId: stakeholder.cadTrustStakeholderId,
1083+
cadTrustProjectId: projectId,
1084+
});
1085+
1086+
const stagingUuid = uuidv4();
1087+
await StagingV2.create({
1088+
uuid: stagingUuid,
1089+
table: 'stakeholder_projects',
1090+
action: 'UPDATE',
1091+
data: JSON.stringify([{
1092+
cad_trust_stakeholder_project_id: joinRecord.cadTrustStakeholderProjectId,
1093+
cad_trust_project_id: projectId,
1094+
cad_trust_stakeholder_id: stakeholder.cadTrustStakeholderId,
1095+
}]),
1096+
committed: false,
1097+
failed_commit: false,
1098+
is_transfer: false,
1099+
});
1100+
1101+
// Retargeting the owning project FK to another org's project must be rejected,
1102+
// since ownership for this join table is anchored to the project.
1103+
const response = await supertest(app)
1104+
.put('/v2/staging')
1105+
.send({
1106+
uuid: stagingUuid,
1107+
data: [{
1108+
cad_trust_stakeholder_project_id: joinRecord.cadTrustStakeholderProjectId,
1109+
cad_trust_project_id: foreignProject.cadTrustProjectId,
1110+
cad_trust_stakeholder_id: stakeholder.cadTrustStakeholderId,
1111+
}],
1112+
})
1113+
.expect(400);
1114+
1115+
expect(response.body.success).to.be.false;
1116+
expect(response.body.error).to.include('Restricted data');
1117+
1118+
const unchangedRecord = await StagingV2.findOne({
1119+
where: { uuid: stagingUuid },
1120+
});
1121+
const parsedData = JSON.parse(unchangedRecord.data);
1122+
expect(parsedData[0].cad_trust_project_id).to.equal(projectId);
1123+
});
1124+
10051125
it('should reject editRecord when data is not an array (DoS prevention)', async function () {
10061126
const programData = await generateV2ProgramData();
10071127
const stagingUuid = uuidv4();

0 commit comments

Comments
 (0)