diff --git a/.gitignore b/.gitignore index 15138456..7a2a9d9e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ RCS/* tests/ui-artifacts/ docs/* !docs/flexible-storage-api-v1.md +!docs/ocs-reports-api-v1.md # Mac OS .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 99ac7063..9d827fc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## Unreleased +### Added +- OCS endpoints to list accessible reports and owned datasets, retrieve report metadata and filtered data including shared reports, and add or delete dataset rows. + ### Fixed - Update bundled Moment.js to 2.31.0, which includes the fix for CVE-2026-17495. - Update bundled PhpSpreadsheet to 5.10.0 to address known spreadsheet processing vulnerabilities. diff --git a/docs/ocs-reports-api-v1.md b/docs/ocs-reports-api-v1.md new file mode 100644 index 00000000..037ed87b --- /dev/null +++ b/docs/ocs-reports-api-v1.md @@ -0,0 +1,82 @@ + + +# OCS reports API v1 + +All paths below are relative to `/ocs/v2.php/apps/analytics/api/v1`. +Authenticate as a Nextcloud user (for example with HTTP Basic authentication and +an app password), send `OCS-APIRequest: true`, and request JSON with +`Accept: application/json`. Responses use the standard `ocs.meta` / `ocs.data` +envelope. Existing `/apps/analytics/api/1.0` through `4.0` APIs are unchanged. + +| Method | Path | Result in `ocs.data` | +| --- | --- | --- | +| GET | `/reports` | Accessible reports with only `id`, `name`, and `dataset`, excluding folders | +| GET | `/reports/{reportId}` | Report metadata and saved visualization/filter options | +| GET | `/reports/{reportId}/data` | Viewer data payload including rows, headers and report options | + +Access includes owned reports and reports shared with the user, their groups, +or through shared report folders, following the report navigation access rules. +Public-link possession and panorama-only access do not grant standalone report +access through these endpoints. Missing, inaccessible, and folder IDs return +an OCS not-found error (HTTP 404 on OCS v2). + +Metadata excludes datasource connection settings, owner IDs and share secrets. +The data endpoint uses the same backend pipeline as the report viewer, including +saved filters, dynamic filter variables, sorting and transformations for internal +and external datasources. It accepts no temporary filter or visualization +overrides. It returns the viewer's backend data, not rendered chart images or +client-side table formatting/pivot output. Datasource failures retain the viewer +payload's `error` field. Conditional caching is not enabled for this endpoint. + +Example: + +```sh +curl --user 'USER:APP_PASSWORD' \ + --header 'OCS-APIRequest: true' \ + --header 'Accept: application/json' \ + 'https://cloud.example/ocs/v2.php/apps/analytics/api/v1/reports/42/data' +``` + +The machine-readable contract is maintained in the repository root `openapi.json`. +This repository currently has no configured OpenAPI extractor; keep the specification +in sync when changing the controller. + +## Dataset operations + +| Method | Path | Result in `ocs.data` | +| --- | --- | --- | +| GET | `/datasets` | Owned datasets: `id`, `name`, `dimension1`, `dimension2`, `value` | +| POST | `/datasets/{datasetId}/data/add` | Existing V4 add/update result | +| POST | `/datasets/{datasetId}/data/delete` | Existing V4 deletion result, including `delete` count | + +The OCS controller delegates these operations to the existing external API handlers. +The dataset list exposes only `id`, `name`, `dimension1`, `dimension2`, and `value`; +internal navigation, scheduling, and storage fields are omitted. The legacy V3 +dataset response is unchanged. +Dataset writes require ownership and legacy storage; shared report access does not +allow dataset writes. Flexible datasets continue to use the stable-column record API. +The URL dataset ID takes precedence over any body parameter. + +Add/update body (technical keys or configured dataset column names): + +```json +{"data":[{"dimension1":"2026-10-15","dimension2":"Sales","value":"42"}]} +``` + +Delete body (a nonempty filter is required): + +```json +{"filter":{"dimension1":{"option":"EQ","value":"2026-10-15"}}} +``` + +Deletion supports `dimension1`, `dimension2`, or their configured names, and the +V4 operators `EQ`, `GT`, `LT`, `IN`, `LIKE`, `NOTLIKE`, and `BETWEEN`, including +dynamic date variables. These endpoints retain V4 application error semantics: +HTTP 200 with `ocs.data.success=false` and `ocs.data.error.code/message`. +Codes are 9001 (unknown), 9002 (invalid input), 9003 (missing or unauthorized +dataset), and 9004 (unsupported storage mode). Check `success`, not just the OCS +metadata. Successful writes return `success=true` and a message; deletion also +returns the deleted row count. Authentication failures remain HTTP 401. diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index ee351b9f..ac1cdd49 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -9,6 +9,8 @@ namespace OCA\Analytics\Controller; use OCA\Analytics\Service\ReportService; +use OCA\Analytics\Service\ShareService; +use OCP\AppFramework\OCS\OCSNotFoundException; use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; @@ -22,10 +24,14 @@ class ApiController extends OCSController { private ReportService $reportService; private $logger; - public function __construct(string $appName, - IRequest $request, - LoggerInterface $logger, - ReportService $reportService + public function __construct( + string $appName, + IRequest $request, + LoggerInterface $logger, + ReportService $reportService, + private ShareService $shareService, + private OutputController $outputController, + private ApiDataController $apiDataController ) { parent::__construct($appName, $request); $this->reportService = $reportService; @@ -68,4 +74,88 @@ public function createFromDataFile($fileId): DataResponse { return new DataResponse(['error' => 'fileId missing'], HTTP::STATUS_BAD_REQUEST); } } -} \ No newline at end of file + + #[NoAdminRequired] + #[NoCSRFRequired] + #[ApiRoute(verb: 'GET', url: '/api/v1/reports')] + public function index(): DataResponse { + $reports = array_values(array_filter( + $this->reportService->index(), + static fn (array $report): bool => (int)$report['type'] !== ReportService::REPORT_TYPE_GROUP + )); + return new DataResponse(array_map( + static fn (array $report): array => array_intersect_key($report, array_flip(['id', 'name', 'dataset'])), + $reports + )); + } + + #[NoAdminRequired] + #[NoCSRFRequired] + #[ApiRoute(verb: 'GET', url: '/api/v1/reports/{reportId}')] + public function read(int $reportId): DataResponse { + return new DataResponse($this->publicMetadata($this->accessibleReport($reportId))); + } + + #[NoAdminRequired] + #[NoCSRFRequired] + #[ApiRoute(verb: 'GET', url: '/api/v1/reports/{reportId}/data')] + public function data(int $reportId): DataResponse { + // Use the viewer pipeline with saved options only, without request overrides + // or conditional caching: every successful response contains the report data. + $result = $this->outputController->getData($this->accessibleReport($reportId)); + unset($result['cache']); + if (isset($result['options'])) { + $result['options'] = $this->publicMetadata($result['options']); + } + return new DataResponse($result); + } + + /** List owned datasets with their column labels. */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[ApiRoute(verb: 'GET', url: '/api/v1/datasets')] + public function datasets(): DataResponse { + return new DataResponse(array_map( + static fn (array $dataset): array => array_intersect_key($dataset, array_flip([ + 'id', 'name', 'dimension1', 'dimension2', 'value', + ])), + $this->apiDataController->datasetIndexV3() + )); + } + + /** Add or update rows using the existing V4 validation and write path. */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[ApiRoute(verb: 'POST', url: '/api/v1/datasets/{datasetId}/data/add')] + public function addData(int $datasetId): DataResponse { + return $this->apiDataController->addDataV4($datasetId); + } + + /** Delete rows using the existing V4 structured-filter write path. */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[ApiRoute(verb: 'POST', url: '/api/v1/datasets/{datasetId}/data/delete')] + public function deleteData(int $datasetId): DataResponse { + return $this->apiDataController->deleteDataV4($datasetId); + } + + private function accessibleReport(int $reportId): array { + $report = $this->reportService->read($reportId); + if (empty($report)) { + $report = $this->shareService->getSharedReport($reportId); + } + if (empty($report) || (int)$report['type'] === ReportService::REPORT_TYPE_GROUP) { + throw new OCSNotFoundException('Report not found'); + } + return $report; + } + + private function publicMetadata(array $report): array { + return array_intersect_key($report, array_flip([ + 'id', 'name', 'subheader', 'type', 'parent', 'dataset', 'version', + 'visualization', 'chart', 'chartoptions', 'dataoptions', 'tableoptions', + 'filteroptions', 'refresh', 'permissions', 'isShare', + 'storageMode', 'schemaVersion', 'columns', 'dimension1', 'dimension2', 'value', + ])); + } +} diff --git a/openapi.json b/openapi.json index 479f6251..f2a92246 100644 --- a/openapi.json +++ b/openapi.json @@ -9,7 +9,9 @@ "post": { "summary": "Create analytics report from a data file", "operationId": "createFromDataFile", - "tags": ["analytics"], + "tags": [ + "analytics" + ], "requestBody": { "required": true, "content": { @@ -22,7 +24,9 @@ "description": "ID of the file to import" } }, - "required": ["fileId"] + "required": [ + "fileId" + ] } } } @@ -35,14 +39,561 @@ "schema": { "type": "object", "properties": { - "version": {"type": "number"}, - "root": { + "ocs": { "type": "object", "properties": { - "orientation": {"type": "string"}, - "rows": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "root": { + "type": "object", + "properties": { + "orientation": { + "type": "string" + }, + "rows": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + } + } + }, + "required": [ + "meta", + "data" + ] + } + }, + "required": [ + "ocs" + ] + } + } + } + }, + "404": { + "description": "File not found" + }, + "400": { + "description": "fileId missing", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ocs": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "required": [ + "meta", + "data" + ] + } + }, + "required": [ + "ocs" + ] + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ocs": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + }, + "required": [ + "meta", + "data" + ] + } + }, + "required": [ + "ocs" + ] + } + } + } + } + }, + "security": [ + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "true" + ] + } + } + ] + } + }, + "/ocs/v2.php/apps/analytics/api/v1/reports": { + "get": { + "operationId": "listReports", + "summary": "List owned and shared reports", + "description": "Includes reports shared with the user, their groups, or through report folders. Excludes public-link-only and panorama-only access.", + "tags": [ + "analytics" + ], + "security": [ + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "true" + ] + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ocs": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReportSummary" + } + } + }, + "required": [ + "meta", + "data" + ] + } + }, + "required": [ + "ocs" + ] + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ocs": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + }, + "required": [ + "meta", + "data" + ] + } + }, + "required": [ + "ocs" + ] + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/analytics/api/v1/reports/{reportId}": { + "get": { + "operationId": "getReport", + "summary": "Get accessible report metadata", + "description": "Includes reports shared with the user, their groups, or through report folders. Excludes public-link-only and panorama-only access.", + "tags": [ + "analytics" + ], + "security": [ + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "true" + ] + } + }, + { + "name": "reportId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Accessible report ID; folders are not reports" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ocs": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ReportMetadata" + } + }, + "required": [ + "meta", + "data" + ] + } + }, + "required": [ + "ocs" + ] + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ocs": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + }, + "required": [ + "meta", + "data" + ] + } + }, + "required": [ + "ocs" + ] + } + } + } + }, + "404": { + "description": "Report missing or inaccessible", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ocs": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + }, + "required": [ + "meta", + "data" + ] + } + }, + "required": [ + "ocs" + ] + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/analytics/api/v1/reports/{reportId}/data": { + "get": { + "operationId": "getReportData", + "summary": "Get report data with saved filters", + "description": "Includes reports shared with the user, their groups, or through report folders. Excludes public-link-only and panorama-only access. Uses the report viewer backend pipeline, including saved filters, variables, sorting and transformations. No temporary filter overrides. Browser-side formatting and pivots are not rendered. Datasource failures use the payload error field.", + "tags": [ + "analytics" + ], + "security": [ + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "true" + ] + } + }, + { + "name": "reportId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Accessible report ID; folders are not reports" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ocs": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ReportData" + } + }, + "required": [ + "meta", + "data" + ] + } + }, + "required": [ + "ocs" + ] + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ocs": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + }, + "required": [ + "meta", + "data" + ] + } + }, + "required": [ + "ocs" + ] + } + } + } + }, + "404": { + "description": "Report missing or inaccessible", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ocs": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + }, + "required": [ + "meta", + "data" + ] + } + }, + "required": [ + "ocs" + ] + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/analytics/api/v1/datasets": { + "get": { + "operationId": "listDatasets", + "summary": "List owned datasets", + "description": "List owned datasets with only their ID, name and column labels. Shared reports do not grant dataset write access. The legacy V3 dataset response is unchanged.", + "tags": [ + "analytics" + ], + "security": [ + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "true" + ] + } + } + ], + "responses": { + "200": { + "description": "Owned datasets", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { "type": "array", - "items": {"type": "object"} + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "name": { + "type": "string", + "nullable": true + }, + "dimension1": { + "type": "string", + "nullable": true + }, + "dimension2": { + "type": "string", + "nullable": true + }, + "value": { + "type": "string", + "nullable": true + } + } + } } } } @@ -51,7 +602,588 @@ } } }, - "404": {"description": "File not found"} + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ocs": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + }, + "required": [ + "meta", + "data" + ] + } + }, + "required": [ + "ocs" + ] + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/analytics/api/v1/datasets/{datasetId}/data/add": { + "post": { + "operationId": "addDatasetData", + "summary": "Add or update dataset rows", + "description": "Delegates to the existing V4 external API. Requires ownership of a legacy dataset; flexible datasets are rejected. Application errors retain HTTP 200 and success=false with error.code and error.message in ocs.data (9002 invalid input, 9003 missing or unauthorized dataset, 9004 unsupported storage mode). Inspect success, not only ocs.meta. The URL datasetId takes precedence over body parameters.", + "tags": [ + "analytics" + ], + "security": [ + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "true" + ] + } + }, + { + "name": "datasetId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Existing V4 operation result; inspect success for application errors", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/DataWriteResult" + } + } + } + } + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ocs": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + }, + "required": [ + "meta", + "data" + ] + } + }, + "required": [ + "ocs" + ] + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {}, + "description": "Provide dimension1, dimension2 and value using technical keys or configured dataset column names." + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/analytics/api/v1/datasets/{datasetId}/data/delete": { + "post": { + "operationId": "deleteDatasetData", + "summary": "Delete dataset rows matching a filter", + "description": "Delegates to the existing V4 external API. Requires ownership of a legacy dataset; flexible datasets are rejected. Application errors retain HTTP 200 and success=false with error.code and error.message in ocs.data (9002 invalid input, 9003 missing or unauthorized dataset, 9004 unsupported storage mode). Inspect success, not only ocs.meta. The URL datasetId takes precedence over body parameters.", + "tags": [ + "analytics" + ], + "security": [ + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "true" + ] + } + }, + { + "name": "datasetId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Existing V4 operation result; inspect success for application errors", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/DataWriteResult" + } + } + } + } + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ocs": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + }, + "required": [ + "meta", + "data" + ] + } + }, + "required": [ + "ocs" + ] + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "filter" + ], + "properties": { + "filter": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "type": "object", + "required": [ + "option", + "value" + ], + "properties": { + "option": { + "type": "string", + "enum": [ + "EQ", + "GT", + "LT", + "IN", + "LIKE", + "NOTLIKE", + "BETWEEN" + ] + }, + "value": { + "type": "string" + } + } + }, + "description": "Keys are dimension1/dimension2 or their configured names. Values support the existing V4 filter syntax and date variables." + } + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "basic_auth": { + "type": "http", + "scheme": "basic" + } + }, + "schemas": { + "OCSMeta": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "statuscode": { + "type": "integer" + }, + "message": { + "type": "string" + } + }, + "required": [ + "status", + "statuscode", + "message" + ] + }, + "ReportSummary": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "dataset" + ], + "properties": { + "id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "name": { + "type": "string" + }, + "dataset": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + } + } + }, + "ReportMetadata": { + "type": "object", + "properties": { + "id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "type": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "dataset": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "parent": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "version": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "permissions": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "isShare": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "schemaVersion": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "refresh": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "name": { + "type": "string", + "nullable": true + }, + "subheader": { + "type": "string", + "nullable": true + }, + "visualization": { + "type": "string", + "nullable": true + }, + "chart": { + "type": "string", + "nullable": true + }, + "chartoptions": { + "type": "string", + "nullable": true + }, + "dataoptions": { + "type": "string", + "nullable": true + }, + "tableoptions": { + "type": "string", + "nullable": true + }, + "filteroptions": { + "type": "string", + "nullable": true + }, + "storageMode": { + "type": "string", + "nullable": true + }, + "dimension1": { + "type": "string", + "nullable": true + }, + "dimension2": { + "type": "string", + "nullable": true + }, + "value": { + "type": "string", + "nullable": true + }, + "columns": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "ReportData": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "header": { + "type": "array", + "items": { + "type": "string" + } + }, + "options": { + "$ref": "#/components/schemas/ReportMetadata" + }, + "filterApplied": { + "type": "string", + "nullable": true + }, + "error": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "thresholds": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "additionalProperties": true + }, + "DataWriteResult": { + "type": "object", + "required": [ + "success" + ], + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "delete": { + "type": "integer", + "description": "Number of deleted rows" + }, + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "integer", + "enum": [ + 9001, + 9002, + 9003, + 9004 + ] + }, + "message": { + "type": "string" + } + } + } } } } diff --git a/tests/Controller/ApiControllerTest.php b/tests/Controller/ApiControllerTest.php new file mode 100644 index 00000000..e0c041f8 --- /dev/null +++ b/tests/Controller/ApiControllerTest.php @@ -0,0 +1,114 @@ +createMock(IRequest::class), $this->createMock(\Psr\Log\LoggerInterface::class), $reports, $shares, $output, $apiData ?? $this->createMock(ApiDataController::class)); + } + + public function testListIncludesSharedReportsAndOmitsFolders(): void { + $reports = $this->createMock(ReportService::class); + $reports->method('index')->willReturn([ + ['id' => 1, 'name' => 'Owned', 'type' => 2, 'parent' => 0, 'dataset' => 2, 'version' => 23495, 'favorite' => 0, 'item_type' => 'report'], ['id' => 2, 'type' => 0], + ['id' => 3, 'name' => 'Shared', 'dataset' => 7, 'type' => 2, 'isShare' => 1, 'futureInternalField' => true], + ]); + $result = $this->controller($reports, $this->createMock(ShareService::class), $this->createMock(OutputController::class))->index()->getData(); + $this->assertSame([['id' => 1, 'name' => 'Owned', 'dataset' => 2], ['id' => 3, 'name' => 'Shared', 'dataset' => 7]], $result); + $this->assertSame([0, 1], array_keys($result)); + } + + public function testSharedMetadataDoesNotExposeConnectionOrShareSecrets(): void { + $reports = $this->createMock(ReportService::class); + $reports->method('read')->with(3)->willReturn([]); + $shares = $this->createMock(ShareService::class); + $shares->method('getSharedReport')->with(3)->willReturn([ + 'id' => 3, 'type' => 2, 'name' => 'Shared', 'filteroptions' => '{"filter":[]}', + 'link' => 'credentials', 'password' => 'hash', 'token' => 'secret', 'user_id' => 'owner', + ]); + $result = $this->controller($reports, $shares, $this->createMock(OutputController::class))->read(3)->getData(); + $this->assertSame(['id', 'type', 'name', 'filteroptions'], array_keys($result)); + } + + public function testDataUsesViewerPipelineWithSavedSharedFilters(): void { + $metadata = ['id' => 3, 'type' => 2, 'filteroptions' => '{"filter":{"dimension1":"saved"}}']; + $reports = $this->createMock(ReportService::class); + $reports->method('read')->willReturn([]); + $shares = $this->createMock(ShareService::class); + $shares->method('getSharedReport')->with(3)->willReturn($metadata); + $output = $this->createMock(OutputController::class); + $output->expects($this->once())->method('getData')->with($metadata)->willReturn([ + 'header' => ['Label', 'Value'], 'data' => [['saved', 42]], + 'filterApplied' => $metadata['filteroptions'], 'options' => $metadata + ['token' => 'secret'], + 'cache' => ['key' => 'internal'], + ]); + $result = $this->controller($reports, $shares, $output, $apiData = null)->data(3)->getData(); + $this->assertSame([['saved', 42]], $result['data']); + $this->assertSame($metadata['filteroptions'], $result['filterApplied']); + $this->assertArrayNotHasKey('cache', $result); + $this->assertArrayNotHasKey('token', $result['options']); + } + + public function testInaccessibleReportNeverExecutesDatasource(): void { + $reports = $this->createMock(ReportService::class); + $reports->method('read')->willReturn([]); + $shares = $this->createMock(ShareService::class); + $shares->method('getSharedReport')->willReturn([]); + $output = $this->createMock(OutputController::class); + $output->expects($this->never())->method('getData'); + $this->expectException(OCSNotFoundException::class); + $this->controller($reports, $shares, $output, $apiData = null)->data(99); + } + + public function testOwnedMetadataDoesNotRequireShare(): void { + $reports = $this->createMock(ReportService::class); + $reports->method('read')->with(1)->willReturn(['id' => 1, 'type' => 2]); + $shares = $this->createMock(ShareService::class); + $shares->expects($this->never())->method('getSharedReport'); + $this->assertSame(1, $this->controller($reports, $shares, $this->createMock(OutputController::class))->read(1)->getData()['id']); + } + + public function testDatasetListingUsesExistingExternalApi(): void { + $apiData = $this->createMock(ApiDataController::class); + $expected = [['id' => 4, 'name' => 'Owned dataset', 'dimension1' => 'Object', 'dimension2' => 'Date', 'value' => 'Value']]; + $datasets = [$expected[0] + [ + 'type' => 2, 'parent' => null, 'storage_mode' => 'legacy', 'schema_version' => 0, + 'schedules' => 1, 'item_type' => 'dataset', 'storageMode' => 'legacy', 'schemaVersion' => 0, + 'futureInternalField' => 'must not leak', + ]]; + $apiData->expects($this->once())->method('datasetIndexV3')->willReturn($datasets); + $controller = $this->controller($this->createMock(ReportService::class), $this->createMock(ShareService::class), $this->createMock(OutputController::class), $apiData); + $this->assertSame($expected, $controller->datasets()->getData()); + } + + /** @dataProvider writeOperations */ + public function testWritesPreserveExistingSuccessAndErrorResponses(string $method, string $delegate, array $payload): void { + $apiData = $this->createMock(ApiDataController::class); + $response = new DataResponse($payload); + $apiData->expects($this->once())->method($delegate)->with(42)->willReturn($response); + $controller = $this->controller($this->createMock(ReportService::class), $this->createMock(ShareService::class), $this->createMock(OutputController::class), $apiData); + $this->assertSame($response, $controller->$method(42)); + } + + public function writeOperations(): array { + return [ + ['addData', 'addDataV4', ['success' => true, 'message' => 'Data update successful']], + ['deleteData', 'deleteDataV4', ['success' => true, 'delete' => 2]], + ['addData', 'addDataV4', ['success' => false, 'error' => ['code' => 9003, 'message' => 'Unknown or unauthorized report or dataset']]], + ['deleteData', 'deleteDataV4', ['success' => false, 'error' => ['code' => 9002, 'message' => 'filter required']]], + ]; + } +} diff --git a/tests/Stubs/OCP/AppFramework/OCS/OCSNotFoundException.php b/tests/Stubs/OCP/AppFramework/OCS/OCSNotFoundException.php new file mode 100644 index 00000000..aab20ab0 --- /dev/null +++ b/tests/Stubs/OCP/AppFramework/OCS/OCSNotFoundException.php @@ -0,0 +1,9 @@ +