Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,13 @@
['name' => 'portfolioReport#index', 'url' => '/api/portfolio-report', 'verb' => 'GET'],

// SPA catch-all — serves the Vue app for any frontend route (history mode routing)
['name' => 'dashboard#page', 'url' => '/{path}', 'verb' => 'GET', 'requirements' => ['path' => '.+'], 'defaults' => ['path' => '']],
// `postfix` keeps this SPA catch-all from colliding with the bare-root
// `dashboard#page` route above: both entries target the same
// controller#method, so without a postfix they generate the same
// internal route name and the later one silently displaces the first —
// which 404'd the app's own entry point (`/apps/softwarecatalog/`) for
// every user, because this route's `path` requirement ('.+') can never
// match an empty path.
['name' => 'dashboard#page', 'url' => '/{path}', 'verb' => 'GET', 'requirements' => ['path' => '.+'], 'defaults' => ['path' => ''], 'postfix' => 'spa'],
],
];
74 changes: 58 additions & 16 deletions lib/Controller/SbomController.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
use OCA\SoftwareCatalog\Exception\UnsupportedSbomFormatException;
use OCA\SoftwareCatalog\Service\SbomImportService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
Expand Down Expand Up @@ -94,6 +95,21 @@ public function __construct(
/**
* Import an SBOM for a `moduleVersie`. Multipart upload only.
*
* `DoesNotExistException` is caught here (not just the explicit
* `RuntimeException` "moduleVersie not found" guard already inside
* `SbomImportService::importForModuleVersie()`): OpenRegister's real
* `ObjectService::find()` does not reliably return `null` for a missing
* object the way the unit-test stub does — for a well-formed but
* non-existent uuid its cross-table fallback lookup re-throws
* `OCP\AppFramework\Db\DoesNotExistException` instead. That exception can
* originate from `authorizeManage()` (via
* `SbomImportService::resolveParentModuleUuid()`, for a non-admin/
* manage-group caller) or from `importForModuleVersie()` itself, so the
* whole method body is covered by one outer try/catch rather than
* threading a guard through each call site individually. Uncaught, this
* escaped as a 500 (confirmed live for `GET .../sbom` on
* `getSbomImportStatus()` — the same defect class applies here).
*
* @param string $moduleVersieUuid The target moduleVersie's uuid.
*
* @return JSONResponse The import result summary, or a 400/401/403/404/422/500.
Expand All @@ -106,23 +122,28 @@ public function __construct(
#[NoCSRFRequired]
public function importSbom(string $moduleVersieUuid): JSONResponse
{
$guard = $this->authorizeManage(moduleVersieUuid: $moduleVersieUuid);
if ($guard instanceof JSONResponse) {
return $guard;
}
try {
$guard = $this->authorizeManage(moduleVersieUuid: $moduleVersieUuid);
if ($guard instanceof JSONResponse) {
return $guard;
}

$validated = $this->validateUpload(moduleVersieUuid: $moduleVersieUuid);
if ($validated instanceof JSONResponse) {
return $validated;
}
$validated = $this->validateUpload(moduleVersieUuid: $moduleVersieUuid);
if ($validated instanceof JSONResponse) {
return $validated;
}

try {
$result = $this->importService->importForModuleVersie(
moduleVersieUuid: $moduleVersieUuid,
rawJson: $validated['contents'],
format: $validated['format'],
fileName: $validated['fileName']
);
} catch (DoesNotExistException $e) {
return new JSONResponse(
data: ['message' => 'moduleVersie not found: '.$moduleVersieUuid, 'error' => 'MODULE_VERSION_NOT_FOUND'],
statusCode: Http::STATUS_NOT_FOUND
);
} catch (UnsupportedSbomFormatException $e) {
return new JSONResponse(
data: ['message' => $e->getMessage(), 'error' => 'UNSUPPORTED_SBOM_FORMAT'],
Expand Down Expand Up @@ -228,12 +249,26 @@ private function validateUpload(string $moduleVersieUuid): array|JSONResponse
* Read SBOM import status/provenance for a `moduleVersie`, optionally
* including a `progress-tracking` snapshot when `operationId` is given.
*
* Confirmed live (500 on a non-existent `moduleVersieUuid`):
* `SbomImportService::getStatus()` calls OpenRegister's real
* `ObjectService::find()`, which — despite its `?ObjectEntity` return
* type suggesting `null` on a miss (and despite the local
* `if ($moduleVersie !== null)` guard already inside `getStatus()`) —
* can re-throw `OCP\AppFramework\Db\DoesNotExistException` from its
* cross-table fallback lookup for a well-formed but unresolvable uuid,
* rather than returning `null`. Uncaught, that propagated straight
* through this controller method as a 500. Caught here and translated
* to a proper 404 — the endpoint is a plain "read status for this uuid"
* lookup, so a missing `moduleVersie` is an ordinary not-found, not a
* server error.
*
* @param string $moduleVersieUuid The target moduleVersie's uuid.
*
* @return JSONResponse `{sbomLastImportedAt, sbomFormat, sbomFileName, progress}`.
* @return JSONResponse `{sbomLastImportedAt, sbomFormat, sbomFileName, progress}`, or a 404.
*
* @NoAdminRequired
* @spec openspec/specs/sbom-import/spec.md#requirement-large-imports-run-in-bounded-batches-with-progress-reporting
* @spec openspec/specs/sbom-import/spec.md#requirement-moduleversie-records-sbom-import-provenance
*/
#[NoAdminRequired]
public function getSbomImportStatus(string $moduleVersieUuid): JSONResponse
Expand All @@ -247,12 +282,19 @@ public function getSbomImportStatus(string $moduleVersieUuid): JSONResponse
$operationId = null;
}

