diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 34791724d..17d4b1418 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -113,17 +113,211 @@ jobs: ./e2e/playwright-report retention-days: 7 + test-migration-wizard: + runs-on: + - codebuild-defguard-core-runner-${{ github.run_id }}-${{ github.run_attempt }} + instance-size:medium + + steps: + - uses: actions/checkout@v6 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to GitHub container registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Export image tag + run: | + BRANCH=${GITHUB_REF#refs/heads/} + if [[ "$BRANCH" == release/* ]]; then + IMAGE_TAG=${BRANCH//\//-} + else + IMAGE_TAG=$BRANCH + fi + echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV + + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version-file: "./e2e/.nvmrc" + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + # FIXME: temporarily pinned because of https://github.com/pnpm/pnpm/pull/9959 + version: 10.17 + run_install: false + + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - uses: actions/cache@v5 + name: Setup pnpm cache + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Pull images + run: docker compose --file './docker-compose.e2e.yaml' pull + + - name: Install E2E dependencies + working-directory: ./e2e + run: pnpm install --frozen-lockfile + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('e2e/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-playwright- + + - name: Install playwright chromium + working-directory: ./e2e + run: | + if [[ "${{ steps.playwright-cache.outputs.cache-hit }}" == "true" ]]; then + npx playwright install-deps chromium + else + npx playwright install --with-deps chromium + fi + + - name: Run migration wizard tests + working-directory: ./e2e + env: + DEFGUARD_LICENSE_KEY: ${{ secrets.DEFGUARD_LICENSE_KEY }} + run: pnpm playwright test --config playwright.config.migration.ts + + - name: Stop compose + if: always() + run: docker compose --file './docker-compose.e2e.yaml' down + + - uses: actions/upload-artifact@v7 + if: failure() + with: + name: playwright-report-migration-wizard + path: | + ./e2e/playwright-report + retention-days: 7 + + test-auto-adoption-wizard: + runs-on: + - codebuild-defguard-core-runner-${{ github.run_id }}-${{ github.run_attempt }} + instance-size:medium + + steps: + - uses: actions/checkout@v6 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to GitHub container registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Export image tag + run: | + BRANCH=${GITHUB_REF#refs/heads/} + if [[ "$BRANCH" == release/* ]]; then + IMAGE_TAG=${BRANCH//\//-} + else + IMAGE_TAG=$BRANCH + fi + echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV + + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version-file: "./e2e/.nvmrc" + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + # FIXME: temporarily pinned because of https://github.com/pnpm/pnpm/pull/9959 + version: 10.17 + run_install: false + + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - uses: actions/cache@v5 + name: Setup pnpm cache + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Pull images + run: docker compose --file './docker-compose.e2e-auto-adoption.yaml' pull + + - name: Install E2E dependencies + working-directory: ./e2e + run: pnpm install --frozen-lockfile + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('e2e/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-playwright- + + - name: Install playwright chromium + working-directory: ./e2e + run: | + if [[ "${{ steps.playwright-cache.outputs.cache-hit }}" == "true" ]]; then + npx playwright install-deps chromium + else + npx playwright install --with-deps chromium + fi + + - name: Run auto-adoption wizard tests + working-directory: ./e2e + env: + DEFGUARD_LICENSE_KEY: ${{ secrets.DEFGUARD_LICENSE_KEY }} + run: pnpm playwright test --config playwright.config.auto-adoption.ts + + - name: Stop compose + if: always() + run: docker compose --file './docker-compose.e2e-auto-adoption.yaml' down + + - uses: actions/upload-artifact@v7 + if: failure() + with: + name: playwright-report-auto-adoption-wizard + path: | + ./e2e/playwright-report + retention-days: 7 + trigger-dev-deploy: - needs: test - if: ${{ github.event_name != 'pull_request' && github.ref_name == 'dev' && needs.test.result == 'success' }} + needs: [test, test-migration-wizard, test-auto-adoption-wizard] + if: ${{ github.event_name != 'pull_request' && github.ref_name == 'dev' && needs.test.result == 'success' && needs.test-migration-wizard.result == 'success' && needs.test-auto-adoption-wizard.result == 'success' }} uses: ./.github/workflows/dev-deployment.yml secrets: inherit trigger-staging-deploy: - needs: test + needs: [test, test-migration-wizard, test-auto-adoption-wizard] if: | github.event_name != 'pull_request' && startsWith(github.ref_name, 'release/') && - needs.test.result == 'success' + needs.test.result == 'success' && + needs.test-migration-wizard.result == 'success' && + needs.test-auto-adoption-wizard.result == 'success' uses: ./.github/workflows/staging-deployment.yml secrets: inherit diff --git a/Cargo.lock b/Cargo.lock index 430660d5c..ed330b686 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5413,9 +5413,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", diff --git a/docker-compose.e2e-auto-adoption.yaml b/docker-compose.e2e-auto-adoption.yaml new file mode 100644 index 000000000..0f6e6ce59 --- /dev/null +++ b/docker-compose.e2e-auto-adoption.yaml @@ -0,0 +1,55 @@ +services: + core: + image: ghcr.io/defguard/defguard:${IMAGE_TAG} + environment: + DEFGUARD_COOKIE_INSECURE: true + DEFGUARD_COOKIE_DOMAIN: localhost + DEFGUARD_LOG_LEVEL: debug + DEFGUARD_DB_HOST: db + DEFGUARD_DB_PORT: 5432 + DEFGUARD_DB_USER: defguard + DEFGUARD_DB_PASSWORD: defguard + DEFGUARD_DB_NAME: defguard + DEFGUARD_GRPC_PORT: 50055 + DEFGUARD_ADOPT_EDGE: "edge:50051" + DEFGUARD_ADOPT_GATEWAY: "gateway:50066" + RUST_BACKTRACE: 1 + ports: + - "8000:8000" + - "50055:50055" + depends_on: + - db + + db: + image: public.ecr.aws/docker/library/postgres:17-alpine + environment: + POSTGRES_DB: defguard + POSTGRES_USER: defguard + POSTGRES_PASSWORD: defguard + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U defguard -d defguard"] + interval: 5s + timeout: 3s + retries: 5 + start_period: 5s + + edge: + image: ghcr.io/defguard/defguard-proxy:${IMAGE_TAG} + ports: + - "8080:8080" + - "50051:50051" + environment: + DEFGUARD_PROXY_GRPC_PORT: 50051 + RUST_BACKTRACE: 1 + + gateway: + image: ghcr.io/defguard/gateway:${IMAGE_TAG} + ports: + - "50066:50066" + environment: + DEFGUARD_GATEWAY_GRPC_PORT: 50066 + RUST_BACKTRACE: 1 + cap_add: + - NET_ADMIN diff --git a/e2e/playwright.config.auto-adoption.ts b/e2e/playwright.config.auto-adoption.ts new file mode 100644 index 000000000..21195ad69 --- /dev/null +++ b/e2e/playwright.config.auto-adoption.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test'; + +import { testsConfig } from './config'; +import { loadEnv } from './utils/loadEnv'; + +loadEnv(); + +export default defineConfig({ + globalSetup: './utils/globalSetupAutoAdoption', + timeout: testsConfig.TEST_TIMEOUT * 1000, + testDir: './tests', + testMatch: '**/autoAdoptionWizard.spec.ts', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: [['html', { open: 'never' }]], + use: { + baseURL: testsConfig.BASE_URL, + trace: 'retain-on-failure', + viewport: { height: 993, width: 1920 }, + video: { mode: 'retain-on-failure' }, + screenshot: 'only-on-failure', + contextOptions: { permissions: ['clipboard-read', 'clipboard-write'] }, + }, + projects: [ + { + name: 'auto-adoption-wizard', + use: { + ...devices['Desktop Chrome'], + viewport: { height: 993, width: 1920 }, + }, + }, + ], +}); diff --git a/e2e/playwright.config.migration.ts b/e2e/playwright.config.migration.ts new file mode 100644 index 000000000..cd82c7cea --- /dev/null +++ b/e2e/playwright.config.migration.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test'; + +import { testsConfig } from './config'; +import { loadEnv } from './utils/loadEnv'; + +loadEnv(); + +export default defineConfig({ + globalSetup: './utils/globalSetupMigration', + timeout: testsConfig.TEST_TIMEOUT * 1000, + testDir: './tests', + testMatch: '**/migrationWizard.spec.ts', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: [['html', { open: 'never' }]], + use: { + baseURL: testsConfig.BASE_URL, + trace: 'retain-on-failure', + viewport: { height: 993, width: 1920 }, + video: { mode: 'retain-on-failure' }, + screenshot: 'only-on-failure', + contextOptions: { permissions: ['clipboard-read', 'clipboard-write'] }, + }, + projects: [ + { + name: 'migration-wizard', + use: { + ...devices['Desktop Chrome'], + viewport: { height: 993, width: 1920 }, + }, + }, + ], +}); diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 1347a210c..787ca1303 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -35,6 +35,9 @@ export default defineConfig({ '**/externalopenid.spec.ts', '**/externalopenidmfa.spec.ts', '**/openid.spec.ts', + // These wizards use dedicated config files with their own globalSetup. + '**/autoAdoptionWizard.spec.ts', + '**/migrationWizard.spec.ts', ], /* Run tests in files in parallel */ fullyParallel: false, diff --git a/e2e/tests/autoAdoptionWizard.spec.ts b/e2e/tests/autoAdoptionWizard.spec.ts new file mode 100644 index 000000000..61af88aea --- /dev/null +++ b/e2e/tests/autoAdoptionWizard.spec.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/test'; + +import { runAutoAdoptionWizard } from '../utils/controllers/wizards/runAutoAdoptionWizard'; +import { dockerRestartAutoAdoption } from '../utils/docker'; + +test.describe('Auto Adoption Wizard', () => { + test.beforeEach(() => { + // Restore DB to the pre-wizard snapshot before each test. + dockerRestartAutoAdoption(); + }); + + test('completes the happy path and lands on vpn-overview', async ({ page }) => { + await runAutoAdoptionWizard(page); + await expect(page).toHaveURL(/\/vpn-overview/); + }); +}); diff --git a/e2e/tests/migrationWizard.spec.ts b/e2e/tests/migrationWizard.spec.ts new file mode 100644 index 000000000..e43baa2bf --- /dev/null +++ b/e2e/tests/migrationWizard.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from '@playwright/test'; + +import { runMigrationWizard } from '../utils/controllers/wizards/runMigrationWizard'; +import { dockerRestart } from '../utils/docker'; + +test.describe('Migration Wizard', () => { + test.beforeEach(() => { + // Restore DB to the pre-wizard migration snapshot before each test. + dockerRestart(); + }); + + test('completes the happy path (no locations) and lands on vpn-overview', async ({ + page, + }) => { + await runMigrationWizard(page); + await expect(page).toHaveURL(/\/vpn-overview/); + }); +}); diff --git a/e2e/utils/controllers/wizards/runAutoAdoptionWizard.ts b/e2e/utils/controllers/wizards/runAutoAdoptionWizard.ts new file mode 100644 index 000000000..a72ebf317 --- /dev/null +++ b/e2e/utils/controllers/wizards/runAutoAdoptionWizard.ts @@ -0,0 +1,81 @@ +import type { Page } from '@playwright/test'; + +import { defaultUserAdmin, testsConfig } from '../../../config'; + +// Run the full auto adoption wizard happy path. +// Assumes core is in auto-adoption mode (DEFGUARD_ADOPT_EDGE + DEFGUARD_ADOPT_GATEWAY +// are set) and the adoption API has already reported success for all components. +export const runAutoAdoptionWizard = async (page: Page) => { + page.setDefaultTimeout(testsConfig.TEST_TIMEOUT * 1000); + + await page.goto(testsConfig.BASE_URL); + + await page + .getByRole('button', { name: 'Start Defguard configuration' }) + .waitFor({ state: 'visible' }); + await page.getByRole('button', { name: 'Start Defguard configuration' }).click(); + + // Admin user step. + await page.getByTestId('field-first_name').waitFor({ state: 'visible' }); + await page.getByTestId('field-first_name').fill(defaultUserAdmin.firstName); + await page.getByTestId('field-last_name').fill(defaultUserAdmin.lastName); + await page.getByTestId('field-username').fill(defaultUserAdmin.username); + await page.getByTestId('field-email').fill(defaultUserAdmin.mail); + await page.getByTestId('field-password').fill(defaultUserAdmin.password); + const adminResp = page.waitForResponse( + (r) => r.url().includes('/initial_setup/admin') && r.request().method() === 'POST', + ); + await page.getByRole('button', { name: 'Continue' }).click(); + await adminResp; + + // Internal URL settings. + await page.getByTestId('field-defguard_url').waitFor({ state: 'visible' }); + await page + .getByTestId('field-defguard_url') + .fill(testsConfig.CORE_BASE_URL.replace('/api/v1', '')); + await page.getByRole('button', { name: 'Continue' }).click(); + + // Internal URL SSL config + await page.getByRole('button', { name: 'Continue' }).waitFor({ state: 'visible' }); + await page.getByRole('button', { name: 'Continue' }).click(); + + // External URL settings. + await page.getByTestId('field-public_proxy_url').waitFor({ state: 'visible' }); + await page.getByTestId('field-public_proxy_url').fill(testsConfig.ENROLLMENT_URL); + await page.getByRole('button', { name: 'Continue' }).click(); + + // External URL SSL config + await page.getByRole('button', { name: 'Continue' }).waitFor({ state: 'visible' }); + await page.getByRole('button', { name: 'Continue' }).click(); + + // VPN settings. + await page.getByTestId('field-vpn_public_ip').waitFor({ state: 'visible' }); + await page.getByTestId('field-vpn_public_ip').fill('10.0.0.1'); + await page.getByTestId('field-vpn_wireguard_port').fill('51820'); + await page.getByTestId('field-vpn_gateway_address').fill('10.10.10.1/24'); + const vpnResp = page.waitForResponse( + (r) => + r.url().includes('/initial_setup/auto_wizard/vpn_settings') && + r.request().method() === 'POST', + ); + await page.getByRole('button', { name: 'Continue' }).click(); + await vpnResp; + + // MFA setup + await page.getByText('Do not enforce MFA').waitFor({ state: 'visible' }); + const mfaResp = page.waitForResponse( + (r) => + r.url().includes('/initial_setup/auto_wizard/mfa_settings') && + r.request().method() === 'POST', + ); + await page.getByRole('button', { name: 'Continue' }).click(); + await mfaResp; + + // Summary + await page + .getByRole('button', { name: 'Go to Defguard' }) + .waitFor({ state: 'visible' }); + await page.getByRole('button', { name: 'Go to Defguard' }).click(); + + await page.waitForURL('**/vpn-overview', { timeout: testsConfig.TEST_TIMEOUT * 1000 }); +}; diff --git a/e2e/utils/controllers/wizards/runMigrationWizard.ts b/e2e/utils/controllers/wizards/runMigrationWizard.ts new file mode 100644 index 000000000..b78f64610 --- /dev/null +++ b/e2e/utils/controllers/wizards/runMigrationWizard.ts @@ -0,0 +1,112 @@ +import type { Page } from '@playwright/test'; + +import { defaultUserAdmin, testsConfig } from '../../../config'; + +// Run the migration wizard happy path (no locations to migrate). +// Assumes core is in migration mode (admin user exists, no wizard row in DB) +// and the user is not yet authenticated. +export const runMigrationWizard = async (page: Page) => { + page.setDefaultTimeout(testsConfig.TEST_TIMEOUT * 1000); + + // The migration wizard route requires authentication. + await page.goto(testsConfig.BASE_URL); + await page.getByTestId('field-username').waitFor({ state: 'visible' }); + await page.getByTestId('field-username').fill(defaultUserAdmin.username); + await page.getByTestId('field-password').fill(defaultUserAdmin.password); + await page.getByTestId('sign-in').click(); + + // Welcome screen + await page + .getByRole('button', { name: 'Start migration process' }) + .waitFor({ state: 'visible' }); + await page.getByRole('button', { name: 'Start migration process' }).click(); + + // General configuration + await page.getByTestId('field-default_admin_group_name').waitFor({ state: 'visible' }); + const generalResp = page.waitForResponse( + (r) => r.url().includes('/api/v1/settings') && r.request().method() === 'PATCH', + ); + await page.getByRole('button', { name: 'Continue' }).click(); + await generalResp; + + // CA configuration. + await page.getByTestId('field-ca_common_name').waitFor({ state: 'visible' }); + await page.getByTestId('field-ca_common_name').fill('Migration Test CA'); + await page.getByTestId('field-ca_email').fill('ca@migration.test'); + const caResp = page.waitForResponse( + (r) => r.url().includes('/migration/ca') && r.request().method() === 'POST', + ); + await page.getByRole('button', { name: 'Continue' }).click(); + await caResp; + + // CA summary + await page + .getByRole('button', { name: 'Download CA certificate' }) + .waitFor({ state: 'visible' }); + await page.getByRole('button', { name: 'Continue' }).click(); + + // Edge deployment + const edgeDeployCheckbox = page.getByRole('button', { + name: 'Confirm that you have deployed Edge', + }); + await edgeDeployCheckbox.waitFor({ state: 'visible' }); + await edgeDeployCheckbox.click(); + await page.getByRole('button', { name: 'Continue' }).click(); + + // Edge component + await page.getByTestId('field-common_name').waitFor({ state: 'visible' }); + await page.getByTestId('field-common_name').fill('edge-test'); + await page.getByTestId('field-ip_or_domain').fill('edge'); + await page.getByRole('button', { name: 'Adopt Edge component' }).click(); + + // Edge adoption + await page.getByRole('button', { name: 'Continue' }).waitFor({ state: 'visible' }); + await page.waitForFunction( + () => + [...document.querySelectorAll('button')].some( + (b) => b.textContent?.trim() === 'Continue' && !b.disabled, + ), + { timeout: 120000 }, + ); + await page.getByRole('button', { name: 'Continue' }).click(); + + // Internal URL settings + await page.getByTestId('field-defguard_url').waitFor({ state: 'visible' }); + await page + .getByTestId('field-defguard_url') + .fill(testsConfig.CORE_BASE_URL.replace('/api/v1', '')); + const internalUrlResp = page.waitForResponse( + (r) => + r.url().includes('/migration/internal_url_settings') && + r.request().method() === 'POST', + ); + await page.getByRole('button', { name: 'Continue' }).click(); + await internalUrlResp; + + // Internal URL SSL config + await page.getByRole('button', { name: 'Continue' }).waitFor({ state: 'visible' }); + await page.getByRole('button', { name: 'Continue' }).click(); + + // External URL settings + await page.getByTestId('field-public_proxy_url').waitFor({ state: 'visible' }); + await page.getByTestId('field-public_proxy_url').fill(testsConfig.ENROLLMENT_URL); + const externalUrlResp = page.waitForResponse( + (r) => + r.url().includes('/migration/external_url_settings') && + r.request().method() === 'POST', + ); + await page.getByRole('button', { name: 'Continue' }).click(); + await externalUrlResp; + + // External URL SSL config + await page.getByRole('button', { name: 'Continue' }).waitFor({ state: 'visible' }); + await page.getByRole('button', { name: 'Continue' }).click(); + + // Confirmation + await page + .getByRole('button', { name: 'Go to Defguard' }) + .waitFor({ state: 'visible' }); + await page.getByRole('button', { name: 'Go to Defguard' }).click(); + + await page.waitForURL('**/vpn-overview', { timeout: testsConfig.TEST_TIMEOUT * 1000 }); +}; diff --git a/e2e/utils/docker.ts b/e2e/utils/docker.ts index 42e169e44..2c5c79e00 100644 --- a/e2e/utils/docker.ts +++ b/e2e/utils/docker.ts @@ -6,6 +6,12 @@ const defguardPath = __dirname.split('e2e')[0]; const dockerFilePath = path.resolve(defguardPath, 'docker-compose.e2e.yaml'); const dockerCompose = `docker compose -f ${dockerFilePath}`; +const dockerFilePathAutoAdoption = path.resolve( + defguardPath, + 'docker-compose.e2e-auto-adoption.yaml', +); +const dockerComposeAutoAdoption = `docker compose -f ${dockerFilePathAutoAdoption}`; + // Run a SQL statement in the postgres maintenance database. const psql = (sql: string) => execSync(`${dockerCompose} exec db psql -U defguard -d postgres -c "${sql}"`); @@ -40,24 +46,96 @@ export const dockerCheckContainers = (): boolean => { return Boolean(containers.length); }; +export const dockerCheckTemplateExists = (): boolean => { + try { + const out = execSync( + `${dockerCompose} exec db psql -U defguard -d postgres -tAc ` + + `"SELECT 1 FROM pg_database WHERE datname = 'defguard_template'"`, + ) + .toString() + .trim(); + return out === '1'; + } catch { + return false; + } +}; + export const dockerRestart = () => { if (!dockerCheckContainers()) { dockerUp(); } else { - // SIGKILL core immediately — no grace period needed in tests. execSync(`${dockerCompose} kill core`); - // Terminate any connections PostgreSQL still sees (kernel closes sockets on - // SIGKILL but PostgreSQL may not have processed the hangup yet). psql( "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'defguard'", ); - // Drop and instantly recreate defguard from the template (filesystem-level copy). psql('DROP DATABASE defguard'); psql('CREATE DATABASE defguard TEMPLATE defguard_template OWNER defguard'); - // Start core and wait for it to be healthy. execSync(`${dockerCompose} start core`); execSync( `until curl -sf http://localhost:8000/api/v1/health > /dev/null; do sleep 1; done`, ); } }; + +const psqlAutoAdoption = (sql: string) => + execSync( + `${dockerComposeAutoAdoption} exec db psql -U defguard -d postgres -c "${sql}"`, + ); + +export const dockerUpAutoAdoption = () => { + execSync(`${dockerComposeAutoAdoption} up --wait`); + execSync( + `${dockerComposeAutoAdoption} exec db sh -c 'until pg_isready; do sleep 1; done; sleep 3'`, + ); +}; + +export const dockerCheckContainersAutoAdoption = (): boolean => { + const containers = execSync(`${dockerComposeAutoAdoption} ps -q`).toString().trim(); + return Boolean(containers.length); +}; + +export const dockerCreateSnapshotAutoAdoption = () => { + execSync(`${dockerComposeAutoAdoption} kill core`); + psqlAutoAdoption( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'defguard'", + ); + psqlAutoAdoption('DROP DATABASE IF EXISTS defguard_template'); + psqlAutoAdoption('CREATE DATABASE defguard_template TEMPLATE defguard OWNER defguard'); + execSync(`${dockerComposeAutoAdoption} start core`); + execSync( + `until curl -sf http://localhost:8000/api/v1/health > /dev/null; do sleep 1; done`, + ); +}; + +export const dockerRestartAutoAdoption = () => { + if (!dockerCheckContainersAutoAdoption()) { + dockerUpAutoAdoption(); + } else { + execSync(`${dockerComposeAutoAdoption} kill core`); + psqlAutoAdoption( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'defguard'", + ); + psqlAutoAdoption('DROP DATABASE defguard'); + psqlAutoAdoption( + 'CREATE DATABASE defguard TEMPLATE defguard_template OWNER defguard', + ); + execSync(`${dockerComposeAutoAdoption} start core`); + execSync( + `until curl -sf http://localhost:8000/api/v1/health > /dev/null; do sleep 1; done`, + ); + } +}; + +// UPDATE rather than DELETE: Wizard::init() calls fetch_one on the singleton row, +// which panics on an empty table. Resetting to active_wizard='none' + completed=false +// lets the wizard detect the existing admin user and activate migration mode normally. +export const dockerCreateMigrationState = () => { + execSync(`${dockerCompose} kill core`); + psql( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'defguard'", + ); + execSync( + `${dockerCompose} exec db psql -U defguard -d defguard -c ` + + `"UPDATE wizard SET active_wizard = 'none', completed = false WHERE is_singleton"`, + ); +}; diff --git a/e2e/utils/globalSetupAutoAdoption.ts b/e2e/utils/globalSetupAutoAdoption.ts new file mode 100644 index 000000000..c68a1733f --- /dev/null +++ b/e2e/utils/globalSetupAutoAdoption.ts @@ -0,0 +1,73 @@ +import { request } from '@playwright/test'; +import http from 'http'; + +import { testsConfig } from '../config'; +import { + dockerCheckContainersAutoAdoption, + dockerCreateSnapshotAutoAdoption, + dockerUpAutoAdoption, +} from './docker'; +import { loadEnv } from './loadEnv'; + +const waitForCore = async () => { + const url = testsConfig.BASE_URL + '/api/v1/health'; + await new Promise((resolve) => { + const check = () => { + const req = http.get(url, (res) => { + if (res.statusCode === 200) { + resolve(); + } else { + setTimeout(check, 2000); + } + }); + req.on('error', () => setTimeout(check, 2000)); + req.end(); + }; + check(); + }); +}; + +// Poll until all components in the auto adoption result report success. +const waitForAutoAdoption = async () => { + const ctx = await request.newContext({ baseURL: testsConfig.BASE_URL }); + await new Promise((resolve) => { + const check = async () => { + try { + const res = await ctx.get('/api/v1/initial_setup/auto_adoption'); + if (res.ok()) { + const data = await res.json(); + const results: Record = + data.adoption_result ?? {}; + const keys = Object.keys(results); + if (keys.length > 0 && keys.every((k) => results[k].success === true)) { + await ctx.dispose(); + resolve(); + return; + } + } + } catch { + // Ignore errors and retry. + } + setTimeout(check, 2000); + }; + check(); + }); +}; + +export default async function globalSetupAutoAdoption() { + loadEnv(); + + if (!dockerCheckContainersAutoAdoption()) { + dockerUpAutoAdoption(); + } + + console.log('Waiting for Core (auto adoption) to be ready...'); + await waitForCore(); + console.log('Waiting for auto adoption components to report success...'); + await waitForAutoAdoption(); + // Snapshot taken BEFORE running the wizard so each test can run the wizard + // from scratch by calling dockerRestartAutoAdoption(). + console.log('Components ready. Creating pre-wizard snapshot...'); + dockerCreateSnapshotAutoAdoption(); + console.log('Snapshot created.'); +} diff --git a/e2e/utils/globalSetupMigration.ts b/e2e/utils/globalSetupMigration.ts new file mode 100644 index 000000000..73dbec4d3 --- /dev/null +++ b/e2e/utils/globalSetupMigration.ts @@ -0,0 +1,71 @@ +import { request } from '@playwright/test'; +import http from 'http'; + +import { defaultUserAdmin, testsConfig } from '../config'; +import { + dockerCheckContainers, + dockerCheckTemplateExists, + dockerCreateMigrationState, + dockerCreateSnapshot, + dockerUp, +} from './docker'; +import { loadEnv } from './loadEnv'; + +const waitForCore = async () => { + const url = testsConfig.BASE_URL + '/api/v1/health'; + await new Promise((resolve) => { + const check = () => { + const req = http.get(url, (res) => { + if (res.statusCode === 200) { + resolve(); + } else { + setTimeout(check, 2000); + } + }); + req.on('error', () => setTimeout(check, 2000)); + req.end(); + }; + check(); + }); +}; + +// POST to the initial setup admin endpoint to create the admin user. +const createAdminUser = async () => { + const ctx = await request.newContext({ baseURL: testsConfig.BASE_URL }); + const res = await ctx.post('/api/v1/initial_setup/admin', { + data: { + username: defaultUserAdmin.username, + password: defaultUserAdmin.password, + email: defaultUserAdmin.mail, + first_name: defaultUserAdmin.firstName, + last_name: defaultUserAdmin.lastName, + automatically_assign_group: true, + }, + }); + await ctx.dispose(); + if (!res.ok()) { + throw new Error(`Failed to create admin user: ${res.status()}`); + } +}; + +export default async function globalSetupMigration() { + loadEnv(); + + if (!dockerCheckContainers()) { + dockerUp(); + } + + if (dockerCheckTemplateExists()) { + console.log('Migration snapshot already exists, skipping global setup.'); + return; + } + + console.log('Waiting for Core (migration) to be ready...'); + await waitForCore(); + console.log('Core ready. Creating admin user...'); + await createAdminUser(); + console.log('Preparing migration state...'); + dockerCreateMigrationState(); + dockerCreateSnapshot(); + console.log('Migration snapshot created.'); +} diff --git a/web/src/pages/MigrationWizardPage/steps/MigrationWizardConfirmationStep/MigrationWizardConfirmationStep.tsx b/web/src/pages/MigrationWizardPage/steps/MigrationWizardConfirmationStep/MigrationWizardConfirmationStep.tsx index 5f2e29aee..2c45f1ba8 100644 --- a/web/src/pages/MigrationWizardPage/steps/MigrationWizardConfirmationStep/MigrationWizardConfirmationStep.tsx +++ b/web/src/pages/MigrationWizardPage/steps/MigrationWizardConfirmationStep/MigrationWizardConfirmationStep.tsx @@ -145,6 +145,7 @@ export const MigrationWizardConfirmationStep = () => { setConfirm((s) => !s); }} text={m.migration_wizard_confirmation_checkbox_label()} + testId="confirm-migration" /> )} @@ -154,7 +155,6 @@ export const MigrationWizardConfirmationStep = () => {