diff --git a/src/main/metaschema-constraints/oscal-external-constraints.xml b/src/main/metaschema-constraints/oscal-external-constraints.xml index 650a686e..0e9ee957 100644 --- a/src/main/metaschema-constraints/oscal-external-constraints.xml +++ b/src/main/metaschema-constraints/oscal-external-constraints.xml @@ -118,14 +118,13 @@ - - In-Scope Control Identifiers - An index of control identifiers that are in-scope for selection in the profile import. - - - - - + + In-Scope Control Identifier + Each referenced control identifier must match a control in the catalog resolved by the surrounding profile import. + Control identifier '{ . }' was not found in the imported catalog. + diff --git a/src/test/java/dev/metaschema/oscal/lib/validation/OscalValidationTest.java b/src/test/java/dev/metaschema/oscal/lib/validation/OscalValidationTest.java index ababfb1c..197d5d2c 100644 --- a/src/test/java/dev/metaschema/oscal/lib/validation/OscalValidationTest.java +++ b/src/test/java/dev/metaschema/oscal/lib/validation/OscalValidationTest.java @@ -112,6 +112,42 @@ void testValidateProfileWithMissingControls() assertFalse(validationResult.isPassing()); } + @Test + void testValidateProfileWithMultipleImports() + throws MetaschemaException, IOException, URISyntaxException, ConstraintValidationException { + // Regression test for oscal-cli#250: a profile that imports two catalogs + // should validate every with-id against the catalog referenced by the + // same import, not against an index built from the first import only. + assertMultiImportProfileValidates("src/test/resources/content/issue-250/test-profile.json"); + } + + @Test + void testValidateProfileWithMultipleImportsReversed() + throws MetaschemaException, IOException, URISyntaxException, ConstraintValidationException { + // Companion regression for oscal-cli#250: the original bug report noted + // that reversing the import order moved the errors. This test validates + // the same two catalogs with their import order swapped to guard against + // any future reintroduction of import-order sensitivity. + assertMultiImportProfileValidates("src/test/resources/content/issue-250/test-profile-reversed.json"); + } + + private void assertMultiImportProfileValidates(@NonNull String profilePath) + throws MetaschemaException, IOException, ConstraintValidationException { + IBindingContext bindingContext = OscalBindingContext.newInstance(); + + IValidationResult validationResult = bindingContext.validateWithConstraints( + Paths.get(profilePath).toUri(), + null); + + if (validationResult.isPassing()) { + LOGGER.info("The resource is valid."); + } else { + LOGGER.info("Validation identified the following issues:"); + new LoggingValidationHandler().handleResults(validationResult); + } + assertTrue(validationResult.isPassing()); + } + private static final class ValidationProvider implements ISchemaValidationProvider { @NonNull private final IModule module; diff --git a/src/test/resources/content/issue-250/test-catalog-ac-1.json b/src/test/resources/content/issue-250/test-catalog-ac-1.json new file mode 100644 index 00000000..a7434c29 --- /dev/null +++ b/src/test/resources/content/issue-250/test-catalog-ac-1.json @@ -0,0 +1,23 @@ +{ + "catalog": { + "uuid": "5f6606a2-5d1f-4df2-8d5e-4c0e7b57bea6", + "metadata": { + "title": "Minimal AC Family Catalog", + "last-modified": "2026-02-25T02:49:04Z", + "version": "1.0.0", + "oscal-version": "1.2.1" + }, + "controls": [ + { + "id": "ac-1", + "title": "Access Control Policy and Procedures", + "parts": [ + { + "name": "statement", + "prose": "Minimal AC-1 statement used only to validate profile imports." + } + ] + } + ] + } +} diff --git a/src/test/resources/content/issue-250/test-catalog.json b/src/test/resources/content/issue-250/test-catalog.json new file mode 100644 index 00000000..fdfb6ba0 --- /dev/null +++ b/src/test/resources/content/issue-250/test-catalog.json @@ -0,0 +1,23 @@ +{ + "catalog": { + "uuid": "a7167eec-1ab2-4ec3-9921-5680981be857", + "metadata": { + "title": "Test Catalog", + "last-modified": "2026-02-25T02:49:04Z", + "version": "1.0.0", + "oscal-version": "1.2.1" + }, + "controls": [ + { + "id": "test-01", + "title": "TEST CONTROL", + "parts": [ + { + "name": "statement", + "prose": "Test content" + } + ] + } + ] + } +} diff --git a/src/test/resources/content/issue-250/test-profile-reversed.json b/src/test/resources/content/issue-250/test-profile-reversed.json new file mode 100644 index 00000000..48643802 --- /dev/null +++ b/src/test/resources/content/issue-250/test-profile-reversed.json @@ -0,0 +1,36 @@ +{ + "profile": { + "uuid": "13a94063-d30b-4fc8-bf3b-b75bf9da0b72", + "metadata": { + "title": "Test Profile (Reversed Imports)", + "last-modified": "2026-03-23T00:00:00Z", + "version": "1.0.0", + "oscal-version": "1.2.1" + }, + "imports": [ + { + "href": "test-catalog.json", + "include-controls": [ + { + "with-ids": [ + "test-01" + ] + } + ] + }, + { + "href": "test-catalog-ac-1.json", + "include-controls": [ + { + "with-ids": [ + "ac-1" + ] + } + ] + } + ], + "merge": { + "as-is": true + } + } +} diff --git a/src/test/resources/content/issue-250/test-profile.json b/src/test/resources/content/issue-250/test-profile.json new file mode 100644 index 00000000..7b3fe445 --- /dev/null +++ b/src/test/resources/content/issue-250/test-profile.json @@ -0,0 +1,36 @@ +{ + "profile": { + "uuid": "e285521b-890c-4b8e-b0c3-e72fac21a51e", + "metadata": { + "title": "Test Profile", + "last-modified": "2026-03-23T00:00:00Z", + "version": "1.0.0", + "oscal-version": "1.2.1" + }, + "imports": [ + { + "href": "test-catalog-ac-1.json", + "include-controls": [ + { + "with-ids": [ + "ac-1" + ] + } + ] + }, + { + "href": "test-catalog.json", + "include-controls": [ + { + "with-ids": [ + "test-01" + ] + } + ] + } + ], + "merge": { + "as-is": true + } + } +}