Skip to content

Commit eab720d

Browse files
authored
Fix/extend error checks (#347)
* Treat non-200 link checker responses as service offline When the Internet Archive link checker endpoint returns a non-200, treat it as the service being offline: leave the link untouched (do not mark it broken) and reschedule, rather than throwing or recording a verdict. - Find_Or_Create_Snapshot_Event: catch Service_Offline_Exception around check_single and reschedule like the existing offline guard. - Unit tests for the event (200 baseline + 403/404/500/503 do nothing). - Unit test for the REST endpoint (non-200 returns the error, link not marked broken). - E2E spec + self-contained mapped mu-plugin covering 200/403/404/500/502/503. * Trim non-200 comment to a brief note on the catch * Tidy comments: brief one-liners, restore existing status comment
1 parent c02e5e8 commit eab720d

6 files changed

Lines changed: 406 additions & 1 deletion

File tree

.wp-env.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
"core": null,
33
"phpVersion": "8.3",
44
"plugins": [ "." ],
5+
"mappings": {
6+
"wp-content/mu-plugins/iawmlf-e2e-non200.php": "./e2e/mu-plugins/iawmlf-e2e-non200.php"
7+
},
58
"port": 57893,
69
"testsPort": 57894,
710
"config": {
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<?php
2+
/**
3+
* E2E helper for: "a non-200 from the Internet Archive link checker must not
4+
* mark a link as broken". Mapped into wp-content/mu-plugins via .wp-env.json.
5+
*
6+
* Self-contained:
7+
* - Forces the live web check endpoint (…/livewebcheck) to return whatever
8+
* HTTP status the `iawmlf_e2e_force_status` option holds (0 = pass through).
9+
* - `wp iawmlf-e2e-non200 seed <code>` seeds a post + a not-broken, never
10+
* checked link, sets the forced status to <code> (default 502), and prints
11+
* KEY=VALUE lines.
12+
* - `wp iawmlf-e2e-non200 cleanup` removes that post + link and the forced status.
13+
*
14+
* @package Internet_Archive\Wayback_Machine_Link_Fixer\E2E
15+
*/
16+
17+
defined( 'ABSPATH' ) || exit;
18+
19+
use Internet_Archive\Wayback_Machine_Link_Fixer\Settings\Settings;
20+
21+
const IAWMLF_E2E_LINK_URL = 'https://example.com/iawmlf-e2e-non200';
22+
const IAWMLF_E2E_POST_SLUG = 'iawmlf-e2e-non200';
23+
const IAWMLF_E2E_OPTION = 'iawmlf_e2e_force_status';
24+
25+
// Force the live web check endpoint to the configured status code.
26+
add_filter(
27+
'pre_http_request',
28+
function ( $pre, $args, $url ) {
29+
$code = (int) get_option( IAWMLF_E2E_OPTION, 0 );
30+
31+
if ( 0 === $code || ! is_string( $url ) || false === strpos( $url, 'livewebcheck' ) ) {
32+
return $pre;
33+
}
34+
35+
return array(
36+
'headers' => array(),
37+
// 200 needs a valid body; any non-200 makes the checker treat it as offline.
38+
'body' => 200 === $code ? wp_json_encode( array( 'status' => 200 ) ) : '',
39+
'response' => array(
40+
'code' => $code,
41+
'message' => '',
42+
),
43+
);
44+
},
45+
10,
46+
3
47+
);
48+
49+
if ( ! ( defined( 'WP_CLI' ) && WP_CLI ) ) {
50+
return;
51+
}
52+
53+
/**
54+
* Delete the seeded post + link and clear the forced status (idempotent).
55+
*/
56+
function iawmlf_e2e_non200_cleanup() {
57+
global $wpdb;
58+
59+
delete_option( IAWMLF_E2E_OPTION );
60+
$wpdb->delete( Settings::get_link_table_name(), array( 'url' => IAWMLF_E2E_LINK_URL ), array( '%s' ) );
61+
62+
foreach ( get_posts(
63+
array(
64+
'name' => IAWMLF_E2E_POST_SLUG,
65+
'post_type' => 'post',
66+
'post_status' => 'any',
67+
'numberposts' => 1,
68+
)
69+
) as $p ) {
70+
wp_delete_post( $p->ID, true );
71+
}
72+
}
73+
74+
WP_CLI::add_command(
75+
'iawmlf-e2e-non200 seed',
76+
function ( $args ) {
77+
global $wpdb;
78+
79+
$code = isset( $args[0] ) ? (int) $args[0] : 502;
80+
81+
// "replace_link" mode so the front-end enqueues and would rewrite broken links.
82+
update_option( Settings::FIXER_OPTION, Settings::FIXER_OPTION_REPLACE_LINK );
83+
update_option( Settings::LINK_EXCLUSIONS, array() );
84+
85+
iawmlf_e2e_non200_cleanup();
86+
update_option( IAWMLF_E2E_OPTION, $code );
87+
88+
// Not broken and never checked, so the front-end performs a live check.
89+
$wpdb->insert(
90+
Settings::get_link_table_name(),
91+
array(
92+
'url' => IAWMLF_E2E_LINK_URL,
93+
'archived' => 'https://web.archive.org/web/2024/' . IAWMLF_E2E_LINK_URL,
94+
'checks' => wp_json_encode( array() ),
95+
'message' => '',
96+
'redirect_url' => '',
97+
'is_broken' => 0,
98+
'excluded' => 0,
99+
'archive_process' => 'done',
100+
),
101+
array( '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s' )
102+
);
103+
$link_id = (int) $wpdb->insert_id;
104+
105+
$post_id = wp_insert_post(
106+
array(
107+
'post_title' => 'IAWMLF E2E Non-200 Link Check',
108+
'post_name' => IAWMLF_E2E_POST_SLUG,
109+
'post_status' => 'publish',
110+
'post_type' => 'post',
111+
'post_content' => sprintf( '<p>Check: <a href="%s">checked link</a></p>', esc_url( IAWMLF_E2E_LINK_URL ) ),
112+
)
113+
);
114+
115+
update_post_meta( $post_id, Settings::LINK_META_KEY, array( $link_id ) );
116+
117+
echo 'POST_URL=' . get_permalink( $post_id ) . "\n";
118+
echo 'LINK_URL=' . IAWMLF_E2E_LINK_URL . "\n";
119+
}
120+
);
121+
122+
WP_CLI::add_command(
123+
'iawmlf-e2e-non200 cleanup',
124+
function () {
125+
iawmlf_e2e_non200_cleanup();
126+
echo "CLEANED=1\n";
127+
}
128+
);
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
const { test, expect } = require( '@playwright/test' );
2+
const { execSync } = require( 'child_process' );
3+
const path = require( 'path' );
4+
5+
/**
6+
* Regression: whatever status the link checker returns, a working link must not
7+
* be marked broken on the front end. Forces each status via the e2e mu-plugin.
8+
*/
9+
10+
const PLUGIN_DIR = 'wp-content/plugins/internet-archive-wayback-machine-link-fixer';
11+
12+
// 200 baseline + the non-2xx codes seen during the DDoS outage.
13+
const CODES = [ 200, 403, 404, 500, 502, 503 ];
14+
15+
function wpCli( cmd ) {
16+
return execSync(
17+
`npx wp-env run cli --env-cwd='${ PLUGIN_DIR }' -- wp ${ cmd }`,
18+
{ cwd: path.join( __dirname, '../..' ), encoding: 'utf8' }
19+
);
20+
}
21+
22+
function seed( code ) {
23+
const output = wpCli( `iawmlf-e2e-non200 seed ${ code }` );
24+
const parse = ( key ) => {
25+
const m = output.match( new RegExp( `^${ key }=(.+)$`, 'm' ) );
26+
if ( ! m ) {
27+
throw new Error( `Seeder did not print ${ key }. Output was:\n${ output }` );
28+
}
29+
return m[ 1 ].trim();
30+
};
31+
return { postUrl: parse( 'POST_URL' ), linkUrl: parse( 'LINK_URL' ) };
32+
}
33+
34+
test.describe( 'link checker status codes', () => {
35+
test.afterEach( () => {
36+
wpCli( 'iawmlf-e2e-non200 cleanup' );
37+
} );
38+
39+
for ( const code of CODES ) {
40+
test( `link checker returning ${ code } does not mark the link broken`, async ( { page } ) => {
41+
const seeded = seed( code );
42+
43+
const checkResponse = page.waitForResponse( ( response ) =>
44+
response.url().includes( 'iawmlf/v1/link-check' )
45+
);
46+
47+
await page.goto( seeded.postUrl );
48+
49+
const checkedLink = page.locator( 'a', { hasText: 'checked link' } );
50+
await expect( checkedLink ).toBeVisible();
51+
52+
// 200 is a real verdict; any non-200 makes the route return a 500.
53+
const response = await checkResponse;
54+
expect( response.status() ).toBe( 200 === code ? 200 : 500 );
55+
56+
// The checker processed the link (proves the JS ran)...
57+
await expect( checkedLink ).toHaveAttribute( 'data-iawmlf-archived-url', /.+/ );
58+
59+
// ...but the link must never be marked broken or have its href rewritten.
60+
await expect( checkedLink ).not.toHaveClass( /iawmlf-broken-link/ );
61+
await expect( checkedLink ).toHaveAttribute( 'href', seeded.linkUrl );
62+
} );
63+
}
64+
} );

src/Event/Find_Or_Create_Snapshot_Event.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Exception;
1515
use Internet_Archive\Wayback_Machine_Link_Fixer\Link\Link_Repository;
1616
use Internet_Archive\Wayback_Machine_Link_Fixer\Wayback_Machine\Wayback_Machine_Service;
17+
use Internet_Archive\Wayback_Machine_Link_Fixer\Wayback_Machine\Exception\Service_Offline_Exception;
1718

1819
defined( 'ABSPATH' ) || exit;
1920

@@ -133,7 +134,13 @@ public function __invoke( int $link_id ): void {
133134
$link->set_archived_href( $snapshot['url'] );
134135

135136
// Get the current link status.
136-
$status = $this->wayback_machine->check_single( $link->get_href() );
137+
try {
138+
$status = $this->wayback_machine->check_single( $link->get_href() );
139+
} catch ( Service_Offline_Exception $e ) {
140+
// Non-200 from the link checker: treat as offline, reschedule.
141+
self::add_to_queue_with_delay( $link_id, 1 * \HOUR_IN_SECONDS );
142+
return;
143+
}
137144

138145
// Add to the link.
139146
$link->add_check( $status );

tests/Event/Test_Find_Or_Create_Snapshot.php

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,160 @@ public function set_up(): void {
3535
parent::set_up();
3636
}
3737

38+
/**
39+
* Tear down.
40+
*/
41+
public function tear_down(): void {
42+
remove_all_filters( 'pre_http_request' );
43+
delete_transient( 'iawmlf_archive_api_online' );
44+
parent::tear_down();
45+
}
46+
47+
/**
48+
* Mocks the event's archive.org calls; livewebcheck returns $link_check_code.
49+
*/
50+
private function mock_archive_http( int $link_check_code ): void {
51+
$snapshot_body = json_encode(
52+
array(
53+
'url' => 'http://example.com',
54+
'archived_snapshots' => array(
55+
'closest' => array(
56+
'available' => true,
57+
'status' => 200,
58+
'url' => 'http://web.archive.org/web/20240101000000/http://example.com',
59+
'timestamp' => '20240101000000',
60+
),
61+
),
62+
)
63+
);
64+
65+
add_filter(
66+
'pre_http_request',
67+
function ( $pre, $args, $url ) use ( $link_check_code, $snapshot_body ) {
68+
// The system status endpoint (is_online).
69+
if ( false !== strpos( $url, 'save/status/system' ) ) {
70+
return array(
71+
'response' => array( 'code' => 200 ),
72+
'body' => json_encode( array( 'status' => 'ok' ) ),
73+
);
74+
}
75+
76+
// The find-snapshot endpoint (get_latest_snapshot).
77+
if ( false !== strpos( $url, 'wayback/available' ) ) {
78+
return array(
79+
'response' => array( 'code' => 200 ),
80+
'body' => $snapshot_body,
81+
);
82+
}
83+
84+
// The link checker endpoint (check_single) - the one under test.
85+
if ( false !== strpos( $url, 'livewebcheck' ) ) {
86+
return array(
87+
'response' => array( 'code' => $link_check_code ),
88+
'body' => json_encode( array( 'status' => 200 ) ),
89+
);
90+
}
91+
92+
// Anything else, a benign 200.
93+
return array(
94+
'response' => array( 'code' => 200 ),
95+
'body' => '{}',
96+
);
97+
},
98+
10,
99+
3
100+
);
101+
}
102+
103+
/**
104+
* @testdox BASELINE: when the link checker returns a 200, the link is checked and marked done.
105+
*
106+
* @return void
107+
*/
108+
public function test_200_from_link_checker_processes_link(): void {
109+
$this->mock_archive_http( 200 );
110+
delete_transient( 'iawmlf_archive_api_online' );
111+
112+
$repo = new Link_Repository();
113+
$link = $repo->upsert( new Link( 'https://example.com' ) );
114+
115+
$event = new Find_Or_Create_Snapshot_Event();
116+
$event->setup();
117+
$event( $link->get_id() );
118+
119+
$link = $repo->find_by_id( $link->get_id() );
120+
121+
// A successful check means the link is processed/done.
122+
$this->assertEquals( Link::PROCESS_DONE, $link->get_archive_process() );
123+
}
124+
125+
/**
126+
* @testdox When a link-check HTTP request comes back as a non-200, the event must do nothing - the link is left untouched.
127+
*
128+
* @dataProvider non_success_status_code_provider
129+
*
130+
* @param int $code The HTTP status code the livewebcheck endpoint returns.
131+
*
132+
* @return void
133+
*/
134+
public function test_non_2xx_from_link_checker_does_nothing( int $code ): void {
135+
$this->mock_archive_http( $code );
136+
delete_transient( 'iawmlf_archive_api_online' );
137+
138+
$repo = new Link_Repository();
139+
$link = $repo->upsert( new Link( 'https://example.com' ) );
140+
$id = $link->get_id();
141+
142+
// Capture the state before the event runs.
143+
$process_before = $link->get_archive_process();
144+
145+
$event = new Find_Or_Create_Snapshot_Event();
146+
$event->setup();
147+
148+
try {
149+
$event( $id );
150+
} catch ( \Throwable $e ) {
151+
// "Do nothing" means it must not bubble an error to the scheduler either.
152+
$this->fail( "A {$code} link-check response must be handled silently, but the event threw: " . $e->getMessage() );
153+
}
154+
155+
$link = $repo->find_by_id( $id );
156+
157+
// The links process state must be unchanged.
158+
$this->assertSame(
159+
$process_before,
160+
$link->get_archive_process(),
161+
"A {$code} link-check response must not change the link's process state."
162+
);
163+
164+
// The link must not have been marked done.
165+
$this->assertNotEquals(
166+
Link::PROCESS_DONE,
167+
$link->get_archive_process(),
168+
"A {$code} link-check response must not mark the link as done."
169+
);
170+
171+
// The link must not have had an archived url applied.
172+
$this->assertEmpty(
173+
$link->get_archived_href(),
174+
"A {$code} link-check response must not set an archived url on the link."
175+
);
176+
}
177+
178+
/**
179+
* Non-2xx (offline) HTTP status codes seen during the DDoS outage.
180+
*
181+
* @return array<string, array{0:int}>
182+
*/
183+
public static function non_success_status_code_provider(): array {
184+
return array(
185+
'503 service unavailable' => array( 503 ),
186+
'404 not found' => array( 404 ),
187+
'403 forbidden' => array( 403 ),
188+
'500 generic non-2xx' => array( 500 ),
189+
);
190+
}
191+
38192
/**
39193
* @testdox When a link is added to the queue and its url is already an archive.org url (HTTPS), it should not be added to the queue and a message added to the link object.
40194
*

0 commit comments

Comments
 (0)