|
4 | 4 |
|
5 | 5 | namespace Sbpp\Tests\Unit; |
6 | 6 |
|
| 7 | +use PHPUnit\Framework\Attributes\DataProvider; |
7 | 8 | use PHPUnit\Framework\TestCase; |
8 | 9 | use Sbpp\Version; |
9 | 10 |
|
@@ -151,4 +152,191 @@ public function testMissingVersionJsonFallsThrough(): void |
151 | 152 |
|
152 | 153 | $this->assertSame(Version::DEV_SENTINEL, $resolved['version']); |
153 | 154 | } |
| 155 | + |
| 156 | + /** |
| 157 | + * Issue #1305 — the canonical "v1.x file preserved through a |
| 158 | + * v1→v2 upgrade overlay" case. The v1.x repo carried |
| 159 | + * `web/configs/version.json` as a checked-in, hand-edited file |
| 160 | + * with `{"version": "1.8.1", "git": "1434"}`; that file was |
| 161 | + * deleted from `main` in #1070 (commit `9d1caefd`, May 2 2026) |
| 162 | + * with the release workflow taking over file ownership at build |
| 163 | + * time. An operator who upgraded a v1.8 install to v2.0 with a |
| 164 | + * "skip if exists" overlay tool (FTP, `rsync` without `--delete`, |
| 165 | + * a manual directory-by-directory copy that treats `configs/` as |
| 166 | + * user data) keeps the stale file on disk; pre-#1305 the |
| 167 | + * resolver returned that file's contents verbatim and the chrome |
| 168 | + * footer read `SourceBans++ 1.8.1 | Git: 1434` on a v2.0 install. |
| 169 | + * |
| 170 | + * The fix: tier-1 input is gated by the major-component floor |
| 171 | + * (`Version::MIN_TIER1_MAJOR`). Anything below the floor falls |
| 172 | + * through to tier-2 (`git describe`) so the operator sees the |
| 173 | + * actual codebase version (or the `'dev'` sentinel — see the |
| 174 | + * sibling test below — instead of phantom v1.x copy. |
| 175 | + */ |
| 176 | + public function testStaleV1JsonFallsThroughToGitDescribe(): void |
| 177 | + { |
| 178 | + $resolved = Version::resolve( |
| 179 | + versionJsonPath: '/whatever', |
| 180 | + jsonReader: static fn (): array => [ |
| 181 | + 'version' => '1.8.1', |
| 182 | + 'git' => '1434', |
| 183 | + ], |
| 184 | + gitDescribe: static fn (): string => "v2.0.0\n", |
| 185 | + gitShortRev: static fn (): string => "abc1234\n", |
| 186 | + ); |
| 187 | + |
| 188 | + $this->assertSame('v2.0.0', $resolved['version']); |
| 189 | + $this->assertSame('abc1234', $resolved['git']); |
| 190 | + } |
| 191 | + |
| 192 | + /** |
| 193 | + * Companion to the test above — same stale-tier-1 case but on a |
| 194 | + * production install where git isn't available (no `.git` dir, |
| 195 | + * no `git` binary in the image). The fall-through cascade lands |
| 196 | + * at tier-3, the `'dev'` sentinel. That's not a perfect outcome |
| 197 | + * (the operator sees `dev` instead of the actual `2.0.0` they |
| 198 | + * deployed), but it's a self-describing signal that something |
| 199 | + * is wrong with the install metadata — and crucially, it's NOT |
| 200 | + * the phantom v1.x string that telemetry / bug reports / E2E |
| 201 | + * specs would key off. |
| 202 | + */ |
| 203 | + public function testStaleV1JsonFallsThroughToDevSentinel(): void |
| 204 | + { |
| 205 | + $resolved = Version::resolve( |
| 206 | + versionJsonPath: '/whatever', |
| 207 | + jsonReader: static fn (): array => [ |
| 208 | + 'version' => '1.8.1', |
| 209 | + 'git' => '1434', |
| 210 | + ], |
| 211 | + gitDescribe: static fn (): string => '', |
| 212 | + gitShortRev: static fn (): string => '', |
| 213 | + ); |
| 214 | + |
| 215 | + $this->assertSame(Version::DEV_SENTINEL, $resolved['version']); |
| 216 | + $this->assertSame(0, $resolved['git']); |
| 217 | + } |
| 218 | + |
| 219 | + /** |
| 220 | + * Boundary — a tier-1 file exactly at the floor (`2.0.0`) is |
| 221 | + * accepted. The release workflow writes the git tag verbatim, so |
| 222 | + * the v2.0.0 tarball's `version.json` reads `"version": "2.0.0"` |
| 223 | + * exactly — that's the case this guards. |
| 224 | + */ |
| 225 | + public function testTarballJsonAtFloorMajorIsAccepted(): void |
| 226 | + { |
| 227 | + $resolved = Version::resolve( |
| 228 | + versionJsonPath: '/whatever', |
| 229 | + jsonReader: static fn (): array => [ |
| 230 | + 'version' => '2.0.0', |
| 231 | + 'git' => 'abc1234', |
| 232 | + ], |
| 233 | + gitDescribe: static fn (): string => self::fail('git describe must not run when JSON tier-1 is acceptable'), |
| 234 | + gitShortRev: static fn (): string => self::fail('git rev-parse must not run when JSON tier-1 is acceptable'), |
| 235 | + ); |
| 236 | + |
| 237 | + $this->assertSame('2.0.0', $resolved['version']); |
| 238 | + $this->assertSame('abc1234', $resolved['git']); |
| 239 | + } |
| 240 | + |
| 241 | + /** |
| 242 | + * Pre-release tags within the current major (`2.0.0-rc.1`, |
| 243 | + * `2.1.0-beta.3`) are accepted. The release.yml regex allows |
| 244 | + * `^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?...` so a v2.0.0-rc.1 |
| 245 | + * tarball is a real shape. Compared to a strict |
| 246 | + * `version_compare(jsonVersion, '2.0.0', '>=')` floor — which |
| 247 | + * would reject `2.0.0-rc.1` because PHP / semver sort |
| 248 | + * pre-release identifiers BEFORE the release — major-only |
| 249 | + * comparison correctly admits them. |
| 250 | + */ |
| 251 | + public function testTarballJsonPreReleaseWithinCurrentMajorIsAccepted(): void |
| 252 | + { |
| 253 | + $resolved = Version::resolve( |
| 254 | + versionJsonPath: '/whatever', |
| 255 | + jsonReader: static fn (): array => [ |
| 256 | + 'version' => '2.0.0-rc.1', |
| 257 | + 'git' => 'abc1234', |
| 258 | + ], |
| 259 | + gitDescribe: static fn (): string => self::fail('git describe must not run when pre-release JSON is acceptable'), |
| 260 | + gitShortRev: static fn (): string => self::fail('git rev-parse must not run when pre-release JSON is acceptable'), |
| 261 | + ); |
| 262 | + |
| 263 | + $this->assertSame('2.0.0-rc.1', $resolved['version']); |
| 264 | + $this->assertSame('abc1234', $resolved['git']); |
| 265 | + } |
| 266 | + |
| 267 | + /** |
| 268 | + * Forward-compat — a future major (3.x.y) is accepted without a |
| 269 | + * code change here. The floor is a *minimum*, not a *match*; |
| 270 | + * future v3 tarballs are perfectly trustworthy on the current v2 |
| 271 | + * codebase as long as the deployment is consistent (which is the |
| 272 | + * operator's contract — the floor is here to catch the *backward* |
| 273 | + * mismatch that #1305 surfaces, not to enforce same-major). |
| 274 | + */ |
| 275 | + public function testTarballJsonFutureMajorIsAccepted(): void |
| 276 | + { |
| 277 | + $resolved = Version::resolve( |
| 278 | + versionJsonPath: '/whatever', |
| 279 | + jsonReader: static fn (): array => [ |
| 280 | + 'version' => '3.0.0', |
| 281 | + 'git' => 'def5678', |
| 282 | + ], |
| 283 | + gitDescribe: static fn (): string => self::fail('git describe must not run when forward-compat JSON is acceptable'), |
| 284 | + gitShortRev: static fn (): string => self::fail('git rev-parse must not run when forward-compat JSON is acceptable'), |
| 285 | + ); |
| 286 | + |
| 287 | + $this->assertSame('3.0.0', $resolved['version']); |
| 288 | + $this->assertSame('def5678', $resolved['git']); |
| 289 | + } |
| 290 | + |
| 291 | + /** |
| 292 | + * Defensive — a malformed `version` field (empty string, free |
| 293 | + * text, the `'dev'` sentinel itself somehow slipping into the |
| 294 | + * JSON, a hand-edited "unknown" placeholder) doesn't match the |
| 295 | + * `^v?\d+` shape and falls through. The release.yml regex |
| 296 | + * enforces full semver on the build tag so a real tarball can |
| 297 | + * never produce these, but operator hand-edits and corrupted |
| 298 | + * JSON are real-world failure modes worth pinning. |
| 299 | + */ |
| 300 | + #[DataProvider('provideMalformedTier1Versions')] |
| 301 | + public function testTarballJsonMalformedFallsThrough(string $malformedVersion): void |
| 302 | + { |
| 303 | + $resolved = Version::resolve( |
| 304 | + versionJsonPath: '/whatever', |
| 305 | + jsonReader: static fn () => [ |
| 306 | + 'version' => $malformedVersion, |
| 307 | + 'git' => 'abc1234', |
| 308 | + ], |
| 309 | + gitDescribe: static fn (): string => 'v2.0.0', |
| 310 | + gitShortRev: static fn (): string => 'def5678', |
| 311 | + ); |
| 312 | + |
| 313 | + $this->assertSame('v2.0.0', $resolved['version'], "malformed tier-1 version '$malformedVersion' should fall through to tier-2"); |
| 314 | + $this->assertSame('def5678', $resolved['git']); |
| 315 | + } |
| 316 | + |
| 317 | + /** |
| 318 | + * @return iterable<string, array{0: string}> |
| 319 | + */ |
| 320 | + public static function provideMalformedTier1Versions(): iterable |
| 321 | + { |
| 322 | + yield 'empty string' => ['']; |
| 323 | + yield 'unknown placeholder' => ['unknown']; |
| 324 | + yield 'dev sentinel leaked into JSON' => ['dev']; |
| 325 | + yield 'free-text edit' => ['SourceBans++']; |
| 326 | + yield 'leading dot' => ['.0.0']; |
| 327 | + yield 'wrong prefix' => ['ver1.8.1']; |
| 328 | + } |
| 329 | + |
| 330 | + /** |
| 331 | + * Pin the floor constant explicitly. A future bump of the floor |
| 332 | + * (say, when v3.0 ships and the maintainer wants to start |
| 333 | + * rejecting v2.x stale files in v3.x installs the same way #1305 |
| 334 | + * rejects v1.x in v2.x) shows up here as a deliberate test edit |
| 335 | + * rather than a silent constant change. Keeps the rationale |
| 336 | + * paired with the bump. |
| 337 | + */ |
| 338 | + public function testFloorConstantIsTwo(): void |
| 339 | + { |
| 340 | + $this->assertSame(2, Version::MIN_TIER1_MAJOR); |
| 341 | + } |
154 | 342 | } |
0 commit comments