diff --git a/docs/reference/classes.md b/docs/reference/classes.md index d9b2cb6..ade3077 100644 --- a/docs/reference/classes.md +++ b/docs/reference/classes.md @@ -314,7 +314,7 @@ it there at all. Refuses cloning outright. A clone would be a second, unaudited reference to the same plaintext with an -independent lifetime – and, since the vault is keyed by object id rather than +independent lifetime – and, since the vault is keyed by object ID rather than copied, a bare `clone` would otherwise leave the clone's reveal() silently reading nothing. @@ -358,13 +358,12 @@ public function __construct( string $name, $value, string $fingerprint ) #### `WP_Secret::__debugInfo()` -Masks the instance for var_dump(). +Masks the instance for var_dump() and print_r(). -Note that print_r() and var_export() have no equivalent hook. They are safe -regardless, because the plaintext is never a declared property for them to -enumerate; they will surface the (non-sensitive) name and fingerprint rather -than this placeholder. See docs/journal/open-questions.md for the documented -var_export() limitation. +Note that var_export() does not use this hook. It does not expose plaintext +from the vault, but it enumerates all instance properties, including the +unmasked WP_Error reason on a withheld secret. Use a masked output path +instead when the reason must remain private. ```php public function __debugInfo(): array @@ -1081,7 +1080,7 @@ Master keys are derived from the root key on demand and never stored: - Site scope, blog N: sodium_crypto_kdf_derive_from_key( 32, N, 'wpsecsit', $root ). Distinct per blog, which shared network-wide salts otherwise cannot provide. - Network scope: sodium_crypto_kdf_derive_from_key( 32, 0, 'wpsecnet', $root ). - Subkey id 0 is reserved for this (blog ids start at 1) and the context string + Subkey ID 0 is reserved for this (blog IDs start at 1) and the context string differs from the site path, so there is no collision between the two. Identical on every blog, so a network secret written on one blog reads on every other. @@ -1101,7 +1100,7 @@ keyring (a KMS or HSM call) is invoked once per request, not once per secret. | Constant | Value | Description | |---|---|---| | `NETWORK_KDF_CONTEXT` | `'wpsecnet'` | KDF context for deriving the network-scope master key. Exactly 8 bytes. | -| `NETWORK_SUBKEY_ID` | `0` | Reserved KDF subkey id for network scope. | +| `NETWORK_SUBKEY_ID` | `0` | Reserved KDF subkey ID for network scope. | | `ROOT_KEY_OPTION` | `'_wp_secrets_root_key'` | Option name the wrapped root key is stored under. | | `SITE_KDF_CONTEXT` | `'wpsecsit'` | KDF context for deriving a site-scope master key. Exactly 8 bytes. | @@ -1148,9 +1147,9 @@ public function get_master_key( string $scope, ?int $site_id = null ) | Parameter | Type | Description | |---|---|---| | `$scope` | `string` | 'site' or 'network'. | -| `$site_id` | `int\|null` | Blog id for site scope. Defaults to the current blog. Ignored for network scope. | +| `$site_id` | `int\|null` | Blog ID for site scope. Defaults to the current blog. Ignored for network scope. | -**Returns:** `string|WP_Error` 32-byte master key on success. WP_Error on failure, including when a caller passes an invalid scope or site id – see WP_Secrets_Cipher::validate_common() for why that is a WP_Error and not an exception. +**Returns:** `string|WP_Error` 32-byte master key on success. WP_Error on failure, including when a caller passes an invalid scope or site ID – see WP_Secrets_Cipher::validate_common() for why that is a WP_Error and not an exception. **Since:** 7.2.0 @@ -1386,10 +1385,6 @@ public function is_writable(): bool Shared implementation behind wp_list_secrets() and wp_list_network_secrets(). -Beyond the API surface the proposal names, and justified by its statement that -"the hooks and accessors an admin screen would need are in scope now; the screen -itself is not." - Fingerprints returned here come directly from the stored record field, not recomputed by decrypting each secret. That is a deliberate difference from WP_Secret::fingerprint(), which always recomputes – recomputing here would mean @@ -1418,10 +1413,6 @@ Shared implementation behind the two version-retirement functions. Backs wp_retire_secret_version() and wp_retire_network_secret_version(). -Beyond the API surface the proposal names. It states that retirement is "an -explicit operator action" but names no function; this is this implementation's -name for it, pending confirmation in the comments thread. - ```php public function retire_previous( string $name, bool $network = false ) ``` diff --git a/docs/reference/functions.md b/docs/reference/functions.md index 6503ad9..bf2534f 100644 --- a/docs/reference/functions.md +++ b/docs/reference/functions.md @@ -96,10 +96,9 @@ function wp_get_secret( string $name, string $version = WP_Secret_Version::CURRE Imports an existing option's value as a secret. -For an explicit migration, "on their own explicit upgrade schedule," -in the proposal's words, because "core cannot reliably tell which options are -credentials, and guessing would break sites." The source option is left -untouched: this reads it, it does not move or delete it. +For an explicit migration on the caller's own schedule, because core cannot +reliably tell which options are credentials, and guessing would break sites. The +source option is left untouched: this reads it, it does not move or delete it. The imported secret is flagged needs_rotation, unconditionally. A credential that sat in a plain option has already been through however many backups and @@ -144,10 +143,6 @@ function wp_list_network_secrets( string $namespace = '' ) Lists secrets by name and metadata. Never a value. -Beyond the API surface the proposal names, and justified by its statement that -the hooks and accessors a future admin screen needs are in scope now, even though -the screen itself is not. - ```php function wp_list_secrets( string $namespace = '' ) ``` @@ -188,8 +183,6 @@ An explicit operator action, with no timers and no cron. Calling this on a secre that has never been rotated, or that does not exist, is a successful no-op: the previous slot is already absent either way. -Beyond the API surface the proposal names. - ```php function wp_retire_secret_version( string $name ) ``` @@ -494,7 +487,7 @@ function wp_secrets_site_health_result( string $test, string $label, string $sta ## `wp_secrets_site_health_test_key_source()` -Site Health test: is the site key a dedicated constant, or a weaker fallback? +Tests whether the site key comes from a dedicated constant or a weaker fallback. ```php function wp_secrets_site_health_test_key_source(): array @@ -508,7 +501,7 @@ function wp_secrets_site_health_test_key_source(): array ## `wp_secrets_site_health_test_needs_rotation()` -Site Health test: are any secrets flagged as needing rotation? +Tests whether any secrets are flagged as needing rotation. ```php function wp_secrets_site_health_test_needs_rotation(): array @@ -522,7 +515,7 @@ function wp_secrets_site_health_test_needs_rotation(): array ## `wp_secrets_site_health_test_undecryptable()` -Site Health test: does every stored secret still decrypt? +Tests whether every stored secret still decrypts. Network secrets are included only for a super admin on a multisite install – never shown to a site administrator who is not one. @@ -541,9 +534,8 @@ function wp_secrets_site_health_test_undecryptable(): array Registers the Secrets API's Site Health tests. -No settings screen – the proposal defers that to 7.3 – but the health signal an -operator needs to notice a broken key, a weak key source, or a pending rotation is -in scope now. +There is no settings screen for secrets, so these tests are how an operator +notices a broken key, a weak key source, or a pending rotation. ```php function wp_secrets_site_health_tests( $tests ) @@ -572,9 +564,9 @@ so that listings and admin screens can be sensible. It confers no isolation: any plugin that can run PHP can read any secret, namespaced or not. An unnamespaced name ('secret-name', no '/') is accepted, but reports through -_doing_it_wrong(). It exists for one reason: code written against the earlier -prototype used a flat keyspace, and refusing those names outright would mean -every such call site has to be rewritten before it can be ported at all. +_doing_it_wrong(). It exists for one reason: existing code may already use a flat +keyspace, and refusing those names outright would mean every such call site has +to be rewritten before it can be ported at all. Accepting them keeps that migration incremental. Nothing else should use one. Two plugins that both choose 'api-key' collide silently, which is a real problem even though it is not a security one. @@ -599,8 +591,7 @@ Encrypts and stores a network-scope secret. Site secrets and network secrets are separate functions with separate capabilities and separate storage prefixes, with no implicit fallback from one -scope to the other. The proposal describes network scope but does not name these -functions; these are this implementation's names for them. +scope to the other. ```php function wp_set_network_secret( string $name, $value ) @@ -757,10 +748,10 @@ function _wp_secrets_get_provider(): WP_Secrets_Provider Returns the active secret store. -No filter is applied here, or anywhere on the retrieval path. See -docs/spec/extension-points.md. A secrets.php drop-in overrides the store by setting -$GLOBALS['wp_secrets_store'] to an instance before this is first called, and -that global is checked here directly, not through a hook. +No filter is applied here, or anywhere on the retrieval path. A secrets.php +drop-in overrides the store by setting $GLOBALS['wp_secrets_store'] to an +instance before this is first called, and that global is checked here directly, +not through a hook. If the global was set but is not a valid WP_Secrets_Store, or if the drop-in itself failed to load cleanly, this returns WP_Secrets_Broken_Store rather than diff --git a/docs/reference/hooks.md b/docs/reference/hooks.md index 8f458a5..6fbe165 100644 --- a/docs/reference/hooks.md +++ b/docs/reference/hooks.md @@ -20,14 +20,14 @@ There is no filter anywhere in core-bound code, and nothing on the retrieval pat Fires whenever a secret is created, updated, deleted, imported, or retired. -The only hook in the core-bound Secrets API code, and it does not fire on the -retrieval path. Fingerprints are readable; values are never passed. +This hook does not fire on the retrieval path. Fingerprints are passed; +values never are. | Parameter | Type | Description | |---|---|---| | `$name` | `string` | The secret's namespaced name. | | `$action` | `string` | One of 'created', 'updated', 'deleted', 'imported', 'retired'. | -| `$actor_id` | `int` | The current user id, or 0. | +| `$actor_id` | `int` | The current user ID, or 0. | | `$timestamp` | `int` | Unix timestamp of the change. | | `$old_fingerprint` | `string` | The previous fingerprint, or '' if none. | | `$new_fingerprint` | `string` | The new fingerprint, or '' if the secret was deleted. | diff --git a/secrets-api.php b/secrets-api.php index 708e7b8..1b00b80 100644 --- a/secrets-api.php +++ b/secrets-api.php @@ -301,6 +301,12 @@ function wp_secrets_api_notice_conflict(): void { * userland and is recorded in docs/journal/test-coverage-gaps.md rather than * silently assumed away. * + * @global bool $wp_secrets_dropin_loaded Whether a secrets.php drop-in file was found. + * @global bool $wp_secrets_dropin_broken Whether the drop-in failed to load or left an invalid override. + * @global WP_Secrets_Provider $wp_secrets_provider Optional. Provider override set by the drop-in. + * @global WP_Secrets_Store $wp_secrets_store Optional. Store override set by the drop-in. + * @global WP_Secrets_Keyring $wp_secrets_keyring Optional. Keyring override set by the drop-in. + * * @return void */ function wp_secrets_api_load_dropin(): void { diff --git a/src/wp-admin/includes/secrets-site-health.php b/src/wp-admin/includes/secrets-site-health.php index d6a19b4..646bc4a 100644 --- a/src/wp-admin/includes/secrets-site-health.php +++ b/src/wp-admin/includes/secrets-site-health.php @@ -10,9 +10,8 @@ /** * Registers the Secrets API's Site Health tests. * - * No settings screen – the proposal defers that to 7.3 – but the health signal an - * operator needs to notice a broken key, a weak key source, or a pending rotation is - * in scope now. + * There is no settings screen for secrets, so these tests are how an operator + * notices a broken key, a weak key source, or a pending rotation. * * @since 7.2.0 * @@ -74,7 +73,7 @@ function wp_secrets_site_health_result( string $test, string $label, string $sta } /** - * Site Health test: is the site key a dedicated constant, or a weaker fallback? + * Tests whether the site key comes from a dedicated constant or a weaker fallback. * * @since 7.2.0 * @@ -130,7 +129,7 @@ function wp_secrets_site_health_test_key_source(): array { } /** - * Site Health test: does every stored secret still decrypt? + * Tests whether every stored secret still decrypts. * * Network secrets are included only for a super admin on a multisite install – * never shown to a site administrator who is not one. @@ -218,7 +217,7 @@ function wp_secrets_site_health_find_undecryptable( bool $network ): array { } /** - * Site Health test: are any secrets flagged as needing rotation? + * Tests whether any secrets are flagged as needing rotation. * * @since 7.2.0 * diff --git a/src/wp-includes/secrets.php b/src/wp-includes/secrets.php index 99d87ed..826eb41 100644 --- a/src/wp-includes/secrets.php +++ b/src/wp-includes/secrets.php @@ -50,7 +50,7 @@ /** * A caller passed an argument this API cannot act on. * - * An unrecognized version constant, or an invalid scope, slot, or site id. + * An unrecognized version constant, or an invalid scope, slot, or site ID. * * Distinct from the codes above in cause rather than in severity: those describe * a runtime condition a correct caller can still hit (a name that failed @@ -86,9 +86,9 @@ * * Distinct from WP_SECRETS_ERROR_RECORD_MALFORMED so that an operator, or Site * Health, can tell a corrupt record apart from one written by a newer version of - * this plugin than is currently active. See - * docs/decisions/0006-record-format-v2-not-read-compatible.md. The upgrade path for a future v2 is not - * designed, so an unrecognized version is rejected outright rather than guessed at. + * WordPress than is currently running. The upgrade path for a future record format + * is not designed, so an unrecognized version is rejected outright rather than + * guessed at. * * @since 7.2.0 */ @@ -184,15 +184,19 @@ function wp_secrets_memzero( &$value ): void { $alias =& $value; sodium_memzero( $alias ); } catch ( \Throwable $e ) { - // A value this function doesn't exclusively own (a shared, - // copy-on-write buffer) can legitimately refuse. The unconditional - // reassignment below still runs regardless of what happened here. + /* + * A value this function doesn't exclusively own (a shared, + * copy-on-write buffer) can legitimately refuse. The unconditional + * reassignment below still runs regardless of what happened here. + */ unset( $e ); } } - // Reached on every path, including sodium_memzero() success (it leaves the - // variable null, not an empty string) so callers see one consistent contract. + /* + * Reached on every path, including sodium_memzero() success (it leaves the + * variable null, not an empty string) so callers see one consistent contract. + */ $value = ''; } @@ -208,9 +212,9 @@ function wp_secrets_memzero( &$value ): void { * plugin that can run PHP can read any secret, namespaced or not. * * An unnamespaced name ('secret-name', no '/') is accepted, but reports through - * _doing_it_wrong(). It exists for one reason: code written against the earlier - * prototype used a flat keyspace, and refusing those names outright would mean - * every such call site has to be rewritten before it can be ported at all. + * _doing_it_wrong(). It exists for one reason: existing code may already use a flat + * keyspace, and refusing those names outright would mean every such call site has + * to be rewritten before it can be ported at all. * Accepting them keeps that migration incremental. Nothing else should use one. * Two plugins that both choose 'api-key' collide silently, which is a real problem * even though it is not a security one. @@ -268,7 +272,7 @@ function wp_secrets_validate_name( string $name ) { __FUNCTION__, sprintf( /* translators: %s: The unnamespaced secret name. */ - __( 'The secret name "%s" has no namespace. Namespaced names ("plugin-slug/secret-name") group secrets by owner so that listings and admin screens can be organized; unnamespaced names are supported only so that code written against the Displace prototype can be ported incrementally.' ), + __( 'The secret name "%s" has no namespace. Namespaced names ("plugin-slug/secret-name") group secrets by owner so that listings and admin screens can be organized; unnamespaced names are supported only so that existing code using flat names can be ported incrementally.' ), $name ), '7.2.0' @@ -301,10 +305,10 @@ function wp_secrets_validate_name( string $name ) { /** * Returns the active secret store. * - * No filter is applied here, or anywhere on the retrieval path. See - * docs/spec/extension-points.md. A secrets.php drop-in overrides the store by setting - * $GLOBALS['wp_secrets_store'] to an instance before this is first called, and - * that global is checked here directly, not through a hook. + * No filter is applied here, or anywhere on the retrieval path. A secrets.php + * drop-in overrides the store by setting $GLOBALS['wp_secrets_store'] to an + * instance before this is first called, and that global is checked here directly, + * not through a hook. * * If the global was set but is not a valid WP_Secrets_Store, or if the drop-in * itself failed to load cleanly, this returns WP_Secrets_Broken_Store rather than @@ -314,6 +318,9 @@ function wp_secrets_validate_name( string $name ) { * * @since 7.2.0 * + * @global bool $wp_secrets_dropin_broken Whether the secrets.php drop-in failed to load cleanly. + * @global WP_Secrets_Store $wp_secrets_store Optional. Store override set by the drop-in. + * * @return WP_Secrets_Store */ function _wp_secrets_get_store(): WP_Secrets_Store { @@ -350,6 +357,9 @@ function _wp_secrets_get_store(): WP_Secrets_Store { * * @since 7.2.0 * + * @global bool $wp_secrets_dropin_broken Whether the secrets.php drop-in failed to load cleanly. + * @global WP_Secrets_Keyring $wp_secrets_keyring Optional. Keyring override set by the drop-in. + * * @return WP_Secrets_Key_Manager */ function _wp_secrets_get_key_manager(): WP_Secrets_Key_Manager { @@ -385,30 +395,14 @@ function _wp_secrets_get_key_manager(): WP_Secrets_Key_Manager { * * @since 7.2.0 * + * @global bool $wp_secrets_dropin_loaded Whether a secrets.php drop-in file was found. + * * @return bool */ function wp_using_secrets_dropin(): bool { return ! empty( $GLOBALS['wp_secrets_dropin_loaded'] ); } - - - - - - - - - - - - - - - - - - /** * Encrypts and stores a secret. * @@ -430,10 +424,9 @@ function wp_set_secret( string $name, $value ) { /** * Imports an existing option's value as a secret. * - * For an explicit migration, "on their own explicit upgrade schedule," - * in the proposal's words, because "core cannot reliably tell which options are - * credentials, and guessing would break sites." The source option is left - * untouched: this reads it, it does not move or delete it. + * For an explicit migration on the caller's own schedule, because core cannot + * reliably tell which options are credentials, and guessing would break sites. The + * source option is left untouched: this reads it, it does not move or delete it. * * The imported secret is flagged needs_rotation, unconditionally. A credential that * sat in a plain option has already been through however many backups and @@ -474,8 +467,6 @@ function wp_import_option_as_secret( string $option, string $name ) { return _wp_secrets_set( $name, $value, false, true, 'imported' ); } - - /** * Retrieves a secret. * @@ -514,8 +505,6 @@ function wp_delete_secret( string $name ) { * that has never been rotated, or that does not exist, is a successful no-op: * the previous slot is already absent either way. * - * Beyond the API surface the proposal names. - * * @since 7.2.0 * * @param string $name The secret's namespaced name. @@ -528,10 +517,6 @@ function wp_retire_secret_version( string $name ) { /** * Lists secrets by name and metadata. Never a value. * - * Beyond the API surface the proposal names, and justified by its statement that - * the hooks and accessors a future admin screen needs are in scope now, even though - * the screen itself is not. - * * @since 7.2.0 * * @param string $namespace Only secrets whose name starts with "{$namespace}/" are @@ -542,7 +527,7 @@ function wp_retire_secret_version( string $name ) { * * @phpstan-return list|WP_Error */ -function wp_list_secrets( string $namespace = '' ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.namespaceFound -- $namespace is the parameter name the proposal specifies. +function wp_list_secrets( string $namespace = '' ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.namespaceFound -- $namespace is the public parameter name; renaming it would break named-argument callers. return _wp_secrets_list( $namespace, false ); } @@ -551,8 +536,7 @@ function wp_list_secrets( string $namespace = '' ) { // phpcs:ignore Universal.N * * Site secrets and network secrets are separate functions with separate * capabilities and separate storage prefixes, with no implicit fallback from one - * scope to the other. The proposal describes network scope but does not name these - * functions; these are this implementation's names for them. + * scope to the other. * * @since 7.2.0 * @@ -641,6 +625,9 @@ function wp_list_network_secrets( string $namespace = '' ) { // phpcs:ignore Uni * * @since 7.2.0 * + * @global bool $wp_secrets_dropin_broken Whether the secrets.php drop-in failed to load cleanly. + * @global WP_Secrets_Provider $wp_secrets_provider Optional. Provider override set by the drop-in. + * * @return WP_Secrets_Provider */ function _wp_secrets_get_provider(): WP_Secrets_Provider { diff --git a/src/wp-includes/secrets/class-wp-secret.php b/src/wp-includes/secrets/class-wp-secret.php index 260990c..d8db880 100644 --- a/src/wp-includes/secrets/class-wp-secret.php +++ b/src/wp-includes/secrets/class-wp-secret.php @@ -31,7 +31,7 @@ final class WP_Secret implements JsonSerializable { * Deliberately not an SplObjectStorage keyed by the instance itself: that holds a * strong reference to its keys, which would keep every WP_Secret ever constructed * alive – and its plaintext un-zeroed – for the life of the request. Keying by - * the integer id instead leaves an instance's own reference count to govern its + * the integer ID instead leaves an instance's own reference count to govern its * lifetime normally, so __destruct() fires and zeroes the value the moment nothing * else references the instance. * @@ -224,13 +224,12 @@ public function __toString(): string { } /** - * Masks the instance for var_dump(). + * Masks the instance for var_dump() and print_r(). * - * Note that print_r() and var_export() have no equivalent hook. They are safe - * regardless, because the plaintext is never a declared property for them to - * enumerate; they will surface the (non-sensitive) name and fingerprint rather - * than this placeholder. See docs/journal/open-questions.md for the documented - * var_export() limitation. + * Note that var_export() does not use this hook. It does not expose plaintext + * from the vault, but it enumerates all instance properties, including the + * unmasked WP_Error reason on a withheld secret. Use a masked output path + * instead when the reason must remain private. * * @since 7.2.0 * @@ -314,7 +313,7 @@ public function __unserialize( array $data ): void { * Refuses cloning outright. * * A clone would be a second, unaudited reference to the same plaintext with an - * independent lifetime – and, since the vault is keyed by object id rather than + * independent lifetime – and, since the vault is keyed by object ID rather than * copied, a bare `clone` would otherwise leave the clone's reveal() silently * reading nothing. * diff --git a/src/wp-includes/secrets/class-wp-secrets-broken-store.php b/src/wp-includes/secrets/class-wp-secrets-broken-store.php index 31e64d8..bdb2432 100644 --- a/src/wp-includes/secrets/class-wp-secrets-broken-store.php +++ b/src/wp-includes/secrets/class-wp-secrets-broken-store.php @@ -75,7 +75,6 @@ public function list_names( bool $network = false ): WP_Error { return $this->error(); } - /** * Builds the error every method returns. * diff --git a/src/wp-includes/secrets/class-wp-secrets-cipher.php b/src/wp-includes/secrets/class-wp-secrets-cipher.php index 4dc6315..5cb1284 100644 --- a/src/wp-includes/secrets/class-wp-secrets-cipher.php +++ b/src/wp-includes/secrets/class-wp-secrets-cipher.php @@ -256,7 +256,7 @@ public function fingerprint( string $master_key, $plaintext ) { * * @param string $purpose One of the AAD_* class constants. * @param string $scope 'site' or 'network'. - * @param int $site_id Blog id for site scope, 0 for network scope. + * @param int $site_id Blog ID for site scope, 0 for network scope. * @param string $name Validated secret name. * @param string $slot A WP_Secret_Version constant. * @return string @@ -284,7 +284,7 @@ private function build_aad( string $purpose, string $scope, int $site_id, string * * @param string $master_key Candidate master key. * @param string $scope Candidate scope. - * @param int $site_id Candidate site id. + * @param int $site_id Candidate site ID. * @param string $name Candidate secret name. * @param string $slot Candidate slot. * @return true|WP_Error diff --git a/src/wp-includes/secrets/class-wp-secrets-key-manager.php b/src/wp-includes/secrets/class-wp-secrets-key-manager.php index 9174037..de2e730 100644 --- a/src/wp-includes/secrets/class-wp-secrets-key-manager.php +++ b/src/wp-includes/secrets/class-wp-secrets-key-manager.php @@ -22,7 +22,7 @@ * - Site scope, blog N: sodium_crypto_kdf_derive_from_key( 32, N, 'wpsecsit', $root ). * Distinct per blog, which shared network-wide salts otherwise cannot provide. * - Network scope: sodium_crypto_kdf_derive_from_key( 32, 0, 'wpsecnet', $root ). - * Subkey id 0 is reserved for this (blog ids start at 1) and the context string + * Subkey ID 0 is reserved for this (blog IDs start at 1) and the context string * differs from the site path, so there is no collision between the two. Identical * on every blog, so a network secret written on one blog reads on every other. * @@ -66,9 +66,9 @@ final class WP_Secrets_Key_Manager { const NETWORK_KDF_CONTEXT = 'wpsecnet'; /** - * Reserved KDF subkey id for network scope. + * Reserved KDF subkey ID for network scope. * - * Blog ids start at 1, so this can never collide with a site-scope subkey id. + * Blog IDs start at 1, so this can never collide with a site-scope subkey ID. * * @since 7.2.0 * @var int @@ -136,11 +136,11 @@ public function get_keyring(): WP_Secrets_Keyring { * @since 7.2.0 * * @param string $scope 'site' or 'network'. - * @param int|null $site_id Blog id for site scope. Defaults to the current blog. + * @param int|null $site_id Blog ID for site scope. Defaults to the current blog. * Ignored for network scope. * @return string|WP_Error 32-byte master key on success. WP_Error on failure, * including when a caller passes an invalid scope or - * site id – see WP_Secrets_Cipher::validate_common() + * site ID – see WP_Secrets_Cipher::validate_common() * for why that is a WP_Error and not an exception. * * @phpstan-param 'site'|'network' $scope diff --git a/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php b/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php index 0c21195..7fdf1e5 100644 --- a/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php +++ b/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php @@ -233,7 +233,7 @@ private function slot_fingerprint( $slot ): string { * @param WP_Secrets_Cipher $cipher A cipher instance. * @param string $master_key 32-byte master key. * @param string $scope 'site' or 'network'. - * @param int $site_id Blog id for site scope, 0 for network scope. + * @param int $site_id Blog ID for site scope, 0 for network scope. * @param string $name The secret's namespaced name. * @param array $current_slot The outgoing current slot, as stored. * @return array|WP_Error The same value, re-encrypted and bound to @@ -419,15 +419,15 @@ public function set( string $name, $value, bool $network = false, bool $needs_ro /** * Fires whenever a secret is created, updated, deleted, imported, or retired. * - * The only hook in the core-bound Secrets API code, and it does not fire on the - * retrieval path. Fingerprints are readable; values are never passed. + * This hook does not fire on the retrieval path. Fingerprints are passed; + * values never are. * * @since 7.2.0 * * @param string $name The secret's namespaced name. * @param string $action One of 'created', 'updated', 'deleted', * 'imported', 'retired'. - * @param int $actor_id The current user id, or 0. + * @param int $actor_id The current user ID, or 0. * @param int $timestamp Unix timestamp of the change. * @param string $old_fingerprint The previous fingerprint, or '' if none. * @param string $new_fingerprint The new fingerprint, or '' if the secret was deleted. @@ -485,8 +485,10 @@ public function get( string $name, string $version, bool $network = false ) { } if ( ! isset( $record[ $version ] ) || ! is_array( $record[ $version ] ) ) { - // The secret exists, but this slot does not. Absence, not an error – - // this matters most for PREVIOUS on a secret that has never been rotated. + /* + * The secret exists, but this slot does not. Absence, not an error – + * this matters most for PREVIOUS on a secret that has never been rotated. + */ return null; } @@ -587,10 +589,6 @@ public function delete( string $name, bool $network = false ) { * * Backs wp_retire_secret_version() and wp_retire_network_secret_version(). * - * Beyond the API surface the proposal names. It states that retirement is "an - * explicit operator action" but names no function; this is this implementation's - * name for it, pending confirmation in the comments thread. - * * @since 7.2.0 * * @param string $name The secret's namespaced name. @@ -651,10 +649,6 @@ public function retire_previous( string $name, bool $network = false ) { /** * Shared implementation behind wp_list_secrets() and wp_list_network_secrets(). * - * Beyond the API surface the proposal names, and justified by its statement that - * "the hooks and accessors an admin screen would need are in scope now; the screen - * itself is not." - * * Fingerprints returned here come directly from the stored record field, not * recomputed by decrypting each secret. That is a deliberate difference from * WP_Secret::fingerprint(), which always recomputes – recomputing here would mean diff --git a/src/wp-includes/secrets/class-wp-secrets-option-store.php b/src/wp-includes/secrets/class-wp-secrets-option-store.php index 7ffc149..5519d7d 100644 --- a/src/wp-includes/secrets/class-wp-secrets-option-store.php +++ b/src/wp-includes/secrets/class-wp-secrets-option-store.php @@ -124,8 +124,10 @@ public function delete( string $name, bool $network = false ) { return true; } - // delete_*_option() also returns false when the option never existed. - // Deleting something already absent is not a failure. + /* + * delete_*_option() also returns false when the option never existed. + * Deleting something already absent is not a failure. + */ $still_exists = null !== ( $network ? get_site_option( $option_name, null ) : get_option( $option_name, null ) ); if ( ! $still_exists ) { @@ -191,7 +193,6 @@ public function list_names( bool $network = false ) { return $names; } - /** * Builds the option name a secret is stored under. * diff --git a/tests/includes/class-wp-secrets-keyring-conformance.php b/tests/includes/class-wp-secrets-keyring-conformance.php index e893a9a..be4a87a 100644 --- a/tests/includes/class-wp-secrets-keyring-conformance.php +++ b/tests/includes/class-wp-secrets-keyring-conformance.php @@ -10,7 +10,8 @@ * rather than an exception or a falsy string. Those are the properties that * actually matter to WP_Secrets_Key_Manager, the only caller of a keyring. * - * @package SecretsAPI + * @package WordPress + * @subpackage Secrets */ abstract class WP_Secrets_Keyring_Conformance extends WP_UnitTestCase { @@ -32,12 +33,12 @@ public function test_wrap_returns_a_non_empty_string_that_unwraps_to_the_same_by $wrapped = $keyring->wrap( $key_material ); - $this->assertIsString( $wrapped ); - $this->assertNotSame( '', $wrapped ); + $this->assertIsString( $wrapped, 'wrap() should return a string.' ); + $this->assertNotSame( '', $wrapped, 'wrap() should not return an empty string.' ); $unwrapped = $keyring->unwrap( $wrapped ); - $this->assertSame( $key_material, $unwrapped ); + $this->assertSame( $key_material, $unwrapped, 'unwrap() should return the original key material.' ); } /** @@ -53,7 +54,7 @@ public function test_two_wraps_of_the_same_bytes_return_different_strings(): voi $first = $keyring->wrap( $key_material ); $second = $keyring->wrap( $key_material ); - $this->assertNotSame( $first, $second ); + $this->assertNotSame( $first, $second, 'Two wraps of the same key material should return different strings.' ); } /** @@ -66,7 +67,7 @@ public function test_unwrap_of_garbage_is_a_wp_error(): void { $keyring = $this->keyring(); $garbage = 'garbage-' . bin2hex( random_bytes( 16 ) ); - $this->assertWPError( $keyring->unwrap( $garbage ) ); + $this->assertWPError( $keyring->unwrap( $garbage ), 'Unwrapping garbage should return a WP_Error.' ); } /** @@ -78,11 +79,11 @@ public function test_unwrap_of_a_truncated_value_is_a_wp_error(): void { $keyring = $this->keyring(); $wrapped = $keyring->wrap( random_bytes( 32 ) ); - $this->assertIsString( $wrapped ); + $this->assertIsString( $wrapped, 'wrap() should return a string.' ); $truncated = substr( $wrapped, 0, intdiv( strlen( $wrapped ), 2 ) ); - $this->assertWPError( $keyring->unwrap( $truncated ) ); + $this->assertWPError( $keyring->unwrap( $truncated ), 'Unwrapping a truncated value should return a WP_Error.' ); } /** @@ -94,13 +95,13 @@ public function test_unwrap_of_a_value_with_one_flipped_byte_is_a_wp_error(): vo $keyring = $this->keyring(); $wrapped = $keyring->wrap( random_bytes( 32 ) ); - $this->assertIsString( $wrapped ); + $this->assertIsString( $wrapped, 'wrap() should return a string.' ); $flip_at = intdiv( strlen( $wrapped ), 2 ); $flipped = $wrapped; $flipped[ $flip_at ] = chr( ord( $wrapped[ $flip_at ] ) ^ 0x01 ); - $this->assertWPError( $keyring->unwrap( $flipped ) ); + $this->assertWPError( $keyring->unwrap( $flipped ), 'Unwrapping a value with one flipped byte should return a WP_Error.' ); } /** @@ -110,7 +111,7 @@ public function test_unwrap_of_a_value_with_one_flipped_byte_is_a_wp_error(): vo public function test_get_key_source_returns_a_non_empty_string(): void { $source = $this->keyring()->get_key_source(); - $this->assertIsString( $source ); - $this->assertNotSame( '', trim( $source ) ); + $this->assertIsString( $source, 'get_key_source() should return a string.' ); + $this->assertNotSame( '', trim( $source ), 'get_key_source() should not return an empty or whitespace-only string.' ); } } diff --git a/tests/includes/class-wp-secrets-provider-conformance.php b/tests/includes/class-wp-secrets-provider-conformance.php index 98feab7..8e09a6e 100644 --- a/tests/includes/class-wp-secrets-provider-conformance.php +++ b/tests/includes/class-wp-secrets-provider-conformance.php @@ -19,7 +19,8 @@ * version history is not asked to produce a previous one. What does not vary is * checked for everyone. * - * @package SecretsAPI + * @package WordPress + * @subpackage Secrets */ abstract class WP_Secrets_Provider_Conformance extends WP_UnitTestCase { @@ -102,27 +103,36 @@ private function has_previous_version( WP_Secrets_Provider $provider, $name ) { // – declarations must be coherent ---------------------------------------- + /** + * Ensure that the provider reports a label that is neither empty nor only whitespace. + */ public function test_reports_a_non_empty_label(): void { $label = $this->provider()->get_label(); - $this->assertIsString( $label ); - $this->assertNotSame( '', trim( $label ) ); + $this->assertIsString( $label, 'The provider label should be a string.' ); + $this->assertNotSame( '', trim( $label ), 'The provider label should not be empty or only whitespace.' ); } + /** + * Ensure that the provider reports one of the BOUNDARY_* constants as its protection boundary. + */ public function test_reports_a_known_protection_boundary(): void { $this->assertContains( $this->provider()->get_protection_boundary(), array( WP_Secrets_Provider::BOUNDARY_WORDPRESS, WP_Secrets_Provider::BOUNDARY_PROVIDER ), - 'get_protection_boundary() must return one of the BOUNDARY_* constants.' + 'get_protection_boundary() should return one of the BOUNDARY_* constants.' ); } + /** + * Ensure that the provider declares whether it accepts writes as a boolean. + */ public function test_is_writable_returns_a_bool(): void { - $this->assertIsBool( $this->provider()->is_writable() ); + $this->assertIsBool( $this->provider()->is_writable(), 'The provider should report is_writable() as a bool.' ); } /** - * The declaration has to match the behaviour, or a settings screen that trusts + * The declaration has to match the behavior, or a settings screen that trusts * is_writable() will offer a save control that cannot work. */ public function test_a_read_only_provider_actually_refuses_writes(): void { @@ -134,11 +144,11 @@ public function test_a_read_only_provider_actually_refuses_writes(): void { $result = $provider->set( $this->conformance_name(), 'value' ); - $this->assertWPError( $result ); + $this->assertWPError( $result, 'The provider should refuse a write with a WP_Error when read-only.' ); $this->assertSame( WP_SECRETS_ERROR_PROVIDER_READ_ONLY, $result->get_error_code(), - 'A provider that declares itself read-only must refuse with secret_provider_read_only.' + 'A provider that declares itself read-only should refuse with secret_provider_read_only.' ); } @@ -153,7 +163,7 @@ public function test_a_read_only_provider_actually_refuses_writes(): void { public function test_a_name_that_was_never_set_is_null(): void { $result = $this->provider()->get( 'conformance/never-set-' . uniqid(), WP_Secret_Version::CURRENT ); - $this->assertNull( $result ); + $this->assertNull( $result, 'The provider should return null for a name that was never set.' ); } /** @@ -166,14 +176,17 @@ public function test_previous_on_a_provider_or_secret_without_history_is_null_no $this->require_writable( $provider ); $name = $this->conformance_name(); - $this->assertNotWPError( $provider->set( $name, 'only-ever-one-value' ) ); + $this->assertNotWPError( $provider->set( $name, 'only-ever-one-value' ), 'The provider should accept the initial write.' ); $previous = $provider->get( $name, WP_Secret_Version::PREVIOUS ); $this->assertNotWPError( $previous, 'PREVIOUS with no previous value is absence, not an error.' ); - $this->assertNull( $previous ); + $this->assertNull( $previous, 'The provider should return null for a PREVIOUS version that does not exist.' ); } + /** + * Ensure that the provider treats deleting a secret that was never set as success. + */ public function test_deleting_something_absent_is_success(): void { $provider = $this->provider(); $this->require_writable( $provider ); @@ -184,30 +197,36 @@ public function test_deleting_something_absent_is_success(): void { ); } + /** + * Ensure that the provider treats retiring a secret that has no previous version as success. + */ public function test_retiring_with_no_previous_version_is_success(): void { $provider = $this->provider(); $this->require_writable( $provider ); $name = $this->conformance_name(); - $provider->set( $name, 'value' ); + $this->assertTrue( $provider->set( $name, 'value' ), 'Setting the secret to value should succeed.' ); - $this->assertNotWPError( $provider->retire_previous( $name ) ); + $this->assertNotWPError( $provider->retire_previous( $name ), 'The provider should treat retiring with no previous version as success.' ); } // – the round trip -------------------------------------------------------- + /** + * Ensure that the provider returns a stored value unchanged, under the name it was stored as. + */ public function test_a_stored_value_comes_back_intact(): void { $provider = $this->provider(); $this->require_writable( $provider ); $name = $this->conformance_name(); - $this->assertNotWPError( $provider->set( $name, 'sk_live_conformance_value' ) ); + $this->assertNotWPError( $provider->set( $name, 'sk_live_conformance_value' ), 'The provider should accept the write.' ); $secret = $provider->get( $name, WP_Secret_Version::CURRENT ); - $this->assertInstanceOf( 'WP_Secret', $secret ); - $this->assertSame( 'sk_live_conformance_value', $secret->reveal() ); - $this->assertSame( $name, $secret->get_name() ); + $this->assertInstanceOf( 'WP_Secret', $secret, 'The provider should return the stored value as a WP_Secret.' ); + $this->assertSame( 'sk_live_conformance_value', $secret->reveal(), 'The provider should return the stored value unchanged.' ); + $this->assertSame( $name, $secret->get_name(), 'The provider should return the secret under the name it was stored as.' ); } /** @@ -220,20 +239,20 @@ public function test_a_fingerprint_is_stable_for_the_same_value(): void { $this->require_writable( $provider ); $name = $this->conformance_name(); - $provider->set( $name, 'stable-value' ); + $this->assertTrue( $provider->set( $name, 'stable-value' ), 'Setting the secret to stable-value should succeed.' ); $first_read = $provider->get( $name, WP_Secret_Version::CURRENT ); $second_read = $provider->get( $name, WP_Secret_Version::CURRENT ); - $this->assertInstanceOf( 'WP_Secret', $first_read ); - $this->assertInstanceOf( 'WP_Secret', $second_read ); + $this->assertInstanceOf( 'WP_Secret', $first_read, 'The provider should return the first read as a WP_Secret.' ); + $this->assertInstanceOf( 'WP_Secret', $second_read, 'The provider should return the second read as a WP_Secret.' ); $first = $first_read->fingerprint(); $second = $second_read->fingerprint(); - $this->assertIsString( $first ); - $this->assertNotSame( '', $first ); - $this->assertSame( $first, $second ); + $this->assertIsString( $first, 'The provider should return the fingerprint as a string.' ); + $this->assertNotSame( '', $first, 'The provider should return a non-empty fingerprint.' ); + $this->assertSame( $first, $second, 'The provider should return the same fingerprint for the same value.' ); } /** @@ -252,8 +271,8 @@ public function test_writing_an_unchanged_value_keeps_the_previous_version(): vo $this->require_writable( $provider ); $name = $this->conformance_name(); - $provider->set( $name, 'first-value' ); - $provider->set( $name, 'second-value' ); + $this->assertTrue( $provider->set( $name, 'first-value' ), 'Setting the secret to first-value should succeed.' ); + $this->assertTrue( $provider->set( $name, 'second-value' ), 'Setting the secret to second-value should succeed.' ); $previous = $provider->get( $name, WP_Secret_Version::PREVIOUS ); @@ -261,23 +280,26 @@ public function test_writing_an_unchanged_value_keeps_the_previous_version(): vo $this->markTestSkipped( 'Provider keeps no previous version; there is nothing to keep.' ); } - $this->assertNotWPError( $provider->set( $name, 'second-value' ) ); + $this->assertNotWPError( $provider->set( $name, 'second-value' ), 'The provider should accept a write of the unchanged value.' ); $kept = $provider->get( $name, WP_Secret_Version::PREVIOUS ); - $this->assertInstanceOf( 'WP_Secret', $kept, 'Writing an unchanged value must not discard the previous version.' ); - $this->assertSame( $previous->fingerprint(), $kept->fingerprint(), 'Writing an unchanged value must not replace the previous version.' ); + $this->assertInstanceOf( 'WP_Secret', $kept, 'Writing an unchanged value should not discard the previous version.' ); + $this->assertSame( $previous->fingerprint(), $kept->fingerprint(), 'Writing an unchanged value should not replace the previous version.' ); } + /** + * Ensure that the provider reports a deleted secret as absent. + */ public function test_a_deleted_secret_reads_as_absent(): void { $provider = $this->provider(); $this->require_writable( $provider ); $name = $this->conformance_name(); - $provider->set( $name, 'value' ); - $this->assertNotWPError( $provider->delete( $name ) ); + $this->assertTrue( $provider->set( $name, 'value' ), 'Setting the secret to value should succeed.' ); + $this->assertNotWPError( $provider->delete( $name ), 'The provider should accept the delete.' ); - $this->assertNull( $provider->get( $name, WP_Secret_Version::CURRENT ) ); + $this->assertNull( $provider->get( $name, WP_Secret_Version::CURRENT ), 'The provider should report a deleted secret as null.' ); } // – every change is reported ---------------------------------------------- @@ -294,33 +316,36 @@ public function test_a_successful_write_reports_a_change(): void { $name = $this->conformance_name(); $this->record_changes(); - $this->assertNotWPError( $provider->set( $name, 'UNIQUE-CONFORMANCE-CANARY-7c1d' ) ); + $this->assertNotWPError( $provider->set( $name, 'UNIQUE-CONFORMANCE-CANARY-7c1d' ), 'The provider should accept the write.' ); - $this->assertCount( 1, $this->recorded_changes, 'set() must fire wp_secret_changed exactly once.' ); + $this->assertCount( 1, $this->recorded_changes, 'set() should fire wp_secret_changed exactly once.' ); $change = $this->recorded_changes[0]; $this->assertCount( 7, $change, 'wp_secret_changed takes seven arguments, ending with $network.' ); - $this->assertSame( $name, $change[0] ); - $this->assertContains( $change[1], array( 'created', 'updated' ) ); + $this->assertSame( $name, $change[0], 'The provider should report the name of the written secret.' ); + $this->assertContains( $change[1], array( 'created', 'updated' ), 'The provider should report a write as created or updated.' ); $this->assertIsInt( $change[2], 'The actor is a user id, or 0.' ); $this->assertIsInt( $change[3], 'The timestamp is a Unix timestamp.' ); $this->assertIsString( $change[4], "The old fingerprint is a string, '' when there is none." ); $this->assertIsString( $change[5], "The new fingerprint is a string, '' when there is none." ); - $this->assertFalse( $change[6], 'A site-scope write must report $network as false.' ); - $this->assertNotContains( 'UNIQUE-CONFORMANCE-CANARY-7c1d', $change, 'wp_secret_changed must never be passed a value.' ); + $this->assertFalse( $change[6], 'A site-scope write should report $network as false.' ); + $this->assertNotContains( 'UNIQUE-CONFORMANCE-CANARY-7c1d', $change, 'wp_secret_changed should never be passed a value.' ); } + /** + * Ensure that the provider reports an action override passed to set() to wp_secret_changed unchanged. + */ public function test_an_action_override_is_reported_as_given(): void { $provider = $this->provider(); $this->require_writable( $provider ); $this->record_changes(); - $this->assertNotWPError( $provider->set( $this->conformance_name(), 'value', false, false, 'imported' ) ); + $this->assertNotWPError( $provider->set( $this->conformance_name(), 'value', false, false, 'imported' ), 'The provider should accept a write with an action override.' ); - $this->assertCount( 1, $this->recorded_changes ); - $this->assertSame( 'imported', $this->recorded_changes[0][1] ); + $this->assertCount( 1, $this->recorded_changes, 'The provider should fire wp_secret_changed exactly once for the write.' ); + $this->assertSame( 'imported', $this->recorded_changes[0][1], 'The provider should report the action override as given.' ); } /** @@ -334,28 +359,31 @@ public function test_a_network_scope_change_reports_its_scope(): void { $name = $this->conformance_name(); $this->record_changes(); - $this->assertNotWPError( $provider->set( $name, 'value', true ) ); - $this->assertNotWPError( $provider->delete( $name, true ) ); + $this->assertNotWPError( $provider->set( $name, 'value', true ), 'The provider should accept a network-scope write.' ); + $this->assertNotWPError( $provider->delete( $name, true ), 'The provider should accept a network-scope delete.' ); - $this->assertCount( 2, $this->recorded_changes ); - $this->assertTrue( $this->recorded_changes[0][6], 'A network-scope write must report $network as true.' ); - $this->assertTrue( $this->recorded_changes[1][6], 'A network-scope delete must report $network as true.' ); + $this->assertCount( 2, $this->recorded_changes, 'The provider should fire wp_secret_changed once for each network-scope change.' ); + $this->assertTrue( $this->recorded_changes[0][6], 'A network-scope write should report $network as true.' ); + $this->assertTrue( $this->recorded_changes[1][6], 'A network-scope delete should report $network as true.' ); } + /** + * Ensure that the provider fires wp_secret_changed once, as deleted, when a secret is deleted. + */ public function test_deleting_a_secret_reports_a_change(): void { $provider = $this->provider(); $this->require_writable( $provider ); $name = $this->conformance_name(); - $provider->set( $name, 'value' ); + $this->assertTrue( $provider->set( $name, 'value' ), 'Setting the secret to value should succeed.' ); $this->record_changes(); - $this->assertNotWPError( $provider->delete( $name ) ); + $this->assertNotWPError( $provider->delete( $name ), 'The provider should accept the delete.' ); - $this->assertCount( 1, $this->recorded_changes, 'delete() must fire wp_secret_changed exactly once.' ); - $this->assertSame( $name, $this->recorded_changes[0][0] ); - $this->assertSame( 'deleted', $this->recorded_changes[0][1] ); + $this->assertCount( 1, $this->recorded_changes, 'delete() should fire wp_secret_changed exactly once.' ); + $this->assertSame( $name, $this->recorded_changes[0][0], 'The provider should report the name of the deleted secret.' ); + $this->assertSame( 'deleted', $this->recorded_changes[0][1], 'The provider should report a delete as deleted.' ); } /** @@ -368,8 +396,8 @@ public function test_retiring_a_previous_version_reports_a_change(): void { $this->require_writable( $provider ); $name = $this->conformance_name(); - $provider->set( $name, 'first-value' ); - $provider->set( $name, 'second-value' ); + $this->assertTrue( $provider->set( $name, 'first-value' ), 'Setting the secret to first-value should succeed.' ); + $this->assertTrue( $provider->set( $name, 'second-value' ), 'Setting the secret to second-value should succeed.' ); if ( ! $this->has_previous_version( $provider, $name ) ) { $this->markTestSkipped( 'Provider keeps no previous version; there is nothing to retire.' ); @@ -377,17 +405,20 @@ public function test_retiring_a_previous_version_reports_a_change(): void { $this->record_changes(); - $this->assertNotWPError( $provider->retire_previous( $name ) ); + $this->assertNotWPError( $provider->retire_previous( $name ), 'The provider should accept retiring the previous version.' ); if ( $this->has_previous_version( $provider, $name ) ) { $this->markTestSkipped( 'Provider leaves version history to its backend; nothing was cleared.' ); } - $this->assertCount( 1, $this->recorded_changes, 'retire_previous() must fire wp_secret_changed exactly once.' ); - $this->assertSame( $name, $this->recorded_changes[0][0] ); - $this->assertSame( 'retired', $this->recorded_changes[0][1] ); + $this->assertCount( 1, $this->recorded_changes, 'retire_previous() should fire wp_secret_changed exactly once.' ); + $this->assertSame( $name, $this->recorded_changes[0][0], 'The provider should report the name of the retired secret.' ); + $this->assertSame( 'retired', $this->recorded_changes[0][1], 'The provider should report a retire as retired.' ); } + /** + * Ensure that a read-only provider does not fire wp_secret_changed for a write it refused. + */ public function test_a_refused_write_reports_no_change(): void { $provider = $this->provider(); @@ -397,44 +428,53 @@ public function test_a_refused_write_reports_no_change(): void { $this->record_changes(); - $this->assertWPError( $provider->set( $this->conformance_name(), 'value' ) ); - $this->assertCount( 0, $this->recorded_changes, 'A write that did not happen must not be reported as a change.' ); + $this->assertWPError( $provider->set( $this->conformance_name(), 'value' ), 'The provider should refuse a write with a WP_Error when read-only.' ); + $this->assertCount( 0, $this->recorded_changes, 'A write that did not happen should not be reported as a change.' ); } // – listing never leaks --------------------------------------------------- - public function test_listing_returns_an_array_or_an_error_and_never_a_value(): void { + /** + * Ensure that the provider's listing of secrets never contains a stored plaintext value. + * + * The whole listing is encoded and searched rather than checked field by field, so a + * value leaked under any key, including one the contract does not define, is caught. + */ + public function test_listing_never_contains_a_stored_value(): void { $provider = $this->provider(); $this->require_writable( $provider ); $name = $this->conformance_name(); - $provider->set( $name, 'UNIQUE-CONFORMANCE-CANARY-3f9b' ); + $this->assertTrue( $provider->set( $name, 'UNIQUE-CONFORMANCE-CANARY-3f9b' ), 'Setting the secret to UNIQUE-CONFORMANCE-CANARY-3f9b should succeed.' ); $entries = $provider->list_secrets(); - $this->assertIsArray( $entries ); + $this->assertIsArray( $entries, 'The provider should return the listing as an array.' ); $dump = wp_json_encode( $entries ); - $this->assertIsString( $dump, 'list_secrets() must return something that can be encoded and inspected.' ); + $this->assertIsString( $dump, 'list_secrets() should return something that can be encoded and inspected.' ); $this->assertStringNotContainsString( 'UNIQUE-CONFORMANCE-CANARY-3f9b', $dump, - 'list_secrets() must never expose a plaintext.' + 'list_secrets() should never expose a plaintext.' ); } + /** + * Ensure that the provider includes the name of a stored secret in its listing. + */ public function test_listing_includes_a_stored_secret(): void { $provider = $this->provider(); $this->require_writable( $provider ); $name = $this->conformance_name(); - $provider->set( $name, 'value' ); + $this->assertTrue( $provider->set( $name, 'value' ), 'Setting the secret to value should succeed.' ); $entries = $provider->list_secrets(); - $this->assertNotWPError( $entries ); - $this->assertContains( $name, wp_list_pluck( $entries, 'name' ) ); + $this->assertNotWPError( $entries, 'The provider should return the listing without a WP_Error.' ); + $this->assertContains( $name, wp_list_pluck( $entries, 'name' ), 'The provider should include a stored secret in the listing.' ); } /** @@ -446,16 +486,16 @@ public function test_listing_respects_a_name_prefix(): void { $provider = $this->provider(); $this->require_writable( $provider ); - $provider->set( 'conformance-a/one', 'value' ); - $provider->set( 'conformance-b/two', 'value' ); + $this->assertTrue( $provider->set( 'conformance-a/one', 'value' ), 'Setting conformance-a/one to value should succeed.' ); + $this->assertTrue( $provider->set( 'conformance-b/two', 'value' ), 'Setting conformance-b/two to value should succeed.' ); $entries = $provider->list_secrets( 'conformance-a' ); - $this->assertNotWPError( $entries ); + $this->assertNotWPError( $entries, 'The provider should return the prefixed listing without a WP_Error.' ); $names = wp_list_pluck( $entries, 'name' ); - $this->assertContains( 'conformance-a/one', $names ); - $this->assertNotContains( 'conformance-b/two', $names ); + $this->assertContains( 'conformance-a/one', $names, 'The provider should include a secret matching the prefix.' ); + $this->assertNotContains( 'conformance-b/two', $names, 'The provider should omit a secret not matching the prefix.' ); } } diff --git a/tests/includes/trait-wp-secrets-assertions.php b/tests/includes/trait-wp-secrets-assertions.php index 504d6dd..7958940 100644 --- a/tests/includes/trait-wp-secrets-assertions.php +++ b/tests/includes/trait-wp-secrets-assertions.php @@ -5,7 +5,8 @@ * Kept in one place so a change to what "never contains plaintext" means is a single * edit rather than a search across the suite. * - * @package SecretsAPI + * @package WordPress + * @subpackage Secrets */ /** @@ -50,13 +51,15 @@ public function assertIsSecret( $maybe_secret, $expected = null, $message = '' ) * @param string $slot A WP_Secret_Version constant. * @param string $expected Expected plaintext. * @param bool $network Whether this is a network-scope secret. + * @param string $message Optional failure message. * * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $slot */ - public function assertRecordSlotDecryptsTo( $name, $slot, $expected, $network = false ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid -- PHPUnit assertion naming, like assertWPError(). - $secret = $network ? wp_get_network_secret( $name, $slot ) : wp_get_secret( $name, $slot ); + public function assertRecordSlotDecryptsTo( $name, $slot, $expected, $network = false, $message = '' ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid -- PHPUnit assertion naming, like assertWPError(). + $secret = $network ? wp_get_network_secret( $name, $slot ) : wp_get_secret( $name, $slot ); + $context = '' !== $message ? $message . ' ' : ''; - $this->assertIsSecret( $secret, $expected, sprintf( 'Slot "%s" of "%s":', $slot, $name ) ); + $this->assertIsSecret( $secret, $expected, $context . sprintf( 'Slot "%s" of "%s":', $slot, $name ) ); } /** @@ -79,7 +82,7 @@ public function assertNeverContainsPlaintext( $plaintext, $haystack, $message = $this->assertStringNotContainsString( $plaintext, $flattened, - '' !== $message ? $message : 'Plaintext leaked into a structure that must never contain it.' + '' !== $message ? $message : 'Plaintext leaked into a structure that should never contain it.' ); } } diff --git a/tests/phpunit/test-secrets-broken-dropin-fallbacks.php b/tests/phpunit/test-secrets-broken-dropin-fallbacks.php index 6c77d26..6bff50f 100644 --- a/tests/phpunit/test-secrets-broken-dropin-fallbacks.php +++ b/tests/phpunit/test-secrets-broken-dropin-fallbacks.php @@ -4,75 +4,136 @@ * sentinels _wp_secrets_get_store()/_wp_secrets_get_key_manager() fall back to when * a secrets.php drop-in exists but left an invalid override. * + * @ticket 66187 + * * @group secrets */ class Tests_Secrets_BrokenDropinFallbacks extends WP_UnitTestCase { + /** + * Ensure that WP_Secrets_Broken_Store implements the WP_Secrets_Store interface. + * + * @coversNothing + */ public function test_broken_store_implements_the_interface(): void { - $this->assertInstanceOf( WP_Secrets_Store::class, new WP_Secrets_Broken_Store() ); // @phpstan-ignore method.alreadyNarrowedType (Intentionally checking the class against its interface at runtime.) + $this->assertInstanceOf( WP_Secrets_Store::class, new WP_Secrets_Broken_Store(), 'WP_Secrets_Broken_Store should implement WP_Secrets_Store.' ); // @phpstan-ignore method.alreadyNarrowedType (Intentionally checking the class against its interface at runtime.) } + /** + * Ensure that WP_Secrets_Broken_Store::get() fails with a store-unavailable error. + * + * @covers WP_Secrets_Broken_Store::get + */ public function test_broken_store_get_fails(): void { $result = ( new WP_Secrets_Broken_Store() )->get( 'myplugin/api-key' ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_STORE_UNAVAILABLE, $result->get_error_code() ); + $this->assertWPError( $result, 'get() on the broken store should return a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_STORE_UNAVAILABLE, $result->get_error_code(), 'get() on the broken store should report the store as unavailable.' ); } + /** + * Ensure that WP_Secrets_Broken_Store::set() fails with a store-unavailable error. + * + * @covers WP_Secrets_Broken_Store::set + */ public function test_broken_store_set_fails(): void { $result = ( new WP_Secrets_Broken_Store() )->set( 'myplugin/api-key', array() ); - $this->assertWPError( $result ); + $this->assertWPError( $result, 'set() on the broken store should return a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_STORE_UNAVAILABLE, $result->get_error_code(), 'set() on the broken store should report the store as unavailable.' ); } + /** + * Ensure that WP_Secrets_Broken_Store::delete() fails with a store-unavailable error. + * + * @covers WP_Secrets_Broken_Store::delete + */ public function test_broken_store_delete_fails(): void { $result = ( new WP_Secrets_Broken_Store() )->delete( 'myplugin/api-key' ); - $this->assertWPError( $result ); + $this->assertWPError( $result, 'delete() on the broken store should return a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_STORE_UNAVAILABLE, $result->get_error_code(), 'delete() on the broken store should report the store as unavailable.' ); } + /** + * Ensure that WP_Secrets_Broken_Store::list_names() fails with a store-unavailable error. + * + * @covers WP_Secrets_Broken_Store::list_names + */ public function test_broken_store_list_names_fails(): void { $result = ( new WP_Secrets_Broken_Store() )->list_names(); - $this->assertWPError( $result ); + $this->assertWPError( $result, 'list_names() on the broken store should return a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_STORE_UNAVAILABLE, $result->get_error_code(), 'list_names() on the broken store should report the store as unavailable.' ); } - + /** + * Ensure that WP_Secrets_Broken_Keyring implements the WP_Secrets_Keyring interface. + * + * @coversNothing + */ public function test_broken_keyring_implements_the_interface(): void { - $this->assertInstanceOf( WP_Secrets_Keyring::class, new WP_Secrets_Broken_Keyring() ); // @phpstan-ignore method.alreadyNarrowedType (Intentionally checking the class against its interface at runtime.) + $this->assertInstanceOf( WP_Secrets_Keyring::class, new WP_Secrets_Broken_Keyring(), 'WP_Secrets_Broken_Keyring should implement WP_Secrets_Keyring.' ); // @phpstan-ignore method.alreadyNarrowedType (Intentionally checking the class against its interface at runtime.) } + /** + * Ensure that WP_Secrets_Broken_Keyring::wrap() fails with a key-unavailable error. + * + * @covers WP_Secrets_Broken_Keyring::wrap + */ public function test_broken_keyring_wrap_fails(): void { $result = ( new WP_Secrets_Broken_Keyring() )->wrap( 'material' ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() ); + $this->assertWPError( $result, 'wrap() on the broken keyring should return a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code(), 'wrap() on the broken keyring should report the key as unavailable.' ); } + /** + * Ensure that WP_Secrets_Broken_Keyring::unwrap() fails with a key-unavailable error. + * + * @covers WP_Secrets_Broken_Keyring::unwrap + */ public function test_broken_keyring_unwrap_fails(): void { $result = ( new WP_Secrets_Broken_Keyring() )->unwrap( 'wrapped' ); - $this->assertWPError( $result ); + $this->assertWPError( $result, 'unwrap() on the broken keyring should return a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code(), 'unwrap() on the broken keyring should report the key as unavailable.' ); } + /** + * Ensure that WP_Secrets_Broken_Keyring::get_key_source() returns a description for Site Health. + * + * @covers WP_Secrets_Broken_Keyring::get_key_source + */ public function test_broken_keyring_reports_a_key_source(): void { - $this->assertIsString( ( new WP_Secrets_Broken_Keyring() )->get_key_source() ); + $this->assertIsString( ( new WP_Secrets_Broken_Keyring() )->get_key_source(), 'The broken keyring should report its key source as a string.' ); } + /** + * Ensure that wp_using_secrets_dropin() returns false when no drop-in has been loaded. + * + * @covers ::wp_using_secrets_dropin + */ public function test_wp_using_secrets_dropin_is_false_by_default(): void { - $this->assertFalse( wp_using_secrets_dropin() ); + $this->assertFalse( wp_using_secrets_dropin(), 'wp_using_secrets_dropin() should be false when no drop-in is loaded.' ); } /** + * Ensure that wp_using_secrets_dropin() returns true once the drop-in loader flag is set. + * + * @covers ::wp_using_secrets_dropin + * * @runInSeparateProcess * @preserveGlobalState disabled */ public function test_wp_using_secrets_dropin_reports_true_once_the_loader_flag_is_set(): void { - // Simulates what wp_secrets_api_load_dropin() sets when a drop-in file is - // found, without depending on an actual file on disk – see - // tests/phpunit/test-secrets-extension-points.php for why. + /* + * Simulates what wp_secrets_api_load_dropin() sets when a drop-in file is found. + * The loader runs once per request, before any test, so it cannot be driven + * against a real file here. + */ $GLOBALS['wp_secrets_dropin_loaded'] = true; - $this->assertTrue( wp_using_secrets_dropin() ); + $this->assertTrue( wp_using_secrets_dropin(), 'wp_using_secrets_dropin() should be true once the loader flag is set.' ); } } diff --git a/tests/phpunit/test-secrets-config-keyring-conformance.php b/tests/phpunit/test-secrets-config-keyring-conformance.php index 51f486f..ea66013 100644 --- a/tests/phpunit/test-secrets-config-keyring-conformance.php +++ b/tests/phpunit/test-secrets-config-keyring-conformance.php @@ -7,7 +7,13 @@ * known-good subject a host writing a KMS- or HSM-backed keyring can compare * their own failures against. * + * @ticket 66187 + * * @group secrets + * + * @covers WP_Secrets_Config_Key_Provider::wrap + * @covers WP_Secrets_Config_Key_Provider::unwrap + * @covers WP_Secrets_Config_Key_Provider::get_key_source */ class Tests_Secrets_ConfigKeyringConformance extends WP_Secrets_Keyring_Conformance { diff --git a/tests/phpunit/test-secrets-error-codes.php b/tests/phpunit/test-secrets-error-codes.php index 9d5dc6f..8be7202 100644 --- a/tests/phpunit/test-secrets-error-codes.php +++ b/tests/phpunit/test-secrets-error-codes.php @@ -3,52 +3,84 @@ * Pins the exact error-code strings so a later commit can't accidentally rename * one – these are part of the API's public error contract. * + * @ticket 66187 + * * @group secrets */ class Tests_Secrets_ErrorCodes extends WP_UnitTestCase { /** + * Ensure that each error code constant holds its published string value. + * + * @coversNothing + * * @dataProvider data_published_error_codes * * @param string $constant Name of the error-code constant. * @param string $expected Value the published contract assigns to it. */ public function test_error_code_values_match_the_published_contract( $constant, $expected ): void { - $this->assertSame( $expected, constant( $constant ) ); + $this->assertSame( $expected, constant( $constant ), sprintf( '%s should hold its published error code string.', $constant ) ); } /** * @return array */ - public function data_published_error_codes() { + public static function data_published_error_codes() { return array( - 'decryption failed' => array( 'WP_SECRETS_ERROR_DECRYPTION_FAILED', 'secret_decryption_failed' ), - 'key unavailable' => array( 'WP_SECRETS_ERROR_KEY_UNAVAILABLE', 'secret_key_unavailable' ), - 'store unavailable' => array( 'WP_SECRETS_ERROR_STORE_UNAVAILABLE', 'secret_store_unavailable' ), - 'invalid name' => array( 'WP_SECRETS_ERROR_INVALID_NAME', 'secret_invalid_name' ), - 'invalid value' => array( 'WP_SECRETS_ERROR_INVALID_VALUE', 'secret_invalid_value' ), - 'crypto unavailable' => array( 'WP_SECRETS_ERROR_CRYPTO_UNAVAILABLE', 'secret_crypto_unavailable' ), - 'record malformed' => array( 'WP_SECRETS_ERROR_RECORD_MALFORMED', 'secret_record_malformed' ), + 'decryption failed' => array( 'WP_SECRETS_ERROR_DECRYPTION_FAILED', 'secret_decryption_failed' ), + 'key unavailable' => array( 'WP_SECRETS_ERROR_KEY_UNAVAILABLE', 'secret_key_unavailable' ), + 'store unavailable' => array( 'WP_SECRETS_ERROR_STORE_UNAVAILABLE', 'secret_store_unavailable' ), + 'invalid name' => array( 'WP_SECRETS_ERROR_INVALID_NAME', 'secret_invalid_name' ), + 'invalid value' => array( 'WP_SECRETS_ERROR_INVALID_VALUE', 'secret_invalid_value' ), + 'invalid argument' => array( 'WP_SECRETS_ERROR_INVALID_ARGUMENT', 'secret_invalid_argument' ), + 'crypto unavailable' => array( 'WP_SECRETS_ERROR_CRYPTO_UNAVAILABLE', 'secret_crypto_unavailable' ), + 'record malformed' => array( 'WP_SECRETS_ERROR_RECORD_MALFORMED', 'secret_record_malformed' ), + 'record unsupported version' => array( 'WP_SECRETS_ERROR_RECORD_UNSUPPORTED_VERSION', 'secret_record_unsupported_version' ), + 'provider read only' => array( 'WP_SECRETS_ERROR_PROVIDER_READ_ONLY', 'secret_provider_read_only' ), ); } + /** + * Ensure that no two error code constants share a value. + * + * @coversNothing + */ public function test_all_error_codes_are_distinct(): void { - $codes = array( - WP_SECRETS_ERROR_DECRYPTION_FAILED, - WP_SECRETS_ERROR_KEY_UNAVAILABLE, - WP_SECRETS_ERROR_STORE_UNAVAILABLE, - WP_SECRETS_ERROR_INVALID_NAME, - WP_SECRETS_ERROR_INVALID_VALUE, - WP_SECRETS_ERROR_CRYPTO_UNAVAILABLE, - WP_SECRETS_ERROR_RECORD_MALFORMED, + $codes = array_map( 'constant', array_column( self::data_published_error_codes(), 0 ) ); + + $this->assertSame( count( $codes ), count( array_unique( $codes ) ), 'Every error code constant should have a distinct value.' ); + } + + /** + * Ensure that every WP_SECRETS_ERROR_* constant is pinned by the published contract. + * + * Fails when a new error code is added without being added to the data provider + * above, so the contract cannot silently fall behind the codes the API returns. + * + * @coversNothing + */ + public function test_every_error_code_constant_is_pinned(): void { + $defined = array_filter( + array_keys( get_defined_constants() ), + static function ( $name ) { + return str_starts_with( $name, 'WP_SECRETS_ERROR_' ); + } ); - $this->assertSame( count( $codes ), count( array_unique( $codes ) ) ); + $this->assertSameSets( array_column( self::data_published_error_codes(), 0 ), $defined, 'Every WP_SECRETS_ERROR_* constant should be listed in the published contract.' ); } + /** + * Ensure that WP_SECRETS_MAX_NAME_LENGTH fits the longest prefixed name in option_name. + * + * @coversNothing + */ public function test_max_name_length_matches_the_options_column_budget(): void { - // wp_options.option_name is VARCHAR(191); '_wp_network_secret_' (19 - // characters) is the longest prefix any store here uses. - $this->assertSame( 191 - strlen( '_wp_network_secret_' ), WP_SECRETS_MAX_NAME_LENGTH ); + /* + * wp_options.option_name is VARCHAR(191); '_wp_network_secret_' (19 + * characters) is the longest prefix any store here uses. + */ + $this->assertSame( 191 - strlen( '_wp_network_secret_' ), WP_SECRETS_MAX_NAME_LENGTH, 'WP_SECRETS_MAX_NAME_LENGTH should fit the longest prefixed name in option_name.' ); } } diff --git a/tests/phpunit/test-secrets-extension-points.php b/tests/phpunit/test-secrets-extension-points.php index f5df122..33c6537 100644 --- a/tests/phpunit/test-secrets-extension-points.php +++ b/tests/phpunit/test-secrets-extension-points.php @@ -12,11 +12,13 @@ * first time either is called in a process, and by the time any test method runs, * dozens of earlier tests have already triggered that caching with the defaults. * - * @runInSeparateProcess gives each test a fresh process with nothing cached yet, so - * setting the global before the first call actually takes effect. This tests the - * real consumption logic in _wp_secrets_get_store()/_wp_secrets_get_key_manager(); - * see docs/journal/test-coverage-gaps.md for what this does not cover (the drop-in's own file - * loading, which cannot be driven the same way). + * Running each test in a separate process gives it a fresh process with nothing + * cached yet, so setting the global before the first call actually takes effect. + * This tests the real consumption logic in _wp_secrets_get_store() and + * _wp_secrets_get_key_manager(). It does not cover wp_secrets_api_load_dropin() loading + * a drop-in file from disk, which cannot be driven the same way. + * + * @ticket 66187 * * @group secrets */ @@ -25,6 +27,10 @@ class Tests_Secrets_ExtensionPoints extends WP_UnitTestCase { use WP_Secrets_Assertions; /** + * Ensure that a secret round-trips through a plugin-supplied store paired with the default keyring. + * + * @covers ::_wp_secrets_get_store + * * @runInSeparateProcess * @preserveGlobalState disabled */ @@ -34,30 +40,39 @@ public function test_mock_store_with_the_default_keyring_round_trips(): void { $GLOBALS['wp_secrets_store'] = new Mock_Store(); - $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ) ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Writing through the mock store should succeed.' ); $secret = wp_get_secret( 'myplugin/api-key' ); - $this->assertInstanceOf( WP_Secret::class, $secret ); - $this->assertSame( 'value', $secret->reveal() ); + $this->assertInstanceOf( WP_Secret::class, $secret, 'Reading through the mock store should return a WP_Secret.' ); + $this->assertSame( 'value', $secret->reveal(), 'The secret should reveal the value written through the mock store.' ); } /** + * Ensure that a secret round-trips through a plugin-supplied keyring paired with the default store. + * + * @covers ::_wp_secrets_get_key_manager + * * @runInSeparateProcess * @preserveGlobalState disabled */ public function test_mock_keyring_with_the_default_store_round_trips(): void { $GLOBALS['wp_secrets_keyring'] = new Mock_Keyring(); - $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ) ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Writing through the mock keyring should succeed.' ); $secret = wp_get_secret( 'myplugin/api-key' ); - $this->assertInstanceOf( WP_Secret::class, $secret ); - $this->assertSame( 'value', $secret->reveal() ); + $this->assertInstanceOf( WP_Secret::class, $secret, 'Reading through the mock keyring should return a WP_Secret.' ); + $this->assertSame( 'value', $secret->reveal(), 'The secret should reveal the value written through the mock keyring.' ); } /** + * Ensure that a secret round-trips when both the store and the keyring are plugin-supplied. + * + * @covers ::_wp_secrets_get_store + * @covers ::_wp_secrets_get_key_manager + * * @runInSeparateProcess * @preserveGlobalState disabled */ @@ -65,18 +80,21 @@ public function test_mock_store_and_mock_keyring_together_round_trip(): void { $GLOBALS['wp_secrets_store'] = new Mock_Store(); $GLOBALS['wp_secrets_keyring'] = new Mock_Keyring(); - $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ) ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Writing through the mock store and keyring should succeed.' ); $secret = wp_get_secret( 'myplugin/api-key' ); - $this->assertInstanceOf( WP_Secret::class, $secret ); - $this->assertSame( 'value', $secret->reveal() ); + $this->assertInstanceOf( WP_Secret::class, $secret, 'Reading through the mock store and keyring should return a WP_Secret.' ); + $this->assertSame( 'value', $secret->reveal(), 'The secret should reveal the value written through the mock store and keyring.' ); } /** * A store that can read but not write: an existing value seeded before write * support is turned off must remain readable, while a new write is rejected. * + * @covers ::wp_set_secret + * @covers ::wp_get_secret + * * @runInSeparateProcess * @preserveGlobalState disabled */ @@ -87,65 +105,85 @@ public function test_read_succeeds_while_write_fails(): void { $store = new Mock_Store(); $GLOBALS['wp_secrets_store'] = $store; - wp_set_secret( 'myplugin/api-key', 'seeded-before-write-broke' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'seeded-before-write-broke' ), 'Setting myplugin/api-key to seeded-before-write-broke should succeed.' ); $store->configure_fail( 'set', true ); $write_result = wp_set_secret( 'myplugin/api-key', 'new-value' ); $read_result = wp_get_secret( 'myplugin/api-key' ); - $this->assertWPError( $write_result ); - $this->assertInstanceOf( WP_Secret::class, $read_result ); - $this->assertSame( 'seeded-before-write-broke', $read_result->reveal() ); + $this->assertWPError( $write_result, 'A write to a store that cannot write should return a WP_Error.' ); + $this->assertInstanceOf( WP_Secret::class, $read_result, 'A read from a store that cannot write should still return a WP_Secret.' ); + $this->assertSame( 'seeded-before-write-broke', $read_result->reveal(), 'The secret should reveal the value seeded before writes were disabled.' ); } - /** - * A store that refuses writes must still serve reads. This previously configured - * a capability flag that nothing consulted, so it passed regardless of behavior. - * It now makes set() actually fail, and checks both halves. + * Ensure that an unreachable plugin-supplied store fails reads and writes without a local fallback. + * + * @covers ::_wp_secrets_get_store + * @covers ::wp_set_secret + * @covers ::wp_get_secret * * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_a_store_that_refuses_writes_still_serves_reads(): void { + public function test_unreachable_store_fails_closed_with_no_local_fallback(): void { // Isolated, so this test supplies the site key rather than relying on the test config's salts. define( 'WP_SECRETS_KEY', base64_encode( str_repeat( 'k', 32 ) ) ); $store = new Mock_Store(); - $GLOBALS['wp_secrets_store'] = $store; - - wp_set_secret( 'myplugin/api-key', 'value' ); - $store->configure_fail( 'set', true ); - - $this->assertWPError( wp_set_secret( 'myplugin/api-key', 'another' ) ); + $GLOBALS['wp_secrets_store'] = $store->configure_fail( 'get', true )->configure_fail( 'set', true ); - $secret = wp_get_secret( 'myplugin/api-key' ); + $set_result = wp_set_secret( 'myplugin/api-key', 'value' ); + $get_result = wp_get_secret( 'myplugin/api-key' ); - $this->assertInstanceOf( WP_Secret::class, $secret ); - $this->assertSame( 'value', $secret->reveal() ); + $this->assertWPError( $set_result, 'A write to an unreachable store should return a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_STORE_UNAVAILABLE, $set_result->get_error_code(), 'A write to an unreachable store should report the store as unavailable.' ); + $this->assertWPError( $get_result, 'A read from an unreachable store should return a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_STORE_UNAVAILABLE, $get_result->get_error_code(), 'A read from an unreachable store should report the store as unavailable.' ); + + /* + * Fails closed, not merely differently: nothing was written to the real + * default store either, which a silent fallback would have done. + */ + $this->assertFalse( get_option( '_wp_secret_myplugin/api-key' ), 'Nothing should be written to the default options store as a fallback.' ); + $this->assertFalse( get_site_option( '_wp_network_secret_myplugin/api-key' ), 'Nothing should be written to the default network store as a fallback.' ); } /** + * Ensure that wp_list_secrets() returns a WP_Error when the store cannot list its secrets. + * + * An unreachable store must not be reported as an empty listing, which would read + * as "no secrets exist", and the error passed back must not carry a stored value. + * + * @covers ::wp_list_secrets + * * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_unreachable_store_fails_closed_with_no_local_fallback(): void { + public function test_listing_fails_closed_when_the_store_cannot_list(): void { + // Isolated, so this test supplies the site key rather than relying on the test config's salts. + define( 'WP_SECRETS_KEY', base64_encode( str_repeat( 'k', 32 ) ) ); + $store = new Mock_Store(); - $GLOBALS['wp_secrets_store'] = $store->configure_fail( 'get', true )->configure_fail( 'set', true ); + $GLOBALS['wp_secrets_store'] = $store; - $set_result = wp_set_secret( 'myplugin/api-key', 'value' ); - $get_result = wp_get_secret( 'myplugin/api-key' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'UNIQUE-PLAINTEXT-CANARY-5c1e' ), 'Setting myplugin/api-key to UNIQUE-PLAINTEXT-CANARY-5c1e should succeed.' ); + $store->configure_fail( 'list', true ); - $this->assertWPError( $set_result ); - $this->assertWPError( $get_result ); + $result = wp_list_secrets(); - // Fails closed, not merely differently: nothing was written to the real - // default store either, which a silent fallback would have done. - $this->assertFalse( get_option( '_wp_secret_myplugin/api-key' ) ); + $this->assertWPError( $result, 'A store that cannot list should be reported as an error, not an empty list.' ); + $this->assertSame( WP_SECRETS_ERROR_STORE_UNAVAILABLE, $result->get_error_code(), 'The store error should be passed through unchanged.' ); + $this->assertNeverContainsPlaintext( 'UNIQUE-PLAINTEXT-CANARY-5c1e', $result, 'A listing error should never carry a stored value.' ); } /** + * Ensure that a keyring that cannot wrap keys fails the write and stores nothing locally. + * + * @covers ::_wp_secrets_get_key_manager + * @covers ::wp_set_secret + * * @runInSeparateProcess * @preserveGlobalState disabled */ @@ -154,8 +192,9 @@ public function test_unreachable_keyring_fails_closed_with_no_local_fallback(): $result = wp_set_secret( 'myplugin/api-key', 'value' ); - $this->assertWPError( $result ); - $this->assertFalse( get_option( '_wp_secret_myplugin/api-key' ) ); + $this->assertWPError( $result, 'A write through a keyring that cannot wrap should return a WP_Error.' ); + $this->assertFalse( get_option( '_wp_secret_myplugin/api-key' ), 'Nothing should be written to the default options store as a fallback.' ); + $this->assertFalse( get_site_option( '_wp_network_secret_myplugin/api-key' ), 'Nothing should be written to the default network store as a fallback.' ); } /** @@ -163,15 +202,22 @@ public function test_unreachable_keyring_fails_closed_with_no_local_fallback(): * the whole point of the envelope. Mock_Store records every record it is * given; this asserts the plaintext appears nowhere in any of them. * + * @covers ::wp_set_secret + * @covers WP_Secrets_Libsodium_Provider::set + * * @runInSeparateProcess * @preserveGlobalState disabled */ public function test_the_store_is_never_handed_a_plaintext(): void { + // Isolated, so this test supplies the site key rather than relying on the test config's salts. + define( 'WP_SECRETS_KEY', base64_encode( str_repeat( 'k', 32 ) ) ); + $store = new Mock_Store(); $GLOBALS['wp_secrets_store'] = $store; - wp_set_secret( 'myplugin/api-key', 'UNIQUE-PLAINTEXT-CANARY-9f3a' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'UNIQUE-PLAINTEXT-CANARY-9f3a' ), 'Setting myplugin/api-key to UNIQUE-PLAINTEXT-CANARY-9f3a should succeed.' ); + $this->assertNotEmpty( $store->get_received_records(), 'The store should have been handed the encrypted record.' ); $this->assertNeverContainsPlaintext( 'UNIQUE-PLAINTEXT-CANARY-9f3a', $store->get_received_records(), @@ -181,23 +227,30 @@ public function test_the_store_is_never_handed_a_plaintext(): void { /** * Simulates the state a broken secrets.php drop-in leaves behind - * ($GLOBALS['wp_secrets_dropin_broken'] set by wp_secrets_api_load_dropin() – - * see that function's docblock in secrets-api.php for why the drop-in's own file - * loading cannot be driven the same way this simulates its aftermath) and + * ($GLOBALS['wp_secrets_dropin_broken'], set by wp_secrets_api_load_dropin(), which + * runs once per request before any test and so cannot be driven directly) and * confirms every operation fails closed with WP_Error rather than a fatal error * or a silent fallback to local storage. * + * @covers ::_wp_secrets_get_provider + * @covers WP_Secrets_Broken_Provider::set + * @covers WP_Secrets_Broken_Provider::get + * @covers WP_Secrets_Broken_Provider::delete + * @covers WP_Secrets_Broken_Provider::list_secrets + * @covers WP_Secrets_Broken_Provider::retire_previous + * @covers WP_Secrets_Broken_Provider::is_writable + * * @runInSeparateProcess * @preserveGlobalState disabled */ public function test_a_broken_dropin_state_fails_every_operation_closed(): void { $GLOBALS['wp_secrets_dropin_broken'] = true; - $this->assertWPError( wp_set_secret( 'myplugin/api-key', 'value' ) ); - $this->assertWPError( wp_get_secret( 'myplugin/api-key' ) ); - $this->assertWPError( wp_delete_secret( 'myplugin/api-key' ) ); - $this->assertWPError( wp_list_secrets() ); - $this->assertWPError( wp_retire_secret_version( 'myplugin/api-key' ) ); - $this->assertFalse( wp_secrets_provider_is_writable() ); + $this->assertWPError( wp_set_secret( 'myplugin/api-key', 'value' ), 'wp_set_secret() should return a WP_Error when the drop-in is broken.' ); + $this->assertWPError( wp_get_secret( 'myplugin/api-key' ), 'wp_get_secret() should return a WP_Error when the drop-in is broken.' ); + $this->assertWPError( wp_delete_secret( 'myplugin/api-key' ), 'wp_delete_secret() should return a WP_Error when the drop-in is broken.' ); + $this->assertWPError( wp_list_secrets(), 'wp_list_secrets() should return a WP_Error when the drop-in is broken.' ); + $this->assertWPError( wp_retire_secret_version( 'myplugin/api-key' ), 'wp_retire_secret_version() should return a WP_Error when the drop-in is broken.' ); + $this->assertFalse( wp_secrets_provider_is_writable(), 'The provider should not report as writable when the drop-in is broken.' ); } } diff --git a/tests/phpunit/test-secrets-mock-keyring-conformance.php b/tests/phpunit/test-secrets-mock-keyring-conformance.php index 2c9911e..05029ea 100644 --- a/tests/phpunit/test-secrets-mock-keyring-conformance.php +++ b/tests/phpunit/test-secrets-mock-keyring-conformance.php @@ -7,7 +7,11 @@ * substitutes for, so a test that passes with Mock_Keyring in place is a test * that would also have to reckon with a conforming real keyring. * + * @ticket 66187 + * * @group secrets + * + * @coversNothing */ class Tests_Secrets_MockKeyringConformance extends WP_Secrets_Keyring_Conformance { diff --git a/tests/phpunit/test-secrets-network-scope.php b/tests/phpunit/test-secrets-network-scope.php index 6875bec..2f97644 100644 --- a/tests/phpunit/test-secrets-network-scope.php +++ b/tests/phpunit/test-secrets-network-scope.php @@ -2,76 +2,120 @@ /** * Tests for the network-scope public functions. * + * @ticket 66187 + * * @group secrets */ class Tests_Secrets_NetworkScope extends WP_UnitTestCase { use WP_Secrets_Assertions; + /** + * Ensure that a value stored by wp_set_network_secret() is returned by wp_get_network_secret(). + * + * @covers ::wp_set_network_secret + * @covers ::wp_get_network_secret + */ public function test_set_then_get_round_trips(): void { - $this->assertTrue( wp_set_network_secret( 'myplugin/api-key', 'value' ) ); + $this->assertTrue( wp_set_network_secret( 'myplugin/api-key', 'value' ), 'Setting a network secret should succeed.' ); $secret = wp_get_network_secret( 'myplugin/api-key' ); - $this->assertInstanceOf( WP_Secret::class, $secret ); - $this->assertSame( 'value', $secret->reveal() ); + $this->assertInstanceOf( WP_Secret::class, $secret, 'The network secret should be readable as a WP_Secret.' ); + $this->assertSame( 'value', $secret->reveal(), 'The network secret should return the value it was set to.' ); } + /** + * Ensure that wp_get_network_secret() returns null for a network secret that was never set. + * + * @covers ::wp_get_network_secret + */ public function test_get_returns_null_for_an_absent_secret(): void { - $this->assertNull( wp_get_network_secret( 'myplugin/never-set' ) ); + $this->assertNull( wp_get_network_secret( 'myplugin/never-set' ), 'A network secret that was never set should be null.' ); } + /** + * Ensure that a site secret and a network secret with the same name hold separate values. + * + * @covers ::wp_set_network_secret + * @covers ::wp_get_network_secret + */ public function test_site_and_network_scope_are_independent_under_the_same_name(): void { - wp_set_secret( 'myplugin/api-key', 'site-value' ); - wp_set_network_secret( 'myplugin/api-key', 'network-value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'site-value' ), 'Setting myplugin/api-key to site-value should succeed.' ); + $this->assertTrue( wp_set_network_secret( 'myplugin/api-key', 'network-value' ), 'Setting network secret myplugin/api-key to network-value should succeed.' ); $site_secret = wp_get_secret( 'myplugin/api-key' ); $network_secret = wp_get_network_secret( 'myplugin/api-key' ); - $this->assertInstanceOf( WP_Secret::class, $site_secret ); - $this->assertInstanceOf( WP_Secret::class, $network_secret ); - $this->assertSame( 'site-value', $site_secret->reveal() ); - $this->assertSame( 'network-value', $network_secret->reveal() ); + $this->assertInstanceOf( WP_Secret::class, $site_secret, 'The site secret should be readable as a WP_Secret.' ); + $this->assertInstanceOf( WP_Secret::class, $network_secret, 'The network secret should be readable as a WP_Secret.' ); + $this->assertSame( 'site-value', $site_secret->reveal(), 'The site secret should keep its own value.' ); + $this->assertSame( 'network-value', $network_secret->reveal(), 'The network secret should keep its own value.' ); } + /** + * Ensure that wp_delete_network_secret() removes a network secret so it can no longer be read. + * + * @covers ::wp_delete_network_secret + */ public function test_delete_removes_a_network_secret(): void { - wp_set_network_secret( 'myplugin/api-key', 'value' ); + $this->assertTrue( wp_set_network_secret( 'myplugin/api-key', 'value' ), 'Setting network secret myplugin/api-key to value should succeed.' ); - $this->assertTrue( wp_delete_network_secret( 'myplugin/api-key' ) ); - $this->assertNull( wp_get_network_secret( 'myplugin/api-key' ) ); + $this->assertTrue( wp_delete_network_secret( 'myplugin/api-key' ), 'Deleting a network secret should succeed.' ); + $this->assertNull( wp_get_network_secret( 'myplugin/api-key' ), 'A deleted network secret should be null.' ); } + /** + * Ensure that wp_delete_network_secret() leaves a site secret of the same name in place. + * + * @covers ::wp_delete_network_secret + */ public function test_delete_does_not_touch_the_site_scope_secret_of_the_same_name(): void { - wp_set_secret( 'myplugin/api-key', 'site-value' ); - wp_set_network_secret( 'myplugin/api-key', 'network-value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'site-value' ), 'Setting myplugin/api-key to site-value should succeed.' ); + $this->assertTrue( wp_set_network_secret( 'myplugin/api-key', 'network-value' ), 'Setting network secret myplugin/api-key to network-value should succeed.' ); - wp_delete_network_secret( 'myplugin/api-key' ); + $this->assertTrue( wp_delete_network_secret( 'myplugin/api-key' ), 'Deleting network secret myplugin/api-key should succeed.' ); $site_secret = wp_get_secret( 'myplugin/api-key' ); - $this->assertInstanceOf( WP_Secret::class, $site_secret ); - $this->assertSame( 'site-value', $site_secret->reveal() ); + $this->assertInstanceOf( WP_Secret::class, $site_secret, 'The site secret should still be readable as a WP_Secret.' ); + $this->assertSame( 'site-value', $site_secret->reveal(), 'The site secret should keep its value after the network secret is deleted.' ); } - public function test_previous_version_and_demotion_work_the_same_as_site_scope(): void { - wp_set_network_secret( 'myplugin/api-key', 'first-value' ); - wp_set_network_secret( 'myplugin/api-key', 'second-value' ); + /** + * Ensure that overwriting a network secret demotes the old value to the previous slot. + * + * @covers ::wp_set_network_secret + */ + public function test_overwriting_a_network_secret_demotes_the_old_value(): void { + $this->assertTrue( wp_set_network_secret( 'myplugin/api-key', 'first-value' ), 'Setting network secret myplugin/api-key to first-value should succeed.' ); + $this->assertTrue( wp_set_network_secret( 'myplugin/api-key', 'second-value' ), 'Setting network secret myplugin/api-key to second-value should succeed.' ); - $this->assertRecordSlotDecryptsTo( 'myplugin/api-key', WP_Secret_Version::PREVIOUS, 'first-value', true ); - $this->assertRecordSlotDecryptsTo( 'myplugin/api-key', WP_Secret_Version::CURRENT, 'second-value', true ); + $this->assertRecordSlotDecryptsTo( 'myplugin/api-key', WP_Secret_Version::PREVIOUS, 'first-value', true, 'The previous slot should decrypt to the first network value.' ); + $this->assertRecordSlotDecryptsTo( 'myplugin/api-key', WP_Secret_Version::CURRENT, 'second-value', true, 'The current slot should decrypt to the second network value.' ); } + /** + * Ensure that wp_retire_network_secret_version() clears the previous version of a network secret. + * + * @covers ::wp_retire_network_secret_version + */ public function test_retire_clears_the_previous_slot(): void { - wp_set_network_secret( 'myplugin/api-key', 'first-value' ); - wp_set_network_secret( 'myplugin/api-key', 'second-value' ); + $this->assertTrue( wp_set_network_secret( 'myplugin/api-key', 'first-value' ), 'Setting network secret myplugin/api-key to first-value should succeed.' ); + $this->assertTrue( wp_set_network_secret( 'myplugin/api-key', 'second-value' ), 'Setting network secret myplugin/api-key to second-value should succeed.' ); - $this->assertTrue( wp_retire_network_secret_version( 'myplugin/api-key' ) ); - $this->assertNull( wp_get_network_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS ) ); + $this->assertTrue( wp_retire_network_secret_version( 'myplugin/api-key' ), 'Retiring the previous network version should succeed.' ); + $this->assertNull( wp_get_network_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS ), 'The previous network version should be gone after retiring it.' ); } /** * A listener is told which scope changed. Without it, a network secret and a * site secret of the same name are indistinguishable in an audit log. + * + * @covers ::wp_set_network_secret + * @covers ::wp_retire_network_secret_version + * @covers ::wp_delete_network_secret + * @covers ::wp_set_secret */ public function test_change_hook_reports_network_scope(): void { $scopes = array(); @@ -84,11 +128,11 @@ function ( ...$args ) use ( &$scopes ) { 7 ); - wp_set_network_secret( 'myplugin/api-key', 'first-value' ); - wp_set_network_secret( 'myplugin/api-key', 'second-value' ); - wp_retire_network_secret_version( 'myplugin/api-key' ); - wp_delete_network_secret( 'myplugin/api-key' ); - wp_set_secret( 'myplugin/api-key', 'site-value' ); + $this->assertTrue( wp_set_network_secret( 'myplugin/api-key', 'first-value' ), 'Setting network secret myplugin/api-key to first-value should succeed.' ); + $this->assertTrue( wp_set_network_secret( 'myplugin/api-key', 'second-value' ), 'Setting network secret myplugin/api-key to second-value should succeed.' ); + $this->assertTrue( wp_retire_network_secret_version( 'myplugin/api-key' ), 'Retiring the previous version of network secret myplugin/api-key should succeed.' ); + $this->assertTrue( wp_delete_network_secret( 'myplugin/api-key' ), 'Deleting network secret myplugin/api-key should succeed.' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'site-value' ), 'Setting myplugin/api-key to site-value should succeed.' ); $this->assertSame( array( @@ -98,72 +142,97 @@ function ( ...$args ) use ( &$scopes ) { array( 'deleted', true ), array( 'created', false ), ), - $scopes + $scopes, + 'wp_secret_changed should report each action with its network scope flag.' ); } + /** + * Ensure that wp_list_network_secrets() lists network secrets and omits site secrets. + * + * @covers ::wp_list_network_secrets + */ public function test_list_returns_network_secrets_only(): void { - wp_set_secret( 'myplugin/site-only', 'value' ); - wp_set_network_secret( 'myplugin/network-only', 'value' ); + $this->assertTrue( wp_set_secret( 'myplugin/site-only', 'value' ), 'Setting myplugin/site-only to value should succeed.' ); + $this->assertTrue( wp_set_network_secret( 'myplugin/network-only', 'value' ), 'Setting network secret myplugin/network-only to value should succeed.' ); $entries = wp_list_network_secrets(); - $this->assertIsArray( $entries ); - $this->assertSame( array( 'myplugin/network-only' ), wp_list_pluck( $entries, 'name' ) ); + $this->assertIsArray( $entries, 'wp_list_network_secrets() should return an array.' ); + $this->assertSame( array( 'myplugin/network-only' ), wp_list_pluck( $entries, 'name' ), 'The network listing should contain only the network secret.' ); } + /** + * Ensure that wp_get_network_secret() returns a WP_Error for an unrecognized version. + * + * @covers ::wp_get_network_secret + * @covers ::_wp_secrets_get + */ public function test_an_invalid_version_is_a_wp_error_same_as_site_scope(): void { $this->setExpectedIncorrectUsage( '_wp_secrets_get' ); $result = wp_get_network_secret( 'myplugin/api-key', 'not-a-real-version' ); // @phpstan-ignore argument.type (Intentionally passing an invalid value.) - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code() ); + $this->assertWPError( $result, 'An invalid version should be reported as a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code(), 'An invalid version should be rejected with WP_SECRETS_ERROR_INVALID_ARGUMENT.' ); } + /** + * Ensure that wp_set_network_secret() rejects a name that fails validation. + * + * @covers ::wp_set_network_secret + */ public function test_set_rejects_an_invalid_name(): void { $result = wp_set_network_secret( 'Not A Valid Name', 'value' ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code() ); + $this->assertWPError( $result, 'An invalid name should be rejected with a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code(), 'An invalid name should be rejected with WP_SECRETS_ERROR_INVALID_NAME.' ); } /** * The entire point of network scope: a secret written from one blog's context * must read back identically from another blog's context. Requires multisite. + * + * @group multisite + * @group ms-required + * + * @covers ::wp_set_network_secret + * @covers ::wp_get_network_secret */ public function test_network_secret_is_readable_from_any_blog(): void { - if ( ! is_multisite() ) { - $this->markTestSkipped( 'Requires multisite: proves network secrets are not bound to the blog that wrote them.' ); - } + $this->skipWithoutMultisite(); - wp_set_network_secret( 'myplugin/api-key', 'cross-blog-value' ); + $this->assertTrue( wp_set_network_secret( 'myplugin/api-key', 'cross-blog-value' ), 'Setting network secret myplugin/api-key to cross-blog-value should succeed.' ); $second_blog_id = self::factory()->blog->create(); switch_to_blog( $second_blog_id ); $secret = wp_get_network_secret( 'myplugin/api-key' ); restore_current_blog(); - $this->assertIsSecret( $secret, 'cross-blog-value', 'Read from a second blog:' ); + $this->assertIsSecret( $secret, 'cross-blog-value', 'The network secret should be readable from a second blog.' ); } /** * The mirror image: a site-scope secret must NOT be readable from another blog, * proving there is no implicit fallback between scopes and no accidental sharing * across blogs for site-scope secrets. + * + * @group multisite + * @group ms-required + * + * @covers ::wp_set_secret + * @covers ::wp_get_secret */ public function test_site_secret_is_not_readable_from_a_different_blog(): void { - if ( ! is_multisite() ) { - $this->markTestSkipped( 'Requires multisite: proves site secrets do not leak across blogs.' ); - } + $this->skipWithoutMultisite(); - wp_set_secret( 'myplugin/api-key', 'blog-one-value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'blog-one-value' ), 'Setting myplugin/api-key to blog-one-value should succeed.' ); $second_blog_id = self::factory()->blog->create(); switch_to_blog( $second_blog_id ); $result = wp_get_secret( 'myplugin/api-key' ); restore_current_blog(); - $this->assertNull( $result ); + $this->assertNull( $result, 'A site secret should not be readable from a different blog.' ); } } diff --git a/tests/phpunit/test-secrets-provider-conformance.php b/tests/phpunit/test-secrets-provider-conformance.php index dafcb49..120a790 100644 --- a/tests/phpunit/test-secrets-provider-conformance.php +++ b/tests/phpunit/test-secrets-provider-conformance.php @@ -6,7 +6,18 @@ * kit nobody has run against a working implementation is a wish list. It also * means a host can compare their own failures against a known-good subject. * + * @ticket 66187 + * * @group secrets + * + * @covers WP_Secrets_Libsodium_Provider::get + * @covers WP_Secrets_Libsodium_Provider::set + * @covers WP_Secrets_Libsodium_Provider::delete + * @covers WP_Secrets_Libsodium_Provider::retire_previous + * @covers WP_Secrets_Libsodium_Provider::list_secrets + * @covers WP_Secrets_Libsodium_Provider::is_writable + * @covers WP_Secrets_Libsodium_Provider::get_label + * @covers WP_Secrets_Libsodium_Provider::get_protection_boundary */ class Tests_Secrets_LibsodiumProviderConformance extends WP_Secrets_Provider_Conformance { diff --git a/tests/phpunit/test-secrets-provider.php b/tests/phpunit/test-secrets-provider.php index f107385..b3f1221 100644 --- a/tests/phpunit/test-secrets-provider.php +++ b/tests/phpunit/test-secrets-provider.php @@ -3,6 +3,8 @@ * Tests for the provider seam: the outermost extension point, and what replaced * the store's old supports() flag. * + * @ticket 66187 + * * @group secrets */ class Tests_Secrets_Provider extends WP_UnitTestCase { @@ -94,49 +96,77 @@ public function is_writable(): bool { // – the shipped provider ------------------------------------------------- + /** + * Ensure that WP_Secrets_Libsodium_Provider is used when no drop-in supplies a provider. + * + * @covers ::_wp_secrets_get_provider + */ public function test_the_default_provider_is_the_libsodium_one(): void { - $this->assertInstanceOf( 'WP_Secrets_Libsodium_Provider', _wp_secrets_get_provider() ); + $this->assertInstanceOf( 'WP_Secrets_Libsodium_Provider', _wp_secrets_get_provider(), 'The default provider should be WP_Secrets_Libsodium_Provider.' ); } + /** + * Ensure that the default provider declares BOUNDARY_WORDPRESS as its protection boundary. + * + * @covers WP_Secrets_Libsodium_Provider::get_protection_boundary + */ public function test_the_default_provider_protects_inside_wordpress(): void { $this->assertSame( WP_Secrets_Provider::BOUNDARY_WORDPRESS, - _wp_secrets_get_provider()->get_protection_boundary() + _wp_secrets_get_provider()->get_protection_boundary(), + 'The default provider should declare BOUNDARY_WORDPRESS.' ); } + /** + * Ensure that wp_secrets_provider_is_writable() reports the default provider as writable. + * + * @covers ::wp_secrets_provider_is_writable + */ public function test_the_default_provider_accepts_writes(): void { - $this->assertTrue( wp_secrets_provider_is_writable() ); + $this->assertTrue( wp_secrets_provider_is_writable(), 'The default provider should be writable.' ); } /** * The label is what Site Health shows an operator. It must name the key source, * because "libsodium" alone does not tell anyone whether their root key is * derived from wp-config.php salts or held in a KMS. + * + * @covers ::wp_secrets_provider_label */ public function test_the_default_provider_label_names_the_key_source(): void { $label = wp_secrets_provider_label(); - $this->assertStringContainsString( 'libsodium', $label ); + $this->assertStringContainsString( 'libsodium', $label, 'The provider label should mention libsodium.' ); $this->assertStringContainsString( _wp_secrets_get_key_manager()->get_keyring()->get_key_source(), - $label + $label, + 'The provider label should name the key source.' ); } // – a platform provider, which is what the seam exists for ---------------- /** + * Ensure that a provider set in the wp_secrets_provider global is used in place of the default. + * + * Runs in a separate process because _wp_secrets_get_provider() caches the provider it + * resolves on first use, so the global has to be set before anything asks for one. + * + * @covers ::_wp_secrets_get_provider + * @covers ::wp_secrets_provider_label + * * @runInSeparateProcess * @preserveGlobalState disabled */ public function test_a_drop_in_provider_replaces_the_default_entirely(): void { $GLOBALS['wp_secrets_provider'] = $this->read_only_provider(); - $this->assertSame( 'Example Platform (KMS)', wp_secrets_provider_label() ); + $this->assertSame( 'Example Platform (KMS)', wp_secrets_provider_label(), 'The label should come from the drop-in provider.' ); $this->assertSame( WP_Secrets_Provider::BOUNDARY_PROVIDER, - _wp_secrets_get_provider()->get_protection_boundary() + _wp_secrets_get_provider()->get_protection_boundary(), + 'The protection boundary should come from the drop-in provider.' ); } @@ -145,31 +175,36 @@ public function test_a_drop_in_provider_replaces_the_default_entirely(): void { * refuses writes, declaring it up front so a settings screen can disable its * save control rather than discovering the refusal after the fact. * + * @covers ::wp_secrets_provider_is_writable + * @covers ::wp_set_secret + * * @runInSeparateProcess * @preserveGlobalState disabled */ public function test_a_read_only_provider_refuses_writes_and_declares_it(): void { $GLOBALS['wp_secrets_provider'] = $this->read_only_provider(); - $this->assertFalse( wp_secrets_provider_is_writable() ); + $this->assertFalse( wp_secrets_provider_is_writable(), 'A read-only provider should report as not writable.' ); $result = wp_set_secret( 'myplugin/api-key', 'value' ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_PROVIDER_READ_ONLY, $result->get_error_code() ); + $this->assertWPError( $result, 'A write to a read-only provider should return a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_PROVIDER_READ_ONLY, $result->get_error_code(), 'A write to a read-only provider should be reported as provider read-only.' ); } /** * Reads still work on a read-only provider – "read-only" is about writes, not * about being broken. Absence is still absence. * + * @covers ::wp_get_secret + * * @runInSeparateProcess * @preserveGlobalState disabled */ public function test_a_read_only_provider_still_serves_reads(): void { $GLOBALS['wp_secrets_provider'] = $this->read_only_provider(); - $this->assertNull( wp_get_secret( 'myplugin/api-key' ) ); + $this->assertNull( wp_get_secret( 'myplugin/api-key' ), 'A read-only provider should report a missing secret as null.' ); } // – fail closed ----------------------------------------------------------- @@ -178,6 +213,9 @@ public function test_a_read_only_provider_still_serves_reads(): void { * A broken drop-in must not quietly revert to the default provider: that would * downgrade a site's protection at the exact moment nobody is watching. * + * @covers ::_wp_secrets_get_provider + * @covers WP_Secrets_Broken_Provider::is_writable + * * @runInSeparateProcess * @preserveGlobalState disabled */ @@ -186,36 +224,24 @@ public function test_a_broken_dropin_fails_closed_rather_than_reverting_to_the_d $provider = _wp_secrets_get_provider(); - $this->assertNotInstanceOf( 'WP_Secrets_Libsodium_Provider', $provider ); - $this->assertInstanceOf( 'WP_Secrets_Broken_Provider', $provider ); - $this->assertFalse( $provider->is_writable() ); - } - - /** - * @runInSeparateProcess - * @preserveGlobalState disabled - */ - public function test_a_broken_dropin_makes_every_operation_a_wp_error(): void { - $GLOBALS['wp_secrets_dropin_broken'] = true; - - $this->assertWPError( wp_set_secret( 'myplugin/api-key', 'value' ) ); - $this->assertWPError( wp_get_secret( 'myplugin/api-key' ) ); - $this->assertWPError( wp_delete_secret( 'myplugin/api-key' ) ); - $this->assertWPError( wp_list_secrets() ); - $this->assertWPError( wp_retire_secret_version( 'myplugin/api-key' ) ); + $this->assertNotInstanceOf( 'WP_Secrets_Libsodium_Provider', $provider, 'A broken drop-in should not fall back to WP_Secrets_Libsodium_Provider.' ); + $this->assertInstanceOf( 'WP_Secrets_Broken_Provider', $provider, 'A broken drop-in should resolve to WP_Secrets_Broken_Provider.' ); + $this->assertFalse( $provider->is_writable(), 'A broken drop-in provider should report as not writable.' ); } /** * Specifically not null: a misconfigured credential backend must never look * like a working site that happens to have no secrets in it yet. * + * @covers ::wp_get_secret + * * @runInSeparateProcess * @preserveGlobalState disabled */ public function test_a_broken_dropin_read_is_an_error_not_an_absence(): void { $GLOBALS['wp_secrets_dropin_broken'] = true; - $this->assertNotNull( wp_get_secret( 'myplugin/api-key' ) ); + $this->assertNotNull( wp_get_secret( 'myplugin/api-key' ), 'A read under a broken drop-in should not be reported as an absence.' ); } /** @@ -229,19 +255,24 @@ public function test_a_broken_dropin_read_is_an_error_not_an_absence(): void { * served from wp_options, find nothing there, and report a site's credentials * absent when they are merely unreachable. * + * @covers ::_wp_secrets_get_provider + * * @runInSeparateProcess * @preserveGlobalState disabled */ public function test_a_non_provider_global_fails_closed(): void { $GLOBALS['wp_secrets_provider'] = new stdClass(); - $this->assertInstanceOf( 'WP_Secrets_Broken_Provider', _wp_secrets_get_provider() ); + $this->assertInstanceOf( 'WP_Secrets_Broken_Provider', _wp_secrets_get_provider(), 'A non-provider global should resolve to WP_Secrets_Broken_Provider.' ); } /** * And it is an error rather than an absence, which is the property that * actually protects the operator. * + * @covers ::_wp_secrets_get_provider + * @covers ::wp_get_secret + * * @runInSeparateProcess * @preserveGlobalState disabled */ @@ -250,8 +281,8 @@ public function test_a_non_provider_global_reads_as_an_error_not_an_absence(): v $result = wp_get_secret( 'myplugin/api-key' ); - $this->assertNotNull( $result ); - $this->assertWPError( $result ); + $this->assertNotNull( $result, 'A read under a non-provider global should not be reported as an absence.' ); + $this->assertWPError( $result, 'A read under a non-provider global should return a WP_Error.' ); } /** @@ -260,12 +291,14 @@ public function test_a_non_provider_global_reads_as_an_error_not_an_absence(): v * supported arrangement rather than a broken one. It gets the shipped libsodium * provider, composed with the keyring it did supply. * + * @covers ::_wp_secrets_get_provider + * * @runInSeparateProcess * @preserveGlobalState disabled */ public function test_a_keyring_only_dropin_still_gets_the_default_provider(): void { $GLOBALS['wp_secrets_keyring'] = new Mock_Keyring(); - $this->assertInstanceOf( 'WP_Secrets_Libsodium_Provider', _wp_secrets_get_provider() ); + $this->assertInstanceOf( 'WP_Secrets_Libsodium_Provider', _wp_secrets_get_provider(), 'A keyring-only drop-in should still get WP_Secrets_Libsodium_Provider.' ); } } diff --git a/tests/phpunit/test-secrets-readonly-provider-conformance.php b/tests/phpunit/test-secrets-readonly-provider-conformance.php index c93464c..f64b6f3 100644 --- a/tests/phpunit/test-secrets-readonly-provider-conformance.php +++ b/tests/phpunit/test-secrets-readonly-provider-conformance.php @@ -7,7 +7,11 @@ * A conformance kit that has only ever run against a writable provider would not * have proven that its own skips work. * + * @ticket 66187 + * * @group secrets + * + * @coversNothing */ class Tests_Secrets_ReadOnlyProviderConformance extends WP_Secrets_Provider_Conformance { diff --git a/tests/phpunit/test-secrets-site-health.php b/tests/phpunit/test-secrets-site-health.php index d0eee49..cde4564 100644 --- a/tests/phpunit/test-secrets-site-health.php +++ b/tests/phpunit/test-secrets-site-health.php @@ -2,6 +2,8 @@ /** * Tests for the Site Health integration in src/wp-admin/includes/secrets-site-health.php. * + * @ticket 66187 + * * @group secrets */ class Tests_Secrets_SiteHealth extends WP_UnitTestCase { @@ -14,15 +16,15 @@ class Tests_Secrets_SiteHealth extends WP_UnitTestCase { * @return array> The section's fields, keyed by field name. */ private function debug_fields( array $info ): array { - $this->assertArrayHasKey( 'secrets-api', $info ); - $this->assertArrayHasKey( 'fields', $info['secrets-api'] ); - $this->assertIsArray( $info['secrets-api']['fields'] ); + $this->assertArrayHasKey( 'secrets-api', $info, 'The debug information should include a secrets-api section.' ); + $this->assertArrayHasKey( 'fields', $info['secrets-api'], 'The secrets-api section should include a fields entry.' ); + $this->assertIsArray( $info['secrets-api']['fields'], 'The secrets-api fields entry should be an array.' ); $fields = array(); foreach ( $info['secrets-api']['fields'] as $key => $field ) { - $this->assertIsString( $key ); - $this->assertIsArray( $field ); + $this->assertIsString( $key, 'Each debug field key should be a string.' ); + $this->assertIsArray( $field, 'Each debug field should be an array.' ); $fields[ $key ] = $field; } @@ -30,26 +32,42 @@ private function debug_fields( array $info ): array { return $fields; } + /** + * Ensure that the three Secrets API checks are registered as direct tests with existing callbacks. + * + * @covers ::wp_secrets_site_health_tests + */ public function test_registers_three_tests_under_direct(): void { $tests = wp_secrets_site_health_tests( array() ); - $this->assertArrayHasKey( 'direct', $tests ); - $this->assertArrayHasKey( 'secrets_api_key_source', $tests['direct'] ); - $this->assertArrayHasKey( 'secrets_api_undecryptable', $tests['direct'] ); - $this->assertArrayHasKey( 'secrets_api_needs_rotation', $tests['direct'] ); + $this->assertArrayHasKey( 'direct', $tests, 'The registered tests should include a direct group.' ); + $this->assertCount( 3, $tests['direct'], 'Exactly three Secrets API tests should be registered as direct tests.' ); + $this->assertArrayHasKey( 'secrets_api_key_source', $tests['direct'], 'The key source test should be registered as a direct test.' ); + $this->assertArrayHasKey( 'secrets_api_undecryptable', $tests['direct'], 'The undecryptable test should be registered as a direct test.' ); + $this->assertArrayHasKey( 'secrets_api_needs_rotation', $tests['direct'], 'The needs rotation test should be registered as a direct test.' ); foreach ( $tests['direct'] as $key => $test ) { - $this->assertIsString( $test['test'] ); + $this->assertIsString( $test['test'], 'Each registered test callback should be a function name string.' ); $this->assertTrue( function_exists( $test['test'] ), "Registered test callback \"{$test['test']}\" for \"{$key}\" does not exist." ); } } + /** + * Ensure that wp_secrets_site_health_tests() keeps tests that were already registered. + * + * @covers ::wp_secrets_site_health_tests + */ public function test_preserves_existing_tests(): void { $tests = wp_secrets_site_health_tests( array( 'direct' => array( 'existing_test' => array( 'label' => 'x' ) ) ) ); - $this->assertArrayHasKey( 'existing_test', $tests['direct'] ); + $this->assertArrayHasKey( 'existing_test', $tests['direct'], 'A previously registered test should be preserved.' ); } + /** + * Ensure that the key source check recommends defining WP_SECRETS_KEY when it is not set. + * + * @covers ::wp_secrets_site_health_test_key_source + */ public function test_key_source_test_recommends_a_dedicated_key_by_default(): void { if ( 'put your unique phrase here' === LOGGED_IN_KEY || 'put your unique phrase here' === LOGGED_IN_SALT ) { $this->markTestSkipped( 'Needs real LOGGED_IN_KEY and LOGGED_IN_SALT values; wp-tests-config-sample.php ships placeholders.' ); @@ -57,11 +75,18 @@ public function test_key_source_test_recommends_a_dedicated_key_by_default(): vo $result = wp_secrets_site_health_test_key_source(); - $this->assertSame( 'recommended', $result['status'] ); - $this->assertStringContainsString( 'WP_SECRETS_KEY', $result['description'] ); + $this->assertSame( 'recommended', $result['status'], 'The key source status should be recommended without WP_SECRETS_KEY.' ); + $this->assertStringContainsString( 'WP_SECRETS_KEY', $result['description'], 'The description should recommend defining WP_SECRETS_KEY.' ); } /** + * Ensure that the key source check recommends regenerating a WP_SECRETS_KEY not in base64 form. + * + * Runs in a separate process because WP_SECRETS_KEY is a constant and cannot be + * undefined for later tests. + * + * @covers ::wp_secrets_site_health_test_key_source + * * @runInSeparateProcess * @preserveGlobalState disabled */ @@ -70,11 +95,18 @@ public function test_key_source_test_recommends_regeneration_for_a_legacy_key(): $result = wp_secrets_site_health_test_key_source(); - $this->assertSame( 'recommended', $result['status'] ); - $this->assertStringContainsString( 'generate-key', $result['description'] ); + $this->assertSame( 'recommended', $result['status'], 'A legacy WP_SECRETS_KEY should produce a recommended status.' ); + $this->assertStringContainsString( 'generate-key', $result['description'], 'The description should mention generate-key for regenerating the key.' ); } /** + * Ensure that the key source check passes for a base64-encoded 32-byte WP_SECRETS_KEY. + * + * Runs in a separate process because WP_SECRETS_KEY is a constant and cannot be + * undefined for later tests. + * + * @covers ::wp_secrets_site_health_test_key_source + * * @runInSeparateProcess * @preserveGlobalState disabled */ @@ -83,10 +115,17 @@ public function test_key_source_test_is_good_for_a_dedicated_base64_key(): void $result = wp_secrets_site_health_test_key_source(); - $this->assertSame( 'good', $result['status'] ); + $this->assertSame( 'good', $result['status'], 'A base64-encoded 32-byte WP_SECRETS_KEY should produce a good status.' ); } /** + * Ensure that the key source check is critical when the secrets drop-in failed to load. + * + * Runs in a separate process because the key manager is cached in a static variable + * the first time it is requested, so the broken flag must be set before then. + * + * @covers ::wp_secrets_site_health_test_key_source + * * @runInSeparateProcess * @preserveGlobalState disabled */ @@ -95,146 +134,201 @@ public function test_key_source_test_is_critical_when_the_keyring_is_broken(): v $result = wp_secrets_site_health_test_key_source(); - $this->assertSame( 'critical', $result['status'] ); + $this->assertSame( 'critical', $result['status'], 'A broken secrets drop-in should produce a critical status.' ); } + /** + * Ensure that the undecryptable check passes when every stored secret decrypts. + * + * @covers ::wp_secrets_site_health_test_undecryptable + */ public function test_undecryptable_test_is_good_when_nothing_is_broken(): void { - wp_set_secret( 'myplugin/api-key', 'value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); $result = wp_secrets_site_health_test_undecryptable(); - $this->assertSame( 'good', $result['status'] ); + $this->assertSame( 'good', $result['status'], 'The status should be good when every secret decrypts.' ); } + /** + * Ensure that the undecryptable check passes when no secrets are stored. + * + * @covers ::wp_secrets_site_health_test_undecryptable + */ public function test_undecryptable_test_is_good_with_no_secrets_at_all(): void { $result = wp_secrets_site_health_test_undecryptable(); - $this->assertSame( 'good', $result['status'] ); + $this->assertSame( 'good', $result['status'], 'The status should be good when no secrets are stored.' ); } + /** + * Ensure that the undecryptable check is critical and names the failing secret and its fingerprint. + * + * The stored ciphertext is overwritten to simulate a record that no longer decrypts. + * + * @covers ::wp_secrets_site_health_test_undecryptable + */ public function test_undecryptable_test_is_critical_and_lists_the_broken_secret(): void { - wp_set_secret( 'myplugin/api-key', 'value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); $secret = wp_get_secret( 'myplugin/api-key' ); - $this->assertInstanceOf( WP_Secret::class, $secret ); + $this->assertInstanceOf( WP_Secret::class, $secret, 'The stored secret should be retrievable as a WP_Secret.' ); $fingerprint = $secret->fingerprint(); $record = get_option( '_wp_secret_myplugin/api-key' ); - $this->assertIsArray( $record ); - $this->assertArrayHasKey( 'current', $record ); - $this->assertIsArray( $record['current'] ); + $this->assertIsArray( $record, 'The stored record should be an array.' ); + $this->assertArrayHasKey( 'current', $record, 'The stored record should have a current slot.' ); + $this->assertIsArray( $record['current'], 'The current slot should be an array.' ); $record['current']['ct'] = base64_encode( 'not decryptable' ); update_option( '_wp_secret_myplugin/api-key', $record, false ); $result = wp_secrets_site_health_test_undecryptable(); - $this->assertSame( 'critical', $result['status'] ); - $this->assertStringContainsString( 'myplugin/api-key', $result['description'] ); - $this->assertStringContainsString( $fingerprint, $result['description'] ); + $this->assertSame( 'critical', $result['status'], 'An undecryptable secret should produce a critical status.' ); + $this->assertStringContainsString( 'myplugin/api-key', $result['description'], 'The description should name the undecryptable secret.' ); + $this->assertStringContainsString( $fingerprint, $result['description'], 'The description should include the fingerprint of the undecryptable secret.' ); } + /** + * Ensure that the undecryptable check's description never includes a secret's plaintext value. + * + * @covers ::wp_secrets_site_health_test_undecryptable + */ public function test_undecryptable_test_never_contains_a_plaintext(): void { - wp_set_secret( 'myplugin/api-key', 'UNIQUE-PLAINTEXT-CANARY-9f3a' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'UNIQUE-PLAINTEXT-CANARY-9f3a' ), 'Setting myplugin/api-key to UNIQUE-PLAINTEXT-CANARY-9f3a should succeed.' ); $record = get_option( '_wp_secret_myplugin/api-key' ); - $this->assertIsArray( $record ); - $this->assertArrayHasKey( 'current', $record ); - $this->assertIsArray( $record['current'] ); + $this->assertIsArray( $record, 'The stored record should be an array.' ); + $this->assertArrayHasKey( 'current', $record, 'The stored record should have a current slot.' ); + $this->assertIsArray( $record['current'], 'The current slot should be an array.' ); $record['current']['ct'] = base64_encode( 'not decryptable' ); update_option( '_wp_secret_myplugin/api-key', $record, false ); $result = wp_secrets_site_health_test_undecryptable(); - $this->assertStringNotContainsString( 'UNIQUE-PLAINTEXT-CANARY-9f3a', $result['description'] ); + $this->assertStringNotContainsString( 'UNIQUE-PLAINTEXT-CANARY-9f3a', $result['description'], 'The description should never contain the plaintext value.' ); } + /** + * Ensure that the rotation check passes for a secret stored directly with wp_set_secret(). + * + * @covers ::wp_secrets_site_health_test_needs_rotation + */ public function test_needs_rotation_test_is_good_by_default(): void { - wp_set_secret( 'myplugin/api-key', 'value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); $result = wp_secrets_site_health_test_needs_rotation(); - $this->assertSame( 'good', $result['status'] ); + $this->assertSame( 'good', $result['status'], 'A directly stored secret should not need rotation.' ); } + /** + * Ensure that the rotation check recommends rotating a secret imported from a plain option. + * + * @covers ::wp_secrets_site_health_test_needs_rotation + */ public function test_needs_rotation_test_is_recommended_after_an_import(): void { update_option( 'my_plugin_api_key', 'value' ); - wp_import_option_as_secret( 'my_plugin_api_key', 'myplugin/api-key' ); + $this->assertTrue( wp_import_option_as_secret( 'my_plugin_api_key', 'myplugin/api-key' ), 'Importing my_plugin_api_key as myplugin/api-key should succeed.' ); $result = wp_secrets_site_health_test_needs_rotation(); - $this->assertSame( 'recommended', $result['status'] ); - $this->assertStringContainsString( '1', $result['description'] ); + $this->assertSame( 'recommended', $result['status'], 'An imported secret should produce a recommended status.' ); + $this->assertStringContainsString( '1 secret was imported', $result['description'], 'The description should report the number of secrets flagged for rotation.' ); } + /** + * Ensure that the debug information reports the provider, keyring, record version and secret count. + * + * @covers ::wp_secrets_site_health_debug_info + */ public function test_debug_info_reports_expected_fields(): void { - wp_set_secret( 'myplugin/api-key', 'value' ); - - $info = wp_secrets_site_health_debug_info( array() ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); - $this->assertArrayHasKey( 'secrets-api', $info ); + $info = wp_secrets_site_health_debug_info( array() ); $fields = $this->debug_fields( $info ); - $this->assertArrayHasKey( 'dropin_active', $fields ); - $this->assertArrayHasKey( 'provider_class', $fields ); - $this->assertArrayHasKey( 'keyring_class', $fields ); - $this->assertArrayHasKey( 'key_source', $fields ); - $this->assertArrayHasKey( 'record_version', $fields ); - $this->assertArrayHasKey( 'site_secret_count', $fields ); - - $this->assertSame( 'WP_Secrets_Libsodium_Provider', $fields['provider_class']['value'] ); - $this->assertSame( 'WP_Secrets_Config_Key_Provider', $fields['keyring_class']['value'] ); - $this->assertSame( '1', $fields['record_version']['value'] ); - $this->assertSame( '1', $fields['site_secret_count']['value'] ); + $this->assertArrayHasKey( 'dropin_active', $fields, 'The debug fields should include dropin_active.' ); + $this->assertArrayHasKey( 'provider_class', $fields, 'The debug fields should include provider_class.' ); + $this->assertArrayHasKey( 'keyring_class', $fields, 'The debug fields should include keyring_class.' ); + $this->assertArrayHasKey( 'key_source', $fields, 'The debug fields should include key_source.' ); + $this->assertArrayHasKey( 'record_version', $fields, 'The debug fields should include record_version.' ); + $this->assertArrayHasKey( 'site_secret_count', $fields, 'The debug fields should include site_secret_count.' ); + + $this->assertSame( 'No', $fields['dropin_active']['value'], 'No drop-in is installed in the test environment.' ); + $this->assertSame( 'WP_Secrets_Libsodium_Provider', $fields['provider_class']['value'], 'The provider class should be WP_Secrets_Libsodium_Provider.' ); + $this->assertSame( 'WP_Secrets_Config_Key_Provider', $fields['keyring_class']['value'], 'The keyring class should be WP_Secrets_Config_Key_Provider.' ); + $this->assertSame( ( new WP_Secrets_Config_Key_Provider() )->get_key_source(), $fields['key_source']['value'], 'The key source should be reported from the keyring in use.' ); + $this->assertSame( '1', $fields['record_version']['value'], 'The record version should be reported as 1.' ); + $this->assertSame( '1', $fields['site_secret_count']['value'], 'The site secret count should be 1.' ); } + /** + * Ensure that the debug information reports no active drop-in when none is installed. + * + * @covers ::wp_secrets_site_health_debug_info + */ public function test_debug_info_reports_no_dropin_by_default(): void { $info = wp_secrets_site_health_debug_info( array() ); $fields = $this->debug_fields( $info ); - $this->assertArrayHasKey( 'dropin_active', $fields ); - $this->assertSame( 'No', $fields['dropin_active']['value'] ); + $this->assertArrayHasKey( 'dropin_active', $fields, 'The debug fields should include dropin_active.' ); + $this->assertSame( 'No', $fields['dropin_active']['value'], 'The drop-in should be reported as inactive by default.' ); } + /** + * Ensure that the debug information never includes a secret's plaintext value. + * + * @covers ::wp_secrets_site_health_debug_info + */ public function test_debug_info_never_contains_a_plaintext(): void { - wp_set_secret( 'myplugin/api-key', 'UNIQUE-PLAINTEXT-CANARY-9f3a' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'UNIQUE-PLAINTEXT-CANARY-9f3a' ), 'Setting myplugin/api-key to UNIQUE-PLAINTEXT-CANARY-9f3a should succeed.' ); $info = wp_secrets_site_health_debug_info( array() ); $dump = wp_json_encode( $info['secrets-api'] ); - $this->assertIsString( $dump ); - $this->assertStringNotContainsString( 'UNIQUE-PLAINTEXT-CANARY-9f3a', $dump ); + $this->assertIsString( $dump, 'The secrets-api section should encode to a JSON string.' ); + $this->assertStringNotContainsString( 'UNIQUE-PLAINTEXT-CANARY-9f3a', $dump, 'The debug information should never contain the plaintext value.' ); } + /** + * Ensure that the debug information omits network secret counts on a single site install. + * + * @covers ::wp_secrets_site_health_debug_info + */ public function test_debug_info_omits_network_fields_on_single_site(): void { $info = wp_secrets_site_health_debug_info( array() ); - $this->assertArrayNotHasKey( 'network_secret_count', $this->debug_fields( $info ) ); + $this->assertArrayNotHasKey( 'network_secret_count', $this->debug_fields( $info ), 'Network secret counts should be omitted on a single site install.' ); } /** * Network secret counts appear only for a super admin on a multisite install – * never for a site administrator who is not one. + * + * @group multisite + * @group ms-required + * + * @covers ::wp_secrets_site_health_debug_info */ public function test_debug_info_includes_network_fields_only_for_a_super_admin(): void { - if ( ! is_multisite() ) { - $this->markTestSkipped( 'Requires multisite.' ); - } + $this->skipWithoutMultisite(); $user_id = self::factory()->user->create(); wp_set_current_user( $user_id ); $info = wp_secrets_site_health_debug_info( array() ); - $this->assertArrayNotHasKey( 'network_secret_count', $this->debug_fields( $info ), 'A non-super-admin must not see network secret counts.' ); + $this->assertArrayNotHasKey( 'network_secret_count', $this->debug_fields( $info ), 'A non-super-admin should not see network secret counts.' ); grant_super_admin( $user_id ); $info = wp_secrets_site_health_debug_info( array() ); - $this->assertArrayHasKey( 'network_secret_count', $this->debug_fields( $info ) ); + $this->assertArrayHasKey( 'network_secret_count', $this->debug_fields( $info ), 'A super admin should see network secret counts.' ); } } diff --git a/tests/phpunit/test-secrets-three-state-contract.php b/tests/phpunit/test-secrets-three-state-contract.php index acf2666..8fc1ada 100644 --- a/tests/phpunit/test-secrets-three-state-contract.php +++ b/tests/phpunit/test-secrets-three-state-contract.php @@ -5,44 +5,71 @@ * could not be retrieved. The whole design rests on never collapsing these, so every * broken case here explicitly asserts `null !== $result` as well as WP_Error. * + * @ticket 66187 + * * @group secrets */ class Tests_Secrets_ThreeStateContract extends WP_UnitTestCase { + /** + * Ensure that wp_get_secret() returns null for a secret that was never set. + * + * @covers ::wp_get_secret + */ public function test_absent_is_null(): void { $result = wp_get_secret( 'myplugin/never-set' ); - $this->assertNull( $result ); + $this->assertNull( $result, 'A secret that was never set should be null.' ); } + /** + * Ensure that wp_get_secret() returns null, not an error, for a missing previous version. + * + * @covers ::wp_get_secret + */ public function test_previous_slot_absent_on_an_existing_secret_is_also_null(): void { - wp_set_secret( 'myplugin/api-key', 'value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); $result = wp_get_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS ); - $this->assertNull( $result ); + $this->assertNull( $result, 'A missing previous version should be null.' ); } + /** + * Ensure that wp_get_secret() returns a WP_Error, not null, when the stored record is not an array. + * + * @covers ::wp_get_secret + */ public function test_malformed_record_is_wp_error_not_null(): void { update_option( '_wp_secret_myplugin/api-key', 'this is a string, not a record array', false ); $result = wp_get_secret( 'myplugin/api-key' ); - $this->assertNotNull( $result ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_RECORD_MALFORMED, $result->get_error_code() ); + $this->assertNotNull( $result, 'A malformed record should not be reported as null.' ); + $this->assertWPError( $result, 'A malformed record should be reported as a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_RECORD_MALFORMED, $result->get_error_code(), 'A non-array record should produce the malformed record error code.' ); } + /** + * Ensure that wp_get_secret() returns a WP_Error, not null, for a record with no current slot. + * + * @covers ::wp_get_secret + */ public function test_record_missing_the_current_slot_is_wp_error_not_null(): void { update_option( '_wp_secret_myplugin/api-key', array( 'v' => 1 ), false ); $result = wp_get_secret( 'myplugin/api-key' ); - $this->assertNotNull( $result ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_RECORD_MALFORMED, $result->get_error_code() ); + $this->assertNotNull( $result, 'A record without a current slot should not be reported as null.' ); + $this->assertWPError( $result, 'A record without a current slot should be reported as a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_RECORD_MALFORMED, $result->get_error_code(), 'A record without a current slot should produce the malformed record error code.' ); } + /** + * Ensure that wp_get_secret() returns a WP_Error, not null, for a record of an unknown format version. + * + * @covers ::wp_get_secret + */ public function test_unsupported_record_version_is_wp_error_not_null(): void { update_option( '_wp_secret_myplugin/api-key', @@ -61,9 +88,9 @@ public function test_unsupported_record_version_is_wp_error_not_null(): void { $result = wp_get_secret( 'myplugin/api-key' ); - $this->assertNotNull( $result ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_RECORD_UNSUPPORTED_VERSION, $result->get_error_code() ); + $this->assertNotNull( $result, 'An unsupported record version should not be reported as null.' ); + $this->assertWPError( $result, 'An unsupported record version should be reported as a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_RECORD_UNSUPPORTED_VERSION, $result->get_error_code(), 'An unsupported record version should produce the unsupported version error code.' ); } /** @@ -71,31 +98,35 @@ public function test_unsupported_record_version_is_wp_error_not_null(): void { * legitimately-encrypted record under a different name's option row – as if it * had been copied or corrupted – must fail decryption, not silently succeed * under the wrong context or crash. + * + * @covers ::wp_get_secret */ public function test_aad_mismatch_from_a_copied_record_is_wp_error_not_null(): void { - wp_set_secret( 'myplugin/original-name', 'value' ); + $this->assertTrue( wp_set_secret( 'myplugin/original-name', 'value' ), 'Setting myplugin/original-name to value should succeed.' ); $copied_record = get_option( '_wp_secret_myplugin/original-name' ); update_option( '_wp_secret_myplugin/different-name', $copied_record, false ); $result = wp_get_secret( 'myplugin/different-name' ); - $this->assertNotNull( $result ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_DECRYPTION_FAILED, $result->get_error_code() ); + $this->assertNotNull( $result, 'A record copied under another name should not be reported as null.' ); + $this->assertWPError( $result, 'A record copied under another name should be reported as a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_DECRYPTION_FAILED, $result->get_error_code(), 'A record copied under another name should fail decryption.' ); } /** - * An operator sets the constant wrong after secrets exist: WP_SECRETS_KEY is - * defined with a value that cannot serve as a usable key once secrets already - * exist under the site's real key. wp_get_secret() must fail with WP_Error, not + * An operator defines WP_SECRETS_KEY with a value that is not a usable string, + * here an integer, after secrets already exist. The key is rejected as unavailable + * before it is ever tried, and wp_get_secret() must report that as a WP_Error, not * silently return null as if the secret never existed. * * The write goes through a hand-built WP_Secrets_Libsodium_Provider rather than * wp_set_secret() so it bypasses the static _wp_secrets_get_key_manager(), whose - * request-scoped root-key cache (ADR 0009) would otherwise keep serving the + * request-scoped root-key cache would otherwise keep serving the * already-unwrapped key and mask the misconfiguration this test defines below. * + * @covers ::wp_get_secret + * * @runInSeparateProcess * @preserveGlobalState disabled */ @@ -108,15 +139,48 @@ public function test_key_unavailable_is_wp_error_not_null(): void { new WP_Secrets_Option_Store(), new WP_Secrets_Key_Manager( new WP_Secrets_Config_Key_Provider() ) ); - $provider->set( 'myplugin/api-key', 'value' ); + $this->assertTrue( $provider->set( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); define( 'WP_SECRETS_KEY', 424242 ); $result = wp_get_secret( 'myplugin/api-key' ); - $this->assertNotNull( $result ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() ); + $this->assertNotNull( $result, 'A secret with an unusable WP_SECRETS_KEY should not be reported as null.' ); + $this->assertWPError( $result, 'A secret with an unusable WP_SECRETS_KEY should be reported as a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code(), 'An unusable WP_SECRETS_KEY should produce the key unavailable error code.' ); + } + + /** + * Ensure that a different valid WP_SECRETS_KEY makes wp_get_secret() return a WP_Error, not null. + * + * This is the realistic misconfiguration: a well-formed key that is not the one the + * stored root key was wrapped under, such as a regenerated key or one copied from + * another site. The secret is written through a keyring that reads + * WP_SECRETS_KEY_PREVIOUS, so this process can then define a different + * WP_SECRETS_KEY for the read. + * + * @covers ::wp_get_secret + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_a_different_valid_key_is_wp_error_not_null(): void { + define( 'WP_SECRETS_KEY_PREVIOUS', base64_encode( str_repeat( 'a', 32 ) ) ); + + $provider = new WP_Secrets_Libsodium_Provider( + new WP_Secrets_Option_Store(), + new WP_Secrets_Key_Manager( new WP_Secrets_Config_Key_Provider( true ) ) + ); + $this->assertTrue( $provider->set( 'myplugin/api-key', 'value' ), 'Writing the secret under the original key should succeed.' ); + + define( 'WP_SECRETS_KEY', base64_encode( str_repeat( 'b', 32 ) ) ); + + $result = wp_get_secret( 'myplugin/api-key' ); + + $this->assertNotNull( $result, 'A secret read under a different valid WP_SECRETS_KEY should not be reported as null.' ); + $this->assertWPError( $result, 'A secret read under a different valid WP_SECRETS_KEY should be reported as a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code(), 'A different valid WP_SECRETS_KEY should produce the key unavailable error code.' ); + $this->assertStringContainsString( 'could not be decrypted', $result->get_error_message(), 'The error should come from failing to decrypt the root key, not from rejecting the key format.' ); } /** @@ -124,17 +188,20 @@ public function test_key_unavailable_is_wp_error_not_null(): void { * wrapped root key has been corrupted – simulating the option row being * damaged after secrets already exist. get_master_key() must fail before * decryption is ever attempted, since a usable root key was never obtained. + * + * @covers ::wp_get_secret + * @covers WP_Secrets_Key_Manager::get_master_key */ public function test_a_corrupted_wrapped_root_key_is_wp_error_not_null(): void { - wp_set_secret( 'myplugin/api-key', 'value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); update_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION, 'not-a-valid-wrapped-value' ); $result = wp_get_secret( 'myplugin/api-key' ); - $this->assertNotNull( $result ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() ); + $this->assertNotNull( $result, 'A corrupted wrapped root key should not be reported as null.' ); + $this->assertWPError( $result, 'A corrupted wrapped root key should be reported as a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code(), 'A corrupted wrapped root key should produce the key unavailable error code.' ); } /** @@ -144,60 +211,69 @@ public function test_a_corrupted_wrapped_root_key_is_wp_error_not_null(): void { * empty fingerprint to WP_Secret's constructor, which rejects one, producing an * uncaught InvalidArgumentException – a fatal, and a fourth state the contract * does not allow. + * + * @covers ::wp_get_secret */ public function test_record_missing_its_fingerprint_field_still_returns_a_secret(): void { - wp_set_secret( 'myplugin/api-key', 'value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); $record = get_option( '_wp_secret_myplugin/api-key' ); - $this->assertIsArray( $record ); - $this->assertArrayHasKey( 'current', $record ); - $this->assertIsArray( $record['current'] ); + $this->assertIsArray( $record, 'The stored record should be an array.' ); + $this->assertArrayHasKey( 'current', $record, 'The stored record should have a current slot.' ); + $this->assertIsArray( $record['current'], 'The current slot should be an array.' ); unset( $record['current']['fingerprint'] ); update_option( '_wp_secret_myplugin/api-key', $record, false ); $result = wp_get_secret( 'myplugin/api-key' ); - $this->assertInstanceOf( WP_Secret::class, $result ); - $this->assertSame( 'value', $result->reveal() ); - $this->assertNotSame( '', $result->fingerprint() ); + $this->assertInstanceOf( WP_Secret::class, $result, 'A record without a fingerprint field should still return a WP_Secret.' ); + $this->assertSame( 'value', $result->reveal(), 'A record without a fingerprint field should still decrypt to the stored value.' ); + $this->assertNotSame( '', $result->fingerprint(), 'The fingerprint should be recomputed when the record has none.' ); } /** * The 'fingerprint' field is outside the AAD, so anyone who can write to the * store can change it freely. WP_Secret::fingerprint() must report the value * recomputed from the decrypted plaintext, not whatever the record claims. + * + * @covers ::wp_get_secret */ public function test_fingerprint_is_recomputed_not_read_from_the_record(): void { - wp_set_secret( 'myplugin/api-key', 'value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); $before = wp_get_secret( 'myplugin/api-key' ); - $this->assertInstanceOf( WP_Secret::class, $before ); + $this->assertInstanceOf( WP_Secret::class, $before, 'The secret should be a WP_Secret before the fingerprint is tampered with.' ); $genuine = $before->fingerprint(); $record = get_option( '_wp_secret_myplugin/api-key' ); - $this->assertIsArray( $record ); - $this->assertArrayHasKey( 'current', $record ); - $this->assertIsArray( $record['current'] ); + $this->assertIsArray( $record, 'The stored record should be an array.' ); + $this->assertArrayHasKey( 'current', $record, 'The stored record should have a current slot.' ); + $this->assertIsArray( $record['current'], 'The current slot should be an array.' ); $record['current']['fingerprint'] = 'deadbeefdeadbeefdeadbeefdeadbeef'; update_option( '_wp_secret_myplugin/api-key', $record, false ); $after = wp_get_secret( 'myplugin/api-key' ); - $this->assertInstanceOf( WP_Secret::class, $after ); - $this->assertSame( $genuine, $after->fingerprint() ); + $this->assertInstanceOf( WP_Secret::class, $after, 'The secret should be a WP_Secret after the fingerprint is tampered with.' ); + $this->assertSame( $genuine, $after->fingerprint(), 'The fingerprint should be recomputed rather than read from the record.' ); } + /** + * Ensure that wp_get_secret() returns a WP_Secret for a secret that exists and decrypts. + * + * @covers ::wp_get_secret + */ public function test_exists_and_decrypts_is_a_wp_secret(): void { - wp_set_secret( 'myplugin/api-key', 'value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); $result = wp_get_secret( 'myplugin/api-key' ); - $this->assertNotNull( $result ); - $this->assertNotWPError( $result ); - $this->assertInstanceOf( WP_Secret::class, $result ); + $this->assertNotNull( $result, 'An existing secret should not be reported as null.' ); + $this->assertNotWPError( $result, 'An existing secret should not be reported as a WP_Error.' ); + $this->assertInstanceOf( WP_Secret::class, $result, 'An existing secret should be returned as a WP_Secret.' ); } } diff --git a/tests/phpunit/test-secrets-version-slots.php b/tests/phpunit/test-secrets-version-slots.php index b4262ab..8e1ee5b 100644 --- a/tests/phpunit/test-secrets-version-slots.php +++ b/tests/phpunit/test-secrets-version-slots.php @@ -1,9 +1,9 @@ assertIsArray( $record ); - $this->assertArrayHasKey( $slot, $record ); - $this->assertIsArray( $record[ $slot ] ); + $this->assertIsArray( $record, 'The stored record should be an array.' ); + $this->assertArrayHasKey( $slot, $record, 'The stored record should contain the requested slot.' ); + $this->assertIsArray( $record[ $slot ], 'The requested slot of the stored record should be an array.' ); return $record[ $slot ]; } - public function test_first_write_leaves_no_previous_slot(): void { - wp_set_secret( 'myplugin/api-key', 'first-value' ); - - $this->assertNull( wp_get_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS ) ); - } - /** * The headline case: PREVIOUS must actually decrypt back to the prior value. + * + * @covers ::wp_set_secret + * @covers ::wp_get_secret */ public function test_previous_returns_the_actual_prior_value(): void { - wp_set_secret( 'myplugin/api-key', 'first-value' ); - wp_set_secret( 'myplugin/api-key', 'second-value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'first-value' ), 'Setting myplugin/api-key to first-value should succeed.' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'second-value' ), 'Setting myplugin/api-key to second-value should succeed.' ); - $this->assertRecordSlotDecryptsTo( 'myplugin/api-key', WP_Secret_Version::PREVIOUS, 'first-value' ); - $this->assertRecordSlotDecryptsTo( 'myplugin/api-key', WP_Secret_Version::CURRENT, 'second-value' ); + $this->assertRecordSlotDecryptsTo( 'myplugin/api-key', WP_Secret_Version::PREVIOUS, 'first-value', false, 'The previous slot should decrypt to the first value.' ); + $this->assertRecordSlotDecryptsTo( 'myplugin/api-key', WP_Secret_Version::CURRENT, 'second-value', false, 'The current slot should decrypt to the second value.' ); } + /** + * Ensure that a third write discards the oldest value from the stored record. + * + * @covers ::wp_set_secret + * @covers WP_Secrets_Libsodium_Provider::set + */ public function test_third_write_discards_the_oldest_value(): void { - wp_set_secret( 'myplugin/api-key', 'value-a' ); - wp_set_secret( 'myplugin/api-key', 'value-b' ); - wp_set_secret( 'myplugin/api-key', 'value-c' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value-a' ), 'Setting myplugin/api-key to value-a should succeed.' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value-b' ), 'Setting myplugin/api-key to value-b should succeed.' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value-c' ), 'Setting myplugin/api-key to value-c should succeed.' ); - $this->assertRecordSlotDecryptsTo( 'myplugin/api-key', WP_Secret_Version::PREVIOUS, 'value-b' ); + $this->assertRecordSlotDecryptsTo( 'myplugin/api-key', WP_Secret_Version::PREVIOUS, 'value-b', false, 'The previous slot should decrypt to the second of three values.' ); - // value-a is gone from the stored record entirely, not merely unreachable - // through the two named slots. - $this->assertNeverContainsPlaintext( 'value-a', get_option( '_wp_secret_myplugin/api-key' ) ); + /* + * value-a is gone from the stored record entirely, not merely unreachable + * through the two named slots. + */ + $this->assertNeverContainsPlaintext( 'value-a', get_option( '_wp_secret_myplugin/api-key' ), 'The stored record should no longer contain the oldest value.' ); } /** @@ -62,34 +67,46 @@ public function test_third_write_discards_the_oldest_value(): void { * the previous value a rotation still depends on. * * @ticket 66353 + * + * @covers ::wp_set_secret + * @covers ::wp_get_secret */ public function test_rewriting_the_current_value_keeps_the_previous_slot(): void { - wp_set_secret( 'myplugin/api-key', 'first-value' ); - wp_set_secret( 'myplugin/api-key', 'second-value' ); - wp_set_secret( 'myplugin/api-key', 'second-value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'first-value' ), 'Setting myplugin/api-key to first-value should succeed.' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'second-value' ), 'Setting myplugin/api-key to second-value should succeed.' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'second-value' ), 'Setting myplugin/api-key to second-value again should succeed.' ); - $this->assertRecordSlotDecryptsTo( 'myplugin/api-key', WP_Secret_Version::PREVIOUS, 'first-value' ); - $this->assertRecordSlotDecryptsTo( 'myplugin/api-key', WP_Secret_Version::CURRENT, 'second-value' ); + $this->assertRecordSlotDecryptsTo( 'myplugin/api-key', WP_Secret_Version::PREVIOUS, 'first-value', false, 'Rewriting the current value should leave the previous slot holding the first value.' ); + $this->assertRecordSlotDecryptsTo( 'myplugin/api-key', WP_Secret_Version::CURRENT, 'second-value', false, 'The current slot should still decrypt to the rewritten value.' ); } /** + * Ensure that writing the value a secret already holds succeeds without changing the stored record. + * * @ticket 66353 + * + * @covers ::wp_set_secret + * @covers WP_Secrets_Libsodium_Provider::set */ public function test_rewriting_the_current_value_leaves_the_stored_record_untouched(): void { - wp_set_secret( 'myplugin/api-key', 'first-value' ); - wp_set_secret( 'myplugin/api-key', 'second-value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'first-value' ), 'Setting myplugin/api-key to first-value should succeed.' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'second-value' ), 'Setting myplugin/api-key to second-value should succeed.' ); $before = get_option( '_wp_secret_myplugin/api-key' ); - $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'second-value' ) ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'second-value' ), 'Rewriting the current value should report success.' ); - $this->assertSame( $before, get_option( '_wp_secret_myplugin/api-key' ) ); + $this->assertSame( $before, get_option( '_wp_secret_myplugin/api-key' ), 'Rewriting the current value should leave the stored record unchanged.' ); } /** + * Ensure that writing the value a secret already holds does not fire wp_secret_changed. + * * @ticket 66353 + * + * @covers ::wp_set_secret */ public function test_rewriting_the_current_value_does_not_fire_the_change_hook(): void { - wp_set_secret( 'myplugin/api-key', 'value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); $fired = false; add_action( @@ -99,23 +116,26 @@ function () use ( &$fired ) { } ); - wp_set_secret( 'myplugin/api-key', 'value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value again should succeed.' ); - $this->assertFalse( $fired ); + $this->assertFalse( $fired, 'Rewriting the current value should not fire wp_secret_changed.' ); } /** * Re-saving an imported value through a settings form has not rotated it. * * @ticket 66353 + * + * @covers ::wp_set_secret + * @covers WP_Secrets_Libsodium_Provider::set */ public function test_rewriting_the_current_value_keeps_its_needs_rotation_flag(): void { update_option( 'my_plugin_api_key', 'imported-value' ); - wp_import_option_as_secret( 'my_plugin_api_key', 'myplugin/api-key' ); + $this->assertTrue( wp_import_option_as_secret( 'my_plugin_api_key', 'myplugin/api-key' ), 'Importing my_plugin_api_key as myplugin/api-key should succeed.' ); - wp_set_secret( 'myplugin/api-key', 'imported-value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'imported-value' ), 'Setting myplugin/api-key to imported-value should succeed.' ); - $this->assertTrue( $this->stored_slot( WP_Secret_Version::CURRENT )['needs_rotation'] ); + $this->assertTrue( $this->stored_slot( WP_Secret_Version::CURRENT )['needs_rotation'], 'Rewriting an imported value should keep its needs_rotation flag.' ); } /** @@ -124,58 +144,81 @@ public function test_rewriting_the_current_value_keeps_its_needs_rotation_flag() * version. * * @ticket 66353 + * + * @covers ::wp_import_option_as_secret + * @covers WP_Secrets_Libsodium_Provider::set */ public function test_importing_the_current_value_flags_it_and_keeps_the_previous_slot(): void { - wp_set_secret( 'myplugin/api-key', 'first-value' ); - wp_set_secret( 'myplugin/api-key', 'second-value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'first-value' ), 'Setting myplugin/api-key to first-value should succeed.' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'second-value' ), 'Setting myplugin/api-key to second-value should succeed.' ); update_option( 'my_plugin_api_key', 'second-value' ); - $this->assertTrue( wp_import_option_as_secret( 'my_plugin_api_key', 'myplugin/api-key' ) ); + $this->assertTrue( wp_import_option_as_secret( 'my_plugin_api_key', 'myplugin/api-key' ), 'Importing an option holding the current value should succeed.' ); - $this->assertTrue( $this->stored_slot( WP_Secret_Version::CURRENT )['needs_rotation'] ); - $this->assertRecordSlotDecryptsTo( 'myplugin/api-key', WP_Secret_Version::PREVIOUS, 'first-value' ); - $this->assertRecordSlotDecryptsTo( 'myplugin/api-key', WP_Secret_Version::CURRENT, 'second-value' ); + $this->assertTrue( $this->stored_slot( WP_Secret_Version::CURRENT )['needs_rotation'], 'The imported current value should be flagged as needing rotation.' ); + $this->assertRecordSlotDecryptsTo( 'myplugin/api-key', WP_Secret_Version::PREVIOUS, 'first-value', false, 'Importing the current value should leave the previous slot holding the first value.' ); + $this->assertRecordSlotDecryptsTo( 'myplugin/api-key', WP_Secret_Version::CURRENT, 'second-value', false, 'The current slot should still decrypt to the imported value.' ); } + /** + * Ensure that a demoted value keeps the fingerprint it had as the current version. + * + * @covers ::wp_set_secret + * @covers ::wp_get_secret + */ public function test_previous_preserves_its_original_fingerprint(): void { - wp_set_secret( 'myplugin/api-key', 'first-value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'first-value' ), 'Setting myplugin/api-key to first-value should succeed.' ); $original = wp_get_secret( 'myplugin/api-key' ); - $this->assertInstanceOf( WP_Secret::class, $original ); + $this->assertInstanceOf( WP_Secret::class, $original, 'The first value should be readable as a WP_Secret.' ); $original_fingerprint = $original->fingerprint(); - wp_set_secret( 'myplugin/api-key', 'second-value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'second-value' ), 'Setting myplugin/api-key to second-value should succeed.' ); $previous = wp_get_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS ); - $this->assertInstanceOf( WP_Secret::class, $previous ); - $this->assertSame( $original_fingerprint, $previous->fingerprint() ); + $this->assertInstanceOf( WP_Secret::class, $previous, 'The demoted value should be readable as a WP_Secret.' ); + $this->assertSame( $original_fingerprint, $previous->fingerprint(), 'The demoted value should keep its original fingerprint.' ); } + /** + * Ensure that a demoted value keeps the created timestamp from its original write. + * + * @covers ::wp_set_secret + * @covers WP_Secrets_Libsodium_Provider::set + */ public function test_previous_preserves_its_original_created_timestamp(): void { - wp_set_secret( 'myplugin/api-key', 'first-value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'first-value' ), 'Setting myplugin/api-key to first-value should succeed.' ); $original_created = $this->stored_slot( WP_Secret_Version::CURRENT )['created']; - wp_set_secret( 'myplugin/api-key', 'second-value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'second-value' ), 'Setting myplugin/api-key to second-value should succeed.' ); $demoted_created = $this->stored_slot( WP_Secret_Version::PREVIOUS )['created']; - $this->assertSame( $original_created, $demoted_created ); + $this->assertSame( $original_created, $demoted_created, 'The demoted value should keep its original created timestamp.' ); } + /** + * Ensure that a demoted value carries its needs-rotation flag into the previous slot. + * + * @covers ::wp_set_secret + * @covers WP_Secrets_Libsodium_Provider::set + */ public function test_previous_preserves_its_needs_rotation_flag(): void { - wp_set_secret( 'myplugin/api-key', 'first-value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'first-value' ), 'Setting myplugin/api-key to first-value should succeed.' ); - // No public API sets this flag yet (that lands with wp_import_option_as_secret() - // in a later commit); set it directly to prove demotion carries it forward. + /* + * Set the flag directly rather than through wp_import_option_as_secret(), so + * this test depends only on demotion carrying the flag forward. + */ $record = get_option( '_wp_secret_myplugin/api-key' ); - $this->assertIsArray( $record ); - $this->assertArrayHasKey( 'current', $record ); - $this->assertIsArray( $record['current'] ); + $this->assertIsArray( $record, 'The stored record should be an array.' ); + $this->assertArrayHasKey( 'current', $record, 'The stored record should have a current slot.' ); + $this->assertIsArray( $record['current'], 'The current slot of the stored record should be an array.' ); $record['current']['needs_rotation'] = true; update_option( '_wp_secret_myplugin/api-key', $record, false ); - wp_set_secret( 'myplugin/api-key', 'second-value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'second-value' ), 'Setting myplugin/api-key to second-value should succeed.' ); - $this->assertTrue( $this->stored_slot( WP_Secret_Version::PREVIOUS )['needs_rotation'] ); + $this->assertTrue( $this->stored_slot( WP_Secret_Version::PREVIOUS )['needs_rotation'], 'The demoted value should keep its needs_rotation flag.' ); } /** @@ -184,26 +227,23 @@ public function test_previous_preserves_its_needs_rotation_flag(): void { * slot's stored bytes into 'previous' would leave it permanently undecryptable * there. Demoting must re-encrypt, which means the stored ciphertext actually * changes even though the plaintext does not. + * + * @covers ::wp_set_secret + * @covers WP_Secrets_Libsodium_Provider::set */ public function test_demotion_actually_reencrypts_rather_than_copying_ciphertext(): void { - wp_set_secret( 'myplugin/api-key', 'first-value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'first-value' ), 'Setting myplugin/api-key to first-value should succeed.' ); $original_ciphertext = $this->stored_slot( WP_Secret_Version::CURRENT )['ct']; - wp_set_secret( 'myplugin/api-key', 'second-value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'second-value' ), 'Setting myplugin/api-key to second-value should succeed.' ); $demoted_ciphertext = $this->stored_slot( WP_Secret_Version::PREVIOUS )['ct']; - $this->assertNotSame( $original_ciphertext, $demoted_ciphertext ); + $this->assertNotSame( $original_ciphertext, $demoted_ciphertext, 'Demotion should re-encrypt the value rather than copy its ciphertext.' ); // And it must still actually decrypt to the original plaintext. $previous = wp_get_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS ); - $this->assertInstanceOf( WP_Secret::class, $previous ); - $this->assertSame( 'first-value', $previous->reveal() ); - } - - public function test_previous_on_a_secret_with_no_previous_slot_is_null(): void { - wp_set_secret( 'myplugin/api-key', 'value' ); - - $this->assertNull( wp_get_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS ) ); + $this->assertInstanceOf( WP_Secret::class, $previous, 'The demoted value should be readable as a WP_Secret.' ); + $this->assertSame( 'first-value', $previous->reveal(), 'The demoted value should decrypt to the original plaintext.' ); } /** @@ -211,60 +251,88 @@ public function test_previous_on_a_secret_with_no_previous_slot_is_null(): void * orphaned by a botched rotation), the write must still succeed rather than * inheriting the old corruption – refusing here would be the exact * corrupted-record-blocks-everything failure this API is built to avoid. + * + * @covers ::wp_set_secret + * @covers ::wp_get_secret */ public function test_a_write_succeeds_even_if_the_outgoing_current_slot_cannot_be_decrypted(): void { - wp_set_secret( 'myplugin/api-key', 'value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); $record = get_option( '_wp_secret_myplugin/api-key' ); - $this->assertIsArray( $record ); - $this->assertArrayHasKey( 'current', $record ); - $this->assertIsArray( $record['current'] ); + $this->assertIsArray( $record, 'The stored record should be an array.' ); + $this->assertArrayHasKey( 'current', $record, 'The stored record should have a current slot.' ); + $this->assertIsArray( $record['current'], 'The current slot of the stored record should be an array.' ); $record['current']['ct'] = base64_encode( 'not decryptable under any key' ); update_option( '_wp_secret_myplugin/api-key', $record, false ); - $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'new-value' ) ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'new-value' ), 'A write over an undecryptable current slot should succeed.' ); $current = wp_get_secret( 'myplugin/api-key' ); - $this->assertInstanceOf( WP_Secret::class, $current ); - $this->assertSame( 'new-value', $current->reveal() ); + $this->assertInstanceOf( WP_Secret::class, $current, 'The new current value should be readable as a WP_Secret.' ); + $this->assertSame( 'new-value', $current->reveal(), 'The new current value should decrypt to the value just written.' ); // The undecryptable slot could not be demoted, so it is simply gone. - $this->assertNull( wp_get_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS ) ); + $this->assertNull( wp_get_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS ), 'An undecryptable current slot should not be demoted to the previous slot.' ); } + /** + * Ensure that wp_retire_secret_version() removes the previous version and keeps the current one. + * + * @covers ::wp_retire_secret_version + */ public function test_retire_clears_previous_and_leaves_current_intact(): void { - wp_set_secret( 'myplugin/api-key', 'first-value' ); - wp_set_secret( 'myplugin/api-key', 'second-value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'first-value' ), 'Setting myplugin/api-key to first-value should succeed.' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'second-value' ), 'Setting myplugin/api-key to second-value should succeed.' ); - $this->assertTrue( wp_retire_secret_version( 'myplugin/api-key' ) ); + $this->assertTrue( wp_retire_secret_version( 'myplugin/api-key' ), 'Retiring the previous version should succeed.' ); - $this->assertNull( wp_get_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS ) ); + $this->assertNull( wp_get_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS ), 'The previous version should be gone after retiring it.' ); $current = wp_get_secret( 'myplugin/api-key' ); - $this->assertInstanceOf( WP_Secret::class, $current ); - $this->assertSame( 'second-value', $current->reveal() ); + $this->assertInstanceOf( WP_Secret::class, $current, 'The current version should still be readable as a WP_Secret.' ); + $this->assertSame( 'second-value', $current->reveal(), 'The current version should be unchanged by retiring the previous one.' ); } + /** + * Ensure that wp_retire_secret_version() returns true for a secret with no previous version. + * + * @covers ::wp_retire_secret_version + */ public function test_retire_on_a_secret_with_no_previous_slot_is_a_successful_noop(): void { - wp_set_secret( 'myplugin/api-key', 'value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); - $this->assertTrue( wp_retire_secret_version( 'myplugin/api-key' ) ); + $this->assertTrue( wp_retire_secret_version( 'myplugin/api-key' ), 'Retiring with no previous version should report success.' ); } + /** + * Ensure that wp_retire_secret_version() returns true for a secret that was never set. + * + * @covers ::wp_retire_secret_version + */ public function test_retire_on_a_never_set_secret_is_a_successful_noop(): void { - $this->assertTrue( wp_retire_secret_version( 'myplugin/never-set' ) ); + $this->assertTrue( wp_retire_secret_version( 'myplugin/never-set' ), 'Retiring a secret that was never set should report success.' ); } + /** + * Ensure that wp_retire_secret_version() rejects an invalid secret name. + * + * @covers ::wp_retire_secret_version + */ public function test_retire_rejects_an_invalid_name(): void { $result = wp_retire_secret_version( 'Not A Valid Name' ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code() ); + $this->assertWPError( $result, 'An invalid name should be rejected with a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code(), 'An invalid name should be rejected with WP_SECRETS_ERROR_INVALID_NAME.' ); } + /** + * Ensure that wp_retire_secret_version() fires wp_secret_changed as retired, with the old fingerprint. + * + * @covers ::wp_retire_secret_version + */ public function test_retire_fires_the_change_hook_with_the_retired_action(): void { - wp_set_secret( 'myplugin/api-key', 'first-value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'first-value' ), 'Setting myplugin/api-key to first-value should succeed.' ); $original = wp_get_secret( 'myplugin/api-key' ); - $this->assertInstanceOf( WP_Secret::class, $original ); + $this->assertInstanceOf( WP_Secret::class, $original, 'The first value should be readable as a WP_Secret.' ); $fingerprint_being_retired = $original->fingerprint(); - wp_set_secret( 'myplugin/api-key', 'second-value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'second-value' ), 'Setting myplugin/api-key to second-value should succeed.' ); $captured = null; add_action( @@ -276,21 +344,26 @@ function ( ...$args ) use ( &$captured ) { 7 ); - wp_retire_secret_version( 'myplugin/api-key' ); + $this->assertTrue( wp_retire_secret_version( 'myplugin/api-key' ), 'Retiring the previous version of myplugin/api-key should succeed.' ); - $this->assertIsArray( $captured ); - $this->assertCount( 7, $captured ); + $this->assertIsArray( $captured, 'wp_secret_changed should fire when the previous version is retired.' ); + $this->assertCount( 7, $captured, 'wp_secret_changed should be passed seven arguments.' ); list( $name, $action, , , $old_fingerprint, $new_fingerprint ) = $captured; - $this->assertSame( 'myplugin/api-key', $name ); - $this->assertSame( 'retired', $action ); - $this->assertSame( $fingerprint_being_retired, $old_fingerprint ); - $this->assertSame( '', $new_fingerprint ); + $this->assertSame( 'myplugin/api-key', $name, 'The change hook should report the name of the secret.' ); + $this->assertSame( 'retired', $action, 'The change hook should report the retired action.' ); + $this->assertSame( $fingerprint_being_retired, $old_fingerprint, 'The change hook should report the retired fingerprint as the old fingerprint.' ); + $this->assertSame( '', $new_fingerprint, 'The change hook should report an empty new fingerprint.' ); } + /** + * Ensure that wp_retire_secret_version() does not fire wp_secret_changed when nothing is retired. + * + * @covers ::wp_retire_secret_version + */ public function test_retire_does_not_fire_the_change_hook_on_a_noop(): void { - wp_set_secret( 'myplugin/api-key', 'value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); $fired = false; add_action( @@ -300,8 +373,8 @@ function () use ( &$fired ) { } ); - wp_retire_secret_version( 'myplugin/api-key' ); + $this->assertTrue( wp_retire_secret_version( 'myplugin/api-key' ), 'Retiring the previous version of myplugin/api-key should succeed.' ); - $this->assertFalse( $fired ); + $this->assertFalse( $fired, 'A no-op retire should not fire wp_secret_changed.' ); } } diff --git a/tests/phpunit/test-wp-delete-secret.php b/tests/phpunit/test-wp-delete-secret.php index 83b9bb3..02e1647 100644 --- a/tests/phpunit/test-wp-delete-secret.php +++ b/tests/phpunit/test-wp-delete-secret.php @@ -2,33 +2,55 @@ /** * Tests for wp_delete_secret(). * + * @ticket 66187 + * * @group secrets */ class Tests_Secrets_WpDeleteSecret extends WP_UnitTestCase { + /** + * Ensure that wp_delete_secret() removes a stored secret so it can no longer be retrieved. + * + * @covers ::wp_delete_secret + */ public function test_deletes_an_existing_secret(): void { - wp_set_secret( 'myplugin/api-key', 'value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); - $this->assertTrue( wp_delete_secret( 'myplugin/api-key' ) ); - $this->assertNull( wp_get_secret( 'myplugin/api-key' ) ); + $this->assertTrue( wp_delete_secret( 'myplugin/api-key' ), 'Deleting an existing secret should return true.' ); + $this->assertNull( wp_get_secret( 'myplugin/api-key' ), 'A deleted secret should no longer be retrievable.' ); } + /** + * Ensure that wp_delete_secret() returns true for a secret that was never stored. + * + * @covers ::wp_delete_secret + */ public function test_deleting_an_absent_secret_is_not_an_error(): void { - $this->assertTrue( wp_delete_secret( 'myplugin/never-set' ) ); + $this->assertTrue( wp_delete_secret( 'myplugin/never-set' ), 'Deleting an absent secret should return true.' ); } + /** + * Ensure that wp_delete_secret() returns an invalid name error for a malformed name. + * + * @covers ::wp_delete_secret + */ public function test_rejects_an_invalid_name(): void { $result = wp_delete_secret( 'Not A Valid Name' ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code() ); + $this->assertWPError( $result, 'An invalid name should be reported as a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code(), 'An invalid name should use the invalid name error code.' ); } + /** + * Ensure that deleting a secret fires wp_secret_changed with the deleted action and old fingerprint. + * + * @covers ::wp_delete_secret + */ public function test_change_hook_fires_with_deleted_action(): void { - wp_set_secret( 'myplugin/api-key', 'value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); $secret = wp_get_secret( 'myplugin/api-key' ); - $this->assertInstanceOf( WP_Secret::class, $secret ); + $this->assertInstanceOf( WP_Secret::class, $secret, 'The stored secret should be retrievable as a WP_Secret.' ); $fingerprint = $secret->fingerprint(); @@ -42,18 +64,23 @@ function ( ...$args ) use ( &$captured ) { 7 ); - wp_delete_secret( 'myplugin/api-key' ); + $this->assertTrue( wp_delete_secret( 'myplugin/api-key' ), 'Deleting myplugin/api-key should succeed.' ); - $this->assertIsArray( $captured ); + $this->assertIsArray( $captured, 'The wp_secret_changed hook should fire.' ); list( $name, $action, , , $old_fingerprint, $new_fingerprint ) = $captured; - $this->assertSame( 'myplugin/api-key', $name ); - $this->assertSame( 'deleted', $action ); - $this->assertSame( $fingerprint, $old_fingerprint ); - $this->assertSame( '', $new_fingerprint ); + $this->assertSame( 'myplugin/api-key', $name, 'The hook should receive the secret name.' ); + $this->assertSame( 'deleted', $action, 'The hook action should be deleted.' ); + $this->assertSame( $fingerprint, $old_fingerprint, 'The old fingerprint should match the deleted secret.' ); + $this->assertSame( '', $new_fingerprint, 'The new fingerprint should be empty after deletion.' ); } + /** + * Ensure that wp_secret_changed does not fire when deleting a secret that does not exist. + * + * @covers ::wp_delete_secret + */ public function test_change_hook_does_not_fire_when_nothing_was_deleted(): void { $fired = false; add_action( @@ -63,27 +90,37 @@ function () use ( &$fired ) { } ); - wp_delete_secret( 'myplugin/never-set' ); + $this->assertTrue( wp_delete_secret( 'myplugin/never-set' ), 'Deleting myplugin/never-set should succeed.' ); - $this->assertFalse( $fired ); + $this->assertFalse( $fired, 'The hook should not fire when nothing was deleted.' ); } /** * A corrupted record must still be removable. Refusing here would leave a secret * permanently stuck – unreadable, unrepairable, and undeletable without editing * the database by hand. + * + * @covers ::wp_delete_secret */ public function test_a_corrupt_record_is_still_deletable(): void { update_option( '_wp_secret_myplugin/corrupt', 'not a record at all', false ); - $this->assertTrue( wp_delete_secret( 'myplugin/corrupt' ) ); - $this->assertNull( wp_get_secret( 'myplugin/corrupt' ) ); + $this->assertTrue( wp_delete_secret( 'myplugin/corrupt' ), 'Deleting a corrupt record should return true.' ); + $this->assertNull( wp_get_secret( 'myplugin/corrupt' ), 'A deleted corrupt record should no longer be retrievable.' ); } + /** + * Ensure that wp_delete_secret() succeeds without a logged-in user. + * + * Capability checks belong at the operator boundary (CLI, admin screens), not in the + * API itself, so plugins can manage their own secrets from any context. + * + * @covers ::wp_delete_secret + */ public function test_no_capability_check_is_applied(): void { - wp_set_secret( 'myplugin/api-key', 'value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); wp_set_current_user( 0 ); - $this->assertTrue( wp_delete_secret( 'myplugin/api-key' ) ); + $this->assertTrue( wp_delete_secret( 'myplugin/api-key' ), 'Deleting a secret without a logged-in user should succeed.' ); } } diff --git a/tests/phpunit/test-wp-import-option-as-secret.php b/tests/phpunit/test-wp-import-option-as-secret.php index a6051be..a06f71e 100644 --- a/tests/phpunit/test-wp-import-option-as-secret.php +++ b/tests/phpunit/test-wp-import-option-as-secret.php @@ -2,77 +2,124 @@ /** * Tests for wp_import_option_as_secret(). * + * @ticket 66187 + * * @group secrets */ class Tests_Secrets_WpImportOptionAsSecret extends WP_UnitTestCase { + /** + * Ensure that wp_import_option_as_secret() stores the option's value as a secret. + * + * @covers ::wp_import_option_as_secret + */ public function test_imports_an_existing_option_value(): void { update_option( 'my_plugin_api_key', 'sk_live_from_an_option' ); - $this->assertTrue( wp_import_option_as_secret( 'my_plugin_api_key', 'myplugin/api-key' ) ); + $this->assertTrue( wp_import_option_as_secret( 'my_plugin_api_key', 'myplugin/api-key' ), 'Importing an existing option should succeed.' ); $secret = wp_get_secret( 'myplugin/api-key' ); - $this->assertInstanceOf( WP_Secret::class, $secret ); - $this->assertSame( 'sk_live_from_an_option', $secret->reveal() ); + $this->assertInstanceOf( WP_Secret::class, $secret, 'The imported secret should be a WP_Secret.' ); + $this->assertSame( 'sk_live_from_an_option', $secret->reveal(), 'The imported secret should decrypt to the option value.' ); } + /** + * Ensure that wp_import_option_as_secret() does not modify or delete the source option. + * + * @covers ::wp_import_option_as_secret + */ public function test_leaves_the_source_option_untouched(): void { update_option( 'my_plugin_api_key', 'sk_live_from_an_option' ); - wp_import_option_as_secret( 'my_plugin_api_key', 'myplugin/api-key' ); + $this->assertTrue( wp_import_option_as_secret( 'my_plugin_api_key', 'myplugin/api-key' ), 'Importing my_plugin_api_key as myplugin/api-key should succeed.' ); - $this->assertSame( 'sk_live_from_an_option', get_option( 'my_plugin_api_key' ) ); + $this->assertSame( 'sk_live_from_an_option', get_option( 'my_plugin_api_key' ), 'The source option should be left unchanged.' ); } + /** + * Ensure that a secret imported from an option is flagged as needing rotation. + * + * @covers ::wp_import_option_as_secret + * @covers WP_Secrets_Libsodium_Provider::set + */ public function test_flags_the_imported_secret_for_rotation(): void { update_option( 'my_plugin_api_key', 'value' ); - wp_import_option_as_secret( 'my_plugin_api_key', 'myplugin/api-key' ); + $this->assertTrue( wp_import_option_as_secret( 'my_plugin_api_key', 'myplugin/api-key' ), 'Importing my_plugin_api_key as myplugin/api-key should succeed.' ); $record = get_option( '_wp_secret_myplugin/api-key' ); - $this->assertIsArray( $record ); - $this->assertArrayHasKey( 'current', $record ); - $this->assertIsArray( $record['current'] ); - $this->assertTrue( $record['current']['needs_rotation'] ); + $this->assertIsArray( $record, 'The stored record should be an array.' ); + $this->assertArrayHasKey( 'current', $record, 'The stored record should have a current slot.' ); + $this->assertIsArray( $record['current'], 'The current slot should be an array.' ); + $this->assertTrue( $record['current']['needs_rotation'], 'An imported secret should be flagged as needing rotation.' ); } + /** + * Ensure that a secret written by wp_set_secret() is not flagged as needing rotation. + * + * The counterpart to the import test above: it proves the flag comes from the import, + * not from a default applied to every write. + * + * @covers ::wp_set_secret + * @covers WP_Secrets_Libsodium_Provider::set + */ public function test_an_ordinary_write_does_not_flag_needs_rotation(): void { - wp_set_secret( 'myplugin/api-key', 'value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); $record = get_option( '_wp_secret_myplugin/api-key' ); - $this->assertIsArray( $record ); - $this->assertArrayHasKey( 'current', $record ); - $this->assertIsArray( $record['current'] ); - $this->assertFalse( $record['current']['needs_rotation'] ); + $this->assertIsArray( $record, 'The stored record should be an array.' ); + $this->assertArrayHasKey( 'current', $record, 'The stored record should have a current slot.' ); + $this->assertIsArray( $record['current'], 'The current slot should be an array.' ); + $this->assertFalse( $record['current']['needs_rotation'], 'An ordinary write should not be flagged as needing rotation.' ); } + /** + * Ensure that wp_import_option_as_secret() rejects an option that does not exist. + * + * @covers ::wp_import_option_as_secret + */ public function test_rejects_a_nonexistent_option(): void { $result = wp_import_option_as_secret( 'this_option_was_never_set', 'myplugin/api-key' ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_INVALID_VALUE, $result->get_error_code() ); + $this->assertWPError( $result, 'A nonexistent option should be reported as a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_VALUE, $result->get_error_code(), 'A nonexistent option should produce the invalid value error code.' ); } + /** + * Ensure that wp_import_option_as_secret() rejects an option whose value is not a string. + * + * @covers ::wp_import_option_as_secret + */ public function test_rejects_a_non_string_option_value(): void { update_option( 'my_plugin_settings', array( 'not' => 'a string' ) ); $result = wp_import_option_as_secret( 'my_plugin_settings', 'myplugin/api-key' ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_INVALID_VALUE, $result->get_error_code() ); + $this->assertWPError( $result, 'A non-string option value should be reported as a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_VALUE, $result->get_error_code(), 'A non-string option value should produce the invalid value error code.' ); } + /** + * Ensure that wp_import_option_as_secret() rejects an invalid destination secret name. + * + * @covers ::wp_import_option_as_secret + */ public function test_rejects_an_invalid_destination_name(): void { update_option( 'my_plugin_api_key', 'value' ); $result = wp_import_option_as_secret( 'my_plugin_api_key', 'Not A Valid Name' ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code() ); + $this->assertWPError( $result, 'An invalid destination name should be reported as a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code(), 'An invalid destination name should produce the invalid name error code.' ); } + /** + * Ensure that importing an option fires wp_secret_changed with the imported action. + * + * @covers ::wp_import_option_as_secret + */ public function test_fires_the_change_hook_with_the_imported_action(): void { update_option( 'my_plugin_api_key', 'value' ); @@ -86,32 +133,34 @@ function ( ...$args ) use ( &$captured ) { 7 ); - wp_import_option_as_secret( 'my_plugin_api_key', 'myplugin/api-key' ); + $this->assertTrue( wp_import_option_as_secret( 'my_plugin_api_key', 'myplugin/api-key' ), 'Importing my_plugin_api_key as myplugin/api-key should succeed.' ); - $this->assertIsArray( $captured ); + $this->assertIsArray( $captured, 'The wp_secret_changed action should have fired.' ); list( $name, $action ) = $captured; - $this->assertSame( 'myplugin/api-key', $name ); - $this->assertSame( 'imported', $action ); + $this->assertSame( 'myplugin/api-key', $name, 'The hook should receive the destination secret name.' ); + $this->assertSame( 'imported', $action, 'The hook should receive the imported action.' ); } /** * An import onto an existing secret behaves like any other write, including * demotion – there is nothing special about the destination just because the * new value's source happens to be an option. + * + * @covers ::wp_import_option_as_secret */ public function test_importing_over_an_existing_secret_demotes_it_like_any_write(): void { - wp_set_secret( 'myplugin/api-key', 'original-value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'original-value' ), 'Setting myplugin/api-key to original-value should succeed.' ); update_option( 'my_plugin_api_key', 'imported-value' ); - wp_import_option_as_secret( 'my_plugin_api_key', 'myplugin/api-key' ); + $this->assertTrue( wp_import_option_as_secret( 'my_plugin_api_key', 'myplugin/api-key' ), 'Importing my_plugin_api_key as myplugin/api-key should succeed.' ); $current = wp_get_secret( 'myplugin/api-key' ); $previous = wp_get_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS ); - $this->assertInstanceOf( WP_Secret::class, $current ); - $this->assertInstanceOf( WP_Secret::class, $previous ); - $this->assertSame( 'imported-value', $current->reveal() ); - $this->assertSame( 'original-value', $previous->reveal() ); + $this->assertInstanceOf( WP_Secret::class, $current, 'The current version should be a WP_Secret.' ); + $this->assertInstanceOf( WP_Secret::class, $previous, 'The previous version should be a WP_Secret.' ); + $this->assertSame( 'imported-value', $current->reveal(), 'The current version should decrypt to the imported value.' ); + $this->assertSame( 'original-value', $previous->reveal(), 'The previous version should decrypt to the original value.' ); } } diff --git a/tests/phpunit/test-wp-list-secrets.php b/tests/phpunit/test-wp-list-secrets.php index a088eb3..94059c2 100644 --- a/tests/phpunit/test-wp-list-secrets.php +++ b/tests/phpunit/test-wp-list-secrets.php @@ -2,136 +2,193 @@ /** * Tests for wp_list_secrets(). * + * @ticket 66187 + * * @group secrets */ class Tests_Secrets_WpListSecrets extends WP_UnitTestCase { + /** + * Ensure that wp_list_secrets() returns an empty array when no secrets are stored. + * + * @covers ::wp_list_secrets + */ public function test_returns_an_empty_array_when_nothing_is_set(): void { - $this->assertSame( array(), wp_list_secrets() ); + $this->assertSame( array(), wp_list_secrets(), 'An empty store should list no secrets.' ); } + /** + * Ensure that each wp_list_secrets() entry has exactly the documented metadata keys, in order. + * + * @covers ::wp_list_secrets + */ public function test_lists_every_secret_with_the_expected_keys(): void { - wp_set_secret( 'myplugin/api-key', 'value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); $entries = wp_list_secrets(); - $this->assertIsArray( $entries ); - $this->assertCount( 1, $entries ); + $this->assertIsArray( $entries, 'wp_list_secrets() should return an array.' ); + $this->assertCount( 1, $entries, 'Exactly one secret should be listed.' ); $this->assertSame( array( 'name', 'fingerprint', 'created', 'has_previous', 'needs_rotation' ), - array_keys( $entries[0] ) + array_keys( $entries[0] ), + 'The entry should have exactly the documented metadata keys, in order.' ); - $this->assertSame( 'myplugin/api-key', $entries[0]['name'] ); + $this->assertSame( 'myplugin/api-key', $entries[0]['name'], 'The entry should carry the secret name.' ); } /** * Never a value, under any circumstance – the entire justification for this * function existing depends on it. + * + * @covers ::wp_list_secrets */ public function test_never_returns_a_value(): void { - wp_set_secret( 'myplugin/api-key', 'a-plaintext-value-that-must-not-leak' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'a-plaintext-value-that-must-not-leak' ), 'Setting myplugin/api-key to a-plaintext-value-that-must-not-leak should succeed.' ); $dump = wp_json_encode( wp_list_secrets() ); - $this->assertIsString( $dump ); - $this->assertStringNotContainsString( 'a-plaintext-value-that-must-not-leak', $dump ); + $this->assertIsString( $dump, 'The listing should encode to a JSON string.' ); + $this->assertStringNotContainsString( 'a-plaintext-value-that-must-not-leak', $dump, 'The listing should never contain the plaintext value.' ); } + /** + * Ensure that a listed fingerprint matches WP_Secret::fingerprint() for the same secret. + * + * @covers ::wp_list_secrets + */ public function test_fingerprint_matches_the_secrets_own_fingerprint(): void { - wp_set_secret( 'myplugin/api-key', 'value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); $secret = wp_get_secret( 'myplugin/api-key' ); $entries = wp_list_secrets(); - $this->assertInstanceOf( WP_Secret::class, $secret ); - $this->assertIsArray( $entries ); - $this->assertSame( $secret->fingerprint(), $entries[0]['fingerprint'] ); + $this->assertInstanceOf( WP_Secret::class, $secret, 'The secret should be a WP_Secret.' ); + $this->assertIsArray( $entries, 'wp_list_secrets() should return an array.' ); + $this->assertCount( 1, $entries, 'Exactly one secret should be listed.' ); + $this->assertSame( $secret->fingerprint(), $entries[0]['fingerprint'], 'The listed fingerprint should match the fingerprint of the secret.' ); } + /** + * Ensure that has_previous is false for a new secret and true once it has been overwritten. + * + * @covers ::wp_list_secrets + */ public function test_has_previous_is_false_before_a_rotation_and_true_after(): void { - wp_set_secret( 'myplugin/api-key', 'first-value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'first-value' ), 'Setting myplugin/api-key to first-value should succeed.' ); $before = wp_list_secrets(); - $this->assertIsArray( $before ); - $this->assertFalse( $before[0]['has_previous'] ); + $this->assertIsArray( $before, 'wp_list_secrets() should return an array before the overwrite.' ); + $this->assertFalse( $before[0]['has_previous'], 'has_previous should be false before the secret is overwritten.' ); - wp_set_secret( 'myplugin/api-key', 'second-value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'second-value' ), 'Setting myplugin/api-key to second-value should succeed.' ); $after = wp_list_secrets(); - $this->assertIsArray( $after ); - $this->assertTrue( $after[0]['has_previous'] ); + $this->assertIsArray( $after, 'wp_list_secrets() should return an array after the overwrite.' ); + $this->assertTrue( $after[0]['has_previous'], 'has_previous should be true after the secret is overwritten.' ); } + /** + * Ensure that the needs_rotation entry reflects the flag stored on the current version. + * + * The stored record is edited directly to set the flag, so the test exercises + * wp_list_secrets() without depending on wp_import_option_as_secret(). + * + * @covers ::wp_list_secrets + */ public function test_needs_rotation_reflects_the_current_slots_flag(): void { - wp_set_secret( 'myplugin/api-key', 'value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); $record = get_option( '_wp_secret_myplugin/api-key' ); - $this->assertIsArray( $record ); - $this->assertArrayHasKey( 'current', $record ); - $this->assertIsArray( $record['current'] ); + $this->assertIsArray( $record, 'The stored record should be an array.' ); + $this->assertArrayHasKey( 'current', $record, 'The stored record should have a current slot.' ); + $this->assertIsArray( $record['current'], 'The current slot should be an array.' ); $record['current']['needs_rotation'] = true; update_option( '_wp_secret_myplugin/api-key', $record, false ); $entries = wp_list_secrets(); - $this->assertIsArray( $entries ); - $this->assertTrue( $entries[0]['needs_rotation'] ); + $this->assertIsArray( $entries, 'wp_list_secrets() should return an array.' ); + $this->assertCount( 1, $entries, 'Exactly one secret should be listed.' ); + $this->assertTrue( $entries[0]['needs_rotation'], 'needs_rotation should reflect the flag stored on the current slot.' ); } + /** + * Ensure that wp_list_secrets() returns only the secrets in the requested namespace. + * + * @covers ::wp_list_secrets + */ public function test_filters_by_namespace(): void { - wp_set_secret( 'pluginone/key', 'value' ); - wp_set_secret( 'plugintwo/key', 'value' ); + $this->assertTrue( wp_set_secret( 'pluginone/key', 'value' ), 'Setting pluginone/key to value should succeed.' ); + $this->assertTrue( wp_set_secret( 'plugintwo/key', 'value' ), 'Setting plugintwo/key to value should succeed.' ); $entries = wp_list_secrets( 'pluginone' ); - $this->assertIsArray( $entries ); - $this->assertSame( array( 'pluginone/key' ), wp_list_pluck( $entries, 'name' ) ); + $this->assertIsArray( $entries, 'wp_list_secrets() should return an array.' ); + $this->assertSame( array( 'pluginone/key' ), wp_list_pluck( $entries, 'name' ), 'Only secrets in the requested namespace should be listed.' ); } + /** + * Ensure that wp_list_secrets() with an empty namespace returns secrets from every namespace. + * + * @covers ::wp_list_secrets + */ public function test_empty_namespace_returns_everything(): void { - wp_set_secret( 'pluginone/key', 'value' ); - wp_set_secret( 'plugintwo/key', 'value' ); + $this->assertTrue( wp_set_secret( 'pluginone/key', 'value' ), 'Setting pluginone/key to value should succeed.' ); + $this->assertTrue( wp_set_secret( 'plugintwo/key', 'value' ), 'Setting plugintwo/key to value should succeed.' ); $entries = wp_list_secrets( '' ); - $this->assertIsArray( $entries ); - $this->assertCount( 2, $entries ); + $this->assertIsArray( $entries, 'wp_list_secrets() should return an array.' ); + $this->assertCount( 2, $entries, 'An empty namespace should list secrets from every namespace.' ); } + /** + * Ensure that a namespace filter matches whole namespaces, not partial prefixes of them. + * + * @covers ::wp_list_secrets + */ public function test_namespace_does_not_match_a_prefix_of_a_different_namespace(): void { // 'plugin' must not match 'pluginone/key' – only a full "namespace/" prefix. - wp_set_secret( 'pluginone/key', 'value' ); + $this->assertTrue( wp_set_secret( 'pluginone/key', 'value' ), 'Setting pluginone/key to value should succeed.' ); - $this->assertSame( array(), wp_list_secrets( 'plugin' ) ); + $this->assertSame( array(), wp_list_secrets( 'plugin' ), 'A namespace should not match a prefix of a different namespace.' ); } /** * A corrupted record must still appear in the list – Site Health's * undecryptable-secrets check depends on being able to see it exists. + * + * @covers ::wp_list_secrets */ public function test_a_corrupt_record_is_still_listed_with_blank_metadata(): void { update_option( '_wp_secret_myplugin/corrupt', 'not a record at all', false ); $entries = wp_list_secrets(); - $this->assertIsArray( $entries ); - $this->assertCount( 1, $entries ); - $this->assertSame( 'myplugin/corrupt', $entries[0]['name'] ); - $this->assertSame( '', $entries[0]['fingerprint'] ); - $this->assertFalse( $entries[0]['has_previous'] ); + $this->assertIsArray( $entries, 'wp_list_secrets() should return an array.' ); + $this->assertCount( 1, $entries, 'The corrupt record should still be listed.' ); + $this->assertSame( 'myplugin/corrupt', $entries[0]['name'], 'The corrupt record entry should carry its name.' ); + $this->assertSame( '', $entries[0]['fingerprint'], 'The corrupt record entry should have a blank fingerprint.' ); + $this->assertFalse( $entries[0]['has_previous'], 'The corrupt record entry should report no previous version.' ); } + /** + * Ensure that wp_list_secrets() excludes network secrets. + * + * @covers ::wp_list_secrets + */ public function test_does_not_list_network_scope_secrets(): void { - wp_set_secret( 'myplugin/site-only', 'value' ); - _wp_secrets_set( 'myplugin/network-only', 'value', true ); + $this->assertTrue( wp_set_secret( 'myplugin/site-only', 'value' ), 'Setting myplugin/site-only to value should succeed.' ); + $this->assertTrue( wp_set_network_secret( 'myplugin/network-only', 'value' ), 'Setting network secret myplugin/network-only to value should succeed.' ); $entries = wp_list_secrets(); - $this->assertIsArray( $entries ); - $this->assertSame( array( 'myplugin/site-only' ), wp_list_pluck( $entries, 'name' ) ); + $this->assertIsArray( $entries, 'wp_list_secrets() should return an array.' ); + $this->assertSame( array( 'myplugin/site-only' ), wp_list_pluck( $entries, 'name' ), 'Only site-scope secrets should be listed.' ); } } diff --git a/tests/phpunit/test-wp-secret-version.php b/tests/phpunit/test-wp-secret-version.php index c972472..e7e236c 100644 --- a/tests/phpunit/test-wp-secret-version.php +++ b/tests/phpunit/test-wp-secret-version.php @@ -1,30 +1,55 @@ assertIsString( WP_Secret_Version::CURRENT ); - $this->assertIsString( WP_Secret_Version::PREVIOUS ); - $this->assertNotSame( WP_Secret_Version::CURRENT, WP_Secret_Version::PREVIOUS ); + $this->assertIsString( WP_Secret_Version::CURRENT, 'WP_Secret_Version::CURRENT should be a string.' ); + $this->assertIsString( WP_Secret_Version::PREVIOUS, 'WP_Secret_Version::PREVIOUS should be a string.' ); + $this->assertNotSame( WP_Secret_Version::CURRENT, WP_Secret_Version::PREVIOUS, 'CURRENT and PREVIOUS should hold different values.' ); } + /** + * Ensure that WP_Secret_Version cannot be extended. + * + * @coversNothing + */ public function test_class_is_final(): void { $reflection = new ReflectionClass( WP_Secret_Version::class ); - $this->assertTrue( $reflection->isFinal() ); + $this->assertTrue( $reflection->isFinal(), 'WP_Secret_Version should be declared final.' ); } + /** + * Ensure that WP_Secret_Version is a pure constants holder with no constructor. + * + * @coversNothing + */ public function test_class_declares_no_constructor(): void { $reflection = new ReflectionClass( WP_Secret_Version::class ); - $this->assertNull( $reflection->getConstructor() ); + $this->assertNull( $reflection->getConstructor(), 'WP_Secret_Version should not declare a constructor.' ); } + /** + * Ensure that WP_Secret_Version defines only the current and previous slots. + * + * Records hold exactly two slots and the cipher accepts only these two values, so a + * new constant would need matching support elsewhere before it could be used. + * + * @coversNothing + */ public function test_class_declares_exactly_two_constants(): void { $reflection = new ReflectionClass( WP_Secret_Version::class ); - $this->assertCount( 2, $reflection->getConstants() ); + $this->assertCount( 2, $reflection->getConstants(), 'WP_Secret_Version should declare exactly two constants.' ); } } diff --git a/tests/phpunit/test-wp-secret.php b/tests/phpunit/test-wp-secret.php index 2418044..c001bfd 100644 --- a/tests/phpunit/test-wp-secret.php +++ b/tests/phpunit/test-wp-secret.php @@ -2,12 +2,15 @@ /** * Tests for WP_Secret. * - * @package SecretsAPI + * @package WordPress + * @subpackage Secrets */ /** * Masking, serialization refusal, and vault lifecycle for WP_Secret. * + * @ticket 66187 + * * @group secrets */ class Tests_Secrets_WPSecret extends WP_UnitTestCase { @@ -25,46 +28,89 @@ private function make_secret() { return new WP_Secret( self::NAME, self::PLAINTEXT, self::FINGERPRINT ); } + /** + * Ensure that WP_Secret cannot be extended. + * + * A subclass could declare a property or magic method that exposes the plaintext, + * bypassing the masking guarantees this class makes. + * + * @coversNothing + */ public function test_class_is_final(): void { $reflection = new ReflectionClass( WP_Secret::class ); - $this->assertTrue( $reflection->isFinal() ); + $this->assertTrue( $reflection->isFinal(), 'WP_Secret should be declared final.' ); } + /** + * Ensure that WP_Secret implements JsonSerializable so json_encode() goes through the mask. + * + * @coversNothing + */ public function test_implements_json_serializable(): void { - $this->assertInstanceOf( JsonSerializable::class, $this->make_secret() ); + $this->assertInstanceOf( JsonSerializable::class, $this->make_secret(), 'WP_Secret should implement JsonSerializable.' ); } + /** + * Ensure that WP_Secret::reveal() returns the plaintext the secret was constructed with. + * + * @covers WP_Secret::reveal + */ public function test_reveal_returns_the_exact_plaintext(): void { $secret = $this->make_secret(); - $this->assertSame( self::PLAINTEXT, $secret->reveal() ); + $this->assertSame( self::PLAINTEXT, $secret->reveal(), 'reveal() should return the plaintext the secret was constructed with.' ); } + /** + * Ensure that WP_Secret::fingerprint() returns the fingerprint the secret was constructed with. + * + * @covers WP_Secret::fingerprint + */ public function test_fingerprint_returns_the_exact_fingerprint(): void { $secret = $this->make_secret(); - $this->assertSame( self::FINGERPRINT, $secret->fingerprint() ); + $this->assertSame( self::FINGERPRINT, $secret->fingerprint(), 'fingerprint() should return the fingerprint the secret was constructed with.' ); } + /** + * Ensure that WP_Secret::get_name() returns the name the secret was constructed with. + * + * @covers WP_Secret::get_name + */ public function test_get_name_returns_the_exact_name(): void { $secret = $this->make_secret(); - $this->assertSame( self::NAME, $secret->get_name() ); + $this->assertSame( self::NAME, $secret->get_name(), 'get_name() should return the name the secret was constructed with.' ); } + /** + * Ensure that constructing a WP_Secret with a non-string value throws InvalidArgumentException. + * + * @covers WP_Secret::__construct + */ public function test_constructor_rejects_a_non_string_value(): void { $this->expectException( InvalidArgumentException::class ); new WP_Secret( self::NAME, 12345, self::FINGERPRINT ); // @phpstan-ignore argument.type (Intentionally passing a non-string value.) } + /** + * Ensure that constructing a WP_Secret with an empty name throws InvalidArgumentException. + * + * @covers WP_Secret::__construct + */ public function test_constructor_rejects_an_empty_name(): void { $this->expectException( InvalidArgumentException::class ); new WP_Secret( '', self::PLAINTEXT, self::FINGERPRINT ); } + /** + * Ensure that constructing a WP_Secret with an empty fingerprint throws InvalidArgumentException. + * + * @covers WP_Secret::__construct + */ public function test_constructor_rejects_an_empty_fingerprint(): void { $this->expectException( InvalidArgumentException::class ); @@ -74,7 +120,11 @@ public function test_constructor_rejects_an_empty_fingerprint(): void { /** * Every representation short of reveal() must exclude the plaintext. * - * @dataProvider data_masking_surfaces + * @covers WP_Secret::__toString + * @covers WP_Secret::jsonSerialize + * @covers WP_Secret::__debugInfo + * + * @dataProvider data_masking_surfaces_never_contain_the_plaintext * * @param callable( WP_Secret ): ( string|false ) $callback Renders the secret through one surface. */ @@ -83,14 +133,14 @@ public function test_masking_surfaces_never_contain_the_plaintext( $callback ): $output = $callback( $secret ); - $this->assertIsString( $output ); - $this->assertStringNotContainsString( self::PLAINTEXT, $output ); + $this->assertIsString( $output, 'The masking surface should render the secret as a string.' ); + $this->assertStringNotContainsString( self::PLAINTEXT, $output, 'The masking surface output should not contain the plaintext.' ); } /** * @return array */ - public function data_masking_surfaces() { + public static function data_masking_surfaces_never_contain_the_plaintext() { return array( '(string) cast' => array( function ( WP_Secret $secret ) { @@ -138,22 +188,37 @@ function ( WP_Secret $secret ) { ); } + /** + * Ensure that casting a WP_Secret to string yields the [secret:{name}] placeholder. + * + * @covers WP_Secret::__toString + */ public function test_to_string_yields_the_masked_placeholder(): void { $secret = $this->make_secret(); - $this->assertSame( '[secret:' . self::NAME . ']', (string) $secret ); + $this->assertSame( '[secret:' . self::NAME . ']', (string) $secret, 'Casting to string should yield the masked placeholder.' ); } + /** + * Ensure that json_encode() renders a WP_Secret as the [secret:{name}] placeholder. + * + * @covers WP_Secret::jsonSerialize + */ public function test_json_encode_yields_the_masked_placeholder(): void { $secret = $this->make_secret(); // json_encode() escapes '/' by default; decode rather than compare raw JSON. $json = json_encode( $secret ); - $this->assertIsString( $json ); - $this->assertSame( '[secret:' . self::NAME . ']', json_decode( $json ) ); + $this->assertIsString( $json, 'json_encode() should return a JSON string.' ); + $this->assertSame( '[secret:' . self::NAME . ']', json_decode( $json ), 'The decoded JSON should be the masked placeholder.' ); } + /** + * Ensure that var_dump() of a WP_Secret shows the [secret:{name}] placeholder. + * + * @covers WP_Secret::__debugInfo + */ public function test_var_dump_yields_the_masked_placeholder(): void { $secret = $this->make_secret(); @@ -161,9 +226,14 @@ public function test_var_dump_yields_the_masked_placeholder(): void { var_dump( $secret ); $output = ob_get_clean(); - $this->assertStringContainsString( '[secret:' . self::NAME . ']', $output ); + $this->assertStringContainsString( '[secret:' . self::NAME . ']', $output, 'var_dump() output should contain the masked placeholder.' ); } + /** + * Ensure that serializing a WP_Secret throws LogicException. + * + * @covers WP_Secret::__serialize + */ public function test_serialize_throws(): void { $secret = $this->make_secret(); @@ -172,6 +242,11 @@ public function test_serialize_throws(): void { serialize( $secret ); } + /** + * Ensure that cloning a WP_Secret throws LogicException. + * + * @covers WP_Secret::__clone + */ public function test_clone_throws(): void { $secret = $this->make_secret(); @@ -185,7 +260,12 @@ public function test_clone_throws(): void { * because __serialize() alone shadows __sleep() and vice versa depending on which * pair PHP's engine prefers – this proves each of the four refuses independently. * - * @dataProvider data_refusing_magic_methods + * @covers WP_Secret::__sleep + * @covers WP_Secret::__wakeup + * @covers WP_Secret::__serialize + * @covers WP_Secret::__unserialize + * + * @dataProvider data_serialization_magic_methods_throw_directly * * @param string $method Magic method name. * @param array $args Arguments to invoke it with. @@ -205,7 +285,7 @@ public function test_serialization_magic_methods_throw_directly( $method, $args /** * @return array}> */ - public function data_refusing_magic_methods() { + public static function data_serialization_magic_methods_throw_directly() { return array( '__sleep' => array( '__sleep', array() ), '__wakeup' => array( '__wakeup', array() ), @@ -214,6 +294,12 @@ public function data_refusing_magic_methods() { ); } + /** + * Ensure that a secret's plaintext is removed from the vault once its last reference is released. + * + * @covers WP_Secret::__construct + * @covers WP_Secret::__destruct + */ public function test_destructing_the_last_reference_removes_it_from_the_vault(): void { $secret = $this->make_secret(); $id = spl_object_id( $secret ); @@ -225,15 +311,15 @@ public function test_destructing_the_last_reference_removes_it_from_the_vault(): $vault = $vault_property->getValue(); - $this->assertIsArray( $vault ); - $this->assertArrayHasKey( $id, $vault ); + $this->assertIsArray( $vault, 'The vault should be an array while the secret is referenced.' ); + $this->assertArrayHasKey( $id, $vault, 'The vault should hold the secret while it is referenced.' ); unset( $secret ); $vault = $vault_property->getValue(); - $this->assertIsArray( $vault ); - $this->assertArrayNotHasKey( $id, $vault ); + $this->assertIsArray( $vault, 'The vault should be an array after the secret is destroyed.' ); + $this->assertArrayNotHasKey( $id, $vault, 'The vault should not hold the secret after its last reference is released.' ); } /** @@ -244,6 +330,9 @@ public function test_destructing_the_last_reference_removes_it_from_the_vault(): * while a persistent backend (Redis, Memcached) serializes instead, which * __serialize() refuses. Both are LogicException, so both are covered here * without the test having to know which cache is installed. + * + * @covers WP_Secret::__clone + * @covers WP_Secret::__serialize */ public function test_wp_cache_set_of_a_secret_is_refused(): void { $secret = $this->make_secret(); @@ -263,6 +352,8 @@ public function test_wp_cache_set_of_a_secret_is_refused(): void { * regression guard against a future change that moves the plaintext back into a * declared property, which would defeat the masking design without breaking any * of the magic-method tests. + * + * @covers WP_Secret::__construct */ public function test_nothing_plaintext_survives_a_refused_cache_write(): void { $secret = $this->make_secret(); @@ -275,19 +366,26 @@ public function test_nothing_plaintext_survives_a_refused_cache_write(): void { $cached = wp_cache_get( 'secret', 'secrets-test' ); - $this->assertNeverContainsPlaintext( self::PLAINTEXT, $cached ); + $this->assertNeverContainsPlaintext( self::PLAINTEXT, $cached, 'The cache should never hold the plaintext after a refused write.' ); } + /** + * Ensure that each WP_Secret holds its own plaintext and destroying one leaves the other intact. + * + * @covers WP_Secret::__construct + * @covers WP_Secret::reveal + * @covers WP_Secret::__destruct + */ public function test_two_instances_do_not_share_a_vault_slot(): void { $a = new WP_Secret( 'plugin/a', 'value-a', 'fp-a' ); $b = new WP_Secret( 'plugin/b', 'value-b', 'fp-b' ); - $this->assertSame( 'value-a', $a->reveal() ); - $this->assertSame( 'value-b', $b->reveal() ); + $this->assertSame( 'value-a', $a->reveal(), 'The first secret should reveal its own plaintext.' ); + $this->assertSame( 'value-b', $b->reveal(), 'The second secret should reveal its own plaintext.' ); unset( $a ); - $this->assertSame( 'value-b', $b->reveal() ); + $this->assertSame( 'value-b', $b->reveal(), 'The second secret should still reveal its plaintext after the first is destroyed.' ); } // – withheld secrets (provider does not release the value) --------------- @@ -297,6 +395,9 @@ public function test_two_instances_do_not_share_a_vault_slot(): void { * HSM signing key, a brokered credential. Everything except reveal() behaves * normally, which is the point – it still lists, still fingerprints, and * still masks itself everywhere. + * + * @covers WP_Secret::withheld + * @covers WP_Secret::reveal */ public function test_withheld_reveal_returns_the_providers_reason(): void { $reason = new WP_Error( 'provider_withholds_value', 'This key never leaves the HSM.' ); @@ -304,10 +405,17 @@ public function test_withheld_reveal_returns_the_providers_reason(): void { $revealed = $secret->reveal(); - $this->assertWPError( $revealed ); - $this->assertSame( 'provider_withholds_value', $revealed->get_error_code() ); + $this->assertWPError( $revealed, 'reveal() on a withheld secret should return a WP_Error.' ); + $this->assertSame( 'provider_withholds_value', $revealed->get_error_code(), 'reveal() should return the error code the provider gave.' ); } + /** + * Ensure that a withheld secret still reports its name and fingerprint. + * + * @covers WP_Secret::withheld + * @covers WP_Secret::get_name + * @covers WP_Secret::fingerprint + */ public function test_withheld_still_reports_name_and_fingerprint(): void { $secret = WP_Secret::withheld( 'myplugin/signing-key', @@ -315,13 +423,17 @@ public function test_withheld_still_reports_name_and_fingerprint(): void { new WP_Error( 'provider_withholds_value', 'nope' ) ); - $this->assertSame( 'myplugin/signing-key', $secret->get_name() ); - $this->assertSame( 'abc123', $secret->fingerprint() ); + $this->assertSame( 'myplugin/signing-key', $secret->get_name(), 'A withheld secret should still report its name.' ); + $this->assertSame( 'abc123', $secret->fingerprint(), 'A withheld secret should still report its fingerprint.' ); } /** * The masking guarantees are not weakened by the withheld path: there is no * plaintext to leak, and the error reason must not leak either. + * + * @covers WP_Secret::withheld + * @covers WP_Secret::__debugInfo + * @covers WP_Secret::__toString */ public function test_withheld_masks_like_any_other_secret(): void { $secret = WP_Secret::withheld( @@ -332,17 +444,7 @@ public function test_withheld_masks_like_any_other_secret(): void { $printed = print_r( $secret, true ); - $this->assertStringNotContainsString( 'UNIQUE-REASON-CANARY-4b1c', $printed ); - $this->assertStringContainsString( '[secret:myplugin/signing-key]', (string) $secret ); - } - - /** - * An ordinary secret is unaffected: reveal() still returns the plaintext, and - * the widened return type is not a behaviour change for the shipped provider. - */ - public function test_an_ordinary_secret_still_reveals_a_plain_string(): void { - $secret = new WP_Secret( 'myplugin/api-key', 'sk_live_value', 'fingerprint' ); - - $this->assertSame( 'sk_live_value', $secret->reveal() ); + $this->assertStringNotContainsString( 'UNIQUE-REASON-CANARY-4b1c', $printed, 'print_r() output should not contain the withheld reason.' ); + $this->assertStringContainsString( '[secret:myplugin/signing-key]', (string) $secret, 'Casting a withheld secret to string should yield the masked placeholder.' ); } } diff --git a/tests/phpunit/test-wp-secrets-cipher.php b/tests/phpunit/test-wp-secrets-cipher.php index 63b8eec..fc2a4ea 100644 --- a/tests/phpunit/test-wp-secrets-cipher.php +++ b/tests/phpunit/test-wp-secrets-cipher.php @@ -2,6 +2,8 @@ /** * Tests for WP_Secrets_Cipher. * + * @ticket 66187 + * * @group secrets */ class Tests_Secrets_WPSecretsCipher extends WP_UnitTestCase { @@ -35,46 +37,73 @@ private function master_key( $byte = 0x11 ) { return str_repeat( chr( $byte ), 32 ); } + /** + * Ensure that a value encrypted by WP_Secrets_Cipher::encrypt_value() decrypts back unchanged. + * + * @covers WP_Secrets_Cipher::encrypt_value + * @covers WP_Secrets_Cipher::decrypt_value + */ public function test_round_trips_a_value(): void { $cipher = new WP_Secrets_Cipher(); $record = $cipher->encrypt_value( $this->master_key(), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, 'sk_live_secret' ); - $this->assertIsArray( $record ); + $this->assertIsArray( $record, 'Encrypting a value should return a record array.' ); $plaintext = $cipher->decrypt_value( $this->master_key(), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, $record ); - $this->assertSame( 'sk_live_secret', $plaintext ); + $this->assertSame( 'sk_live_secret', $plaintext, 'The record should decrypt to the original plaintext.' ); } + /** + * Ensure that an empty string encrypts and decrypts back to an empty string. + * + * @covers WP_Secrets_Cipher::encrypt_value + * @covers WP_Secrets_Cipher::decrypt_value + */ public function test_round_trips_an_empty_string_value(): void { $cipher = new WP_Secrets_Cipher(); $record = $cipher->encrypt_value( $this->master_key(), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, '' ); $plaintext = $cipher->decrypt_value( $this->master_key(), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, $record ); - $this->assertSame( '', $plaintext ); + $this->assertSame( '', $plaintext, 'An empty string should round-trip to an empty string.' ); } + /** + * Ensure that a value encrypted under network scope with site ID 0 decrypts back unchanged. + * + * @covers WP_Secrets_Cipher::encrypt_value + * @covers WP_Secrets_Cipher::decrypt_value + */ public function test_round_trips_under_network_scope(): void { $cipher = new WP_Secrets_Cipher(); $record = $cipher->encrypt_value( $this->master_key(), 'network', 0, self::NAME, WP_Secret_Version::CURRENT, 'network-secret' ); $plaintext = $cipher->decrypt_value( $this->master_key(), 'network', 0, self::NAME, WP_Secret_Version::CURRENT, $record ); - $this->assertSame( 'network-secret', $plaintext ); + $this->assertSame( 'network-secret', $plaintext, 'A network-scope record should decrypt to the original plaintext.' ); } + /** + * Ensure that WP_Secrets_Cipher::encrypt_value() refuses to encrypt a non-string plaintext. + * + * @covers WP_Secrets_Cipher::encrypt_value + */ public function test_encrypt_rejects_a_non_string_value(): void { $cipher = new WP_Secrets_Cipher(); $result = $cipher->encrypt_value( $this->master_key(), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, array() ); // @phpstan-ignore argument.type (Intentionally passing a non-string plaintext.) - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_INVALID_VALUE, $result->get_error_code() ); + $this->assertWPError( $result, 'Encrypting a non-string value should return a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_VALUE, $result->get_error_code(), 'A non-string value should be reported as an invalid value.' ); } /** - * @dataProvider data_bad_master_keys + * Ensure that WP_Secrets_Cipher::encrypt_value() refuses a master key that is not 32 bytes. + * + * @covers WP_Secrets_Cipher::encrypt_value + * + * @dataProvider data_encrypt_rejects_a_bad_master_key * * @param string $bad_key Master key the cipher must refuse. */ @@ -83,14 +112,14 @@ public function test_encrypt_rejects_a_bad_master_key( $bad_key ): void { $result = $cipher->encrypt_value( $bad_key, 'site', 1, self::NAME, WP_Secret_Version::CURRENT, 'value' ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() ); + $this->assertWPError( $result, 'Encrypting with a bad master key should return a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code(), 'A bad master key should be reported as key unavailable.' ); } /** * @return array */ - public function data_bad_master_keys() { + public static function data_encrypt_rejects_a_bad_master_key() { return array( 'too short' => array( str_repeat( 'a', 31 ) ), 'too long' => array( str_repeat( 'a', 33 ) ), @@ -98,15 +127,29 @@ public function data_bad_master_keys() { ); } + /** + * Ensure that WP_Secrets_Cipher::encrypt_value() returns the name validation error for a bad name. + * + * @covers WP_Secrets_Cipher::encrypt_value + */ public function test_encrypt_propagates_an_invalid_name(): void { $cipher = new WP_Secrets_Cipher(); $result = $cipher->encrypt_value( $this->master_key(), 'site', 1, 'Not A Valid Name', WP_Secret_Version::CURRENT, 'value' ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code() ); + $this->assertWPError( $result, 'Encrypting under an invalid name should return a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code(), 'An invalid name should be reported with the invalid name error code.' ); } + /** + * Ensure that an unknown scope triggers a _doing_it_wrong() notice and returns a WP_Error. + * + * Scope, site ID and slot are only ever supplied by core itself, so a bad value is a + * programming error: it is flagged to the developer but still reported as a WP_Error + * rather than thrown. + * + * @covers WP_Secrets_Cipher::encrypt_value + */ public function test_encrypt_reports_an_invalid_scope_as_a_wp_error(): void { $cipher = new WP_Secrets_Cipher(); @@ -114,10 +157,15 @@ public function test_encrypt_reports_an_invalid_scope_as_a_wp_error(): void { $result = $cipher->encrypt_value( $this->master_key(), 'bogus-scope', 1, self::NAME, WP_Secret_Version::CURRENT, 'value' ); // @phpstan-ignore argument.type (Intentionally passing an invalid value.) - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code() ); + $this->assertWPError( $result, 'An invalid scope should be reported as a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code(), 'An invalid scope should be reported as an invalid argument.' ); } + /** + * Ensure that a negative site ID triggers a _doing_it_wrong() notice and returns a WP_Error. + * + * @covers WP_Secrets_Cipher::encrypt_value + */ public function test_encrypt_reports_a_negative_site_id_as_a_wp_error(): void { $cipher = new WP_Secrets_Cipher(); @@ -125,10 +173,15 @@ public function test_encrypt_reports_a_negative_site_id_as_a_wp_error(): void { $result = $cipher->encrypt_value( $this->master_key(), 'site', -1, self::NAME, WP_Secret_Version::CURRENT, 'value' ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code() ); + $this->assertWPError( $result, 'A negative site ID should be reported as a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code(), 'A negative site ID should be reported as an invalid argument.' ); } + /** + * Ensure that a slot other than a WP_Secret_Version constant is reported as a caller error. + * + * @covers WP_Secrets_Cipher::encrypt_value + */ public function test_encrypt_reports_an_invalid_slot_as_a_wp_error(): void { $cipher = new WP_Secrets_Cipher(); @@ -136,15 +189,17 @@ public function test_encrypt_reports_an_invalid_slot_as_a_wp_error(): void { $result = $cipher->encrypt_value( $this->master_key(), 'site', 1, self::NAME, 'not-a-real-slot', 'value' ); // @phpstan-ignore argument.type (Intentionally passing an invalid value.) - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code() ); + $this->assertWPError( $result, 'An invalid slot should be reported as a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code(), 'An invalid slot should be reported as an invalid argument.' ); } /** * AAD binding: a record encrypted under one context must not decrypt under a * different one, for any single component of that context. * - * @dataProvider data_mismatched_context + * @covers WP_Secrets_Cipher::decrypt_value + * + * @dataProvider data_decrypt_fails_when_context_does_not_match * * @param string $scope Scope to decrypt under. * @param int $site_id Site id to decrypt under. @@ -160,14 +215,14 @@ public function test_decrypt_fails_when_context_does_not_match( $scope, $site_id $result = $cipher->decrypt_value( $this->master_key(), $scope, $site_id, $name, $slot, $record ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_DECRYPTION_FAILED, $result->get_error_code() ); + $this->assertWPError( $result, 'Decrypting under a mismatched context should return a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_DECRYPTION_FAILED, $result->get_error_code(), 'A mismatched context should be reported as a decryption failure.' ); } /** * @return array */ - public function data_mismatched_context() { + public static function data_decrypt_fails_when_context_does_not_match() { return array( 'different scope' => array( 'network', 1, self::NAME, WP_Secret_Version::CURRENT ), 'different site id' => array( 'site', 2, self::NAME, WP_Secret_Version::CURRENT ), @@ -176,18 +231,30 @@ public function data_mismatched_context() { ); } + /** + * Ensure that a record cannot be decrypted with a master key other than the one that encrypted it. + * + * @covers WP_Secrets_Cipher::decrypt_value + */ public function test_decrypt_fails_under_a_different_master_key(): void { $cipher = new WP_Secrets_Cipher(); $record = $cipher->encrypt_value( $this->master_key( 0x11 ), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, 'value' ); $result = $cipher->decrypt_value( $this->master_key( 0x22 ), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, $record ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_DECRYPTION_FAILED, $result->get_error_code() ); + $this->assertWPError( $result, 'Decrypting under a different master key should return a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_DECRYPTION_FAILED, $result->get_error_code(), 'A different master key should be reported as a decryption failure.' ); } /** - * @dataProvider data_tamperable_fields + * Ensure that decryption fails when a single byte of any encrypted field is altered. + * + * Every field feeds an authenticated decryption, so corrupting one must be reported + * as an error rather than yielding different plaintext. + * + * @covers WP_Secrets_Cipher::decrypt_value + * + * @dataProvider data_decrypt_fails_when_a_field_is_tampered * * @param string $field Record field to corrupt. */ @@ -195,12 +262,12 @@ public function test_decrypt_fails_when_a_field_is_tampered( $field ): void { $cipher = new WP_Secrets_Cipher(); $record = $cipher->encrypt_value( $this->master_key(), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, 'value' ); - $this->assertIsArray( $record ); - $this->assertArrayHasKey( $field, $record ); + $this->assertIsArray( $record, 'Encrypting a value should return a record array.' ); + $this->assertArrayHasKey( $field, $record, 'The record should contain the field being tampered with.' ); $raw = base64_decode( $record[ $field ], true ); - $this->assertIsString( $raw ); + $this->assertIsString( $raw, 'The record field should be valid base64.' ); $last = strlen( $raw ) - 1; $raw[ $last ] = chr( ( ord( $raw[ $last ] ) + 1 ) % 256 ); @@ -208,13 +275,14 @@ public function test_decrypt_fails_when_a_field_is_tampered( $field ): void { $result = $cipher->decrypt_value( $this->master_key(), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, $record ); - $this->assertWPError( $result ); + $this->assertWPError( $result, 'Decrypting a tampered record should return a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_DECRYPTION_FAILED, $result->get_error_code(), 'A tampered field should be reported as a decryption failure.' ); } /** * @return array */ - public function data_tamperable_fields() { + public static function data_decrypt_fails_when_a_field_is_tampered() { return array( 'ciphertext' => array( 'ct' ), 'value nonce' => array( 'nonce' ), @@ -224,7 +292,11 @@ public function data_tamperable_fields() { } /** - * @dataProvider data_malformed_records + * Ensure that a record with a missing, non-base64 or wrongly sized field is reported as malformed. + * + * @covers WP_Secrets_Cipher::decrypt_value + * + * @dataProvider data_decrypt_rejects_a_malformed_record * * @param mixed $record Stored record the cipher must refuse. */ @@ -233,14 +305,14 @@ public function test_decrypt_rejects_a_malformed_record( $record ): void { $result = $cipher->decrypt_value( $this->master_key(), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, $record ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_RECORD_MALFORMED, $result->get_error_code() ); + $this->assertWPError( $result, 'Decrypting a malformed record should return a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_RECORD_MALFORMED, $result->get_error_code(), 'A malformed record should be reported as record malformed.' ); } /** * @return array */ - public function data_malformed_records() { + public static function data_decrypt_rejects_a_malformed_record() { return array( 'not an array' => array( 'just a string' ), 'missing ct' => array( @@ -269,19 +341,32 @@ public function data_malformed_records() { ); } + /** + * Ensure that encrypting the same value twice produces a different data key and ciphertext. + * + * Each write must generate a fresh data key, so identical plaintexts are never + * recognizable from their stored records. + * + * @covers WP_Secrets_Cipher::encrypt_value + */ public function test_data_key_independence(): void { $cipher = new WP_Secrets_Cipher(); $a = $cipher->encrypt_value( $this->master_key(), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, 'same-value' ); $b = $cipher->encrypt_value( $this->master_key(), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, 'same-value' ); - $this->assertIsArray( $a ); - $this->assertIsArray( $b ); + $this->assertIsArray( $a, 'The first encryption should return a record array.' ); + $this->assertIsArray( $b, 'The second encryption should return a record array.' ); - $this->assertNotSame( $a['dk'], $b['dk'] ); - $this->assertNotSame( $a['ct'], $b['ct'] ); + $this->assertNotSame( $a['dk'], $b['dk'], 'Each encryption should use a freshly wrapped data key.' ); + $this->assertNotSame( $a['ct'], $b['ct'], 'Encrypting the same value twice should produce different ciphertexts.' ); } + /** + * Ensure that no value or data key nonce is reused across repeated encryptions. + * + * @covers WP_Secrets_Cipher::encrypt_value + */ public function test_nonce_uniqueness_across_many_writes(): void { $cipher = new WP_Secrets_Cipher(); $nonces = array(); @@ -289,58 +374,88 @@ public function test_nonce_uniqueness_across_many_writes(): void { for ( $i = 0; $i < 50; $i++ ) { $record = $cipher->encrypt_value( $this->master_key(), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, 'value' ); - $this->assertIsArray( $record ); + $this->assertIsArray( $record, 'Each encryption should return a record array.' ); $nonces[] = $record['nonce']; $nonces[] = $record['dk_nonce']; } - $this->assertSame( count( $nonces ), count( array_unique( $nonces ) ) ); + $this->assertSame( count( $nonces ), count( array_unique( $nonces ) ), 'No value or data key nonce should be reused across writes.' ); } + /** + * Ensure that the same master key and plaintext always produce the same fingerprint. + * + * @covers WP_Secrets_Cipher::fingerprint + */ public function test_fingerprint_is_stable_for_the_same_inputs(): void { $cipher = new WP_Secrets_Cipher(); $this->assertSame( $cipher->fingerprint( $this->master_key(), 'same-value' ), - $cipher->fingerprint( $this->master_key(), 'same-value' ) + $cipher->fingerprint( $this->master_key(), 'same-value' ), + 'The same master key and plaintext should produce the same fingerprint.' ); } + /** + * Ensure that the same plaintext fingerprints differently under a different master key. + * + * Keying the fingerprint stops it acting as a cross-site lookup oracle for a known value. + * + * @covers WP_Secrets_Cipher::fingerprint + */ public function test_fingerprint_differs_across_master_keys(): void { $cipher = new WP_Secrets_Cipher(); $this->assertNotSame( $cipher->fingerprint( $this->master_key( 0x11 ), 'same-value' ), - $cipher->fingerprint( $this->master_key( 0x22 ), 'same-value' ) + $cipher->fingerprint( $this->master_key( 0x22 ), 'same-value' ), + 'The same plaintext should fingerprint differently under different master keys.' ); } + /** + * Ensure that different plaintexts produce different fingerprints under the same master key. + * + * @covers WP_Secrets_Cipher::fingerprint + */ public function test_fingerprint_differs_across_plaintexts(): void { $cipher = new WP_Secrets_Cipher(); $this->assertNotSame( $cipher->fingerprint( $this->master_key(), 'value-a' ), - $cipher->fingerprint( $this->master_key(), 'value-b' ) + $cipher->fingerprint( $this->master_key(), 'value-b' ), + 'Different plaintexts should produce different fingerprints.' ); } + /** + * Ensure that WP_Secrets_Cipher::fingerprint() refuses a master key that is not 32 bytes. + * + * @covers WP_Secrets_Cipher::fingerprint + */ public function test_fingerprint_rejects_a_bad_master_key(): void { $cipher = new WP_Secrets_Cipher(); $result = $cipher->fingerprint( 'too-short', 'value' ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() ); + $this->assertWPError( $result, 'Fingerprinting with a bad master key should return a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code(), 'A bad master key should be reported as key unavailable.' ); } + /** + * Ensure that WP_Secrets_Cipher::fingerprint() refuses a non-string plaintext. + * + * @covers WP_Secrets_Cipher::fingerprint + */ public function test_fingerprint_rejects_a_non_string_value(): void { $cipher = new WP_Secrets_Cipher(); $result = $cipher->fingerprint( $this->master_key(), array() ); // @phpstan-ignore argument.type (Intentionally passing a non-string plaintext.) - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_INVALID_VALUE, $result->get_error_code() ); + $this->assertWPError( $result, 'Fingerprinting a non-string value should return a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_VALUE, $result->get_error_code(), 'A non-string value should be reported as an invalid value.' ); } /** @@ -349,19 +464,23 @@ public function test_fingerprint_rejects_a_non_string_value(): void { * both PHP 7.4 and 8.5 and pinned here so an accidental change to either * primitive, the KDF subkey id, the context string, or the output length is * caught rather than silently changing every existing fingerprint. + * + * @covers WP_Secrets_Cipher::fingerprint */ public function test_fingerprint_known_answer_vector(): void { $cipher = new WP_Secrets_Cipher(); $fingerprint = $cipher->fingerprint( str_repeat( chr( 0x11 ), 32 ), 'sk_live_example_value' ); - $this->assertSame( '62d94d88bf417f09171e8efb7eb30297', $fingerprint ); + $this->assertSame( '62d94d88bf417f09171e8efb7eb30297', $fingerprint, 'The fingerprint should match the pinned known-answer vector.' ); } /** * Pins the exact AAD format. A reviewer reordering these fields, or a future * commit accidentally dropping the site id or slot, silently weakens the binding * this whole design leans on – this test makes that change loud instead. + * + * @covers WP_Secrets_Cipher::build_aad */ public function test_aad_format_known_answer(): void { $cipher = new WP_Secrets_Cipher(); @@ -372,7 +491,7 @@ public function test_aad_format_known_answer(): void { array( WP_Secrets_Cipher::AAD_VALUE, 'site', 1, self::NAME, WP_Secret_Version::CURRENT ) ); - $this->assertSame( 'wp-secrets-value-v1|site|1|myplugin/api-key|current', $aad ); + $this->assertSame( 'wp-secrets-value-v1|site|1|myplugin/api-key|current', $aad, 'The AAD should match the pinned format.' ); } /** @@ -383,6 +502,8 @@ public function test_aad_format_known_answer(): void { * exists purely to catch a libsodium/sodium_compat behavior change or version * mismatch that our own round-trip tests, which always encrypt and decrypt with * the same implementation, would never surface. + * + * @coversNothing */ public function test_raw_aead_primitive_known_answer(): void { $key = str_repeat( chr( 0x22 ), 32 ); @@ -390,6 +511,6 @@ public function test_raw_aead_primitive_known_answer(): void { $aad = 'wp-secrets-value-v1|site|1|plugin/key|current'; $cipher = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt( 'hello world', $aad, $nonce, $key ); - $this->assertSame( 'fa9f4801aae412e9d2746dddc65d7b3d5a689f4e89461caf54975b', bin2hex( $cipher ) ); + $this->assertSame( 'fa9f4801aae412e9d2746dddc65d7b3d5a689f4e89461caf54975b', bin2hex( $cipher ), 'The raw AEAD ciphertext should match the pinned known-answer vector.' ); } } diff --git a/tests/phpunit/test-wp-secrets-config-key-provider.php b/tests/phpunit/test-wp-secrets-config-key-provider.php index b19a994..bbc96a8 100644 --- a/tests/phpunit/test-wp-secrets-config-key-provider.php +++ b/tests/phpunit/test-wp-secrets-config-key-provider.php @@ -4,11 +4,13 @@ * * Constants (WP_SECRETS_KEY, WP_SECRETS_KEY_PREVIOUS) that this class reads can only * be defined once per PHP process, so any test that needs a specific value for them - * runs @runInSeparateProcess. LOGGED_IN_KEY/LOGGED_IN_SALT are already defined by the + * runs in a separate process. LOGGED_IN_KEY/LOGGED_IN_SALT are already defined by the * WordPress test suite's own bootstrap before any test of ours can run, in every * process including an isolated one, so the salt-fallback path is exercised against * whatever the ambient harness defines them as rather than a value this suite picks. * + * @ticket 66187 + * * @group secrets */ class Tests_Secrets_WPSecretsConfigKeyProvider extends WP_UnitTestCase { @@ -30,71 +32,112 @@ private function invoke_private( object $instance, string $method, array $args = return $reflection->invokeArgs( $instance, $args ); } + /** + * Ensure that WP_Secrets_Config_Key_Provider implements WP_Secrets_Keyring. + * + * @coversNothing + */ public function test_implements_the_keyring_interface(): void { - $this->assertInstanceOf( WP_Secrets_Keyring::class, new WP_Secrets_Config_Key_Provider() ); // @phpstan-ignore method.alreadyNarrowedType (Pins the interface the class declares, so removing it fails a test.) + $this->assertInstanceOf( WP_Secrets_Keyring::class, new WP_Secrets_Config_Key_Provider(), 'WP_Secrets_Config_Key_Provider should implement WP_Secrets_Keyring.' ); // @phpstan-ignore method.alreadyNarrowedType (Pins the interface the class declares, so removing it fails a test.) } + /** + * Ensure that unwrapping a wrapped value returns the original key material. + * + * @covers WP_Secrets_Config_Key_Provider::wrap + * @covers WP_Secrets_Config_Key_Provider::unwrap + */ public function test_wrap_then_unwrap_round_trips(): void { $provider = new WP_Secrets_Config_Key_Provider(); $material = random_bytes( 32 ); $wrapped = $provider->wrap( $material ); - $this->assertIsString( $wrapped ); + $this->assertIsString( $wrapped, 'wrap() should return a string.' ); $unwrapped = $provider->unwrap( $wrapped ); - $this->assertSame( $material, $unwrapped ); + $this->assertSame( $material, $unwrapped, 'unwrap() should return the original key material.' ); } + /** + * Ensure that wrapping empty key material returns an invalid value error. + * + * @covers WP_Secrets_Config_Key_Provider::wrap + */ public function test_wrap_rejects_an_empty_string(): void { $provider = new WP_Secrets_Config_Key_Provider(); $result = $provider->wrap( '' ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_INVALID_VALUE, $result->get_error_code() ); + $this->assertWPError( $result, 'Wrapping an empty string should return a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_VALUE, $result->get_error_code(), 'Wrapping an empty string should return the invalid value error code.' ); } + /** + * Ensure that wrapping the same key material twice uses a fresh nonce each time. + * + * @covers WP_Secrets_Config_Key_Provider::wrap + */ public function test_two_wraps_of_the_same_material_produce_different_output(): void { $provider = new WP_Secrets_Config_Key_Provider(); $material = random_bytes( 32 ); - $this->assertNotSame( $provider->wrap( $material ), $provider->wrap( $material ) ); + $this->assertNotSame( $provider->wrap( $material ), $provider->wrap( $material ), 'Two wraps of the same key material should produce different output.' ); } + /** + * Ensure that unwrapping an empty string returns a key unavailable error. + * + * @covers WP_Secrets_Config_Key_Provider::unwrap + */ public function test_unwrap_rejects_an_empty_string(): void { $provider = new WP_Secrets_Config_Key_Provider(); $result = $provider->unwrap( '' ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() ); + $this->assertWPError( $result, 'Unwrapping an empty string should return a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code(), 'Unwrapping an empty string should return the key unavailable error code.' ); } + /** + * Ensure that unwrapping a value that is not strict base64 returns a key unavailable error. + * + * @covers WP_Secrets_Config_Key_Provider::unwrap + */ public function test_unwrap_rejects_garbage_base64(): void { $provider = new WP_Secrets_Config_Key_Provider(); $result = $provider->unwrap( 'not valid base64 at all!!!' ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() ); + $this->assertWPError( $result, 'Unwrapping invalid base64 should return a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code(), 'Unwrapping invalid base64 should return the key unavailable error code.' ); } + /** + * Ensure that unwrapping data no longer than a nonce returns a key unavailable error. + * + * @covers WP_Secrets_Config_Key_Provider::unwrap + */ public function test_unwrap_rejects_data_too_short_to_contain_a_nonce(): void { $provider = new WP_Secrets_Config_Key_Provider(); $result = $provider->unwrap( base64_encode( 'x' ) ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() ); + $this->assertWPError( $result, 'Unwrapping data shorter than a nonce should return a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code(), 'Unwrapping data shorter than a nonce should return the key unavailable error code.' ); } + /** + * Ensure that unwrapping a ciphertext with a modified byte fails authentication. + * + * @covers WP_Secrets_Config_Key_Provider::unwrap + */ public function test_unwrap_rejects_a_tampered_ciphertext(): void { $provider = new WP_Secrets_Config_Key_Provider(); $wrapped = $provider->wrap( random_bytes( 32 ) ); - $this->assertIsString( $wrapped ); + $this->assertIsString( $wrapped, 'wrap() should return a string.' ); $raw = base64_decode( $wrapped, true ); - $this->assertIsString( $raw ); + $this->assertIsString( $raw, 'The wrapped value should be valid strict base64.' ); $tampered_raw = $raw; $last_byte = strlen( $tampered_raw ) - 1; @@ -102,22 +145,35 @@ public function test_unwrap_rejects_a_tampered_ciphertext(): void { $result = $provider->unwrap( base64_encode( $tampered_raw ) ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() ); + $this->assertWPError( $result, 'Unwrapping a tampered ciphertext should return a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code(), 'Unwrapping a tampered ciphertext should return the key unavailable error code.' ); } + /** + * Ensure that the key source names LOGGED_IN_KEY when WP_SECRETS_KEY is not defined. + * + * @covers WP_Secrets_Config_Key_Provider::get_key_source + */ public function test_get_key_source_reports_the_salt_fallback_when_wp_secrets_key_is_unset(): void { if ( 'put your unique phrase here' === LOGGED_IN_KEY || 'put your unique phrase here' === LOGGED_IN_SALT ) { $this->markTestSkipped( 'Needs real LOGGED_IN_KEY and LOGGED_IN_SALT values; wp-tests-config-sample.php ships placeholders.' ); } + if ( defined( 'WP_SECRETS_KEY' ) ) { + $this->markTestSkipped( 'Needs WP_SECRETS_KEY to be undefined; it is defined in this process.' ); + } + $provider = new WP_Secrets_Config_Key_Provider(); - $this->assertStringContainsString( 'LOGGED_IN_KEY', $provider->get_key_source() ); + $this->assertStringContainsString( 'LOGGED_IN_KEY', $provider->get_key_source(), 'The key source should name LOGGED_IN_KEY when WP_SECRETS_KEY is unset.' ); } /** - * @dataProvider data_canonical_base64_32 + * Ensure that only the canonical base64 encoding of exactly 32 bytes is recognized. + * + * @covers WP_Secrets_Config_Key_Provider::is_canonical_base64_32 + * + * @dataProvider data_is_canonical_base64_32 * * @param string|int $value Candidate value. * @param bool $expected Whether it is the canonical base64 encoding of 32 bytes. @@ -125,13 +181,13 @@ public function test_get_key_source_reports_the_salt_fallback_when_wp_secrets_ke public function test_is_canonical_base64_32( $value, $expected ): void { $provider = new WP_Secrets_Config_Key_Provider(); - $this->assertSame( $expected, $this->invoke_private( $provider, 'is_canonical_base64_32', array( $value ) ) ); + $this->assertSame( $expected, $this->invoke_private( $provider, 'is_canonical_base64_32', array( $value ) ), 'is_canonical_base64_32() should return the expected result for this value.' ); } /** * @return array */ - public function data_canonical_base64_32(): array { + public static function data_is_canonical_base64_32(): array { return array( 'exactly 32 bytes, canonical encoding' => array( base64_encode( str_repeat( 'A', 32 ) ), true ), '31 bytes' => array( base64_encode( str_repeat( 'A', 31 ) ), false ), @@ -144,7 +200,11 @@ public function data_canonical_base64_32(): array { } /** - * @dataProvider data_usable_salt_values + * Ensure that salt values are rejected when missing, empty, non-string or the sample placeholder. + * + * @covers WP_Secrets_Config_Key_Provider::are_usable_salt_values + * + * @dataProvider data_are_usable_salt_values * * @param string|int|null $key Candidate LOGGED_IN_KEY value. * @param string $salt Candidate LOGGED_IN_SALT value. @@ -153,13 +213,13 @@ public function data_canonical_base64_32(): array { public function test_are_usable_salt_values( $key, $salt, $expected ): void { $provider = new WP_Secrets_Config_Key_Provider(); - $this->assertSame( $expected, $this->invoke_private( $provider, 'are_usable_salt_values', array( $key, $salt ) ) ); + $this->assertSame( $expected, $this->invoke_private( $provider, 'are_usable_salt_values', array( $key, $salt ) ), 'are_usable_salt_values() should return the expected result for this pair.' ); } /** * @return array */ - public function data_usable_salt_values(): array { + public static function data_are_usable_salt_values(): array { return array( 'both usable' => array( 'a real key', 'a real salt', true ), 'key is the placeholder' => array( 'put your unique phrase here', 'a real salt', false ), @@ -171,6 +231,10 @@ public function data_usable_salt_values(): array { } /** + * Ensure that a canonical base64 WP_SECRETS_KEY is used as the decoded raw bytes. + * + * @covers WP_Secrets_Config_Key_Provider::get_site_key + * * @runInSeparateProcess * @preserveGlobalState disabled */ @@ -180,7 +244,7 @@ public function test_derives_raw_bytes_when_wp_secrets_key_is_canonical_base64_3 $provider = new WP_Secrets_Config_Key_Provider(); - $this->assertSame( $raw, $this->invoke_private( $provider, 'get_site_key' ) ); + $this->assertSame( $raw, $this->invoke_private( $provider, 'get_site_key' ), 'A canonical base64 WP_SECRETS_KEY should be used as the decoded raw bytes.' ); } /** @@ -189,6 +253,8 @@ public function test_derives_raw_bytes_when_wp_secrets_key_is_canonical_base64_3 * algorithm, its output length, or its inputs is caught rather than silently * changing what key every existing secret is wrapped under. * + * @covers WP_Secrets_Config_Key_Provider::get_site_key + * * @runInSeparateProcess * @preserveGlobalState disabled */ @@ -198,10 +264,11 @@ public function test_derives_a_known_answer_via_the_legacy_interpretation(): voi $provider = new WP_Secrets_Config_Key_Provider(); $key = $this->invoke_private( $provider, 'get_site_key' ); - $this->assertIsString( $key ); + $this->assertIsString( $key, 'get_site_key() should return a string for a legacy WP_SECRETS_KEY.' ); $this->assertSame( 'db0aa0426b1dfaecc1878c103914462b0066eca0cdd050654a179588fed74b21', - bin2hex( $key ) + bin2hex( $key ), + 'The legacy site key should match the pinned known-answer vector.' ); } @@ -210,6 +277,8 @@ public function test_derives_a_known_answer_via_the_legacy_interpretation(): voi * the legacy (hashed) interpretation rather than being treated as raw key * material of the wrong length. * + * @covers WP_Secrets_Config_Key_Provider::get_site_key + * * @runInSeparateProcess * @preserveGlobalState disabled */ @@ -222,11 +291,16 @@ public function test_wrong_length_base64_falls_through_to_legacy_interpretation( $this->assertSame( sodium_crypto_generichash( $sixteen_bytes, '', 32 ), - $key + $key, + 'A wrong-length base64 WP_SECRETS_KEY should be hashed via the legacy interpretation.' ); } /** + * Ensure that a non-string WP_SECRETS_KEY fails closed with a key unavailable error. + * + * @covers WP_Secrets_Config_Key_Provider::get_site_key + * * @runInSeparateProcess * @preserveGlobalState disabled */ @@ -236,11 +310,15 @@ public function test_non_string_wp_secrets_key_is_unavailable(): void { $provider = new WP_Secrets_Config_Key_Provider(); $result = $this->invoke_private( $provider, 'get_site_key' ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() ); + $this->assertWPError( $result, 'A non-string WP_SECRETS_KEY should return a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code(), 'A non-string WP_SECRETS_KEY should return the key unavailable error code.' ); } /** + * Ensure that the key source identifies a canonical base64 WP_SECRETS_KEY. + * + * @covers WP_Secrets_Config_Key_Provider::get_key_source + * * @runInSeparateProcess * @preserveGlobalState disabled */ @@ -249,10 +327,14 @@ public function test_get_key_source_reports_base64_form(): void { $provider = new WP_Secrets_Config_Key_Provider(); - $this->assertStringContainsString( 'base64', $provider->get_key_source() ); + $this->assertStringContainsString( 'base64', $provider->get_key_source(), 'The key source should identify a canonical base64 WP_SECRETS_KEY.' ); } /** + * Ensure that the key source identifies a WP_SECRETS_KEY using the legacy interpretation. + * + * @covers WP_Secrets_Config_Key_Provider::get_key_source + * * @runInSeparateProcess * @preserveGlobalState disabled */ @@ -261,10 +343,15 @@ public function test_get_key_source_reports_legacy_form(): void { $provider = new WP_Secrets_Config_Key_Provider(); - $this->assertStringContainsString( 'legacy', $provider->get_key_source() ); + $this->assertStringContainsString( 'legacy', $provider->get_key_source(), 'The key source should identify a legacy WP_SECRETS_KEY.' ); } /** + * Ensure that a previous-key provider derives its site key from WP_SECRETS_KEY_PREVIOUS. + * + * @covers WP_Secrets_Config_Key_Provider::__construct + * @covers WP_Secrets_Config_Key_Provider::get_site_key + * * @runInSeparateProcess * @preserveGlobalState disabled */ @@ -273,13 +360,15 @@ public function test_use_previous_key_reads_the_previous_constant(): void { $provider = new WP_Secrets_Config_Key_Provider( true ); - $this->assertSame( str_repeat( 'C', 32 ), $this->invoke_private( $provider, 'get_site_key' ) ); + $this->assertSame( str_repeat( 'C', 32 ), $this->invoke_private( $provider, 'get_site_key' ), 'A previous-key provider should use the bytes from WP_SECRETS_KEY_PREVIOUS.' ); } /** * Defining only the previous-key constant must not affect the current-key * provider: it should still fall through to the salt fallback. * + * @covers WP_Secrets_Config_Key_Provider::get_site_key + * * @runInSeparateProcess * @preserveGlobalState disabled */ @@ -293,11 +382,23 @@ public function test_previous_key_constant_does_not_leak_into_the_current_key_pa $provider = new WP_Secrets_Config_Key_Provider( false ); $key = $this->invoke_private( $provider, 'get_site_key' ); - $this->assertNotWPError( $key ); - $this->assertNotSame( str_repeat( 'C', 32 ), $key ); + $this->assertNotWPError( $key, 'The current-key provider should derive a site key from the salts.' ); + $this->assertNotSame( str_repeat( 'C', 32 ), $key, 'The current-key provider should not use WP_SECRETS_KEY_PREVIOUS.' ); + $this->assertSame( + sodium_crypto_generichash( LOGGED_IN_KEY . LOGGED_IN_SALT, '', SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES ), + $key, + 'The current-key provider should use the key derived from LOGGED_IN_KEY and LOGGED_IN_SALT.' + ); } /** + * Ensure that a previous-key provider fails when WP_SECRETS_KEY_PREVIOUS is not defined. + * + * The previous-key provider must fail closed rather than fall back to the salts, or + * a rotation could unwrap under a key the site never configured as the old one. + * + * @covers WP_Secrets_Config_Key_Provider::get_site_key + * * @runInSeparateProcess * @preserveGlobalState disabled */ @@ -305,14 +406,17 @@ public function test_use_previous_key_without_the_constant_defined_is_unavailabl $provider = new WP_Secrets_Config_Key_Provider( true ); $result = $this->invoke_private( $provider, 'get_site_key' ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() ); + $this->assertWPError( $result, 'A previous-key provider without WP_SECRETS_KEY_PREVIOUS should return a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code(), 'Without WP_SECRETS_KEY_PREVIOUS, the previous-key provider should return key unavailable.' ); } /** * A key rotation unwraps under the outgoing key and wraps under the incoming * one. This proves the two providers actually disagree when they should. * + * @covers WP_Secrets_Config_Key_Provider::wrap + * @covers WP_Secrets_Config_Key_Provider::unwrap + * * @runInSeparateProcess * @preserveGlobalState disabled */ @@ -324,9 +428,9 @@ public function test_old_and_new_key_providers_are_not_interchangeable(): void { $new_provider = new WP_Secrets_Config_Key_Provider( false ); $wrapped_under_old = $old_provider->wrap( random_bytes( 32 ) ); - $this->assertIsString( $wrapped_under_old ); + $this->assertIsString( $wrapped_under_old, 'The old provider should wrap to a string.' ); - $this->assertWPError( $new_provider->unwrap( $wrapped_under_old ) ); - $this->assertIsString( $old_provider->unwrap( $wrapped_under_old ) ); + $this->assertWPError( $new_provider->unwrap( $wrapped_under_old ), 'The new provider should not unwrap a value wrapped by the old provider.' ); + $this->assertIsString( $old_provider->unwrap( $wrapped_under_old ), 'The old provider should unwrap its own wrapped value.' ); } } diff --git a/tests/phpunit/test-wp-secrets-key-manager.php b/tests/phpunit/test-wp-secrets-key-manager.php index a42f607..b6bf4d0 100644 --- a/tests/phpunit/test-wp-secrets-key-manager.php +++ b/tests/phpunit/test-wp-secrets-key-manager.php @@ -2,28 +2,45 @@ /** * Tests for WP_Secrets_Key_Manager. * + * @ticket 66187 + * * @group secrets */ class Tests_Secrets_WPSecretsKeyManager extends WP_UnitTestCase { + /** + * Ensure that the first call to get a root key generates 32 bytes and stores the wrapped value. + * + * @covers WP_Secrets_Key_Manager::get_root_key + */ public function test_get_root_key_generates_and_persists_one(): void { $manager = new WP_Secrets_Key_Manager(); - $this->assertFalse( get_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION ) ); + $this->assertFalse( get_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION ), 'No root key should be stored before the first get_root_key() call.' ); $root_key = $manager->get_root_key(); - $this->assertIsString( $root_key ); - $this->assertSame( 32, strlen( $root_key ) ); - $this->assertNotFalse( get_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION ) ); + $this->assertIsString( $root_key, 'get_root_key() should return a string.' ); + $this->assertSame( 32, strlen( $root_key ), 'The generated root key should be 32 bytes long.' ); + $this->assertNotFalse( get_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION ), 'The wrapped root key should be stored after the first get_root_key() call.' ); } + /** + * Ensure that repeated root key reads on one key manager return the same bytes. + * + * @covers WP_Secrets_Key_Manager::get_root_key + */ public function test_get_root_key_is_stable_on_the_same_instance(): void { $manager = new WP_Secrets_Key_Manager(); - $this->assertSame( $manager->get_root_key(), $manager->get_root_key() ); + $this->assertSame( $manager->get_root_key(), $manager->get_root_key(), 'Repeated get_root_key() calls on one instance should return the same bytes.' ); } + /** + * Ensure that a new key manager reads the stored root key rather than generating another. + * + * @covers WP_Secrets_Key_Manager::get_root_key + */ public function test_get_root_key_persists_across_instances(): void { $first = new WP_Secrets_Key_Manager(); $root_a = $first->get_root_key(); @@ -31,9 +48,14 @@ public function test_get_root_key_persists_across_instances(): void { $second = new WP_Secrets_Key_Manager(); $root_b = $second->get_root_key(); - $this->assertSame( $root_a, $root_b ); + $this->assertSame( $root_a, $root_b, 'A second key manager should return the same root key as the first.' ); } + /** + * Ensure that a pre-existing wrapped root key is unwrapped and returned as is. + * + * @covers WP_Secrets_Key_Manager::get_root_key + */ public function test_get_root_key_uses_an_existing_value_rather_than_regenerating(): void { $keyring = new WP_Secrets_Config_Key_Provider(); $known = str_repeat( 'R', 32 ); @@ -42,55 +64,89 @@ public function test_get_root_key_uses_an_existing_value_rather_than_regeneratin $manager = new WP_Secrets_Key_Manager( $keyring ); - $this->assertSame( $known, $manager->get_root_key() ); + $this->assertSame( $known, $manager->get_root_key(), 'get_root_key() should return the pre-existing root key unchanged.' ); } + /** + * Ensure that the site master key is the KDF subkey of the root key for the current blog. + * + * @covers WP_Secrets_Key_Manager::get_master_key + */ public function test_get_master_key_matches_manual_derivation(): void { $manager = new WP_Secrets_Key_Manager(); $root_key = $manager->get_root_key(); - $this->assertIsString( $root_key ); + $this->assertIsString( $root_key, 'get_root_key() should return a string.' ); $expected = sodium_crypto_kdf_derive_from_key( 32, get_current_blog_id(), 'wpsecsit', $root_key ); - $this->assertSame( $expected, $manager->get_master_key( 'site' ) ); + $this->assertSame( $expected, $manager->get_master_key( 'site' ), 'The site master key should match a manual KDF derivation from the root key.' ); } + /** + * Ensure that site-scope master keys differ between site IDs. + * + * @covers WP_Secrets_Key_Manager::get_master_key + */ public function test_get_master_key_differs_per_site_id(): void { $manager = new WP_Secrets_Key_Manager(); $this->assertNotSame( $manager->get_master_key( 'site', 5 ), - $manager->get_master_key( 'site', 6 ) + $manager->get_master_key( 'site', 6 ), + 'Site-scope master keys for different site IDs should differ.' ); } + /** + * Ensure that the network master key differs from the master key for site ID 1. + * + * @covers WP_Secrets_Key_Manager::get_master_key + */ public function test_get_master_key_site_and_network_scope_differ(): void { $manager = new WP_Secrets_Key_Manager(); $this->assertNotSame( $manager->get_master_key( 'site', 1 ), - $manager->get_master_key( 'network' ) + $manager->get_master_key( 'network' ), + 'The network master key should differ from the site master key for site ID 1.' ); } + /** + * Ensure that the network master key is identical across repeated derivations. + * + * @covers WP_Secrets_Key_Manager::get_master_key + */ public function test_get_master_key_network_scope_is_stable(): void { $manager = new WP_Secrets_Key_Manager(); $this->assertSame( $manager->get_master_key( 'network' ), - $manager->get_master_key( 'network' ) + $manager->get_master_key( 'network' ), + 'Repeated network master key derivations should return the same bytes.' ); } + /** + * Ensure that a site-scope master key without a site ID is derived for the current blog. + * + * @covers WP_Secrets_Key_Manager::get_master_key + */ public function test_get_master_key_defaults_site_id_to_the_current_blog(): void { $manager = new WP_Secrets_Key_Manager(); $this->assertSame( $manager->get_master_key( 'site' ), - $manager->get_master_key( 'site', get_current_blog_id() ) + $manager->get_master_key( 'site', get_current_blog_id() ), + 'A site master key without a site ID should match the one for the current blog.' ); } + /** + * Ensure that an unknown scope triggers _doing_it_wrong() and returns an invalid argument error. + * + * @covers WP_Secrets_Key_Manager::get_master_key + */ public function test_get_master_key_reports_an_invalid_scope_as_a_wp_error(): void { $manager = new WP_Secrets_Key_Manager(); @@ -98,12 +154,16 @@ public function test_get_master_key_reports_an_invalid_scope_as_a_wp_error(): vo $result = $manager->get_master_key( 'bogus' ); // @phpstan-ignore argument.type (Intentionally passing an invalid value.) - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code() ); + $this->assertWPError( $result, 'An invalid scope should be reported as a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code(), 'An invalid scope should return the invalid argument error code.' ); } /** - * @dataProvider data_invalid_site_ids + * Ensure that a site ID below 1 triggers _doing_it_wrong() and returns an invalid argument error. + * + * @covers WP_Secrets_Key_Manager::get_master_key + * + * @dataProvider data_get_master_key_reports_an_invalid_site_id_as_a_wp_error * * @param int $site_id A value that is not a usable blog id. */ @@ -114,14 +174,14 @@ public function test_get_master_key_reports_an_invalid_site_id_as_a_wp_error( $s $result = $manager->get_master_key( 'site', $site_id ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code() ); + $this->assertWPError( $result, 'An invalid site ID should be reported as a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code(), 'An invalid site ID should return the invalid argument error code.' ); } /** * @return array */ - public function data_invalid_site_ids(): array { + public static function data_get_master_key_reports_an_invalid_site_id_as_a_wp_error(): array { return array( 'zero' => array( 0 ), 'negative' => array( -1 ), @@ -134,11 +194,14 @@ public function data_invalid_site_ids(): array { * this is the entire reason network secrets can be read from any blog. Requires * the multisite suite; skips under single-site since there is only one blog to * compare against. + * + * @group multisite + * @group ms-required + * + * @covers WP_Secrets_Key_Manager::get_master_key */ public function test_network_scope_master_key_is_identical_across_blogs(): void { - if ( ! is_multisite() ) { - $this->markTestSkipped( 'Requires multisite: proves the root key is shared network-wide.' ); - } + $this->skipWithoutMultisite(); $second_blog_id = self::factory()->blog->create(); @@ -149,17 +212,20 @@ public function test_network_scope_master_key_is_identical_across_blogs(): void $network_key_on_blog_2 = ( new WP_Secrets_Key_Manager() )->get_master_key( 'network' ); restore_current_blog(); - $this->assertSame( $network_key_on_blog_1, $network_key_on_blog_2 ); + $this->assertSame( $network_key_on_blog_1, $network_key_on_blog_2, 'The network master key should be identical on both blogs.' ); } /** * Site-scope master keys, by contrast, must differ per blog even though every * blog derives from the same shared root key. + * + * @group multisite + * @group ms-required + * + * @covers WP_Secrets_Key_Manager::get_master_key */ public function test_site_scope_master_key_differs_across_blogs(): void { - if ( ! is_multisite() ) { - $this->markTestSkipped( 'Requires multisite: proves per-blog cryptographic separation.' ); - } + $this->skipWithoutMultisite(); $second_blog_id = self::factory()->blog->create(); @@ -170,7 +236,7 @@ public function test_site_scope_master_key_differs_across_blogs(): void { $site_key_on_blog_2 = ( new WP_Secrets_Key_Manager() )->get_master_key( 'site' ); restore_current_blog(); - $this->assertNotSame( $site_key_on_blog_1, $site_key_on_blog_2 ); + $this->assertNotSame( $site_key_on_blog_1, $site_key_on_blog_2, 'The site master key should differ between blogs.' ); } /** @@ -180,11 +246,14 @@ public function test_site_scope_master_key_differs_across_blogs(): void { * * Requires the multisite suite; skips under single-site since sitemeta and * the main site's options are the same table there. + * + * @group multisite + * @group ms-required + * + * @covers WP_Secrets_Key_Manager::get_root_key */ public function test_get_root_key_adopts_a_pre_conversion_root_key(): void { - if ( ! is_multisite() ) { - $this->markTestSkipped( 'Requires multisite: proves the main-site option row is adopted.' ); - } + $this->skipWithoutMultisite(); $manager = new WP_Secrets_Key_Manager(); $root_key = $manager->get_root_key(); @@ -198,25 +267,29 @@ public function test_get_root_key_adopts_a_pre_conversion_root_key(): void { $fresh_manager = new WP_Secrets_Key_Manager(); $adopted_key = $fresh_manager->get_root_key(); - $this->assertIsString( $root_key ); - $this->assertIsString( $adopted_key ); - $this->assertTrue( hash_equals( $root_key, $adopted_key ) ); - $this->assertNotFalse( get_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION ) ); - $this->assertFalse( get_blog_option( $main_site_id, WP_Secrets_Key_Manager::ROOT_KEY_OPTION ) ); + $this->assertIsString( $root_key, 'The original root key should be a string.' ); + $this->assertIsString( $adopted_key, 'The adopted root key should be a string.' ); + $this->assertTrue( hash_equals( $root_key, $adopted_key ), 'The adopted root key should match the pre-conversion root key.' ); + $this->assertNotFalse( get_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION ), 'The adopted root key should be stored in sitemeta.' ); + $this->assertFalse( get_blog_option( $main_site_id, WP_Secrets_Key_Manager::ROOT_KEY_OPTION ), 'The main-site option row should be removed after adoption.' ); } /** * Requires the multisite suite; skips under single-site for the same reason * as the adoption test above. + * + * @group multisite + * @group ms-required + * + * @covers WP_Secrets_Key_Manager::get_root_key + * @covers ::wp_get_secret */ public function test_secret_written_before_conversion_still_decrypts(): void { - if ( ! is_multisite() ) { - $this->markTestSkipped( 'Requires multisite: proves secrets survive conversion.' ); - } + $this->skipWithoutMultisite(); $main_site_id = get_main_site_id(); - $this->assertTrue( wp_set_secret( 'secrets-api/pre-conversion', 'shh' ) ); + $this->assertTrue( wp_set_secret( 'secrets-api/pre-conversion', 'shh' ), 'Storing the secret before conversion should succeed.' ); $wrapped = get_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION ); delete_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION ); @@ -224,19 +297,22 @@ public function test_secret_written_before_conversion_still_decrypts(): void { $secret = wp_get_secret( 'secrets-api/pre-conversion' ); - $this->assertNotWPError( $secret ); - $this->assertInstanceOf( WP_Secret::class, $secret ); - $this->assertSame( 'shh', $secret->reveal() ); + $this->assertNotWPError( $secret, 'Reading the secret after conversion should not return a WP_Error.' ); + $this->assertInstanceOf( WP_Secret::class, $secret, 'The secret read after conversion should be a WP_Secret.' ); + $this->assertSame( 'shh', $secret->reveal(), 'The secret should reveal its original value after conversion.' ); } /** * Requires the multisite suite; skips under single-site for the same reason * as the adoption test above. + * + * @group multisite + * @group ms-required + * + * @covers WP_Secrets_Key_Manager::rotate_site_key */ public function test_rotate_site_key_works_after_conversion_before_any_read(): void { - if ( ! is_multisite() ) { - $this->markTestSkipped( 'Requires multisite: proves rotation adopts a pre-conversion key too.' ); - } + $this->skipWithoutMultisite(); $main_site_id = get_main_site_id(); $right_keyring = new WP_Secrets_Config_Key_Provider(); @@ -252,24 +328,34 @@ public function test_rotate_site_key_works_after_conversion_before_any_read(): v $fresh_manager = new WP_Secrets_Key_Manager( $right_keyring ); $rotated = $fresh_manager->rotate_site_key( $right_keyring, $right_keyring ); - $this->assertNotWPError( $rotated ); - $this->assertTrue( $rotated ); + $this->assertNotWPError( $rotated, 'Rotating after conversion should not return a WP_Error.' ); + $this->assertTrue( $rotated, 'Rotating after conversion should return true.' ); $master_after = $fresh_manager->get_master_key( 'site' ); - $this->assertSame( $master_before, $master_after ); + $this->assertSame( $master_before, $master_after, 'The site master key should be unchanged by rotation after conversion.' ); } + /** + * Ensure that rotating the site key before any root key exists returns a store error. + * + * @covers WP_Secrets_Key_Manager::rotate_site_key + */ public function test_rotate_fails_when_no_root_key_exists(): void { $manager = new WP_Secrets_Key_Manager(); $keyring = new WP_Secrets_Config_Key_Provider(); $result = $manager->rotate_site_key( $keyring, $keyring ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_STORE_UNAVAILABLE, $result->get_error_code() ); + $this->assertWPError( $result, 'Rotating without a stored root key should return a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_STORE_UNAVAILABLE, $result->get_error_code(), 'Rotating without a stored root key should return the store unavailable error code.' ); } + /** + * Ensure that rotation fails when the old keyring cannot unwrap the stored root key. + * + * @covers WP_Secrets_Key_Manager::rotate_site_key + */ public function test_rotate_fails_when_the_old_keyring_is_wrong(): void { $right_keyring = new WP_Secrets_Config_Key_Provider(); $manager = new WP_Secrets_Key_Manager( $right_keyring ); @@ -302,7 +388,8 @@ public function get_key_source(): string { $result = $manager->rotate_site_key( $wrong_keyring, $right_keyring ); - $this->assertWPError( $result ); + $this->assertWPError( $result, 'Rotating with an old keyring that cannot unwrap should return a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code(), 'The old keyring\'s unwrap error should be passed through.' ); } /** @@ -311,6 +398,9 @@ public function get_key_source(): string { * This is the whole point of the envelope design, so it gets an end-to-end proof * rather than just testing rotate_site_key() in isolation. * + * @covers WP_Secrets_Key_Manager::rotate_site_key + * @covers WP_Secrets_Key_Manager::get_master_key + * * @runInSeparateProcess * @preserveGlobalState disabled */ @@ -327,21 +417,32 @@ public function test_rotation_does_not_change_any_derived_master_key(): void { $master_before = $manager_under_old_key->get_master_key( 'site', 7 ); $rotated = $manager_under_old_key->rotate_site_key( $old_keyring, $new_keyring ); - $this->assertNotWPError( $rotated ); + $this->assertNotWPError( $rotated, 'Rotating from the old keyring to the new one should not return a WP_Error.' ); $manager_under_new_key = new WP_Secrets_Key_Manager( $new_keyring ); $master_after = $manager_under_new_key->get_master_key( 'site', 7 ); - $this->assertSame( $master_before, $master_after ); + $this->assertSame( $master_before, $master_after, 'The derived master key should be unchanged by rotation.' ); - // The old keyring alone is no longer sufficient: the stored root key is now - // wrapped under the new key. Checked with a fresh manager instance, since - // $manager_under_old_key's own cache was correctly primed by the rotation - // it just performed and is not the thing under test here. + /* + * The old keyring alone is no longer sufficient: the stored root key is now + * wrapped under the new key. Checked with a fresh manager instance, since + * $manager_under_old_key's own cache was correctly primed by the rotation + * it just performed and is not the thing under test here. + */ $fresh_manager_under_old_key = new WP_Secrets_Key_Manager( $old_keyring ); - $this->assertWPError( $fresh_manager_under_old_key->get_root_key() ); + $this->assertWPError( $fresh_manager_under_old_key->get_root_key(), 'The old keyring alone should no longer unwrap the rotated root key.' ); } + /** + * Ensure that deriving many master keys unwraps the root key only once per key manager. + * + * Guards the per-request root key cache, which keeps a remote keyring such as a KMS + * or HSM to a single call per request rather than one per derivation. + * + * @covers WP_Secrets_Key_Manager::get_master_key + * @covers WP_Secrets_Key_Manager::get_root_key + */ public function test_unwrap_is_called_once_across_repeated_master_key_derivations(): void { $mock = new Mock_Keyring(); $root = random_bytes( 32 ); @@ -351,18 +452,25 @@ public function test_unwrap_is_called_once_across_repeated_master_key_derivation for ( $i = 0; $i < 5; $i++ ) { $site_key = $manager->get_master_key( 'site', $i + 1 ); - $this->assertIsString( $site_key ); - $this->assertSame( 32, strlen( $site_key ) ); + $this->assertIsString( $site_key, 'Each site master key should be a string.' ); + $this->assertSame( 32, strlen( $site_key ), 'Each site master key should be 32 bytes long.' ); $network_key = $manager->get_master_key( 'network' ); - $this->assertIsString( $network_key ); - $this->assertSame( 32, strlen( $network_key ) ); + $this->assertIsString( $network_key, 'Each network master key should be a string.' ); + $this->assertSame( 32, strlen( $network_key ), 'Each network master key should be 32 bytes long.' ); } - $this->assertSame( 1, $mock->unwrap_call_count() ); + $this->assertSame( 1, $mock->unwrap_call_count(), 'The root key should be unwrapped only once across all derivations.' ); } /** + * Ensure that repeated wp_get_secret() calls unwrap the root key only once. + * + * Runs in a separate process because _wp_secrets_get_key_manager() memoizes the key + * manager in a static, so the mock keyring global is only picked up on first use. + * + * @covers WP_Secrets_Key_Manager::get_root_key + * * @runInSeparateProcess * @preserveGlobalState disabled */ @@ -373,17 +481,23 @@ public function test_unwrap_is_called_once_across_many_secret_reads(): void { $GLOBALS['wp_secrets_keyring'] = $mock; - $this->assertNotWPError( wp_set_secret( 'conformance/root-key-cache', 'the-value' ) ); + $this->assertTrue( wp_set_secret( 'conformance/root-key-cache', 'the-value' ), 'Storing the secret should succeed.' ); for ( $i = 0; $i < 10; $i++ ) { $secret = wp_get_secret( 'conformance/root-key-cache' ); - $this->assertInstanceOf( 'WP_Secret', $secret ); - $this->assertSame( 'the-value', $secret->reveal() ); + $this->assertInstanceOf( 'WP_Secret', $secret, 'Each read should return a WP_Secret.' ); + $this->assertSame( 'the-value', $secret->reveal(), 'Each read should reveal the stored value.' ); } - $this->assertSame( 1, $mock->unwrap_call_count() ); + $this->assertSame( 1, $mock->unwrap_call_count(), 'The root key should be unwrapped only once across all secret reads.' ); } + /** + * Ensure that rotation reuses the cached root key and leaves the cache valid afterward. + * + * @covers WP_Secrets_Key_Manager::rotate_site_key + * @covers WP_Secrets_Key_Manager::get_root_key + */ public function test_rotate_site_key_updates_the_cache_without_another_unwrap(): void { $mock = new Mock_Keyring(); $second_mock = new Mock_Keyring(); @@ -393,13 +507,21 @@ public function test_rotate_site_key_updates_the_cache_without_another_unwrap(): $manager = new WP_Secrets_Key_Manager( $mock ); $root_key = $manager->get_root_key(); - $this->assertNotWPError( $manager->rotate_site_key( $mock, $second_mock ) ); + $this->assertNotWPError( $manager->rotate_site_key( $mock, $second_mock ), 'Rotating the site key should not return a WP_Error.' ); - $this->assertSame( $root_key, $manager->get_root_key() ); - $this->assertSame( 1, $mock->unwrap_call_count() ); - $this->assertSame( 0, $second_mock->unwrap_call_count() ); + $this->assertSame( $root_key, $manager->get_root_key(), 'The cached root key should be unchanged after rotation.' ); + $this->assertSame( 1, $mock->unwrap_call_count(), 'The original keyring should have unwrapped only once.' ); + $this->assertSame( 0, $second_mock->unwrap_call_count(), 'The new keyring should not have been asked to unwrap.' ); } + /** + * Ensure that the cached root key is discarded when the stored wrapped value changes. + * + * Overwrites the stored option directly to simulate another request restoring or + * re-wrapping the root key mid-request. + * + * @covers WP_Secrets_Key_Manager::get_root_key + */ public function test_a_changed_wrapped_value_is_unwrapped_again_rather_than_served_from_cache(): void { $mock = new Mock_Keyring(); $root = random_bytes( 32 ); @@ -407,14 +529,19 @@ public function test_a_changed_wrapped_value_is_unwrapped_again_rather_than_serv update_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION, $mock->wrap( $root ) ); $manager = new WP_Secrets_Key_Manager( $mock ); - $this->assertSame( $root, $manager->get_root_key() ); + $this->assertSame( $root, $manager->get_root_key(), 'get_root_key() should return the initially stored root key.' ); update_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION, $mock->wrap( $other_root ) ); - $this->assertSame( $other_root, $manager->get_root_key() ); - $this->assertSame( 2, $mock->unwrap_call_count() ); + $this->assertSame( $other_root, $manager->get_root_key(), 'get_root_key() should return the new root key after the stored value changes.' ); + $this->assertSame( 2, $mock->unwrap_call_count(), 'The changed wrapped value should trigger a second unwrap.' ); } + /** + * Ensure that a failed unwrap is retried on the next call and a later success is cached. + * + * @covers WP_Secrets_Key_Manager::get_root_key + */ public function test_an_unwrap_error_is_not_cached(): void { $mock = new Mock_Keyring(); $root = random_bytes( 32 ); @@ -422,22 +549,30 @@ public function test_an_unwrap_error_is_not_cached(): void { $manager = new WP_Secrets_Key_Manager( $mock ); - // Each call is assigned before it is asserted on: get_root_key() answers - // differently as the keyring's state changes, so no two calls are the - // same expression as far as a reader (or an analyzer) should assume. + /* + * Each call is assigned before it is asserted on: get_root_key() answers + * differently as the keyring's state changes, so no two calls are the + * same expression as far as a reader (or an analyzer) should assume. + */ $mock->configure_fail_unwrap( true ); $while_failing = $manager->get_root_key(); - $this->assertWPError( $while_failing ); + $this->assertWPError( $while_failing, 'get_root_key() should return a WP_Error while unwrap fails.' ); $mock->configure_fail_unwrap( false ); $after_recovery = $manager->get_root_key(); - $this->assertSame( $root, $after_recovery ); + $this->assertSame( $root, $after_recovery, 'get_root_key() should return the root key once unwrap recovers.' ); $from_cache = $manager->get_root_key(); - $this->assertSame( $root, $from_cache ); - $this->assertSame( 2, $mock->unwrap_call_count() ); + $this->assertSame( $root, $from_cache, 'get_root_key() should return the root key from cache after recovery.' ); + $this->assertSame( 2, $mock->unwrap_call_count(), 'The root key should be unwrapped once for the failure and once for the recovery.' ); } + /** + * Ensure that a newly generated root key is cached so deriving a master key needs no unwrap. + * + * @covers WP_Secrets_Key_Manager::get_root_key + * @covers WP_Secrets_Key_Manager::generate_root_key + */ public function test_generate_root_key_primes_the_cache(): void { $mock = new Mock_Keyring(); @@ -445,10 +580,18 @@ public function test_generate_root_key_primes_the_cache(): void { $manager->get_root_key(); $manager->get_master_key( 'site' ); - $this->assertSame( 0, $mock->unwrap_call_count() ); - $this->assertSame( 1, $mock->wrap_call_count() ); + $this->assertSame( 0, $mock->unwrap_call_count(), 'A newly generated root key should not need unwrapping.' ); + $this->assertSame( 1, $mock->wrap_call_count(), 'The newly generated root key should be wrapped exactly once.' ); } + /** + * Ensure that zeroing the returned root key does not wipe the key manager's cached copy. + * + * Callers are required to zero the root key they receive, so the copy handed out must + * never share storage with the cache. + * + * @covers WP_Secrets_Key_Manager::get_root_key + */ public function test_the_returned_root_key_is_a_copy_the_caller_can_zero(): void { $mock = new Mock_Keyring(); $root = random_bytes( 32 ); @@ -457,10 +600,10 @@ public function test_the_returned_root_key_is_a_copy_the_caller_can_zero(): void $manager = new WP_Secrets_Key_Manager( $mock ); $copy = $manager->get_root_key(); - $this->assertIsString( $copy ); + $this->assertIsString( $copy, 'get_root_key() should return a string.' ); wp_secrets_memzero( $copy ); - $this->assertSame( $root, $manager->get_root_key() ); - $this->assertSame( 1, $mock->unwrap_call_count() ); + $this->assertSame( $root, $manager->get_root_key(), 'Zeroing the returned copy should not wipe the cached root key.' ); + $this->assertSame( 1, $mock->unwrap_call_count(), 'The cached root key should be served without another unwrap.' ); } } diff --git a/tests/phpunit/test-wp-secrets-memzero.php b/tests/phpunit/test-wp-secrets-memzero.php index 350a3fc..77e7628 100644 --- a/tests/phpunit/test-wp-secrets-memzero.php +++ b/tests/phpunit/test-wp-secrets-memzero.php @@ -1,43 +1,67 @@ assertNotSame( 'a plaintext value', $value ); - $this->assertSame( '', $value ); + $this->assertSame( '', $value, 'The variable should be an empty string after zeroing.' ); } + /** + * Ensure that wp_secrets_memzero() does not modify a non-string value. + * + * @covers ::wp_secrets_memzero + */ public function test_leaves_non_string_values_untouched(): void { $value = 42; wp_secrets_memzero( $value ); // @phpstan-ignore argument.type (Intentionally passing a non-string value.) - $this->assertSame( 42, $value ); + $this->assertSame( 42, $value, 'A non-string value should be left unchanged.' ); } + /** + * Ensure that wp_secrets_memzero() leaves an empty string as an empty string. + * + * @covers ::wp_secrets_memzero + */ public function test_leaves_an_empty_string_untouched(): void { $value = ''; wp_secrets_memzero( $value ); - $this->assertSame( '', $value ); + $this->assertSame( '', $value, 'An empty string should remain an empty string.' ); } - public function test_does_not_throw_when_the_value_is_shared(): void { + /** + * Ensure that wp_secrets_memzero() clears the variable passed and leaves a shared copy untouched. + * + * @covers ::wp_secrets_memzero + */ + public function test_clears_the_variable_and_leaves_a_shared_copy_untouched(): void { $value = 'shared plaintext'; $copy = $value; wp_secrets_memzero( $value ); - $this->assertSame( '', $value ); - // The shared copy is unaffected – see the docblock on wp_secrets_memzero(): - // this is hygiene, not a guarantee, for exactly this reason. - $this->assertSame( 'shared plaintext', $copy ); // @phpstan-ignore method.alreadyNarrowedType (Whether the copy survives the scrub is decided by the engine at runtime, not by the types.) + $this->assertSame( '', $value, 'The zeroed variable should be an empty string.' ); + + /* + * The shared copy is unaffected – see the docblock on wp_secrets_memzero(): + * this is hygiene, not a guarantee, for exactly this reason. + */ + $this->assertSame( 'shared plaintext', $copy, 'The shared copy should keep its original value.' ); // @phpstan-ignore method.alreadyNarrowedType (Whether the copy survives the scrub is decided by the engine at runtime, not by the types.) } } diff --git a/tests/phpunit/test-wp-secrets-option-store.php b/tests/phpunit/test-wp-secrets-option-store.php index 2c61262..01c472e 100644 --- a/tests/phpunit/test-wp-secrets-option-store.php +++ b/tests/phpunit/test-wp-secrets-option-store.php @@ -2,6 +2,8 @@ /** * Tests for WP_Secrets_Option_Store. * + * @ticket 66187 + * * @group secrets */ class Tests_Secrets_WPSecretsOptionStore extends WP_UnitTestCase { @@ -25,44 +27,77 @@ private function sample_record(): array { ); } + /** + * Ensure that WP_Secrets_Option_Store implements the WP_Secrets_Store interface. + * + * @coversNothing + */ public function test_implements_the_store_interface(): void { - $this->assertInstanceOf( WP_Secrets_Store::class, new WP_Secrets_Option_Store() ); // @phpstan-ignore method.alreadyNarrowedType (Pins the interface the class declares, so removing it fails a test.) + $this->assertInstanceOf( WP_Secrets_Store::class, new WP_Secrets_Option_Store(), 'WP_Secrets_Option_Store should implement WP_Secrets_Store.' ); // @phpstan-ignore method.alreadyNarrowedType (Pins the interface the class declares, so removing it fails a test.) } + /** + * Ensure that WP_Secrets_Option_Store::get() returns null when no record is stored. + * + * @covers WP_Secrets_Option_Store::get + */ public function test_get_returns_null_for_an_absent_secret(): void { $store = new WP_Secrets_Option_Store(); - $this->assertNull( $store->get( 'myplugin/absent' ) ); + $this->assertNull( $store->get( 'myplugin/absent' ), 'An absent secret should be returned as null.' ); } + /** + * Ensure that a site-scope record is returned unchanged after being stored. + * + * @covers WP_Secrets_Option_Store::set + * @covers WP_Secrets_Option_Store::get + */ public function test_set_then_get_round_trips_a_record_site_scope(): void { $store = new WP_Secrets_Option_Store(); $record = $this->sample_record(); - $this->assertTrue( $store->set( 'myplugin/key', $record ) ); - $this->assertSame( $record, $store->get( 'myplugin/key' ) ); + $this->assertTrue( $store->set( 'myplugin/key', $record ), 'Storing a site-scope record should succeed.' ); + $this->assertSame( $record, $store->get( 'myplugin/key' ), 'The site-scope record should be returned unchanged.' ); } + /** + * Ensure that a network-scope record is returned unchanged after being stored. + * + * @covers WP_Secrets_Option_Store::set + * @covers WP_Secrets_Option_Store::get + */ public function test_set_then_get_round_trips_a_record_network_scope(): void { $store = new WP_Secrets_Option_Store(); $record = $this->sample_record(); - $this->assertTrue( $store->set( 'myplugin/key', $record, true ) ); - $this->assertSame( $record, $store->get( 'myplugin/key', true ) ); + $this->assertTrue( $store->set( 'myplugin/key', $record, true ), 'Storing a network-scope record should succeed.' ); + $this->assertSame( $record, $store->get( 'myplugin/key', true ), 'The network-scope record should be returned unchanged.' ); } + /** + * Ensure that site and network records with the same name are stored independently. + * + * @covers WP_Secrets_Option_Store::set + * @covers WP_Secrets_Option_Store::get + */ public function test_site_and_network_scope_do_not_collide(): void { $store = new WP_Secrets_Option_Store(); $site_record = $this->sample_record(); $network_record = array_merge( $this->sample_record(), array( 'marker' => 'network' ) ); - $store->set( 'myplugin/key', $site_record, false ); - $store->set( 'myplugin/key', $network_record, true ); + $this->assertTrue( $store->set( 'myplugin/key', $site_record, false ), 'Storing the record for myplugin/key in site scope should succeed.' ); + $this->assertTrue( $store->set( 'myplugin/key', $network_record, true ), 'Storing the record for myplugin/key in network scope should succeed.' ); - $this->assertSame( $site_record, $store->get( 'myplugin/key', false ) ); - $this->assertSame( $network_record, $store->get( 'myplugin/key', true ) ); + $this->assertSame( $site_record, $store->get( 'myplugin/key', false ), 'The site-scope record should not be overwritten by the network-scope record.' ); + $this->assertSame( $network_record, $store->get( 'myplugin/key', true ), 'The network-scope record should be stored separately from the site-scope record.' ); } + /** + * Ensure that WP_Secrets_Option_Store::get() returns a malformed record error for a non-array value. + * + * @covers WP_Secrets_Option_Store::get + */ public function test_get_returns_an_error_when_the_stored_value_is_not_an_array(): void { update_option( '_wp_secret_myplugin/key', 'not an array', false ); @@ -70,32 +105,49 @@ public function test_get_returns_an_error_when_the_stored_value_is_not_an_array( $result = $store->get( 'myplugin/key' ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_RECORD_MALFORMED, $result->get_error_code() ); + $this->assertWPError( $result, 'A non-array stored value should be reported as a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_RECORD_MALFORMED, $result->get_error_code(), 'A non-array stored value should produce the malformed record error code.' ); } + /** + * Ensure that WP_Secrets_Option_Store::delete() removes a stored record. + * + * @covers WP_Secrets_Option_Store::delete + */ public function test_delete_removes_an_existing_record(): void { $store = new WP_Secrets_Option_Store(); - $store->set( 'myplugin/key', $this->sample_record() ); + $this->assertTrue( $store->set( 'myplugin/key', $this->sample_record() ), 'Storing the record for myplugin/key should succeed.' ); - $this->assertTrue( $store->delete( 'myplugin/key' ) ); - $this->assertNull( $store->get( 'myplugin/key' ) ); + $this->assertTrue( $store->delete( 'myplugin/key' ), 'Deleting an existing record should succeed.' ); + $this->assertNull( $store->get( 'myplugin/key' ), 'A deleted record should no longer be returned.' ); } + /** + * Ensure that WP_Secrets_Option_Store::delete() returns true when no record is stored. + * + * @covers WP_Secrets_Option_Store::delete + */ public function test_delete_on_an_absent_record_is_not_an_error(): void { $store = new WP_Secrets_Option_Store(); - $this->assertTrue( $store->delete( 'myplugin/never-existed' ) ); + $this->assertTrue( $store->delete( 'myplugin/never-existed' ), 'Deleting an absent record should report success.' ); } /** + * Ensure that site-scope secret records are stored with autoloading disabled. + * + * Autoloaded options are loaded into memory on every request, so keeping secrets + * out of the autoload set limits where their ciphertext travels. + * + * @covers WP_Secrets_Option_Store::set + * * @global wpdb $wpdb WordPress database abstraction object. */ public function test_site_scope_records_are_not_autoloaded(): void { global $wpdb; $store = new WP_Secrets_Option_Store(); - $store->set( 'myplugin/key', $this->sample_record() ); + $this->assertTrue( $store->set( 'myplugin/key', $this->sample_record() ), 'Storing the record for myplugin/key should succeed.' ); $autoload = $wpdb->get_var( $wpdb->prepare( @@ -104,66 +156,85 @@ public function test_site_scope_records_are_not_autoloaded(): void { ) ); - $this->assertContains( $autoload, array( 'no', 'off' ) ); + $this->assertContains( $autoload, array( 'no', 'off' ), 'Site-scope secret records should not be autoloaded.' ); } + /** + * Ensure that WP_Secrets_Option_Store::list_names() returns names without the option prefix. + * + * @covers WP_Secrets_Option_Store::list_names + */ public function test_list_names_returns_stored_names_without_the_prefix(): void { $store = new WP_Secrets_Option_Store(); - $store->set( 'myplugin/one', $this->sample_record() ); - $store->set( 'myplugin/two', $this->sample_record() ); + $this->assertTrue( $store->set( 'myplugin/one', $this->sample_record() ), 'Storing the record for myplugin/one should succeed.' ); + $this->assertTrue( $store->set( 'myplugin/two', $this->sample_record() ), 'Storing the record for myplugin/two should succeed.' ); $names = $store->list_names(); - $this->assertIsArray( $names ); - $this->assertContains( 'myplugin/one', $names ); - $this->assertContains( 'myplugin/two', $names ); + $this->assertIsArray( $names, 'list_names() should return an array.' ); + $this->assertContains( 'myplugin/one', $names, 'The first stored name should be listed without the prefix.' ); + $this->assertContains( 'myplugin/two', $names, 'The second stored name should be listed without the prefix.' ); } + /** + * Ensure that WP_Secrets_Option_Store::list_names() excludes options that are not secret records. + * + * @covers WP_Secrets_Option_Store::list_names + */ public function test_list_names_does_not_include_unrelated_options(): void { update_option( 'completely_unrelated_option', 'value' ); $store = new WP_Secrets_Option_Store(); - $store->set( 'myplugin/one', $this->sample_record() ); + $this->assertTrue( $store->set( 'myplugin/one', $this->sample_record() ), 'Storing the record for myplugin/one should succeed.' ); $names = $store->list_names(); - $this->assertIsArray( $names ); - $this->assertNotContains( 'completely_unrelated_option', $names ); + $this->assertIsArray( $names, 'list_names() should return an array.' ); + $this->assertNotContains( 'completely_unrelated_option', $names, 'Options that are not secret records should not be listed.' ); } - public function test_list_names_does_not_include_network_scope_names(): void { + /** + * Ensure that WP_Secrets_Option_Store::list_names() returns only the names from the requested scope. + * + * @covers WP_Secrets_Option_Store::list_names + */ + public function test_list_names_returns_only_the_requested_scope(): void { $store = new WP_Secrets_Option_Store(); - $store->set( 'myplugin/site-only', $this->sample_record(), false ); - $store->set( 'myplugin/network-only', $this->sample_record(), true ); + $this->assertTrue( $store->set( 'myplugin/site-only', $this->sample_record(), false ), 'Storing the record for myplugin/site-only in site scope should succeed.' ); + $this->assertTrue( $store->set( 'myplugin/network-only', $this->sample_record(), true ), 'Storing the record for myplugin/network-only in network scope should succeed.' ); $site_names = $store->list_names( false ); $network_names = $store->list_names( true ); - $this->assertIsArray( $site_names ); - $this->assertIsArray( $network_names ); - $this->assertContains( 'myplugin/site-only', $site_names ); - $this->assertNotContains( 'myplugin/network-only', $site_names ); - $this->assertContains( 'myplugin/network-only', $network_names ); - $this->assertNotContains( 'myplugin/site-only', $network_names ); + $this->assertIsArray( $site_names, 'list_names() should return an array for the site scope.' ); + $this->assertIsArray( $network_names, 'list_names() should return an array for the network scope.' ); + $this->assertContains( 'myplugin/site-only', $site_names, 'The site-scope name should be listed in the site scope.' ); + $this->assertNotContains( 'myplugin/network-only', $site_names, 'The network-scope name should not be listed in the site scope.' ); + $this->assertContains( 'myplugin/network-only', $network_names, 'The network-scope name should be listed in the network scope.' ); + $this->assertNotContains( 'myplugin/site-only', $network_names, 'The site-scope name should not be listed in the network scope.' ); } /** * '_' is a single-character wildcard in SQL LIKE. An option name with any other * character where the prefix's underscore falls must not match unless the prefix * is properly escaped before the query is built. + * + * @covers WP_Secrets_Option_Store::list_names */ public function test_list_names_escapes_the_underscore_wildcard(): void { - // Would match "_wp_secret_%" under an *unescaped* LIKE, since '_' matches - // any single character – proving esc_like() is actually applied. + /* + * Would match "_wp_secret_%" under an *unescaped* LIKE, since '_' matches + * any single character – proving esc_like() is actually applied. + */ update_option( '_wpXsecret_decoy/name', 'irrelevant value', false ); $store = new WP_Secrets_Option_Store(); - $store->set( 'myplugin/real', $this->sample_record() ); + $this->assertTrue( $store->set( 'myplugin/real', $this->sample_record() ), 'Storing the record for myplugin/real should succeed.' ); $names = $store->list_names(); - $this->assertIsArray( $names ); - $this->assertContains( 'myplugin/real', $names ); - $this->assertNotContains( 'decoy/name', $names ); + $this->assertIsArray( $names, 'list_names() should return an array.' ); + $this->assertContains( 'myplugin/real', $names, 'The real secret name should be listed.' ); + $this->assertNotContains( 'decoy/name', $names, 'An option matching only via an unescaped underscore wildcard should not be listed.' ); } } diff --git a/tests/phpunit/test-wp-secrets-validate-name.php b/tests/phpunit/test-wp-secrets-validate-name.php index 0e097a8..9440ed2 100644 --- a/tests/phpunit/test-wp-secrets-validate-name.php +++ b/tests/phpunit/test-wp-secrets-validate-name.php @@ -1,22 +1,28 @@ assertTrue( wp_secrets_validate_name( $name ) ); + $this->assertTrue( wp_secrets_validate_name( $name ), 'A well-formed namespaced name should be accepted.' ); } /** * @return array */ - public function data_valid_names(): array { + public static function data_accepts_valid_names(): array { return array( 'simple' => array( 'myplugin/api-key' ), 'single character segments' => array( 'a/b' ), @@ -30,37 +36,66 @@ public function data_valid_names(): array { } /** - * @dataProvider data_invalid_names + * Ensure that wp_secrets_validate_name() rejects malformed, overlong and wrongly cased names. + * + * @covers ::wp_secrets_validate_name + * + * @dataProvider data_rejects_invalid_names * * @param string $name A name that must be rejected. */ public function test_rejects_invalid_names( $name ): void { $result = wp_secrets_validate_name( $name ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code() ); + $this->assertWPError( $result, 'An invalid name should be rejected with a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code(), 'An invalid name should be reported with the invalid name error code.' ); + } + + /** + * @return array + */ + public static function data_rejects_invalid_names(): array { + return array( + 'empty string' => array( '' ), + 'two slashes' => array( 'too/many/slashes' ), + 'uppercase, unnamespaced' => array( 'NotAValidName' ), + 'space, unnamespaced' => array( 'not a valid name' ), + 'empty namespace' => array( '/key' ), + 'empty key' => array( 'namespace/' ), + 'uppercase namespace' => array( 'MyPlugin/key' ), + 'uppercase key' => array( 'plugin/MyKey' ), + 'leading hyphen in namespace' => array( '-plugin/key' ), + 'trailing hyphen in namespace' => array( 'plugin-/key' ), + 'leading underscore in key' => array( 'plugin/_key' ), + 'trailing underscore in key' => array( 'plugin/key_' ), + 'space in key' => array( 'plugin/key with space' ), + 'dot in key' => array( 'plugin/key.name' ), + 'non-ascii' => array( 'plügin/key' ), + 'one character over the max' => array( str_repeat( 'a', 85 ) . '/' . str_repeat( 'b', 87 ) ), + ); } /** * An unnamespaced name is accepted, but reports through _doing_it_wrong(). - * It exists so code written against the Displace prototype – whose keyspace - * was flat – can be ported one call site at a time rather than all at once. - * See docs/decisions/0005-namespaces-are-not-access-control.md for the consequence. + * It exists so existing code that uses flat names can be ported one call site + * at a time rather than all at once. + * + * @covers ::wp_secrets_validate_name * - * @dataProvider data_unnamespaced_names + * @dataProvider data_accepts_an_unnamespaced_name_but_reports_it * * @param string $name An unnamespaced name that must be accepted. */ public function test_accepts_an_unnamespaced_name_but_reports_it( $name ): void { $this->setExpectedIncorrectUsage( 'wp_secrets_validate_name' ); - $this->assertTrue( wp_secrets_validate_name( $name ) ); + $this->assertTrue( wp_secrets_validate_name( $name ), 'An unnamespaced name should be accepted.' ); } /** * @return array */ - public function data_unnamespaced_names(): array { + public static function data_accepts_an_unnamespaced_name_but_reports_it(): array { return array( 'simple' => array( 'api_key' ), 'with hyphen' => array( 'api-key' ), @@ -73,35 +108,13 @@ public function data_unnamespaced_names(): array { * The unnamespaced form relaxes the "exactly one slash" rule, not the * character rules – an unnamespaced name is still held to the same segment * pattern as a namespaced one. + * + * @covers ::wp_secrets_validate_name */ public function test_an_unnamespaced_name_still_obeys_the_character_rules(): void { $result = wp_secrets_validate_name( 'Not_A_Valid_Name' ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code() ); - } - - /** - * @return array - */ - public function data_invalid_names(): array { - return array( - 'empty string' => array( '' ), - 'two slashes' => array( 'too/many/slashes' ), - 'uppercase, unnamespaced' => array( 'NotAValidName' ), - 'space, unnamespaced' => array( 'not a valid name' ), - 'empty namespace' => array( '/key' ), - 'empty key' => array( 'namespace/' ), - 'uppercase namespace' => array( 'MyPlugin/key' ), - 'uppercase key' => array( 'plugin/MyKey' ), - 'leading hyphen in namespace' => array( '-plugin/key' ), - 'trailing hyphen in namespace' => array( 'plugin-/key' ), - 'leading underscore in key' => array( 'plugin/_key' ), - 'trailing underscore in key' => array( 'plugin/key_' ), - 'space in key' => array( 'plugin/key with space' ), - 'dot in key' => array( 'plugin/key.name' ), - 'non-ascii' => array( 'plügin/key' ), - 'one character over the max' => array( str_repeat( 'a', 85 ) . '/' . str_repeat( 'b', 87 ) ), - ); + $this->assertWPError( $result, 'An unnamespaced name with invalid characters should be rejected with a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code(), 'An unnamespaced name with invalid characters should be reported as an invalid name.' ); } } diff --git a/tests/phpunit/test-wp-set-secret-and-wp-get-secret.php b/tests/phpunit/test-wp-set-secret-and-wp-get-secret.php index a7943ae..f1ad1e5 100644 --- a/tests/phpunit/test-wp-set-secret-and-wp-get-secret.php +++ b/tests/phpunit/test-wp-set-secret-and-wp-get-secret.php @@ -2,42 +2,45 @@ /** * Tests for wp_set_secret() and wp_get_secret(). * + * @ticket 66187 + * * @group secrets */ class Tests_Secrets_WpSetSecretAndWpGetSecret extends WP_UnitTestCase { use WP_Secrets_Assertions; + /** + * Ensure that a stored secret is retrieved with its original value, name and a fingerprint. + * + * @covers ::wp_set_secret + * @covers ::wp_get_secret + */ public function test_set_then_get_round_trips(): void { - $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'sk_live_secret' ) ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'sk_live_secret' ), 'wp_set_secret() should return true on success.' ); $secret = wp_get_secret( 'myplugin/api-key' ); - $this->assertInstanceOf( WP_Secret::class, $secret ); - $this->assertSame( 'sk_live_secret', $secret->reveal() ); - $this->assertSame( 'myplugin/api-key', $secret->get_name() ); - $this->assertNotSame( '', $secret->fingerprint() ); - } - - public function test_get_returns_null_for_an_absent_secret(): void { - $this->assertNull( wp_get_secret( 'myplugin/never-set' ) ); + $this->assertInstanceOf( WP_Secret::class, $secret, 'wp_get_secret() should return a WP_Secret.' ); + $this->assertSame( 'sk_live_secret', $secret->reveal(), 'The secret should reveal its original value.' ); + $this->assertSame( 'myplugin/api-key', $secret->get_name(), 'The secret should report its name.' ); + $this->assertNotSame( '', $secret->fingerprint(), 'The secret fingerprint should not be empty.' ); } + /** + * Ensure that wp_get_secret() returns the current version when no version is requested. + * + * @covers ::wp_get_secret + */ public function test_get_defaults_to_the_current_version(): void { - wp_set_secret( 'myplugin/api-key', 'value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); $default = wp_get_secret( 'myplugin/api-key' ); $current = wp_get_secret( 'myplugin/api-key', WP_Secret_Version::CURRENT ); - $this->assertInstanceOf( WP_Secret::class, $default ); - $this->assertInstanceOf( WP_Secret::class, $current ); - $this->assertSame( $default->reveal(), $current->reveal() ); - } - - public function test_get_previous_on_a_never_rotated_secret_is_null_not_error(): void { - wp_set_secret( 'myplugin/api-key', 'value' ); - - $this->assertNull( wp_get_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS ) ); + $this->assertInstanceOf( WP_Secret::class, $default, 'The default lookup should return a WP_Secret.' ); + $this->assertInstanceOf( WP_Secret::class, $current, 'The current version lookup should return a WP_Secret.' ); + $this->assertSame( $default->reveal(), $current->reveal(), 'The default lookup should reveal the same value as the current version.' ); } /** @@ -45,77 +48,105 @@ public function test_get_previous_on_a_never_rotated_secret_is_null_not_error(): * WP_Error plus a _doing_it_wrong() notice rather than by throwing. The * WP_Error branch of the three-state contract already covers it, so this adds * no fourth state. + * + * @covers ::wp_get_secret */ public function test_an_invalid_version_is_a_wp_error_not_an_exception(): void { $this->setExpectedIncorrectUsage( '_wp_secrets_get' ); $result = wp_get_secret( 'myplugin/api-key', 'not-a-real-version' ); // @phpstan-ignore argument.type (Intentionally passing an invalid value.) - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code() ); + $this->assertWPError( $result, 'An invalid version should be reported as a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code(), 'An invalid version should use the invalid argument error code.' ); } + /** + * Ensure that wp_set_secret() returns an invalid name error for a malformed name. + * + * @covers ::wp_set_secret + */ public function test_set_rejects_an_invalid_name(): void { $result = wp_set_secret( 'Not A Valid Name', 'value' ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code() ); + $this->assertWPError( $result, 'An invalid name should be reported as a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code(), 'An invalid name should use the invalid name error code.' ); } + /** + * Ensure that wp_get_secret() returns an invalid name error for a malformed name. + * + * @covers ::wp_get_secret + */ public function test_get_rejects_an_invalid_name(): void { $result = wp_get_secret( 'Not A Valid Name' ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code() ); + $this->assertWPError( $result, 'An invalid name should be reported as a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code(), 'An invalid name should use the invalid name error code.' ); } + /** + * Ensure that wp_set_secret() returns an invalid value error for a non-string value. + * + * @covers ::wp_set_secret + */ public function test_set_rejects_a_non_string_value(): void { $result = wp_set_secret( 'myplugin/api-key', array( 'not', 'a', 'string' ) ); // @phpstan-ignore argument.type (Intentionally passing an invalid value.) - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_INVALID_VALUE, $result->get_error_code() ); + $this->assertWPError( $result, 'A non-string value should be reported as a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_VALUE, $result->get_error_code(), 'A non-string value should use the invalid value error code.' ); } + /** + * Ensure that an empty string can be stored and retrieved as a secret value. + * + * @covers ::wp_set_secret + * @covers ::wp_get_secret + */ public function test_set_accepts_an_empty_string_value(): void { - $this->assertTrue( wp_set_secret( 'myplugin/api-key', '' ) ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', '' ), 'Storing an empty string should succeed.' ); $secret = wp_get_secret( 'myplugin/api-key' ); - $this->assertInstanceOf( WP_Secret::class, $secret ); - $this->assertSame( '', $secret->reveal() ); + $this->assertInstanceOf( WP_Secret::class, $secret, 'The empty secret should be retrievable as a WP_Secret.' ); + $this->assertSame( '', $secret->reveal(), 'The secret should reveal an empty string.' ); } /** * Overwriting is the only repair path an operator has through this API, so a * corrupted existing record must not block a write. + * + * @covers ::wp_set_secret */ public function test_a_corrupt_record_can_be_overwritten(): void { update_option( '_wp_secret_myplugin/corrupt', 'not a record at all', false ); - $this->assertTrue( wp_set_secret( 'myplugin/corrupt', 'a fresh value' ) ); + $this->assertTrue( wp_set_secret( 'myplugin/corrupt', 'a fresh value' ), 'Overwriting a corrupt record should succeed.' ); $secret = wp_get_secret( 'myplugin/corrupt' ); - $this->assertInstanceOf( WP_Secret::class, $secret ); - $this->assertSame( 'a fresh value', $secret->reveal() ); + $this->assertInstanceOf( WP_Secret::class, $secret, 'The overwritten secret should be retrievable as a WP_Secret.' ); + $this->assertSame( 'a fresh value', $secret->reveal(), 'The secret should reveal the fresh value.' ); } /** * Nothing plaintext reaches the database. Scans every option row rather than just * the secret's own, since the failure this guards against is a plaintext landing * somewhere unexpected. + * + * @covers ::wp_set_secret + * @covers WP_Secrets_Libsodium_Provider::set */ public function test_the_options_table_contains_no_plaintext_after_a_write(): void { global $wpdb; - $this->assertInstanceOf( wpdb::class, $wpdb ); + $this->assertInstanceOf( wpdb::class, $wpdb, 'The global $wpdb should be a wpdb instance.' ); - wp_set_secret( 'myplugin/api-key', 'UNIQUE-PLAINTEXT-CANARY-9f3a' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'UNIQUE-PLAINTEXT-CANARY-9f3a' ), 'Setting myplugin/api-key to UNIQUE-PLAINTEXT-CANARY-9f3a should succeed.' ); $this->assertNeverContainsPlaintext( 'UNIQUE-PLAINTEXT-CANARY-9f3a', $wpdb->get_results( "SELECT option_name, option_value FROM {$wpdb->options}", ARRAY_A ), - 'The options table must never contain a plaintext secret.' + 'The options table should never contain a plaintext secret.' ); } @@ -125,23 +156,39 @@ public function test_the_options_table_contains_no_plaintext_after_a_write(): vo * The retrieval path clears its own local copy once the WP_Secret owns the value; * that only leaves the vault intact because PHP's copy-on-write splits the two * references first. + * + * @covers ::wp_get_secret */ public function test_reveal_still_works_after_the_internal_memzero(): void { - wp_set_secret( 'myplugin/api-key', 'must-survive-memzero' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'must-survive-memzero' ), 'Setting myplugin/api-key to must-survive-memzero should succeed.' ); $secret = wp_get_secret( 'myplugin/api-key' ); - $this->assertInstanceOf( WP_Secret::class, $secret ); - $this->assertSame( 'must-survive-memzero', $secret->reveal() ); + $this->assertInstanceOf( WP_Secret::class, $secret, 'The stored secret should be retrievable as a WP_Secret.' ); + $this->assertSame( 'must-survive-memzero', $secret->reveal(), 'The secret should reveal its value after the internal memzero.' ); } + /** + * Ensure that wp_set_secret() and wp_get_secret() succeed without a logged-in user. + * + * Capability checks belong at the operator boundary (CLI, admin screens), not in the + * API itself, so plugins can use their own secrets from any context, such as cron. + * + * @covers ::wp_set_secret + * @covers ::wp_get_secret + */ public function test_no_capability_check_is_applied(): void { wp_set_current_user( 0 ); // No logged-in user, no capabilities at all. - $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ) ); - $this->assertInstanceOf( WP_Secret::class, wp_get_secret( 'myplugin/api-key' ) ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Storing a secret without a logged-in user should succeed.' ); + $this->assertInstanceOf( WP_Secret::class, wp_get_secret( 'myplugin/api-key' ), 'Retrieving a secret without a logged-in user should return a WP_Secret.' ); } + /** + * Ensure that storing a new secret fires wp_secret_changed as created, with its actor and time. + * + * @covers ::wp_set_secret + */ public function test_change_hook_fires_on_create_with_created_action(): void { $captured = null; add_action( @@ -153,23 +200,33 @@ function ( ...$args ) use ( &$captured ) { 7 ); - wp_set_secret( 'myplugin/api-key', 'value' ); + $before = time(); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); + $after = time(); - $this->assertNotNull( $captured ); + $this->assertNotNull( $captured, 'The wp_secret_changed hook should fire.' ); list( $name, $action, $actor_id, $timestamp, $old_fingerprint, $new_fingerprint, $network ) = $captured; - $this->assertSame( 'myplugin/api-key', $name ); - $this->assertSame( 'created', $action ); - $this->assertSame( '', $old_fingerprint ); - $this->assertNotSame( '', $new_fingerprint ); - $this->assertFalse( $network ); + $this->assertSame( 'myplugin/api-key', $name, 'The hook should receive the secret name.' ); + $this->assertSame( 'created', $action, 'The hook action should be created.' ); + $this->assertSame( 0, $actor_id, 'With no user logged in, the actor should be reported as 0.' ); + $this->assertGreaterThanOrEqual( $before, $timestamp, 'The timestamp should not predate the write.' ); + $this->assertLessThanOrEqual( $after, $timestamp, 'The timestamp should not postdate the write.' ); + $this->assertSame( '', $old_fingerprint, 'The old fingerprint should be empty for a new secret.' ); + $this->assertNotSame( '', $new_fingerprint, 'The new fingerprint should not be empty.' ); + $this->assertFalse( $network, 'The network flag should be false for a site secret.' ); } + /** + * Ensure that overwriting a secret fires wp_secret_changed as updated, with a new fingerprint. + * + * @covers ::wp_set_secret + */ public function test_change_hook_fires_on_overwrite_with_updated_action(): void { - wp_set_secret( 'myplugin/api-key', 'first-value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'first-value' ), 'Setting myplugin/api-key to first-value should succeed.' ); $first = wp_get_secret( 'myplugin/api-key' ); - $this->assertInstanceOf( WP_Secret::class, $first ); + $this->assertInstanceOf( WP_Secret::class, $first, 'The first secret should be retrievable as a WP_Secret.' ); $first_fingerprint = $first->fingerprint(); $captured = null; @@ -182,16 +239,21 @@ function ( ...$args ) use ( &$captured ) { 7 ); - wp_set_secret( 'myplugin/api-key', 'second-value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'second-value' ), 'Setting myplugin/api-key to second-value should succeed.' ); - $this->assertIsArray( $captured ); + $this->assertIsArray( $captured, 'The wp_secret_changed hook should fire.' ); list( , $action, , , $old_fingerprint, $new_fingerprint ) = $captured; - $this->assertSame( 'updated', $action ); - $this->assertSame( $first_fingerprint, $old_fingerprint ); - $this->assertNotSame( $old_fingerprint, $new_fingerprint ); + $this->assertSame( 'updated', $action, 'The hook action should be updated.' ); + $this->assertSame( $first_fingerprint, $old_fingerprint, 'The old fingerprint should match the first value.' ); + $this->assertNotSame( $old_fingerprint, $new_fingerprint, 'The new fingerprint should differ from the old one.' ); } + /** + * Ensure that wp_secret_changed reports the current user's ID as the actor. + * + * @covers ::wp_set_secret + */ public function test_change_hook_reports_the_current_user_as_actor(): void { $user_id = self::factory()->user->create(); wp_set_current_user( $user_id ); @@ -206,11 +268,19 @@ function ( $name, $action, $actor_id ) use ( &$captured_actor ) { 3 ); - wp_set_secret( 'myplugin/api-key', 'value' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); - $this->assertSame( $user_id, $captured_actor ); + $this->assertSame( $user_id, $captured_actor, 'The hook should report the current user as the actor.' ); } + /** + * Ensure that wp_secret_changed never receives the secret's plaintext value. + * + * Any callback can listen to this hook, including logging and auditing plugins, so + * every argument passed to it must be safe to record. + * + * @covers ::wp_set_secret + */ public function test_change_hook_never_receives_a_value(): void { $captured_args = null; add_action( @@ -222,13 +292,13 @@ function ( ...$args ) use ( &$captured_args ) { 7 ); - wp_set_secret( 'myplugin/api-key', 'a-plaintext-value-that-must-not-leak' ); + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'a-plaintext-value-that-must-not-leak' ), 'Setting myplugin/api-key to a-plaintext-value-that-must-not-leak should succeed.' ); - $this->assertIsArray( $captured_args ); + $this->assertIsArray( $captured_args, 'The wp_secret_changed hook should fire.' ); foreach ( $captured_args as $arg ) { - $this->assertIsScalar( $arg ); - $this->assertStringNotContainsString( 'a-plaintext-value-that-must-not-leak', (string) $arg ); + $this->assertIsScalar( $arg, 'Each hook argument should be scalar.' ); + $this->assertStringNotContainsString( 'a-plaintext-value-that-must-not-leak', (string) $arg, 'No hook argument should contain the plaintext value.' ); } } }