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/CONTRIBUTING.md b/CONTRIBUTING.md index 493d93e..53b108d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ Contribution guidelines are centralized for the whole Ichava org: -- [Code of Conduct](CODE_OF_CONDUCT.md) +- [Code of Conduct](https://github.com/ichava/.github/blob/main/CODE_OF_CONDUCT.md) For this pack specifically: icon assets are refreshed from upstream by the [icon-sets-sync](https://github.com/ichava/icon-sets-sync/blob/main/docs/maintainer-sync.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..9f59f90 --- /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-ionicons/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-ionicons/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 1946ca0..4d5a4ea 100644 --- a/composer.json +++ b/composer.json @@ -49,10 +49,10 @@ ], "require-dev": { "composer/semver": "^3.4", + "laravel/pint": "^1.18", "orchestra/testbench": "^11.0", "pestphp/pest": "^4.6 || ^5.0", "phpstan/phpstan": "^2.1", - "laravel/pint": "^1.18", "symfony/yaml": "^7.0 || ^8.0" }, "autoload": { diff --git a/src/Providers/IconsServiceProvider.php b/src/Providers/IconsServiceProvider.php index 6e25ab3..380cbf3 100644 --- a/src/Providers/IconsServiceProvider.php +++ b/src/Providers/IconsServiceProvider.php @@ -4,7 +4,7 @@ namespace Simtabi\Laranail\Ichava\IconSetsIonicons\Providers; -use Simtabi\Laranail\Ichava\Support\IconPackServiceProvider; +use Simtabi\Laranail\Ichava\Providers\IconPackServiceProvider; use Simtabi\Laranail\Ichava\IconSetsIonicons\Constants\IconsConstants; use Simtabi\Laranail\Ichava\IconSetsIonicons\View\Components\IconComponent; diff --git a/tests/TestCase.php b/tests/TestCase.php index c0f951b..870624b 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,87 +4,17 @@ namespace Simtabi\Laranail\Ichava\IconSetsIonicons\Tests; -use Orchestra\Testbench\TestCase as Orchestra; -use Simtabi\Laranail\Ichava\Providers\IchavaServiceProvider; +use Simtabi\Laranail\Ichava\Testing\PackTestCase; use Simtabi\Laranail\Ichava\IconSetsIonicons\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 9f3ecc2..587abb8 100644 --- a/tests/Unit/NoStrayTextNodesTest.php +++ b/tests/Unit/NoStrayTextNodesTest.php @@ -2,85 +2,21 @@ declare(strict_types=1); -/** - * No SVG in the pack may carry text directly under `` or ``. - * - * Such text renders nothing, but the icon generator (ichava/js-icon-components) - * refuses the file. The pack's SVGs went through icon-sets-sync's sanitiser when - * they were vendored, which drops it; this pins the result. Text inside - * `` and `` is legitimate and is not looked at: only direct - * children of `svg` and `g` are. - */ -function 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 pack_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 = pack_svg_files(dirname(__DIR__, 2) . '/resources/assets/svg/files'); - - // 1,357 icons across 3 variants. A glob that stops matching must fail, not pass. - expect(count($files))->toBeGreaterThanOrEqual(1357); - - $offenders = []; - foreach ($files as $file) { - $text = 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(stray_text_nodes_in($dir . '/bad.svg'))->toBe(['d']) - ->and(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: 1357); }); 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/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); });