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> */ 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; } }