From a2327d05dd86f088b7aa73211b0ec4b415e24edd Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Wed, 6 May 2026 22:48:16 -0500 Subject: [PATCH] docs: add admin hardening implementation plan --- ...-wordpress-content-sync-admin-hardening.md | 849 ++++++++++++++++++ 1 file changed, 849 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-26-wordpress-content-sync-admin-hardening.md diff --git a/docs/superpowers/plans/2026-04-26-wordpress-content-sync-admin-hardening.md b/docs/superpowers/plans/2026-04-26-wordpress-content-sync-admin-hardening.md new file mode 100644 index 0000000..4ed082c --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-wordpress-content-sync-admin-hardening.md @@ -0,0 +1,849 @@ +# Admin Workflow and Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Complete the plugin's usable admin workflow for configuring sync pairs, testing connections, importing/exporting package files, viewing operation results/logs, and running a final smoke checklist with hardened security and user-facing errors. + +**Architecture:** Phase 6 keeps the current import-first sync engine and adds admin workflows around it. Settings stay option-backed, state-changing admin actions use dedicated controllers with capability and nonce checks, and templates receive pre-sanitized view data while still escaping every output at render time. + +**Tech Stack:** PHP 7.4, WordPress admin APIs, WordPress HTTP/REST APIs, PHPUnit, PHPStan, PHPCS/WPCS, existing `SettingsRepository`, `RestTransportClient`, `JsonFileTransport`, `SyncEngine`, and `OptionLogger`. + +--- + +## File Structure + +- `src/Settings/Settings.php`: expand typed settings to include sync pair credentials, URL mappings, selected content types, default direction, and retention/debug controls. +- `src/Admin/AdminNotice.php`: value object for redirect-driven admin notices with `type`, `message`, and optional result context. +- `src/Admin/AdminNoticeRepository.php`: converts `$_GET` query flags into safe notices for templates. +- `src/Admin/SettingsController.php`: handles settings saves through `admin_post_wpcs_save_settings`. +- `src/Admin/ConnectionTestController.php`: handles connection diagnostics through `admin_post_wpcs_test_connection`. +- `src/Admin/FileExportController.php`: exports a valid empty package scaffold for a configured pair until full extraction is implemented. +- `src/Admin/LogController.php`: handles log clearing through `admin_post_wpcs_clear_logs`. +- `src/Admin/AdminPage.php`: registers the new controllers and passes settings/notices/logs to the dashboard template. +- `src/Logging/OptionLogger.php`: add safe read/clear helpers for operation history. +- `src/Transport/RestTransportClient.php`: treat REST `accepted: false` JSON responses as remote rejections. +- `templates/admin/dashboard.php`: replace the placeholder dashboard with forms for settings, diagnostics, file import/export, operation history, and smoke guidance. +- `tests/Unit/Admin/*Test.php`: unit coverage for notices, settings save, connection diagnostics, export, log clearing, and dashboard rendering. +- `tests/Unit/SettingsTest.php`: settings expansion and sanitization coverage. +- `tests/Unit/Transport/RestTransportClientTest.php`: REST rejected-body coverage. +- `docs/smoke/phase-6-admin-hardening.md`: manual smoke checklist and known local environment notes. + +--- + +## Task 1: Expand Settings for Admin Workflow + +**Files:** +- Modify: `src/Settings/Settings.php` +- Modify: `tests/Unit/SettingsTest.php` + +- [ ] **Step 1: Write failing settings tests** + +Add tests to `tests/Unit/SettingsTest.php`: + +```php +public function test_it_sanitizes_full_admin_workflow_settings(): void { + $settings = Settings::fromArray( + array( + 'sync_pairs' => array( + array( + 'name' => 'Production to Staging', + 'source_url' => 'https://example.test/', + 'destination_url' => 'https://staging.example.test/', + 'username' => '', + 'application_password' => 'secret app password', + 'default_direction' => 'push', + 'content_types' => array( 'posts', 'terms', 'media', 'bad_type' ), + 'url_mappings' => array( + array( + 'source' => 'https://example.test', + 'destination' => 'https://staging.example.test', + ), + ), + ), + ), + 'log_retention' => '50', + 'debug_logging' => '1', + ) + ); + + $pairs = $settings->syncPairs(); + + self::assertSame( 'Production to Staging', $pairs[0]['name'] ); + self::assertSame( 'https://example.test/', $pairs[0]['source_url'] ); + self::assertSame( 'https://staging.example.test/', $pairs[0]['destination_url'] ); + self::assertSame( 'codex', $pairs[0]['username'] ); + self::assertSame( 'secret app password', $pairs[0]['application_password'] ); + self::assertSame( 'push', $pairs[0]['default_direction'] ); + self::assertSame( array( 'posts', 'terms', 'media' ), $pairs[0]['content_types'] ); + self::assertSame( + array( + array( + 'source' => 'https://example.test', + 'destination' => 'https://staging.example.test', + ), + ), + $pairs[0]['url_mappings'] + ); + self::assertSame( 50, $settings->logRetention() ); + self::assertTrue( $settings->debugLoggingEnabled() ); +} +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: `composer test -- --filter SettingsTest` + +Expected: FAIL because `logRetention()`, `debugLoggingEnabled()`, and expanded sync pair keys do not exist. + +- [ ] **Step 3: Implement settings expansion** + +Update `Settings` to support: + +```php +private const DIRECTIONS = array( 'push', 'pull' ); +private const CONTENT_TYPES = array( 'posts', 'terms', 'media', 'custom_post_types' ); +private const MIN_LOGS = 10; +private const MAX_LOGS = 1000; + +public function logRetention(): int; +public function debugLoggingEnabled(): bool; +``` + +Update `sanitizeSyncPairs()` so each pair returns: + +```php +array( + 'name' => $name, + 'source_url' => $source_url, + 'destination_url' => $destination_url, + 'username' => $username, + 'application_password' => $application_password, + 'default_direction' => $direction, + 'content_types' => $content_types, + 'url_mappings' => $url_mappings, +) +``` + +Rules: +- Preserve legacy `name`, `source_url`, and `destination_url` behavior. +- Sanitize `username` with `sanitize_text_field()`. +- Sanitize `application_password` with `sanitize_text_field()` and never log it. +- Keep only allowed content types. +- Keep only URL mappings where both source and destination are non-empty URLs. +- Clamp log retention between `10` and `1000`. +- Default direction to `push`. + +- [ ] **Step 4: Run focused tests** + +Run: `composer test -- --filter SettingsTest` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Settings/Settings.php tests/Unit/SettingsTest.php +git commit -m "feat: expand admin sync settings" +``` + +--- + +## Task 2: Admin Notices + +**Files:** +- Create: `src/Admin/AdminNotice.php` +- Create: `src/Admin/AdminNoticeRepository.php` +- Create: `tests/Unit/Admin/AdminNoticeRepositoryTest.php` + +- [ ] **Step 1: Write failing notice tests** + +Create `tests/Unit/Admin/AdminNoticeRepositoryTest.php`: + +```php +current(); + + self::assertSame( 'success', $notices[0]->type() ); + self::assertSame( 'The package JSON file was imported successfully.', $notices[0]->message() ); + } + + public function test_it_sanitizes_error_notices(): void { + $_GET['wpcs_import_error'] = ''; + $notices = ( new AdminNoticeRepository() )->current(); + + self::assertSame( 'error', $notices[0]->type() ); + self::assertSame( 'Bad package', $notices[0]->message() ); + } +} +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: `composer test -- --filter AdminNoticeRepositoryTest` + +Expected: FAIL with missing classes. + +- [ ] **Step 3: Implement notices** + +Create `AdminNotice` with: + +```php +public function __construct( string $type, string $message ); +public function type(): string; +public function message(): string; +``` + +Allowed types: `success`, `warning`, `error`, `info`; fallback to `info`. + +Create `AdminNoticeRepository::current(): array` returning notices for: +- `wpcs_imported=1`: success, imported successfully. +- `wpcs_import_error`: sanitized error message. +- `wpcs_settings_saved=1`: success, settings saved. +- `wpcs_connection_ok=1`: success, REST connection succeeded. +- `wpcs_connection_error`: sanitized error message. +- `wpcs_logs_cleared=1`: success, logs cleared. +- `wpcs_export_error`: sanitized error message. + +- [ ] **Step 4: Run focused tests** + +Run: `composer test -- --filter AdminNoticeRepositoryTest` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Admin/AdminNotice.php src/Admin/AdminNoticeRepository.php tests/Unit/Admin/AdminNoticeRepositoryTest.php +git commit -m "feat: add admin notices" +``` + +--- + +## Task 3: Settings Save Controller + +**Files:** +- Create: `src/Admin/SettingsController.php` +- Modify: `src/Plugin.php` +- Create: `tests/Unit/Admin/SettingsControllerTest.php` +- Modify: `tests/Unit/PluginTest.php` + +- [ ] **Step 1: Write failing controller tests** + +Create `tests/Unit/Admin/SettingsControllerTest.php`: + +```php + 'debug', + 'conflict_strategy' => 'manual_review', + ); + + ( new SettingsController( new SettingsRepository() ) )->handleSave(); + + self::assertSame( 'debug', $GLOBALS['wpcs_test_options'][ SettingsRepository::OPTION_NAME ]['logging_level'] ); + self::assertStringContainsString( 'wpcs_settings_saved=1', $GLOBALS['wpcs_redirect_location'] ); + } +} +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: `composer test -- --filter SettingsControllerTest` + +Expected: FAIL with missing `SettingsController`. + +- [ ] **Step 3: Implement controller** + +Create `SettingsController`: + +```php +final class SettingsController { + private SettingsRepository $settings_repository; + + public function __construct( SettingsRepository $settings_repository ) {} + public function register(): void; + public function handleSave(): void; +} +``` + +Behavior: +- `register()` hooks `admin_post_wpcs_save_settings`. +- `handleSave()` requires `manage_options`. +- Verify nonce `wpcs_save_settings` / `wpcs_settings_nonce`. +- Read `$_POST['wpcs_settings']`, unslash with `wp_unslash()`, require array. +- Persist through `SettingsRepository::save( Settings::fromArray( $data ) )`. +- Redirect to dashboard with `wpcs_settings_saved=1`. + +Update `Plugin::create()` to register `SettingsController`. +Update `Plugin::register()` to call `SettingsController::register()`. +Update `PluginTest` to assert the service exists and the hook is registered. + +- [ ] **Step 4: Run focused tests** + +Run: `composer test -- --filter "SettingsControllerTest|PluginTest"` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Admin/SettingsController.php src/Plugin.php tests/Unit/Admin/SettingsControllerTest.php tests/Unit/PluginTest.php +git commit -m "feat: save admin sync settings" +``` + +--- + +## Task 4: Dashboard Settings Form + +**Files:** +- Modify: `templates/admin/dashboard.php` +- Modify: `src/Admin/AdminPage.php` +- Modify: `tests/Unit/Admin/DashboardTemplateTest.php` + +- [ ] **Step 1: Write failing dashboard tests** + +Add tests: + +```php +public function test_it_renders_settings_form_with_nonce_and_escaped_pair_values(): void { + $settings = Settings::fromArray( + array( + 'sync_pairs' => array( + array( + 'name' => 'Staging', + 'source_url' => 'https://example.test', + 'destination_url' => 'https://staging.example.test', + 'username' => 'codex', + ), + ), + ) + ); + + $output = $this->renderDashboard( $settings ); + + self::assertStringContainsString( 'action="https://example.test/wp-admin/admin-post.php"', $output ); + self::assertStringContainsString( 'name="action" value="wpcs_save_settings"', $output ); + self::assertStringContainsString( 'Staging', $output ); + self::assertStringNotContainsString( 'Staging', $output ); + self::assertStringContainsString( 'name="wpcs_settings[sync_pairs][0][application_password]"', $output ); +} +``` + +Update `renderDashboard()` to accept optional `Settings $settings`. + +- [ ] **Step 2: Run test to verify failure** + +Run: `composer test -- --filter DashboardTemplateTest` + +Expected: FAIL because the dashboard does not render the settings form. + +- [ ] **Step 3: Implement dashboard form** + +Update `dashboard.php`: +- Replace the foundation notice with a "Configuration" section. +- Render a form posting to `admin-post.php`. +- Include `action=wpcs_save_settings`. +- Include nonce `wp_nonce_field( 'wpcs_save_settings', 'wpcs_settings_nonce' )`. +- Render existing sync pairs; if none exist, render one blank pair row. +- Fields per pair: name, source URL, destination URL, username, application password, default direction, content types, URL mappings. +- Never render saved application password values back into the password field; render placeholder text only. +- Escape every attribute with `esc_attr()` and every text node with `esc_html()`. + +- [ ] **Step 4: Run focused tests** + +Run: `composer test -- --filter DashboardTemplateTest` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add templates/admin/dashboard.php src/Admin/AdminPage.php tests/Unit/Admin/DashboardTemplateTest.php +git commit -m "feat: render admin settings workflow" +``` + +--- + +## Task 5: Connection Diagnostics + +**Files:** +- Create: `src/Admin/ConnectionTestController.php` +- Modify: `src/Plugin.php` +- Create: `tests/Unit/Admin/ConnectionTestControllerTest.php` +- Modify: `tests/Unit/Admin/DashboardTemplateTest.php` +- Modify: `templates/admin/dashboard.php` + +- [ ] **Step 1: Write failing diagnostics tests** + +Create `ConnectionTestControllerTest` that injects `RestTransportClient`, posts `pair_index=0`, and verifies: +- capability and nonce are required. +- success redirects with `wpcs_connection_ok=1`. +- failures redirect with `wpcs_connection_error`. +- application password is never placed in redirect query args. + +Use settings fixture: + +```php +update_option( + SettingsRepository::OPTION_NAME, + array( + 'sync_pairs' => array( + array( + 'name' => 'Staging', + 'destination_url' => 'https://destination.test', + 'username' => 'codex', + 'application_password' => 'app-pass', + ), + ), + ), + false +); +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: `composer test -- --filter ConnectionTestControllerTest` + +Expected: FAIL with missing controller. + +- [ ] **Step 3: Implement diagnostics controller** + +Create `ConnectionTestController`: +- Hook `admin_post_wpcs_test_connection`. +- Require `manage_options`. +- Verify nonce `wpcs_test_connection` / `wpcs_connection_nonce`. +- Read `pair_index` as integer. +- Load selected pair from settings. +- Call `RestTransportClient::testConnection( destination_url, username, application_password )`. +- On success redirect with `wpcs_connection_ok=1`. +- On `RestTransportException` redirect with sanitized `wpcs_connection_error`. +- Log success/failure without credentials. + +Update `Plugin` to register the controller. +Update dashboard to add a "Test REST Connection" button per pair with nonce. + +- [ ] **Step 4: Run focused tests** + +Run: `composer test -- --filter "ConnectionTestControllerTest|DashboardTemplateTest|PluginTest"` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Admin/ConnectionTestController.php src/Plugin.php templates/admin/dashboard.php tests/Unit/Admin/ConnectionTestControllerTest.php tests/Unit/Admin/DashboardTemplateTest.php tests/Unit/PluginTest.php +git commit -m "feat: add connection diagnostics" +``` + +--- + +## Task 6: REST Transport Rejected Body Handling + +**Files:** +- Modify: `src/Transport/RestTransportClient.php` +- Modify: `tests/Unit/Transport/RestTransportClientTest.php` + +- [ ] **Step 1: Write failing REST client test** + +Add: + +```php +public function test_it_throws_when_receive_endpoint_returns_accepted_false(): void { + $GLOBALS['wpcs_http_response'] = array( + 'response' => array( 'code' => 200 ), + 'body' => '{"accepted":false,"import":{"errors":["Posts failed."]}}', + ); + $client = new RestTransportClient(); + + $this->expectException( RestTransportException::class ); + $this->expectExceptionMessage( 'Posts failed.' ); + + $client->sendPackage( 'https://destination.test', 'codex', 'app-pass', $this->package() ); +} +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `composer test -- --filter RestTransportClientTest` + +Expected: FAIL because HTTP 200 currently counts as success. + +- [ ] **Step 3: Implement body inspection** + +Update `assertSuccessfulResponse()`: +- If status code is expected, decode body. +- If decoded body has `accepted === false`, throw `RestTransportException::remoteRejected()`. +- Prefer first string in `import.errors`, then first string in `errors`, then `message`, then fallback. + +- [ ] **Step 4: Run focused tests** + +Run: `composer test -- --filter RestTransportClientTest` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Transport/RestTransportClient.php tests/Unit/Transport/RestTransportClientTest.php +git commit -m "fix: reject failed rest imports" +``` + +--- + +## Task 7: Operation Logs and Clear Action + +**Files:** +- Modify: `src/Logging/OptionLogger.php` +- Create: `src/Admin/LogController.php` +- Modify: `src/Plugin.php` +- Modify: `templates/admin/dashboard.php` +- Modify: `tests/Unit/OptionLoggerTest.php` +- Create: `tests/Unit/Admin/LogControllerTest.php` +- Modify: `tests/Unit/Admin/DashboardTemplateTest.php` + +- [ ] **Step 1: Write failing logger/controller tests** + +Add `OptionLoggerTest` coverage for: + +```php +$logger = new OptionLogger( 10 ); +$logger->info( 'Imported content package.' ); +self::assertCount( 1, $logger->entries() ); +$logger->clear(); +self::assertSame( array(), $logger->entries() ); +``` + +Create `LogControllerTest` verifying: +- capability and nonce are required. +- `handleClear()` clears `OptionLogger::OPTION_NAME`. +- redirect contains `wpcs_logs_cleared=1`. + +- [ ] **Step 2: Run tests to verify failure** + +Run: `composer test -- --filter "OptionLoggerTest|LogControllerTest"` + +Expected: FAIL with missing methods/controller. + +- [ ] **Step 3: Implement logs** + +Update `OptionLogger`: + +```php +public function entries(): array; +public function clear(): void; +``` + +Create `LogController`: +- Hook `admin_post_wpcs_clear_logs`. +- Require `manage_options`. +- Verify nonce `wpcs_clear_logs` / `wpcs_logs_nonce`. +- Clear logs. +- Redirect with `wpcs_logs_cleared=1`. + +Update dashboard: +- Render a recent operation history table. +- Show timestamp, level, message, and redacted context summary. +- Add "Clear Logs" form with nonce. + +- [ ] **Step 4: Run focused tests** + +Run: `composer test -- --filter "OptionLoggerTest|LogControllerTest|DashboardTemplateTest|PluginTest"` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Logging/OptionLogger.php src/Admin/LogController.php src/Plugin.php templates/admin/dashboard.php tests/Unit/OptionLoggerTest.php tests/Unit/Admin/LogControllerTest.php tests/Unit/Admin/DashboardTemplateTest.php tests/Unit/PluginTest.php +git commit -m "feat: add operation log controls" +``` + +--- + +## Task 8: File Export Scaffold + +**Files:** +- Create: `src/Admin/FileExportController.php` +- Modify: `src/Plugin.php` +- Modify: `templates/admin/dashboard.php` +- Create: `tests/Unit/Admin/FileExportControllerTest.php` +- Modify: `tests/Unit/Admin/DashboardTemplateTest.php` + +- [ ] **Step 1: Write failing export tests** + +Create `FileExportControllerTest` verifying: +- capability and nonce are required. +- a configured pair exports a valid JSON package with all four record buckets. +- response headers include `Content-Type: application/json`. +- no content mutation occurs during export. + +- [ ] **Step 2: Run tests to verify failure** + +Run: `composer test -- --filter FileExportControllerTest` + +Expected: FAIL with missing controller. + +- [ ] **Step 3: Implement export scaffold** + +Create `FileExportController`: +- Hook `admin_post_wpcs_export_package`. +- Require `manage_options`. +- Verify nonce `wpcs_export_package` / `wpcs_export_nonce`. +- Read `pair_index`. +- Build an empty `ContentPackage` using the selected pair: + - `source.site_url` from pair source URL. + - `destination.site_url` from pair destination URL. + - manifest counts all zero. + - records buckets all empty. + - checksum from `PackageChecksum::records()`. +- Export with `JsonFileTransport::export()`. +- Send JSON download headers. + +Update dashboard with an "Export Empty Package" form and helper copy stating full extraction will be added in a later slice. + +- [ ] **Step 4: Run focused tests** + +Run: `composer test -- --filter "FileExportControllerTest|DashboardTemplateTest|PluginTest"` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Admin/FileExportController.php src/Plugin.php templates/admin/dashboard.php tests/Unit/Admin/FileExportControllerTest.php tests/Unit/Admin/DashboardTemplateTest.php tests/Unit/PluginTest.php +git commit -m "feat: add package export scaffold" +``` + +--- + +## Task 9: Import Result UI Hardening + +**Files:** +- Modify: `src/Admin/FileImportController.php` +- Modify: `templates/admin/dashboard.php` +- Modify: `tests/Unit/Admin/FileImportControllerTest.php` +- Modify: `tests/Unit/Admin/DashboardTemplateTest.php` + +- [ ] **Step 1: Write failing result tests** + +Add file import test asserting success redirects include counts: + +```php +self::assertStringContainsString( 'wpcs_created=0', $GLOBALS['wpcs_redirect_location'] ); +self::assertStringContainsString( 'wpcs_updated=0', $GLOBALS['wpcs_redirect_location'] ); +self::assertStringContainsString( 'wpcs_skipped=0', $GLOBALS['wpcs_redirect_location'] ); +self::assertStringContainsString( 'wpcs_conflicts=0', $GLOBALS['wpcs_redirect_location'] ); +``` + +Add dashboard test asserting the success notice includes created/updated/skipped/conflict counts from sanitized query args. + +- [ ] **Step 2: Run tests to verify failure** + +Run: `composer test -- --filter "FileImportControllerTest|DashboardTemplateTest"` + +Expected: FAIL because result counts are not in redirects or UI. + +- [ ] **Step 3: Implement result count redirects** + +Update `FileImportController` success redirect: + +```php +array( + 'wpcs_imported' => '1', + 'wpcs_created' => (string) $result->created(), + 'wpcs_updated' => (string) $result->updated(), + 'wpcs_skipped' => (string) $result->skipped(), + 'wpcs_conflicts' => (string) $result->conflicts(), +) +``` + +Update dashboard to display counts with `absint()` and escaped labels. + +- [ ] **Step 4: Run focused tests** + +Run: `composer test -- --filter "FileImportControllerTest|DashboardTemplateTest"` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Admin/FileImportController.php templates/admin/dashboard.php tests/Unit/Admin/FileImportControllerTest.php tests/Unit/Admin/DashboardTemplateTest.php +git commit -m "feat: show import result summaries" +``` + +--- + +## Task 10: Smoke Checklist Documentation + +**Files:** +- Create: `docs/smoke/phase-6-admin-hardening.md` + +- [ ] **Step 1: Create smoke checklist** + +Create `docs/smoke/phase-6-admin-hardening.md` with: + +```markdown +# Phase 6 Admin Hardening Smoke Checklist + +## Environment + +- WordPress site URL: +- Plugin branch/commit: +- PHP version: +- WordPress version: + +## Checks + +- [ ] Plugin activates without fatal errors. +- [ ] Tools -> Content Sync loads for an administrator. +- [ ] Non-administrators cannot access the dashboard. +- [ ] Settings save rejects missing/invalid nonce. +- [ ] Settings save persists sync pair name, URLs, username, content types, direction, and URL mappings. +- [ ] Saved application password is not rendered back into the password field. +- [ ] REST status endpoint rejects unauthenticated HTTP requests. +- [ ] REST status endpoint accepts authenticated requests when the server passes `Authorization` to PHP. +- [ ] Connection test succeeds with a valid application password. +- [ ] Connection test shows an actionable error for invalid credentials. +- [ ] Invalid package file import redirects with an error notice. +- [ ] Valid empty package import redirects with success and result counts. +- [ ] REST package POST accepts a valid package and includes import counts. +- [ ] REST package POST rejects invalid package data. +- [ ] Operation log table shows recent events with redacted credential-like fields. +- [ ] Clear logs requires nonce and removes log entries. +- [ ] Export scaffold downloads valid JSON. + +## Local Notes + +- On Herd/nginx, direct HTTP Basic auth may require forwarding the `Authorization` header. If HTTP application-password smoke returns 401 while internal REST dispatch passes, verify server auth header configuration before treating it as a plugin failure. +``` + +- [ ] **Step 2: Commit docs** + +```bash +git add docs/smoke/phase-6-admin-hardening.md +git commit -m "docs: add admin hardening smoke checklist" +``` + +--- + +## Task 11: Full Phase 6 Verification + +**Files:** +- Verify all files changed in Tasks 1-10. + +- [ ] **Step 1: Run Composer validation** + +Run: `composer validate --strict` + +Expected: PASS with `./composer.json is valid`. + +- [ ] **Step 2: Run PHPCS** + +Run: `composer lint` + +Expected: PASS with no PHPCS errors or warnings. + +- [ ] **Step 3: Run PHPStan** + +Run: `vendor\bin\phpstan analyse --memory-limit=1G` + +Expected: PASS with `[OK] No errors`. + +- [ ] **Step 4: Run PHPUnit** + +Run: `composer test` + +Expected: PASS with all unit tests passing. + +- [ ] **Step 5: Copy runtime files to Herd test plugin** + +Run: + +```powershell +Copy-Item -Path src,templates,wp-content-sync.php,composer.json -Destination 'C:\Users\ksolo\Herd\basic-wp\wp-content\plugins\WP-Content-Sync' -Recurse -Force +``` + +Expected: command exits 0. + +- [ ] **Step 6: Run manual smoke checklist** + +Use `docs/smoke/phase-6-admin-hardening.md`. + +Required checks before completion: +- Plugin active. +- Admin dashboard loads after login. +- Settings save works and rejects invalid nonce. +- Connection diagnostics show success/failure notices. +- Invalid file import redirects with error. +- Valid empty file import redirects with success counts. +- REST unauthenticated status returns 401. +- REST valid and invalid package behavior is verified by HTTP application password if server passes Basic auth, otherwise by internal REST dispatch plus documented server caveat. +- Logs render and clear. +- Export scaffold downloads valid JSON. + +- [ ] **Step 7: Commit verification notes if changed** + +If smoke notes are updated with environment results: + +```bash +git add docs/smoke/phase-6-admin-hardening.md +git commit -m "docs: record phase 6 smoke results" +``` + +--- + +## Spec Coverage + +- Sync pair configuration is covered by Tasks 1, 3, and 4. +- Credentials and authentication setup are covered by Tasks 1, 4, and 5, with password fields not rendered back to the browser. +- URL mapping configuration is covered by Task 1 and Task 4. +- Content type selection and default sync direction are covered by Task 1 and Task 4. +- Connection diagnostics are covered by Task 5. +- Import screens and user-facing import errors are covered by Tasks 2, 4, and 9. +- Export screen is covered by Task 8 as a valid package scaffold until full extraction is implemented. +- Operation history and debug/log controls are covered by Task 7. +- REST failure hardening is covered by Task 6. +- Nonces/capabilities for state-changing admin actions are covered by Tasks 3, 5, 7, 8, and existing file import tests. +- Final smoke/integration checklist is covered by Tasks 10 and 11. + +## Deferred Work + +- Full content extraction for non-empty package exports remains a later slice because Phase 5 built import orchestration and handlers, not source extraction orchestration. +- Background queues, progress polling, and cancelable long-running operations remain a later scalability slice. +- HTTP Basic authentication pass-through is a server configuration concern; plugin REST behavior remains covered by controller tests and internal REST dispatch smoke. + +## Placeholder Scan + +- No unresolved placeholder markers are intentionally included. +- Each task names exact files, expected failing tests, implementation behavior, verification commands, and commit messages. +- Deferred work is explicitly scoped with rationale and is not required for Phase 6 exit criteria in this implementation slice.