Compare commits

...
Author SHA1 Message Date
kristianwindandClaude Opus 5 cc0aaf9272 Scope the compatibility bump's git identity to its own commit
`ci/bump-tested-up-to.sh` ran `git config user.name "compatibility check"`
before committing. That writes .git/config and outlives the script, so from
the first run onward every commit in that working copy is authored by
"compatibility check <noreply@localhost>" with no Co-Authored-By trailer —
including a release made by a person days later about something else.

git announces an identity it cannot FIND and says nothing about one that is
wrong, so the missing case is loud and this one is silent. Nothing fails,
nothing warns, and the author is only visible to somebody reading `git log`
for another reason. Measured across the family by the Nolimit Security
session: six misattributed hand commits in one sibling including two
releases, two in another, one in a third. This tree was clean — no local
identity, 0 misattributed — and one run of the script away.

The commit still signs itself as the robot, which is right: that one is not
a person's. It just does it with `git -c … commit`, which configures the
command and not the clone.

Guarded in ci/static.sh, because the fix is one line and reverting it would
be invisible for exactly as long as nobody reads an author field. Widened
past what was reported: the pattern accepts any scope flag, since --global
is the same fault one scope wider and reaches every repository on the box.

Proven with a full matrix rather than one trigger — no flag, --local,
--global and --system each go red with the named message and exit 1; a
commented mention, `git -c user.name=… commit`, `git config core.autocrlf`
and `git config --global core.pager` all stay green. The correct form being
a control matters: a check that flagged the fix would get one of the two
deleted. Five of those assertions are now in ci/selftest.sh (36 total), each
with a landing check that the plant is in the file before its verdict is
believed, and the passing line carries the script count — "every script is
clean" reads the same over zero files as over eleven.

ci/ ships nothing, so there is no version bump and no release here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 12:17:48 +02:00
Kristian Wind f6ea46a93e Merge pull request #42 from kristianwind/count-assertions-and-bump-headers
CI / Static checks (push) Successful in 1m5s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m13s
1.6.2: WP 7.1.2 and WC 11.1.2 declared, and the caller counts the assertions
2026-09-25 08:53:23 +02:00
kristianwindandClaude Opus 5 2b289c6763 1.6.2: WP 7.1.2 and WC 11.1.2 declared, and the caller counts the assertions
Kristian asked the family to check WordPress 7.1.2. Run pinned rather than
against "latest", because latest drifts and then nobody knows which version a
green belonged to:

  PHP 7.4   69 assertions, 0 failures   "none from this plugin"
  PHP 8.3   69 assertions, 0 failures   "none from this plugin"
  PHP 8.4   69 assertions, 0 failures   "none from this plugin"

The container reported WordPress 7.1.2 and WooCommerce 11.1.2 on all three,
read out of the run rather than from the variable it was given. So there is no
problem with 7.1.2 here.

The findings were in the reporting, not the code.

The headers were stale: "Tested up to: 7.1" and "WC tested up to: 11.1.0"
against current 7.1.2 and 11.1.2. The WooCommerce one is the visible half — a
shop on 11.1.2 sees an untested warning on the WooCommerce status page, and on
this plugin that banner sits in front of somebody deciding whether to update a
discount engine.

And the suite said "All assertions passed" without a number, which is the exact
failure every other check here was fixed for. It now prints the count.

🔴 Then the floor for that count found a bigger hole than it was written for.
Putting it at the end of the smoke file does nothing: wp eval-file runs the
file in function scope, so a `return` partway through skips everything after
it, including the floor. Measured — truncating tests/smoke.php before the
shipping section ran two thirds of the assertions and ci/site-test.sh reported
GREEN.

A file cannot assert about its own early exit. ci/site-test.sh now captures the
output and checks the total against MIN_SITE_ASSERTIONS from outside, where an
early return cannot reach, and fails when the summary line is missing
altogether. The in-file floor stays for a short run that does reach the end.

Proven with the same fixture that exposed it: the truncated suite now exits 1
saying it printed no assertion total.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 08:51:46 +02:00
Kristian Wind 9931c74b45 Merge pull request #41 from kristianwind/menu-1-2-3-pill
CI / Static checks (push) Successful in 1m3s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m17s
1.6.1: shared-menu 1.2.3, and render the dashboard to check the pill
2026-09-23 17:23:20 +02:00
kristianwindandClaude Opus 5 8143d146e4 1.6.1: shared-menu 1.2.3, and render the dashboard to check the pill
The status pill on the shared dashboard is white 11px text over a light
overlay. SEO measured it two ways, arithmetic and a canvas read-back in
headless Chromium: this plugin's #1a7a52 gave 3.50:1 against a 4.5 minimum,
and six of eight family colours failed along with the no-colour fallback.

Not fixable by choosing a different card colour. The header is a direct pair
and passes at 5.32:1; the pill is a COMPOSITE, and 22% white is a floor — a
card has to reach about 7.7:1 before the pill clears 4.5, which is above the
whole family band. So it is the shared file or seven plugins giving up the
colour the cards exist to carry. 1.2.3 makes the overlay rgba(0,0,0,.18) and
this plugin's pill goes to 7.15:1.

Verified before copying: the hash, and php -w over both files to confirm the
executable code really changed — the diff is the version constant and that one
CSS value, nothing else.

The new assertion renders the dashboard and reads the pill out of what came
back, and it is built around the trap SEO warned about: their first harness
printed "no problem" over a code path it never entered, because it stubbed
wpmgr_cards instead of wpmgr_dashboard_cards and the renderer took its early
return. So the card's own title is asserted first — if the renderer bailed,
nothing after it means anything — and the OLD rgba is asserted ABSENT, because
a check that cannot tell the two versions apart cannot testify about either.

Both proven: against 471b0a2a it fails naming "the old 22% white", and with the
card filter removed it fails at 81 bytes rendered. That 81 is the same number
SEO reported from their own broken harness, which is independent confirmation
it is the same code path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 17:21:44 +02:00
Kristian Wind 2490f733de Merge pull request #40 from kristianwind/exclusive-shipping-methods
CI / Static checks (push) Successful in 53s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m5s
1.6.0: a shipping method a group is shown belongs to that group
2026-09-22 09:48:11 +02:00
kristianwindandClaude Opus 5 6ef4ee8fe5 1.6.0: a shipping method a group is shown belongs to that group
Kristian checked out with an address in no group and was offered both free
delivery methods set up for the Netic group. Measured on the shop before
touching anything: one group, four rules, two of them "show" on the free
delivery methods he saw.

It was not a regression. A test asserted the behaviour as intended — "an
unmatched customer sees every method" — so a "show" rule was a whitelist FOR
THE GROUP and nothing more. The plugin did half of what its own description
promises: it filtered what the group sees and never reserved anything from
anyone else. On a shop that takes money the effect is that free delivery
arranged for one company is available to every customer.

A method named in any group's show rules is now reserved for the groups that
name it.

Answering "does it work for other discounts too", which found the hole the
first fix left: it did not. A group with NO rules of its own returned every
rate untouched, and so did a group carrying only "hide" rules, because neither
engages the whitelist — so their members saw another group's reserved methods.
Both now have other groups' reserved methods removed.

The "never leave the customer without shipping" guard is kept and asserted: a
shop that reserved everything it offers must still let an ordinary customer
check out, because that failure is worse than the leak.

Four assertions, each proven red. Two of them caught my own mistakes rather
than the product's: the hide-only fixture originally left nothing for the
customer, so the guard fired and returned every rate — which reads exactly like
the leak it was written to detect. A third rate makes the behaviour observable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 09:46:48 +02:00
Kristian Wind d9de751e3f Merge pull request #39 from kristianwind/readme-follows-the-rename
CI / Static checks (push) Successful in 1m0s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m20s
1.5.4: the text follows the rename, and a check keeps it that way
2026-09-20 22:40:39 +02:00
kristianwindandClaude Opus 5 cb58ffe26e 1.5.4: the text follows the rename, and a check keeps it that way
Kristian read the readme and found it still telling people to configure the
plugin under WooCommerce, for a page that moved to the Nolimit menu three
releases ago. Sweeping for it found seven stale places, and three of them are
notices THE PLUGIN SHOWS THE USER — the missing-WooCommerce notice, the
activation error and the incomplete-install warning all still called it
WooCommerce Email Discount Manager.

That is the mimir.guide failure exactly: the code moved, the text stood still,
and nothing failed. It was found by somebody reading, which is the only way it
could be found, and that is the argument for the check rather than for being
more careful next time.

