Skip to content

Commit cb343b1

Browse files
rpgmemclaude
andauthored
fix(templates): only record a seed run that actually seeded (#865) (#1026)
CertTemplateSeeder::maybe_seed() wrote the seed-version flag unconditionally, including when the run created nothing. The whole path fails silently: Utils::read_file_contents() returns '' for a file it cannot read, seed() then continues past that definition without a word, and the flag short-circuits every later run — so a first seed that read nothing left the pool permanently empty and never retried. That is not cosmetic. An empty pool is exactly the condition under which AdminAssetsManager::discover_layout_templates() falls through to the deprecated legacy html/ glob, so the fallback's stated exit condition ("removed once the pool seeds on every install") could not be met while this hole existed. The flag is now written only once pool_has_defaults() confirms the pool holds a shipped default; otherwise the run leaves it alone and retries on the next admin request, with an off-by-default Debug::log_admin breadcrumb. The guard is deliberately narrow — "not empty", not "everything seeded". A partial seed still populates the picker and keeps the fallback dormant, and gating on completeness would re-run restore()'s meta writes on every admin request for as long as one file stayed unreadable. Also registers the html/ fallback in the CLAUDE.md shim inventory with its exit condition: evidence-gated, not a versioned deprecation cycle, and split across two releases so a repaired install is observed before losing the net. Claude-Session: https://claude.ai/code/session_01D1wR59A8Z7d3QGYnm8q2KQ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 025e1a0 commit cb343b1

4 files changed

Lines changed: 202 additions & 5 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ The format follows [Keep a Changelog] (https://keepachangelog.com/en/1.1.0/).
88
## [Unreleased]
99

1010
### Fixed
11+
- **A failed first seed left the certificate-template pool permanently empty** (#865): `CertTemplateSeeder::maybe_seed()` recorded the seed version even when it created nothing — an unreadable seed file is skipped silently — and the flag then short-circuits every later run. The install kept an empty pool and the form editor's layout picker was served by the deprecated legacy `html/` fallback instead. The version is now recorded only once a default is actually in the pool, so a failed seed retries on the next admin request.
1112
- **The activity log was mostly untranslatable** (#1024): `get_action_label()` fell back to `ucwords()` for any action it did not know, silently rendering untranslated English — 49 of the 64 action keys in use, including every recruitment, privacy and migration event, across the log table, the summary and the CSV export. All 49 now have translated labels, with a guard so a new action cannot rely on the fallback again.
1213
- **Linking a submission to a user logged the wrong action** (#1024): the call passed the action name where the level belongs, so every link and unlink recorded the generic `submission` while `user_linked`/`user_unlinked` landed in the level slot and was discarded by the level validation. Existing rows keep a label; new ones record the real action.
1314
- **The update screen said the plugin was untested on your WordPress** (#1022): `GitHubUpdater` hard-coded the compatibility fields WordPress actually reads, so raising `Tested up to` in `readme.txt` (#984) left the shipped value at 7.0 and every 7.1 site was told a verified release was untested; `Requires at least` had drifted the same way (6.2 vs 6.4). The values are now read from the plugin header and `readme.txt` at update-check time, removing the copy that drifted.

‎CLAUDE.md‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -480,10 +480,23 @@ A full security audit confirmed these hold plugin-wide — keep them that way (t
480480

481481
Inventory of the legacy compatibility shims that remain in the code by design (snapshot — re-confirm the location in code before removing; paths and lines change with every refactor, so the table cites files/methods, never line numbers). Removing them requires evidence that no production installation depends on them.
482482

483-
_The two shims previously tracked here — the `ensure_legacy_caps_renamed()` v1 pre-6.2.0 cap-rename migration and the pre-4.6.15 orphan-cron cleanup (activator + deactivator/uninstall) — were removed in 6.18.0 (#809) as scheduled; the inventory is currently empty._
483+
_The two shims previously tracked here — the `ensure_legacy_caps_renamed()` v1 pre-6.2.0 cap-rename migration and the pre-4.6.15 orphan-cron cleanup (activator + deactivator/uninstall) — were removed in 6.18.0 (#809) as scheduled._
484+
485+
| Shim | Location | Risk if removed | Why it stays |
486+
| --- | --- | --- | --- |
487+
| **Legacy `html/` layout fallback** (#865 phase-4) | `AdminAssetsManager::discover_layout_templates()` → `discover_layout_templates_legacy_glob()` (the `html/*.html` glob when the pool is empty), and `FormEditor`'s by-filename load of `FFC_PLUGIN_DIR . 'html/'` for the `filename` param those entries post. Plus the `html/default_certificate_{1,2,3}.html` files that `.distignore` still ships. | **Medium.** An install whose template pool is empty loses the form editor's layout picker entirely — no defaults to choose, and the by-filename load path that the picker's own entries depend on disappears with it. | The pool is not yet guaranteed non-empty on every install. Until 6.22.0 a first seed that read nothing recorded itself as applied and never retried, permanently emptying the pool (fixed by `CertTemplateSeeder::pool_has_defaults()`); installs that hit it before the fix are repaired by the retry, but only after they take the update. |
484488

485489
When a new shim is added, log it here (Shim · Location · Risk if removed · Why it stays), and when a new feature makes one unsafe or inadequate, open a specific sub-issue + a breaking-change banner in the CHANGELOG.
486490

491+
#### Exit condition for the `html/` fallback — evidence, not a deprecation cycle
492+
493+
Retiring it is **evidence-gated** (the `cpf_rf_encrypted` shape below), *not* a versioned deprecation cycle. The cycle exists for surfaces whose consumers a code scan cannot see — a public method an external integration might call. Both sites here are internal render paths with no hook, no filter and no external caller, so nothing invisible can depend on them; what they depend on is **install state**, which is observable. Two conditions, and note they are genuinely different — conflating them is the mistake that nearly retired this shim early:
494+
495+
1. **The pool seeds on every install** — the fallback's own written condition, and the one that was *not* met before 6.22.0. The seeder fix is what makes it hold; verify on a real install that the layout picker is served from the pool.
496+
2. **Settings → Migrations → `import_legacy_templates` reads 0 pending** — every file an admin dropped into `html/` has been imported. This one has read 0 in production for some time, but it measures *imports*, not seeding, and on its own says nothing about condition 1.
497+
498+
Ship the seeder fix and the removal in **different releases** (6.22.0 → 6.23.0): an install with an empty pool must receive the repair, and be observed to have taken it, before losing the safety net. The removal is ⚠ breaking for anyone still relying on a file in `html/`, so it carries a CHANGELOG banner.
499+
487500
#### Gathering the evidence to remove a **High**-risk shim
488501

489502
Not every High shim is provable by data. Before proposing to build a diagnostic, check whether the evidence already exists — the resolved `cpf_rf_encrypted` case below is the exemplar:

‎includes/admin/class-ffc-cert-template-seeder.php‎

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
* hidden/edited defaults) are never clobbered.
1111
*
1212
* Runs once per seed version, guarded by the `ffc_cert_templates_seeded_version`
13-
* option; the option is bumped when new shipped defaults are added.
13+
* option; the option is bumped when new shipped defaults are added — but only
14+
* once the run has actually left a default in the pool, so a seed that read
15+
* nothing retries instead of recording itself as done (see
16+
* {@see self::pool_has_defaults()}).
1417
*
1518
* @package FreeFormCertificate\Admin
1619
* @since 6.18.0
@@ -78,6 +81,10 @@ class CertTemplateSeeder {
7881
/**
7982
* Run the seeder once per seed version.
8083
*
84+
* The flag is written only when the pool actually ends up holding a shipped
85+
* default — see {@see self::pool_has_defaults()} for why an unconditional
86+
* write is a trap.
87+
*
8188
* @return void
8289
*/
8390
public static function maybe_seed(): void {
@@ -99,9 +106,55 @@ public static function maybe_seed(): void {
99106
self::seed();
100107
}
101108

109+
if ( ! self::pool_has_defaults() ) {
110+
\FreeFormCertificate\Core\Debug::log_admin(
111+
'CertTemplateSeeder: seed run produced no default template; leaving the seed flag unwritten so the next admin request retries.',
112+
array(
113+
'seed_version' => self::SEED_VERSION,
114+
'applied' => $applied,
115+
'seed_dir' => FFC_PLUGIN_DIR . self::SEED_DIR,
116+
)
117+
);
118+
return;
119+
}
120+
102121
update_option( self::SEED_FLAG, self::SEED_VERSION );
103122
}
104123

124+
/**
125+
* Whether the pool holds at least one shipped default after a seed run.
126+
*
127+
* This is the guard that keeps the seeder honest, and it exists because the
128+
* whole path fails silently: {@see \FreeFormCertificate\Core\Utils::read_file_contents()}
129+
* returns '' for a file it cannot read, {@see self::seed()} then `continue`s
130+
* past that definition without a word, and an unconditional
131+
* `update_option( SEED_FLAG, … )` would record the run as applied. On the
132+
* next request `maybe_seed()` returns early, so a first seed that read
133+
* nothing — a deploy that landed mid-rsync, a permissions problem, a
134+
* packaging slip that dropped the seed files — leaves the pool permanently
135+
* empty and never retries.
136+
*
137+
* That is not a cosmetic failure: an empty pool is exactly the condition
138+
* under which
139+
* {@see \FreeFormCertificate\Admin\AdminAssetsManager::discover_layout_templates()}
140+
* falls back to the deprecated legacy `html/` glob, so the fallback's stated
141+
* exit condition — "removed once the pool seeds on every install" — could
142+
* not be met while this hole existed (#865 phase-4).
143+
*
144+
* Deliberately narrow: it asks whether the pool is *empty*, not whether every
145+
* definition seeded. A partial seed still populates the picker and keeps the
146+
* fallback dormant, so recording it as applied is correct — and blocking the
147+
* flag on completeness would re-run restore()'s meta writes on every single
148+
* admin request for as long as one file stayed unreadable. Visibility is not
149+
* consulted either: hiding a default is the admin's choice, not a seed
150+
* failure.
151+
*
152+
* @return bool
153+
*/
154+
private static function pool_has_defaults(): bool {
155+
return array() !== self::existing_default_map();
156+
}
157+
105158
/**
106159
* Create any missing default templates (non-destructive). Also the target
107160
* of a future "Restore defaults" action.

‎tests/Unit/CertTemplateSeederTest.php‎

Lines changed: 133 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,16 +95,27 @@ static function ( $key, $value ) use ( &$bumped ) {
9595

9696
public function test_maybe_seed_seeds_all_defaults_and_bumps_flag(): void {
9797
Functions\when( 'get_option' )->justReturn( 0 );
98-
Functions\when( 'get_posts' )->justReturn( array() ); // no existing defaults
99-
Functions\when( 'get_post_meta' )->justReturn( '' );
98+
99+
$meta = array();
100+
// Stateful pool: empty when seed() looks, populated by the time the
101+
// post-run guard checks whether anything was actually created.
102+
Functions\when( 'get_posts' )->alias(
103+
static function () use ( &$meta ) {
104+
return array_keys( $meta );
105+
}
106+
);
107+
Functions\when( 'get_post_meta' )->alias(
108+
static function ( $id ) use ( &$meta ) {
109+
return $meta[ $id ][ CertTemplateCpt::META_DEFAULT_SLUG ] ?? '';
110+
}
111+
);
100112

101113
$inserted = 0;
102114
Functions\when( 'wp_insert_post' )->alias(
103115
static function () use ( &$inserted ) {
104116
return 100 + ( ++$inserted );
105117
}
106118
);
107-
$meta = array();
108119
Functions\when( 'update_post_meta' )->alias(
109120
static function ( $id, $key, $value ) use ( &$meta ) {
110121
$meta[ $id ][ $key ] = $value;
@@ -219,6 +230,125 @@ static function () use ( &$inserted ) {
219230
$this->assertSame( 5, $inserted, 'the five missing defaults are inserted' );
220231
}
221232

233+
/**
234+
* The #865 phase-4 hole. `maybe_seed()` used to write the seed flag
235+
* unconditionally, so a first seed that created nothing still recorded
236+
* itself as applied — and because the flag short-circuits every later run,
237+
* the pool stayed empty forever. An empty pool is precisely what makes
238+
* `AdminAssetsManager::discover_layout_templates()` fall through to the
239+
* deprecated legacy `html/` glob, so the fallback could never meet its own
240+
* exit condition ("removed once the pool seeds on every install").
241+
*
242+
* Simulated here through a failing `wp_insert_post()`. The real-world cause
243+
* is usually the other one — `Utils::read_file_contents()` returning '' for
244+
* an unreadable seed file, which `seed()` silently `continue`s past — but
245+
* that path cannot be exercised from a unit test: it keys on
246+
* `FFC_PLUGIN_DIR`, a process-wide constant the suite defines once. Both
247+
* causes funnel into the same post-run check, which is what this pins.
248+
*/
249+
public function test_maybe_seed_does_not_mark_seeded_when_nothing_was_created(): void {
250+
Functions\when( 'get_option' )->justReturn( 0 );
251+
Functions\when( 'get_posts' )->justReturn( array() ); // pool stays empty
252+
Functions\when( 'get_post_meta' )->justReturn( '' );
253+
Functions\when( 'update_post_meta' )->justReturn( true );
254+
// wp_insert_post( …, true ) returning WP_Error is the failure shape.
255+
Functions\when( 'wp_insert_post' )->justReturn( new \WP_Error( 'db_insert_error', 'nope' ) );
256+
257+
$written = null;
258+
Functions\when( 'update_option' )->alias(
259+
static function ( $key, $value ) use ( &$written ) {
260+
$written = array( $key, $value );
261+
return true;
262+
}
263+
);
264+
265+
CertTemplateSeeder::maybe_seed();
266+
267+
$this->assertNull(
268+
$written,
269+
'the seed flag must stay unwritten so the next admin request retries'
270+
);
271+
}
272+
273+
/**
274+
* Same guard on the version-bump branch: an already-seeded install whose
275+
* defaults were all deleted must not have the new seed version recorded
276+
* when restore() fails to put any of them back.
277+
*/
278+
public function test_maybe_seed_does_not_bump_the_version_when_restore_creates_nothing(): void {
279+
Functions\when( 'get_option' )->justReturn( 1 ); // seeded under an older version
280+
Functions\when( 'get_posts' )->justReturn( array() ); // …but the pool is empty now
281+
Functions\when( 'get_post_meta' )->justReturn( '' );
282+
Functions\when( 'update_post_meta' )->justReturn( true );
283+
Functions\when( 'wp_insert_post' )->justReturn( new \WP_Error( 'db_insert_error', 'nope' ) );
284+
285+
$written = null;
286+
Functions\when( 'update_option' )->alias(
287+
static function ( $key, $value ) use ( &$written ) {
288+
$written = array( $key, $value );
289+
return true;
290+
}
291+
);
292+
293+
CertTemplateSeeder::maybe_seed();
294+
295+
$this->assertNull(
296+
$written,
297+
'the seed flag must stay unwritten so the next admin request retries'
298+
);
299+
}
300+
301+
/**
302+
* The guard is deliberately narrow — "the pool is not empty", not "every
303+
* definition seeded". A partial seed still populates the layout picker and
304+
* keeps the legacy fallback dormant, so it counts as applied. Blocking the
305+
* flag on completeness instead would re-run the seeder on every admin
306+
* request for as long as a single seed file stayed unreadable.
307+
*/
308+
public function test_maybe_seed_marks_seeded_when_only_some_defaults_could_be_created(): void {
309+
Functions\when( 'get_option' )->justReturn( 0 );
310+
311+
$meta = array();
312+
Functions\when( 'get_posts' )->alias(
313+
static function () use ( &$meta ) {
314+
return array_keys( $meta );
315+
}
316+
);
317+
Functions\when( 'get_post_meta' )->alias(
318+
static function ( $id ) use ( &$meta ) {
319+
return $meta[ $id ][ CertTemplateCpt::META_DEFAULT_SLUG ] ?? '';
320+
}
321+
);
322+
Functions\when( 'update_post_meta' )->alias(
323+
static function ( $id, $key, $value ) use ( &$meta ) {
324+
$meta[ $id ][ $key ] = $value;
325+
return true;
326+
}
327+
);
328+
329+
// Only the first insert succeeds; the other five fail.
330+
$calls = 0;
331+
Functions\when( 'wp_insert_post' )->alias(
332+
static function () use ( &$calls ) {
333+
++$calls;
334+
return 1 === $calls ? 101 : new \WP_Error( 'db_insert_error', 'nope' );
335+
}
336+
);
337+
338+
$bumped = null;
339+
Functions\when( 'update_option' )->alias(
340+
static function ( $key, $value ) use ( &$bumped ) {
341+
$bumped = array( $key, $value );
342+
return true;
343+
}
344+
);
345+
346+
CertTemplateSeeder::maybe_seed();
347+
348+
$this->assertSame( 'ffc_cert_templates_seeded_version', $bumped[0] ?? null );
349+
$this->assertSame( 5, $bumped[1] ?? null, 'a partial seed still counts as applied' );
350+
}
351+
222352
public function test_seed_html_references_shipped_assets_not_html_folder(): void {
223353
// #865 crit #7: default images moved to the versioned assets/ dir so
224354
// html/ can eventually be retired; guard against a seed reference

0 commit comments

Comments
 (0)