add file transport implementation #2
@@ -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' => '<b>Production to Staging</b>',
|
||||
'source_url' => 'https://example.test/',
|
||||
'destination_url' => 'https://staging.example.test/',
|
||||
'username' => '<script>codex</script>',
|
||||
'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
|
||||
<?php
|
||||
namespace WPContentSync\Tests\Unit\Admin;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use WPContentSync\Admin\AdminNoticeRepository;
|
||||
|
||||
class AdminNoticeRepositoryTest extends TestCase {
|
||||
protected function tearDown(): void {
|
||||
$_GET = array();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_it_builds_import_success_notices(): void {
|
||||
$_GET['wpcs_imported'] = '1';
|
||||
$notices = ( new AdminNoticeRepository() )->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'] = '<script>Bad package</script>';
|
||||
$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
|
||||
<?php
|
||||
namespace WPContentSync\Tests\Unit\Admin;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use WPContentSync\Admin\SettingsController;
|
||||
use WPContentSync\Settings\SettingsRepository;
|
||||
|
||||
class SettingsControllerTest extends TestCase {
|
||||
protected function tearDown(): void {
|
||||
unset( $GLOBALS['wpcs_current_user_can'], $GLOBALS['wpcs_nonce_valid'], $GLOBALS['wpcs_redirect_location'], $GLOBALS['wpcs_test_options'] );
|
||||
$_POST = array();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_it_saves_settings_with_nonce_and_capability(): void {
|
||||
$GLOBALS['wpcs_current_user_can']['manage_options'] = true;
|
||||
$GLOBALS['wpcs_nonce_valid']['wpcs_save_settings']['wpcs_settings_nonce'] = true;
|
||||
$_POST['wpcs_settings'] = array(
|
||||
'logging_level' => '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' => '<b>Staging</b>',
|
||||
'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( '<b>Staging</b>', $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.
|
||||
Reference in New Issue
Block a user