ci/static.sh now asserts two things. The name in readme.txt's title matches the
Plugin Name header, because the header is what WordPress shows and everything
else has to agree with it. And no shipped PHP file contains OLD_PLUGIN_NAME,
which is now a value in ci/plugin.conf rather than a habit.

The changelog is deliberately exempt: an entry describes what was true for that
release, and rewriting it would be a different lie. Only text describing the
CURRENT state is checked.

Both halves proven red — a readme title that disagrees, and the old name put
back into one notice.

Not changed: the page stays under Nolimit rather than gaining a second entry
under WooCommerce. One menu is the family's shape, and a duplicate entry is
clutter rather than a fix for a readme that was simply wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 22:39:19 +02:00
Kristian Wind 7ece3f1ee4 Merge pull request #38 from kristianwind/menu-1-2-2-and-153
CI / Static checks (push) Successful in 57s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m11s
1.5.3: shared-menu 1.2.2, and run the glyph style instead of reading it
2026-09-20 19:09:11 +02:00
kristianwindandClaude Opus 5 6a789349ec 1.5.3: shared-menu 1.2.2, and run the glyph style instead of reading it
678f1f03 is withdrawn. It hard-coded the icon box at 36x34, and WordPress
declares that parent three times with different sizes — 36x34 open, 35x30
folded, 34x30 under 960px — so a fixed number is correct in one state and 4px
too tall in the two a laptop is usually in. cache-manager measured where the
ink lands rather than where the box is and found it worse than that: the glyph
moved from 2.5px above centre to 2.5px below in the folded states, the same
error mirrored, in a state that had been nearly right. 1.2.2 removes the number
entirely with width/height at 100%.

1.5.2 is already published with 678f1f03, so this tree shipped the withdrawn
version. Checked before anything else, because a later candidate fatalled on
every admin page with "Unknown format specifier": that file was never taken
here, and the published 1.5.2 emits its stylesheet cleanly — verified by
running sprintf over its format string rather than reading it. So the shop is
cosmetically off in folded menus, not broken.

New assertion, and it is the one this round is really about: the style
function is INVOKED and its output captured, rather than the source being
read. The fatal that prompted it survived three sessions reading the file,
a clean php -l, a clean PHPCS, and two browsers agreeing about the CSS —
because every one of those was fed the stylesheet by hand rather than through
the printf that produces it. A literal per-cent must be doubled in a format
string, and nothing that looks at the file can see that.

Proven red with that exact fault rather than an arbitrary one: undoubling the
per-cent gives "ValueError: Unknown format specifier ';'".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 19:07:44 +02:00
Kristian Wind db5c5e33c8 Merge pull request #37 from kristianwind/menu-1-2-1-and-152
CI / Static checks (push) Successful in 56s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m14s
1.5.2: take shared-menu 1.2.1, which centres the glyph
2026-09-20 18:54:11 +02:00
kristianwindandClaude Opus 5 6114473be0 1.5.2: take shared-menu 1.2.1, which centres the glyph
Kristian reported the infinity sign sitting above centre in the admin menu on
the live shop. Security traced it to WordPress's own stylesheet rather than
adjusting it by eye: #adminmenu div.wp-menu-image is 36x34 and its :before
carries padding 7px 0, which is exactly 7 + 20 + 7 for a 20px dashicon at
line-height 1. The 1.2.0 rule set line-height 1.4 and inherited that padding,
so the pseudo-element was 42px tall inside a 34px parent.

1.2.1 reproduces the box, drops the padding and centres with flex — geometry
rather than a nudge, because U+221E sits on the maths axis instead of filling
its em box.

It is the same half-done abstraction as the escaping fix: the filter made the
character configurable and left the geometry tuned for one specific character.
Nothing could fail, because the only value anyone ever passed was the one it
was tuned for.

Verified before copying, including the part that makes the check meaningful:
Security predicted php -w WOULD differ this round, since the executable code
changed — so equal stripped hashes would have meant the wrong file. They
differ, and the diff is the version constant plus the CSS geometry, with
wpmgr_css_escape untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 18:52:51 +02:00
Kristian Wind d707751a95 Merge pull request #36 from kristianwind/case-not-grep-q
CI / Static checks (push) Successful in 48s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m0s
Replace the two growing `| grep -q` checks with `case`
2026-09-20 10:30:10 +02:00
kristianwindandClaude Opus 5 d121005c0a Replace the two growing | grep -q checks with case
Under `set -o pipefail` a grep that MATCHES can make the pipeline exit 141:
grep closes the pipe on its first hit, the producer takes SIGPIPE, and pipefail
reports that as failure. A match becomes an error. Security found the shape;
measured here before believing it, because the earlier pipefail lesson went the
other way and I did not want to carry a second claim I had not seen:

  yes hittet | grep -q hittet   under pipefail  -> exit 141
  printf hittet | grep -q hittet                -> exit 0

So it needs the producer to outrun the 64 KB pipe buffer, which is why it is
invisible until it is not.

Two of this repo's sites can get there, and both were converted. Neither is
reachable today — that is the reason to do it now rather than an argument
against:

ci/publish-release.sh checked the release feed for a download URL. The feed is
3.5 KB in the new repo and 13.5 KB in the old one across seven releases, so
about 1.9 KB each: it reaches 64 KB around release 34. That failure would land
in the publishing path and say the feed has no zip attached while it plainly
has one, and nothing about the commit that tripped it would have anything to do
with it. A bug on a timer is worse than a bug.

ci/static.sh checked whether each plugin-relative include is in the shipped
list, once per include. Same growth, plus a fork per iteration.

Both now use `case`, which has no pipe, no fork and no producer to signal, and
cannot be reversed by a shell option. The static.sh one wraps both sides in
newlines to keep the exact-line match `grep -qx` gave. Proven both ways:
thirteen includes checked on a clean tree, and excluding one from the zip still
names it.

The remaining `| grep -q` sites were measured rather than assumed safe:
zip_entries is 1.3 KB, the shipped list 478 bytes, static.sh's own output
1.6 KB, and site-test.sh and update-smoke.sh grep a single line of wp-cli
output. Left alone; converting those would be churn against producers that
cannot plausibly reach the buffer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 10:28:52 +02:00
Kristian Wind 36c15dc05e Merge pull request #35 from kristianwind/assert-structure-not-text
CI / Static checks (push) Successful in 48s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m1s
Assert structure rather than text, and never leave a half-built zip
2026-09-20 10:21:49 +02:00
kristianwindandClaude Opus 5 c54b5c2cef Assert structure rather than text, and never leave a half-built zip
Three things, all the same shape: a check that recognises a spelling has one
blind spot per spelling, and one that reads the thing itself has none of that
kind. Security hit it when an assertion matching the literal '∞' went red over
a file drawing exactly that character through a CSS escape.

Swept this repo's assertions for the same and found two.

"the plugin itself loads the shared menu file" searched THIS FILE'S SOURCE TEXT
for "'includes/shared-menu.php',". A comment mentioning that path would have
satisfied it — the same hollow shape as grepping a file for the words of the
guard it is supposed to have, which this kit already had once today. The admin
list is now a method, admin_includes(), and the test reads the array the plugin
actually loads from. Proven red by removing the entry: the failure prints the
list it did find.

The package-URL check asserted the absence of one particular IP address.
Gitea's ROOT_URL has been a raw address before and may be again, so naming
today's is blind to tomorrow's. It now asserts the host and the scheme.
Proven red with the realistic fault rather than a contrived one — the raw
address in the Update URI header:

  FAIL so it is never the raw address … expected 'gitea.nolimit.dk', got '57.129.89.117'
  FAIL and it is fetched over https … expected 'https', got 'http'

And ci/build-zip.sh left the archive behind when it failed. Every exit in the
checks section happens after the archive is written, so a failed build kept a
half-built artefact that looks exactly like a good one — and both the release
step and anyone publishing by hand take build/<slug>.zip by name. Found by
stubbing ci/dist-files.sh empty: exit 1, correct message, zip still on disk.
A trap removes it now, proven both ways.

