diff --git a/src/app/Console/Commands/Data/Import/IP4NetsCommand.php b/src/app/Console/Commands/Data/Import/IP4NetsCommand.php index ee0935d8..4ab55fa7 100644 --- a/src/app/Console/Commands/Data/Import/IP4NetsCommand.php +++ b/src/app/Console/Commands/Data/Import/IP4NetsCommand.php @@ -1,211 +1,208 @@ 'http://ftp.afrinic.net/stats/afrinic/delegated-afrinic-latest', 'apnic' => 'http://ftp.apnic.net/apnic/stats/apnic/delegated-apnic-latest', 'arin' => 'http://ftp.arin.net/pub/stats/arin/delegated-arin-extended-latest', 'lacnic' => 'http://ftp.lacnic.net/pub/stats/lacnic/delegated-lacnic-latest', 'ripencc' => 'https://ftp.ripe.net/ripe/stats/delegated-ripencc-latest' ]; $today = Carbon::now()->toDateString(); foreach ($rirs as $rir => $url) { $file = storage_path("{$rir}-{$today}"); \App\Utils::downloadFile($url, $file); $serial = $this->serialFromStatsFile($file); if (!$serial) { \Log::error("Can not derive serial from {$file}"); continue; } $numLines = $this->countLines($file); if (!$numLines) { \Log::error("No relevant lines could be found in {$file}"); continue; } - $bar = $this->createProgressBar($numLines, "Importing IPv4 Networks from {$file}"); + $bar = $this->createProgressBar($numLines, "Importing IPv4 Networks from {$rir}-{$today}"); $fp = fopen($file, 'r'); $nets = []; while (!feof($fp)) { $line = trim(fgets($fp)); if ($line == "") { continue; } if ((int)$line) { continue; } if ($line[0] == "#") { continue; } $items = explode('|', $line); if (sizeof($items) < 7) { continue; } - if ($items[1] == "*") { + if ($items[1] == "*" || $items[1] == "" || $items[1] == "ZZ") { continue; } if ($items[2] != "ipv4") { continue; } if ($items[5] == "00000000") { $items[5] = "19700102"; } - if ($items[1] == "" || $items[1] == "ZZ") { - continue; - } - $bar->advance(); $mask = 32 - log($items[4], 2); + $broadcast = long2ip((ip2long($items[3]) + 2 ** (32 - $mask)) - 1); $net = \App\IP4Net::where( [ - 'net_number' => $items[3], + 'net_number' => inet_pton($items[3]), 'net_mask' => $mask, - 'net_broadcast' => long2ip((ip2long($items[3]) + 2 ** (32 - $mask)) - 1) + 'net_broadcast' => inet_pton($broadcast), ] )->first(); if ($net) { if ($net->updated_at > Carbon::now()->subDays(1)) { continue; } // don't use ->update() method because it doesn't update updated_at which we need for expiry $net->rir_name = $rir; $net->country = $items[1]; $net->serial = $serial; $net->updated_at = Carbon::now(); $net->save(); continue; } $nets[] = [ 'rir_name' => $rir, - 'net_number' => $items[3], + 'net_number' => inet_pton($items[3]), 'net_mask' => $mask, - 'net_broadcast' => long2ip((ip2long($items[3]) + 2 ** (32 - $mask)) - 1), + 'net_broadcast' => inet_pton($broadcast), 'country' => $items[1], 'serial' => $serial, 'created_at' => Carbon::parse($items[5], 'UTC'), 'updated_at' => Carbon::now() ]; if (sizeof($nets) >= 100) { \App\IP4Net::insert($nets); $nets = []; } } if (sizeof($nets) > 0) { \App\IP4Net::insert($nets); $nets = []; } $bar->finish(); $this->info("DONE"); } return 0; } private function countLines($file) { $numLines = 0; $fh = fopen($file, 'r'); while (!feof($fh)) { $line = trim(fgets($fh)); $items = explode('|', $line); if (sizeof($items) < 3) { continue; } if ($items[2] == "ipv4") { $numLines++; } } fclose($fh); return $numLines; } private function serialFromStatsFile($file) { $serial = null; $fh = fopen($file, 'r'); while (!feof($fh)) { $line = trim(fgets($fh)); $items = explode('|', $line); if (sizeof($items) < 2) { continue; } if ((int)$items[2]) { $serial = (int)$items[2]; break; } } fclose($fh); return $serial; } } diff --git a/src/app/Console/Commands/Data/Import/IP6NetsCommand.php b/src/app/Console/Commands/Data/Import/IP6NetsCommand.php index 4daec217..868fa7f8 100644 --- a/src/app/Console/Commands/Data/Import/IP6NetsCommand.php +++ b/src/app/Console/Commands/Data/Import/IP6NetsCommand.php @@ -1,209 +1,205 @@ 'http://ftp.afrinic.net/stats/afrinic/delegated-afrinic-latest', 'apnic' => 'http://ftp.apnic.net/apnic/stats/apnic/delegated-apnic-latest', 'arin' => 'http://ftp.arin.net/pub/stats/arin/delegated-arin-extended-latest', 'lacnic' => 'http://ftp.lacnic.net/pub/stats/lacnic/delegated-lacnic-latest', 'ripencc' => 'https://ftp.ripe.net/ripe/stats/delegated-ripencc-latest' ]; $today = Carbon::now()->toDateString(); foreach ($rirs as $rir => $url) { $file = storage_path("{$rir}-{$today}"); \App\Utils::downloadFile($url, $file); $serial = $this->serialFromStatsFile($file); if (!$serial) { \Log::error("Can not derive serial from {$file}"); continue; } $numLines = $this->countLines($file); if (!$numLines) { \Log::error("No relevant lines could be found in {$file}"); continue; } - $bar = $this->createProgressBar($numLines, "Importing IPv6 Networks from {$file}"); + $bar = $this->createProgressBar($numLines, "Importing IPv6 Networks from {$rir}-{$today}"); $fp = fopen($file, 'r'); $nets = []; while (!feof($fp)) { $line = trim(fgets($fp)); if ($line == "") { continue; } if ((int)$line) { continue; } if ($line[0] == "#") { continue; } $items = explode('|', $line); if (sizeof($items) < 7) { continue; } - if ($items[1] == "*") { + if ($items[1] == "*" || $items[1] == "" || $items[1] == "ZZ") { continue; } if ($items[2] != "ipv6") { continue; } if ($items[5] == "00000000") { $items[5] = "19700102"; } - if ($items[1] == "" || $items[1] == "ZZ") { - continue; - } - $bar->advance(); $broadcast = \App\Utils::ip6Broadcast($items[3], (int)$items[4]); $net = \App\IP6Net::where( [ - 'net_number' => $items[3], + 'net_number' => inet_pton($items[3]), 'net_mask' => (int)$items[4], - 'net_broadcast' => $broadcast + 'net_broadcast' => inet_pton($broadcast), ] )->first(); if ($net) { if ($net->updated_at > Carbon::now()->subDays(1)) { continue; } // don't use ->update() method because it doesn't update updated_at which we need for expiry $net->rir_name = $rir; $net->country = $items[1]; $net->serial = $serial; $net->updated_at = Carbon::now(); $net->save(); continue; } $nets[] = [ 'rir_name' => $rir, - 'net_number' => $items[3], + 'net_number' => inet_pton($items[3]), 'net_mask' => (int)$items[4], - 'net_broadcast' => $broadcast, + 'net_broadcast' => inet_pton($broadcast), 'country' => $items[1], 'serial' => $serial, 'created_at' => Carbon::parse($items[5], 'UTC'), 'updated_at' => Carbon::now() ]; if (sizeof($nets) >= 100) { \App\IP6Net::insert($nets); $nets = []; } } if (sizeof($nets) > 0) { \App\IP6Net::insert($nets); $nets = []; } $bar->finish(); $this->info("DONE"); } } private function countLines($file) { $numLines = 0; $fh = fopen($file, 'r'); while (!feof($fh)) { $line = trim(fgets($fh)); $items = explode('|', $line); if (sizeof($items) < 3) { continue; } if ($items[2] == "ipv6") { $numLines++; } } fclose($fh); return $numLines; } private function serialFromStatsFile($file) { $serial = null; $fh = fopen($file, 'r'); while (!feof($fh)) { $line = trim(fgets($fh)); $items = explode('|', $line); if (sizeof($items) < 2) { continue; } if ((int)$items[2]) { $serial = (int)$items[2]; break; } } fclose($fh); return $serial; } } diff --git a/src/app/IP4Net.php b/src/app/IP4Net.php index a7580c7e..d301728e 100644 --- a/src/app/IP4Net.php +++ b/src/app/IP4Net.php @@ -1,38 +1,84 @@ The attributes that are mass assignable */ protected $fillable = [ 'rir_name', 'net_number', 'net_mask', 'net_broadcast', 'country', 'serial', 'created_at', 'updated_at' ]; /** * Get IP network by IP address * - * @param string $ip IPv4 address + * @param string $ip IP address * - * @return ?\App\IP4Net IPv4 network record, Null if not found + * @return ?self IP network record, Null if not found */ public static function getNet($ip) { - $where = 'INET_ATON(net_number) <= INET_ATON(?) and INET_ATON(net_broadcast) >= INET_ATON(?)'; + $ip = inet_pton($ip); - return self::whereRaw($where, [$ip, $ip]) - ->orderByRaw('INET_ATON(net_number), net_mask DESC') + if (!$ip) { + return null; + } + + return static::where('net_number', '<=', $ip) + ->where('net_broadcast', '>=', $ip) + ->orderByRaw('net_number, net_mask DESC') ->first(); } + + /** + * net_number accessor. Internally we store IP addresses + * in a numeric form, outside they are human-readable. + * + * @return \Illuminate\Database\Eloquent\Casts\Attribute + */ + protected function netNumber(): Attribute + { + return Attribute::make( + get: fn ($ip) => inet_ntop($ip), + set: fn ($ip) => inet_pton($ip), + ); + } + + /** + * net_broadcast accessor. Internally we store IP addresses + * in a numeric form, outside they are human-readable. + * + * @return \Illuminate\Database\Eloquent\Casts\Attribute + */ + protected function netBroadcast(): Attribute + { + return Attribute::make( + get: fn ($ip) => inet_ntop($ip), + set: fn ($ip) => inet_pton($ip), + ); + } } diff --git a/src/app/IP6Net.php b/src/app/IP6Net.php index 288f91c8..eff75841 100644 --- a/src/app/IP6Net.php +++ b/src/app/IP6Net.php @@ -1,38 +1,9 @@ The attributes that are mass assignable */ - protected $fillable = [ - 'rir_name', - 'net_number', - 'net_mask', - 'net_broadcast', - 'country', - 'serial', - 'created_at', - 'updated_at' - ]; - - /** - * Get IP network by IP address - * - * @param string $ip IPv6 address - * - * @return ?\App\IP6Net IPv6 network record, Null if not found - */ - public static function getNet($ip) - { - $where = 'INET6_ATON(net_number) <= INET6_ATON(?) and INET6_ATON(net_broadcast) >= INET6_ATON(?)'; - - return IP6Net::whereRaw($where, [$ip, $ip]) - ->orderByRaw('INET6_ATON(net_number), net_mask DESC') - ->first(); - } } diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php index d84828f0..e78673c8 100644 --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -1,167 +1,172 @@ format('Y-m-d h:i:s'); + } elseif ($ipv && is_string($entry) && strlen($entry) == ($ipv == 6 ? 16 : 4)) { + // binary IP address? use HEX representation + return '0x' . bin2hex($entry); } + return $entry; }, $array); + return implode(', ', $serialized); } /** * Bootstrap any application services. * * @return void */ public function boot() { \App\Domain::observe(\App\Observers\DomainObserver::class); \App\Entitlement::observe(\App\Observers\EntitlementObserver::class); \App\Group::observe(\App\Observers\GroupObserver::class); \App\GroupSetting::observe(\App\Observers\GroupSettingObserver::class); \App\Meet\Room::observe(\App\Observers\Meet\RoomObserver::class); \App\PackageSku::observe(\App\Observers\PackageSkuObserver::class); \App\PlanPackage::observe(\App\Observers\PlanPackageObserver::class); \App\Resource::observe(\App\Observers\ResourceObserver::class); \App\ResourceSetting::observe(\App\Observers\ResourceSettingObserver::class); \App\SharedFolder::observe(\App\Observers\SharedFolderObserver::class); \App\SharedFolderAlias::observe(\App\Observers\SharedFolderAliasObserver::class); \App\SharedFolderSetting::observe(\App\Observers\SharedFolderSettingObserver::class); \App\SignupCode::observe(\App\Observers\SignupCodeObserver::class); \App\SignupInvitation::observe(\App\Observers\SignupInvitationObserver::class); \App\Transaction::observe(\App\Observers\TransactionObserver::class); \App\User::observe(\App\Observers\UserObserver::class); \App\UserAlias::observe(\App\Observers\UserAliasObserver::class); \App\UserSetting::observe(\App\Observers\UserSettingObserver::class); \App\VerificationCode::observe(\App\Observers\VerificationCodeObserver::class); \App\Wallet::observe(\App\Observers\WalletObserver::class); \App\PowerDNS\Domain::observe(\App\Observers\PowerDNS\DomainObserver::class); \App\PowerDNS\Record::observe(\App\Observers\PowerDNS\RecordObserver::class); Schema::defaultStringLength(191); // Log SQL queries in debug mode if (\config('app.debug')) { DB::listen(function ($query) { \Log::debug( sprintf( '[SQL] %s [%s]: %.4f sec.', $query->sql, - self::serializeSQLBindings($query->bindings), + self::serializeSQLBindings($query->bindings, $query->sql), $query->time / 1000 ) ); }); } // Register some template helpers Blade::directive( 'theme_asset', function ($path) { $path = trim($path, '/\'"'); return ""; } ); Builder::macro( 'withEnvTenantContext', function (string $table = null) { $tenantId = \config('app.tenant_id'); if ($tenantId) { /** @var Builder $this */ return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId); } /** @var Builder $this */ return $this->whereNull(($table ? "$table." : "") . "tenant_id"); } ); Builder::macro( 'withObjectTenantContext', function (object $object, string $table = null) { $tenantId = $object->tenant_id; if ($tenantId) { /** @var Builder $this */ return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId); } /** @var Builder $this */ return $this->whereNull(($table ? "$table." : "") . "tenant_id"); } ); Builder::macro( 'withSubjectTenantContext', function (string $table = null) { if ($user = auth()->user()) { $tenantId = $user->tenant_id; } else { $tenantId = \config('app.tenant_id'); } if ($tenantId) { /** @var Builder $this */ return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId); } /** @var Builder $this */ return $this->whereNull(($table ? "$table." : "") . "tenant_id"); } ); // Query builder 'whereLike' mocro Builder::macro( 'whereLike', function (string $column, string $search, int $mode = 0) { $search = addcslashes($search, '%_'); switch ($mode) { case 2: $search .= '%'; break; case 1: $search = '%' . $search; break; default: $search = '%' . $search . '%'; } /** @var Builder $this */ return $this->where($column, 'like', $search); } ); } } diff --git a/src/database/migrations/2022_07_07_100000_ip_nets_optimization.php b/src/database/migrations/2022_07_07_100000_ip_nets_optimization.php new file mode 100644 index 00000000..4d064d22 --- /dev/null +++ b/src/database/migrations/2022_07_07_100000_ip_nets_optimization.php @@ -0,0 +1,97 @@ +bigIncrements('id'); + $table->string('rir_name', 8); + $table->bigInteger('net_number'); + $table->bigInteger('net_broadcast'); + $table->tinyInteger('net_mask')->unsigned(); + $table->string('country', 2)->nullable(); + $table->bigInteger('serial')->unsigned(); + $table->timestamps(); + + $table->index(['net_number', 'net_broadcast', 'net_mask']); + } + ); + + Schema::create( + 'ip6nets', + function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('rir_name', 8); + $table->bigInteger('net_number'); + $table->bigInteger('net_broadcast'); + $table->tinyInteger('net_mask')->unsigned(); + $table->string('country', 2)->nullable(); + $table->bigInteger('serial')->unsigned(); + $table->timestamps(); + + $table->index(['net_number', 'net_broadcast', 'net_mask']); + } + ); + + // VARBINARY is MySQL specific and Laravel does not support it natively + DB::statement("alter table ip4nets change net_number net_number varbinary(4) not null"); + DB::statement("alter table ip4nets change net_broadcast net_broadcast varbinary(4) not null"); + DB::statement("alter table ip6nets change net_number net_number varbinary(16) not null"); + DB::statement("alter table ip6nets change net_broadcast net_broadcast varbinary(16) not null"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ip4nets'); + Schema::dropIfExists('ip6nets'); + + Schema::create( + 'ip4nets', + function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('rir_name', 8); + $table->string('net_number', 15)->index(); + $table->tinyInteger('net_mask')->unsigned(); + $table->string('net_broadcast', 15)->index(); + $table->string('country', 2)->nullable(); + $table->bigInteger('serial')->unsigned(); + $table->timestamps(); + + $table->index(['net_number', 'net_mask', 'net_broadcast']); + } + ); + + Schema::create( + 'ip6nets', + function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('rir_name', 8); + $table->string('net_number', 39)->index(); + $table->tinyInteger('net_mask')->unsigned(); + $table->string('net_broadcast', 39)->index(); + $table->string('country', 2)->nullable(); + $table->bigInteger('serial')->unsigned(); + $table->timestamps(); + + $table->index(['net_number', 'net_mask', 'net_broadcast']); + } + ); + } +}; diff --git a/src/tests/Feature/Controller/AuthTest.php b/src/tests/Feature/Controller/AuthTest.php index 6deba9f5..4740d9e2 100644 --- a/src/tests/Feature/Controller/AuthTest.php +++ b/src/tests/Feature/Controller/AuthTest.php @@ -1,330 +1,330 @@ app['auth']->guard($guard); if ($guard instanceof \Illuminate\Auth\SessionGuard) { $guard->logout(); } } $protectedProperty = new \ReflectionProperty($this->app['auth'], 'guards'); $protectedProperty->setAccessible(true); $protectedProperty->setValue($this->app['auth'], []); } /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestDomain('userscontroller.com'); $this->expectedExpiry = \config('auth.token_expiry_minutes') * 60; - \App\IP4Net::where('net_number', '127.0.0.0')->delete(); + \App\IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete(); $user = $this->getTestUser('john@kolab.org'); $user->setSetting('limit_geo', null); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestDomain('userscontroller.com'); - \App\IP4Net::where('net_number', '127.0.0.0')->delete(); + \App\IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete(); $user = $this->getTestUser('john@kolab.org'); $user->setSetting('limit_geo', null); parent::tearDown(); } /** * Test fetching current user info (/api/auth/info) */ public function testInfo(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $response = $this->get("api/auth/info"); $response->assertStatus(401); $response = $this->actingAs($user)->get("api/auth/info"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($user->id, $json['id']); $this->assertEquals($user->email, $json['email']); $this->assertEquals(User::STATUS_NEW | User::STATUS_ACTIVE, $json['status']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); $this->assertTrue(!isset($json['access_token'])); // Note: Details of the content are tested in testUserResponse() // Test token refresh via the info request // First we log in to get the refresh token $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $user = $this->getTestUser('john@kolab.org'); $response = $this->post("api/auth/login", $post); $json = $response->json(); $response = $this->actingAs($user) ->post("api/auth/info?refresh=1", ['refresh_token' => $json['refresh_token']]); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('john@kolab.org', $json['email']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue(!empty($json['expires_in'])); } /** * Test fetching current user location (/api/auth/location) */ public function testLocation(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); // Authentication required $response = $this->get("api/auth/location"); $response->assertStatus(401); $headers = ['X-Client-IP' => '127.0.0.2']; $response = $this->actingAs($user)->withHeaders($headers)->get("api/auth/location"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('127.0.0.2', $json['ipAddress']); $this->assertSame('', $json['countryCode']); \App\IP4Net::create([ 'net_number' => '127.0.0.0', 'net_broadcast' => '127.255.255.255', 'net_mask' => 8, 'country' => 'US', 'rir_name' => 'test', 'serial' => 1, ]); $response = $this->actingAs($user)->withHeaders($headers)->get("api/auth/location"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('127.0.0.2', $json['ipAddress']); $this->assertSame('US', $json['countryCode']); } /** * Test /api/auth/login */ public function testLogin(): string { $user = $this->getTestUser('john@kolab.org'); // Request with no data $response = $this->post("api/auth/login", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); $this->assertArrayHasKey('password', $json['errors']); // Request with invalid password $post = ['email' => 'john@kolab.org', 'password' => 'wrong']; $response = $this->post("api/auth/login", $post); $response->assertStatus(401); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame('Invalid username or password.', $json['message']); // Valid user+password $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $response = $this->post("api/auth/login", $post); $json = $response->json(); $response->assertStatus(200); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue( ($this->expectedExpiry - 5) < $json['expires_in'] && $json['expires_in'] < ($this->expectedExpiry + 5) ); $this->assertEquals('bearer', $json['token_type']); $this->assertEquals($user->id, $json['id']); $this->assertEquals($user->email, $json['email']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); // Valid user+password (upper-case) $post = ['email' => 'John@Kolab.org', 'password' => 'simple123']; $response = $this->post("api/auth/login", $post); $json = $response->json(); $response->assertStatus(200); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue( ($this->expectedExpiry - 5) < $json['expires_in'] && $json['expires_in'] < ($this->expectedExpiry + 5) ); $this->assertEquals('bearer', $json['token_type']); // TODO: We have browser tests for 2FA but we should probably also test it here return $json['access_token']; } /** * Test /api/auth/login with geo-lockin */ public function testLoginGeoLock(): void { $user = $this->getTestUser('john@kolab.org'); $user->setConfig(['limit_geo' => ['US']]); $headers['X-Client-IP'] = '127.0.0.2'; $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $response = $this->withHeaders($headers)->post("api/auth/login", $post); $response->assertStatus(401); $json = $response->json(); $this->assertSame("Invalid username or password.", $json['message']); $this->assertSame('error', $json['status']); \App\IP4Net::create([ 'net_number' => '127.0.0.0', 'net_broadcast' => '127.255.255.255', 'net_mask' => 8, 'country' => 'US', 'rir_name' => 'test', 'serial' => 1, ]); $response = $this->withHeaders($headers)->post("api/auth/login", $post); $response->assertStatus(200); $json = $response->json(); $this->assertTrue(!empty($json['access_token'])); $this->assertEquals($user->id, $json['id']); } /** * Test /api/auth/logout * * @depends testLogin */ public function testLogout($token): void { // Request with no token, testing that it requires auth $response = $this->post("api/auth/logout"); $response->assertStatus(401); // Test the same using JSON mode $response = $this->json('POST', "api/auth/logout", []); $response->assertStatus(401); // Request with invalid token $response = $this->withHeaders(['Authorization' => 'Bearer ' . "foobar"])->post("api/auth/logout"); $response->assertStatus(401); // Request with valid token $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->post("api/auth/logout"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('Successfully logged out.', $json['message']); $this->resetAuth(); // Check if it really destroyed the token? $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->get("api/auth/info"); $response->assertStatus(401); } /** * Test /api/auth/refresh */ public function testRefresh(): void { // Request with no token, testing that it requires auth $response = $this->post("api/auth/refresh"); $response->assertStatus(401); // Test the same using JSON mode $response = $this->json('POST', "api/auth/refresh", []); $response->assertStatus(401); // Login the user to get a valid token $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $response = $this->post("api/auth/login", $post); $response->assertStatus(200); $json = $response->json(); $token = $json['access_token']; $user = $this->getTestUser('john@kolab.org'); // Request with a valid token $response = $this->actingAs($user)->post("api/auth/refresh", ['refresh_token' => $json['refresh_token']]); $response->assertStatus(200); $json = $response->json(); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue($json['access_token'] != $token); $this->assertTrue( ($this->expectedExpiry - 5) < $json['expires_in'] && $json['expires_in'] < ($this->expectedExpiry + 5) ); $this->assertEquals('bearer', $json['token_type']); $new_token = $json['access_token']; // TODO: Shall we invalidate the old token? // And if the new token is working $response = $this->withHeaders(['Authorization' => 'Bearer ' . $new_token])->get("api/auth/info"); $response->assertStatus(200); } } diff --git a/src/tests/Feature/Controller/NGINXTest.php b/src/tests/Feature/Controller/NGINXTest.php index 2d01108d..4f1f27ff 100644 --- a/src/tests/Feature/Controller/NGINXTest.php +++ b/src/tests/Feature/Controller/NGINXTest.php @@ -1,292 +1,292 @@ getTestUser('john@kolab.org'); \App\CompanionApp::where('user_id', $john->id)->delete(); \App\AuthAttempt::where('user_id', $john->id)->delete(); $john->setSettings([ 'limit_geo' => null, 'guam_enabled' => false, ]); - \App\IP4Net::where('net_number', '127.0.0.0')->delete(); + \App\IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete(); $this->useServicesUrl(); } /** * {@inheritDoc} */ public function tearDown(): void { $john = $this->getTestUser('john@kolab.org'); \App\CompanionApp::where('user_id', $john->id)->delete(); \App\AuthAttempt::where('user_id', $john->id)->delete(); $john->setSettings([ 'limit_geo' => null, 'guam_enabled' => false, ]); - \App\IP4Net::where('net_number', '127.0.0.0')->delete(); + \App\IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete(); parent::tearDown(); } /** * Test the webhook */ public function testNGINXWebhook(): void { $john = $this->getTestUser('john@kolab.org'); $response = $this->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'authentication failure'); $pass = \App\Utils::generatePassphrase(); $headers = [ 'Auth-Login-Attempt' => '1', 'Auth-Method' => 'plain', 'Auth-Pass' => $pass, 'Auth-Protocol' => 'imap', 'Auth-Ssl' => 'on', 'Auth-User' => 'john@kolab.org', 'Client-Ip' => '127.0.0.1', 'Host' => '127.0.0.1', 'Auth-SSL' => 'on', 'Auth-SSL-Verify' => 'SUCCESS', 'Auth-SSL-Subject' => '/CN=example.com', 'Auth-SSL-Issuer' => '/CN=example.com', 'Auth-SSL-Serial' => 'C07AD56B846B5BFF', 'Auth-SSL-Fingerprint' => '29d6a80a123d13355ed16b4b04605e29cb55a5ad' ]; // Pass $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'OK'); $response->assertHeader('auth-port', '12143'); // Invalid Password $modifiedHeaders = $headers; $modifiedHeaders['Auth-Pass'] = "Invalid"; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'authentication failure'); // Empty Password $modifiedHeaders = $headers; $modifiedHeaders['Auth-Pass'] = ""; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'authentication failure'); // Empty User $modifiedHeaders = $headers; $modifiedHeaders['Auth-User'] = ""; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'authentication failure'); // Invalid User $modifiedHeaders = $headers; $modifiedHeaders['Auth-User'] = "foo@kolab.org"; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'authentication failure'); // Empty Ip $modifiedHeaders = $headers; $modifiedHeaders['Client-Ip'] = ""; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'authentication failure'); // SMTP Auth Protocol $modifiedHeaders = $headers; $modifiedHeaders['Auth-Protocol'] = "smtp"; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'OK'); $response->assertHeader('auth-server', '127.0.0.1'); $response->assertHeader('auth-port', '10465'); $response->assertHeader('auth-pass', $pass); // Empty Auth Protocol $modifiedHeaders = $headers; $modifiedHeaders['Auth-Protocol'] = ""; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'authentication failure'); // Guam $john->setSettings(['guam_enabled' => true]); $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'OK'); $response->assertHeader('auth-server', '127.0.0.1'); $response->assertHeader('auth-port', '9143'); $companionApp = $this->getTestCompanionApp( 'testdevice', $john, [ 'notification_token' => 'notificationtoken', 'mfa_enabled' => 1, 'name' => 'testname', ] ); // 2-FA with accepted auth attempt $authAttempt = \App\AuthAttempt::recordAuthAttempt($john, "127.0.0.1"); $authAttempt->accept(); $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'OK'); // Deny $authAttempt->deny(); $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'authentication failure'); // 2-FA without device $companionApp->delete(); $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'OK'); // Geo-lockin (failure) $john->setSettings(['limit_geo' => '["PL","US"]']); $headers['Auth-Protocol'] = 'imap'; $headers['Client-Ip'] = '127.0.0.1'; $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'authentication failure'); $authAttempt = \App\AuthAttempt::where('ip', $headers['Client-Ip'])->where('user_id', $john->id)->first(); $this->assertSame('geolocation', $authAttempt->reason); \App\AuthAttempt::where('user_id', $john->id)->delete(); // Geo-lockin (success) \App\IP4Net::create([ 'net_number' => '127.0.0.0', 'net_broadcast' => '127.255.255.255', 'net_mask' => 8, 'country' => 'US', 'rir_name' => 'test', 'serial' => 1, ]); $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'OK'); $this->assertCount(0, \App\AuthAttempt::where('user_id', $john->id)->get()); } /** * Test the httpauth webhook */ public function testNGINXHttpAuthHook(): void { $john = $this->getTestUser('john@kolab.org'); $response = $this->get("api/webhooks/nginx-httpauth"); $response->assertStatus(401); $pass = \App\Utils::generatePassphrase(); $headers = [ 'Php-Auth-Pw' => $pass, 'Php-Auth-User' => 'john@kolab.org', 'X-Forwarded-For' => '127.0.0.1', 'X-Forwarded-Proto' => 'https', 'X-Original-Uri' => '/iRony/', 'X-Real-Ip' => '127.0.0.1', ]; // Pass $response = $this->withHeaders($headers)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(200); // domain.tld\username $modifiedHeaders = $headers; $modifiedHeaders['Php-Auth-User'] = "kolab.org\\john"; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(200); // Invalid Password $modifiedHeaders = $headers; $modifiedHeaders['Php-Auth-Pw'] = "Invalid"; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(403); // Empty Password $modifiedHeaders = $headers; $modifiedHeaders['Php-Auth-Pw'] = ""; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(401); // Empty User $modifiedHeaders = $headers; $modifiedHeaders['Php-Auth-User'] = ""; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(403); // Invalid User $modifiedHeaders = $headers; $modifiedHeaders['Php-Auth-User'] = "foo@kolab.org"; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(403); // Empty Ip $modifiedHeaders = $headers; $modifiedHeaders['X-Real-Ip'] = ""; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(403); $companionApp = $this->getTestCompanionApp( 'testdevice', $john, [ 'notification_token' => 'notificationtoken', 'mfa_enabled' => 1, 'name' => 'testname', ] ); // 2-FA with accepted auth attempt $authAttempt = \App\AuthAttempt::recordAuthAttempt($john, "127.0.0.1"); $authAttempt->accept(); $response = $this->withHeaders($headers)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(200); // Deny $authAttempt->deny(); $response = $this->withHeaders($headers)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(403); // 2-FA without device $companionApp->delete(); $response = $this->withHeaders($headers)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(200); } } diff --git a/src/tests/Feature/Controller/PasswordResetTest.php b/src/tests/Feature/Controller/PasswordResetTest.php index 6ac3c6b1..096a4bb6 100644 --- a/src/tests/Feature/Controller/PasswordResetTest.php +++ b/src/tests/Feature/Controller/PasswordResetTest.php @@ -1,475 +1,475 @@ deleteTestUser('passwordresettest@' . \config('app.domain')); - \App\IP4Net::where('net_number', '127.0.0.0')->delete(); + \App\IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('passwordresettest@' . \config('app.domain')); - \App\IP4Net::where('net_number', '127.0.0.0')->delete(); + \App\IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete(); parent::tearDown(); } /** * Test password-reset/init with invalid input */ public function testPasswordResetInitInvalidInput(): void { // Empty input data $data = []; $response = $this->post('/api/auth/password-reset/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); // Data with invalid email $data = [ 'email' => '@example.org', ]; $response = $this->post('/api/auth/password-reset/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); // Data with valid but non-existing email $data = [ 'email' => 'non-existing-password-reset@example.org', ]; $response = $this->post('/api/auth/password-reset/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); // Data with valid email af an existing user with no external email $data = ['email' => 'passwordresettest@' . \config('app.domain')]; $response = $this->post('/api/auth/password-reset/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); } /** * Test password-reset/init with valid input * * @return array */ public function testPasswordResetInitValidInput() { Queue::fake(); // Assert that no jobs were pushed... Queue::assertNothingPushed(); // Add required external email address to user settings $user = $this->getTestUser('passwordresettest@' . \config('app.domain')); $user->setSetting('external_email', 'ext@email.com'); $data = [ 'email' => 'passwordresettest@' . \config('app.domain'), ]; $response = $this->post('/api/auth/password-reset/init', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertNotEmpty($json['code']); // Assert the email sending job was pushed once Queue::assertPushed(\App\Jobs\PasswordResetEmail::class, 1); // Assert the job has proper data assigned Queue::assertPushed(\App\Jobs\PasswordResetEmail::class, function ($job) use ($user, &$code, $json) { $code = TestCase::getObjectProperty($job, 'code'); return $code->user->id == $user->id && $code->code == $json['code']; }); return [ 'code' => $code ]; } /** * Test password-reset/init with geo-lockin */ public function testPasswordResetInitGeoLock(): void { Queue::fake(); $user = $this->getTestUser('passwordresettest@' . \config('app.domain')); $user->setConfig(['limit_geo' => ['US']]); $user->setSetting('external_email', 'ext@email.com'); $headers['X-Client-IP'] = '127.0.0.2'; $post = ['email' => 'passwordresettest@' . \config('app.domain')]; $response = $this->withHeaders($headers)->post('/api/auth/password-reset/init', $post); $json = $response->json(); $response->assertStatus(422); $this->assertCount(2, $json); $this->assertSame('error', $json['status']); $this->assertSame("The request location is not allowed.", $json['errors']['email']); \App\IP4Net::create([ 'net_number' => '127.0.0.0', 'net_broadcast' => '127.255.255.255', 'net_mask' => 8, 'country' => 'US', 'rir_name' => 'test', 'serial' => 1, ]); $response = $this->withHeaders($headers)->post('/api/auth/password-reset/init', $post); $json = $response->json(); $response->assertStatus(200); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertNotEmpty($json['code']); } /** * Test password-reset/verify with invalid input * * @return void */ public function testPasswordResetVerifyInvalidInput() { // Empty data $data = []; $response = $this->post('/api/auth/password-reset/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Add verification code and required external email address to user settings $user = $this->getTestUser('passwordresettest@' . \config('app.domain')); $code = new VerificationCode(['mode' => 'password-reset']); $user->verificationcodes()->save($code); // Data with existing code but missing short_code $data = [ 'code' => $code->code, ]; $response = $this->post('/api/auth/password-reset/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Data with invalid code $data = [ 'short_code' => '123456789', 'code' => $code->code, ]; $response = $this->post('/api/auth/password-reset/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // TODO: Test expired code } /** * Test password-reset/verify with valid input * * @return void */ public function testPasswordResetVerifyValidInput() { // Add verification code and required external email address to user settings $user = $this->getTestUser('passwordresettest@' . \config('app.domain')); $code = new VerificationCode(['mode' => 'password-reset']); $user->verificationcodes()->save($code); // Data with invalid code $data = [ 'short_code' => $code->short_code, 'code' => $code->code, ]; $response = $this->post('/api/auth/password-reset/verify', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertSame($user->id, $json['userId']); } /** * Test password-reset with invalid input * * @return void */ public function testPasswordResetInvalidInput() { // Empty data $data = []; $response = $this->post('/api/auth/password-reset', $data); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('code', $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); $user = $this->getTestUser('passwordresettest@' . \config('app.domain')); $code = new VerificationCode(['mode' => 'password-reset']); $user->verificationcodes()->save($code); // Data with existing code but missing password $data = [ 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/password-reset', $data); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('password', $json['errors']); // Data with existing code but wrong password confirmation $data = [ 'code' => $code->code, 'short_code' => $code->short_code, 'password' => 'password', 'password_confirmation' => 'passwrong', ]; $response = $this->post('/api/auth/password-reset', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('password', $json['errors']); // Data with existing code but password too short $data = [ 'code' => $code->code, 'short_code' => $code->short_code, 'password' => 'pas', 'password_confirmation' => 'pas', ]; $response = $this->post('/api/auth/password-reset', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('password', $json['errors']); // Data with invalid short code $data = [ 'code' => $code->code, 'short_code' => '123456789', 'password' => 'password', 'password_confirmation' => 'password', ]; $response = $this->post('/api/auth/password-reset', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); } /** * Test password reset with valid input * * @return void */ public function testPasswordResetValidInput() { $user = $this->getTestUser('passwordresettest@' . \config('app.domain')); $code = new VerificationCode(['mode' => 'password-reset']); $user->verificationcodes()->save($code); Queue::fake(); Queue::assertNothingPushed(); $data = [ 'password' => 'testtest', 'password_confirmation' => 'testtest', 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/password-reset', $data); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame('bearer', $json['token_type']); $this->assertTrue(!empty($json['expires_in']) && is_int($json['expires_in']) && $json['expires_in'] > 0); $this->assertNotEmpty($json['access_token']); $this->assertSame($user->email, $json['email']); $this->assertSame($user->id, $json['id']); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\User\UpdateJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail == $user->email && $userId == $user->id; } ); // Check if the code has been removed $this->assertNull(VerificationCode::find($code->code)); // TODO: Check password before and after (?) // TODO: Check if the access token works } /** * Test creating a password verification code * * @return void */ public function testCodeCreate() { $user = $this->getTestUser('john@kolab.org'); $user->verificationcodes()->delete(); $response = $this->actingAs($user)->post('/api/v4/password-reset/code', []); $response->assertStatus(200); $json = $response->json(); $code = $user->verificationcodes()->first(); $this->assertSame('success', $json['status']); $this->assertSame($code->code, $json['code']); $this->assertSame($code->short_code, $json['short_code']); $this->assertStringContainsString(now()->addHours(24)->toDateString(), $json['expires_at']); } /** * Test deleting a password verification code * * @return void */ public function testCodeDelete() { $user = $this->getTestUser('passwordresettest@' . \config('app.domain')); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $john->verificationcodes()->delete(); $jack->verificationcodes()->delete(); $john_code = new VerificationCode(['mode' => 'password-reset']); $john->verificationcodes()->save($john_code); $jack_code = new VerificationCode(['mode' => 'password-reset']); $jack->verificationcodes()->save($jack_code); $user_code = new VerificationCode(['mode' => 'password-reset']); $user->verificationcodes()->save($user_code); // Unauth access $response = $this->delete('/api/v4/password-reset/code/' . $user_code->code); $response->assertStatus(401); // Non-existing code $response = $this->actingAs($john)->delete('/api/v4/password-reset/code/123'); $response->assertStatus(404); // Existing code belonging to another user not controlled by the acting user $response = $this->actingAs($john)->delete('/api/v4/password-reset/code/' . $user_code->code); $response->assertStatus(403); // Deleting owned code $response = $this->actingAs($john)->delete('/api/v4/password-reset/code/' . $john_code->code); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $john->verificationcodes()->count()); $this->assertSame('success', $json['status']); $this->assertSame("Password reset code deleted successfully.", $json['message']); // Deleting code of another user owned by the acting user // also use short_code+code as input parameter $id = $jack_code->short_code . '-' . $jack_code->code; $response = $this->actingAs($john)->delete('/api/v4/password-reset/code/' . $id); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $jack->verificationcodes()->count()); $this->assertSame('success', $json['status']); $this->assertSame("Password reset code deleted successfully.", $json['message']); } } diff --git a/src/tests/Unit/UtilsTest.php b/src/tests/Unit/UtilsTest.php index d1065d5b..7286a7f3 100644 --- a/src/tests/Unit/UtilsTest.php +++ b/src/tests/Unit/UtilsTest.php @@ -1,153 +1,194 @@ delete(); + \App\IP6Net::where('net_number', inet_pton('2001:db8::ff00:42:0'))->delete(); + + $this->assertSame('', Utils::countryForIP('127.0.0.1', '')); + $this->assertSame('CH', Utils::countryForIP('127.0.0.1')); + $this->assertSame('', Utils::countryForIP('2001:db8::ff00:42:1', '')); + $this->assertSame('CH', Utils::countryForIP('2001:db8::ff00:42:1')); + + \App\IP4Net::create([ + 'net_number' => '127.0.0.0', + 'net_broadcast' => '127.255.255.255', + 'net_mask' => 8, + 'country' => 'US', + 'rir_name' => 'test', + 'serial' => 1, + ]); + + \App\IP6Net::create([ + 'net_number' => '2001:db8::ff00:42:0', + 'net_broadcast' => \App\Utils::ip6Broadcast('2001:db8::ff00:42:0', 8), + 'net_mask' => 8, + 'country' => 'PL', + 'rir_name' => 'test', + 'serial' => 1, + ]); + + $this->assertSame('US', Utils::countryForIP('127.0.0.1', '')); + $this->assertSame('US', Utils::countryForIP('127.0.0.1')); + $this->assertSame('PL', Utils::countryForIP('2001:db8::ff00:42:1', '')); + $this->assertSame('PL', Utils::countryForIP('2001:db8::ff00:42:1')); + + \App\IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete(); + \App\IP6Net::where('net_number', inet_pton('2001:db8::ff00:42:0'))->delete(); + } + /** * Test for Utils::emailToLower() */ public function testEmailToLower(): void { - $this->assertSame('test@test.tld', \App\Utils::emailToLower('test@Test.Tld')); - $this->assertSame('test@test.tld', \App\Utils::emailToLower('Test@Test.Tld')); - $this->assertSame('shared+shared/Test@test.tld', \App\Utils::emailToLower('shared+shared/Test@Test.Tld')); + $this->assertSame('test@test.tld', Utils::emailToLower('test@Test.Tld')); + $this->assertSame('test@test.tld', Utils::emailToLower('Test@Test.Tld')); + $this->assertSame('shared+shared/Test@test.tld', Utils::emailToLower('shared+shared/Test@Test.Tld')); } /** * Test for Utils::normalizeAddress() */ public function testNormalizeAddress(): void { - $this->assertSame('', \App\Utils::normalizeAddress('')); - $this->assertSame('', \App\Utils::normalizeAddress(null)); - $this->assertSame('test', \App\Utils::normalizeAddress('TEST')); - $this->assertSame('test@domain.tld', \App\Utils::normalizeAddress('Test@Domain.TLD')); - $this->assertSame('test@domain.tld', \App\Utils::normalizeAddress('Test+Trash@Domain.TLD')); - - $this->assertSame(['', ''], \App\Utils::normalizeAddress('', true)); - $this->assertSame(['', ''], \App\Utils::normalizeAddress(null, true)); - $this->assertSame(['test', ''], \App\Utils::normalizeAddress('TEST', true)); - $this->assertSame(['test', 'domain.tld'], \App\Utils::normalizeAddress('Test@Domain.TLD', true)); - $this->assertSame(['test', 'domain.tld'], \App\Utils::normalizeAddress('Test+Trash@Domain.TLD', true)); + $this->assertSame('', Utils::normalizeAddress('')); + $this->assertSame('', Utils::normalizeAddress(null)); + $this->assertSame('test', Utils::normalizeAddress('TEST')); + $this->assertSame('test@domain.tld', Utils::normalizeAddress('Test@Domain.TLD')); + $this->assertSame('test@domain.tld', Utils::normalizeAddress('Test+Trash@Domain.TLD')); + + $this->assertSame(['', ''], Utils::normalizeAddress('', true)); + $this->assertSame(['', ''], Utils::normalizeAddress(null, true)); + $this->assertSame(['test', ''], Utils::normalizeAddress('TEST', true)); + $this->assertSame(['test', 'domain.tld'], Utils::normalizeAddress('Test@Domain.TLD', true)); + $this->assertSame(['test', 'domain.tld'], Utils::normalizeAddress('Test+Trash@Domain.TLD', true)); } /** * Test for Utils::powerSet() */ public function testPowerSet(): void { $set = []; - $result = \App\Utils::powerSet($set); + $result = Utils::powerSet($set); $this->assertIsArray($result); $this->assertCount(0, $result); $set = ["a1"]; - $result = \App\Utils::powerSet($set); + $result = Utils::powerSet($set); $this->assertIsArray($result); $this->assertCount(1, $result); $this->assertTrue(in_array(["a1"], $result)); $set = ["a1", "a2"]; - $result = \App\Utils::powerSet($set); + $result = Utils::powerSet($set); $this->assertIsArray($result); $this->assertCount(3, $result); $this->assertTrue(in_array(["a1"], $result)); $this->assertTrue(in_array(["a2"], $result)); $this->assertTrue(in_array(["a1", "a2"], $result)); $set = ["a1", "a2", "a3"]; - $result = \App\Utils::powerSet($set); + $result = Utils::powerSet($set); $this->assertIsArray($result); $this->assertCount(7, $result); $this->assertTrue(in_array(["a1"], $result)); $this->assertTrue(in_array(["a2"], $result)); $this->assertTrue(in_array(["a3"], $result)); $this->assertTrue(in_array(["a1", "a2"], $result)); $this->assertTrue(in_array(["a1", "a3"], $result)); $this->assertTrue(in_array(["a2", "a3"], $result)); $this->assertTrue(in_array(["a1", "a2", "a3"], $result)); } /** * Test for Utils::serviceUrl() */ public function testServiceUrl(): void { $public_href = 'https://public.url/cockpit'; $local_href = 'https://local.url/cockpit'; \config([ 'app.url' => $local_href, 'app.public_url' => '', ]); $this->assertSame($local_href, Utils::serviceUrl('')); $this->assertSame($local_href . '/unknown', Utils::serviceUrl('unknown')); $this->assertSame($local_href . '/unknown', Utils::serviceUrl('/unknown')); \config([ 'app.url' => $local_href, 'app.public_url' => $public_href, ]); $this->assertSame($public_href, Utils::serviceUrl('')); $this->assertSame($public_href . '/unknown', Utils::serviceUrl('unknown')); $this->assertSame($public_href . '/unknown', Utils::serviceUrl('/unknown')); } /** * Test for Utils::uuidInt() */ public function testUuidInt(): void { $result = Utils::uuidInt(); $this->assertTrue(is_int($result)); $this->assertTrue($result > 0); } /** * Test for Utils::uuidStr() */ public function testUuidStr(): void { $result = Utils::uuidStr(); $this->assertTrue(is_string($result)); $this->assertTrue(strlen($result) === 36); $this->assertTrue(preg_match('/[^a-f0-9-]/i', $result) === 0); } /** * Test for Utils::exchangeRate() */ public function testExchangeRate(): void { $this->assertSame(1.0, Utils::exchangeRate("DUMMY", "dummy")); // Exchange rates are volatile, can't test with high accuracy. $this->assertTrue(Utils::exchangeRate("CHF", "EUR") >= 0.88); //$this->assertEqualsWithDelta(0.90503424978382, Utils::exchangeRate("CHF", "EUR"), PHP_FLOAT_EPSILON); $this->assertTrue(Utils::exchangeRate("EUR", "CHF") <= 1.12); //$this->assertEqualsWithDelta(1.1049305595217682, Utils::exchangeRate("EUR", "CHF"), PHP_FLOAT_EPSILON); $this->expectException(\Exception::class); $this->assertSame(1.0, Utils::exchangeRate("CHF", "FOO")); $this->expectException(\Exception::class); $this->assertSame(1.0, Utils::exchangeRate("FOO", "CHF")); } }