diff --git a/.github/workflows/registry-ci.yml b/.github/workflows/registry-ci.yml new file mode 100644 index 000000000..45e855497 --- /dev/null +++ b/.github/workflows/registry-ci.yml @@ -0,0 +1,255 @@ +name: COmanage Registry setup + PHPUnit (multi-PHP, multi-DB) + +on: + workflow_dispatch: + push: + pull_request: + +jobs: + setup-and-test: + name: setup-and-test (php=${{ matrix.php }}, db=${{ matrix.db.engine }}) + runs-on: + - codebuild-comanage-pipeline-${{ github.run_id }}-${{ github.run_attempt }} + + strategy: + fail-fast: false + matrix: + php: ["8.3"] + db: + - engine: postgres + image: postgres:16-alpine + port: 5432 + health_cmd: 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' + - engine: mysql + image: mysql:8.0 + port: 3306 + health_cmd: 'mysqladmin ping -h 127.0.0.1 -uroot -p"$MYSQL_ROOT_PASSWORD" --silent' + - engine: mariadb + image: mariadb:11 + port: 3306 + health_cmd: 'mariadb-admin ping -h 127.0.0.1 -uroot -p"$MARIADB_ROOT_PASSWORD" --silent' + + # Exactly ONE service container per matrix run (the image changes) + services: + db: + image: ${{ matrix.db.image }} + # Publish the DB port so the job can connect via Docker-host networking. + # NOTE: If your runner executes steps in a container, 127.0.0.1 won't work; + # we compute the Docker host gateway IP in a later step. + ports: + - ${{ matrix.db.port }}:${{ matrix.db.port }} + env: + # Postgres vars (used only by postgres image) + POSTGRES_DB: registry_test + POSTGRES_USER: test_user + POSTGRES_PASSWORD: test_password + + # MySQL vars (used only by mysql image; mariadb image ignores these) + MYSQL_DATABASE: registry_test + MYSQL_USER: test_user + MYSQL_PASSWORD: test_password + MYSQL_ROOT_PASSWORD: root_password + + # MariaDB vars (used only by mariadb image) + MARIADB_DATABASE: registry_test + MARIADB_USER: test_user + MARIADB_PASSWORD: test_password + MARIADB_ROOT_PASSWORD: root_password + options: >- + --health-cmd "${{ matrix.db.health_cmd }}" + --health-interval 10s + --health-timeout 5s + --health-retries 20 + + env: + COMANAGE_REGISTRY_DIR: /srv/comanage-registry + + # Matrix DB selection for this run + DB_ENGINE: ${{ matrix.db.engine }} + + # Values used by your PHPUnit setup test + COMANAGE_REGISTRY_ADMIN_GIVEN_NAME: Admin + COMANAGE_REGISTRY_ADMIN_FAMILY_NAME: User + COMANAGE_REGISTRY_ADMIN_USERNAME: admin + COMANAGE_REGISTRY_SECURITY_SALT: phpunit-security-salt + + # DB credentials/name (host/port will be set dynamically in a step) + COMANAGE_REGISTRY_DATABASE: registry_test + COMANAGE_REGISTRY_DATABASE_USER: test_user + COMANAGE_REGISTRY_DATABASE_USER_PASSWORD: test_password + COMANAGE_REGISTRY_DATABASE_PERSISTENT: "false" + + steps: + - name: Upgrade OS packages + shell: bash + run: | + set -euxo pipefail + sudo apt-get update + sudo apt-get upgrade -y + + - name: Checkout repository at the exact commit + shell: bash + run: | + set -euxo pipefail + git clone "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" "${COMANAGE_REGISTRY_DIR}" + cd "${COMANAGE_REGISTRY_DIR}" + git fetch --no-tags --prune --depth=1 origin "${GITHUB_SHA}" + git checkout --force "${GITHUB_SHA}" + git rev-parse HEAD + + - name: Install PHP ${{ matrix.php }} and extensions + shell: bash + run: | + set -euxo pipefail + sudo apt-get install -y --no-install-recommends \ + software-properties-common ca-certificates gnupg + sudo add-apt-repository -y ppa:ondrej/php + sudo apt-get update + + PHP_VER="${{ matrix.php }}" + sudo apt-get install -y --no-install-recommends \ + php${PHP_VER}-cli \ + php${PHP_VER}-mbstring \ + php${PHP_VER}-intl \ + php${PHP_VER}-ldap \ + php${PHP_VER}-xml \ + php${PHP_VER}-zip \ + php${PHP_VER}-pdo \ + php${PHP_VER}-mysql \ + php${PHP_VER}-pgsql \ + php${PHP_VER}-gd \ + php${PHP_VER}-xsl \ + php${PHP_VER}-memcached \ + php${PHP_VER}-curl + + sudo update-alternatives --set php /usr/bin/php${PHP_VER} + sudo ln -sf /usr/bin/php${PHP_VER} /usr/local/bin/php + + echo "PHP_VER=${PHP_VER}" >> "$GITHUB_ENV" + echo "/usr/local/bin" >> "$GITHUB_PATH" + + - name: Install OS packages needed for setup + shell: bash + run: | + set -euxo pipefail + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + wget curl tar ca-certificates \ + git unzip \ + libicu-dev \ + libldap2-dev \ + libxml2 \ + zlib1g \ + libsodium23 \ + libpng-dev \ + libjpeg-dev \ + libfreetype6-dev \ + libxslt1.1 \ + libmemcached11 \ + tree + + - name: Show versions + shell: bash + run: | + set -euxo pipefail + cat /etc/os-release || true + uname -a + php -v + composer --version + docker --version + docker version + echo "DOCKER_API_VERSION=${DOCKER_API_VERSION-}" + echo "${DOCKER_HOST-}" + docker context show + + - name: Wait for DB to be ready (inside the service container) + shell: bash + run: | + set -euxo pipefail + case "${DB_ENGINE}" in + postgres) + docker exec "${{ job.services.db.id }}" sh -lc 'for i in $(seq 1 60); do pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" && exit 0; sleep 1; done; exit 1' + ;; + mysql) + docker exec "${{ job.services.db.id }}" sh -lc 'for i in $(seq 1 60); do mysqladmin ping -h 127.0.0.1 -uroot -p"$MYSQL_ROOT_PASSWORD" --silent && exit 0; sleep 1; done; exit 1' + ;; + mariadb) + docker exec "${{ job.services.db.id }}" sh -lc 'for i in $(seq 1 60); do mariadb-admin ping -h 127.0.0.1 -uroot -p"$MARIADB_ROOT_PASSWORD" --silent && exit 0; sleep 1; done; exit 1' + ;; + *) + echo "Unknown DB_ENGINE=${DB_ENGINE}" + exit 1 + ;; + esac + + - name: Determine DB host/port for published ports (Option 1) + shell: bash + run: | + set -euxo pipefail + + # If steps run inside a container, localhost is the *job container*. + # Use the default gateway (Docker host) to reach published ports. + if [ -f /.dockerenv ]; then + DB_HOST="$(ip route | awk '/default/ {print $3; exit}')" + else + DB_HOST="127.0.0.1" + fi + + DB_PORT="${{ matrix.db.port }}" + + echo "Using DB host=${DB_HOST} port=${DB_PORT} engine=${DB_ENGINE}" + + { + echo "COMANAGE_REGISTRY_DATABASE_HOST=${DB_HOST}" + echo "COMANAGE_REGISTRY_DATABASE_PORT=${DB_PORT}" + } >> "$GITHUB_ENV" + + - name: Smoke test DB TCP connectivity (from the job environment) + shell: bash + run: | + set -euxo pipefail + php -r ' + $h=getenv("COMANAGE_REGISTRY_DATABASE_HOST"); $p=(int)getenv("COMANAGE_REGISTRY_DATABASE_PORT"); + $fp=@fsockopen($h,$p,$errno,$errstr,5); + if(!$fp){fwrite(STDERR,"TCP connect failed to $h:$p: $errno $errstr\n"); exit(1);} + fclose($fp); + echo "TCP connect OK to $h:$p\n"; + ' + + - name: Create local/config/database.php placeholder (optional) + shell: bash + run: | + set -euxo pipefail + cd "${COMANAGE_REGISTRY_DIR}/local/config" + sudo mkdir -p . + sudo tee database.php > /dev/null <<'PHP' + + + + + + + + + + + + + + + + + + + + + + + + + + + + tests/TestCase/ + + + + + + + + + + + + + src/ + plugins/*/src/ + + + src/Console/Installer.php + + + diff --git a/app/tests/TestCase/ApplicationTest.php b/app/tests/TestCase/ApplicationTest.php index 4ba3acf11..4c2fbde99 100644 --- a/app/tests/TestCase/ApplicationTest.php +++ b/app/tests/TestCase/ApplicationTest.php @@ -1,4 +1,5 @@ bootstrap(); $plugins = $app->getPlugins(); - $this->assertCount(3, $plugins); - $this->assertSame('Bake', $plugins->get('Bake')->getName()); - $this->assertSame('DebugKit', $plugins->get('DebugKit')->getName()); - $this->assertSame('Migrations', $plugins->get('Migrations')->getName()); + $this->assertTrue($plugins->has('Bake'), 'plugins has Bake?'); + $this->assertFalse($plugins->has('DebugKit'), 'plugins has DebugKit?'); + $this->assertTrue($plugins->has('Migrations'), 'plugins has Migrations?'); } - /** - * testBootstrapPluginWitoutHalt - * - * @return void - */ - public function testBootstrapPluginWithoutHalt() + public function testBootstrapInDebug(): void { - $this->expectException(InvalidArgumentException::class); - - $app = $this->getMockBuilder(Application::class) - ->setConstructorArgs([dirname(dirname(__DIR__)) . '/config']) - ->onlyMethods(['addPlugin']) - ->getMock(); - - $app->method('addPlugin') - ->will($this->throwException(new InvalidArgumentException('test exception.'))); + Configure::write('debug', true); + $app = new Application(dirname(__DIR__, 2) . '/config'); $app->bootstrap(); + $plugins = $app->getPlugins(); + + $this->assertTrue($plugins->has('DebugKit'), 'plugins has DebugKit?'); } - /** - * testMiddleware - * - * @return void - */ - public function testMiddleware() + public function testMiddleware(): void { - $app = new Application(dirname(dirname(__DIR__)) . '/config'); - $middleware = new MiddlewareQueue(); + $app = new Application(dirname(__DIR__, 2) . '/config'); + $middleware = new MiddlewareQueue(); $middleware = $app->middleware($middleware); $this->assertInstanceOf(ErrorHandlerMiddleware::class, $middleware->current()); + $middleware->seek(1); $this->assertInstanceOf(AssetMiddleware::class, $middleware->current()); + $middleware->seek(2); $this->assertInstanceOf(RoutingMiddleware::class, $middleware->current()); } diff --git a/app/tests/TestCase/Command/RegistrySetupTest.php b/app/tests/TestCase/Command/RegistrySetupTest.php new file mode 100644 index 000000000..06ca74c40 --- /dev/null +++ b/app/tests/TestCase/Command/RegistrySetupTest.php @@ -0,0 +1,66 @@ +exec(sprintf( + 'setup --admin-given-name %s --admin-family-name %s --admin-username %s', + $adminGiven, + $adminFamily, + $adminUser, + )); + $this->assertExitCode(0, 'bin/cake setup should exit successfully'); + + $connection = ConnectionManager::get('default'); + + // Verify we can talk to the DB and it has tables. + $tables = $connection->getSchemaCollection()->listTables(); + $this->assertNotEmpty($tables, 'Expected at least one table in the test database'); + + // Assert schema.json tables exist in the DB. + $schemaFile = $appDir . '/config/schema/schema.json'; + $this->assertFileExists($schemaFile, 'Expected schema.json to exist at config/schema/schema.json'); + + $json = file_get_contents($schemaFile); + $this->assertNotFalse($json, 'Failed to read schema file: ' . $schemaFile); + + $data = json_decode($json, true); + $this->assertIsArray($data, 'schema.json did not decode into an array'); + $this->assertArrayHasKey('tables', $data, 'schema.json is missing the "tables" key'); + $this->assertIsArray($data['tables'], 'schema.json "tables" should be an object/map'); + + $expectedTables = array_keys($data['tables']); + $missing = array_values(array_diff($expectedTables, $tables)); + + $this->assertSame( + [], + $missing, + "Some tables from config/schema/schema.json are missing in the database.\n" + . 'Missing: ' . implode(', ', $missing) . "\n" + . 'DB_ENGINE=' . (getenv('DB_ENGINE') ?: '(not set)') + ); + } +} diff --git a/app/tests/TestCase/Controller/MostlyStaticPagesControllerTest.php b/app/tests/TestCase/Controller/MostlyStaticPagesControllerTest.php new file mode 100644 index 000000000..97169a123 --- /dev/null +++ b/app/tests/TestCase/Controller/MostlyStaticPagesControllerTest.php @@ -0,0 +1,98 @@ +get('Cos'); + $co = $Cos->find('COmanageCO')->firstOrFail(); + + return (int)$co->id; + } + + private function assertDefaultPageRendersOverHttp(string $slug): void + { + $coId = $this->getComanageCoId(); + + $MostlyStaticPages = TableRegistry::getTableLocator()->get('MostlyStaticPages'); + $page = $MostlyStaticPages->find() + ->where(['co_id' => $coId, 'name' => $slug]) + ->firstOrFail(); + + // Correct route is /{coid}/{name} + $this->get("/{$coId}/{$slug}"); + + $this->assertResponseOk(); + $this->assertResponseContains((string)$page->title); + } + + #[DataProvider('defaultPageSlugsProvider')] + public function testDefaultPagesRenderOverHttp(string $slug): void + { + $this->assertDefaultPageRendersOverHttp($slug); + } + + private function assertDefaultPageExists(string $name, string $expectedContext): void + { + $coId = $this->getComanageCoId(); + + $MostlyStaticPages = TableRegistry::getTableLocator()->get('MostlyStaticPages'); + + $page = $MostlyStaticPages->find() + ->where([ + 'co_id' => $coId, + 'name' => $name, + 'status' => SuspendableStatusEnum::Active, + 'context' => $expectedContext, + ]) + ->first(); + + $this->assertNotEmpty( + $page, + sprintf('Expected Mostly Static Page "%s" to exist for CO %d after setup', $name, $coId) + ); + } + + #[DataProvider('defaultPagesProvider')] + public function testDefaultPagesExist(string $name, string $expectedContext): void + { + $this->assertDefaultPageExists($name, $expectedContext); + } +} diff --git a/app/tests/TestCase/Controller/PagesControllerTest.php b/app/tests/TestCase/Controller/PagesControllerTest.php index bf018e115..a6350b006 100644 --- a/app/tests/TestCase/Controller/PagesControllerTest.php +++ b/app/tests/TestCase/Controller/PagesControllerTest.php @@ -1,4 +1,5 @@ get('/'); - $this->assertResponseOk(); - $this->get('/'); - $this->assertResponseOk(); - } - - /** - * testDisplay method - * - * @return void - */ - public function testDisplay() - { - $this->get('/pages/home'); - $this->assertResponseOk(); - $this->assertResponseContains('CakePHP'); - $this->assertResponseContains(''); - } - - /** - * Test that missing template renders 404 page in production - * - * @return void - */ - public function testMissingTemplate() - { - Configure::write('debug', false); - $this->get('/pages/not_existing'); - - $this->assertResponseError(); - $this->assertResponseContains('Error'); - } - - /** - * Test that missing template in debug mode renders missing_template error page - * - * @return void - */ - public function testMissingTemplateInDebug() - { - Configure::write('debug', true); - $this->get('/pages/not_existing'); - - $this->assertResponseFailure(); - $this->assertResponseContains('Missing Template'); - $this->assertResponseContains('Stacktrace'); - $this->assertResponseContains('not_existing.php'); - } - - /** - * Test directory traversal protection - * - * @return void - */ - public function testDirectoryTraversalProtection() - { - $this->get('/pages/../Layout/ajax'); - $this->assertResponseCode(403); - $this->assertResponseContains('Forbidden'); - } - - /** - * Test that CSRF protection is applied to page rendering. - * - * @return void - */ - public function testCsrfAppliedError() - { - $this->post('/pages/home', ['hello' => 'world']); - - $this->assertResponseCode(403); - $this->assertResponseContains('CSRF'); - } - - /** - * Test that CSRF protection is applied to page rendering. - * - * @return void - */ - public function testCsrfAppliedOk() - { - $this->enableCsrfToken(); - $this->post('/pages/home', ['hello' => 'world']); - - $this->assertResponseCode(200); - $this->assertResponseContains('CakePHP'); - } + use IntegrationTestTrait; + + /** + * testDisplay method + * + * @return void + */ + public function testDisplay() + { + Configure::write('debug', true); + $this->get('/pages/home'); + + $this->assertResponseOk(); + + $body = (string)$this->_response->getBody(); + + // Debug: dump actual response content to STDOUT (first 2000 chars) + fwrite(STDOUT, "\n--- /pages/home response body (first 2000 chars) ---\n"); + fwrite(STDOUT, substr($body, 0, 2000) . "\n"); + fwrite(STDOUT, "--- end ---\n\n"); + + $this->assertNotEmpty($body, 'Expected /pages/home to render a non-empty response body'); + } + + + /** + * Test that missing template renders 404 page in production + * + * @return void + */ + public function testMissingTemplate() + { + Configure::write('debug', false); + $this->get('/pages/not_existing'); + + $this->assertResponseError(); + $this->assertResponseContains('Error'); + } + + /** + * Test that missing template in debug mode renders missing_template error page + * + * @return void + */ + public function testMissingTemplateInDebug() + { + Configure::write('debug', true); + $this->get('/pages/not_existing'); + + $this->assertResponseFailure(); + $this->assertResponseContains('Missing Template'); + $this->assertResponseContains('stack-frames'); + $this->assertResponseContains('not_existing.php'); + } + + /** + * Test directory traversal protection + * + * @return void + */ + public function testDirectoryTraversalProtection() + { + $this->enableCsrfToken(); + + $this->get('/pages/../Layout/ajax'); + $this->assertResponseCode(403); + $this->assertResponseContains('Forbidden'); + } + + /** + * Test that CSRF protection is applied to page rendering. + * + * @return void + */ + public function testCsrfAppliedError() + { + $this->post('/pages/home', ['hello' => 'world']); + + $this->assertResponseCode(403); + $this->assertResponseContains('CSRF'); + } + + /** + * Test that CSRF protection is applied to page rendering. + * + * @return void + */ + public function testCsrfAppliedOk() + { + $this->enableCsrfToken(); + $this->enableSecurityToken(); + + $this->post('/pages/home', ['hello' => 'world']); + + $this->assertThat(403, $this->logicalNot(new StatusCode($this->_response))); + $this->assertResponseNotContains('CSRF'); + } } diff --git a/app/tests/bootstrap.php b/app/tests/bootstrap.php index 962815cd4..509884a16 100644 --- a/app/tests/bootstrap.php +++ b/app/tests/bootstrap.php @@ -1,52 +1,139 @@ 'Cake\Database\Connection', - 'driver' => 'Cake\Database\Driver\Sqlite', - 'database' => TMP . 'debug_kit.sqlite', - 'encoding' => 'utf8', - 'cacheMetadata' => true, +ConnectionManager::drop('test'); +ConnectionManager::drop('default'); + +if ($dbEngine === 'postgres') { + $config = [ + 'className' => Connection::class, + 'driver' => Postgres::class, + 'persistent' => false, + 'host' => $host, + 'port' => $port, + 'username' => $testDbUser, + 'password' => $testDbPass, + 'database' => $testDbName, 'quoteIdentifiers' => false, -]); + ]; +} else { + // mysql or mariadb (CakePHP uses the MySQL driver for MariaDB) + $config = [ + 'className' => Connection::class, + 'driver' => Mysql::class, + 'persistent' => false, + 'host' => $host, + 'port' => $port, + 'username' => $testDbUser, + 'password' => $testDbPass, + 'database' => $testDbName, + 'encoding' => 'utf8mb4', + 'timezone' => 'UTC', + 'quoteIdentifiers' => true, + ]; +} -ConnectionManager::alias('test_debug_kit', 'debug_kit'); +// Make both datasources point at the same test database. +// Most application code uses the `default` datasource. +ConnectionManager::setConfig('test', $config); +ConnectionManager::setConfig('default', $config); -// Fixate sessionid early on, as php7.2+ -// does not allow the sessionid to be set after stdout -// has been written to. -session_id('cli'); +// Connection aliasing needs to happen before schema is built. +// Otherwise, objects created during CLI commands might use the wrong datasource. +ConnectionHelper::addTestAliases(); + +/** + * Run a Cake CLI command in-process (no shelling out). + * + * @param list $argv + * @return int exit code + */ +$runCake = static function (array $argv): int { + $app = new Application(dirname(__DIR__) . '/config'); + $runner = new CommandRunner($app); + + return $runner->run($argv); +}; + +// 1) Clear caches (matches: ./bin/cake cache clear_all) +$exit = $runCake(['bin/cake', 'cache', 'clear_all']); +if ($exit !== 0) { + throw new \RuntimeException('Failed running: bin/cake cache clear_all (exit=' . $exit . ')'); +} + +// 2) Apply database schema (matches: ./bin/cake database) +$exit = $runCake(['bin/cake', 'database']); +if ($exit !== 0) { + throw new \RuntimeException('Failed running: bin/cake database (exit=' . $exit . ')'); +} \ No newline at end of file