diff --git a/REUSE.toml b/REUSE.toml index 7698fd6add..aa0491b531 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -20,6 +20,7 @@ path = [ ".github/copilot-instructions.md", ".patches/**", "appinfo/info.xml", + "appinfo/usage-statistics/schema-v1.json", "composer.json", "composer.lock", ".github/ISSUE_TEMPLATE/2_feature_request.md", diff --git a/appinfo/usage-statistics/README.md b/appinfo/usage-statistics/README.md new file mode 100644 index 0000000000..f4d3b926d7 --- /dev/null +++ b/appinfo/usage-statistics/README.md @@ -0,0 +1,66 @@ + +# LibreSign usage statistics schema + +`schema-v1.json` is the metric contract LibreSign registers with the usage statistics server. It uses only the fields the server accepts. This file records what the JSON cannot hold: where each value comes from and how a past month is rebuilt. + +A registered schema version is immutable. Any change to a metric, including a new optional one, needs a new `schemaVersion`. A new metric must not shorten the history of the metrics we can already rebuild. + +## Reports + +- Each report covers one calendar month in UTC. The start is inclusive and the end exclusive. +- The *current* report covers the most recent closed month and includes the snapshots of the environment at collection time. +- A *historical* report rebuilds an older month from stored events. It never includes snapshots, because LibreSign has no record of past versions or settings. +- `0` is a known zero: no activity in a month that can be rebuilt. A missing metric means its value is unknown for that month. +- Report generation fails instead of leaving a metric out when that metric should be available but is missing or invalid. +- Every metric has exactly one collector in `lib/Service/UsageStatistics/Collector/`, and a test enforces this. + +## Counting rules + +- A signing request is one participant expected to sign. Observers are counted only in `participants.observers_added`. +- An envelope and each of its files carry their own signing request for the same signer. Signing metrics count only requests on top-level files, that is, standalone files and envelopes, never the files inside an envelope. +- Identification documents also create LibreSign files and signing requests, but they are not signing requests. They are left out of every document and signing metric. +- Counts are aggregates of events and never deduplicate people. +- Deleting a signing request, deleting the signed file and `occ libresign:crl:cleanup` remove rows. A rebuilt month is therefore a lower bound of what happened. + +## Metrics + +### Snapshots: current report only + +| Metric | Source of truth | +|---|---| +| `nextcloud.version` | `OCP\ServerVersion`, major.minor.patch | +| `nextcloud.users_total` | `IUserManager::countUsersTotal()`. Unavailable when a user backend cannot count. | +| `nextcloud.users_active_30d` | Users whose last login is within 30 days of collection | +| `nextcloud.background_job_mode` | `core` / `backgroundjobs_mode`, reduced to `ajax`, `webcron`, `cron` or `other` | +| `php.version` | PHP major.minor | +| `database.type` | `IDBConnection::getDatabaseProvider(true)` | +| `database.version` | The server version reduced to major.minor. Unavailable on Oracle. | +| `system.os_family` | `PHP_OS_FAMILY` | +| `system.architecture` | Machine type normalized to `x86_64`, `aarch64` or `other`. Never the host name or kernel. | +| `libresign.version` | Installed LibreSign version | +| `libresign.signature_engine` | `signature_engine` setting. Anything other than `PhpNative` runs `JSignPdf`. | +| `libresign.signing_mode` | `signing_mode` setting, `sync` or `async` | +| `certificate.engine` | `certificate_engine` setting: `openssl`, `cfssl`, `none` or `other` | +| `certificate.root_ca_configured` | `ca.pem` and `ca-key.pem` exist in the stored PKI path. The value is read without creating the path or contacting CFSSL. | + +No certificate subject, issuer, serial number, CA identifier, path or URL is ever reported. + +### Monthly counters + +| Metric | Event timestamp | History | +|---|---|---| +| `documents.files_created` | `libresign_file.created_at` of top-level files that are not envelopes and have no files | Since the first install | +| `documents.envelopes_created` | `libresign_file.created_at` of top-level files stored as envelopes or holding files | Envelopes arrived in 12.2/13. Earlier months are a known zero. | +| `signatures.requests_created` | `libresign_sign_request.created_at`, signers only | Since the first install | +| `signatures.completed` | `libresign_sign_request.signed`, signers only | Since the first install | +| `signatures.rejected` | `libresign_sign_request.rejected_at`, signers only | Rejection arrived in 15.0 together with the column. Earlier months are a known zero. | +| `participants.observers_added` | `libresign_sign_request.created_at`, observers only | Observers arrived together with `participant_role`. Earlier months are a known zero. | +| `certificate.certificates_issued` | `libresign_crl.issued_at` of `leaf` certificates | See below | +| `certificate.certificates_revoked` | `libresign_crl.revoked_at` of `leaf` certificates | See below | + +Certificates are recorded only since 12.1 (OpenSSL) and 12.2/13 (CFSSL). The certificate counters are known for every month when the first recorded certificate is not later than the first LibreSign file. This is a fresh install, which creates its root CA first. Otherwise the instance was upgraded, and they are known only for months that start after the first recorded certificate. + +The historical series starts at the month of the first LibreSign file. diff --git a/appinfo/usage-statistics/schema-v1.json b/appinfo/usage-statistics/schema-v1.json new file mode 100644 index 0000000000..b431ac38e6 --- /dev/null +++ b/appinfo/usage-statistics/schema-v1.json @@ -0,0 +1,28 @@ +{ + "application": "libresign", + "schemaVersion": 1, + "metrics": [ + {"category": "nextcloud", "key": "version", "type": "string", "kind": "snapshot", "aggregation": "distribution", "description": "Nextcloud server version (major.minor.patch)", "required": false}, + {"category": "nextcloud", "key": "users_total", "type": "integer", "kind": "snapshot", "aggregation": "numerical", "description": "Number of user accounts", "required": false}, + {"category": "nextcloud", "key": "users_active_30d", "type": "integer", "kind": "snapshot", "aggregation": "numerical", "description": "Number of users who logged in during the last 30 days", "required": false}, + {"category": "nextcloud", "key": "background_job_mode", "type": "string", "kind": "snapshot", "aggregation": "distribution", "description": "Background job mode: ajax, webcron, cron or other", "required": false}, + {"category": "php", "key": "version", "type": "string", "kind": "snapshot", "aggregation": "distribution", "description": "PHP version (major.minor)", "required": false}, + {"category": "database", "key": "type", "type": "string", "kind": "snapshot", "aggregation": "distribution", "description": "Database platform: mysql, mariadb, postgres, sqlite or oracle", "required": false}, + {"category": "database", "key": "version", "type": "string", "kind": "snapshot", "aggregation": "distribution", "description": "Database server version (major.minor)", "required": false}, + {"category": "system", "key": "os_family", "type": "string", "kind": "snapshot", "aggregation": "distribution", "description": "Operating system family reported by PHP", "required": false}, + {"category": "system", "key": "architecture", "type": "string", "kind": "snapshot", "aggregation": "distribution", "description": "CPU architecture: x86_64, aarch64 or other", "required": false}, + {"category": "libresign", "key": "version", "type": "string", "kind": "snapshot", "aggregation": "distribution", "description": "LibreSign version", "required": false}, + {"category": "libresign", "key": "signature_engine", "type": "string", "kind": "snapshot", "aggregation": "distribution", "description": "PDF signature engine: JSignPdf or PhpNative", "required": false}, + {"category": "libresign", "key": "signing_mode", "type": "string", "kind": "snapshot", "aggregation": "distribution", "description": "Signing mode: sync or async", "required": false}, + {"category": "certificate", "key": "engine", "type": "string", "kind": "snapshot", "aggregation": "distribution", "description": "Certificate engine: openssl, cfssl, none or other", "required": false}, + {"category": "certificate", "key": "root_ca_configured", "type": "boolean", "kind": "snapshot", "aggregation": "distribution", "description": "Whether a root certificate authority is configured", "required": false}, + {"category": "certificate", "key": "certificates_issued", "type": "integer", "kind": "period", "aggregation": "numerical", "description": "Signing certificates issued during the period", "required": false}, + {"category": "certificate", "key": "certificates_revoked", "type": "integer", "kind": "period", "aggregation": "numerical", "description": "Signing certificates revoked during the period", "required": false}, + {"category": "documents", "key": "files_created", "type": "integer", "kind": "period", "aggregation": "numerical", "description": "Standalone files created during the period", "required": true}, + {"category": "documents", "key": "envelopes_created", "type": "integer", "kind": "period", "aggregation": "numerical", "description": "Envelopes created during the period", "required": true}, + {"category": "signatures", "key": "requests_created", "type": "integer", "kind": "period", "aggregation": "numerical", "description": "Signing requests created during the period", "required": true}, + {"category": "signatures", "key": "completed", "type": "integer", "kind": "period", "aggregation": "numerical", "description": "Signing requests completed during the period", "required": true}, + {"category": "signatures", "key": "rejected", "type": "integer", "kind": "period", "aggregation": "numerical", "description": "Signing requests rejected during the period", "required": true}, + {"category": "participants", "key": "observers_added", "type": "integer", "kind": "period", "aggregation": "numerical", "description": "Observers added during the period", "required": true} + ] +} diff --git a/lib/Service/UsageStatistics/Collector/CertificateActivityCollector.php b/lib/Service/UsageStatistics/Collector/CertificateActivityCollector.php new file mode 100644 index 0000000000..d08e28d802 --- /dev/null +++ b/lib/Service/UsageStatistics/Collector/CertificateActivityCollector.php @@ -0,0 +1,60 @@ +isHistoryComplete($period)) { + return [ + 'certificate.certificates_issued' => MetricValue::unavailable(), + 'certificate.certificates_revoked' => MetricValue::unavailable(), + ]; + } + return [ + 'certificate.certificates_issued' => MetricValue::of($this->reader->countSigningCertificatesIssued($period)), + 'certificate.certificates_revoked' => MetricValue::of($this->reader->countSigningCertificatesRevoked($period)), + ]; + } + + private function isHistoryComplete(ReportPeriod $period): bool { + $firstActivityAt = $this->reader->firstActivityAt(); + $firstCertificateAt = $this->reader->firstCertificateRecordedAt(); + if ($firstCertificateAt === null) { + return $firstActivityAt === null; + } + if ($firstActivityAt === null || $firstCertificateAt <= $firstActivityAt) { + return true; + } + return $period->start() >= $firstCertificateAt; + } +} diff --git a/lib/Service/UsageStatistics/Collector/DocumentActivityCollector.php b/lib/Service/UsageStatistics/Collector/DocumentActivityCollector.php new file mode 100644 index 0000000000..438cefc9ed --- /dev/null +++ b/lib/Service/UsageStatistics/Collector/DocumentActivityCollector.php @@ -0,0 +1,39 @@ + MetricValue::of($this->reader->countStandaloneFilesCreated($period)), + 'documents.envelopes_created' => MetricValue::of($this->reader->countEnvelopesCreated($period)), + ]; + } +} diff --git a/lib/Service/UsageStatistics/Collector/EnvironmentCollector.php b/lib/Service/UsageStatistics/Collector/EnvironmentCollector.php new file mode 100644 index 0000000000..e2714ed82e --- /dev/null +++ b/lib/Service/UsageStatistics/Collector/EnvironmentCollector.php @@ -0,0 +1,129 @@ +metricIds(), MetricValue::unavailable()); + } + + $usersTotal = $this->userManager->countUsersTotal(); + $databaseVersion = $this->databaseVersion(); + return [ + 'nextcloud.version' => MetricValue::of($this->runtime->nextcloudVersion()), + // Some user backends cannot count their users. + 'nextcloud.users_total' => $usersTotal === false ? MetricValue::unavailable() : MetricValue::of($usersTotal), + 'nextcloud.users_active_30d' => MetricValue::of($this->countRecentlyActiveUsers()), + 'nextcloud.background_job_mode' => MetricValue::of($this->backgroundJobMode()), + 'php.version' => MetricValue::of($this->runtime->phpVersion()), + 'database.type' => MetricValue::of($this->runtime->databasePlatform()), + 'database.version' => $databaseVersion === null ? MetricValue::unavailable() : MetricValue::of($databaseVersion), + 'system.os_family' => MetricValue::of($this->runtime->osFamily()), + 'system.architecture' => MetricValue::of($this->architecture()), + ]; + } + + /** + * Users come sorted by their last login, most recent first, so counting + * stops at the first one outside the window. Pages advance by what each + * call returned and end on an empty one, so a server that caps the batch + * below the requested size cannot end the count early. + */ + private function countRecentlyActiveUsers(): int { + $since = $this->timeFactory->getTime() - self::ACTIVE_USERS_DAYS * 86400; + $count = 0; + $offset = 0; + while (($userIds = $this->userManager->getLastLoggedInUsers(self::LAST_LOGIN_BATCH_SIZE, $offset)) !== []) { + foreach ($userIds as $userId) { + $user = $this->userManager->get($userId); + if ($user === null) { + continue; + } + if ($user->getLastLogin() < $since) { + return $count; + } + $count++; + } + $offset += count($userIds); + } + return $count; + } + + private function backgroundJobMode(): string { + $mode = $this->appConfig->getValueString('core', 'backgroundjobs_mode', 'ajax'); + return in_array($mode, self::BACKGROUND_JOB_MODES, true) ? $mode : 'other'; + } + + /** + * Only major.minor: full version strings carry build and distribution details. + */ + private function databaseVersion(): ?string { + $version = $this->runtime->databaseServerVersion(); + if ($version === null) { + return null; + } + if (preg_match('/^(\d+)\.(\d+)/', trim($version), $matches) !== 1) { + throw new UsageStatisticsException('Unrecognized database version format'); + } + return $matches[1] . '.' . $matches[2]; + } + + private function architecture(): string { + try { + return InstallTarget::normalizeArchitecture($this->runtime->machine()); + } catch (\InvalidArgumentException) { + return 'other'; + } + } +} diff --git a/lib/Service/UsageStatistics/Collector/LibreSignConfigurationCollector.php b/lib/Service/UsageStatistics/Collector/LibreSignConfigurationCollector.php new file mode 100644 index 0000000000..e865f9d381 --- /dev/null +++ b/lib/Service/UsageStatistics/Collector/LibreSignConfigurationCollector.php @@ -0,0 +1,88 @@ +metricIds(), MetricValue::unavailable()); + } + + $certificateEngine = $this->certificateEngine(); + return [ + 'libresign.version' => MetricValue::of($this->appManager->getAppVersion(Application::APP_ID)), + 'libresign.signature_engine' => MetricValue::of($this->signatureEngine()), + 'libresign.signing_mode' => MetricValue::of($this->signingMode()), + 'certificate.engine' => MetricValue::of($certificateEngine), + 'certificate.root_ca_configured' => MetricValue::of($this->isRootCaConfigured($certificateEngine)), + ]; + } + + /** Same fallback as the admin settings: anything but PhpNative runs JSignPdf. */ + private function signatureEngine(): string { + $engine = $this->appConfig->getValueString(Application::APP_ID, 'signature_engine', 'JSignPdf'); + return $engine === 'PhpNative' ? $engine : 'JSignPdf'; + } + + /** Same fallback as the signing mode policy. */ + private function signingMode(): string { + $mode = $this->appConfig->getValueString(Application::APP_ID, 'signing_mode', 'sync'); + return in_array($mode, ['sync', 'async'], true) ? $mode : 'sync'; + } + + private function certificateEngine(): string { + $engine = $this->appConfig->getValueString(Application::APP_ID, 'certificate_engine', 'openssl'); + return in_array($engine, self::CERTIFICATE_ENGINES, true) ? $engine : 'other'; + } + + private function isRootCaConfigured(string $certificateEngine): bool { + if (!in_array($certificateEngine, ['openssl', 'cfssl'], true)) { + return false; + } + $configPath = $this->appConfig->getValueString(Application::APP_ID, 'config_path'); + if ($configPath === '' || !is_dir($configPath)) { + return false; + } + return file_exists($configPath . DIRECTORY_SEPARATOR . 'ca.pem') + && file_exists($configPath . DIRECTORY_SEPARATOR . 'ca-key.pem'); + } +} diff --git a/lib/Service/UsageStatistics/Collector/SigningActivityCollector.php b/lib/Service/UsageStatistics/Collector/SigningActivityCollector.php new file mode 100644 index 0000000000..8d9d1ca24f --- /dev/null +++ b/lib/Service/UsageStatistics/Collector/SigningActivityCollector.php @@ -0,0 +1,46 @@ + MetricValue::of($this->reader->countSigningRequestsCreated($period)), + 'signatures.completed' => MetricValue::of($this->reader->countSigningRequestsCompleted($period)), + 'signatures.rejected' => MetricValue::of($this->reader->countSigningRequestsRejected($period)), + 'participants.observers_added' => MetricValue::of($this->reader->countObserversAdded($period)), + ]; + } +} diff --git a/lib/Service/UsageStatistics/Contract/IMetricCollector.php b/lib/Service/UsageStatistics/Contract/IMetricCollector.php new file mode 100644 index 0000000000..cf28705473 --- /dev/null +++ b/lib/Service/UsageStatistics/Contract/IMetricCollector.php @@ -0,0 +1,30 @@ + + */ + public function metricIds(): array; + + /** + * Must answer every id from metricIds(), using MetricValue::unavailable() + * when the value cannot be known for the period. + * + * @return array + */ + public function collect(ReportPeriod $period, ReportMode $mode): array; +} diff --git a/lib/Service/UsageStatistics/Model/CollectedMetric.php b/lib/Service/UsageStatistics/Model/CollectedMetric.php new file mode 100644 index 0000000000..42e88dbeb7 --- /dev/null +++ b/lib/Service/UsageStatistics/Model/CollectedMetric.php @@ -0,0 +1,29 @@ + $this->category, + 'key' => $this->key, + 'type' => $this->type, + 'value' => $this->value, + ]; + } +} diff --git a/lib/Service/UsageStatistics/Model/MetricDefinition.php b/lib/Service/UsageStatistics/Model/MetricDefinition.php new file mode 100644 index 0000000000..05af7bb05c --- /dev/null +++ b/lib/Service/UsageStatistics/Model/MetricDefinition.php @@ -0,0 +1,33 @@ +category . '.' . $this->key; + } + + public function isSnapshot(): bool { + return $this->kind === self::KIND_SNAPSHOT; + } +} diff --git a/lib/Service/UsageStatistics/Model/MetricValue.php b/lib/Service/UsageStatistics/Model/MetricValue.php new file mode 100644 index 0000000000..ab30cd778a --- /dev/null +++ b/lib/Service/UsageStatistics/Model/MetricValue.php @@ -0,0 +1,37 @@ +available; + } + + public function value(): int|bool|string|null { + return $this->value; + } +} diff --git a/lib/Service/UsageStatistics/Model/ReportMode.php b/lib/Service/UsageStatistics/Model/ReportMode.php new file mode 100644 index 0000000000..20fd38bee0 --- /dev/null +++ b/lib/Service/UsageStatistics/Model/ReportMode.php @@ -0,0 +1,16 @@ +setTimezone(new \DateTimeZone('UTC')); + $start = $utc->setDate((int)$utc->format('Y'), (int)$utc->format('n'), 1)->setTime(0, 0); + return new self($start, $start->modify('+1 month')); + } + + public function start(): \DateTimeImmutable { + return $this->start; + } + + public function end(): \DateTimeImmutable { + return $this->end; + } + + public function contains(\DateTimeInterface $instant): bool { + return $instant >= $this->start && $instant < $this->end; + } + + public function next(): self { + return new self($this->end, $this->end->modify('+1 month')); + } +} diff --git a/lib/Service/UsageStatistics/Model/UsageReport.php b/lib/Service/UsageStatistics/Model/UsageReport.php new file mode 100644 index 0000000000..6fe8f0c95b --- /dev/null +++ b/lib/Service/UsageStatistics/Model/UsageReport.php @@ -0,0 +1,22 @@ + $metrics + */ + public function __construct( + public readonly string $application, + public readonly int $schemaVersion, + public readonly ReportPeriod $period, + public readonly array $metrics, + ) { + } +} diff --git a/lib/Service/UsageStatistics/Source/RuntimeEnvironment.php b/lib/Service/UsageStatistics/Source/RuntimeEnvironment.php new file mode 100644 index 0000000000..c0f3c50d11 --- /dev/null +++ b/lib/Service/UsageStatistics/Source/RuntimeEnvironment.php @@ -0,0 +1,74 @@ +serverVersion->getMajorVersion(), + $this->serverVersion->getMinorVersion(), + $this->serverVersion->getPatchVersion(), + ); + } + + public function phpVersion(): string { + return PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION; + } + + public function osFamily(): string { + return PHP_OS_FAMILY; + } + + /** Machine hardware name only; never the hostname or the kernel string. */ + public function machine(): string { + return php_uname('m'); + } + + /** + * @return IDBConnection::PLATFORM_* + */ + public function databasePlatform(): string { + return $this->db->getDatabaseProvider(true); + } + + /** + * The server version string as reported by the database, or null when the + * platform offers no portable way to read it. + */ + public function databaseServerVersion(): ?string { + $query = match ($this->databasePlatform()) { + IDBConnection::PLATFORM_MYSQL, IDBConnection::PLATFORM_MARIADB => 'SELECT VERSION()', + IDBConnection::PLATFORM_POSTGRES => 'SHOW server_version', + IDBConnection::PLATFORM_SQLITE => 'SELECT sqlite_version()', + default => null, + }; + if ($query === null) { + return null; + } + $result = $this->db->executeQuery($query); + $version = $result->fetchOne(); + $result->closeCursor(); + return is_string($version) ? $version : null; + } +} diff --git a/lib/Service/UsageStatistics/Source/UsageActivityReader.php b/lib/Service/UsageStatistics/Source/UsageActivityReader.php new file mode 100644 index 0000000000..b7d099aa41 --- /dev/null +++ b/lib/Service/UsageStatistics/Source/UsageActivityReader.php @@ -0,0 +1,160 @@ +db->getQueryBuilder(); + $qb->select($qb->func()->min('created_at')) + ->from('libresign_file'); + return $this->fetchDateTime($qb); + } + + /** When the first certificate of any type was recorded. */ + public function firstCertificateRecordedAt(): ?\DateTimeImmutable { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->min('issued_at')) + ->from('libresign_crl'); + return $this->fetchDateTime($qb); + } + + public function countStandaloneFilesCreated(ReportPeriod $period): int { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('*')) + ->from('libresign_file', 'f') + ->where($qb->expr()->isNull('f.parent_file_id')) + ->andWhere($qb->expr()->neq('f.node_type', $qb->createNamedParameter(NodeType::ENVELOPE->value))) + ->andWhere($this->notExists($qb, $this->childFiles())) + ->andWhere($this->notExists($qb, $this->identificationDocuments('f.id'))); + $this->whereInPeriod($qb, 'f.created_at', $period); + return $this->fetchCount($qb); + } + + public function countEnvelopesCreated(ReportPeriod $period): int { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('*')) + ->from('libresign_file', 'f') + ->where($qb->expr()->isNull('f.parent_file_id')) + ->andWhere($qb->expr()->orX( + $qb->expr()->eq('f.node_type', $qb->createNamedParameter(NodeType::ENVELOPE->value)), + $qb->createFunction('EXISTS (' . $this->childFiles()->getSQL() . ')'), + )); + $this->whereInPeriod($qb, 'f.created_at', $period); + return $this->fetchCount($qb); + } + + public function countSigningRequestsCreated(ReportPeriod $period): int { + return $this->countSignRequests($period, 'sr.created_at', ParticipantRole::SIGNER); + } + + public function countSigningRequestsCompleted(ReportPeriod $period): int { + return $this->countSignRequests($period, 'sr.signed', ParticipantRole::SIGNER); + } + + public function countSigningRequestsRejected(ReportPeriod $period): int { + return $this->countSignRequests($period, 'sr.rejected_at', ParticipantRole::SIGNER); + } + + public function countObserversAdded(ReportPeriod $period): int { + return $this->countSignRequests($period, 'sr.created_at', ParticipantRole::OBSERVER); + } + + public function countSigningCertificatesIssued(ReportPeriod $period): int { + return $this->countSigningCertificates($period, 'issued_at'); + } + + public function countSigningCertificatesRevoked(ReportPeriod $period): int { + return $this->countSigningCertificates($period, 'revoked_at'); + } + + private function countSignRequests(ReportPeriod $period, string $eventColumn, ParticipantRole $role): int { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('*')) + ->from('libresign_sign_request', 'sr') + ->innerJoin('sr', 'libresign_file', 'f', $qb->expr()->eq('f.id', 'sr.file_id')) + ->where($qb->expr()->isNull('f.parent_file_id')) + ->andWhere($qb->expr()->eq('sr.participant_role', $qb->createNamedParameter($role->value))) + ->andWhere($this->notExists($qb, $this->identificationDocuments('sr.file_id'))); + $this->whereInPeriod($qb, $eventColumn, $period); + return $this->fetchCount($qb); + } + + private function countSigningCertificates(ReportPeriod $period, string $eventColumn): int { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('*')) + ->from('libresign_crl') + ->where($qb->expr()->eq('certificate_type', $qb->createNamedParameter(CertificateType::LEAF->value))); + $this->whereInPeriod($qb, $eventColumn, $period); + return $this->fetchCount($qb); + } + + private function childFiles(): IQueryBuilder { + $sub = $this->db->getQueryBuilder(); + return $sub->select($sub->expr()->literal(1)) + ->from('libresign_file', 'child') + ->where($sub->expr()->eq('child.parent_file_id', 'f.id')); + } + + private function identificationDocuments(string $fileIdColumn): IQueryBuilder { + $sub = $this->db->getQueryBuilder(); + return $sub->select($sub->expr()->literal(1)) + ->from('libresign_id_docs', 'idd') + ->where($sub->expr()->eq('idd.file_id', $fileIdColumn)); + } + + private function notExists(IQueryBuilder $outer, IQueryBuilder $subQuery): string { + return (string)$outer->createFunction('NOT EXISTS (' . $subQuery->getSQL() . ')'); + } + + private function whereInPeriod(IQueryBuilder $qb, string $column, ReportPeriod $period): void { + $qb->andWhere($qb->expr()->gte($column, $qb->createNamedParameter($period->start(), IQueryBuilder::PARAM_DATETIME_IMMUTABLE))) + ->andWhere($qb->expr()->lt($column, $qb->createNamedParameter($period->end(), IQueryBuilder::PARAM_DATETIME_IMMUTABLE))); + } + + private function fetchCount(IQueryBuilder $qb): int { + $result = $qb->executeQuery(); + $count = $result->fetchOne(); + $result->closeCursor(); + return (int)$count; + } + + private function fetchDateTime(IQueryBuilder $qb): ?\DateTimeImmutable { + $result = $qb->executeQuery(); + $value = $result->fetchOne(); + $result->closeCursor(); + if (!is_string($value) || $value === '') { + return null; + } + return new \DateTimeImmutable($value, new \DateTimeZone('UTC')); + } +} diff --git a/lib/Service/UsageStatistics/UsageReportBuilder.php b/lib/Service/UsageStatistics/UsageReportBuilder.php new file mode 100644 index 0000000000..9353946b0f --- /dev/null +++ b/lib/Service/UsageStatistics/UsageReportBuilder.php @@ -0,0 +1,143 @@ + $collectorClasses + */ + public function __construct( + private ContainerInterface $container, + private UsageActivityReader $activityReader, + private UsageStatisticsSchema $schema, + private array $collectorClasses = UsageStatisticsCollectors::ALL, + ) { + } + + public function build(ReportPeriod $period, ReportMode $mode): UsageReport { + $values = []; + foreach ($this->collectorsByMetric() as $collector) { + $answers = $collector->collect($period, $mode); + $expected = $collector->metricIds(); + $missing = array_diff($expected, array_keys($answers)); + $unexpected = array_diff(array_keys($answers), $expected); + if ($missing !== [] || $unexpected !== []) { + throw new UsageStatisticsException(sprintf( + 'Collector %s answered unexpected metrics (missing: %s; unexpected: %s)', + $collector::class, + implode(', ', $missing), + implode(', ', $unexpected), + )); + } + $values += $answers; + } + + $metrics = []; + foreach ($this->schema->definitions() as $definition) { + $value = $values[$definition->id()]; + if (!$value instanceof MetricValue) { + throw new UsageStatisticsException('Invalid answer for ' . $definition->id()); + } + $metric = $this->toCollectedMetric($definition, $value, $mode); + if ($metric !== null) { + $metrics[] = $metric; + } + } + + if ($metrics === []) { + throw new UsageStatisticsException('A usage report needs at least one metric'); + } + + return new UsageReport($this->schema->application(), $this->schema->version(), $period, $metrics); + } + + /** The month of the first LibreSign activity, where the historical series starts. */ + public function firstActivityPeriod(): ?ReportPeriod { + $firstActivityAt = $this->activityReader->firstActivityAt(); + return $firstActivityAt === null ? null : ReportPeriod::monthContaining($firstActivityAt); + } + + /** + * Resolves the collectors and checks that every schema metric has exactly one. + * + * @return array keyed by collector class + */ + private function collectorsByMetric(): array { + $collectors = []; + $owners = []; + foreach ($this->collectorClasses as $collectorClass) { + $collector = $this->container->get($collectorClass); + if (!$collector instanceof IMetricCollector) { + throw new UsageStatisticsException('Invalid usage statistics collector: ' . $collectorClass); + } + foreach ($collector->metricIds() as $metricId) { + $this->schema->get($metricId); + if (isset($owners[$metricId])) { + throw new UsageStatisticsException('Metric has more than one collector: ' . $metricId); + } + $owners[$metricId] = $collectorClass; + } + $collectors[$collectorClass] = $collector; + } + + foreach ($this->schema->definitions() as $definition) { + if (!isset($owners[$definition->id()])) { + throw new UsageStatisticsException('Metric has no collector: ' . $definition->id()); + } + } + + return $collectors; + } + + private function toCollectedMetric(MetricDefinition $definition, MetricValue $value, ReportMode $mode): ?CollectedMetric { + if (!$value->isAvailable()) { + if ($definition->required) { + throw new UsageStatisticsException('Required metric is unavailable: ' . $definition->id()); + } + return null; + } + if ($mode === ReportMode::HISTORICAL && $definition->isSnapshot()) { + throw new UsageStatisticsException('A current snapshot cannot be reported for a past period: ' . $definition->id()); + } + + $raw = $value->value(); + $valid = match ($definition->type) { + 'integer' => is_int($raw) && $raw >= 0, + 'boolean' => is_bool($raw), + 'string' => is_string($raw) && $raw !== '' && strlen($raw) <= self::MAX_STRING_BYTES && mb_check_encoding($raw, 'UTF-8'), + default => false, + }; + if (!$valid || $raw === null) { + throw new UsageStatisticsException('Invalid value for ' . $definition->id()); + } + + return new CollectedMetric($definition->category, $definition->key, $definition->type, $raw); + } +} diff --git a/lib/Service/UsageStatistics/UsageStatisticsCollectors.php b/lib/Service/UsageStatistics/UsageStatisticsCollectors.php new file mode 100644 index 0000000000..721ea4134d --- /dev/null +++ b/lib/Service/UsageStatistics/UsageStatisticsCollectors.php @@ -0,0 +1,26 @@ +> */ + public const ALL = [ + EnvironmentCollector::class, + LibreSignConfigurationCollector::class, + CertificateActivityCollector::class, + DocumentActivityCollector::class, + SigningActivityCollector::class, + ]; +} diff --git a/lib/Service/UsageStatistics/UsageStatisticsException.php b/lib/Service/UsageStatistics/UsageStatisticsException.php new file mode 100644 index 0000000000..bac5b28e40 --- /dev/null +++ b/lib/Service/UsageStatistics/UsageStatisticsException.php @@ -0,0 +1,16 @@ + */ + private array $definitions = []; + + /** + * @param array|null $data a decoded schema; null reads the one shipped with the app + */ + public function __construct(?array $data = null) { + $data ??= self::readShippedSchema(); + + $unknownFields = array_diff(array_keys($data), ['application', 'schemaVersion', 'metrics']); + if ($unknownFields !== []) { + throw new UsageStatisticsException('Unknown schema field: ' . implode(', ', $unknownFields)); + } + if (($data['application'] ?? null) !== Application::APP_ID) { + throw new UsageStatisticsException('Schema application must be ' . Application::APP_ID); + } + $version = $data['schemaVersion'] ?? null; + if (!is_int($version) || $version < 1) { + throw new UsageStatisticsException('Schema version must be a positive integer'); + } + $metrics = $data['metrics'] ?? null; + if (!is_array($metrics) || !array_is_list($metrics) || $metrics === [] || count($metrics) > self::MAX_METRICS) { + throw new UsageStatisticsException('Schema must define between 1 and ' . self::MAX_METRICS . ' metrics'); + } + + foreach ($metrics as $metric) { + $definition = self::parseMetric($metric); + if (isset($this->definitions[$definition->id()])) { + throw new UsageStatisticsException('Duplicate metric: ' . $definition->id()); + } + $this->definitions[$definition->id()] = $definition; + } + $this->application = Application::APP_ID; + $this->version = $version; + } + + public static function load(): self { + return new self(); + } + + public static function fromArray(array $data): self { + return new self($data); + } + + private static function readShippedSchema(): array { + $path = dirname(__DIR__, 3) . '/appinfo/usage-statistics/schema-v' . self::VERSION . '.json'; + $content = is_readable($path) ? file_get_contents($path) : false; + if ($content === false) { + throw new UsageStatisticsException('Usage statistics schema not found: ' . $path); + } + try { + $data = json_decode($content, true, 16, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new UsageStatisticsException('Usage statistics schema is not valid JSON', 0, $e); + } + if (!is_array($data)) { + throw new UsageStatisticsException('Usage statistics schema must be a JSON object'); + } + return $data; + } + + private static function parseMetric(mixed $metric): MetricDefinition { + if (!is_array($metric)) { + throw new UsageStatisticsException('Each metric must be an object'); + } + $fields = array_keys($metric); + sort($fields); + $expected = self::METRIC_FIELDS; + sort($expected); + if ($fields !== $expected) { + throw new UsageStatisticsException('A metric must have exactly the fields: ' . implode(', ', self::METRIC_FIELDS)); + } + foreach (['category', 'key'] as $name) { + if (!is_string($metric[$name]) || preg_match(self::NAME_PATTERN, $metric[$name]) !== 1) { + throw new UsageStatisticsException('Invalid metric ' . $name); + } + } + if (!in_array($metric['type'], self::TYPES, true) + || !in_array($metric['kind'], self::KINDS, true) + || !in_array($metric['aggregation'], self::AGGREGATIONS, true)) { + throw new UsageStatisticsException('Invalid type, kind or aggregation for ' . $metric['category'] . '.' . $metric['key']); + } + if ($metric['aggregation'] === 'numerical' && !in_array($metric['type'], ['integer', 'number'], true)) { + throw new UsageStatisticsException('Numerical aggregation requires a numeric type'); + } + if (!is_string($metric['description']) || strlen($metric['description']) > self::MAX_DESCRIPTION_BYTES) { + throw new UsageStatisticsException('Invalid metric description'); + } + if (!is_bool($metric['required'])) { + throw new UsageStatisticsException('Metric required flag must be a boolean'); + } + + return new MetricDefinition( + $metric['category'], + $metric['key'], + $metric['type'], + $metric['kind'], + $metric['aggregation'], + $metric['description'], + $metric['required'], + ); + } + + public function application(): string { + return $this->application; + } + + public function version(): int { + return $this->version; + } + + /** @return list */ + public function definitions(): array { + return array_values($this->definitions); + } + + public function get(string $metricId): MetricDefinition { + return $this->definitions[$metricId] + ?? throw new UsageStatisticsException('Unknown usage statistics metric: ' . $metricId); + } +} diff --git a/tests/php/Integration/Service/UsageStatistics/Source/UsageActivityReaderTest.php b/tests/php/Integration/Service/UsageStatistics/Source/UsageActivityReaderTest.php new file mode 100644 index 0000000000..63e883de5f --- /dev/null +++ b/tests/php/Integration/Service/UsageStatistics/Source/UsageActivityReaderTest.php @@ -0,0 +1,191 @@ +fileMapper = Server::get(FileMapper::class); + $this->signRequestMapper = Server::get(SignRequestMapper::class); + $this->crlMapper = Server::get(CrlMapper::class); + $this->reader = Server::get(UsageActivityReader::class); + $this->deleteCertificates(); + } + + public function tearDown(): void { + $this->deleteCertificates(); + parent::tearDown(); + } + + public function testNothingRecordedYet(): void { + $this->assertNull($this->reader->firstActivityAt()); + $this->assertNull($this->reader->firstCertificateRecordedAt()); + $this->assertSame(0, $this->reader->countStandaloneFilesCreated($this->august())); + } + + public function testCountsEachEventOnceInsideTheStartInclusiveEndExclusivePeriod(): void { + $this->seedActivity(); + + $august = $this->august(); + $this->assertSame(1, $this->reader->countStandaloneFilesCreated($august), 'files'); + $this->assertSame(2, $this->reader->countEnvelopesCreated($august), 'envelopes'); + $this->assertSame(4, $this->reader->countSigningRequestsCreated($august), 'requests'); + $this->assertSame(3, $this->reader->countSigningRequestsCompleted($august), 'completed'); + $this->assertSame(0, $this->reader->countSigningRequestsRejected($august), 'rejected'); + $this->assertSame(1, $this->reader->countObserversAdded($august), 'observers'); + + $september = $august->next(); + $this->assertSame(1, $this->reader->countStandaloneFilesCreated($september), 'files in September'); + $this->assertSame(1, $this->reader->countSigningRequestsRejected($september), 'rejected in September'); + } + + public function testAPeriodWithoutActivityCountsZero(): void { + $this->seedActivity(); + + $october = $this->august()->next()->next(); + $this->assertSame(0, $this->reader->countStandaloneFilesCreated($october)); + $this->assertSame(0, $this->reader->countEnvelopesCreated($october)); + $this->assertSame(0, $this->reader->countSigningRequestsCreated($october)); + $this->assertSame(0, $this->reader->countSigningRequestsCompleted($october)); + $this->assertSame(0, $this->reader->countObserversAdded($october)); + $this->assertSame(0, $this->reader->countSigningCertificatesIssued($october)); + } + + public function testTheFirstActivityIsTheOldestLibreSignFile(): void { + $this->seedActivity(); + + $this->assertSame('2026-07-31 23:59:59', $this->reader->firstActivityAt()?->format('Y-m-d H:i:s')); + } + + public function testOnlySigningCertificatesAreCounted(): void { + $this->insertCertificate(CertificateType::ROOT, '2026-08-01 08:00:00'); + $this->insertCertificate(CertificateType::LEAF, '2026-08-02 10:00:00'); + $this->insertCertificate(CertificateType::LEAF, '2026-08-20 10:00:00', '2026-08-25 10:00:00'); + $this->insertCertificate(CertificateType::LEAF, '2026-09-01 00:00:00'); + $this->insertCertificate(CertificateType::INTERMEDIATE, '2026-08-03 00:00:00', '2026-08-04 00:00:00'); + + $this->assertSame(2, $this->reader->countSigningCertificatesIssued($this->august())); + $this->assertSame(1, $this->reader->countSigningCertificatesRevoked($this->august())); + $this->assertSame('2026-08-01 08:00:00', $this->reader->firstCertificateRecordedAt()?->format('Y-m-d H:i:s')); + } + + /** + * August 2026: one standalone file, one envelope with two files, one + * envelope stored before node_type existed, one identification document. + */ + private function seedActivity(): void { + $standalone = $this->insertFile('2026-08-01 00:00:00'); + $this->insertSignRequest($standalone, '2026-08-01 00:00:00', signed: '2026-08-31 23:59:59'); + $this->insertSignRequest($standalone, '2026-08-05 00:00:00', rejected: '2026-09-01 00:00:00'); + $this->insertSignRequest($standalone, '2026-08-05 00:00:00', role: ParticipantRole::OBSERVER); + + $this->insertFile('2026-09-01 00:00:00'); + $this->insertFile('2026-07-31 23:59:59'); + + $envelope = $this->insertFile('2026-08-10 00:00:00', NodeType::ENVELOPE); + foreach (['2026-08-10 00:00:01', '2026-08-10 00:00:02'] as $createdAt) { + $child = $this->insertFile($createdAt, NodeType::FILE, $envelope); + $this->insertSignRequest($child, $createdAt, signed: '2026-08-12 00:00:00'); + $this->insertSignRequest($child, $createdAt, signed: '2026-08-12 00:00:00'); + } + $this->insertSignRequest($envelope, '2026-08-10 00:00:00', signed: '2026-08-12 00:00:00'); + $this->insertSignRequest($envelope, '2026-08-10 00:00:00', signed: '2026-08-12 00:00:00'); + + $legacyEnvelope = $this->insertFile('2026-08-11 00:00:00'); + $this->insertFile('2026-08-11 00:00:01', NodeType::FILE, $legacyEnvelope); + + $identificationDocument = $this->insertFile('2026-08-15 00:00:00'); + $idDocSignRequest = $this->insertSignRequest($identificationDocument, '2026-08-15 00:00:00', signed: '2026-08-16 00:00:00'); + Server::get(IdDocsMapper::class)->save($identificationDocument->getId(), $idDocSignRequest->getId(), 'usage-user', 'IDENTIFICATION'); + } + + private function insertFile(string $createdAt, NodeType $nodeType = NodeType::FILE, ?File $parent = null): File { + $file = new File(); + $file->setNodeId(900000 + ++$this->sequence); + $file->setUserId('usage-user'); + $file->setUuid(sprintf('00000000-0000-4000-8000-%012d', $this->sequence)); + $file->setCreatedAt(new \DateTime($createdAt, new \DateTimeZone('UTC'))); + $file->setName('usage-' . $this->sequence . '.pdf'); + $file->setStatus(FileStatus::DRAFT->value); + $file->setNodeTypeEnum($nodeType); + $file->setParentFileId($parent?->getId()); + return $this->fileMapper->insert($file); + } + + private function insertSignRequest( + File $file, + string $createdAt, + ?string $signed = null, + ?string $rejected = null, + ParticipantRole $role = ParticipantRole::SIGNER, + ): SignRequest { + $utc = new \DateTimeZone('UTC'); + $signRequest = new SignRequest(); + $signRequest->setFileId($file->getId()); + $signRequest->setDisplayName('Signer ' . ++$this->sequence); + $signRequest->setUuid(sprintf('11111111-0000-4000-8000-%012d', $this->sequence)); + $signRequest->setCreatedAt(new \DateTime($createdAt, $utc)); + $signRequest->setParticipantRole($role->value); + if ($signed !== null) { + $signRequest->setSigned(new \DateTime($signed, $utc)); + } + if ($rejected !== null) { + $signRequest->setRejectedAt(new \DateTime($rejected, $utc)); + } + return $this->signRequestMapper->insert($signRequest); + } + + private function insertCertificate(CertificateType $type, string $issuedAt, ?string $revokedAt = null): void { + $utc = new \DateTimeZone('UTC'); + $certificate = new Crl(); + $certificate->setSerialNumber((string)(700000 + ++$this->sequence)); + $certificate->setOwner('usage-user'); + $certificate->setStatus($revokedAt === null ? CRLStatus::ISSUED : CRLStatus::REVOKED); + $certificate->setIssuedAt(new \DateTime($issuedAt, $utc)); + $certificate->setCertificateType($type->value); + if ($revokedAt !== null) { + $certificate->setRevokedAt(new \DateTime($revokedAt, $utc)); + } + $this->crlMapper->insert($certificate); + } + + private function august(): ReportPeriod { + return ReportPeriod::monthContaining(new \DateTimeImmutable('2026-08-15T00:00:00Z')); + } + + private function deleteCertificates(): void { + Server::get(IDBConnection::class)->getQueryBuilder()->delete('libresign_crl')->executeStatement(); + } +} diff --git a/tests/php/Integration/Service/UsageStatistics/UsageReportBuilderTest.php b/tests/php/Integration/Service/UsageStatistics/UsageReportBuilderTest.php new file mode 100644 index 0000000000..80394d548c --- /dev/null +++ b/tests/php/Integration/Service/UsageStatistics/UsageReportBuilderTest.php @@ -0,0 +1,57 @@ +build($this->period(), ReportMode::CURRENT); + + $reported = array_map(static fn ($metric): string => $metric->category . '.' . $metric->key, $report->metrics); + $this->assertContains('nextcloud.version', $reported); + $this->assertContains('database.type', $reported); + $this->assertContains('database.version', $reported); + $this->assertContains('signatures.requests_created', $reported); + foreach (UsageStatisticsSchema::load()->definitions() as $definition) { + if ($definition->required) { + $this->assertContains($definition->id(), $reported); + } + } + } + + public function testAHistoricalReportHasNoSnapshot(): void { + $report = Server::get(UsageReportBuilder::class)->build($this->period(), ReportMode::HISTORICAL); + + $schema = UsageStatisticsSchema::load(); + foreach ($report->metrics as $metric) { + $this->assertFalse($schema->get($metric->category . '.' . $metric->key)->isSnapshot()); + } + } + + public function testBuildingAReportChangesNothing(): void { + $appConfig = self::getMockAppConfig(); + $before = $appConfig->getAllValues('libresign'); + + Server::get(UsageReportBuilder::class)->build($this->period(), ReportMode::CURRENT); + + $this->assertSame($before, $appConfig->getAllValues('libresign')); + } + + private function period(): ReportPeriod { + return ReportPeriod::monthContaining(new \DateTimeImmutable('2026-09-15T00:00:00Z')); + } +} diff --git a/tests/php/Unit/Service/UsageStatistics/Collector/CertificateActivityCollectorTest.php b/tests/php/Unit/Service/UsageStatistics/Collector/CertificateActivityCollectorTest.php new file mode 100644 index 0000000000..e0aee0bbdf --- /dev/null +++ b/tests/php/Unit/Service/UsageStatistics/Collector/CertificateActivityCollectorTest.php @@ -0,0 +1,92 @@ +reader = $this->createMock(UsageActivityReader::class); + $this->reader->method('countSigningCertificatesIssued')->willReturn(4); + $this->reader->method('countSigningCertificatesRevoked')->willReturn(1); + } + + #[DataProvider('availabilityProvider')] + public function testCertificatesAreReportedOnlyWhenTheirHistoryIsComplete( + ?string $firstActivityAt, + ?string $firstCertificateAt, + string $periodMonth, + bool $available, + ): void { + $this->reader->method('firstActivityAt')->willReturn($this->date($firstActivityAt)); + $this->reader->method('firstCertificateRecordedAt')->willReturn($this->date($firstCertificateAt)); + + $values = (new CertificateActivityCollector($this->reader))->collect( + ReportPeriod::monthContaining(new \DateTimeImmutable($periodMonth)), + ReportMode::HISTORICAL, + ); + + $this->assertSame($available, $values['certificate.certificates_issued']->isAvailable()); + $this->assertSame($available, $values['certificate.certificates_revoked']->isAvailable()); + if ($available) { + $this->assertSame(4, $values['certificate.certificates_issued']->value()); + $this->assertSame(1, $values['certificate.certificates_revoked']->value()); + } + } + + public static function availabilityProvider(): array { + return [ + 'fresh install: the root CA is older than the first file' => [ + '2026-03-10T12:00:00Z', '2026-03-10T09:00:00Z', '2026-03-01T00:00:00Z', true, + ], + 'certificates recorded since the first activity, later month' => [ + '2026-03-10T12:00:00Z', '2026-03-10T12:00:00Z', '2026-07-01T00:00:00Z', true, + ], + 'upgraded instance: month before the first recorded certificate' => [ + '2024-01-05T00:00:00Z', '2025-10-28T15:00:00Z', '2025-09-01T00:00:00Z', false, + ], + 'upgraded instance: the month of the upgrade is incomplete' => [ + '2024-01-05T00:00:00Z', '2025-10-28T15:00:00Z', '2025-10-01T00:00:00Z', false, + ], + 'upgraded instance: first month that starts after the first record' => [ + '2024-01-05T00:00:00Z', '2025-10-28T15:00:00Z', '2025-11-01T00:00:00Z', true, + ], + 'activity but no certificate recorded at all' => [ + '2024-01-05T00:00:00Z', null, '2026-08-01T00:00:00Z', false, + ], + 'nothing recorded yet: a known zero' => [ + null, null, '2026-08-01T00:00:00Z', true, + ], + ]; + } + + public function testTheCurrentReportUsesTheSameRule(): void { + $this->reader->method('firstActivityAt')->willReturn($this->date('2024-01-05T00:00:00Z')); + $this->reader->method('firstCertificateRecordedAt')->willReturn($this->date('2025-10-28T15:00:00Z')); + + $values = (new CertificateActivityCollector($this->reader))->collect( + ReportPeriod::monthContaining(new \DateTimeImmutable('2026-08-01T00:00:00Z')), + ReportMode::CURRENT, + ); + + $this->assertSame(4, $values['certificate.certificates_issued']->value()); + } + + private function date(?string $value): ?\DateTimeImmutable { + return $value === null ? null : new \DateTimeImmutable($value); + } +} diff --git a/tests/php/Unit/Service/UsageStatistics/Collector/DocumentActivityCollectorTest.php b/tests/php/Unit/Service/UsageStatistics/Collector/DocumentActivityCollectorTest.php new file mode 100644 index 0000000000..f90785f008 --- /dev/null +++ b/tests/php/Unit/Service/UsageStatistics/Collector/DocumentActivityCollectorTest.php @@ -0,0 +1,39 @@ +createMock(UsageActivityReader::class); + $reader->expects($this->once())->method('countStandaloneFilesCreated')->with($period)->willReturn(7); + $reader->expects($this->once())->method('countEnvelopesCreated')->with($period)->willReturn(0); + + $values = (new DocumentActivityCollector($reader))->collect($period, $mode); + + $this->assertSame(7, $values['documents.files_created']->value()); + $this->assertTrue($values['documents.envelopes_created']->isAvailable()); + $this->assertSame(0, $values['documents.envelopes_created']->value()); + } + + public static function modesProvider(): array { + return [ + 'current' => [ReportMode::CURRENT], + 'historical' => [ReportMode::HISTORICAL], + ]; + } +} diff --git a/tests/php/Unit/Service/UsageStatistics/Collector/EnvironmentCollectorTest.php b/tests/php/Unit/Service/UsageStatistics/Collector/EnvironmentCollectorTest.php new file mode 100644 index 0000000000..bce5af60bb --- /dev/null +++ b/tests/php/Unit/Service/UsageStatistics/Collector/EnvironmentCollectorTest.php @@ -0,0 +1,230 @@ +userManager = $this->createMock(IUserManager::class); + $this->userManager->method('countUsersTotal')->willReturn(42); + $this->userManager->method('getLastLoggedInUsers')->willReturn([]); + $this->timeFactory = $this->createMock(ITimeFactory::class); + $this->timeFactory->method('getTime')->willReturn(self::NOW); + $this->runtime = $this->createMock(RuntimeEnvironment::class); + $this->runtime->method('nextcloudVersion')->willReturn('36.0.1'); + $this->runtime->method('phpVersion')->willReturn('8.3'); + $this->runtime->method('osFamily')->willReturn('Linux'); + $this->runtime->method('machine')->willReturn('x86_64'); + $this->runtime->method('databasePlatform')->willReturn('mariadb'); + } + + public function testAHistoricalReportKnowsNoEnvironmentValue(): void { + $this->userManager->expects($this->never())->method('countUsersTotal'); + $this->runtime->expects($this->never())->method('databaseServerVersion'); + + $values = $this->collector()->collect($this->period(), ReportMode::HISTORICAL); + + $this->assertSame($this->collector()->metricIds(), array_keys($values)); + foreach ($values as $value) { + $this->assertFalse($value->isAvailable()); + } + } + + public function testTheCurrentReportCarriesCoarseEnvironmentValues(): void { + $this->runtime->method('databaseServerVersion')->willReturn('11.4.3-MariaDB-1:11.4.3+maria~ubu2204'); + + $values = $this->values($this->collector()->collect($this->period(), ReportMode::CURRENT)); + + $this->assertSame([ + 'nextcloud.version' => '36.0.1', + 'nextcloud.users_total' => 42, + 'nextcloud.users_active_30d' => 0, + 'nextcloud.background_job_mode' => 'ajax', + 'php.version' => '8.3', + 'database.type' => 'mariadb', + 'database.version' => '11.4', + 'system.os_family' => 'Linux', + 'system.architecture' => 'x86_64', + ], $values); + } + + #[DataProvider('databaseVersionsProvider')] + public function testTheDatabaseVersionKeepsOnlyMajorAndMinor(string $reported, string $expected): void { + $this->runtime->method('databaseServerVersion')->willReturn($reported); + + $values = $this->collector()->collect($this->period(), ReportMode::CURRENT); + + $this->assertSame($expected, $values['database.version']->value()); + } + + public static function databaseVersionsProvider(): array { + return [ + 'postgres with distribution details' => ['16.4 (Debian 16.4-1.pgdg120+2)', '16.4'], + 'mysql' => ['8.0.39', '8.0'], + 'sqlite' => ['3.46.1', '3.46'], + ]; + } + + public function testAnUnreadableDatabaseVersionFailsInsteadOfGuessing(): void { + $this->runtime->method('databaseServerVersion')->willReturn('unknown build'); + + $this->expectException(UsageStatisticsException::class); + + $this->collector()->collect($this->period(), ReportMode::CURRENT); + } + + public function testADatabaseWithoutPortableVersionQueryIsUnavailable(): void { + $this->runtime->method('databaseServerVersion')->willReturn(null); + + $values = $this->collector()->collect($this->period(), ReportMode::CURRENT); + + $this->assertFalse($values['database.version']->isAvailable()); + } + + public function testAUserBackendThatCannotCountMakesTheTotalUnavailable(): void { + $userManager = $this->createMock(IUserManager::class); + $userManager->method('countUsersTotal')->willReturn(false); + $userManager->method('getLastLoggedInUsers')->willReturn([]); + $this->userManager = $userManager; + + $values = $this->collector()->collect($this->period(), ReportMode::CURRENT); + + $this->assertFalse($values['nextcloud.users_total']->isAvailable()); + } + + public function testActiveUsersStopAtTheFirstLoginOutsideTheWindow(): void { + $lastLogins = [ + 'alice' => self::NOW - 3600, + 'bob' => self::NOW - 29 * 86400, + 'carol' => self::NOW - 31 * 86400, + 'dave' => self::NOW - 1, + ]; + $userManager = $this->createMock(IUserManager::class); + $userManager->method('countUsersTotal')->willReturn(4); + $userManager->method('getLastLoggedInUsers')->willReturn(array_keys($lastLogins)); + $userManager->method('get')->willReturnCallback(function (string $userId) use ($lastLogins): IUser { + $user = $this->createMock(IUser::class); + $user->method('getLastLogin')->willReturn($lastLogins[$userId]); + return $user; + }); + $this->userManager = $userManager; + + $values = $this->collector()->collect($this->period(), ReportMode::CURRENT); + + $this->assertSame(2, $values['nextcloud.users_active_30d']->value()); + } + + #[DataProvider('activeUsersBeyondOneBatchProvider')] + public function testActiveUsersAreCountedBeyondTheServerBatchLimit(int $active, int $inactive): void { + $lastLogins = []; + for ($i = 0; $i < $active; $i++) { + $lastLogins['active' . $i] = self::NOW - $i; + } + for ($i = 0; $i < $inactive; $i++) { + $lastLogins['inactive' . $i] = self::NOW - 31 * 86400 - $i; + } + $userManager = $this->createMock(IUserManager::class); + $userManager->method('countUsersTotal')->willReturn($active + $inactive); + // Same contract as Nextcloud's user manager: most recent login first, + // and never more than 100 users per call, whatever the limit asked. + $userManager->method('getLastLoggedInUsers')->willReturnCallback( + static fn (?int $limit = null, int $offset = 0): array => array_slice(array_keys($lastLogins), $offset, min(100, $limit ?: 25)), + ); + $userManager->method('get')->willReturnCallback(function (string $userId) use ($lastLogins): IUser { + $user = $this->createMock(IUser::class); + $user->method('getLastLogin')->willReturn($lastLogins[$userId]); + return $user; + }); + $this->userManager = $userManager; + + $values = $this->collector()->collect($this->period(), ReportMode::CURRENT); + + $this->assertSame($active, $values['nextcloud.users_active_30d']->value()); + } + + public static function activeUsersBeyondOneBatchProvider(): array { + return [ + 'all active, ending on a partial batch' => [250, 0], + 'all active, ending on a full batch' => [200, 0], + 'window ends in the second batch' => [150, 30], + ]; + } + + public function testAnUnknownArchitectureIsReportedAsOther(): void { + $runtime = $this->createMock(RuntimeEnvironment::class); + $runtime->method('nextcloudVersion')->willReturn('36.0.1'); + $runtime->method('phpVersion')->willReturn('8.3'); + $runtime->method('osFamily')->willReturn('Linux'); + $runtime->method('machine')->willReturn('riscv64'); + $runtime->method('databasePlatform')->willReturn('sqlite'); + $runtime->method('databaseServerVersion')->willReturn('3.46.1'); + $this->runtime = $runtime; + + $values = $this->collector()->collect($this->period(), ReportMode::CURRENT); + + $this->assertSame('other', $values['system.architecture']->value()); + } + + #[DataProvider('backgroundJobModesProvider')] + public function testTheBackgroundJobModeIsOneOfTheKnownModes(string $stored, string $expected): void { + $this->runtime->method('databaseServerVersion')->willReturn('8.0.39'); + self::getMockAppConfigWithReset()->setValueString('core', 'backgroundjobs_mode', $stored); + + $values = $this->collector()->collect($this->period(), ReportMode::CURRENT); + + $this->assertSame($expected, $values['nextcloud.background_job_mode']->value()); + } + + public static function backgroundJobModesProvider(): array { + return [ + 'cron' => ['cron', 'cron'], + 'webcron' => ['webcron', 'webcron'], + 'unexpected value' => ['https://cron.example.com/secret', 'other'], + ]; + } + + private function collector(): EnvironmentCollector { + return new EnvironmentCollector( + $this->userManager, + self::getMockAppConfig(), + $this->timeFactory, + $this->runtime, + ); + } + + private function period(): ReportPeriod { + return ReportPeriod::monthContaining(new \DateTimeImmutable('2026-09-15T00:00:00Z')); + } + + /** + * @param array $values + * @return array + */ + private function values(array $values): array { + return array_map(static fn (MetricValue $value) => $value->value(), $values); + } +} diff --git a/tests/php/Unit/Service/UsageStatistics/Collector/LibreSignConfigurationCollectorTest.php b/tests/php/Unit/Service/UsageStatistics/Collector/LibreSignConfigurationCollectorTest.php new file mode 100644 index 0000000000..5fd518ae82 --- /dev/null +++ b/tests/php/Unit/Service/UsageStatistics/Collector/LibreSignConfigurationCollectorTest.php @@ -0,0 +1,130 @@ +appConfig = self::getMockAppConfigWithReset(); + } + + public function tearDown(): void { + if ($this->pkiPath !== '') { + array_map('unlink', glob($this->pkiPath . '/*') ?: []); + rmdir($this->pkiPath); + } + } + + public function testAHistoricalReportKnowsNoConfigurationValue(): void { + $values = $this->collector()->collect($this->period(), ReportMode::HISTORICAL); + + foreach ($values as $value) { + $this->assertFalse($value->isAvailable()); + } + } + + public function testDefaultsMatchWhatLibreSignRuns(): void { + $values = $this->collector()->collect($this->period(), ReportMode::CURRENT); + + $this->assertSame('16.0.0', $values['libresign.version']->value()); + $this->assertSame('JSignPdf', $values['libresign.signature_engine']->value()); + $this->assertSame('sync', $values['libresign.signing_mode']->value()); + $this->assertSame('openssl', $values['certificate.engine']->value()); + $this->assertFalse($values['certificate.root_ca_configured']->value()); + } + + #[DataProvider('storedValuesProvider')] + public function testStoredValuesAreNormalized(string $key, string $stored, string $metricId, string $expected): void { + $this->appConfig->setValueString('libresign', $key, $stored); + + $values = $this->collector()->collect($this->period(), ReportMode::CURRENT); + + $this->assertSame($expected, $values[$metricId]->value()); + } + + public static function storedValuesProvider(): array { + return [ + 'native signature engine' => ['signature_engine', 'PhpNative', 'libresign.signature_engine', 'PhpNative'], + 'unknown signature engine runs JSignPdf' => ['signature_engine', 'Other', 'libresign.signature_engine', 'JSignPdf'], + 'async signing' => ['signing_mode', 'async', 'libresign.signing_mode', 'async'], + 'unknown signing mode runs sync' => ['signing_mode', 'later', 'libresign.signing_mode', 'sync'], + 'cfssl' => ['certificate_engine', 'cfssl', 'certificate.engine', 'cfssl'], + 'no certificate engine' => ['certificate_engine', 'none', 'certificate.engine', 'none'], + 'unknown certificate engine' => ['certificate_engine', 'custom', 'certificate.engine', 'other'], + ]; + } + + #[DataProvider('rootCaProvider')] + public function testTheRootCaIsConfiguredWhenItsFilesExist(string $engine, array $files, bool $expected): void { + $this->pkiPath = sys_get_temp_dir() . '/libresign-usage-pki-' . uniqid('', true); + mkdir($this->pkiPath); + foreach ($files as $file) { + touch($this->pkiPath . '/' . $file); + } + $this->appConfig->setValueString('libresign', 'certificate_engine', $engine); + $this->appConfig->setValueString('libresign', 'config_path', $this->pkiPath); + + $values = $this->collector()->collect($this->period(), ReportMode::CURRENT); + + $this->assertSame($expected, $values['certificate.root_ca_configured']->value()); + } + + public static function rootCaProvider(): array { + return [ + 'openssl with certificate and key' => ['openssl', ['ca.pem', 'ca-key.pem'], true], + 'cfssl with certificate and key' => ['cfssl', ['ca.pem', 'ca-key.pem'], true], + 'certificate without key' => ['openssl', ['ca.pem'], false], + 'no certificate engine' => ['none', ['ca.pem', 'ca-key.pem'], false], + ]; + } + + public function testAMissingPkiDirectoryIsNotCreated(): void { + $missingPath = sys_get_temp_dir() . '/libresign-usage-missing-' . uniqid('', true); + $this->appConfig->setValueString('libresign', 'config_path', $missingPath); + + $values = $this->collector()->collect($this->period(), ReportMode::CURRENT); + + $this->assertFalse($values['certificate.root_ca_configured']->value()); + $this->assertDirectoryDoesNotExist($missingPath); + } + + public function testNoConfigurationDetailBecomesAMetricValue(): void { + $this->pkiPath = sys_get_temp_dir() . '/libresign-usage-pki-' . uniqid('', true); + mkdir($this->pkiPath); + touch($this->pkiPath . '/ca.pem'); + touch($this->pkiPath . '/ca-key.pem'); + $this->appConfig->setValueString('libresign', 'config_path', $this->pkiPath); + + $values = $this->collector()->collect($this->period(), ReportMode::CURRENT); + + foreach ($values as $value) { + $this->assertStringNotContainsString($this->pkiPath, (string)$value->value()); + } + } + + private function collector(): LibreSignConfigurationCollector { + $appManager = $this->createMock(IAppManager::class); + $appManager->method('getAppVersion')->with('libresign')->willReturn('16.0.0'); + return new LibreSignConfigurationCollector($appManager, $this->appConfig); + } + + private function period(): ReportPeriod { + return ReportPeriod::monthContaining(new \DateTimeImmutable('2026-09-15T00:00:00Z')); + } +} diff --git a/tests/php/Unit/Service/UsageStatistics/Collector/SigningActivityCollectorTest.php b/tests/php/Unit/Service/UsageStatistics/Collector/SigningActivityCollectorTest.php new file mode 100644 index 0000000000..4c3c57e38f --- /dev/null +++ b/tests/php/Unit/Service/UsageStatistics/Collector/SigningActivityCollectorTest.php @@ -0,0 +1,44 @@ +createMock(UsageActivityReader::class); + $reader->method('countSigningRequestsCreated')->with($period)->willReturn(5); + $reader->method('countSigningRequestsCompleted')->with($period)->willReturn(4); + $reader->method('countSigningRequestsRejected')->with($period)->willReturn(0); + $reader->method('countObserversAdded')->with($period)->willReturn(2); + + $values = (new SigningActivityCollector($reader))->collect($period, $mode); + + $this->assertSame([ + 'signatures.requests_created' => 5, + 'signatures.completed' => 4, + 'signatures.rejected' => 0, + 'participants.observers_added' => 2, + ], array_map(static fn ($value) => $value->value(), $values)); + } + + public static function modesProvider(): array { + return [ + 'current' => [ReportMode::CURRENT], + 'historical' => [ReportMode::HISTORICAL], + ]; + } +} diff --git a/tests/php/Unit/Service/UsageStatistics/Model/ReportPeriodTest.php b/tests/php/Unit/Service/UsageStatistics/Model/ReportPeriodTest.php new file mode 100644 index 0000000000..41ff91df66 --- /dev/null +++ b/tests/php/Unit/Service/UsageStatistics/Model/ReportPeriodTest.php @@ -0,0 +1,49 @@ +assertSame($start, $period->start()->format(\DateTimeInterface::RFC3339)); + $this->assertSame($end, $period->end()->format(\DateTimeInterface::RFC3339)); + } + + public static function instantsProvider(): array { + return [ + 'middle of the month' => ['2026-08-15T10:00:00+00:00', '2026-08-01T00:00:00+00:00', '2026-09-01T00:00:00+00:00'], + 'first instant belongs to the month' => ['2026-08-01T00:00:00+00:00', '2026-08-01T00:00:00+00:00', '2026-09-01T00:00:00+00:00'], + 'last instant before the next month' => ['2026-08-31T23:59:59+00:00', '2026-08-01T00:00:00+00:00', '2026-09-01T00:00:00+00:00'], + 'local time is converted to UTC first' => ['2026-09-01T01:00:00+03:00', '2026-08-01T00:00:00+00:00', '2026-09-01T00:00:00+00:00'], + 'december rolls over the year' => ['2025-12-20T00:00:00+00:00', '2025-12-01T00:00:00+00:00', '2026-01-01T00:00:00+00:00'], + ]; + } + + public function testBoundariesAreStartInclusiveAndEndExclusive(): void { + $period = ReportPeriod::monthContaining(new \DateTimeImmutable('2026-08-10T00:00:00Z')); + + $this->assertTrue($period->contains(new \DateTimeImmutable('2026-08-01T00:00:00Z'))); + $this->assertTrue($period->contains(new \DateTimeImmutable('2026-08-31T23:59:59Z'))); + $this->assertFalse($period->contains(new \DateTimeImmutable('2026-09-01T00:00:00Z'))); + $this->assertFalse($period->contains(new \DateTimeImmutable('2026-07-31T23:59:59Z'))); + } + + public function testNextReturnsTheFollowingMonth(): void { + $period = ReportPeriod::monthContaining(new \DateTimeImmutable('2026-12-05T00:00:00Z'))->next(); + + $this->assertSame('2027-01-01T00:00:00+00:00', $period->start()->format(\DateTimeInterface::RFC3339)); + $this->assertSame('2027-02-01T00:00:00+00:00', $period->end()->format(\DateTimeInterface::RFC3339)); + } +} diff --git a/tests/php/Unit/Service/UsageStatistics/UsageReportBuilderTest.php b/tests/php/Unit/Service/UsageStatistics/UsageReportBuilderTest.php new file mode 100644 index 0000000000..ed63c6ef60 --- /dev/null +++ b/tests/php/Unit/Service/UsageStatistics/UsageReportBuilderTest.php @@ -0,0 +1,245 @@ +activityReader = $this->createMock(UsageActivityReader::class); + } + + public function testEveryShippedSchemaMetricHasExactlyOneCollector(): void { + $owners = []; + foreach (UsageStatisticsCollectors::ALL as $collectorClass) { + $collector = (new \ReflectionClass($collectorClass))->newInstanceWithoutConstructor(); + foreach ($collector->metricIds() as $metricId) { + $this->assertArrayNotHasKey($metricId, $owners, $metricId . ' has more than one collector'); + $owners[$metricId] = $collectorClass; + } + } + + $schemaIds = array_map( + static fn ($definition): string => $definition->id(), + UsageStatisticsSchema::load()->definitions(), + ); + sort($schemaIds); + $collectedIds = array_keys($owners); + sort($collectedIds); + $this->assertSame($schemaIds, $collectedIds); + } + + public function testBuildsTheMetricsInSchemaOrder(): void { + $builder = $this->builder($this->schema(), [ + $this->collector(['documents.files_created' => MetricValue::of(3), 'php.version' => MetricValue::of('8.3')]), + ]); + + $report = $builder->build($this->period(), ReportMode::CURRENT); + + $this->assertSame('libresign', $report->application); + $this->assertSame(1, $report->schemaVersion); + $this->assertSame([ + ['category' => 'php', 'key' => 'version', 'type' => 'string', 'value' => '8.3'], + ['category' => 'documents', 'key' => 'files_created', 'type' => 'integer', 'value' => 3], + ], array_map(static fn ($metric): array => $metric->toArray(), $report->metrics)); + } + + public function testAZeroCounterIsReportedAsAKnownZero(): void { + $builder = $this->builder($this->schema(), [ + $this->collector(['documents.files_created' => MetricValue::of(0), 'php.version' => MetricValue::unavailable()]), + ]); + + $report = $builder->build($this->period(), ReportMode::HISTORICAL); + + $this->assertCount(1, $report->metrics); + $this->assertSame(0, $report->metrics[0]->value); + } + + public function testAnUnavailableOptionalMetricIsOmitted(): void { + $builder = $this->builder($this->schema(), [ + $this->collector(['documents.files_created' => MetricValue::of(1), 'php.version' => MetricValue::unavailable()]), + ]); + + $report = $builder->build($this->period(), ReportMode::CURRENT); + + $this->assertSame(['files_created'], array_map(static fn ($metric): string => $metric->key, $report->metrics)); + } + + public function testAHistoricalReportNeverCarriesASnapshot(): void { + $builder = $this->builder($this->schema(), [ + $this->collector(['documents.files_created' => MetricValue::of(1), 'php.version' => MetricValue::of('8.3')]), + ]); + + $this->expectException(UsageStatisticsException::class); + $this->expectExceptionMessage('php.version'); + + $builder->build($this->period(), ReportMode::HISTORICAL); + } + + #[DataProvider('unsafeCollectorAnswersProvider')] + public function testAnUnsafeCollectorAnswerFailsTheWholeReport(array $answers): void { + $builder = $this->builder($this->schema(), [$this->collector($answers, ['documents.files_created', 'php.version'])]); + + $this->expectException(UsageStatisticsException::class); + + $builder->build($this->period(), ReportMode::CURRENT); + } + + public static function unsafeCollectorAnswersProvider(): array { + $string = MetricValue::of('8.3'); + return [ + 'a metric is not answered' => [['documents.files_created' => MetricValue::of(1)]], + 'an unexpected metric is answered' => [['documents.files_created' => MetricValue::of(1), 'php.version' => $string, 'php.extra' => $string]], + 'a required metric is unavailable' => [['documents.files_created' => MetricValue::unavailable(), 'php.version' => $string]], + 'an integer is a string' => [['documents.files_created' => MetricValue::of('1'), 'php.version' => $string]], + 'an integer is negative' => [['documents.files_created' => MetricValue::of(-1), 'php.version' => $string]], + 'a string is an integer' => [['documents.files_created' => MetricValue::of(1), 'php.version' => MetricValue::of(8)]], + 'a string is empty' => [['documents.files_created' => MetricValue::of(1), 'php.version' => MetricValue::of('')]], + 'a string is too long' => [['documents.files_created' => MetricValue::of(1), 'php.version' => MetricValue::of(str_repeat('a', 1025))]], + 'a string is not UTF-8' => [['documents.files_created' => MetricValue::of(1), 'php.version' => MetricValue::of("\xff")]], + 'an answer is not a metric value' => [['documents.files_created' => 1, 'php.version' => $string]], + ]; + } + + public function testASchemaMetricWithoutCollectorIsRejected(): void { + $builder = $this->builder($this->schema(), [$this->collector(['documents.files_created' => MetricValue::of(1)])]); + + $this->expectException(UsageStatisticsException::class); + $this->expectExceptionMessage('php.version'); + + $builder->build($this->period(), ReportMode::CURRENT); + } + + public function testAMetricWithTwoCollectorsIsRejected(): void { + $builder = $this->builder($this->schema(), [ + $this->collector(['documents.files_created' => MetricValue::of(1), 'php.version' => MetricValue::of('8.3')]), + $this->collector(['php.version' => MetricValue::of('8.3')]), + ]); + + $this->expectException(UsageStatisticsException::class); + $this->expectExceptionMessage('php.version'); + + $builder->build($this->period(), ReportMode::CURRENT); + } + + public function testACollectorMetricOutsideTheSchemaIsRejected(): void { + $builder = $this->builder($this->schema(), [ + $this->collector(['documents.files_created' => MetricValue::of(1), 'php.version' => MetricValue::of('8.3'), 'php.extensions' => MetricValue::of(3)]), + ]); + + $this->expectException(UsageStatisticsException::class); + $this->expectExceptionMessage('php.extensions'); + + $builder->build($this->period(), ReportMode::CURRENT); + } + + public function testAReportWithoutAnyMetricIsRejected(): void { + $schema = UsageStatisticsSchema::fromArray([ + 'application' => 'libresign', + 'schemaVersion' => 1, + 'metrics' => [$this->metric('php', 'version', 'string', 'snapshot', 'distribution', false)], + ]); + $builder = $this->builder($schema, [$this->collector(['php.version' => MetricValue::unavailable()])]); + + $this->expectException(UsageStatisticsException::class); + + $builder->build($this->period(), ReportMode::HISTORICAL); + } + + public function testTheFirstActivityPeriodIsTheMonthOfTheFirstLibreSignFile(): void { + $this->activityReader->method('firstActivityAt')->willReturn(new \DateTimeImmutable('2025-03-17T08:00:00Z')); + + $period = $this->builder($this->schema(), [])->firstActivityPeriod(); + + $this->assertSame('2025-03-01T00:00:00+00:00', $period?->start()->format(\DateTimeInterface::RFC3339)); + } + + public function testThereIsNoFirstActivityPeriodBeforeAnyActivity(): void { + $this->activityReader->method('firstActivityAt')->willReturn(null); + + $this->assertNull($this->builder($this->schema(), [])->firstActivityPeriod()); + } + + private function period(): ReportPeriod { + return ReportPeriod::monthContaining(new \DateTimeImmutable('2026-08-10T00:00:00Z')); + } + + private function schema(): UsageStatisticsSchema { + return UsageStatisticsSchema::fromArray([ + 'application' => 'libresign', + 'schemaVersion' => 1, + 'metrics' => [ + $this->metric('php', 'version', 'string', 'snapshot', 'distribution', false), + $this->metric('documents', 'files_created', 'integer', 'period', 'numerical', true), + ], + ]); + } + + private function metric(string $category, string $key, string $type, string $kind, string $aggregation, bool $required): array { + return [ + 'category' => $category, + 'key' => $key, + 'type' => $type, + 'kind' => $kind, + 'aggregation' => $aggregation, + 'description' => $key, + 'required' => $required, + ]; + } + + /** + * @param array $answers + * @param list|null $metricIds defaults to the answered ids + */ + private function collector(array $answers, ?array $metricIds = null): IMetricCollector { + return new class($answers, $metricIds ?? array_keys($answers)) implements IMetricCollector { + public function __construct( + private array $answers, + private array $metricIds, + ) { + } + + public function metricIds(): array { + return $this->metricIds; + } + + public function collect(ReportPeriod $period, ReportMode $mode): array { + return $this->answers; + } + }; + } + + /** + * @param list $collectors + */ + private function builder(UsageStatisticsSchema $schema, array $collectors): UsageReportBuilder { + $byClass = []; + foreach ($collectors as $index => $collector) { + $byClass['collector' . $index] = $collector; + } + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnCallback(static fn (string $id) => $byClass[$id]); + + return new UsageReportBuilder($container, $this->activityReader, $schema, array_keys($byClass)); + } +} diff --git a/tests/php/Unit/Service/UsageStatistics/UsageStatisticsSchemaTest.php b/tests/php/Unit/Service/UsageStatistics/UsageStatisticsSchemaTest.php new file mode 100644 index 0000000000..31c56ef78f --- /dev/null +++ b/tests/php/Unit/Service/UsageStatistics/UsageStatisticsSchemaTest.php @@ -0,0 +1,101 @@ +assertSame('libresign', $schema->application()); + $this->assertSame(1, $schema->version()); + $this->assertNotEmpty($schema->definitions()); + } + + public function testTheShippedSchemaFollowsTheFixedRulesOfVersionOne(): void { + foreach (UsageStatisticsSchema::load()->definitions() as $definition) { + if ($definition->isSnapshot()) { + $expectedAggregation = $definition->type === 'integer' ? 'numerical' : 'distribution'; + $this->assertSame($expectedAggregation, $definition->aggregation, $definition->id()); + $this->assertFalse($definition->required, $definition->id() . ' must be optional: historical reports omit snapshots'); + continue; + } + $this->assertSame('period', $definition->kind, $definition->id()); + $this->assertSame('integer', $definition->type, $definition->id()); + $this->assertSame('numerical', $definition->aggregation, $definition->id()); + } + } + + public function testTheShippedSchemaHasTheCategoriesOfTheIssue(): void { + $categories = array_values(array_unique(array_map( + static fn ($definition): string => $definition->category, + UsageStatisticsSchema::load()->definitions(), + ))); + + $this->assertSame( + ['nextcloud', 'php', 'database', 'system', 'libresign', 'certificate', 'documents', 'signatures', 'participants'], + $categories, + ); + } + + #[DataProvider('invalidSchemasProvider')] + public function testInvalidSchemasAreRejected(array $schema): void { + $this->expectException(UsageStatisticsException::class); + + UsageStatisticsSchema::fromArray($schema); + } + + public static function invalidSchemasProvider(): array { + $metric = [ + 'category' => 'documents', + 'key' => 'files_created', + 'type' => 'integer', + 'kind' => 'period', + 'aggregation' => 'numerical', + 'description' => 'Files', + 'required' => true, + ]; + $schema = static fn (array $metrics, array $extra = []): array => array_merge( + ['application' => 'libresign', 'schemaVersion' => 1, 'metrics' => $metrics], + $extra, + ); + return [ + 'unknown top-level field' => [$schema([$metric], ['source' => 'x'])], + 'unknown metric field' => [$schema([array_merge($metric, ['source' => 'SignRequest.createdAt'])])], + 'missing metric field' => [$schema([array_diff_key($metric, ['kind' => true])])], + 'no metrics' => [$schema([])], + 'wrong application' => [array_merge($schema([$metric]), ['application' => 'other'])], + 'schema version below one' => [array_merge($schema([$metric]), ['schemaVersion' => 0])], + 'key with a slash' => [$schema([array_merge($metric, ['key' => 'files/created'])])], + 'unknown type' => [$schema([array_merge($metric, ['type' => 'float'])])], + 'unknown kind' => [$schema([array_merge($metric, ['kind' => 'gauge'])])], + 'numerical string' => [$schema([array_merge($metric, ['type' => 'string', 'kind' => 'snapshot'])])], + 'required is not a boolean' => [$schema([array_merge($metric, ['required' => 'yes'])])], + 'description too long' => [$schema([array_merge($metric, ['description' => str_repeat('a', 513)])])], + 'duplicate metric' => [$schema([$metric, $metric])], + ]; + } + + public function testDefinitionsAreIndexedByCategoryAndKey(): void { + $definition = UsageStatisticsSchema::load()->get('signatures.completed'); + + $this->assertSame('signatures', $definition->category); + $this->assertSame('completed', $definition->key); + } + + public function testAnUnknownMetricIsRejected(): void { + $this->expectException(UsageStatisticsException::class); + + UsageStatisticsSchema::load()->get('signatures.unknown'); + } +}