diff --git a/.gitattributes b/.gitattributes index fb641b1..6c541c6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -24,3 +24,7 @@ /docs export-ignore /.dev export-ignore /phpstan.neon.dist export-ignore + +# Licence files whose sha256 is recorded in config.json stay byte-for-byte +# as vendored: no line-ending normalisation on any checkout. +/resources/assets/svg/licenses/** -text diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index a9911bc..2ec6770 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -34,6 +34,8 @@ jobs: # (SiblingConstraintsTest). CI aliases the siblings' main into that line so # this repository is tested against what it will ship beside, and restores # composer.json after the install so tests read the committed constraints. + # Until the re-squash tags core again, no core tag carries + # Providers\IconPackServiceProvider, so this alias is what a pack needs to boot. run: composer require "ichava/core:dev-main as 0.1.99" --no-update --no-interaction - name: Install dependencies @@ -68,6 +70,8 @@ jobs: # (SiblingConstraintsTest). CI aliases the siblings' main into that line so # this repository is tested against what it will ship beside, and restores # composer.json after the install so tests read the committed constraints. + # Until the re-squash tags core again, no core tag carries + # Providers\IconPackServiceProvider, so this alias is what a pack needs to boot. run: composer require "ichava/core:dev-main as 0.1.99" --no-update --no-interaction - name: Install dependencies @@ -102,6 +106,8 @@ jobs: # (SiblingConstraintsTest). CI aliases the siblings' main into that line so # this repository is tested against what it will ship beside, and restores # composer.json after the install so tests read the committed constraints. + # Until the re-squash tags core again, no core tag carries + # Providers\IconPackServiceProvider, so this alias is what a pack needs to boot. run: composer require "ichava/core:dev-main as 0.1.99" --no-update --no-interaction - name: Install dependencies diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index c1da79a..d58a8fe 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -36,6 +36,8 @@ jobs: # (SiblingConstraintsTest). CI aliases the siblings' main into that line so # this repository is tested against what it will ship beside, and restores # composer.json after the install so tests read the committed constraints. + # Until the re-squash tags core again, no core tag carries + # Providers\IconPackServiceProvider, so this alias is what a pack needs to boot. run: composer require "ichava/core:dev-main as 0.1.99" --no-update --no-interaction - name: Install dependencies diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml index 094e751..ce1c621 100644 --- a/.github/workflows/sync-upstream.yml +++ b/.github/workflows/sync-upstream.yml @@ -10,7 +10,7 @@ # # A human reviews the PR diff and merges. release-please then opens the # release PR, and its tag starts release.yml, which publishes the GitHub -# release. Packagist reads the tag and lists the new version. +# release. Nothing is published to Packagist; installs come from the tags. # # The pin is a full commit SHA of icon-sets-sync, and `toolkit-ref` must be # the same commit. Dependabot (github-actions) advances the `uses:` pin; diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3c53384..a86cdd1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -50,6 +50,8 @@ jobs: # (SiblingConstraintsTest). CI aliases the siblings' main into that line so # this repository is tested against what it will ship beside, and restores # composer.json after the install so tests read the committed constraints. + # Until the re-squash tags core again, no core tag carries + # Providers\IconPackServiceProvider, so this alias is what a pack needs to boot. run: composer require "ichava/core:dev-main as 0.1.99" --no-update --no-interaction - name: Install dependencies diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..57cb813 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,35 @@ +# Security policy + +Do not report a suspected vulnerability in a public issue, discussion or pull request. + +## Reporting a vulnerability + +**Use GitHub private vulnerability reporting.** On this repository's +[Security tab](https://github.com/ichava/icon-sets-bootstrap/security/policy), choose +*Report a vulnerability*. The report arrives attached to the repository, with a +draft advisory and a CVE request path in place. It is off by default, so switch +it on once the repository is public, and keep naming it here only while the +button is there: + +```bash +gh api -X PUT repos/ichava/icon-sets-bootstrap/private-vulnerability-reporting +``` + +**Or email [security@simtabi.com](mailto:security@simtabi.com).** That is the only +disclosure address, and it works whether or not the button is available. Other +Simtabi addresses, including `ichava@simtabi.com`, handle community mail and +may delay a report. + +Response times, coordinated disclosure, safe harbor and credit follow the +[organisation security policy](https://github.com/ichava/.github/blob/main/SECURITY.md). + +## Scope + +This package ships icon assets and the provider that registers them with +`ichava/core`. + +Core owns sanitisation, caching, seeding and the HTTP surface, so a defect in +how an icon is sanitised, cached or served belongs to +[ichava/core](https://github.com/ichava/core) rather than here. What belongs +here is the vendored assets themselves, this package's own registration code, +and anything in its build or sync tooling that widens what a refresh can reach. diff --git a/composer.json b/composer.json index 095e510..96c3bc0 100644 --- a/composer.json +++ b/composer.json @@ -48,9 +48,9 @@ ], "require-dev": { "composer/semver": "^3.4", + "laravel/pint": "^1.18", "orchestra/testbench": "^11.0", "pestphp/pest": "^4.6 || ^5.0", - "laravel/pint": "^1.18", "phpstan/phpstan": "^2.1", "symfony/yaml": "^7.0 || ^8.0" }, diff --git a/src/Providers/IconsServiceProvider.php b/src/Providers/IconsServiceProvider.php index d80685e..7f2837d 100644 --- a/src/Providers/IconsServiceProvider.php +++ b/src/Providers/IconsServiceProvider.php @@ -4,7 +4,7 @@ namespace Simtabi\Laranail\Ichava\IconSetsBootstrap\Providers; -use Simtabi\Laranail\Ichava\Support\IconPackServiceProvider; +use Simtabi\Laranail\Ichava\Providers\IconPackServiceProvider; use Simtabi\Laranail\Ichava\IconSetsBootstrap\Constants\IconsConstants; use Simtabi\Laranail\Ichava\IconSetsBootstrap\View\Components\IconComponent; diff --git a/tests/TestCase.php b/tests/TestCase.php index a94d5f7..8f1f8d5 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,87 +4,17 @@ namespace Simtabi\Laranail\Ichava\IconSetsBootstrap\Tests; -use Orchestra\Testbench\TestCase as Orchestra; -use Simtabi\Laranail\Ichava\Providers\IchavaServiceProvider; +use Simtabi\Laranail\Ichava\Testing\PackTestCase; use Simtabi\Laranail\Ichava\IconSetsBootstrap\Providers\IconsServiceProvider; -abstract class TestCase extends Orchestra +/** + * Boots core and this pack, on the database `DB_CONNECTION` names and a + * non-serialising array cache: core's PackTestCase, which every pack shares. + */ +abstract class TestCase extends PackTestCase { - protected function getPackageProviders($app): array + protected function packProvider(): string { - return [ - IchavaServiceProvider::class, - IconsServiceProvider::class, - ]; - } - - protected function defineEnvironment($app): void - { - $this->configureDatabase($app); - $this->configureCache($app); - } - - /** - * Point the suite at whichever database `DB_CONNECTION` names. - * - * This pack's own tests do not reach the database -- they assert that the provider - * boots, that the constants resolve from `config.json`, and that the enum helpers use - * the configured prefix. The harness still matches `ichava/core` and `ichava/icon-browser` - * so that `DB_CONNECTION=pgsql vendor/bin/pest` means the same thing in every PHP - * package here, and a pack test that does reach the database needs no new plumbing. - */ - protected function configureDatabase($app): void - { - $driver = env('DB_CONNECTION', 'sqlite'); - - $app['config']->set('database.default', 'testing'); - $app['config']->set('database.connections.testing', match ($driver) { - 'pgsql' => [ - 'driver' => 'pgsql', - 'host' => env('DB_HOST', '127.0.0.1'), - 'port' => env('DB_PORT', '5432'), - 'database' => env('DB_DATABASE', 'ichava_test'), - 'username' => env('DB_USERNAME', 'ichava'), - 'password' => env('DB_PASSWORD', 'secret'), - 'charset' => 'utf8', - 'prefix' => '', - 'search_path' => 'public', - 'sslmode' => 'prefer', - ], - 'mysql', 'mariadb' => [ - 'driver' => $driver, - 'host' => env('DB_HOST', '127.0.0.1'), - 'port' => env('DB_PORT', '3306'), - 'database' => env('DB_DATABASE', 'ichava_test'), - 'username' => env('DB_USERNAME', 'ichava'), - 'password' => env('DB_PASSWORD', 'secret'), - 'charset' => 'utf8mb4', - 'collation' => 'utf8mb4_unicode_ci', - 'prefix' => '', - 'strict' => true, - 'engine' => 'InnoDB', - ], - default => [ - 'driver' => 'sqlite', - 'database' => env('DB_DATABASE', ':memory:'), - 'prefix' => '', - - // Off by default on SQLite, and Laravel only enables them when the key is - // present, so without this the engine's cascading deletes go unenforced. - 'foreign_key_constraints' => true, - ], - }); - } - - protected function configureCache($app): void - { - // The array store returns the object it was given, so nothing here crosses - // serialize()/unserialize(). A test that depends on that boundary must switch to a - // serialising store itself. - $app['config']->set('cache.default', 'array'); - $app['config']->set('cache.stores.array', [ - 'driver' => 'array', - 'serialize' => false, - ]); + return IconsServiceProvider::class; } } diff --git a/tests/Unit/CoreOnlyDependencyTest.php b/tests/Unit/CoreOnlyDependencyTest.php index 21a6d36..52ccc39 100644 --- a/tests/Unit/CoreOnlyDependencyTest.php +++ b/tests/Unit/CoreOnlyDependencyTest.php @@ -2,76 +2,29 @@ declare(strict_types=1); +use Simtabi\Laranail\Ichava\Testing\PackConformance; + /* |-------------------------------------------------------------------------- | A pack depends on ichava/core, and on nothing else |-------------------------------------------------------------------------- | -| Core alone uses the laranail/* packages. A pack requires `php` and -| `ichava/core`, extends core's IconPackServiceProvider, and names no class -| from any other Simtabi\Laranail namespace, so a laranail change reaches the -| packs through core and nowhere else. -| -| `Simtabi\Laranail\Ichava\` is core's namespace, and this pack's own sits -| under it. `Simtabi\Laranail\Ichava\IconBrowser\` is ichava/icon-browser's, -| which a pack must not depend on either. -| -| Reads the committed composer.json. CI aliases core's main into it for the -| install and restores the file before the tests run. +| The rule lives once, in core's Testing\PackConformance; this file names it +| for this pack, so the pack's test names stay as they were. | -| Pest functions only: a PHPUnit class in the same file would stop being -| collected. +| `composer pint` runs `vendor/bin/laranail-pint`, which reaches the pack through +| core's require: a sanctioned transitive dev tool, not a pack dependency. | */ -/** @return array */ -function coreOnlyComposerJson(): array -{ - $composer = json_decode((string) file_get_contents(dirname(__DIR__, 2) . '/composer.json'), true, flags: JSON_THROW_ON_ERROR); - - return is_array($composer) ? $composer : []; -} - it('requires php and ichava/core, and nothing else', function (): void { - $require = array_keys(coreOnlyComposerJson()['require'] ?? []); - sort($require); - - expect($require)->toBe(['ichava/core', 'php']); + PackConformance::requiresOnlyCore(dirname(__DIR__, 2)); }); it('declares the composer type core discovers packs by', function (): void { - expect(coreOnlyComposerJson()['type'] ?? null)->toBe('ichava-icon-set'); + PackConformance::declaresPackType(dirname(__DIR__, 2)); }); it('imports no laranail namespace but ichava/core from src/', function (): void { - $inspected = 0; - $offenders = []; - - $files = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator(dirname(__DIR__, 2) . '/src', FilesystemIterator::SKIP_DOTS), - ); - - foreach ($files as $file) { - if (! $file instanceof SplFileInfo || ! $file->isFile() || $file->getExtension() !== 'php') { - continue; - } - - $inspected++; - $source = (string) file_get_contents($file->getPathname()); - - // Every spelling: a `use` line, a fully qualified name, or a string with - // escaped backslashes. - preg_match_all('/Simtabi\\\\+Laranail\\\\+(\w+)(?:\\\\+(\w+))?/', $source, $matches, PREG_SET_ORDER); - - foreach ($matches as $match) { - if ($match[1] !== 'Ichava' || ($match[2] ?? '') === 'IconBrowser') { - $offenders[] = $file->getFilename() . ': ' . $match[0]; - } - } - } - - // Every pack has a provider, a constants class, an enum and a component. A - // walk that stops finding them must fail, not pass over an empty tree. - expect($inspected)->toBeGreaterThanOrEqual(4) - ->and($offenders)->toBe([]); + PackConformance::importsOnlyCore(dirname(__DIR__, 2)); }); diff --git a/tests/Unit/NoStrayTextNodesTest.php b/tests/Unit/NoStrayTextNodesTest.php index 75d11fd..8e6c141 100644 --- a/tests/Unit/NoStrayTextNodesTest.php +++ b/tests/Unit/NoStrayTextNodesTest.php @@ -2,84 +2,21 @@ declare(strict_types=1); -/** - * No SVG in the pack may carry text directly under `` or ``. - * - * The icon generator (ichava/js-icon-components) refuses such a file, and - * OpenMoji 15.1.0 shipped one. Bootstrap Icons 1.13.2 ships none; this keeps it so. - * Text inside `` and `` is legitimate and is not looked at: only - * direct children of `svg` and `g` are. - */ -function bootstrap_stray_text_nodes_in(string $file): array -{ - $dom = new DOMDocument; - - if (@$dom->loadXML((string) file_get_contents($file), LIBXML_NONET) === false || $dom->documentElement === null) { - return ['unparseable']; - } - - $found = []; - $walk = function (DOMElement $element) use (&$walk, &$found): void { - if (in_array($element->localName, ['svg', 'g'], true)) { - foreach ($element->childNodes as $child) { - if (($child->nodeType === XML_TEXT_NODE || $child->nodeType === XML_CDATA_SECTION_NODE) && trim((string) $child->nodeValue) !== '') { - $found[] = trim((string) $child->nodeValue); - } - } - } - - foreach ($element->childNodes as $child) { - if ($child instanceof DOMElement) { - $walk($child); - } - } - }; - $walk($dom->documentElement); - - return $found; -} - -function bootstrap_svg_files(string $root): array -{ - $files = []; - $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS)); - - foreach ($iterator as $file) { - if ($file->isFile() && $file->getExtension() === 'svg') { - $files[] = $file->getPathname(); - } - } - - return $files; -} +use Simtabi\Laranail\Ichava\Testing\PackConformance; + +/* +|-------------------------------------------------------------------------- +| No text node directly under or +|-------------------------------------------------------------------------- +| +| The rule lives once, in core's Testing\PackConformance; this file names it +| for this pack, so the pack's test names stay as they were. +| +| A renderer draws such text as stray glyphs, and js-icon-components refuses it. +| The detector itself is tested in core (SvgInspectorTest). +| +*/ it('has no text node directly under svg or g in any icon', function (): void { - $files = bootstrap_svg_files(dirname(__DIR__, 2) . '/resources/assets/svg/files'); - - // 2,078 icons. A glob that stops matching must fail, not pass. - expect(count($files))->toBeGreaterThanOrEqual(2078); - - $offenders = []; - foreach ($files as $file) { - $text = bootstrap_stray_text_nodes_in($file); - if ($text !== []) { - $offenders[] = substr($file, (int) strpos($file, 'files/') + 6) . ' => ' . implode(' | ', $text); - } - } - - expect($offenders)->toBe([]); -}); - -it('detects a stray text node and ignores title and desc text', function (): void { - $dir = sys_get_temp_dir() . '/stray-' . bin2hex(random_bytes(4)); - mkdir($dir); - file_put_contents($dir . '/bad.svg', "\n\n d\n"); - file_put_contents($dir . '/good.svg', 'Trainfast'); - - expect(bootstrap_stray_text_nodes_in($dir . '/bad.svg'))->toBe(['d']) - ->and(bootstrap_stray_text_nodes_in($dir . '/good.svg'))->toBe([]); - - unlink($dir . '/bad.svg'); - unlink($dir . '/good.svg'); - rmdir($dir); + PackConformance::noStrayTextNodes(dirname(__DIR__, 2) . '/resources/assets/svg/files', minFiles: 2078); }); diff --git a/tests/Unit/PhpstanLevelTest.php b/tests/Unit/PhpstanLevelTest.php index 0b2c96f..82d77ee 100644 --- a/tests/Unit/PhpstanLevelTest.php +++ b/tests/Unit/PhpstanLevelTest.php @@ -2,38 +2,22 @@ declare(strict_types=1); +use Simtabi\Laranail\Ichava\Testing\PackConformance; + /* |-------------------------------------------------------------------------- | PHPStan runs at level max, with no baseline |-------------------------------------------------------------------------- | -| The level is the gate's whole strength, and lowering it is a one-line edit -| that turns nothing red: the analysis simply passes at the weaker level. So -| the configured level is pinned here, read from phpstan.neon.dist itself, and -| so is the absence of a baseline, which would hide errors the same way. -| -| Pest functions only: a PHPUnit class in the same file would stop being -| collected. +| The rule lives once, in core's Testing\PackConformance; this file names it +| for this pack, so the pack's test names stay as they were. | */ it('configures PHPStan at level max', function (): void { - $path = dirname(__DIR__, 2) . '/phpstan.neon.dist'; - $this->assertFileExists($path); - - $count = preg_match_all('/^\s*level:\s*(\S+)\s*$/m', (string) file_get_contents($path), $matches); - - $this->assertSame(1, $count, 'phpstan.neon.dist must set exactly one level.'); - $this->assertSame('max', $matches[1][0], 'phpstan.neon.dist must analyse at level max.'); + PackConformance::analysesAtLevelMax(dirname(__DIR__, 2)); }); it('analyses without a baseline', function (): void { - $root = dirname(__DIR__, 2); - - $this->assertFileDoesNotExist($root . '/phpstan-baseline.neon'); - $this->assertStringNotContainsString( - 'baseline', - (string) file_get_contents($root . '/phpstan.neon.dist'), - 'phpstan.neon.dist must not include a baseline.', - ); + PackConformance::analysesWithoutBaseline(dirname(__DIR__, 2)); }); diff --git a/tests/Unit/ReleaseVersionTest.php b/tests/Unit/ReleaseVersionTest.php index 501b447..057ac91 100644 --- a/tests/Unit/ReleaseVersionTest.php +++ b/tests/Unit/ReleaseVersionTest.php @@ -20,7 +20,7 @@ | two the series may run one minor ahead of the manifest, never further. | | Pest functions only: a PHPUnit class in the same file would stop being -| collected (see PackageMetadataTest.php). +| collected. | */ diff --git a/tests/Unit/SanitisedSvgTest.php b/tests/Unit/SanitisedSvgTest.php index 4ca3d01..ab50f09 100644 --- a/tests/Unit/SanitisedSvgTest.php +++ b/tests/Unit/SanitisedSvgTest.php @@ -2,123 +2,20 @@ declare(strict_types=1); -use Simtabi\Laranail\Ichava\Support\SvgPolicy; - -/** - * Every vendored SVG already satisfies core's sanitiser policy, so core serves - * it unchanged. - * - * `core/resources/security/svg-policy.json` is the one definition of what - * survives sanitisation, and core's PHP, both TypeScript clients and the sync - * read it. The pack's files were run through icon-sets-sync's sanitiser when - * they were vendored (Bootstrap Icons 1.13.2); this pins the result against the - * policy core resolves here, so an upstream refresh that reintroduces a - * script, a handler, an external reference or a forbidden tag fails. - */ -function bootstrap_policy_violations(string $file): array -{ - $raw = (string) file_get_contents($file); - $found = []; - - if (stripos($raw, 'loadXML($raw, LIBXML_NONET) === false || $dom->documentElement === null) { - return ['unparseable']; - } - - $tags = array_flip(SvgPolicy::allowedTags()); - $forbidden = array_flip(SvgPolicy::forbiddenTags()); - $attributes = array_flip(SvgPolicy::allowedAttributes()); - $references = SvgPolicy::referenceAttributes(); - $fragment = SvgPolicy::fragmentPattern(); - - $walk = function (DOMElement $element) use (&$walk, &$found, $tags, $forbidden, $attributes, $references, $fragment): void { - $name = $element->localName; - - if (isset($forbidden[$name]) || ! isset($tags[$name])) { - $found[] = "tag {$name}"; - } - - foreach ($element->attributes ?? [] as $attribute) { - $key = $attribute->nodeName; - $value = (string) $attribute->nodeValue; - - if (! isset($attributes[$key]) && $key !== 'role' && preg_match('/^aria-[a-z]+$/', $key) !== 1) { - $found[] = "attribute {$name}@{$key}"; - } - - if (in_array($key, $references, true) && preg_match($fragment, $value) !== 1) { - $found[] = "reference {$key}"; - } - - if (preg_match('/javascript:|data:|expression\(|behavior:|-moz-binding|url\(\s*[\'"]?[^#\s\'")]/i', $value) === 1) { - $found[] = "value {$key}"; - } - } - - foreach ($element->childNodes as $child) { - if ($child instanceof DOMElement) { - $walk($child); - } - } - }; - $walk($dom->documentElement); - - if ($dom->documentElement->localName !== 'svg') { - $found[] = 'root is not svg'; - } - - return array_values(array_unique($found)); -} +use Simtabi\Laranail\Ichava\Testing\PackConformance; + +/* +|-------------------------------------------------------------------------- +| Every SVG is one core's sanitiser leaves as it is +|-------------------------------------------------------------------------- +| +| The rule lives once, in core's Testing\PackConformance; this file names it +| for this pack, so the pack's test names stay as they were. +| +| The detector itself is tested in core (SvgInspectorTest). +| +*/ it('ships only SVGs core sanitises to themselves', function (): void { - $root = dirname(__DIR__, 2) . '/resources/assets/svg/files'; - $files = []; - - foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS)) as $file) { - if ($file->isFile() && $file->getExtension() === 'svg') { - $files[] = $file->getPathname(); - } - } - - // 2,078 icons. A walk that matches nothing must fail, not pass. - expect(count($files))->toBeGreaterThanOrEqual(2078); - - $offenders = []; - - foreach ($files as $file) { - $violations = bootstrap_policy_violations($file); - - if ($violations !== []) { - $offenders[] = substr($file, strlen($root) + 1) . ' => ' . implode(', ', $violations); - } - } - - expect($offenders)->toBe([]); -}); - -it('detects what the policy strips, and passes a clean icon', function (): void { - $dir = sys_get_temp_dir() . '/bootstrap-policy-' . bin2hex(random_bytes(4)); - mkdir($dir); - - file_put_contents($dir . '/bad.svg', ''); - file_put_contents($dir . '/good.svg', ''); - - expect(bootstrap_policy_violations($dir . '/bad.svg'))->toContain('comment', 'attribute svg@onload', 'tag script', 'reference href', 'attribute path@data-slot') - ->and(bootstrap_policy_violations($dir . '/good.svg'))->toBe([]); - - array_map(unlink(...), glob($dir . '/*') ?: []); - rmdir($dir); + PackConformance::svgPolicyClean(dirname(__DIR__, 2) . '/resources/assets/svg/files', minFiles: 2078); }); diff --git a/tests/Unit/SiblingConstraintsTest.php b/tests/Unit/SiblingConstraintsTest.php index 5c36fdf..83ec989 100644 --- a/tests/Unit/SiblingConstraintsTest.php +++ b/tests/Unit/SiblingConstraintsTest.php @@ -2,55 +2,21 @@ declare(strict_types=1); -use Composer\Semver\Semver; +use Simtabi\Laranail\Ichava\Testing\PackConformance; /* |-------------------------------------------------------------------------- | Every ichava/* constraint admits 0.1.0 |-------------------------------------------------------------------------- | -| At release every ichava repository becomes one root commit tagged v0.1.0, so -| 0.1.0 is the only version of any sibling that exists. A constraint that -| excludes it, such as a floor raised to ^0.1.2, cannot resolve once released. +| The rule lives once, in core's Testing\PackConformance; this file names it +| for this pack, so the pack's test names stay as they were. | -| CI tests against the siblings' main branches by aliasing them in the -| workflow and restoring composer.json after the install, so this reads the -| committed constraints, never the CI alias. -| -| Pest functions only: a PHPUnit class in the same file would stop being -| collected. +| CI aliases core's main into the 0.1 line and restores composer.json after the +| install, so this reads the committed constraints, never the CI alias. | */ -/** - * @return array "section.package" => constraint - */ -function siblingConstraintsInComposerJson(): array -{ - $path = dirname(__DIR__, 2) . '/composer.json'; - $composer = json_decode((string) file_get_contents($path), true, flags: JSON_THROW_ON_ERROR); - - $found = []; - foreach (['require', 'require-dev'] as $section) { - foreach ($composer[$section] ?? [] as $package => $constraint) { - if (str_starts_with((string) $package, 'ichava/')) { - $found["{$section}.{$package}"] = (string) $constraint; - } - } - } - - return $found; -} - it('admits 0.1.0 in every ichava/* constraint', function (): void { - $constraints = siblingConstraintsInComposerJson(); - - $this->assertNotEmpty($constraints, 'composer.json names no ichava/* constraint, so nothing was checked.'); - - foreach ($constraints as $where => $constraint) { - $this->assertTrue( - Semver::satisfies('0.1.0', $constraint), - "{$where} is '{$constraint}', which the released 0.1.0 does not satisfy.", - ); - } + PackConformance::siblingConstraintsAdmit(dirname(__DIR__, 2)); }); diff --git a/tests/Unit/SyncWorkflowCallerTest.php b/tests/Unit/SyncWorkflowCallerTest.php index 685ae9b..5c8f31e 100644 --- a/tests/Unit/SyncWorkflowCallerTest.php +++ b/tests/Unit/SyncWorkflowCallerTest.php @@ -2,43 +2,26 @@ declare(strict_types=1); -use Symfony\Component\Yaml\Yaml; - -/** - * The upstream sync is a caller of icon-sets-sync's reusable workflow, pinned to a - * commit, never triggered by a push, and holding only the permissions the called - * workflow needs. - * - * @return array - */ -function sync_caller(): array -{ - $path = dirname(__DIR__, 2) . '/.github/workflows/sync-upstream.yml'; - expect(is_file($path))->toBeTrue(); - - return Yaml::parseFile($path); -} - -it(description: 'calls the reusable sync-pack workflow pinned to a 40-hex commit', closure: function () { - $job = sync_caller()['jobs']['sync'] ?? []; - - expect($job['uses'] ?? '')->toMatch('#^ichava/icon-sets-sync/\.github/workflows/sync-pack\.yml@[0-9a-f]{40}$#'); - expect($job['with']['toolkit-ref'] ?? '')->toMatch('/^[0-9a-f]{40}$/'); - expect(explode('@', $job['uses'])[1])->toBe($job['with']['toolkit-ref']); +use Simtabi\Laranail\Ichava\Testing\PackConformance; + +/* +|-------------------------------------------------------------------------- +| The sync caller calls icon-sets-sync's reusable workflow +|-------------------------------------------------------------------------- +| +| The rule lives once, in core's Testing\PackConformance; this file names it +| for this pack, so the pack's test names stay as they were. +| +*/ + +it('calls the reusable sync-pack workflow pinned to a 40-hex commit', function (): void { + PackConformance::syncCallerIsPinned(dirname(__DIR__, 2)); }); -it(description: 'runs on a schedule and a manual dispatch, never on push', closure: function () { - $on = sync_caller()['on'] ?? null; - - expect($on)->toBeArray(); - expect(array_keys($on))->toBe(['schedule', 'workflow_dispatch']); - expect($on['schedule'])->toHaveCount(1); +it('runs on a schedule and a manual dispatch, never on push', function (): void { + PackConformance::syncCallerTriggers(dirname(__DIR__, 2)); }); -it(description: 'declares only the permissions the called workflow needs', closure: function () { - $workflow = sync_caller(); - - expect($workflow['permissions'])->toBe(['contents' => 'read']); - expect($workflow['jobs']['sync']['permissions'])->toBe(['contents' => 'read', 'issues' => 'write']); - expect(array_keys($workflow['jobs']))->toBe(['sync']); +it('declares only the permissions the called workflow needs', function (): void { + PackConformance::syncCallerPermissions(dirname(__DIR__, 2)); }); diff --git a/tests/Unit/WorkflowPermissionsTest.php b/tests/Unit/WorkflowPermissionsTest.php index cf67857..320784e 100644 --- a/tests/Unit/WorkflowPermissionsTest.php +++ b/tests/Unit/WorkflowPermissionsTest.php @@ -2,32 +2,22 @@ declare(strict_types=1); -/** - * Every workflow declares a top-level `permissions:` block, so the token each job gets does not - * depend on the organisation's default. A job that needs more asks for it at job level. - * - * @return list - */ -function workflow_files(): array -{ - $dir = dirname(__DIR__, 2) . '/.github/workflows'; +use Simtabi\Laranail\Ichava\Testing\PackConformance; - return array_values(array_merge(glob($dir . '/*.yml') ?: [], glob($dir . '/*.yaml') ?: [])); -} +/* +|-------------------------------------------------------------------------- +| Every workflow declares its token's permissions +|-------------------------------------------------------------------------- +| +| The rule lives once, in core's Testing\PackConformance; this file names it +| for this pack, so the pack's test names stay as they were. +| +*/ -it(description: 'reads the workflow files it is meant to guard', closure: function () { - // Floor: the number of workflows today. A glob that stops matching must fail, not pass empty. - expect(count(workflow_files()))->toBeGreaterThanOrEqual(7); +it('reads the workflow files it is meant to guard', function (): void { + PackConformance::workflowsDeclarePermissions(dirname(__DIR__, 2), minFiles: 7); }); -it(description: 'declares a top-level permissions block in every workflow', closure: function () { - $missing = []; - - foreach (workflow_files() as $file) { - if (! preg_match('/^permissions:/m', (string) file_get_contents($file))) { - $missing[] = basename($file); - } - } - - expect($missing)->toBe([]); +it('declares a top-level permissions block in every workflow', function (): void { + PackConformance::workflowsDeclarePermissions(dirname(__DIR__, 2), minFiles: 7); });