return new JSONResponse(
data: $this->importService->getStatus(
moduleVersieUuid: $moduleVersieUuid,
operationId: $operationId
)
);
try {
return new JSONResponse(
data: $this->importService->getStatus(
moduleVersieUuid: $moduleVersieUuid,
operationId: $operationId
)
);
} catch (DoesNotExistException $e) {
return new JSONResponse(
data: ['message' => 'moduleVersie not found: '.$moduleVersieUuid, 'error' => 'MODULE_VERSION_NOT_FOUND'],
statusCode: Http::STATUS_NOT_FOUND
);
}
}//end getSbomImportStatus()

/**
Expand Down
58 changes: 56 additions & 2 deletions lib/Service/FacetService.php
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,7 @@ private function fetchBaseObjects(ObjectService $objectService, string $schema,
$pagedQuery['_page'] = $page;

$paginated = $objectService->searchObjectsPaginated($pagedQuery);
$results = $paginated['results'] ?? [];
$results = array_map([$this, 'normalizeObject'], $paginated['results'] ?? []);
$allObjects = array_merge($allObjects, $results);

$totalPages = (int) ($paginated['pages'] ?? 1);
Expand All @@ -464,6 +464,52 @@ private function fetchBaseObjects(ObjectService $objectService, string $schema,

}//end fetchBaseObjects()

/**
* Normalize a single OpenRegister search result into a plain data-bag
* array, tolerant of the several shapes OpenRegister can hand back.
*
* `ObjectService::searchObjectsPaginated()`/`searchObjects()` return
* `OCA\OpenRegister\Db\ObjectEntity` instances in production (confirmed
* live: `ObjectEntity` given where an `array` was assumed), NOT plain
* arrays — only the unit-test stub returned arrays, which is how this
* shipped green and dead. This is the single boundary every OR search
* result MUST pass through before any other FacetService method (all of
* which are `array`-typed) touches it.
*
* Preference order: already an array → returned as-is. An `ObjectEntity`
* (or any object exposing `jsonSerialize()`) → `jsonSerialize()`, which
* merges the payload properties with `@self` metadata AND mirrors the id
* at the top level (see `ObjectEntity::jsonSerialize()`), so both the
* payload fields `extractRelatedIdentifiers()`/`extractRelatedNames()`
* read AND the `id`/`@self.id` shapes `objectIdentifier()` reads survive.
* An object exposing only `getObject()` → that (payload only, `id`
* still present at top level per `ObjectEntity::getObject()`). Anything
* else → cast to array as a last-resort fallback.
*
* @param mixed $object A single OpenRegister search result entry.
*
* @return array The normalized data-bag array.
*
* @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts
*/
private function normalizeObject(mixed $object): array
{
if (is_array($object) === true) {
return $object;
}

if (is_object($object) === true && method_exists($object, 'jsonSerialize') === true) {
return (array) $object->jsonSerialize();
}

if (is_object($object) === true && method_exists($object, 'getObject') === true) {
return (array) $object->getObject();
}

return (array) $object;

}//end normalizeObject()

