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