Checked while here, from Security's report that the kit's shipped-file floor
was unguarded: this copy is guarded, and red for the right reason. Stubbing the
file list empty gives exit 1, one FAIL naming the empty list, and zero
occurrences of the "every one guarded" line — which is the assertion that
matters, since being red for an unrelated reason would look identical from the
exit code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 10:20:17 +02:00
Kristian Wind 53c34067bc Merge pull request #34 from kristianwind/bump-151
CI / Static checks (push) Successful in 51s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m7s
1.5.1
2026-09-20 10:01:00 +02:00
kristianwindandClaude Opus 5 526faf2c4c 1.5.1
The published 1.5.0 carries includes/shared-menu.php at f4862d5c — contract
1.0.0, the oldest of the six, in an artefact a shop downloads. It was built
before the family froze 1.1.0 and again 1.2.0, so the zip is older than the
repo in the one way that reaches a customer.

This release carries 9012fe98, so the menu shows the infinity glyph and the
glyph is emitted as CSS numeric escapes rather than through esc_attr().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 09:59:43 +02:00
Kristian Wind 96fc5f0041 Merge pull request #33 from kristianwind/shared-menu-1-2-0
CI / Static checks (push) Successful in 50s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m2s
Take shared-menu.php 1.2.0: the glyph is CSS-escaped, not esc_attr'd
2026-09-20 09:54:08 +02:00
kristianwindandClaude Opus 5 f908771f33 Take shared-menu.php 1.2.0: the glyph is CSS-escaped, not esc_attr'd
Frozen by Nolimit SEO as 9012fe98, and it fixes something found here: the 1.1.0
glyph went through esc_attr() into a <style> element, where HTML entities are
not decoded by the CSS parser. It was protecting by coincidence rather than by
working, and Security found the case where the coincidence stops covering — a
backslash, which esc_attr leaves alone, is a CSS escape and swallows the
closing quote.

No injection was possible on any input any of us could construct, so this is
hardening rather than a hole, and the value comes from plugin code rather than
from a request.

The fix changes the alphabet instead of filtering the input: the glyph is
emitted as CSS numeric escapes, so what reaches the stylesheet is nothing but
backslashes, hex digits and spaces. cache-manager's statement of why that is
the right shape is the one worth keeping — the value CANNOT CONTAIN A
DELIMITER. "It escapes correctly" is a description, and a whitelist claims the
same description while needing to be kept honest against inputs nobody has
thought of yet.

Verified here rather than accepted:

- sha256 matches 9012fe98 before copying.
- The "b8d9284b plus two full stops and a phpcs pair" claim is comment-only,
  measured with php -w over both: one sha256 for the stripped source.
- The escaper itself run against seven inputs including a bare backslash, a
  quote, a CSS breakout, an HTML breakout and a NUL, asserting the output
  matches nothing outside [backslash, hex, space]. All seven clean.
- Correct codepoints, and no mb_* calls, so the hand-written decoder holds on
  installs without mbstring — which is every install none of us test on.

WPMGR_MENU_SHA updated in ci/plugin.conf. SEO's instruction said ci/static.sh,
which is their layout, not this one — following it literally would have set a
variable nothing reads, green on both sides while the check still asserted the
old value. Found by searching for the OLD VALUE rather than the path: zero
occurrences of any earlier frozen hash remain anywhere in ci/, tests/ or either
workflow root, and the new one appears exactly once.

