|
| 1 | +<?php |
| 2 | + |
| 3 | +declare(strict_types=1); |
| 4 | + |
| 5 | +use PHPUnit\Framework\TestCase; |
| 6 | + |
| 7 | +// load functions |
| 8 | +require_once dirname(__DIR__, 1) . "/vendor/autoload.php"; |
| 9 | +require_once "src/cache.php"; |
| 10 | + |
| 11 | +final class CacheTest extends TestCase |
| 12 | +{ |
| 13 | + private string $testCacheDir; |
| 14 | + |
| 15 | + protected function setUp(): void |
| 16 | + { |
| 17 | + // Use a temp directory for tests to avoid interfering with production cache |
| 18 | + $this->testCacheDir = sys_get_temp_dir() . "/github-streak-cache-test-" . uniqid(); |
| 19 | + if (!is_dir($this->testCacheDir)) { |
| 20 | + mkdir($this->testCacheDir, 0755, true); |
| 21 | + } |
| 22 | + } |
| 23 | + |
| 24 | + protected function tearDown(): void |
| 25 | + { |
| 26 | + // Clean up test cache directory |
| 27 | + if (is_dir($this->testCacheDir)) { |
| 28 | + $files = glob($this->testCacheDir . "/*.json"); |
| 29 | + if ($files) { |
| 30 | + foreach ($files as $file) { |
| 31 | + @unlink($file); |
| 32 | + } |
| 33 | + } |
| 34 | + @rmdir($this->testCacheDir); |
| 35 | + } |
| 36 | + } |
| 37 | + |
| 38 | + /** |
| 39 | + * Test cache key generation produces consistent results |
| 40 | + */ |
| 41 | + public function testCacheKeyConsistency(): void |
| 42 | + { |
| 43 | + $key1 = getCacheKey("testuser", ["mode" => "weekly"]); |
| 44 | + $key2 = getCacheKey("testuser", ["mode" => "weekly"]); |
| 45 | + $this->assertEquals($key1, $key2, "Same inputs should produce same cache key"); |
| 46 | + } |
| 47 | + |
| 48 | + /** |
| 49 | + * Test cache key generation produces different results for different inputs |
| 50 | + */ |
| 51 | + public function testCacheKeyDifferentInputs(): void |
| 52 | + { |
| 53 | + $key1 = getCacheKey("user1", []); |
| 54 | + $key2 = getCacheKey("user2", []); |
| 55 | + $this->assertNotEquals($key1, $key2, "Different users should produce different cache keys"); |
| 56 | + |
| 57 | + $key3 = getCacheKey("testuser", ["mode" => "weekly"]); |
| 58 | + $key4 = getCacheKey("testuser", ["mode" => "daily"]); |
| 59 | + $this->assertNotEquals($key3, $key4, "Different options should produce different cache keys"); |
| 60 | + } |
| 61 | + |
| 62 | + /** |
| 63 | + * Test that cache key prevents hash collisions |
| 64 | + * e.g., user "ab" with options containing "cd" should not collide with user "abc" with options containing "d" |
| 65 | + */ |
| 66 | + public function testCacheKeyNoCollisions(): void |
| 67 | + { |
| 68 | + // This tests the fix for the hash collision vulnerability |
| 69 | + $key1 = getCacheKey("ab", ["option" => "cd"]); |
| 70 | + $key2 = getCacheKey("abc", ["option" => "d"]); |
| 71 | + $this->assertNotEquals($key1, $key2, "Similar concatenated strings should not produce same hash"); |
| 72 | + |
| 73 | + $key3 = getCacheKey("ab", ["x" => "cd"]); |
| 74 | + $key4 = getCacheKey("abcd", []); |
| 75 | + $this->assertNotEquals($key3, $key4, "User + options should not collide with user alone"); |
| 76 | + } |
| 77 | + |
| 78 | + /** |
| 79 | + * Test cache key generation sorts options for consistency |
| 80 | + */ |
| 81 | + public function testCacheKeyOptionOrdering(): void |
| 82 | + { |
| 83 | + $key1 = getCacheKey("testuser", ["a" => "1", "b" => "2"]); |
| 84 | + $key2 = getCacheKey("testuser", ["b" => "2", "a" => "1"]); |
| 85 | + $this->assertEquals($key1, $key2, "Option order should not affect cache key"); |
| 86 | + } |
| 87 | + |
| 88 | + /** |
| 89 | + * Test cache key is filename-safe (SHA256 hex) |
| 90 | + */ |
| 91 | + public function testCacheKeyFormat(): void |
| 92 | + { |
| 93 | + $key = getCacheKey("testuser", ["mode" => "weekly"]); |
| 94 | + $this->assertMatchesRegularExpression("/^[a-f0-9]{64}$/", $key, "Cache key should be 64-character hex string"); |
| 95 | + } |
| 96 | + |
| 97 | + /** |
| 98 | + * Test cache file path generation |
| 99 | + */ |
| 100 | + public function testGetCacheFilePath(): void |
| 101 | + { |
| 102 | + $key = "abc123"; |
| 103 | + $path = getCacheFilePath($key); |
| 104 | + $this->assertStringEndsWith("/cache/abc123.json", $path); |
| 105 | + } |
| 106 | + |
| 107 | + /** |
| 108 | + * Test setCachedStats and getCachedStats roundtrip |
| 109 | + */ |
| 110 | + public function testCacheRoundtrip(): void |
| 111 | + { |
| 112 | + $user = "roundtripuser"; |
| 113 | + $options = ["mode" => "weekly", "starting_year" => 2020]; |
| 114 | + $stats = [ |
| 115 | + "totalContributions" => 100, |
| 116 | + "currentStreak" => ["start" => "2024-01-01", "end" => "2024-01-10", "length" => 10], |
| 117 | + "longestStreak" => ["start" => "2023-06-01", "end" => "2023-07-15", "length" => 45], |
| 118 | + "firstContribution" => "2020-01-15", |
| 119 | + ]; |
| 120 | + |
| 121 | + // Write to cache |
| 122 | + $result = setCachedStats($user, $options, $stats); |
| 123 | + $this->assertTrue($result, "setCachedStats should return true on success"); |
| 124 | + |
| 125 | + // Read back from cache |
| 126 | + $cached = getCachedStats($user, $options); |
| 127 | + $this->assertNotNull($cached, "getCachedStats should return cached data"); |
| 128 | + $this->assertEquals($stats, $cached, "Cached data should match original"); |
| 129 | + } |
| 130 | + |
| 131 | + /** |
| 132 | + * Test getCachedStats returns null for non-existent cache |
| 133 | + */ |
| 134 | + public function testGetCachedStatsNotFound(): void |
| 135 | + { |
| 136 | + $result = getCachedStats("nonexistentuser12345", []); |
| 137 | + $this->assertNull($result, "getCachedStats should return null for non-existent cache"); |
| 138 | + } |
| 139 | + |
| 140 | + /** |
| 141 | + * Test setCachedStats handles invalid data gracefully |
| 142 | + */ |
| 143 | + public function testSetCachedStatsWithEmptyStats(): void |
| 144 | + { |
| 145 | + $result = setCachedStats("emptyuser", [], []); |
| 146 | + $this->assertTrue($result, "setCachedStats should handle empty stats array"); |
| 147 | + |
| 148 | + $cached = getCachedStats("emptyuser", []); |
| 149 | + $this->assertEquals([], $cached, "Empty stats should be cached and retrieved"); |
| 150 | + } |
| 151 | + |
| 152 | + /** |
| 153 | + * Test clearUserCache clears cache for user with default options |
| 154 | + */ |
| 155 | + public function testClearUserCache(): void |
| 156 | + { |
| 157 | + $user = "clearableuser"; |
| 158 | + $stats = ["totalContributions" => 50]; |
| 159 | + |
| 160 | + // Set cache |
| 161 | + setCachedStats($user, [], $stats); |
| 162 | + $this->assertNotNull(getCachedStats($user, [])); |
| 163 | + |
| 164 | + // Clear cache |
| 165 | + $result = clearUserCache($user); |
| 166 | + $this->assertTrue($result); |
| 167 | + |
| 168 | + // Verify cleared |
| 169 | + $this->assertNull(getCachedStats($user, [])); |
| 170 | + } |
| 171 | + |
| 172 | + /** |
| 173 | + * Test clearUserCache returns true for non-existent user |
| 174 | + */ |
| 175 | + public function testClearUserCacheNonExistent(): void |
| 176 | + { |
| 177 | + $result = clearUserCache("definitelynotauser999"); |
| 178 | + $this->assertTrue($result, "clearUserCache should return true for non-existent cache"); |
| 179 | + } |
| 180 | + |
| 181 | + /** |
| 182 | + * Test ensureCacheDir creates directory |
| 183 | + */ |
| 184 | + public function testEnsureCacheDir(): void |
| 185 | + { |
| 186 | + $result = ensureCacheDir(); |
| 187 | + $this->assertTrue($result); |
| 188 | + $this->assertTrue(is_dir(CACHE_DIR)); |
| 189 | + } |
| 190 | +} |
0 commit comments