From 1930b712fa5b518c629e874aa1c037cf65d465d4 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Mon, 10 Aug 2026 12:33:24 +0200 Subject: [PATCH 1/5] fix(helpers): return instead of exit when loaded outside WordPress The Composer files autoload runs helpers.php before any test bootstrap, so the exit guard silently killed PHPUnit with exit code 0 and no output. --- src/helpers.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/helpers.php b/src/helpers.php index fa87d61..e2add91 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -2,9 +2,10 @@ declare(strict_types=1); -// Prevent direct access. +// Prevent direct access. `return` (not `exit`) so the Composer `files` +// autoload doesn't kill CLI tools like PHPUnit that load outside WordPress. if (! defined('ABSPATH')) { - exit; + return; } use Owlstack\WordPress\Plugin; From 0c7941333867011e5c305f2a583bec527fac5dbb Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Mon, 10 Aug 2026 12:33:24 +0200 Subject: [PATCH 2/5] feat(cloud): OwlStack Cloud connection via scoped site token Adds a revocable site token (SHA-256 hash stored, plaintext shown once) that lets OwlStack Cloud push content into the site through new token-authenticated REST routes under owlstack/v1/cloud: site info, create post, upload image, and delete Cloud-created posts. Incoming content honors a site-level status policy (draft / publish / honor), configurable author, and post type. The plugin makes no outbound calls; the feature is inactive until a token is generated under Owlstack > Cloud. --- src/Admin/CloudSettingsPage.php | 145 +++++++++ src/Admin/views/cloud-settings-page.php | 162 ++++++++++ src/Cloud/CloudSettings.php | 97 ++++++ src/Cloud/CloudTokenService.php | 121 ++++++++ src/Plugin.php | 11 + src/Rest/CloudRestController.php | 374 ++++++++++++++++++++++++ src/Uninstaller.php | 1 + 7 files changed, 911 insertions(+) create mode 100644 src/Admin/CloudSettingsPage.php create mode 100644 src/Admin/views/cloud-settings-page.php create mode 100644 src/Cloud/CloudSettings.php create mode 100644 src/Cloud/CloudTokenService.php create mode 100644 src/Rest/CloudRestController.php diff --git a/src/Admin/CloudSettingsPage.php b/src/Admin/CloudSettingsPage.php new file mode 100644 index 0000000..2f2747d --- /dev/null +++ b/src/Admin/CloudSettingsPage.php @@ -0,0 +1,145 @@ +tokens->isPaired(); + $tokenInfo = $this->tokens->info(); + $settings = $this->settings; + + require __DIR__ . '/views/cloud-settings-page.php'; + } + + /** + * Generate (or regenerate) the site token. + */ + public function handleGenerate(): void + { + $this->verifyRequest('owlstack_cloud_generate'); + + $token = $this->tokens->generate(get_current_user_id()); + + // One-time reveal for the current admin only. + set_transient(self::REVEAL_TRANSIENT . get_current_user_id(), $token, 5 * MINUTE_IN_SECONDS); + + $this->redirectBack('token-generated'); + } + + /** + * Revoke the site token. + */ + public function handleRevoke(): void + { + $this->verifyRequest('owlstack_cloud_revoke'); + + $this->tokens->revoke(); + + $this->redirectBack('token-revoked'); + } + + /** + * Save content-handling settings. + */ + public function handleSettings(): void + { + $this->verifyRequest('owlstack_cloud_settings'); + + // phpcs:disable WordPress.Security.NonceVerification.Missing -- verified in verifyRequest(). + $policy = isset($_POST['post_status_policy']) ? sanitize_key(wp_unslash($_POST['post_status_policy'])) : CloudSettings::POLICY_HONOR; + $author = isset($_POST['default_author']) ? absint(wp_unslash($_POST['default_author'])) : 0; + $type = isset($_POST['post_type']) ? sanitize_key(wp_unslash($_POST['post_type'])) : 'post'; + // phpcs:enable WordPress.Security.NonceVerification.Missing + + $this->settings->update($policy, $author, $type); + + $this->redirectBack('settings-saved'); + } + + // ── Helpers ────────────────────────────────────────────────────────── + + private function verifyRequest(string $action): void + { + if (! current_user_can('manage_options')) { + wp_die(esc_html__('You are not allowed to manage Owlstack Cloud settings.', 'owlstack')); + } + + check_admin_referer($action); + } + + private function redirectBack(string $notice): void + { + wp_safe_redirect( + add_query_arg( + ['page' => self::PAGE_SLUG, 'owlstack-notice' => $notice], + admin_url('admin.php'), + ), + ); + exit; + } +} diff --git a/src/Admin/views/cloud-settings-page.php b/src/Admin/views/cloud-settings-page.php new file mode 100644 index 0000000..f3fb2eb --- /dev/null +++ b/src/Admin/views/cloud-settings-page.php @@ -0,0 +1,162 @@ + +
+

