diff --git a/docker/tests/init.sh b/docker/tests/init.sh index 76e09f85..2c2bb62f 100755 --- a/docker/tests/init.sh +++ b/docker/tests/init.sh @@ -1,76 +1,76 @@ #!/bin/bash #set -e sudo cp -a /src/kolabsrc.orig /src/kolabsrc sudo chmod 777 -R /src/kolabsrc cd /src/kolabsrc sudo rm -rf vendor/ composer.lock php -dmemory_limit=-1 $(command -v composer) install sudo rm -rf node_modules mkdir node_modules npm install find bootstrap/cache/ -type f ! -name ".gitignore" -delete ./artisan key:generate ./artisan clear-compiled ./artisan cache:clear ./artisan horizon:install if [ ! -f storage/oauth-public.key -o ! -f storage/oauth-private.key ]; then ./artisan passport:keys --force fi cat >> .env << EOF PASSPORT_PRIVATE_KEY="$(cat storage/oauth-private.key)" PASSPORT_PUBLIC_KEY="$(cat storage/oauth-public.key)" EOF if rpm -qv chromium 2>/dev/null; then chver=$(rpmquery --queryformat="%{VERSION}" chromium | awk -F'.' '{print $1}') ./artisan dusk:chrome-driver ${chver} fi if [ ! -f 'resources/countries.php' ]; then ./artisan data:countries fi npm run dev # /usr/bin/chromium-browser --no-sandbox --headless --disable-gpu --remote-debugging-port=9222 http://localhost & rm -rf database/database.sqlite ./artisan db:ping --wait php -dmemory_limit=512M ./artisan migrate:refresh --seed ./artisan data:import || : ./artisan queue:work --stop-when-empty # nohup ./artisan horizon >/dev/null 2>&1 & ./artisan octane:start --host=$(grep OCTANE_HTTP_HOST .env | tail -n1 | sed "s/OCTANE_HTTP_HOST=//") >/dev/null 2>&1 & php \ -dmemory_limit=-1 \ vendor/bin/phpunit \ - --exclude flaky \ + --exclude-group skipci \ --verbose \ --stop-on-defect \ --stop-on-error \ --stop-on-failure \ --testsuite Unit php \ -dmemory_limit=-1 \ vendor/bin/phpunit \ - --exclude flaky \ + --exclude-group skipci \ --verbose \ --stop-on-defect \ --stop-on-error \ --stop-on-failure \ --testsuite Functional php \ -dmemory_limit=-1 \ vendor/bin/phpunit \ - --exclude flaky \ + --exclude-group skipci,coinbase,mollie,stripe,meet,dns \ --verbose \ --stop-on-defect \ --stop-on-error \ --stop-on-failure \ --testsuite Feature diff --git a/src/tests/Browser/Admin/DashboardTest.php b/src/tests/Browser/Admin/DashboardTest.php index 9510d351..23c8c3ed 100644 --- a/src/tests/Browser/Admin/DashboardTest.php +++ b/src/tests/Browser/Admin/DashboardTest.php @@ -1,155 +1,157 @@ getTestUser('jack@kolab.org'); $jack->setSetting('external_email', null); $this->deleteTestUser('test@testsearch.com'); $this->deleteTestDomain('testsearch.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $jack = $this->getTestUser('jack@kolab.org'); $jack->setSetting('external_email', null); $this->deleteTestUser('test@testsearch.com'); $this->deleteTestDomain('testsearch.com'); parent::tearDown(); } /** * Test user search + * @group skipci */ public function testSearch(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', \App\Utils::generatePassphrase(), true) ->on(new Dashboard()) ->assertFocused('@search input') ->assertMissing('@search table'); // Test search with no results $browser->type('@search input', 'unknown') ->click('@search form button') ->assertToast(Toast::TYPE_INFO, '0 user accounts have been found.') ->assertMissing('@search table'); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $jack->setSetting('external_email', 'john.doe.external@gmail.com'); // Test search with multiple results $browser->type('@search input', 'john.doe.external@gmail.com') ->click('@search form button') ->assertToast(Toast::TYPE_INFO, '2 user accounts have been found.') ->whenAvailable('@search table', function (Browser $browser) use ($john, $jack) { $browser->assertElementsCount('tbody tr', 2) ->with('tbody tr:first-child', function (Browser $browser) use ($jack) { $browser->assertSeeIn('td:nth-child(1) a', $jack->email) ->assertSeeIn('td:nth-child(2) a', $jack->id); if ($browser->isPhone()) { $browser->assertMissing('td:nth-child(3)'); } else { $browser->assertVisible('td:nth-child(3)') ->assertTextRegExp('td:nth-child(3)', '/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/') ->assertVisible('td:nth-child(4)') ->assertText('td:nth-child(4)', ''); } }) ->with('tbody tr:last-child', function (Browser $browser) use ($john) { $browser->assertSeeIn('td:nth-child(1) a', $john->email) ->assertSeeIn('td:nth-child(2) a', $john->id); if ($browser->isPhone()) { $browser->assertMissing('td:nth-child(3)'); } else { $browser->assertVisible('td:nth-child(3)') ->assertTextRegExp('td:nth-child(3)', '/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/') ->assertVisible('td:nth-child(4)') ->assertText('td:nth-child(4)', ''); } }); }); // Test search with single record result -> redirect to user page $browser->type('@search input', 'kolab.org') ->click('@search form button') ->assertMissing('@search table') ->waitForLocation('/user/' . $john->id) ->waitUntilMissing('.app-loader') ->whenAvailable('#user-info', function (Browser $browser) use ($john) { $browser->assertSeeIn('.card-title', $john->email); }); }); } /** * Test user search deleted user/domain + * @group skipci */ public function testSearchDeleted(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', \App\Utils::generatePassphrase(), true) ->on(new Dashboard()) ->assertFocused('@search input') ->assertMissing('@search table'); // Deleted users/domains $domain = $this->getTestDomain('testsearch.com', ['type' => \App\Domain::TYPE_EXTERNAL]); $user = $this->getTestUser('test@testsearch.com'); $plan = \App\Plan::where('title', 'group')->first(); $user->assignPlan($plan, $domain); $user->setAliases(['alias@testsearch.com']); Queue::fake(); $user->delete(); // Test search with multiple results $browser->type('@search input', 'testsearch.com') ->click('@search form button') ->assertToast(Toast::TYPE_INFO, '1 user accounts have been found.') ->whenAvailable('@search table', function (Browser $browser) use ($user) { $browser->assertElementsCount('tbody tr', 1) ->assertVisible('tbody tr:first-child.text-secondary') ->with('tbody tr:first-child', function (Browser $browser) use ($user) { $browser->assertSeeIn('td:nth-child(1) span', $user->email) ->assertSeeIn('td:nth-child(2) span', $user->id); if ($browser->isPhone()) { $browser->assertMissing('td:nth-child(3)'); } else { $browser->assertVisible('td:nth-child(3)') ->assertTextRegExp('td:nth-child(3)', '/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/') ->assertVisible('td:nth-child(4)') ->assertTextRegExp('td:nth-child(4)', '/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/'); } }); }); }); } } diff --git a/src/tests/Browser/DomainTest.php b/src/tests/Browser/DomainTest.php index c1b08920..f6c032b1 100644 --- a/src/tests/Browser/DomainTest.php +++ b/src/tests/Browser/DomainTest.php @@ -1,351 +1,357 @@ deleteTestDomain('testdomain.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestDomain('testdomain.com'); parent::tearDown(); } /** * Test domain info page (unauthenticated) */ public function testDomainInfoUnauth(): void { // Test that the page requires authentication $this->browse(function ($browser) { $browser->visit('/domain/123')->on(new Home()); }); } /** * Test domains list page (unauthenticated) */ public function testDomainListUnauth(): void { // Test that the page requires authentication $this->browse(function ($browser) { $browser->visit('/domains')->on(new Home()); }); } /** * Test domain info page (non-existing domain id) + * @group skipci */ public function testDomainInfo404(): void { $this->browse(function ($browser) { // FIXME: I couldn't make loginAs() method working // Note: Here we're also testing that unauthenticated request // is passed to logon form and then "redirected" to the requested page $browser->visit('/domain/123') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123') ->assertErrorPage(404); }); } /** * Test domain info page (existing domain) * * @depends testDomainInfo404 + * @group skipci */ public function testDomainInfo(): void { $this->browse(function ($browser) { // Unconfirmed domain $domain = Domain::where('namespace', 'kolab.org')->first(); if ($domain->isConfirmed()) { $domain->status ^= Domain::STATUS_CONFIRMED; $domain->save(); } $domain->setSetting('spf_whitelist', \json_encode(['.test.com'])); $browser->visit('/domain/' . $domain->id) ->on(new DomainInfo()) ->assertSeeIn('.card-title', 'Domain') ->whenAvailable('@general', function ($browser) use ($domain) { $browser->assertSeeIn('form div:nth-child(1) label', 'Status') ->assertSeeIn('form div:nth-child(1) #status.text-danger', 'Not Ready') ->assertSeeIn('form div:nth-child(2) label', 'Name') ->assertValue('form div:nth-child(2) input:disabled', $domain->namespace) ->assertSeeIn('form div:nth-child(3) label', 'Subscriptions'); }) ->whenAvailable('@general form div:nth-child(3) table', function ($browser) { $browser->assertElementsCount('tbody tr', 1) ->assertVisible('tbody tr td.selection input:checked:disabled') ->assertSeeIn('tbody tr td.name', 'External Domain') ->assertSeeIn('tbody tr td.price', '0,00 CHF/month') ->assertTip( 'tbody tr td.buttons button', 'Host a domain that is externally registered' ); }) ->whenAvailable('@verify', function ($browser) use ($domain) { $browser->assertSeeIn('pre', $domain->namespace) ->assertSeeIn('pre', $domain->hash()) ->click('button') ->assertToast(Toast::TYPE_SUCCESS, 'Domain verified successfully.'); // TODO: Test scenario when a domain confirmation failed }) ->whenAvailable('@config', function ($browser) use ($domain) { $browser->assertSeeIn('pre', $domain->namespace); }) ->assertMissing('@general button[type=submit]') ->assertMissing('@verify'); // Check that confirmed domain page contains only the config box $browser->visit('/domain/' . $domain->id) ->on(new DomainInfo()) ->assertMissing('@verify') ->assertPresent('@config'); }); } /** * Test domain settings + * @group skipci */ public function testDomainSettings(): void { $this->browse(function ($browser) { $domain = Domain::where('namespace', 'kolab.org')->first(); $domain->setSetting('spf_whitelist', \json_encode(['.test.com'])); $browser->visit('/domain/' . $domain->id) ->on(new DomainInfo()) ->assertElementsCount('@nav a', 2) ->assertSeeIn('@nav #tab-general', 'General') ->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->with('#settings form', function (Browser $browser) { // Test whitelist widget $widget = new ListInput('#spf_whitelist'); $browser->assertSeeIn('div.row:nth-child(1) label', 'SPF Whitelist') ->assertVisible('div.row:nth-child(1) .list-input') ->with($widget, function (Browser $browser) { $browser->assertListInputValue(['.test.com']) ->assertValue('@input', '') ->addListEntry('invalid domain'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->with($widget, function (Browser $browser) { $err = 'The entry format is invalid. Expected a domain name starting with a dot.'; $browser->assertFormError(2, $err, false) ->removeListEntry(2) ->removeListEntry(1) ->addListEntry('.new.domain.tld'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'Domain settings updated successfully.'); }); }); } /** * Test domains list page * * @depends testDomainListUnauth + * @group skipci */ public function testDomainList(): void { $this->browse(function ($browser) { // Login the user $browser->visit('/login') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) // On dashboard click the "Domains" link ->on(new Dashboard()) ->assertSeeIn('@links a.link-domains', 'Domains') ->click('@links a.link-domains') // On Domains List page click the domain entry ->on(new DomainList()) ->waitFor('@table tbody tr') ->assertVisible('@table tbody tr:first-child td:first-child svg.fa-globe.text-success') ->assertText('@table tbody tr:first-child td:first-child svg title', 'Active') ->assertSeeIn('@table tbody tr:first-child td:first-child', 'kolab.org') ->assertMissing('@table tfoot') ->click('@table tbody tr:first-child td:first-child a') // On Domain Info page verify that's the clicked domain ->on(new DomainInfo()) ->whenAvailable('@config', function ($browser) { $browser->assertSeeIn('pre', 'kolab.org'); }); }); // TODO: Test domains list acting as Ned (John's "delegatee") } /** * Test domains list page (user with no domains) */ public function testDomainListEmpty(): void { $this->browse(function ($browser) { // Login the user $browser->visit('/login') ->on(new Home()) ->submitLogon('jack@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertVisible('@links a.link-profile') ->assertMissing('@links a.link-domains') ->assertMissing('@links a.link-users') ->assertMissing('@links a.link-wallet'); /* // On dashboard click the "Domains" link ->assertSeeIn('@links a.link-domains', 'Domains') ->click('@links a.link-domains') // On Domains List page click the domain entry ->on(new DomainList()) ->assertMissing('@table tbody') ->assertSeeIn('tfoot td', 'There are no domains in this account.'); */ }); } /** * Test domain creation page + * @group skipci */ public function testDomainCreate(): void { $this->browse(function ($browser) { $browser->visit('/login') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->visit('/domains') ->on(new DomainList()) ->assertSeeIn('.card-title button.btn-success', 'Create domain') ->click('.card-title button.btn-success') ->on(new DomainInfo()) ->assertSeeIn('.card-title', 'New domain') ->assertElementsCount('@nav li', 1) ->assertSeeIn('@nav li:first-child', 'General') ->whenAvailable('@general', function ($browser) { $browser->assertSeeIn('form div:nth-child(1) label', 'Name') ->assertValue('form div:nth-child(1) input:not(:disabled)', '') ->assertFocused('form div:nth-child(1) input') ->assertSeeIn('form div:nth-child(2) label', 'Package') ->assertMissing('form div:nth-child(3)'); }) ->whenAvailable('@general form div:nth-child(2) table', function ($browser) { $browser->assertElementsCount('tbody tr', 1) ->assertVisible('tbody tr td.selection input:checked[readonly]') ->assertSeeIn('tbody tr td.name', 'Domain Hosting') ->assertSeeIn('tbody tr td.price', '0,00 CHF/month') ->assertTip( 'tbody tr td.buttons button', 'Use your own, existing domain.' ); }) ->assertSeeIn('@general button.btn-primary[type=submit]', 'Submit') ->assertMissing('@config') ->assertMissing('@verify') ->assertMissing('@settings') ->assertMissing('@status') // Test error handling ->click('button[type=submit]') ->waitFor('#namespace + .invalid-feedback') ->assertSeeIn('#namespace + .invalid-feedback', 'The namespace field is required.') ->assertFocused('#namespace') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->type('@general form div:nth-child(1) input', 'testdomain..com') ->click('button[type=submit]') ->waitFor('#namespace + .invalid-feedback') ->assertSeeIn('#namespace + .invalid-feedback', 'The specified domain is invalid.') ->assertFocused('#namespace') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') // Test success ->type('@general form div:nth-child(1) input', 'testdomain.com') ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'Domain created successfully.') ->on(new DomainList()) ->assertSeeIn('@table tr:nth-child(2) a', 'testdomain.com'); }); } /** * Test domain deletion + * @group skipci */ public function testDomainDelete(): void { // Create the domain to delete $john = $this->getTestUser('john@kolab.org'); $domain = $this->getTestDomain('testdomain.com', ['type' => Domain::TYPE_EXTERNAL]); $packageDomain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domain->assignPackage($packageDomain, $john); $this->browse(function ($browser) { $browser->visit('/login') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123') ->visit('/domains') ->on(new DomainList()) ->assertElementsCount('@table tbody tr', 2) ->assertSeeIn('@table tr:nth-child(2) a', 'testdomain.com') ->click('@table tbody tr:nth-child(2) a') ->on(new DomainInfo()) ->waitFor('button.button-delete') ->assertSeeIn('button.button-delete', 'Delete domain') ->click('button.button-delete') ->with(new Dialog('#delete-warning'), function ($browser) { $browser->assertSeeIn('@title', 'Delete testdomain.com') ->assertFocused('@button-cancel') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Delete') ->click('@button-cancel'); }) ->waitUntilMissing('#delete-warning') ->click('button.button-delete') ->with(new Dialog('#delete-warning'), function (Browser $browser) { $browser->click('@button-action'); }) ->waitUntilMissing('#delete-warning') ->assertToast(Toast::TYPE_SUCCESS, 'Domain deleted successfully.') ->on(new DomainList()) ->assertElementsCount('@table tbody tr', 1); // Test error handling on deleting a non-empty domain $err = 'Unable to delete a domain with assigned users or other objects.'; $browser->click('@table tbody tr:nth-child(1) a') ->on(new DomainInfo()) ->waitFor('button.button-delete') ->click('button.button-delete') ->with(new Dialog('#delete-warning'), function ($browser) { $browser->click('@button-action'); }) ->assertToast(Toast::TYPE_ERROR, $err); }); } } diff --git a/src/tests/Browser/ErrorTest.php b/src/tests/Browser/ErrorTest.php index 920f9a8b..f9d56436 100644 --- a/src/tests/Browser/ErrorTest.php +++ b/src/tests/Browser/ErrorTest.php @@ -1,37 +1,33 @@ browse(function (Browser $browser) { $browser->visit('/unknown') ->waitFor('#app > #error-page') ->assertVisible('#app > #header-menu') - ->assertVisible('#app > #footer-menu'); - - $this->assertSame('404', $browser->text('#error-page .code')); - $this->assertSame('Not found', $browser->text('#error-page .message')); + ->assertVisible('#app > #footer-menu') + ->assertErrorPage(404); }); $this->browse(function (Browser $browser) { $browser->visit('/login/unknown') ->waitFor('#app > #error-page') ->assertVisible('#app > #header-menu') - ->assertVisible('#app > #footer-menu'); - - $this->assertSame('404', $browser->text('#error-page .code')); - $this->assertSame('Not found', $browser->text('#error-page .message')); + ->assertVisible('#app > #footer-menu') + ->assertErrorPage(404); }); } } diff --git a/src/tests/Feature/Controller/DomainsTest.php b/src/tests/Feature/Controller/DomainsTest.php index ebe51586..1b419ae1 100644 --- a/src/tests/Feature/Controller/DomainsTest.php +++ b/src/tests/Feature/Controller/DomainsTest.php @@ -1,607 +1,608 @@ deleteTestUser('test1@' . \config('app.domain')); $this->deleteTestUser('test2@' . \config('app.domain')); $this->deleteTestUser('test1@domainscontroller.com'); $this->deleteTestDomain('domainscontroller.com'); Sku::where('title', 'test')->delete(); } public function tearDown(): void { $this->deleteTestUser('test1@' . \config('app.domain')); $this->deleteTestUser('test2@' . \config('app.domain')); $this->deleteTestUser('test1@domainscontroller.com'); $this->deleteTestDomain('domainscontroller.com'); Sku::where('title', 'test')->delete(); $domain = $this->getTestDomain('kolab.org'); $domain->settings()->whereIn('key', ['spf_whitelist'])->delete(); parent::tearDown(); } /** * Test domain confirm request + * @group skipci */ public function testConfirm(): void { Queue::fake(); $sku_domain = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $user = $this->getTestUser('test1@domainscontroller.com'); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); Entitlement::create([ 'wallet_id' => $user->wallets()->first()->id, 'sku_id' => $sku_domain->id, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class ]); $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/confirm"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(2, $json); $this->assertEquals('error', $json['status']); $this->assertEquals('Domain ownership verification failed.', $json['message']); $domain->status |= Domain::STATUS_CONFIRMED; $domain->save(); $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/confirm"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('Domain verified successfully.', $json['message']); $this->assertTrue(is_array($json['statusInfo'])); // Not authorized access $response = $this->actingAs($john)->get("api/v4/domains/{$domain->id}/confirm"); $response->assertStatus(403); // Authorized access by additional account controller $domain = $this->getTestDomain('kolab.org'); $response = $this->actingAs($ned)->get("api/v4/domains/{$domain->id}/confirm"); $response->assertStatus(200); } /** * Test domain delete request (DELETE /api/v4/domains/) */ public function testDestroy(): void { Queue::fake(); $sku_domain = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $john = $this->getTestUser('john@kolab.org'); $johns_domain = $this->getTestDomain('kolab.org'); $user1 = $this->getTestUser('test1@' . \config('app.domain')); $user2 = $this->getTestUser('test2@' . \config('app.domain')); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); Entitlement::create([ 'wallet_id' => $user1->wallets()->first()->id, 'sku_id' => $sku_domain->id, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class ]); // Not authorized access $response = $this->actingAs($john)->delete("api/v4/domains/{$domain->id}"); $response->assertStatus(403); // Can't delete non-empty domain $response = $this->actingAs($john)->delete("api/v4/domains/{$johns_domain->id}"); $response->assertStatus(422); $json = $response->json(); $this->assertCount(2, $json); $this->assertEquals('error', $json['status']); $this->assertEquals('Unable to delete a domain with assigned users or other objects.', $json['message']); // Successful deletion $response = $this->actingAs($user1)->delete("api/v4/domains/{$domain->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(2, $json); $this->assertEquals('success', $json['status']); $this->assertEquals('Domain deleted successfully.', $json['message']); $this->assertTrue($domain->fresh()->trashed()); // Authorized access by additional account controller $this->deleteTestDomain('domainscontroller.com'); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); Entitlement::create([ 'wallet_id' => $user1->wallets()->first()->id, 'sku_id' => $sku_domain->id, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class ]); $user1->wallets()->first()->addController($user2); $response = $this->actingAs($user2)->delete("api/v4/domains/{$domain->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(2, $json); $this->assertEquals('success', $json['status']); $this->assertEquals('Domain deleted successfully.', $json['message']); $this->assertTrue($domain->fresh()->trashed()); } /** * Test fetching domains list */ public function testIndex(): void { // User with no domains $user = $this->getTestUser('test1@domainscontroller.com'); $response = $this->actingAs($user)->get("api/v4/domains"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(4, $json); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertSame("0 domains have been found.", $json['message']); $this->assertSame([], $json['list']); // User with custom domain(s) $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($john)->get("api/v4/domains"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(4, $json); $this->assertSame(1, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertSame("1 domains have been found.", $json['message']); $this->assertCount(1, $json['list']); $this->assertSame('kolab.org', $json['list'][0]['namespace']); // Values below are tested by Unit tests $this->assertArrayHasKey('isConfirmed', $json['list'][0]); $this->assertArrayHasKey('isDeleted', $json['list'][0]); $this->assertArrayHasKey('isVerified', $json['list'][0]); $this->assertArrayHasKey('isSuspended', $json['list'][0]); $this->assertArrayHasKey('isActive', $json['list'][0]); $this->assertArrayHasKey('isLdapReady', $json['list'][0]); $response = $this->actingAs($ned)->get("api/v4/domains"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(4, $json); $this->assertCount(1, $json['list']); $this->assertSame('kolab.org', $json['list'][0]['namespace']); } /** * Test domain config update (POST /api/v4/domains//config) */ public function testSetConfig(): void { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $domain = $this->getTestDomain('kolab.org'); $domain->setSetting('spf_whitelist', null); // Test unknown domain id $post = ['spf_whitelist' => []]; $response = $this->actingAs($john)->post("/api/v4/domains/123/config", $post); $json = $response->json(); $response->assertStatus(404); // Test access by user not being a wallet controller $post = ['spf_whitelist' => []]; $response = $this->actingAs($jack)->post("/api/v4/domains/{$domain->id}/config", $post); $json = $response->json(); $response->assertStatus(403); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test some invalid data $post = ['grey' => 1]; $response = $this->actingAs($john)->post("/api/v4/domains/{$domain->id}/config", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertCount(1, $json['errors']); $this->assertSame('The requested configuration parameter is not supported.', $json['errors']['grey']); $this->assertNull($domain->fresh()->getSetting('spf_whitelist')); // Test some valid data $post = ['spf_whitelist' => ['.test.domain.com']]; $response = $this->actingAs($john)->post("/api/v4/domains/{$domain->id}/config", $post); $response->assertStatus(200); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertSame('Domain settings updated successfully.', $json['message']); $expected = \json_encode($post['spf_whitelist']); $this->assertSame($expected, $domain->fresh()->getSetting('spf_whitelist')); // Test input validation $post = ['spf_whitelist' => ['aaa']]; $response = $this->actingAs($john)->post("/api/v4/domains/{$domain->id}/config", $post); $response->assertStatus(422); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame( 'The entry format is invalid. Expected a domain name starting with a dot.', $json['errors']['spf_whitelist'][0] ); $this->assertSame($expected, $domain->fresh()->getSetting('spf_whitelist')); } /** * Test fetching domain info */ public function testShow(): void { $sku_domain = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $user = $this->getTestUser('test1@domainscontroller.com'); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); $discount = \App\Discount::withEnvTenantContext()->where('code', 'TEST')->first(); $wallet = $user->wallet(); $wallet->discount()->associate($discount); $wallet->save(); Entitlement::create([ 'wallet_id' => $user->wallets()->first()->id, 'sku_id' => $sku_domain->id, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class ]); $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($domain->id, $json['id']); $this->assertEquals($domain->namespace, $json['namespace']); $this->assertEquals($domain->status, $json['status']); $this->assertEquals($domain->type, $json['type']); $this->assertSame($domain->hash(Domain::HASH_TEXT), $json['hash_text']); $this->assertSame($domain->hash(Domain::HASH_CNAME), $json['hash_cname']); $this->assertSame($domain->hash(Domain::HASH_CODE), $json['hash_code']); $this->assertSame([], $json['config']['spf_whitelist']); $this->assertCount(4, $json['mx']); $this->assertTrue(strpos(implode("\n", $json['mx']), $domain->namespace) !== false); $this->assertCount(8, $json['dns']); $this->assertTrue(strpos(implode("\n", $json['dns']), $domain->namespace) !== false); $this->assertTrue(strpos(implode("\n", $json['dns']), $domain->hash()) !== false); $this->assertTrue(is_array($json['statusInfo'])); // Values below are tested by Unit tests $this->assertArrayHasKey('isConfirmed', $json); $this->assertArrayHasKey('isDeleted', $json); $this->assertArrayHasKey('isVerified', $json); $this->assertArrayHasKey('isSuspended', $json); $this->assertArrayHasKey('isActive', $json); $this->assertArrayHasKey('isLdapReady', $json); $this->assertCount(1, $json['skus']); $this->assertSame(1, $json['skus'][$sku_domain->id]['count']); $this->assertSame([0], $json['skus'][$sku_domain->id]['costs']); $this->assertSame($wallet->id, $json['wallet']['id']); $this->assertSame($wallet->balance, $json['wallet']['balance']); $this->assertSame($wallet->currency, $json['wallet']['currency']); $this->assertSame($discount->discount, $json['wallet']['discount']); $this->assertSame($discount->description, $json['wallet']['discount_description']); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); // Not authorized - Other account domain $response = $this->actingAs($john)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(403); $domain = $this->getTestDomain('kolab.org'); // Ned is an additional controller on kolab.org's wallet $response = $this->actingAs($ned)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(200); // Jack has no entitlement/control over kolab.org $response = $this->actingAs($jack)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(403); } /** * Test fetching SKUs list for a domain (GET /domains//skus) */ public function testSkus(): void { $user = $this->getTestUser('john@kolab.org'); $domain = $this->getTestDomain('kolab.org'); // Unauth access not allowed $response = $this->get("api/v4/domains/{$domain->id}/skus"); $response->assertStatus(401); // Create an sku for another tenant, to make sure it is not included in the result $nsku = Sku::create([ 'title' => 'test', 'name' => 'Test', 'description' => '', 'active' => true, 'cost' => 100, 'handler_class' => 'App\Handlers\Domain', ]); $tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first(); $nsku->tenant_id = $tenant->id; $nsku->save(); $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(1, $json); $this->assertSkuElement('domain-hosting', $json[0], [ 'prio' => 0, 'type' => 'domain', 'handler' => 'DomainHosting', 'enabled' => true, 'readonly' => true, ]); } /** * Test fetching domain status (GET /api/v4/domains//status) * and forcing setup process update (?refresh=1) * * @group dns */ public function testStatus(): void { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $domain = $this->getTestDomain('kolab.org'); // Test unauthorized access $response = $this->actingAs($jack)->get("/api/v4/domains/{$domain->id}/status"); $response->assertStatus(403); $domain->status = Domain::STATUS_NEW | Domain::STATUS_ACTIVE | Domain::STATUS_LDAP_READY; $domain->save(); // Get domain status $response = $this->actingAs($john)->get("/api/v4/domains/{$domain->id}/status"); $response->assertStatus(200); $json = $response->json(); $this->assertFalse($json['isVerified']); $this->assertFalse($json['isReady']); $this->assertCount(4, $json['process']); $this->assertSame('domain-verified', $json['process'][2]['label']); $this->assertSame(false, $json['process'][2]['state']); $this->assertTrue(empty($json['status'])); $this->assertTrue(empty($json['message'])); // Now "reboot" the process and verify the domain $response = $this->actingAs($john)->get("/api/v4/domains/{$domain->id}/status?refresh=1"); $response->assertStatus(200); $json = $response->json(); $this->assertTrue($json['isVerified']); $this->assertTrue($json['isReady']); $this->assertCount(4, $json['process']); $this->assertSame('domain-verified', $json['process'][2]['label']); $this->assertSame(true, $json['process'][2]['state']); $this->assertSame('domain-confirmed', $json['process'][3]['label']); $this->assertSame(true, $json['process'][3]['state']); $this->assertSame('success', $json['status']); $this->assertSame('Setup process finished successfully.', $json['message']); // TODO: Test completing all process steps } /** * Test domain creation (POST /api/v4/domains) */ public function testStore(): void { Queue::fake(); $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); // Test empty request $response = $this->actingAs($john)->post("/api/v4/domains", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("The namespace field is required.", $json['errors']['namespace'][0]); $this->assertCount(1, $json['errors']); $this->assertCount(1, $json['errors']['namespace']); $this->assertCount(2, $json); // Test access by user not being a wallet controller $post = ['namespace' => 'domainscontroller.com']; $response = $this->actingAs($jack)->post("/api/v4/domains", $post); $json = $response->json(); $response->assertStatus(403); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test some invalid data $post = ['namespace' => '--']; $response = $this->actingAs($john)->post("/api/v4/domains", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The specified domain is invalid.', $json['errors']['namespace'][0]); $this->assertCount(1, $json['errors']); $this->assertCount(1, $json['errors']['namespace']); // Test an existing domain $post = ['namespace' => 'kolab.org']; $response = $this->actingAs($john)->post("/api/v4/domains", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The specified domain is not available.', $json['errors']['namespace']); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); // Missing package $post = ['namespace' => 'domainscontroller.com']; $response = $this->actingAs($john)->post("/api/v4/domains", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("Package is required.", $json['errors']['package']); $this->assertCount(2, $json); // Invalid package $post['package'] = $package_kolab->id; $response = $this->actingAs($john)->post("/api/v4/domains", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("Invalid package selected.", $json['errors']['package']); $this->assertCount(2, $json); // Test full and valid data $post['package'] = $package_domain->id; $response = $this->actingAs($john)->post("/api/v4/domains", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("Domain created successfully.", $json['message']); $this->assertCount(2, $json); $domain = Domain::where('namespace', $post['namespace'])->first(); $this->assertInstanceOf(Domain::class, $domain); // Assert the new domain entitlements $this->assertEntitlements($domain, ['domain-hosting']); // Assert the wallet to which the new domain should be assigned to $wallet = $domain->wallet(); $this->assertSame($john->wallets->first()->id, $wallet->id); // Test re-creating a domain $domain->delete(); $response = $this->actingAs($john)->post("/api/v4/domains", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Domain created successfully.", $json['message']); $this->assertCount(2, $json); $domain = Domain::where('namespace', $post['namespace'])->first(); $this->assertInstanceOf(Domain::class, $domain); $this->assertEntitlements($domain, ['domain-hosting']); $wallet = $domain->wallet(); $this->assertSame($john->wallets->first()->id, $wallet->id); // Test creating a domain that is soft-deleted and belongs to another user $domain->delete(); $domain->entitlements()->withTrashed()->update(['wallet_id' => $jack->wallets->first()->id]); $response = $this->actingAs($john)->post("/api/v4/domains", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The specified domain is not available.', $json['errors']['namespace']); // Test acting as account controller (not owner) $this->markTestIncomplete(); } } diff --git a/src/tests/Feature/DomainTest.php b/src/tests/Feature/DomainTest.php index 27f5fd00..a7c56d53 100644 --- a/src/tests/Feature/DomainTest.php +++ b/src/tests/Feature/DomainTest.php @@ -1,371 +1,373 @@ domains as $domain) { $this->deleteTestDomain($domain); } $this->deleteTestUser('user@gmail.com'); } /** * {@inheritDoc} */ public function tearDown(): void { foreach ($this->domains as $domain) { $this->deleteTestDomain($domain); } $this->deleteTestUser('user@gmail.com'); parent::tearDown(); } /** * Test domain create/creating observer */ public function testCreate(): void { Queue::fake(); $domain = Domain::create([ 'namespace' => 'GMAIL.COM', 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); $result = Domain::where('namespace', 'gmail.com')->first(); $this->assertSame('gmail.com', $result->namespace); $this->assertSame($domain->id, $result->id); $this->assertSame($domain->type, $result->type); $this->assertSame(Domain::STATUS_NEW, $result->status); } /** * Test domain creating jobs */ public function testCreateJobs(): void { // Fake the queue, assert that no jobs were pushed... Queue::fake(); Queue::assertNothingPushed(); $domain = Domain::create([ 'namespace' => 'gmail.com', 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\Domain\CreateJob::class, function ($job) use ($domain) { $domainId = TestCase::getObjectProperty($job, 'domainId'); $domainNamespace = TestCase::getObjectProperty($job, 'domainNamespace'); return $domainId === $domain->id && $domainNamespace === $domain->namespace; } ); $job = new \App\Jobs\Domain\CreateJob($domain->id); $job->handle(); } /** * Tests getPublicDomains() method */ public function testGetPublicDomains(): void { $public_domains = Domain::getPublicDomains(); $this->assertNotContains('public-active.com', $public_domains); $queue = Queue::fake(); $domain = Domain::create([ 'namespace' => 'public-active.com', 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); // External domains should not be returned $public_domains = Domain::getPublicDomains(); $this->assertNotContains('public-active.com', $public_domains); $domain->type = Domain::TYPE_PUBLIC; $domain->save(); $public_domains = Domain::getPublicDomains(); $this->assertContains('public-active.com', $public_domains); // Domains of other tenants should not be returned $tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first(); $domain->tenant_id = $tenant->id; $domain->save(); $public_domains = Domain::getPublicDomains(); $this->assertNotContains('public-active.com', $public_domains); } /** * Test domain (ownership) confirmation * * @group dns */ public function testConfirm(): void { /* DNS records for positive and negative tests - kolab.org: ci-success-cname A 212.103.80.148 ci-success-cname MX 10 mx01.kolabnow.com. ci-success-cname TXT "v=spf1 mx -all" kolab-verify.ci-success-cname CNAME 2b719cfa4e1033b1e1e132977ed4fe3e.ci-success-cname ci-failure-cname A 212.103.80.148 ci-failure-cname MX 10 mx01.kolabnow.com. kolab-verify.ci-failure-cname CNAME 2b719cfa4e1033b1e1e132977ed4fe3e.ci-failure-cname ci-success-txt A 212.103.80.148 ci-success-txt MX 10 mx01.kolabnow.com. ci-success-txt TXT "v=spf1 mx -all" ci-success-txt TXT "kolab-verify=de5d04ababb52d52e2519a2f16d11422" ci-failure-txt A 212.103.80.148 ci-failure-txt MX 10 mx01.kolabnow.com. kolab-verify.ci-failure-txt TXT "kolab-verify=de5d04ababb52d52e2519a2f16d11422" ci-failure-none A 212.103.80.148 ci-failure-none MX 10 mx01.kolabnow.com. */ $queue = Queue::fake(); $domain_props = ['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL]; $domain = $this->getTestDomain('ci-failure-none.kolab.org', $domain_props); $this->assertTrue($domain->confirm() === false); $this->assertFalse($domain->isConfirmed()); $domain = $this->getTestDomain('ci-failure-txt.kolab.org', $domain_props); $this->assertTrue($domain->confirm() === false); $this->assertFalse($domain->isConfirmed()); $domain = $this->getTestDomain('ci-failure-cname.kolab.org', $domain_props); $this->assertTrue($domain->confirm() === false); $this->assertFalse($domain->isConfirmed()); $domain = $this->getTestDomain('ci-success-txt.kolab.org', $domain_props); $this->assertTrue($domain->confirm()); $this->assertTrue($domain->isConfirmed()); $domain = $this->getTestDomain('ci-success-cname.kolab.org', $domain_props); $this->assertTrue($domain->confirm()); $this->assertTrue($domain->isConfirmed()); } /** * Test domain deletion */ public function testDelete(): void { Queue::fake(); $domain = $this->getTestDomain('gmail.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $domain->delete(); $this->assertTrue($domain->fresh()->trashed()); $this->assertFalse($domain->fresh()->isDeleted()); // Delete the domain for real $job = new \App\Jobs\Domain\DeleteJob($domain->id); $job->handle(); $this->assertTrue(Domain::withTrashed()->where('id', $domain->id)->first()->isDeleted()); $domain->forceDelete(); $this->assertCount(0, Domain::withTrashed()->where('id', $domain->id)->get()); } /** * Test isEmpty() method */ public function testIsEmpty(): void { Queue::fake(); $this->deleteTestUser('user@gmail.com'); $this->deleteTestGroup('group@gmail.com'); $this->deleteTestResource('resource@gmail.com'); $this->deleteTestSharedFolder('folder@gmail.com'); // Empty domain $domain = $this->getTestDomain('gmail.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); $this->assertTrue($domain->isEmpty()); $this->getTestUser('user@gmail.com'); $this->assertFalse($domain->isEmpty()); $this->deleteTestUser('user@gmail.com'); $this->assertTrue($domain->isEmpty()); $this->getTestGroup('group@gmail.com'); $this->assertFalse($domain->isEmpty()); $this->deleteTestGroup('group@gmail.com'); $this->assertTrue($domain->isEmpty()); $this->getTestResource('resource@gmail.com'); $this->assertFalse($domain->isEmpty()); $this->deleteTestResource('resource@gmail.com'); $this->getTestSharedFolder('folder@gmail.com'); $this->assertFalse($domain->isEmpty()); $this->deleteTestSharedFolder('folder@gmail.com'); // TODO: Test with an existing alias, but not other objects in a domain // Empty public domain $domain = Domain::where('namespace', 'libertymail.net')->first(); $this->assertFalse($domain->isEmpty()); } /** * Test domain restoring */ public function testRestore(): void { Queue::fake(); $domain = $this->getTestDomain('gmail.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_SUSPENDED | Domain::STATUS_LDAP_READY | Domain::STATUS_CONFIRMED, 'type' => Domain::TYPE_PUBLIC, ]); $user = $this->getTestUser('user@gmail.com'); $sku = \App\Sku::where('title', 'domain-hosting')->first(); $now = \Carbon\Carbon::now(); // Assign two entitlements to the domain, so we can assert that only the // ones deleted last will be restored $ent1 = \App\Entitlement::create([ 'wallet_id' => $user->wallets->first()->id, 'sku_id' => $sku->id, 'cost' => 0, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class, ]); $ent2 = \App\Entitlement::create([ 'wallet_id' => $user->wallets->first()->id, 'sku_id' => $sku->id, 'cost' => 0, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class, ]); $domain->delete(); $this->assertTrue($domain->fresh()->trashed()); $this->assertFalse($domain->fresh()->isDeleted()); $this->assertTrue($ent1->fresh()->trashed()); $this->assertTrue($ent2->fresh()->trashed()); // Backdate some properties \App\Entitlement::withTrashed()->where('id', $ent2->id)->update(['deleted_at' => $now->subMinutes(2)]); \App\Entitlement::withTrashed()->where('id', $ent1->id)->update(['updated_at' => $now->subMinutes(10)]); Queue::fake(); $domain->restore(); $domain->refresh(); $this->assertFalse($domain->trashed()); $this->assertFalse($domain->isDeleted()); $this->assertFalse($domain->isSuspended()); $this->assertFalse($domain->isLdapReady()); $this->assertTrue($domain->isActive()); $this->assertTrue($domain->isConfirmed()); // Assert entitlements $this->assertTrue($ent2->fresh()->trashed()); $this->assertFalse($ent1->fresh()->trashed()); $this->assertTrue($ent1->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5))); // We expect only one CreateJob and one UpdateJob // Because how Illuminate/Database/Eloquent/SoftDeletes::restore() method // is implemented we cannot skip the UpdateJob in any way. // I don't want to overwrite this method, the extra job shouldn't do any harm. $this->assertCount(2, Queue::pushedJobs()); // @phpstan-ignore-line Queue::assertPushed(\App\Jobs\Domain\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\Domain\CreateJob::class, function ($job) use ($domain) { return $domain->id === TestCase::getObjectProperty($job, 'domainId'); } ); } /** * Tests for Domain::walletOwner() (from EntitleableTrait) */ public function testWalletOwner(): void { $domain = $this->getTestDomain('kolab.org'); $john = $this->getTestUser('john@kolab.org'); $this->assertSame($john->id, $domain->walletOwner()->id); // A domain without an owner $domain = $this->getTestDomain('gmail.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_SUSPENDED | Domain::STATUS_LDAP_READY | Domain::STATUS_CONFIRMED, 'type' => Domain::TYPE_PUBLIC, ]); $this->assertSame(null, $domain->walletOwner()); } } diff --git a/src/tests/Feature/Jobs/WalletCheckTest.php b/src/tests/Feature/Jobs/WalletCheckTest.php index c57317db..2822d81b 100644 --- a/src/tests/Feature/Jobs/WalletCheckTest.php +++ b/src/tests/Feature/Jobs/WalletCheckTest.php @@ -1,400 +1,401 @@ deleteTestUser('wallet-check@kolabnow.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('wallet-check@kolabnow.com'); parent::tearDown(); } /** * Test job handle, initial negative-balance notification */ public function testHandleInitial(): void { Mail::fake(); $user = $this->prepareTestUser($wallet); $now = Carbon::now(); $wallet->balance = 0; $wallet->save(); $job = new WalletCheck($wallet); $job->handle(); Mail::assertNothingSent(); // Balance is negative now $wallet->balance = -100; $wallet->save(); $job = new WalletCheck($wallet); $job->handle(); Mail::assertNothingSent(); // Balance turned negative 2 hours ago, expect mail sent $wallet->setSetting('balance_negative_since', $now->subHours(2)->toDateTimeString()); $wallet->setSetting('balance_warning_initial', null); $job = new WalletCheck($wallet); $job->handle(); // Assert the mail was sent to the user's email, but not to his external email Mail::assertSent(\App\Mail\NegativeBalance::class, 1); Mail::assertSent(\App\Mail\NegativeBalance::class, function ($mail) use ($user) { return $mail->hasTo($user->email) && !$mail->hasCc('external@test.com'); }); // Run the job again to make sure the notification is not sent again Mail::fake(); $job = new WalletCheck($wallet); $job->handle(); Mail::assertNothingSent(); // Test the migration scenario where a negative wallet has no balance_negative_since set yet Mail::fake(); $wallet->setSetting('balance_negative_since', null); $wallet->setSetting('balance_warning_initial', null); $job = new WalletCheck($wallet); $job->handle(); // Assert the mail was sent to the user's email, but not to his external email Mail::assertSent(\App\Mail\NegativeBalance::class, 1); Mail::assertSent(\App\Mail\NegativeBalance::class, function ($mail) use ($user) { return $mail->hasTo($user->email) && !$mail->hasCc('external@test.com'); }); $wallet->refresh(); $today_regexp = '/' . Carbon::now()->toDateString() . ' [0-9]{2}:[0-9]{2}:[0-9]{2}/'; $this->assertMatchesRegularExpression($today_regexp, $wallet->getSetting('balance_negative_since')); $this->assertMatchesRegularExpression($today_regexp, $wallet->getSetting('balance_warning_initial')); } /** * Test job handle, top-up before reminder notification * * @depends testHandleInitial */ public function testHandleBeforeReminder(): void { Mail::fake(); $user = $this->prepareTestUser($wallet); $now = Carbon::now(); // Balance turned negative 7-1 days ago $wallet->setSetting('balance_negative_since', $now->subDays(7 - 1)->toDateTimeString()); $job = new WalletCheck($wallet); $res = $job->handle(); Mail::assertNothingSent(); // TODO: Test that it actually executed the topUpWallet() $this->assertSame(WalletCheck::THRESHOLD_BEFORE_REMINDER, $res); $this->assertFalse($user->fresh()->isSuspended()); } /** * Test job handle, reminder notification * * @depends testHandleBeforeReminder */ public function testHandleReminder(): void { Mail::fake(); $user = $this->prepareTestUser($wallet); $now = Carbon::now(); // Balance turned negative 7+1 days ago, expect mail sent $wallet->setSetting('balance_negative_since', $now->subDays(7 + 1)->toDateTimeString()); $job = new WalletCheck($wallet); $job->handle(); // Assert the mail was sent to the user's email and to his external email Mail::assertSent(\App\Mail\NegativeBalanceReminderDegrade::class, 1); Mail::assertSent(\App\Mail\NegativeBalanceReminderDegrade::class, function ($mail) use ($user) { return $mail->hasTo($user->email) && $mail->hasCc('external@test.com'); }); // Run the job again to make sure the notification is not sent again Mail::fake(); $job = new WalletCheck($wallet); $job->handle(); Mail::assertNothingSent(); } /** * Test job handle, top-up wallet before account suspending * * @depends testHandleReminder */ /* public function testHandleBeforeSuspended(): void { Mail::fake(); $user = $this->prepareTestUser($wallet); $now = Carbon::now(); // Balance turned negative 7+14-1 days ago $days = 7 + 14 - 1; $wallet->setSetting('balance_negative_since', $now->subDays($days)->toDateTimeString()); $job = new WalletCheck($wallet); $res = $job->handle(); Mail::assertNothingSent(); // TODO: Test that it actually executed the topUpWallet() $this->assertSame(WalletCheck::THRESHOLD_BEFORE_SUSPEND, $res); $this->assertFalse($user->fresh()->isSuspended()); } */ /** * Test job handle, account suspending * * @depends testHandleBeforeSuspended */ /* public function testHandleSuspended(): void { Mail::fake(); $user = $this->prepareTestUser($wallet); $now = Carbon::now(); // Balance turned negative 7+14+1 days ago, expect mail sent $days = 7 + 14 + 1; $wallet->setSetting('balance_negative_since', $now->subDays($days)->toDateTimeString()); $job = new WalletCheck($wallet); $job->handle(); // Assert the mail was sent to the user's email, but not to his external email Mail::assertSent(\App\Mail\NegativeBalanceSuspended::class, 1); Mail::assertSent(\App\Mail\NegativeBalanceSuspended::class, function ($mail) use ($user) { return $mail->hasTo($user->email) && $mail->hasCc('external@test.com'); }); // Check that it has been suspended $this->assertTrue($user->fresh()->isSuspended()); // TODO: Test that group account members/domain are also being suspended // Run the job again to make sure the notification is not sent again Mail::fake(); $job = new WalletCheck($wallet); $job->handle(); Mail::assertNothingSent(); } */ /** * Test job handle, final warning before delete * * @depends testHandleSuspended */ /* public function testHandleBeforeDelete(): void { Mail::fake(); $user = $this->prepareTestUser($wallet); $now = Carbon::now(); // Balance turned negative 7+14+21-3+1 days ago, expect mail sent $days = 7 + 14 + 21 - 3 + 1; $wallet->setSetting('balance_negative_since', $now->subDays($days)->toDateTimeString()); $job = new WalletCheck($wallet); $job->handle(); // Assert the mail was sent to the user's email, and his external email Mail::assertSent(\App\Mail\NegativeBalanceBeforeDelete::class, 1); Mail::assertSent(\App\Mail\NegativeBalanceBeforeDelete::class, function ($mail) use ($user) { return $mail->hasTo($user->email) && $mail->hasCc('external@test.com'); }); // Check that it has not been deleted yet $this->assertFalse($user->fresh()->isDeleted()); // Run the job again to make sure the notification is not sent again Mail::fake(); $job = new WalletCheck($wallet); $job->handle(); Mail::assertNothingSent(); } */ /** * Test job handle, account delete * * @depends testHandleBeforeDelete */ /* public function testHandleDelete(): void { Mail::fake(); $user = $this->prepareTestUser($wallet); $now = Carbon::now(); $this->assertFalse($user->isDeleted()); $this->assertCount(7, $user->entitlements()->get()); // Balance turned negative 7+14+21+1 days ago, expect mail sent $days = 7 + 14 + 21 + 1; $wallet->setSetting('balance_negative_since', $now->subDays($days)->toDateTimeString()); $job = new WalletCheck($wallet); $job->handle(); Mail::assertNothingSent(); // Check that it has not been deleted $this->assertTrue($user->fresh()->trashed()); $this->assertCount(0, $user->entitlements()->get()); // TODO: Test it deletes all members of the group account } */ /** * Test job handle, account degrade * * @depends testHandleReminder */ public function testHandleDegrade(): void { Mail::fake(); $user = $this->prepareTestUser($wallet); $now = Carbon::now(); $this->assertFalse($user->isDegraded()); // Balance turned negative 7+7+1 days ago, expect mail sent $days = 7 + 7 + 1; $wallet->setSetting('balance_negative_since', $now->subDays($days)->toDateTimeString()); $job = new WalletCheck($wallet); $job->handle(); // Assert the mail was sent to the user's email, and his external email Mail::assertSent(\App\Mail\NegativeBalanceDegraded::class, 1); Mail::assertSent(\App\Mail\NegativeBalanceDegraded::class, function ($mail) use ($user) { return $mail->hasTo($user->email) && $mail->hasCc('external@test.com'); }); // Check that it has been degraded $this->assertTrue($user->fresh()->isDegraded()); } /** * Test job handle, periodic reminder to a degraded account * * @depends testHandleDegrade */ public function testHandleDegradeReminder(): void { Mail::fake(); $user = $this->prepareTestUser($wallet); $user->update(['status' => $user->status | User::STATUS_DEGRADED]); $now = Carbon::now(); $this->assertTrue($user->isDegraded()); // Test degraded_last_reminder not set $wallet->setSetting('degraded_last_reminder', null); $job = new WalletCheck($wallet); $res = $job->handle(); Mail::assertNothingSent(); $_last = Wallet::find($wallet->id)->getSetting('degraded_last_reminder'); $this->assertSame(Carbon::now()->toDateTimeString(), $_last); $this->assertSame(WalletCheck::THRESHOLD_DEGRADE_REMINDER, $res); // Test degraded_last_reminder set, but 14 days didn't pass yet $last = $now->copy()->subDays(10); $wallet->setSetting('degraded_last_reminder', $last->toDateTimeString()); $job = new WalletCheck($wallet); $res = $job->handle(); Mail::assertNothingSent(); $_last = $wallet->fresh()->getSetting('degraded_last_reminder'); $this->assertSame(WalletCheck::THRESHOLD_DEGRADE_REMINDER, $res); $this->assertSame($last->toDateTimeString(), $_last); // Test degraded_last_reminder set, and 14 days passed $wallet->setSetting('degraded_last_reminder', $now->copy()->subDays(14)->setSeconds(0)); $job = new WalletCheck($wallet); $res = $job->handle(); // Assert the mail was sent to the user's email, and his external email Mail::assertSent(\App\Mail\DegradedAccountReminder::class, 1); Mail::assertSent(\App\Mail\DegradedAccountReminder::class, function ($mail) use ($user) { return $mail->hasTo($user->email) && $mail->hasCc('external@test.com'); }); $_last = $wallet->fresh()->getSetting('degraded_last_reminder'); $this->assertSame(Carbon::now()->toDateTimeString(), $_last); $this->assertSame(WalletCheck::THRESHOLD_DEGRADE_REMINDER, $res); } /** * A helper to prepare a user for tests */ private function prepareTestUser(&$wallet) { $status = User::STATUS_ACTIVE | User::STATUS_LDAP_READY | User::STATUS_IMAP_READY; $user = $this->getTestUser('wallet-check@kolabnow.com', ['status' => $status]); $user->setSetting('external_email', 'external@test.com'); $wallet = $user->wallets()->first(); $package = \App\Package::withObjectTenantContext($user)->where('title', 'kolab')->first(); $user->assignPackage($package); $wallet->balance = -100; $wallet->save(); return $user; } } diff --git a/src/tests/Feature/SignupCodeTest.php b/src/tests/Feature/SignupCodeTest.php index 4be5ea6b..96e21610 100644 --- a/src/tests/Feature/SignupCodeTest.php +++ b/src/tests/Feature/SignupCodeTest.php @@ -1,49 +1,50 @@ 'User@email.org', ]; + Carbon::setTestNow(Carbon::createFromDate(2022, 02, 02)); $now = Carbon::now(); $code = SignupCode::create($data); $code_length = env('VERIFICATION_CODE_LENGTH', SignupCode::SHORTCODE_LENGTH); $exp = Carbon::now()->addHours(env('SIGNUP_CODE_EXPIRY', SignupCode::CODE_EXP_HOURS)); $this->assertFalse($code->isExpired()); $this->assertTrue(strlen($code->code) === SignupCode::CODE_LENGTH); $this->assertTrue(strlen($code->short_code) === $code_length); $this->assertSame($data['email'], $code->email); $this->assertSame('User', $code->local_part); $this->assertSame('email.org', $code->domain_part); $this->assertSame('127.0.0.1', $code->ip_address); $this->assertInstanceOf(Carbon::class, $code->expires_at); $this->assertSame($code->expires_at->toDateTimeString(), $exp->toDateTimeString()); $inst = SignupCode::find($code->code); $this->assertInstanceOf(SignupCode::class, $inst); $this->assertSame($inst->code, $code->code); $inst->email = 'other@email.com'; $inst->save(); $this->assertSame('other', $inst->local_part); $this->assertSame('email.com', $inst->domain_part); } } diff --git a/src/tests/Feature/WalletTest.php b/src/tests/Feature/WalletTest.php index cc87aedb..b0527803 100644 --- a/src/tests/Feature/WalletTest.php +++ b/src/tests/Feature/WalletTest.php @@ -1,447 +1,448 @@ users as $user) { $this->deleteTestUser($user); } } public function tearDown(): void { foreach ($this->users as $user) { $this->deleteTestUser($user); } Sku::select()->update(['fee' => 0]); parent::tearDown(); } /** * Test that turning wallet balance from negative to positive * unsuspends and undegrades the account */ public function testBalanceTurnsPositive(): void { Queue::fake(); $user = $this->getTestUser('UserWallet1@UserWallet.com'); $user->suspend(); $user->degrade(); $wallet = $user->wallets()->first(); $wallet->balance = -100; $wallet->save(); $this->assertTrue($user->isSuspended()); $this->assertTrue($user->isDegraded()); $this->assertNotNull($wallet->getSetting('balance_negative_since')); $wallet->balance = 100; $wallet->save(); $user->refresh(); $this->assertFalse($user->isSuspended()); $this->assertFalse($user->isDegraded()); $this->assertNull($wallet->getSetting('balance_negative_since')); // TODO: Test group account and unsuspending domain/members/groups } /** * Test for Wallet::balanceLastsUntil() */ public function testBalanceLastsUntil(): void { // Monthly cost of all entitlements: 990 // 28 days: 35.36 per day // 31 days: 31.93 per day $user = $this->getTestUser('jane@kolabnow.com'); $package = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $user->assignPackage($package); $wallet = $user->wallets()->first(); // User/entitlements created today, balance=0 $until = $wallet->balanceLastsUntil(); $this->assertSame( Carbon::now()->addMonthsWithoutOverflow(1)->toDateString(), $until->toDateString() ); // User/entitlements created today, balance=-10 CHF $wallet->balance = -1000; $until = $wallet->balanceLastsUntil(); $this->assertSame(null, $until); // User/entitlements created today, balance=-9,99 CHF (monthly cost) $wallet->balance = 990; $until = $wallet->balanceLastsUntil(); $daysInLastMonth = \App\Utils::daysInLastMonth(); $delta = Carbon::now()->addMonthsWithoutOverflow(1)->addDays($daysInLastMonth)->diff($until)->days; $this->assertTrue($delta <= 1); $this->assertTrue($delta >= -1); // Old entitlements, 100% discount $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subDays(40)); $discount = \App\Discount::withEnvTenantContext()->where('discount', 100)->first(); $wallet->discount()->associate($discount); $until = $wallet->refresh()->balanceLastsUntil(); $this->assertSame(null, $until); // User with no entitlements $wallet->discount()->dissociate($discount); $wallet->entitlements()->delete(); $until = $wallet->refresh()->balanceLastsUntil(); $this->assertSame(null, $until); } /** * Test for Wallet::costsPerDay() */ public function testCostsPerDay(): void { // 990 // 28 days: 35.36 // 31 days: 31.93 $user = $this->getTestUser('jane@kolabnow.com'); $package = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $user->assignPackage($package); $wallet = $user->wallets()->first(); $costsPerDay = $wallet->costsPerDay(); $this->assertTrue($costsPerDay < 35.38); $this->assertTrue($costsPerDay > 31.93); } /** * Verify a wallet is created, when a user is created. */ public function testCreateUserCreatesWallet(): void { $user = $this->getTestUser('UserWallet1@UserWallet.com'); $this->assertCount(1, $user->wallets); $this->assertSame(\config('app.currency'), $user->wallets[0]->currency); $this->assertSame(0, $user->wallets[0]->balance); } /** * Verify a user can haz more wallets. */ public function testAddWallet(): void { $user = $this->getTestUser('UserWallet2@UserWallet.com'); $user->wallets()->save( new Wallet(['currency' => 'USD']) ); $this->assertCount(2, $user->wallets); $user->wallets()->each( function ($wallet) { $this->assertEquals(0, $wallet->balance); } ); // For now all wallets use system currency $this->assertFalse($user->wallets()->where('currency', 'USD')->exists()); } /** * Verify we can not delete a user wallet that holds balance. */ public function testDeleteWalletWithCredit(): void { $user = $this->getTestUser('UserWallet3@UserWallet.com'); $user->wallets()->each( function ($wallet) { $wallet->credit(100)->save(); } ); $user->wallets()->each( function ($wallet) { $this->assertFalse($wallet->delete()); } ); } /** * Verify we can not delete a wallet that is the last wallet. */ public function testDeleteLastWallet(): void { $user = $this->getTestUser('UserWallet4@UserWallet.com'); $this->assertCount(1, $user->wallets); $user->wallets()->each( function ($wallet) { $this->assertFalse($wallet->delete()); } ); } /** * Verify we can remove a wallet that is an additional wallet. */ public function testDeleteAddtWallet(): void { $user = $this->getTestUser('UserWallet5@UserWallet.com'); $user->wallets()->save( new Wallet(['currency' => 'USD']) ); // For now additional wallets with a different currency is not allowed $this->assertFalse($user->wallets()->where('currency', 'USD')->exists()); /* $user->wallets()->each( function ($wallet) { if ($wallet->currency == 'USD') { $this->assertNotFalse($wallet->delete()); } } ); */ } /** * Verify a wallet can be assigned a controller. */ public function testAddWalletController(): void { $userA = $this->getTestUser('WalletControllerA@WalletController.com'); $userB = $this->getTestUser('WalletControllerB@WalletController.com'); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $this->assertCount(1, $userB->accounts); $aWallet = $userA->wallets()->first(); $bAccount = $userB->accounts()->first(); $this->assertTrue($bAccount->id === $aWallet->id); } /** * Test Wallet::isController() */ public function testIsController(): void { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $wallet = $jack->wallet(); $this->assertTrue($wallet->isController($john)); $this->assertTrue($wallet->isController($ned)); $this->assertFalse($wallet->isController($jack)); } /** * Verify controllers can also be removed from wallets. */ public function testRemoveWalletController(): void { $userA = $this->getTestUser('WalletController2A@WalletController.com'); $userB = $this->getTestUser('WalletController2B@WalletController.com'); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $userB->refresh(); $userB->accounts()->each( function ($wallet) use ($userB) { $wallet->removeController($userB); } ); $this->assertCount(0, $userB->accounts); } /** * Test for charging and removing entitlements (including tenant commission calculations) */ public function testChargeAndDeleteEntitlements(): void { $user = $this->getTestUser('jane@kolabnow.com'); $wallet = $user->wallets()->first(); $discount = \App\Discount::withEnvTenantContext()->where('discount', 30)->first(); $wallet->discount()->associate($discount); $wallet->save(); // Add 40% fee to all SKUs Sku::select()->update(['fee' => DB::raw("`cost` * 0.4")]); $package = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $storage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $user->assignPackage($package); $user->assignSku($storage, 5); $user->refresh(); // Reset reseller's wallet balance and transactions $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete(); // ------------------------------------ // Test normal charging of entitlements // ------------------------------------ // Backdate and charge entitlements, we're expecting one month to be charged // Set fake NOW date to make simpler asserting results that depend on number of days in current/last month Carbon::setTestNow(Carbon::create(2021, 5, 21, 12)); $backdate = Carbon::now()->subWeeks(7); $this->backdateEntitlements($user->entitlements, $backdate); $charge = $wallet->chargeEntitlements(); $wallet->refresh(); $reseller_wallet->refresh(); // TODO: Update these comments with what is actually being used to calculate these numbers // 388 + 310 + 17 + 17 = 732 $this->assertSame(-778, $wallet->balance); // 388 - 555 x 40% + 310 - 444 x 40% + 34 - 50 x 40% = 312 $this->assertSame(332, $reseller_wallet->balance); $transactions = Transaction::where('object_id', $wallet->id) ->where('object_type', \App\Wallet::class)->get(); $reseller_transactions = Transaction::where('object_id', $reseller_wallet->id) ->where('object_type', \App\Wallet::class)->get(); $this->assertCount(1, $reseller_transactions); $trans = $reseller_transactions[0]; $this->assertSame("Charged user jane@kolabnow.com", $trans->description); $this->assertSame(332, $trans->amount); $this->assertSame(Transaction::WALLET_CREDIT, $trans->type); $this->assertCount(1, $transactions); $trans = $transactions[0]; $this->assertSame('', $trans->description); $this->assertSame(-778, $trans->amount); $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); // TODO: Test entitlement transaction records // ----------------------------------- // Test charging on entitlement delete // ----------------------------------- $reseller_wallet->balance = 0; $reseller_wallet->save(); $transactions = Transaction::where('object_id', $wallet->id) ->where('object_type', \App\Wallet::class)->delete(); $reseller_transactions = Transaction::where('object_id', $reseller_wallet->id) ->where('object_type', \App\Wallet::class)->delete(); $user->removeSku($storage, 2); // we expect the wallet to have been charged for 19 days of use of // 2 deleted storage entitlements $wallet->refresh(); $reseller_wallet->refresh(); // 2 x round(25 / 31 * 19 * 0.7) = 22 $this->assertSame(-(778 + 22), $wallet->balance); // 22 - 2 x round(25 * 0.4 / 31 * 19) = 10 $this->assertSame(10, $reseller_wallet->balance); $transactions = Transaction::where('object_id', $wallet->id) ->where('object_type', \App\Wallet::class)->get(); $reseller_transactions = Transaction::where('object_id', $reseller_wallet->id) ->where('object_type', \App\Wallet::class)->get(); $this->assertCount(2, $reseller_transactions); $trans = $reseller_transactions[0]; $this->assertSame("Charged user jane@kolabnow.com", $trans->description); $this->assertSame(5, $trans->amount); $this->assertSame(Transaction::WALLET_CREDIT, $trans->type); $trans = $reseller_transactions[1]; $this->assertSame("Charged user jane@kolabnow.com", $trans->description); $this->assertSame(5, $trans->amount); $this->assertSame(Transaction::WALLET_CREDIT, $trans->type); $this->assertCount(2, $transactions); $trans = $transactions[0]; $this->assertSame('', $trans->description); $this->assertSame(-11, $trans->amount); $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); $trans = $transactions[1]; $this->assertSame('', $trans->description); $this->assertSame(-11, $trans->amount); $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); // TODO: Test entitlement transaction records } /** * Tests for updateEntitlements() */ public function testUpdateEntitlements(): void { $this->markTestIncomplete(); } }