|
@@ -0,0 +1,299 @@
|
|
|
|
|
+<?php
|
|
|
|
|
+
|
|
|
|
|
+declare(strict_types=1);
|
|
|
|
|
+
|
|
|
|
|
+namespace App\Tests\Integration;
|
|
|
|
|
+
|
|
|
|
|
+use App\Infrastructure\Db\ConnectionFactory;
|
|
|
|
|
+use Doctrine\DBAL\Connection;
|
|
|
|
|
+use Phinx\Config\Config;
|
|
|
|
|
+use Phinx\Migration\Manager;
|
|
|
|
|
+use PHPUnit\Framework\TestCase;
|
|
|
|
|
+use Symfony\Component\Console\Input\ArrayInput;
|
|
|
|
|
+use Symfony\Component\Console\Output\NullOutput;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Boots an in-memory SQLite database, runs every Phinx migration against it,
|
|
|
|
|
+ * and asserts that the resulting schema has every SPEC §4 table with the
|
|
|
|
|
+ * expected key columns. This catches gross mistakes (missing tables, missing
|
|
|
|
|
+ * binary columns) without depending on MySQL being available locally.
|
|
|
|
|
+ */
|
|
|
|
|
+final class MigrationsTest extends TestCase
|
|
|
|
|
+{
|
|
|
|
|
+ private string $sqlitePath;
|
|
|
|
|
+ private Connection $connection;
|
|
|
|
|
+
|
|
|
|
|
+ protected function setUp(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $this->sqlitePath = sys_get_temp_dir() . '/irdb-migrations-' . bin2hex(random_bytes(6)) . '.sqlite';
|
|
|
|
|
+
|
|
|
|
|
+ $config = new Config([
|
|
|
|
|
+ 'paths' => [
|
|
|
|
|
+ 'migrations' => __DIR__ . '/../../db/migrations',
|
|
|
|
|
+ 'seeds' => __DIR__ . '/../../db/seeds',
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'environments' => [
|
|
|
|
|
+ 'default_migration_table' => 'phinxlog',
|
|
|
|
|
+ 'default_environment' => 'test',
|
|
|
|
|
+ 'test' => [
|
|
|
|
|
+ 'adapter' => 'sqlite',
|
|
|
|
|
+ 'name' => $this->sqlitePath,
|
|
|
|
|
+ 'suffix' => '',
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'version_order' => 'creation',
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ $manager = new Manager($config, new ArrayInput([]), new NullOutput());
|
|
|
|
|
+ $manager->migrate('test');
|
|
|
|
|
+ $manager->seed('test');
|
|
|
|
|
+
|
|
|
|
|
+ $factory = new ConnectionFactory([
|
|
|
|
|
+ 'driver' => 'sqlite',
|
|
|
|
|
+ 'sqlite_path' => $this->sqlitePath,
|
|
|
|
|
+ 'mysql_host' => '',
|
|
|
|
|
+ 'mysql_port' => 3306,
|
|
|
|
|
+ 'mysql_database' => '',
|
|
|
|
|
+ 'mysql_username' => '',
|
|
|
|
|
+ 'mysql_password' => '',
|
|
|
|
|
+ ]);
|
|
|
|
|
+ $this->connection = $factory->create();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ protected function tearDown(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $this->connection->close();
|
|
|
|
|
+ if (file_exists($this->sqlitePath)) {
|
|
|
|
|
+ @unlink($this->sqlitePath);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testEverySpecTableExists(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $expected = [
|
|
|
|
|
+ 'users',
|
|
|
|
|
+ 'oidc_role_mappings',
|
|
|
|
|
+ 'reporters',
|
|
|
|
|
+ 'consumers',
|
|
|
|
|
+ 'policies',
|
|
|
|
|
+ 'policy_category_thresholds',
|
|
|
|
|
+ 'categories',
|
|
|
|
|
+ 'api_tokens',
|
|
|
|
|
+ 'reports',
|
|
|
|
|
+ 'ip_scores',
|
|
|
|
|
+ 'ip_enrichment',
|
|
|
|
|
+ 'manual_blocks',
|
|
|
|
|
+ 'allowlist',
|
|
|
|
|
+ 'audit_log',
|
|
|
|
|
+ 'job_locks',
|
|
|
|
|
+ 'job_runs',
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ $rows = $this->connection
|
|
|
|
|
+ ->executeQuery("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
|
|
|
|
|
+ ->fetchAllAssociative();
|
|
|
|
|
+ $tables = array_column($rows, 'name');
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($expected as $name) {
|
|
|
|
|
+ self::assertContains($name, $tables, "Missing table: {$name}");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testReportsHasExpectedColumns(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $columns = $this->columnsOf('reports');
|
|
|
|
|
+
|
|
|
|
|
+ foreach (['id', 'ip_bin', 'ip_text', 'category_id', 'reporter_id', 'weight_at_report', 'received_at', 'metadata_json'] as $col) {
|
|
|
|
|
+ self::assertArrayHasKey($col, $columns, "reports missing column {$col}");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testIpScoresHasCompositePrimaryKey(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $rows = $this->connection
|
|
|
|
|
+ ->executeQuery('PRAGMA table_info(ip_scores)')
|
|
|
|
|
+ ->fetchAllAssociative();
|
|
|
|
|
+
|
|
|
|
|
+ $pkCols = [];
|
|
|
|
|
+ foreach ($rows as $r) {
|
|
|
|
|
+ if ((int) $r['pk'] > 0) {
|
|
|
|
|
+ $pkCols[(int) $r['pk']] = $r['name'];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ ksort($pkCols);
|
|
|
|
|
+
|
|
|
|
|
+ self::assertSame(['ip_bin', 'category_id'], array_values($pkCols));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testPolicyCategoryThresholdsHasCompositePrimaryKey(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $rows = $this->connection
|
|
|
|
|
+ ->executeQuery('PRAGMA table_info(policy_category_thresholds)')
|
|
|
|
|
+ ->fetchAllAssociative();
|
|
|
|
|
+
|
|
|
|
|
+ $pkCols = [];
|
|
|
|
|
+ foreach ($rows as $r) {
|
|
|
|
|
+ if ((int) $r['pk'] > 0) {
|
|
|
|
|
+ $pkCols[(int) $r['pk']] = $r['name'];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ ksort($pkCols);
|
|
|
|
|
+
|
|
|
|
|
+ self::assertSame(['policy_id', 'category_id'], array_values($pkCols));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testJobLocksPkIsJobName(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $rows = $this->connection
|
|
|
|
|
+ ->executeQuery('PRAGMA table_info(job_locks)')
|
|
|
|
|
+ ->fetchAllAssociative();
|
|
|
|
|
+
|
|
|
|
|
+ $pkCols = [];
|
|
|
|
|
+ foreach ($rows as $r) {
|
|
|
|
|
+ if ((int) $r['pk'] > 0) {
|
|
|
|
|
+ $pkCols[(int) $r['pk']] = $r['name'];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ self::assertSame(['job_name'], array_values($pkCols));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testIpEnrichmentPkIsIpBin(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $rows = $this->connection
|
|
|
|
|
+ ->executeQuery('PRAGMA table_info(ip_enrichment)')
|
|
|
|
|
+ ->fetchAllAssociative();
|
|
|
|
|
+
|
|
|
|
|
+ $pkCols = [];
|
|
|
|
|
+ foreach ($rows as $r) {
|
|
|
|
|
+ if ((int) $r['pk'] > 0) {
|
|
|
|
|
+ $pkCols[(int) $r['pk']] = $r['name'];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ self::assertSame(['ip_bin'], array_values($pkCols));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testReportsHasIpBinIndex(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $rows = $this->connection
|
|
|
|
|
+ ->executeQuery("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='reports'")
|
|
|
|
|
+ ->fetchAllAssociative();
|
|
|
|
|
+ $indexNames = array_column($rows, 'name');
|
|
|
|
|
+
|
|
|
|
|
+ $matchesCompound = false;
|
|
|
|
|
+ foreach ($indexNames as $n) {
|
|
|
|
|
+ if (str_contains($n, 'ip_cat_received') || str_contains($n, 'ip_bin')) {
|
|
|
|
|
+ $matchesCompound = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ self::assertTrue($matchesCompound, 'reports should have an index covering ip_bin');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testApiTokensCheckConstraintEnforced(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ // valid: kind=admin, both NULL
|
|
|
|
|
+ $this->connection->executeStatement(
|
|
|
|
|
+ "INSERT INTO api_tokens (token_hash, token_prefix, kind) VALUES ('a', 'admxxxx', 'admin')"
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // invalid: kind=reporter without reporter_id
|
|
|
|
|
+ $threw = false;
|
|
|
|
|
+ try {
|
|
|
|
|
+ $this->connection->executeStatement(
|
|
|
|
|
+ "INSERT INTO api_tokens (token_hash, token_prefix, kind) VALUES ('b', 'repxxxx', 'reporter')"
|
|
|
|
|
+ );
|
|
|
|
|
+ } catch (\Throwable) {
|
|
|
|
|
+ $threw = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ self::assertTrue($threw, 'CHECK constraint should reject kind=reporter without reporter_id');
|
|
|
|
|
+
|
|
|
|
|
+ // invalid: kind=service with reporter_id set
|
|
|
|
|
+ $threw = false;
|
|
|
|
|
+ try {
|
|
|
|
|
+ // Need a reporter row first for the FK to be satisfiable.
|
|
|
|
|
+ $this->connection->executeStatement(
|
|
|
|
|
+ "INSERT INTO reporters (name, trust_weight, is_active) VALUES ('rx', 1.0, 1)"
|
|
|
|
|
+ );
|
|
|
|
|
+ $rid = (int) $this->connection->lastInsertId();
|
|
|
|
|
+ $this->connection->executeStatement(
|
|
|
|
|
+ 'INSERT INTO api_tokens (token_hash, token_prefix, kind, reporter_id) VALUES (?, ?, ?, ?)',
|
|
|
|
|
+ ['c', 'svcxxxx', 'service', $rid]
|
|
|
|
|
+ );
|
|
|
|
|
+ } catch (\Throwable) {
|
|
|
|
|
+ $threw = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ self::assertTrue($threw, 'CHECK constraint should reject kind=service with reporter_id set');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testForeignKeysEnforcedOnSqlite(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ // foreign_keys PRAGMA must be on for the constraint to fire.
|
|
|
|
|
+ $threw = false;
|
|
|
|
|
+ try {
|
|
|
|
|
+ $this->connection->executeStatement(
|
|
|
|
|
+ 'INSERT INTO consumers (name, policy_id, is_active) VALUES (?, ?, ?)',
|
|
|
|
|
+ ['x', 99999, 1]
|
|
|
|
|
+ );
|
|
|
|
|
+ } catch (\Throwable) {
|
|
|
|
|
+ $threw = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ self::assertTrue($threw, 'consumers.policy_id FK should reject unknown policy id');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testSeedsPopulatedDefaults(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $catCount = (int) $this->connection->fetchOne('SELECT COUNT(*) FROM categories');
|
|
|
|
|
+ $polCount = (int) $this->connection->fetchOne('SELECT COUNT(*) FROM policies');
|
|
|
|
|
+ $thrCount = (int) $this->connection->fetchOne('SELECT COUNT(*) FROM policy_category_thresholds');
|
|
|
|
|
+
|
|
|
|
|
+ self::assertSame(5, $catCount);
|
|
|
|
|
+ self::assertSame(3, $polCount);
|
|
|
|
|
+ self::assertSame(15, $thrCount);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testSeedersAreIdempotent(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ // Run seed again; counts must not change.
|
|
|
|
|
+ $config = new Config([
|
|
|
|
|
+ 'paths' => [
|
|
|
|
|
+ 'migrations' => __DIR__ . '/../../db/migrations',
|
|
|
|
|
+ 'seeds' => __DIR__ . '/../../db/seeds',
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'environments' => [
|
|
|
|
|
+ 'default_migration_table' => 'phinxlog',
|
|
|
|
|
+ 'default_environment' => 'test',
|
|
|
|
|
+ 'test' => [
|
|
|
|
|
+ 'adapter' => 'sqlite',
|
|
|
|
|
+ 'name' => $this->sqlitePath,
|
|
|
|
|
+ 'suffix' => '',
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'version_order' => 'creation',
|
|
|
|
|
+ ]);
|
|
|
|
|
+ $manager = new Manager($config, new ArrayInput([]), new NullOutput());
|
|
|
|
|
+ $manager->seed('test');
|
|
|
|
|
+
|
|
|
|
|
+ self::assertSame(5, (int) $this->connection->fetchOne('SELECT COUNT(*) FROM categories'));
|
|
|
|
|
+ self::assertSame(3, (int) $this->connection->fetchOne('SELECT COUNT(*) FROM policies'));
|
|
|
|
|
+ self::assertSame(15, (int) $this->connection->fetchOne('SELECT COUNT(*) FROM policy_category_thresholds'));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * @return array<string, array<string, mixed>>
|
|
|
|
|
+ */
|
|
|
|
|
+ private function columnsOf(string $table): array
|
|
|
|
|
+ {
|
|
|
|
|
+ $rows = $this->connection
|
|
|
|
|
+ ->executeQuery(sprintf('PRAGMA table_info(%s)', $table))
|
|
|
|
|
+ ->fetchAllAssociative();
|
|
|
|
|
+
|
|
|
|
|
+ $cols = [];
|
|
|
|
|
+ foreach ($rows as $r) {
|
|
|
|
|
+ $cols[$r['name']] = $r;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $cols;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|