+ + +
+

+
+ +
+

+
+ + +

+ +

+ + +

+ + +
+

+

+
+ + + + + + + + + +
+ + + +
+ + +
+ + + +
+ + + +

+ +

+ + + +

+ +
+ + + + + + + + + + + + + + + + + + + +
+
diff --git a/src/Cloud/CloudSettings.php b/src/Cloud/CloudSettings.php new file mode 100644 index 0000000..f982259 --- /dev/null +++ b/src/Cloud/CloudSettings.php @@ -0,0 +1,97 @@ +tokens->all()['post_status_policy'] ?? self::POLICY_HONOR; + + return in_array($policy, self::POLICIES, true) ? $policy : self::POLICY_HONOR; + } + + /** + * Resolve the final post status for a requested status. + */ + public function resolveStatus(string $requested): string + { + $requested = $requested === self::POLICY_DRAFT ? self::POLICY_DRAFT : self::POLICY_PUBLISH; + + return match ($this->statusPolicy()) { + self::POLICY_DRAFT => self::POLICY_DRAFT, + self::POLICY_PUBLISH => self::POLICY_PUBLISH, + default => $requested, + }; + } + + /** + * WP user ID that authors posts created by OwlStack Cloud. + * Falls back to the user who generated the token. + */ + public function defaultAuthor(): int + { + $data = $this->tokens->all(); + + $author = (int) ($data['default_author'] ?? 0); + if ($author > 0 && get_userdata($author) !== false) { + return $author; + } + + $creator = (int) ($data['created_by'] ?? 0); + if ($creator > 0 && get_userdata($creator) !== false) { + return $creator; + } + + return 0; + } + + /** + * Post type for posts created by OwlStack Cloud. + */ + public function postType(): string + { + $type = $this->tokens->all()['post_type'] ?? 'post'; + + return is_string($type) && $type !== '' && post_type_exists($type) ? $type : 'post'; + } + + /** + * Persist settings from the admin form. + */ + public function update(string $statusPolicy, int $defaultAuthor, string $postType): void + { + $data = $this->tokens->all(); + + $data['post_status_policy'] = in_array($statusPolicy, self::POLICIES, true) ? $statusPolicy : self::POLICY_HONOR; + $data['default_author'] = $defaultAuthor > 0 && get_userdata($defaultAuthor) !== false ? $defaultAuthor : 0; + $data['post_type'] = post_type_exists($postType) ? $postType : 'post'; + + $this->tokens->save($data); + } +} diff --git a/src/Cloud/CloudTokenService.php b/src/Cloud/CloudTokenService.php new file mode 100644 index 0000000..69770e8 --- /dev/null +++ b/src/Cloud/CloudTokenService.php @@ -0,0 +1,121 @@ +all(); + $data['token_hash'] = hash('sha256', $token); + $data['token_hint'] = substr($token, 0, strlen(self::TOKEN_PREFIX) + 4); + $data['created_at'] = time(); + $data['created_by'] = $userId; + $data['last_used_at'] = null; + + $this->save($data); + + return $token; + } + + /** + * Check a presented token against the stored hash. + */ + public function verify(string $token): bool + { + $hash = $this->all()['token_hash'] ?? ''; + + if (! is_string($hash) || $hash === '' || $token === '') { + return false; + } + + return hash_equals($hash, hash('sha256', $token)); + } + + /** + * Whether a token has been generated (site is pairable). + */ + public function isPaired(): bool + { + $hash = $this->all()['token_hash'] ?? ''; + + return is_string($hash) && $hash !== ''; + } + + /** + * Remove the token. Cloud requests fail until a new one is generated. + */ + public function revoke(): void + { + $data = $this->all(); + unset($data['token_hash'], $data['token_hint'], $data['created_at'], $data['created_by'], $data['last_used_at']); + $this->save($data); + } + + /** + * Record that the token was just used successfully. + */ + public function touch(): void + { + $data = $this->all(); + $data['last_used_at'] = time(); + $this->save($data); + } + + /** + * Token metadata for the admin UI (never the token itself). + * + * @return array{hint: string, created_at: ?int, created_by: ?int, last_used_at: ?int} + */ + public function info(): array + { + $data = $this->all(); + + return [ + 'hint' => is_string($data['token_hint'] ?? null) ? $data['token_hint'] : '', + 'created_at' => is_int($data['created_at'] ?? null) ? $data['created_at'] : null, + 'created_by' => is_int($data['created_by'] ?? null) ? $data['created_by'] : null, + 'last_used_at' => is_int($data['last_used_at'] ?? null) ? $data['last_used_at'] : null, + ]; + } + + /** + * @return array + */ + public function all(): array + { + $data = get_option(self::OPTION_KEY, []); + + return is_array($data) ? $data : []; + } + + /** + * @param array $data + */ + public function save(array $data): void + { + update_option(self::OPTION_KEY, $data, false); + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 56ffdfc..23b4634 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -30,15 +30,19 @@ use Owlstack\Core\Platforms\Twitter\TwitterPlatform; use Owlstack\Core\Platforms\WhatsApp\WhatsAppPlatform; use Owlstack\Core\Publishing\Publisher; +use Owlstack\WordPress\Admin\CloudSettingsPage; use Owlstack\WordPress\Admin\DeliveryLogsPage; use Owlstack\WordPress\Admin\MetaBox; use Owlstack\WordPress\Admin\OptionsManager; use Owlstack\WordPress\Admin\SettingsPage; use Owlstack\WordPress\Auth\WpTokenStore; +use Owlstack\WordPress\Cloud\CloudSettings; +use Owlstack\WordPress\Cloud\CloudTokenService; use Owlstack\WordPress\Events\WpEventDispatcher; use Owlstack\WordPress\Http\WpHttpClient; use Owlstack\WordPress\Publishing\PostPublisher; use Owlstack\WordPress\Publishing\SendTo; +use Owlstack\WordPress\Rest\CloudRestController; use Owlstack\WordPress\Rest\OwlstackRestController; /** @@ -100,6 +104,7 @@ public function boot(): void // REST API. add_action('rest_api_init', [OwlstackRestController::class, 'register']); + add_action('rest_api_init', [CloudRestController::class, 'register']); // Post publishing hook. add_action('transition_post_status', [PostPublisher::class, 'handle'], 10, 3); @@ -323,6 +328,11 @@ private function registerAdminHooks(): void $logsPage = new DeliveryLogsPage(); add_action('admin_menu', [$logsPage, 'register']); + $cloudTokens = new CloudTokenService(); + $cloudPage = new CloudSettingsPage($cloudTokens, new CloudSettings($cloudTokens)); + add_action('admin_menu', [$cloudPage, 'register']); + $cloudPage->registerActions(); + add_action('admin_enqueue_scripts', [$this, 'enqueueAdminAssets']); } @@ -357,6 +367,7 @@ public function enqueueAdminAssets(string $hook): void $owlstackPages = [ 'toplevel_page_owlstack', 'owlstack_page_owlstack-logs', + 'owlstack_page_owlstack-cloud', 'owlstack_page_owlstack-telegram', 'owlstack_page_owlstack-twitter', 'owlstack_page_owlstack-facebook', diff --git a/src/Rest/CloudRestController.php b/src/Rest/CloudRestController.php new file mode 100644 index 0000000..f7fd3e9 --- /dev/null +++ b/src/Rest/CloudRestController.php @@ -0,0 +1,374 @@ +\d+) + * + * All routes authenticate via the X-Owlstack-Token header against the + * hashed site token — no WordPress user session or nonce is involved. + * The token can only reach these routes; it grants no other WP access. + */ +class CloudRestController +{ + private const NAMESPACE = 'owlstack/v1'; + + private const META_KEY = '_owlstack_cloud'; + + public const ALLOWED_MIME_TYPES = [ + 'jpg|jpeg|jpe' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + ]; + + /** + * Register REST routes. + */ + public static function register(): void + { + register_rest_route(self::NAMESPACE, '/cloud/site', [ + 'methods' => \WP_REST_Server::READABLE, + 'callback' => [self::class, 'siteInfo'], + 'permission_callback' => [self::class, 'checkToken'], + ]); + + register_rest_route(self::NAMESPACE, '/cloud/posts', [ + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => [self::class, 'createPost'], + 'permission_callback' => [self::class, 'checkToken'], + 'args' => [ + 'title' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ], + 'content' => [ + 'required' => true, + 'type' => 'string', + // Sanitized with wp_kses_post in the callback. + ], + 'status' => [ + 'required' => false, + 'type' => 'string', + 'enum' => ['draft', 'publish'], + 'default' => 'publish', + 'sanitize_callback' => 'sanitize_key', + ], + 'featured_media' => [ + 'required' => false, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ], + 'excerpt' => [ + 'required' => false, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_textarea_field', + ], + 'slug' => [ + 'required' => false, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_title', + ], + ], + ]); + + register_rest_route(self::NAMESPACE, '/cloud/media', [ + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => [self::class, 'uploadMedia'], + 'permission_callback' => [self::class, 'checkToken'], + ]); + + register_rest_route(self::NAMESPACE, '/cloud/posts/(?P\d+)', [ + 'methods' => \WP_REST_Server::DELETABLE, + 'callback' => [self::class, 'deletePost'], + 'permission_callback' => [self::class, 'checkToken'], + 'args' => [ + 'id' => [ + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ], + 'force' => [ + 'required' => false, + 'type' => 'boolean', + 'default' => false, + ], + ], + ]); + } + + // ── Auth ───────────────────────────────────────────────────────────── + + /** + * Validate the X-Owlstack-Token header against the stored hash. + */ + public static function checkToken(\WP_REST_Request $request): bool|\WP_Error + { + $tokens = self::tokens(); + + if (! $tokens->isPaired()) { + return new \WP_Error( + 'owlstack_not_paired', + __('No site token has been generated. Create one under Owlstack → Cloud.', 'owlstack'), + ['status' => 403], + ); + } + + $provided = (string) $request->get_header('X-Owlstack-Token'); + + if ($provided === '' || ! $tokens->verify($provided)) { + return new \WP_Error( + 'owlstack_invalid_token', + __('Invalid site token.', 'owlstack'), + ['status' => 401], + ); + } + + $tokens->touch(); + + return true; + } + + // ── Callbacks ──────────────────────────────────────────────────────── + + /** + * Site metadata used by OwlStack Cloud to validate and label the connection. + */ + public static function siteInfo(): \WP_REST_Response + { + $settings = self::settings(); + + return new \WP_REST_Response([ + 'name' => get_bloginfo('name'), + 'url' => home_url(), + 'icon' => get_site_icon_url() ?: null, + 'wp_version' => get_bloginfo('version'), + 'plugin_version' => defined('OWLSTACK_VERSION') ? OWLSTACK_VERSION : null, + 'post_status_policy' => $settings->statusPolicy(), + 'default_post_type' => $settings->postType(), + ]); + } + + /** + * Create a post from OwlStack Cloud content. + */ + public static function createPost(\WP_REST_Request $request): \WP_REST_Response|\WP_Error + { + $settings = self::settings(); + + $author = $settings->defaultAuthor(); + if ($author === 0) { + return new \WP_Error( + 'owlstack_no_author', + __('No valid author is configured for Cloud posts. Check Owlstack → Cloud settings.', 'owlstack'), + ['status' => 500], + ); + } + + $status = $settings->resolveStatus((string) $request->get_param('status')); + + $postarr = [ + 'post_title' => (string) $request->get_param('title'), + 'post_content' => wp_kses_post((string) $request->get_param('content')), + 'post_status' => $status, + 'post_type' => $settings->postType(), + 'post_author' => $author, + 'meta_input' => [self::META_KEY => 1], + ]; + + $excerpt = $request->get_param('excerpt'); + if (is_string($excerpt) && $excerpt !== '') { + $postarr['post_excerpt'] = $excerpt; + } + + $slug = $request->get_param('slug'); + if (is_string($slug) && $slug !== '') { + $postarr['post_name'] = $slug; + } + + $postId = wp_insert_post(wp_slash($postarr), true); + + if (is_wp_error($postId)) { + $postId->add_data(['status' => 500]); + + return $postId; + } + + $featuredMedia = (int) ($request->get_param('featured_media') ?? 0); + if ($featuredMedia > 0 && get_post_type($featuredMedia) === 'attachment') { + set_post_thumbnail($postId, $featuredMedia); + } + + return new \WP_REST_Response([ + 'id' => $postId, + 'status' => get_post_status($postId), + 'link' => get_permalink($postId), + 'edit_link' => admin_url(sprintf('post.php?post=%d&action=edit', $postId)), + ], 201); + } + + /** + * Sideload an image (raw request body, wp/v2-style headers). + */ + public static function uploadMedia(\WP_REST_Request $request): \WP_REST_Response|\WP_Error + { + $bits = $request->get_body(); + if ($bits === '') { + return new \WP_Error( + 'owlstack_empty_upload', + __('No file content received.', 'owlstack'), + ['status' => 400], + ); + } + + $filename = self::filenameFromDisposition((string) $request->get_header('Content-Disposition')); + if ($filename === '') { + return new \WP_Error( + 'owlstack_missing_filename', + __('Content-Disposition header with a filename is required.', 'owlstack'), + ['status' => 400], + ); + } + + $type = wp_check_filetype($filename, self::ALLOWED_MIME_TYPES); + if (empty($type['ext']) || empty($type['type'])) { + return new \WP_Error( + 'owlstack_unsupported_type', + __('Only JPEG, PNG, GIF, and WebP images are supported.', 'owlstack'), + ['status' => 415], + ); + } + + $upload = wp_upload_bits($filename, null, $bits); + if (! empty($upload['error'])) { + return new \WP_Error( + 'owlstack_upload_failed', + (string) $upload['error'], + ['status' => 500], + ); + } + + // Verify the actual bytes match an allowed image type, not just the name. + $real = wp_check_filetype_and_ext($upload['file'], $filename, self::ALLOWED_MIME_TYPES); + if (empty($real['ext']) || empty($real['type'])) { + wp_delete_file($upload['file']); + + return new \WP_Error( + 'owlstack_invalid_image', + __('The uploaded file is not a valid image.', 'owlstack'), + ['status' => 415], + ); + } + + $attachmentId = wp_insert_attachment( + [ + 'post_mime_type' => $real['type'], + 'post_title' => sanitize_file_name(pathinfo($filename, PATHINFO_FILENAME)), + 'post_status' => 'inherit', + 'post_author' => self::settings()->defaultAuthor(), + 'meta_input' => [self::META_KEY => 1], + ], + $upload['file'], + 0, + true, + ); + + if (is_wp_error($attachmentId)) { + wp_delete_file($upload['file']); + $attachmentId->add_data(['status' => 500]); + + return $attachmentId; + } + + require_once ABSPATH . 'wp-admin/includes/image.php'; + wp_update_attachment_metadata( + $attachmentId, + wp_generate_attachment_metadata($attachmentId, $upload['file']), + ); + + return new \WP_REST_Response([ + 'id' => $attachmentId, + 'source_url' => wp_get_attachment_url($attachmentId) ?: $upload['url'], + ], 201); + } + + /** + * Trash or delete a post previously created by OwlStack Cloud. + */ + public static function deletePost(\WP_REST_Request $request): \WP_REST_Response|\WP_Error + { + $postId = (int) $request->get_param('id'); + $post = get_post($postId); + + if (! $post instanceof \WP_Post) { + return new \WP_Error( + 'owlstack_post_not_found', + __('Post not found.', 'owlstack'), + ['status' => 404], + ); + } + + // The token may only touch content it created. + if (! get_post_meta($postId, self::META_KEY, true)) { + return new \WP_Error( + 'owlstack_forbidden_post', + __('This post was not created by OwlStack Cloud.', 'owlstack'), + ['status' => 403], + ); + } + + $force = (bool) $request->get_param('force'); + $result = $force ? wp_delete_post($postId, true) : wp_trash_post($postId); + + if (! $result) { + return new \WP_Error( + 'owlstack_delete_failed', + __('Failed to delete the post.', 'owlstack'), + ['status' => 500], + ); + } + + return new \WP_REST_Response([ + 'deleted' => true, + 'id' => $postId, + ]); + } + + // ── Services ───────────────────────────────────────────────────────── + + private static function tokens(): CloudTokenService + { + return new CloudTokenService(); + } + + private static function settings(): CloudSettings + { + return new CloudSettings(self::tokens()); + } + + /** + * Extract the filename from a Content-Disposition header. + */ + private static function filenameFromDisposition(string $disposition): string + { + if (! preg_match('/filename\*?=(?:UTF-8\'\')?"?([^";]+)"?/i', $disposition, $matches)) { + return ''; + } + + return sanitize_file_name(rawurldecode(trim($matches[1]))); + } +} diff --git a/src/Uninstaller.php b/src/Uninstaller.php index b1f92ad..9ee51cd 100644 --- a/src/Uninstaller.php +++ b/src/Uninstaller.php @@ -35,6 +35,7 @@ public static function uninstall(): void private static function removeOptions(): void { delete_option('owlstack_settings'); + delete_option('owlstack_cloud'); delete_option('owlstack_db_version'); } From ab0b4b4c79464cebea6967a1bd5b4ed2f6a7852b Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Mon, 10 Aug 2026 12:33:24 +0200 Subject: [PATCH 3/5] test(cloud): unit tests for token service, settings, and REST controller Adds stateful option stubs and WP REST/post stubs to the test bootstrap. --- tests/Unit/Cloud/CloudSettingsTest.php | 127 ++++++++ tests/Unit/Cloud/CloudTokenServiceTest.php | 107 +++++++ tests/Unit/Rest/CloudRestControllerTest.php | 223 ++++++++++++++ tests/bootstrap.php | 11 +- tests/stubs-rest.php | 325 ++++++++++++++++++++ 5 files changed, 792 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Cloud/CloudSettingsTest.php create mode 100644 tests/Unit/Cloud/CloudTokenServiceTest.php create mode 100644 tests/Unit/Rest/CloudRestControllerTest.php create mode 100644 tests/stubs-rest.php diff --git a/tests/Unit/Cloud/CloudSettingsTest.php b/tests/Unit/Cloud/CloudSettingsTest.php new file mode 100644 index 0000000..9eb376a --- /dev/null +++ b/tests/Unit/Cloud/CloudSettingsTest.php @@ -0,0 +1,127 @@ +tokens = new CloudTokenService(); + $this->settings = new CloudSettings($this->tokens); + } + + private function setOption(string $key, mixed $value): void + { + $data = $this->tokens->all(); + $data[$key] = $value; + $this->tokens->save($data); + } + + public function testDefaultPolicyIsHonor(): void + { + $this->assertSame('honor', $this->settings->statusPolicy()); + } + + public function testInvalidPolicyFallsBackToHonor(): void + { + $this->setOption('post_status_policy', 'pending'); + + $this->assertSame('honor', $this->settings->statusPolicy()); + } + + #[DataProvider('statusMatrix')] + public function testResolveStatusMatrix(string $policy, string $requested, string $expected): void + { + $this->setOption('post_status_policy', $policy); + + $this->assertSame($expected, $this->settings->resolveStatus($requested)); + } + + /** + * @return array + */ + public static function statusMatrix(): array + { + return [ + 'draft policy, publish requested' => ['draft', 'publish', 'draft'], + 'draft policy, draft requested' => ['draft', 'draft', 'draft'], + 'publish policy, publish requested' => ['publish', 'publish', 'publish'], + 'publish policy, draft requested' => ['publish', 'draft', 'publish'], + 'honor policy, publish requested' => ['honor', 'publish', 'publish'], + 'honor policy, draft requested' => ['honor', 'draft', 'draft'], + ]; + } + + public function testUnknownRequestedStatusResolvesToPublish(): void + { + $this->setOption('post_status_policy', 'honor'); + + $this->assertSame('publish', $this->settings->resolveStatus('private')); + } + + public function testDefaultAuthorFallsBackToTokenCreator(): void + { + $this->tokens->generate(7); + + $this->assertSame(7, $this->settings->defaultAuthor()); + } + + public function testDefaultAuthorPrefersConfiguredUser(): void + { + $this->tokens->generate(7); + $this->setOption('default_author', 1); + + $this->assertSame(1, $this->settings->defaultAuthor()); + } + + public function testDeletedAuthorFallsBack(): void + { + $this->tokens->generate(7); + $this->setOption('default_author', 99); + + $this->assertSame(7, $this->settings->defaultAuthor()); + } + + public function testNoValidAuthorReturnsZero(): void + { + $this->assertSame(0, $this->settings->defaultAuthor()); + } + + public function testPostTypeFallsBackToPost(): void + { + $this->setOption('post_type', 'nonexistent_type'); + + $this->assertSame('post', $this->settings->postType()); + } + + public function testUpdatePersistsValidValues(): void + { + $this->settings->update('draft', 7, 'page'); + + $this->assertSame('draft', $this->settings->statusPolicy()); + $this->assertSame(7, $this->settings->defaultAuthor()); + $this->assertSame('page', $this->settings->postType()); + } + + public function testUpdateRejectsInvalidValues(): void + { + $this->settings->update('pending', 99, 'nonexistent_type'); + + $this->assertSame('honor', $this->settings->statusPolicy()); + $this->assertSame(0, $this->settings->defaultAuthor()); + $this->assertSame('post', $this->settings->postType()); + } +} diff --git a/tests/Unit/Cloud/CloudTokenServiceTest.php b/tests/Unit/Cloud/CloudTokenServiceTest.php new file mode 100644 index 0000000..198076c --- /dev/null +++ b/tests/Unit/Cloud/CloudTokenServiceTest.php @@ -0,0 +1,107 @@ +service = new CloudTokenService(); + } + + public function testGenerateReturnsPrefixedToken(): void + { + $token = $this->service->generate(1); + + $this->assertStringStartsWith('owlstk_', $token); + $this->assertSame(7 + 64, strlen($token)); + } + + public function testPlaintextTokenIsNeverStored(): void + { + $token = $this->service->generate(1); + + $stored = wp_json_encode($GLOBALS['owlstack_test_options'][CloudTokenService::OPTION_KEY]); + + $this->assertStringNotContainsString($token, (string) $stored); + } + + public function testVerifyAcceptsCorrectToken(): void + { + $token = $this->service->generate(1); + + $this->assertTrue($this->service->verify($token)); + } + + public function testVerifyRejectsWrongToken(): void + { + $this->service->generate(1); + + $this->assertFalse($this->service->verify('owlstk_' . str_repeat('0', 64))); + $this->assertFalse($this->service->verify('')); + } + + public function testVerifyRejectsWhenNotPaired(): void + { + $this->assertFalse($this->service->isPaired()); + $this->assertFalse($this->service->verify('owlstk_' . str_repeat('0', 64))); + } + + public function testRegenerateInvalidatesOldToken(): void + { + $old = $this->service->generate(1); + $new = $this->service->generate(1); + + $this->assertFalse($this->service->verify($old)); + $this->assertTrue($this->service->verify($new)); + } + + public function testRevokeRemovesToken(): void + { + $token = $this->service->generate(1); + $this->service->revoke(); + + $this->assertFalse($this->service->isPaired()); + $this->assertFalse($this->service->verify($token)); + } + + public function testRevokePreservesSettings(): void + { + $this->service->generate(1); + $data = $this->service->all(); + $data['post_status_policy'] = 'draft'; + $this->service->save($data); + + $this->service->revoke(); + + $this->assertSame('draft', $this->service->all()['post_status_policy']); + } + + public function testInfoExposesHintButNotHash(): void + { + $token = $this->service->generate(7); + $info = $this->service->info(); + + $this->assertSame(substr($token, 0, 11), $info['hint']); + $this->assertSame(7, $info['created_by']); + $this->assertNull($info['last_used_at']); + $this->assertArrayNotHasKey('token_hash', $info); + } + + public function testTouchRecordsLastUse(): void + { + $this->service->generate(1); + $this->service->touch(); + + $this->assertIsInt($this->service->info()['last_used_at']); + } +} diff --git a/tests/Unit/Rest/CloudRestControllerTest.php b/tests/Unit/Rest/CloudRestControllerTest.php new file mode 100644 index 0000000..2cd0e42 --- /dev/null +++ b/tests/Unit/Rest/CloudRestControllerTest.php @@ -0,0 +1,223 @@ +tokens = new CloudTokenService(); + } + + private function request(array $params = [], array $headers = [], string $body = ''): \WP_REST_Request + { + return new \WP_REST_Request($params, $headers, $body); + } + + // ── checkToken ─────────────────────────────────────────────────────── + + public function testCheckTokenFailsWhenNotPaired(): void + { + $result = CloudRestController::checkToken($this->request()); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertSame('owlstack_not_paired', $result->get_error_code()); + $this->assertSame(403, $result->get_error_data()['status']); + } + + public function testCheckTokenRejectsWrongToken(): void + { + $this->tokens->generate(1); + + $result = CloudRestController::checkToken( + $this->request([], ['X-Owlstack-Token' => 'owlstk_' . str_repeat('0', 64)]), + ); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertSame('owlstack_invalid_token', $result->get_error_code()); + $this->assertSame(401, $result->get_error_data()['status']); + } + + public function testCheckTokenRejectsMissingHeader(): void + { + $this->tokens->generate(1); + + $result = CloudRestController::checkToken($this->request()); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertSame('owlstack_invalid_token', $result->get_error_code()); + } + + public function testCheckTokenAcceptsValidTokenAndRecordsUse(): void + { + $token = $this->tokens->generate(1); + + $result = CloudRestController::checkToken( + $this->request([], ['X-Owlstack-Token' => $token]), + ); + + $this->assertTrue($result); + $this->assertIsInt($this->tokens->info()['last_used_at']); + } + + // ── createPost ─────────────────────────────────────────────────────── + + public function testCreatePostHonorsRequestedStatus(): void + { + $this->tokens->generate(1); + + $response = CloudRestController::createPost($this->request([ + 'title' => 'Hello', + 'content' => '

Body

', + 'status' => 'draft', + ])); + + $this->assertInstanceOf(\WP_REST_Response::class, $response); + $this->assertSame(201, $response->get_status()); + $this->assertSame('draft', $response->get_data()['status']); + } + + public function testCreatePostPolicyOverridesRequestedStatus(): void + { + $this->tokens->generate(1); + $data = $this->tokens->all(); + $data['post_status_policy'] = 'draft'; + $this->tokens->save($data); + + $response = CloudRestController::createPost($this->request([ + 'title' => 'Hello', + 'content' => '

Body

', + 'status' => 'publish', + ])); + + $this->assertSame('draft', $response->get_data()['status']); + } + + public function testCreatePostSanitizesHostileContent(): void + { + $this->tokens->generate(1); + + $response = CloudRestController::createPost($this->request([ + 'title' => 'Hello', + 'content' => '

Safe

', + 'status' => 'publish', + ])); + + $postId = $response->get_data()['id']; + $stored = $GLOBALS['owlstack_test_posts'][$postId]->post_content; + + $this->assertStringContainsString('

Safe

', $stored); + $this->assertStringNotContainsString('