/**
* Resolve the module object(s) backing each base object's GEMMA dimensions.
*
Expand Down Expand Up @@ -567,8 +613,16 @@ private function fetchModulesByIdentifiers(ObjectService $objectService, array $
return [];
}

if (is_array($results) === false) {
// Defensive: `searchObjects()`'s declared return type is
// `array|int` (a `count`-mode caller could pass a shape that
// resolves to an int); this call site is never in count mode,
// but stay defensive rather than fatal on an unexpected shape.
return [];
}

$byId = [];
foreach ($results as $module) {
foreach (array_map([$this, 'normalizeObject'], $results) as $module) {
$key = $this->objectIdentifier(object: $module);
$byId[$key] = $module;
}
Expand Down
45 changes: 36 additions & 9 deletions src/views/organisaties/PortfolioReport.vue
Original file line number Diff line number Diff line change
Expand Up @@ -352,24 +352,51 @@ export default {

/**
* Load the organisation collection the picker depends on.
*
* `getSchemaConfig('organisatie')` cannot be used here: it only
* resolves a type from the `voorzieningenConfig` blob when that
* blob's `<type>_schema` key holds a NUMERIC schema id, and
* `organisatie_schema` is unset on instances where the voorzieningen
* auto-configure flow never wrote it (it only ever populates
* `module`/`compliancy`/`moduleVersie`/`sbomComponent` — see
* `SettingsService::normalizeVoorzieningenConfig()`). That is exactly
* why the picker rendered "No results" despite the register holding
* real `Gemeente`/`Samenwerking`/supplier organisations: the schema
* never got registered, so `fetchCollection()` threw
* "Object type ... is not registered" before any request was even
* sent.
*
* The manifest-driven `Organisaties` index page (`src/manifest.json`
* `pages[].config` for route `/organisaties`) never hits this gap: the
* shared library's self-fetch path
* (`node_modules/@conduction/nextcloud-vue/src/components/CnIndexPage/useSelfFetchList.js`)
* calls `registerObjectType(type, props.schema, props.register, ...)`
* using the SCHEMA SLUG itself (`'organisatie'`) as the id — which
* OpenRegister's `/api/objects/{register}/{schemaSlugOrId}` accepts
* interchangeably with a numeric id — rather than resolving a numeric
* schema id from a config blob first. This mirrors that proven path:
* register the slug directly against the voorzieningen register id
* (which IS reliably populated — `voorzieningenConfig.register`),
* with no dependency on `organisatie_schema` ever being set.
*
* @return {Promise<void>}
*/
async loadOrganisations() {
try {
if (!objectStore.settings && typeof objectStore.fetchSettings === 'function') {
await objectStore.fetchSettings()
}
const voorzieningenConfig = objectStore.settings?.voorzieningen
|| objectStore.settings?.voorzieningenConfig
|| {}
const registerId = voorzieningenConfig.register
if (typeof objectStore.registerObjectType === 'function'
&& registerId
&& !objectStore.objectTypeRegistry?.organisatie) {
let cfg = null
try {
cfg = objectStore.getSchemaConfig?.('organisatie')
} catch (cfgError) {
// getSchemaConfig throws when no schema/register resolves; fall through.
}
if (cfg?.register && cfg?.schema) {
objectStore.registerObjectType('organisatie', cfg.schema, cfg.register)
}
objectStore.registerObjectType('organisatie', 'organisatie', registerId, {
registerSlug: 'voorzieningen',
schemaSlug: 'organisatie',
})
}
if (typeof objectStore.fetchCollection === 'function') {
await objectStore.fetchCollection('organisatie', { _limit: 1000 })
Expand Down
65 changes: 65 additions & 0 deletions tests/Unit/Controller/SbomControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

use OCA\SoftwareCatalog\Controller\SbomController;
use OCA\SoftwareCatalog\Service\SbomImportService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\IGroupManager;
use OCP\IRequest;
Expand Down Expand Up @@ -306,4 +307,68 @@ public function testStatusRefusesUnauthenticated(): void

$this->assertSame(Http::STATUS_UNAUTHORIZED, $response->getStatus());
}//end testStatusRefusesUnauthenticated()

/**
* REGRESSION: a non-existent `moduleVersieUuid` returns 404, not 500.
*
* Confirmed live: `GET /apps/softwarecatalog/api/moduleversies/{uuid}/sbom`
* for a well-formed but non-existent uuid 500'd with an uncaught
* `OCP\AppFramework\Db\DoesNotExistException` ("Object with identifier
* '...' not found in any magic table") — OpenRegister's real
* `ObjectService::find()` re-throws instead of returning `null` for this
* shape, unlike the assumption `SbomImportService::getStatus()`'s own
* `if ($moduleVersie !== null)` guard was written under.
*
* @return void
*/
public function testStatusReturns404ForNonExistentModuleVersie(): void
{
$request = $this->createMock(IRequest::class);
$request->method('getParam')->willReturn(null);
$this->userSession = $this->createMock(IUserSession::class);
$this->groupManager = $this->createMock(IGroupManager::class);
$this->importService = $this->createMock(SbomImportService::class);

$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn('caller-uid');
$this->userSession->method('getUser')->willReturn($user);

$this->importService->method('getStatus')
->willThrowException(new DoesNotExistException("Object with identifier '00000000-0000-0000-0000-000000000000' not found in any magic table"));

$controller = new SbomController(
$request,
$this->userSession,
$this->groupManager,
$this->importService,
$this->createMock(LoggerInterface::class)
);

$response = $controller->getSbomImportStatus('00000000-0000-0000-0000-000000000000');

$this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus());
$this->assertSame('MODULE_VERSION_NOT_FOUND', $response->getData()['error'] ?? null);
}//end testStatusReturns404ForNonExistentModuleVersie()

/**
* REGRESSION: `importSbom()` also translates a `DoesNotExistException`
* escaping `SbomImportService::importForModuleVersie()` (same underlying
* OpenRegister `find()` behaviour as `testStatusReturns404ForNonExistentModuleVersie()`)
* to 404, not 500.
*
* @return void
*/
public function testImportReturns404ForNonExistentModuleVersie(): void
{
$upload = $this->uploadedFile('{"bomFormat":"CycloneDX","specVersion":"1.6","components":[]}');

$controller = $this->makeController(isAdmin: true, memberGroups: [], uploadedFile: $upload);
$this->importService->method('importForModuleVersie')
->willThrowException(new DoesNotExistException("Object with identifier '00000000-0000-0000-0000-000000000000' not found in any magic table"));

$response = $controller->importSbom('00000000-0000-0000-0000-000000000000');

$this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus());
$this->assertSame('MODULE_VERSION_NOT_FOUND', $response->getData()['error'] ?? null);
}//end testImportReturns404ForNonExistentModuleVersie()
}//end class
Loading