Skip to content

Commit 15d5cce

Browse files
CyberSecDefclaude
andcommitted
test: add file-backed service unit tests (mapper, digest, version, changelog, KEV)
Second batch of service-layer unit tests (32 tests), covering the deterministic file-backed services. - R4ToR5Mapper: structural invariants of map() against the committed r4/r5 catalogs — totals populated, kind-counts sum to row count, kinds within the known set, family-ordered, deterministic. Robust to catalog refreshes. - StigDigestBuilder::findPreviousEntry: pure date-sort resolution — immediate predecessor, oldest/unknown/single-entry edges, array + stdClass inputs. - AppVersion: temp-dir — baked VERSION read, whitespace trim, empty-file fallthrough to compute(), result caching, __toString, compute() shape. - Changelog: temp-dir — frozen-JSON load, malformed / missing-key / empty fallthrough, frozenPath(). - Vulns\KevLoader: temp-dir — missing-file degradation, load/meta, case- insensitive byCve, deterministic summary() (far-past/future dates only). Verified on PHP 8.4 (deploy target): OK (102 tests, 3415 assertions). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent faaa7a3 commit 15d5cce

5 files changed

Lines changed: 457 additions & 0 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
namespace App\Tests\Service;
4+
5+
use App\Service\AppVersion;
6+
use PHPUnit\Framework\TestCase;
7+
8+
class AppVersionTest extends TestCase
9+
{
10+
/** @var string[] */
11+
private array $tmpDirs = [];
12+
13+
protected function tearDown(): void
14+
{
15+
foreach ($this->tmpDirs as $d) {
16+
if (is_dir($d)) {
17+
foreach (scandir($d) as $f) {
18+
if ($f !== '.' && $f !== '..') {
19+
@unlink($d . '/' . $f);
20+
}
21+
}
22+
@rmdir($d);
23+
}
24+
}
25+
$this->tmpDirs = [];
26+
}
27+
28+
private function tmpDir(): string
29+
{
30+
$d = sys_get_temp_dir() . '/avtest_' . uniqid('', true);
31+
mkdir($d, 0777, true);
32+
$this->tmpDirs[] = $d;
33+
return $d;
34+
}
35+
36+
public function testReadsBakedVersionFile(): void
37+
{
38+
$d = $this->tmpDir();
39+
file_put_contents($d . '/VERSION', '9.2601.42');
40+
$this->assertSame('9.2601.42', (new AppVersion($d))->get());
41+
}
42+
43+
public function testTrimsWhitespaceFromVersionFile(): void
44+
{
45+
$d = $this->tmpDir();
46+
file_put_contents($d . '/VERSION', " 1.2.3\n");
47+
$this->assertSame('1.2.3', (new AppVersion($d))->get());
48+
}
49+
50+
public function testEmptyVersionFileFallsThroughToCompute(): void
51+
{
52+
$d = $this->tmpDir();
53+
file_put_contents($d . '/VERSION', " \n");
54+
// No VERSION value → live compute → MAJOR.YYMM.commits shape.
55+
$this->assertMatchesRegularExpression('/^3\.\d{4}\.\d+$/', (new AppVersion($d))->get());
56+
}
57+
58+
public function testGetCachesFirstResult(): void
59+
{
60+
$d = $this->tmpDir();
61+
file_put_contents($d . '/VERSION', '5.5.5');
62+
$v = new AppVersion($d);
63+
$first = $v->get();
64+
// Mutating the file after the first read must not change the cached value.
65+
file_put_contents($d . '/VERSION', '6.6.6');
66+
$this->assertSame('5.5.5', $v->get());
67+
$this->assertSame($first, $v->get());
68+
}
69+
70+
public function testToStringMatchesGet(): void
71+
{
72+
$d = $this->tmpDir();
73+
file_put_contents($d . '/VERSION', '7.7.7');
74+
$v = new AppVersion($d);
75+
$this->assertSame('7.7.7', (string) $v);
76+
}
77+
78+
public function testComputeAlwaysProducesMajorYymmCommitsShape(): void
79+
{
80+
// compute() ignores any VERSION file and derives the string live.
81+
// In a throwaway dir with no git repo, commit count resolves to 0.
82+
$this->assertMatchesRegularExpression('/^3\.\d{4}\.\d+$/', (new AppVersion($this->tmpDir()))->compute());
83+
}
84+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php
2+
3+
namespace App\Tests\Service;
4+
5+
use App\Service\Changelog;
6+
use PHPUnit\Framework\TestCase;
7+
8+
class ChangelogTest extends TestCase
9+
{
10+
/** @var string[] */
11+
private array $tmpDirs = [];
12+
13+
protected function tearDown(): void
14+
{
15+
foreach ($this->tmpDirs as $d) {
16+
$this->rrmdir($d);
17+
}
18+
$this->tmpDirs = [];
19+
}
20+
21+
private function tmpDir(): string
22+
{
23+
$d = sys_get_temp_dir() . '/cltest_' . uniqid('', true);
24+
mkdir($d, 0777, true);
25+
$this->tmpDirs[] = $d;
26+
return $d;
27+
}
28+
29+
private function writeChangelog(string $projectDir, string $rawJson): void
30+
{
31+
$dir = $projectDir . '/resources/data';
32+
mkdir($dir, 0777, true);
33+
file_put_contents($dir . '/changelog.json', $rawJson);
34+
}
35+
36+
private function rrmdir(string $d): void
37+
{
38+
if (!is_dir($d)) {
39+
return;
40+
}
41+
foreach (scandir($d) as $f) {
42+
if ($f === '.' || $f === '..') {
43+
continue;
44+
}
45+
$p = $d . '/' . $f;
46+
is_dir($p) ? $this->rrmdir($p) : @unlink($p);
47+
}
48+
@rmdir($d);
49+
}
50+
51+
public function testFrozenPathIsUnderProjectDir(): void
52+
{
53+
$d = $this->tmpDir();
54+
$this->assertSame($d . '/resources/data/changelog.json', (new Changelog($d))->frozenPath());
55+
}
56+
57+
public function testLoadsFrozenEntries(): void
58+
{
59+
$d = $this->tmpDir();
60+
$this->writeChangelog($d, json_encode(['entries' => [
61+
['hash' => 'abc123', 'subject' => 'first'],
62+
['hash' => 'def456', 'subject' => 'second'],
63+
]]));
64+
65+
$entries = (new Changelog($d))->getEntries();
66+
$this->assertCount(2, $entries);
67+
$this->assertSame('abc123', $entries[0]['hash']);
68+
$this->assertSame('second', $entries[1]['subject']);
69+
}
70+
71+
public function testMalformedJsonFallsThroughToEmpty(): void
72+
{
73+
// Invalid JSON → loadFrozen() returns null; the throwaway dir has no
74+
// .git, so the git fallback also yields nothing → empty array.
75+
$d = $this->tmpDir();
76+
$this->writeChangelog($d, 'not valid json {');
77+
$this->assertSame([], (new Changelog($d))->getEntries());
78+
}
79+
80+
public function testMissingEntriesKeyFallsThroughToEmpty(): void
81+
{
82+
$d = $this->tmpDir();
83+
$this->writeChangelog($d, json_encode(['something_else' => true]));
84+
$this->assertSame([], (new Changelog($d))->getEntries());
85+
}
86+
87+
public function testEmptyEntriesArrayIsReturnedAsIs(): void
88+
{
89+
$d = $this->tmpDir();
90+
$this->writeChangelog($d, json_encode(['entries' => []]));
91+
$this->assertSame([], (new Changelog($d))->getEntries());
92+
}
93+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
namespace App\Tests\Service;
4+
5+
use App\Service\R4ToR5Mapper;
6+
use PHPUnit\Framework\TestCase;
7+
8+
/**
9+
* R4ToR5Mapper reads the committed 800-53 r4/r5 catalogs and curated mapping
10+
* from resources/data/rmf/. These tests assert deterministic structural
11+
* invariants of map() against that real data rather than pinning exact counts
12+
* (which legitimately shift when the catalogs are refreshed).
13+
*/
14+
class R4ToR5MapperTest extends TestCase
15+
{
16+
private const ALLOWED_KINDS = ['unchanged', 'withdrawn', 'incorporated-into', 'new-in-r5'];
17+
18+
private function map(): array
19+
{
20+
return (new R4ToR5Mapper())->map();
21+
}
22+
23+
public function testTopLevelStructure(): void
24+
{
25+
$r = $this->map();
26+
$this->assertArrayHasKey('rows', $r);
27+
$this->assertArrayHasKey('kinds', $r);
28+
$this->assertArrayHasKey('families', $r);
29+
$this->assertArrayHasKey('totals', $r);
30+
}
31+
32+
public function testTotalsArePopulated(): void
33+
{
34+
$t = $this->map()['totals'];
35+
$this->assertGreaterThan(0, $t['r4_total']);
36+
$this->assertGreaterThan(0, $t['r5_total']);
37+
$this->assertArrayHasKey('curated_count', $t);
38+
}
39+
40+
public function testRowsAreNonEmptyAndShaped(): void
41+
{
42+
$rows = $this->map()['rows'];
43+
$this->assertNotEmpty($rows);
44+
foreach (['r4', 'r5', 'kind', 'family', 'source'] as $key) {
45+
$this->assertArrayHasKey($key, $rows[0]);
46+
}
47+
}
48+
49+
public function testKindCountsSumToRowCount(): void
50+
{
51+
$r = $this->map();
52+
$this->assertSame(count($r['rows']), array_sum($r['kinds']));
53+
}
54+
55+
public function testEveryKindIsFromTheKnownSet(): void
56+
{
57+
foreach (array_keys($this->map()['kinds']) as $kind) {
58+
$this->assertContains($kind, self::ALLOWED_KINDS);
59+
}
60+
}
61+
62+
public function testRowSourceIsCuratedOrMechanical(): void
63+
{
64+
foreach ($this->map()['rows'] as $row) {
65+
$this->assertContains($row['source'], ['curated', 'mechanical']);
66+
}
67+
}
68+
69+
public function testRowsSortByFamilyThenNumber(): void
70+
{
71+
// AC-1 carries the smallest sort key, so the AC family leads the list.
72+
$rows = $this->map()['rows'];
73+
$this->assertSame('AC', $rows[0]['family']);
74+
}
75+
76+
public function testMapIsDeterministic(): void
77+
{
78+
$mapper = new R4ToR5Mapper();
79+
$this->assertSame(count($mapper->map()['rows']), count($mapper->map()['rows']));
80+
}
81+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
namespace App\Tests\Service;
4+
5+
use App\Service\StigDigestBuilder;
6+
use PHPUnit\Framework\TestCase;
7+
8+
class StigDigestBuilderTest extends TestCase
9+
{
10+
/** @return array<int,array<string,string>> date-descending is NOT assumed; the SUT sorts. */
11+
private function entries(): array
12+
{
13+
return [
14+
['version' => '2', 'release' => '1', 'filename' => 'v2r1.xml', 'date' => '2021-01-01', 'released' => 'Jan 2021'],
15+
['version' => '1', 'release' => '3', 'filename' => 'v1r3.xml', 'date' => '2020-06-01', 'released' => 'Jun 2020'],
16+
['version' => '1', 'release' => '1', 'filename' => 'v1r1.xml', 'date' => '2019-01-01', 'released' => 'Jan 2019'],
17+
];
18+
}
19+
20+
public function testReturnsImmediatelyOlderEntry(): void
21+
{
22+
$prev = (new StigDigestBuilder())->findPreviousEntry($this->entries(), '2', '1');
23+
$this->assertNotNull($prev);
24+
$this->assertSame('1', $prev['version']);
25+
$this->assertSame('3', $prev['release']);
26+
$this->assertSame('v1r3.xml', $prev['filename']);
27+
}
28+
29+
public function testMiddleEntryPreviousIsTheOldest(): void
30+
{
31+
$prev = (new StigDigestBuilder())->findPreviousEntry($this->entries(), '1', '3');
32+
$this->assertSame('v1r1.xml', $prev['filename']);
33+
}
34+
35+
public function testOldestEntryHasNoPrevious(): void
36+
{
37+
$this->assertNull((new StigDigestBuilder())->findPreviousEntry($this->entries(), '1', '1'));
38+
}
39+
40+
public function testUnknownVersionReturnsNull(): void
41+
{
42+
$this->assertNull((new StigDigestBuilder())->findPreviousEntry($this->entries(), '9', '9'));
43+
}
44+
45+
public function testSingleEntryReturnsNull(): void
46+
{
47+
$only = [['version' => '1', 'release' => '1', 'filename' => 'x.xml', 'date' => '2020-01-01']];
48+
$this->assertNull((new StigDigestBuilder())->findPreviousEntry($only, '1', '1'));
49+
}
50+
51+
public function testIgnoresInputOrderAndSortsByDate(): void
52+
{
53+
// Deliberately oldest-first; the SUT must still resolve by date.
54+
$shuffled = [
55+
['version' => '1', 'release' => '1', 'filename' => 'v1r1.xml', 'date' => '2019-01-01'],
56+
['version' => '2', 'release' => '1', 'filename' => 'v2r1.xml', 'date' => '2021-01-01'],
57+
['version' => '1', 'release' => '3', 'filename' => 'v1r3.xml', 'date' => '2020-06-01'],
58+
];
59+
$prev = (new StigDigestBuilder())->findPreviousEntry($shuffled, '2', '1');
60+
$this->assertSame('v1r3.xml', $prev['filename']);
61+
}
62+
63+
public function testAcceptsStdClassEntries(): void
64+
{
65+
$objs = array_map(fn($e) => (object) $e, $this->entries());
66+
$prev = (new StigDigestBuilder())->findPreviousEntry($objs, '2', '1');
67+
$this->assertSame('v1r3.xml', $prev['filename']);
68+
}
69+
70+
public function testMatchesVersionReleaseAsStrings(): void
71+
{
72+
// version/release passed as ints must still match string TOC values.
73+
$prev = (new StigDigestBuilder())->findPreviousEntry($this->entries(), (string) 2, (string) 1);
74+
$this->assertSame('v1r3.xml', $prev['filename']);
75+
}
76+
}

0 commit comments

Comments
 (0)