Then proven consumed in both directions, which is the assertion that catches a
copy nothing reads: changing the file by one byte moves "got", and changing the
value in plugin.conf moves "expected".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 09:52:40 +02:00
14 changed files with 639 additions and 59 deletions
+17 -5
View File
@@ -1,10 +1,10 @@
=== WooCommerce Email Discount Manager ===
=== Nolimit Discounts ===
Contributors: kristianwind
Tags: woocommerce, discount, email, shipping, coupon
Requires at least: 5.8
Tested up to: 7.1
Tested up to: 7.1.2
Requires PHP: 7.4
Stable tag: 1.5.0
Stable tag: 1.6.2
WC requires at least: 6.0
WC tested up to: 11.1.0
License: GPLv2 or later
@@ -14,7 +14,7 @@ Apply discounts and show/hide shipping methods based on the customer's email add
== Description ==
WooCommerce Email Discount Manager lets you give discounts and filter available
Nolimit Discounts lets you give discounts and filter available
shipping methods based on the email address a customer logs in or checks out with.
= Discount Groups =
@@ -63,7 +63,7 @@ coupons in a dropdown.
1. Upload the `woocommerce-email-discount-manager` folder to `/wp-content/plugins/`.
2. Make sure WooCommerce is installed and active.
3. Activate the plugin through the Plugins screen in WordPress.
4. Go to WooCommerce → Email Discounts to configure groups, assignments and shipping rules.
4. Go to Nolimit → Discounts to configure groups, assignments and shipping rules.
= REST API =
@@ -96,6 +96,18 @@ The most specific match wins: exact email, then domain, then partial match.
== Changelog ==
= 1.6.2 =
* Declares compatibility with WordPress 7.1.2 and WooCommerce 11.1.2, both tested. No functional change.
= 1.6.1 =
* The status label on the Nolimit dashboard card is now readable. It was white text on a light overlay, which on this plugin's green gave 3.5:1 where 4.5:1 is the accessibility minimum.
= 1.6.0 =
* A shipping method a group is shown is now reserved for that group: customers who match no group no longer see it. Previously a method set up for approved addresses stayed on the checkout for everyone, so free delivery meant for one company could be chosen by anybody. Customers are never left with no shipping method at all.
= 1.4.6 =
* Housekeeping: the plugin's source moved to a shorter address. Updates continue to arrive as before, and nothing needs doing.
+9
View File
@@ -53,6 +53,15 @@ zip_entries() {
ci_step "what the zip contains"
zip_entries | sed 's/^/ /'
# 🔴 A failed build must not leave a zip behind. Every exit below this point
# happens AFTER the archive is written, so without this the tree keeps a
# half-built artefact that looks exactly like a good one — and the release step
# takes `build/<slug>.zip` by name, as does anyone publishing by hand. The
# check that caught it: stubbing ci/dist-files.sh empty makes this script exit
# 1 with "The main plugin file is not in the zip", and the zip was still there
# afterwards.
trap 'code=$?; [ "$code" = 0 ] || { rm -f "build/$PLUGIN_SLUG.zip"; echo "Removed the incomplete build/$PLUGIN_SLUG.zip." >&2; }; exit $code' EXIT
ci_step "checks"
zip_entries | grep -qx "$PLUGIN_SLUG/$PLUGIN_MAIN" \
|| { echo "The main plugin file is not in the zip." >&2; exit 1; }
+8 -3
View File
@@ -60,10 +60,15 @@ done
git diff --quiet && { echo "Nothing actually changed on disk — check the sed patterns." >&2; exit 1; }
branch="compat/tested-up-to-$current_wp-wc-$current_wc"
git config user.name "compatibility check"
git config user.email "noreply@localhost"
git checkout -b "$branch"
git commit -am "Tested against WordPress $current_wp and WooCommerce $current_wc
# The identity is scoped to THIS commit with `git -c`, never written to the
# clone. `git config user.name` persists in .git/config, so every later commit
# in this working copy — a release days afterwards, by a person — would be
# authored by the robot with no Co-Authored-By trailer. git announces a
# MISSING identity and says nothing about a wrong one, so that is silent.
# Signing this one commit as the robot is right: it is not a person's.
git -c user.name="compatibility check" -c user.email="noreply@localhost" \
commit -am "Tested against WordPress $current_wp and WooCommerce $current_wc
$(printf '%s\n' "${changed[@]}")
+1 -1
View File
@@ -22,7 +22,7 @@ ci_load_conf() {
# leaves the floors empty, every comparison becomes a no-op, and the hollow
# checks these were added to fix come back silently — the same shape of bug.
local missing=
for var in PLUGIN_SLUG PLUGIN_MAIN TEXT_DOMAIN NEEDS_WOOCOMMERCE MIN_SHIPPED_PHP_FILES MIN_TRANSLATION_CALLS RELEASE_REPO WPMGR_MENU_SHA; do
for var in PLUGIN_SLUG PLUGIN_MAIN TEXT_DOMAIN NEEDS_WOOCOMMERCE MIN_SHIPPED_PHP_FILES MIN_TRANSLATION_CALLS RELEASE_REPO WPMGR_MENU_SHA OLD_PLUGIN_NAME MIN_SITE_ASSERTIONS; do
[ -n "${!var:-}" ] || missing="$missing $var"
done
if [ -n "$missing" ]; then
+34 -3
View File
@@ -75,6 +75,37 @@ RELEASE_REPO=kw/nolimit-discounts-dist
# The frozen hash is therefore a family-wide fact, not a local one, and taking
# a new one is something all six do in the same window.
#
# 184cebdc = contract 1.1.0 (the ∞ glyph, and the header/count corrections
# Nolimit SEO found). Previous frozen hash was f4862d5c = 1.0.0.
WPMGR_MENU_SHA=184cebdcd86024d4e864c529bc68ead0f7d63656d9bb3dfe0e1fdf3203e364ae
# 75b04eb4 = contract 1.2.3: the dashboard status pill is rgba(0,0,0,.18)
# instead of 22% white. The pill is white 11px text composited over the card
# colour, and 22% white is a floor: a card has to reach about 7.7:1 before the
# pill clears 4.5:1. This plugin's #1a7a52 gave 3.50 and now gives 7.15. It
# could not be fixed by choosing a different card colour — the whole family
# band fails, including the no-colour fallback.
# Earlier: 471b0a2a = 1.2.2: the icon box is sized 100%/100% instead of a
# hardcoded 36x34. WordPress declares that parent three times and they
# disagree (36x34 open, 35x30 folded, 34x30 under 960px), so a fixed number is
# right in one state and wrong in the two a laptop is usually in.
# Withdrawn: 678f1f03 = 1.2.1: the glyph is centred with flex inside an explicit
# 36x34 box instead of inheriting WordPress's 7px padding, which made the
# pseudo-element 42px tall inside a 34px parent and pushed the sign above
# centre. Kristian reported it from the live shop.
# Earlier: 9012fe98 = contract 1.2.0. The glyph is now emitted as CSS numeric escapes
# rather than through esc_attr(): HTML entities are not decoded by the CSS
# parser, so esc_attr was protecting by coincidence — and a backslash, which it
# leaves alone, IS a CSS escape and swallowed the closing quote. Changing the
# alphabet beats filtering the input: what is emitted is nothing but
# backslashes, hex digits and spaces, so there is nothing left to leave the
# string with. Verified here against seven hostile inputs.
# Earlier frozen hashes: 184cebdc = 1.1.0, f4862d5c = 1.0.0.
WPMGR_MENU_SHA=75b04eb4fe9eda0119312075accf2fc77de244e5c6b759d624a8b1d254c0fc99
# The name this plugin used before the Nolimit rename. Kept so ci/static.sh
# can fail when it reappears in text a user reads — a sentence that goes stale
# breaks nothing by itself, which is why it survives.
OLD_PLUGIN_NAME="WooCommerce Email Discount Manager"
# The fewest assertions a complete run of tests/smoke.php prints. Verified by
# ci/site-test.sh rather than only inside the file: a smoke file runs in
# function scope, so a `return` partway through skips its own floor and the
# run reports green having tested two thirds of what it claims.
MIN_SITE_ASSERTIONS=60
+16 -3
View File
@@ -200,12 +200,25 @@ if [ -z "$feed" ]; then
exit 1
fi
printf '%s' "$feed" | grep -q '"browser_download_url"' || {
# `case`, not `printf … | grep -q`. Under `set -o pipefail` a grep that
# MATCHES can make the pipeline exit 141: grep closes the pipe on the first
# hit and the producer takes SIGPIPE, which pipefail reports as failure. So a
# match becomes an error, in the publishing path, with a message saying the
# feed has no download URL when it plainly does.
#
# Not reachable today — the feed is 3.5 KB and the pipe buffer is 64 KB — but
# it grows about 1.9 KB per release, so it arrives around release 34. A bug on
# a timer is worse than a bug, because nothing about the commit that trips it
# will have anything to do with it. `case` has no pipe, no fork and no
# producer to signal.
case "$feed" in
*'"browser_download_url"'*) ;;
*)
echo
ci_fail "The release is readable, but it has no zip attached. WordPress would be offered an update it cannot download."
exit 1
}
;;
esac
# Checked at the address the PLUGIN builds, not the one the API reports.
#
# A forge fills browser_download_url from its own configured root URL, which is
+48
View File
@@ -205,6 +205,54 @@ ann=$(ci_php ci/check-textdomain.php "$TEXT_DOMAIN" selftest-dotted 2>&1 \
&& ok "the annotated path exists on disk: $ann" \
|| bad "the annotated path is not a file: '$ann'"
# ------------------------------------------------------- the clone's git identity
# 🔴 Grade 2 evidence: the exact line ci/bump-tested-up-to.sh carried until
# 2026-09-25, not an arbitrary `git config`. That line writes .git/config, so
# every later commit in the clone — a release, by a person — is authored by the
# robot with no trailer. Six such commits in one sibling plugin, two in
# another. git warns about an identity it cannot FIND and never about a wrong
# one, so nothing in the repository ever says so.
echo
echo "→ ci/static.sh must catch a ci/ script that configures the clone"
cd "$work/repo"
cp ci/build-zip.sh "$work/build-zip.orig"
for scope in '' '--local' '--global'; do
cp "$work/build-zip.orig" ci/build-zip.sh
line="git config ${scope:+$scope }user.name \"compatibility check\""
printf '%s\n' "$line" >> ci/build-zip.sh
# The plant must BE there before any verdict is believed. A `printf` that
# silently went somewhere else leaves the control state, and every assertion
# below then passes while proving nothing — a hollow probe wearing the
# costume of the most rigorous thing in the suite.
if [ "$(grep -cF "$line" ci/build-zip.sh)" -lt 1 ]; then
bad "could not stage the git-identity fixture for '${scope:-no flag}'"
continue
fi
id_out=$(./ci/static.sh 2>&1 || true)
if printf '%s' "$id_out" | grep -q 'sets a git identity that outlives it'; then
ok "flagged git config ${scope:-(no flag)} user.name"
else
bad "NOT FLAGGED: git config ${scope:-(no flag)} user.name — the clone keeps the robot's name"
fi
done
# The correct form must NOT fire, or the fix trips the check that exists to
# protect it and the next person deletes one of the two.
cp "$work/build-zip.orig" ci/build-zip.sh
printf '%s\n' 'git -c user.name="compatibility check" -c user.email=x commit -m y' >> ci/build-zip.sh
scoped_out=$(./ci/static.sh 2>&1 || true)
printf '%s' "$scoped_out" | grep -q 'sets a git identity that outlives it' \
&& bad "FALSE POSITIVE on 'git -c user.name=… commit' — that is the fix, not the fault" \
|| ok "ignored the scoped 'git -c … commit' form"
cp "$work/build-zip.orig" ci/build-zip.sh
# And the passing line must carry a number: "every script is clean" reads the
# same over zero files as over eleven.
clean_out=$(./ci/static.sh 2>&1 || true)
printf '%s' "$clean_out" | grep -qE "^ [0-9]+ scripts in ci/ leave the clone's git identity alone" \
&& ok "the git-identity check reports how many scripts it read" \
|| bad "the git-identity check printed no count — it cannot tell 11 scripts from 0"
# ------------------------------------------------------------------- plugin.conf
echo
echo "→ the floors must be required, not defaulted"
+31 -1
View File
@@ -177,9 +177,39 @@ wp plugin list --name="$PLUGIN_SLUG" --field=status | grep -qx active \
|| { echo "The plugin did not stay active after activation." >&2; exit 1; }
echo " activated $(wp plugin get "$PLUGIN_SLUG" --field=version)"
# 🔴 The output is captured and the summary checked HERE, by the caller.
#
# A smoke file cannot assert about its own early exit: `wp eval-file` runs it
# in function scope, so a `return` partway through skips everything after it —
# including any floor the file carries at the bottom. Measured: truncating
# tests/smoke.php before the shipping section ran two thirds of the assertions
# and this script reported green, because the file's own floor was one of the
# things never reached.
#
# So the number the file prints is verified against MIN_SITE_ASSERTIONS out
# here, where an early return cannot reach. The floor inside the file stays as
# well; it catches a short run that still gets to the end.
for f in ${SITE_TESTS:-}; do
ci_step "$f"
wp eval-file "wp-content/plugins/$PLUGIN_SLUG/$f"
out=$(wp eval-file "wp-content/plugins/$PLUGIN_SLUG/$f" 2>&1) || { printf '%s\n' "$out"; exit 1; }
printf '%s\n' "$out"
case "$f" in
*smoke*)
ran=$(printf '%s' "$out" | sed -n -E 's/^All ([0-9]+) assertions passed.*/\1/p' | tail -1)
if [ -z "$ran" ]; then
echo
ci_fail "$f printed no assertion total. It exited before its summary — which is what a file that returned halfway through looks like, and it cannot report that about itself."
exit 1
fi
if [ "$ran" -lt "$MIN_SITE_ASSERTIONS" ]; then
echo
ci_fail "$f ran only $ran assertions, expected at least $MIN_SITE_ASSERTIONS. Part of the suite did not run."
exit 1
fi
echo " $ran assertions, at or above the floor of $MIN_SITE_ASSERTIONS"
;;
esac
done
ci_step "PHP notices and deprecations"
+71 -4
View File
@@ -122,8 +122,15 @@ for path in $referenced; do
case "$path" in */*) ;; *) continue ;; esac
[ -d "${path%%/*}" ] || continue
checked=$((checked + 1))
printf '%s\n' "$shipped" | grep -qx "$path" \
|| note_fail "The code loads '$path', but the release zip does not contain it. Committed?"
# `case` rather than piping the list into grep -qx: under pipefail a grep
# that matches can exit 141 when the producer is signalled, so a HIT becomes
# a failure. The list is small today and this loop runs once per include, so
# this also drops a fork per iteration. Newlines on both sides make it an
# exact line match, which is what -qx gave.
case $'\n'"$shipped"$'\n' in
*$'\n'"$path"$'\n'*) ;;
*) note_fail "The code loads '$path', but the release zip does not contain it. Committed?" ;;
esac
done
if [ "$checked" = 0 ]; then
# A check that verifies nothing must say so rather than reporting success.
@@ -134,6 +141,37 @@ else
echo " $checked plugin-relative include(s) checked"
fi
readme=
for f in readme.txt README.txt; do [ -f "$f" ] && readme=$f && break; done
ci_step "the plugin is called the same thing everywhere"
# 🔴 The rename moved the code and left the text behind: the readme still told
# people to look under WooCommerce for a page that had moved to the Nolimit
# menu, and three notices the PLUGIN SHOWS THE USER still used the old name.
# Kristian found it by reading the readme, which is the only reason it was
# found at all — nothing fails when a sentence goes stale.
#
# Same failure as a privacy page standing still while the code moves. The name
# in the header is the one WordPress shows, so it is the one everything else
# has to agree with.
header_name=$(ci_plugin_header 'Plugin Name')
echo " header: $header_name"
readme_name=$(sed -n -E 's/^=== (.+) ===$/\1/p' "$readme" 2>/dev/null | head -1)
echo " $readme: $readme_name"
[ "$header_name" = "$readme_name" ] \
|| note_fail "The plugin header says '$header_name' but $readme says '$readme_name'."
# Old names in text a user reads. The changelog is exempt: an entry describes
# what was true for that release, and rewriting it would be a different lie.
stale=$(./ci/dist-files.sh | grep -E '\.php$' \
| xargs grep -nF "$OLD_PLUGIN_NAME" 2>/dev/null || true)
if [ -n "$stale" ]; then
printf '%s\n' "$stale" | sed 's/^/ /'
note_fail "The old plugin name '$OLD_PLUGIN_NAME' is still in shipped code."
else
echo " no shipped file still says '$OLD_PLUGIN_NAME'"
fi
ci_step "one version, stated once"
# WordPress compares the update feed against the header and nothing else, so a
# constant or a readme that disagrees with it is how a release ends up either
@@ -269,6 +307,36 @@ elif [ "$fails" = "$before" ]; then
echo " $secrets_scanned tracked files read, nothing that looks like a key or a private key"
fi
ci_step "no ci/ script configures the clone it was handed"
# `git config user.name` writes .git/config and OUTLIVES the script. A helper
# that sets an identity before committing therefore renames every later commit
# in that working copy, including a release made by a person days afterwards
# and with no Co-Authored-By trailer. git announces an identity it cannot FIND
# and says nothing about one that is wrong, so the missing case is loud and
# this one is silent: nothing fails, nothing warns, and the author is only
# visible to somebody reading `git log` for another reason.
#
# ci/bump-tested-up-to.sh did this until it was scoped to `git -c … commit`,
# which still signs its own commit as the robot — correct, that one is not a
# person's — without touching the clone. Measured across the family: six
# misattributed hand commits in one sibling, two in another, including
# releases. This tree was clean and one run away.
#
# Any flag, not just --local: --global sets the whole machine's identity, which
# is the same fault one scope wider and reaches every repository on the box.
#
# The rule: a script may configure ITSELF, never the working copy it was
# handed. Reported by the Nolimit Security session 2026-09-25.
scripts_scanned=$(git ls-files 'ci/*.sh' | wc -l)
if [ "$scripts_scanned" -lt 1 ]; then
note_fail "The git-identity scan found no ci/*.sh files at all, so it proved nothing."
elif git ls-files -z 'ci/*.sh' \
| xargs -0 grep -nE '^[^#]*git config([[:space:]]+--[a-z-]+)*[[:space:]]+user\.'; then
note_fail "A ci/ script sets a git identity that outlives it. Scope it to the one commit with 'git -c user.name=… commit', which does not write .git/config."
else
echo " $scripts_scanned scripts in ci/ leave the clone's git identity alone"
fi
ci_step "no placeholders left in the plugin header"
# The header is the plugin's business card: wp-admin shows the author and the
# links to whoever installs it, so a placeholder there survives for as long as
@@ -316,8 +384,7 @@ behind() { # claimed current -> true when claimed is the older of the two
# plugin header, so checking the header for it reports "missing" on a plugin
# that states it correctly. "WC tested up to" is the opposite: WooCommerce
# reads that one from the plugin header and nowhere else.
readme=
for f in readme.txt README.txt; do [ -f "$f" ] && readme=$f && break; done
tested_wp=
[ -n "$readme" ] && tested_wp=$(sed -n -E 's/^Tested up to:[[:space:]]*([^[:space:]]+).*/\1/p' "$readme" | head -1)
+19
View File
@@ -507,6 +507,25 @@ class WC_EDM_DB {
);
}
/**
* Every shipping method any group reserves for itself.
*
* A "show" rule means the group sees that method. It also has to mean
* nobody else does — otherwise reserving a free-delivery method for an
* approved group leaves it on the checkout for every other customer, which
* is what a shop notices as lost shipping revenue rather than as a bug.
*
* @return string[] Rate ids, e.g. "free_shipping:15".
*/
public static function restricted_shipping_methods() {
global $wpdb;
$table = self::table( 'shipping_rules' );
$ids = $wpdb->get_col( "SELECT DISTINCT shipping_method_id FROM {$table} WHERE action = 'show'" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return is_array( $ids ) ? $ids : array();
}
/**
* Replace all shipping rules for a group.
*
+57 -7
View File
@@ -330,15 +330,25 @@ class WC_EDM_Frontend {
public function filter_shipping_rates( $rates, $package ) {
$match = $this->get_current_match();
if ( ! $match ) {
return $rates;
}
if ( 'group' === $match->assignment_type && $match->group_id ) {
// $match is null for a customer who matches nothing — the common case,
// and the one this whole method exists to constrain now.
if ( $match && 'group' === $match->assignment_type && $match->group_id ) {
$rates = $this->apply_group_shipping_rules( $rates, (int) $match->group_id );
} else {
// 🔴 Everyone else — including a matched customer whose assignment
// is not a group — must not see a method some group reserved.
//
// This used to return $rates untouched, and a test asserted that
// as intended: "an unmatched customer sees every method". It made
// a "show" rule a whitelist FOR THE GROUP and nothing more, so
// reserving free delivery for approved addresses left it on the
// checkout for every other customer. Reported from a live shop,
// where a gmail address could pick a free-delivery method meant
// for one company.
$rates = $this->remove_restricted_rates( $rates );
}
if ( ! empty( $match->free_shipping ) ) {
if ( $match && ! empty( $match->free_shipping ) ) {
foreach ( $rates as $rate ) {
$rate->set_cost( 0 );
$rate->set_taxes( array() );
@@ -348,6 +358,33 @@ class WC_EDM_Frontend {
return $rates;
}
/**
* Remove every method some group reserved for itself.
*
* @param array $rates Available shipping rates.
* @return array
*/
private function remove_restricted_rates( $rates ) {
$restricted = WC_EDM_DB::restricted_shipping_methods();
if ( empty( $restricted ) ) {
return $rates;
}
$filtered = array();
foreach ( $rates as $rate_id => $rate ) {
if ( ! in_array( $rate_id, $restricted, true ) ) {
$filtered[ $rate_id ] = $rate;
}
}
// The same guard the group rules carry: a shop that reserved every
// method it offers would otherwise leave an ordinary customer unable
// to check out at all, which is worse than the leak being fixed.
return empty( $filtered ) ? $rates : $filtered;
}
/**
* Apply a group's show/hide shipping rules.
*
@@ -362,8 +399,14 @@ class WC_EDM_Frontend {
private function apply_group_shipping_rules( $rates, $group_id ) {
$rules = WC_EDM_DB::get_shipping_rules( $group_id );
// 🔴 Not `return $rates` — a group with no rules of its own must still
// not see what ANOTHER group reserved. Same for a group carrying only
// "hide" rules: its own shown-set is empty, so the whitelist below
// never engages and every other group's reserved method would survive.
// The leak this fixes was reported for customers matching nothing; it
// existed for these two shapes as well.
if ( empty( $rules ) ) {
return $rates;
return $this->remove_restricted_rates( $rates );
}
$show = array();
@@ -379,6 +422,9 @@ class WC_EDM_Frontend {
$filtered = array();
// Reserved by some group, but not by THIS one.
$others = array_diff( WC_EDM_DB::restricted_shipping_methods(), $show );
foreach ( $rates as $rate_id => $rate ) {
// Whitelist mode: only explicitly shown methods survive.
if ( ! empty( $show ) && ! in_array( $rate_id, $show, true ) ) {
@@ -389,6 +435,10 @@ class WC_EDM_Frontend {
continue;
}
if ( in_array( $rate_id, $others, true ) ) {
continue;
}
$filtered[ $rate_id ] = $rate;
}
+128 -5
View File
@@ -69,7 +69,7 @@ if ( ! defined( 'ABSPATH' ) ) {
}
if ( ! defined( 'WPMGR_MENU_VERSION' ) ) {
define( 'WPMGR_MENU_VERSION', '1.1.0' );
define( 'WPMGR_MENU_VERSION', '1.2.3' );
}
if ( ! defined( 'WPMGR_MENU_SLUG' ) ) {
@@ -160,6 +160,45 @@ if ( ! function_exists( 'wpmgr_ensure_menu' ) ) {
* `font-family` has to be overridden: WordPress sets the icon wrapper to
* the Dashicons font, and Dashicons has no infinity glyph, so inheriting
* it renders nothing at all.
*
* 🔴 The box is WordPress's own, measured rather than guessed, and the
* glyph is centred in it geometrically rather than nudged.
*
* wp-admin/css/admin-menu.css sets `div.wp-menu-image:before` to
* `padding: 7px 0` — exactly 7 + 20 + 7 for a 20px dashicon on
* `line-height: 1` in a 34px box. The first version of this rule set
* `line-height: 1.4` and inherited that padding, making the line box 28px
* and the whole pseudo-element 42px inside a 34px parent. Kristian
* reported the result: the glyph sitting above centre.
*
* So: drop the padding that only existed to position a 20px line, and
* centre with flex. That matters more for an arbitrary glyph than for a
* dashicon — U+221E sits on the maths axis rather than filling its em box,
* so any rule that positions by line-height and padding is positioning the
* box and hoping about the glyph. `align-items: center` centres what is
* actually drawn, which is also what keeps working if anyone returns
* something else from `wpmgr_menu_glyph`.
*
* 🔴 `100%%`, not `100%`. This is a printf format string, so a literal
* per-cent sign has to be doubled — and the first version of the
* percentage fix did not, which is a **fatal on every admin page**:
* "Uncaught ValueError: Unknown format specifier". Three sessions agreed
* on that file after reading it, linting it and running PHPCS over it, and
* all three are blind to this: the syntax is valid and the standard has
* nothing to say. Only running the function finds it, and it was the site
* test that did. The fixed-pixel version could not have had this fault,
* so it arrived with the improvement.
*
* 🔴 The size is `100%`, not the 36x34 the desktop menu happens to be.
* That stylesheet declares the parent THREE times and they disagree:
* `#adminmenu div.wp-menu-image` is 36x34, `.folded #adminmenu
* div.wp-menu-image` is 35x30, and under `@media (max-width: 960px)`
* `.auto-fold #adminmenu div.wp-menu-image` is 34x30. A pseudo-element
* fixed at 36x34 is therefore correct on a wide screen with the menu open
* and 4px too tall in both of the other states — which are the states a
* laptop is in. Percentages resolve against whichever parent is in force,
* so there is no number here to be right about. Measured by reading that
* stylesheet, not by eye.
*/
function wpmgr_menu_glyph_style() {
$glyph = apply_filters( 'wpmgr_menu_glyph', '∞' );
@@ -168,11 +207,84 @@ if ( ! function_exists( 'wpmgr_ensure_menu' ) ) {
return;
}
// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- the slug is reduced to [a-z0-9_-] and the glyph to CSS numeric escapes; both are escaped for the context they are printed in, which is CSS rather than HTML.
printf(
'<style>#adminmenu .toplevel_page_%1$s .wp-menu-image::before{content:"%2$s";font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;font-size:20px;font-weight:400;line-height:1.4}</style>',
esc_attr( WPMGR_MENU_SLUG ),
esc_attr( $glyph )
'<style>#adminmenu .toplevel_page_%1$s .wp-menu-image::before{content:"%2$s";font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;font-size:20px;font-weight:400;display:flex;align-items:center;justify-content:center;width:100%%;height:100%%;padding:0;line-height:1}</style>',
preg_replace( '/[^a-z0-9_-]/', '', strtolower( WPMGR_MENU_SLUG ) ),
wpmgr_css_escape( $glyph )
);
// phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* A string that is safe inside a CSS string literal, whatever it was.
*
* 🔴 esc_attr() is the wrong tool here and it protected by accident.
* Nolimit Discounts measured it: a glyph of `x"} body{display:none}` comes
* out as `x&quot;} …`, the CSS string never closes, and nothing breaks
* out — but only because the entity happens to contain no quote. The CSS
* parser does not decode HTML entities, so it was never escaping in this
* context; it was a substitution that coincidentally removed the one
* character that mattered. Two things follow from that: a glyph containing
* a quote renders as the literal text `&quot;` in the menu, and a single
* backslash — which esc_attr() leaves alone, and which IS an escape to the
* CSS parser — swallows the closing quote and the rest of the rule with
* it. Measured, not reasoned: the string then runs on to the quote in
* "Segoe UI" and the declaration block is corrupt.
*
* So do not filter the input; change its alphabet. Every character becomes
* a CSS numeric escape, so the emitted value contains nothing but
* backslashes, hex digits and spaces and cannot leave the string for any
* input at all. No whitelist to keep honest, no character class to get
* wrong, and an emoji still works.
*
* The decoder is written out rather than using mb_ord() because WordPress
* supports installs without mbstring, and ord() on a multi-byte character
* silently yields the first byte — which would render our own ∞ as
* mojibake on exactly those installs and on no machine any of us test on.
*
* @param string $text Text from a filter, i.e. arbitrary.
* @return string CSS escapes, safe between double quotes.
*/
function wpmgr_css_escape( $text ) {
$out = '';
$len = strlen( $text );
for ( $i = 0; $i < $len; $i++ ) {
$byte = ord( $text[ $i ] );
if ( $byte < 0x80 ) {
$code = $byte;
$tail = 0;
} elseif ( $byte >= 0xF0 ) {
$code = $byte & 0x07;
$tail = 3;
} elseif ( $byte >= 0xE0 ) {
$code = $byte & 0x0F;
$tail = 2;
} elseif ( $byte >= 0xC0 ) {
$code = $byte & 0x1F;
$tail = 1;
} else {
continue; // A stray continuation byte is not a character.
}
while ( $tail > 0 && $i + 1 < $len && 0x80 === ( ord( $text[ $i + 1 ] ) & 0xC0 ) ) {
++$i;
$code = ( $code << 6 ) | ( ord( $text[ $i ] ) & 0x3F );
--$tail;
}
if ( $tail > 0 ) {
continue; // Truncated sequence.
}
// The trailing space terminates the escape, so a following hex
// digit cannot be read as part of it.
$out .= sprintf( '\\%X ', $code );
}
return $out;
}
/**
@@ -225,8 +337,19 @@ if ( ! function_exists( 'wpmgr_ensure_menu' ) ) {
. '.wpmgr-headline{font-size:22px;font-weight:600;margin:0 0 10px}'
. '.wpmgr-card dl{display:grid;grid-template-columns:1fr auto;gap:4px 12px;margin:0;font-size:13px}'
. '.wpmgr-card dt{color:#646970;margin:0}.wpmgr-card dd{margin:0;text-align:right}'
// 🔴 A DARK overlay, not a light one. White 11px text on
// rgba(255,255,255,.22) over a card colour is 3.45:1 on this
// plugin's green and 3.42:1 on a blue — under the 4.5:1 that small
// text needs, on every family colour dark enough to need white
// text in the first place. rgba(0,0,0,.18) takes the same four
// colours to 6.94–9.37:1, because darkening a background that
// already carries white text can only help.
//
// Measured rather than reasoned: the composite was read back out
// of a canvas in headless Chromium, not computed — #50977f for the
// old rule, #19644b for this one.
. '.wpmgr-state{font-size:11px;text-transform:uppercase;letter-spacing:.04em;'
. 'background:rgba(255,255,255,.22);padding:2px 8px;border-radius:10px}'
. 'background:rgba(0,0,0,.18);padding:2px 8px;border-radius:10px}'
. '.wpmgr-card footer{padding:12px 18px;border-top:1px solid #f0f0f1}'
. '</style>';
+171 -10
View File
@@ -31,6 +31,7 @@ if ( ! defined( 'ABSPATH' ) ) {
* that it can.
*/
$GLOBALS['wc_edm_failures'] = 0;
$GLOBALS['wc_edm_passes'] = 0;
( static function () { ++$GLOBALS['wc_edm_failures']; } )();
if ( 1 !== $GLOBALS['wc_edm_failures'] ) {
echo "The failure counter does not reach the verdict: this file cannot go red.\n";
@@ -47,6 +48,7 @@ $GLOBALS['wc_edm_failures'] = 0;
*/
function wc_edm_ok( $what, $condition, $detail = '' ) {
if ( $condition ) {
++$GLOBALS['wc_edm_passes'];
printf( " ok %s\n", $what );
return;
}
@@ -194,9 +196,78 @@ WC()->customer->set_billing_email( 'someone@company.com' );
$filtered = wc_edm()->frontend->filter_shipping_rates( $rates, array() );
wc_edm_is( 'a "show" rule hides every method it does not name', array( 'flat_rate:1' ), array_keys( $filtered ) );
// 🔴 This used to assert "an unmatched customer sees every method", which is
// what made a reserved free-delivery method visible to everybody on a live
// shop. A "show" rule now means the method belongs to that group.
WC()->customer->set_billing_email( 'nobody@nowhere.test' );
$filtered = wc_edm()->frontend->filter_shipping_rates( $rates, array() );
wc_edm_is( 'an unmatched customer sees every method', 2, count( $filtered ) );
wc_edm_is(
'an unmatched customer does not see a method a group reserved',
array( 'local_pickup:2' ),
array_keys( $filtered )
);
// The guard that keeps the fix from being worse than the leak: a shop that
// reserved everything it offers must still let an ordinary customer check out.
WC_EDM_DB::replace_shipping_rules(
(int) $fixtures['group_id'],
array(
array( 'shipping_method_id' => 'flat_rate:1', 'action' => 'show', 'sort_order' => 0 ),
array( 'shipping_method_id' => 'local_pickup:2', 'action' => 'show', 'sort_order' => 1 ),
)
);
$filtered = wc_edm()->frontend->filter_shipping_rates( $rates, array() );
wc_edm_is( 'but is never left with no shipping at all', 2, count( $filtered ) );
WC_EDM_DB::replace_shipping_rules(
(int) $fixtures['group_id'],
array( array( 'shipping_method_id' => 'flat_rate:1', 'action' => 'show', 'sort_order' => 0 ) )
);
// A SECOND group, with no rules of its own. Its members must not see what the
// first group reserved — the leak was reported for customers matching nothing,
// but it existed for this shape too, and for a group carrying only "hide"
// rules, because neither engages the whitelist.
$wc_edm_other = WC_EDM_DB::insert_group( array( 'name' => 'Ruleless', 'discount_percent' => 5 ) );
WC_EDM_DB::insert_email(
array(
'email_pattern' => 'ruleless@elsewhere.test',
'assignment_type' => 'group',
'group_id' => (int) $wc_edm_other,
'coupon_code' => null,
'custom_discount' => null,
'is_active' => 1,
'free_shipping' => 0,
)
);
WC()->customer->set_billing_email( 'ruleless@elsewhere.test' );
$filtered = wc_edm()->frontend->filter_shipping_rates( $rates, array() );
wc_edm_is(
'a group with no rules still does not see another group reserved method',
array( 'local_pickup:2' ),
array_keys( $filtered )
);
// A third rate, deliberately: with only two, hiding one and reserving the
// other leaves nothing, the "never leave the customer without shipping" guard
// fires, and every rate comes back — which reads as a leak but is the guard
// working. The fixture has to leave something for the customer to be given.
$wc_edm_rates3 = $rates;
$wc_edm_rates3['flat_rate:9'] = new WC_Shipping_Rate( 'flat_rate:9', 'Another flat rate', 12, array(), 'flat_rate' );
WC_EDM_DB::replace_shipping_rules(
(int) $wc_edm_other,
array( array( 'shipping_method_id' => 'local_pickup:2', 'action' => 'hide', 'sort_order' => 0 ) )
);
$filtered = wc_edm()->frontend->filter_shipping_rates( $wc_edm_rates3, array() );
wc_edm_ok(
'and neither does a group carrying only hide rules',
! in_array( 'flat_rate:1', array_keys( $filtered ), true ),
'saw: ' . implode( ', ', array_keys( $filtered ) )
);
WC()->customer->set_billing_email( 'someone@company.com' );
WC_EDM_DB::replace_shipping_rules( (int) $fixtures['group_id'], array() );
@@ -278,13 +349,16 @@ wc_edm_ok(
// The other half: the by-hand require above would pass even if the plugin
// never loaded the file in a real admin request. This is the assertion that
// covers that, and it is the half a reduced repro always omits.
//
// Read from the list the plugin actually loads from, NOT searched for in this
// file's source text. The previous version did the latter, and a comment
// mentioning that path would have satisfied it — the same hollow shape as
// grepping a file for the words of the guard it is supposed to have, which is
// a bug this kit has already had once today.
wc_edm_ok(
'and the plugin itself loads the shared menu file in admin',
false !== strpos(
file_get_contents( WC_EDM_PLUGIN_FILE ),
"'includes/shared-menu.php',"
),
'it is not in the is_admin() branch of includes(), so nothing would define wpmgr_ensure_menu()'
in_array( 'includes/shared-menu.php', WC_Email_Discount_Manager::admin_includes(), true ),
'admin includes: ' . implode( ', ', WC_Email_Discount_Manager::admin_includes() )
);
// The card on the shared dashboard. Absent from it is a different failure from
@@ -314,6 +388,68 @@ wc_edm_is(
isset( $wc_edm_mine[0]['colour'] ) ? $wc_edm_mine[0]['colour'] : null
);
// 🔴 The style function is INVOKED, not read. A version of this file shipped
// with `width:100%` inside a printf format string, where a literal per-cent
// must be doubled — an uncaught ValueError on every admin page. Three sessions
// read it, php -l was clean, PHPCS was clean, and two browsers agreed about
// the CSS, because all of them were fed the stylesheet by hand rather than
// through the PHP that produces it. Only running it finds this.
if ( function_exists( 'wpmgr_menu_glyph_style' ) ) {
$wc_edm_threw = '';
ob_start();
try {
wpmgr_menu_glyph_style();
} catch ( \Throwable $e ) {
$wc_edm_threw = get_class( $e ) . ': ' . $e->getMessage();
}
$wc_edm_style = ob_get_clean();
wc_edm_ok(
'emitting the menu glyph style throws nothing',
'' === $wc_edm_threw,
$wc_edm_threw
);
wc_edm_ok(
'and what it emits is a style element carrying the glyph',
false !== strpos( $wc_edm_style, '<style>' )
&& false !== strpos( $wc_edm_style, '</style>' )
&& false !== strpos( $wc_edm_style, '\\221E' ),
'emitted: ' . substr( $wc_edm_style, 0, 120 )
);
} else {
echo "::warning::wpmgr_menu_glyph_style() is not defined, so the glyph style was not exercised.\n";
}
// 🔴 The dashboard is RENDERED and the pill colour read out of what it
// emitted. Asserting the colour alone is not enough: the renderer returns
// early when no card is registered, and a harness that stubs the wrong filter
// name then reports "no problem" over a code path it never entered. Three
// sessions hit exactly that. So the card's own title is asserted first — if
// that is absent the renderer bailed, and nothing after it means anything.
if ( function_exists( 'wpmgr_render_dashboard' ) ) {
ob_start();
wpmgr_render_dashboard();
$wc_edm_dash = ob_get_clean();
wc_edm_ok(
'the shared dashboard renders this plugin card',
false !== strpos( $wc_edm_dash, 'Discounts' )
&& false !== strpos( $wc_edm_dash, 'wpmgr-card' ),
'rendered ' . strlen( $wc_edm_dash ) . ' bytes'
);
// The status pill is white text composited over the card colour, so 22%
// white put this plugin's green at 3.50:1. Named as the literal the file
// must contain, and the OLD value is asserted absent — a check that cannot
// tell the two versions apart cannot testify about either.
wc_edm_ok(
'and the status pill is the contrast-safe background',
false !== strpos( $wc_edm_dash, 'rgba(0,0,0,.18)' )
&& false === strpos( $wc_edm_dash, 'rgba(255,255,255,.22)' ),
'pill rule in output: ' . ( false !== strpos( $wc_edm_dash, 'rgba(255,255,255,.22)' ) ? 'the old 22% white' : 'not found at all' )
);
}
// The frozen hash of includes/shared-menu.php is asserted in ci/static.sh
// against WPMGR_MENU_SHA in ci/plugin.conf, and deliberately NOT repeated
// here: two copies of the same constant drift, and the one that drifts is the
@@ -390,10 +526,21 @@ wc_edm_is(
$update_uri . '/releases/download/v1.4.0/woocommerce-email-discount-manager.zip',
$parsed['package']
);
wc_edm_ok(
// Asserted as the host, not as the absence of one particular IP address.
// Gitea's ROOT_URL has been a raw address before, and the package URL it
// reports carries whatever that is — so a check that names today's IP is blind
// to tomorrow's. What matters is that the URL points at the host we publish
// from, however the forge chose to describe itself.
$wc_edm_host = wp_parse_url( $parsed['package'], PHP_URL_HOST );
wc_edm_is(
'so it is never the raw address the forge happened to report',
false === strpos( $parsed['package'], '57.129.89.117' ) && 0 === strpos( $parsed['package'], 'https://' ),
'got ' . $parsed['package']
'gitea.nolimit.dk',
$wc_edm_host
);
wc_edm_is(
'and it is fetched over https',
'https',
wp_parse_url( $parsed['package'], PHP_URL_SCHEME )
);
wc_edm_is( 'and the tested-up-to line is read out of the release notes', '7.1', $parsed['tested'] );
wc_edm_ok( 'a release with no zip attached is not offered at all', null === $parse->invoke( new WC_EDM_Updater(), array( 'tag_name' => 'v9.9.9', 'assets' => array() ) ) );
@@ -654,8 +801,22 @@ if ( $GLOBALS['wc_edm_failures'] > 0 ) {
printf( "%d assertion(s) failed on WordPress %s.\n", $GLOBALS['wc_edm_failures'], get_bloginfo( 'version' ) );
exit( 1 );
}
// The number, not just the word. "All assertions passed" is also what a file
// that asserted nothing prints — which is the failure this whole suite exists
// to catch everywhere else, and it was in the summary line the entire time.
// A floor as well, for the same reason every count in ci/ has one: a run that
// died early after a handful of assertions would otherwise read as a pass.
if ( $GLOBALS['wc_edm_passes'] < 60 ) {
printf(
"Only %d assertions ran, which is too few to be a complete run.\n",
$GLOBALS['wc_edm_passes']
);
exit( 1 );
}
printf(
"All assertions passed on WordPress %s with WooCommerce %s.\n",
"All %d assertions passed on WordPress %s with WooCommerce %s.\n",
$GLOBALS['wc_edm_passes'],
get_bloginfo( 'version' ),
defined( 'WC_VERSION' ) ? WC_VERSION : '?'
);
+29 -17
View File
@@ -4,7 +4,7 @@
* Plugin URI: https://gitea.nolimit.dk/kw/woo-discount-manager
* Update URI: https://gitea.nolimit.dk/kw/nolimit-discounts-dist
* Description: Apply discounts and show/hide shipping methods based on the customer's email address (full address, domain or partial match).
* Version: 1.5.0
* Version: 1.6.2
* Author: Kristian Wind
* Author URI: https://gitea.nolimit.dk/kw
* License: GPL-2.0-or-later
@@ -12,7 +12,7 @@
* Requires at least: 5.8
* Requires PHP: 7.4
* WC requires at least: 6.0
* WC tested up to: 11.1.0
* WC tested up to: 11.1.2
* Requires Plugins: woocommerce
*/
@@ -28,7 +28,7 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
define( 'WC_EDM_VERSION', '1.5.0' );
define( 'WC_EDM_VERSION', '1.6.2' );
define( 'WC_EDM_PLUGIN_FILE', __FILE__ );
define( 'WC_EDM_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'WC_EDM_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
@@ -211,7 +211,7 @@ final class WC_Email_Discount_Manager {
}
echo '<div class="notice notice-error"><p>';
echo esc_html__( 'WooCommerce Email Discount Manager could not load all of its files, so some features are unavailable. If you just updated the plugin, reload this page — the upload may still have been in progress. If the warning persists, upload the plugin again.', 'wc-email-discount' );
echo esc_html__( 'Nolimit Discounts could not load all of its files, so some features are unavailable. If you just updated the plugin, reload this page — the upload may still have been in progress. If the warning persists, upload the plugin again.', 'wc-email-discount' );
echo '</p><p><code>';
echo implode( '</code>, <code>', array_map( 'esc_html', $this->missing_files ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '</code></p></div>';
@@ -220,6 +220,28 @@ final class WC_Email_Discount_Manager {
/**
* Load class files.
*/
/**
* The files loaded only in an admin request.
*
* A method rather than a literal inside includes() so a test can read the
* actual list. The assertion it replaces searched this file's SOURCE TEXT
* for the shared menu's path, which a comment mentioning that path would
* have satisfied just as well — the same hollow shape as grepping a file
* for the words of the guard it is supposed to have.
*
* @return string[]
*/
public static function admin_includes() {
return array(
'includes/shared-menu.php',
'includes/class-admin.php',
'includes/class-admin-groups.php',
'includes/class-admin-emails.php',
'includes/class-admin-shipping.php',
'includes/class-admin-audit.php',
);
}
private function includes() {
$files = array(
'includes/class-db.php',
@@ -231,17 +253,7 @@ final class WC_Email_Discount_Manager {
);
if ( is_admin() ) {
$files = array_merge(
$files,
array(
'includes/shared-menu.php',
'includes/class-admin.php',
'includes/class-admin-groups.php',
'includes/class-admin-emails.php',
'includes/class-admin-shipping.php',
'includes/class-admin-audit.php',
)
);
$files = array_merge( $files, self::admin_includes() );
}
foreach ( $files as $file ) {
@@ -271,7 +283,7 @@ final class WC_Email_Discount_Manager {
*/
public function woocommerce_missing_notice() {
echo '<div class="notice notice-error"><p>';
esc_html_e( 'WooCommerce Email Discount Manager requires WooCommerce to be installed and active.', 'wc-email-discount' );
esc_html_e( 'Nolimit Discounts requires WooCommerce to be installed and active.', 'wc-email-discount' );
echo '</p></div>';
}
@@ -284,7 +296,7 @@ final class WC_Email_Discount_Manager {
if ( ! class_exists( 'WooCommerce' ) && ! in_array( 'woocommerce/woocommerce.php', $active_plugins, true ) ) {
deactivate_plugins( plugin_basename( WC_EDM_PLUGIN_FILE ) );
wp_die(
esc_html__( 'WooCommerce Email Discount Manager requires WooCommerce to be installed and active. Please activate WooCommerce first.', 'wc-email-discount' ),
esc_html__( 'Nolimit Discounts requires WooCommerce to be installed and active. Please activate WooCommerce first.', 'wc-email-discount' ),
esc_html__( 'Plugin activation error', 'wc-email-discount' ),
array( 'back_link' => true )
);