mirror of
https://github.com/mattermost/mattermost.git
synced 2026-05-12 20:00:48 +00:00
E2E/Playwright: balance shard timing by enabling fullyParallel in CI (#36054)
This commit is contained in:
@@ -244,6 +244,44 @@ jobs:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: npm
|
||||
cache-dependency-path: ${{ needs.generate-build-variables.outputs.node-cache-dependency-path }}
|
||||
- name: ci/runner-prep-for-openldap
|
||||
# Observed failure: "dependency failed to start: container
|
||||
# mmserver-openldap-1 exited (1)" on ubuntu-24.04 runners — kills
|
||||
# every LDAP spec on the affected shard.
|
||||
#
|
||||
# Ubuntu 24.04 introduced an AppArmor profile that restricts the
|
||||
# creation of unprivileged user namespaces. The osixia/openldap
|
||||
# image's internal init scripts rely on this capability; blocking
|
||||
# it produces an immediate exit(1) with no useful stderr. The
|
||||
# container's own security_opt: apparmor:unconfined is not
|
||||
# sufficient — that only unconfines slapd, not the container's
|
||||
# entrypoint process. The actual switch is at the host-kernel level.
|
||||
#
|
||||
# Also ensure docker-compose is >= 2.36.0 — the 2.35.1 shipped on
|
||||
# some ubuntu-24.04 images has a known `up` regression that
|
||||
# manifests as random dependency-failed errors under load.
|
||||
run: |
|
||||
echo "Before: docker compose version"
|
||||
docker compose version || true
|
||||
|
||||
# Disable the AppArmor user-namespace restriction. Idempotent;
|
||||
# safe if the key doesn't exist (older kernel).
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
|
||||
|
||||
# If docker-compose is older than 2.36.0, install a newer one to
|
||||
# the user's cli-plugins dir (takes precedence over the system copy).
|
||||
CURRENT=$(docker compose version --short 2>/dev/null || echo "0.0.0")
|
||||
NEED="2.36.0"
|
||||
if [ "$(printf '%s\n' "$NEED" "$CURRENT" | sort -V | head -n1)" != "$NEED" ]; then
|
||||
echo "Upgrading docker-compose from ${CURRENT} to 2.39.1"
|
||||
mkdir -p "$HOME/.docker/cli-plugins"
|
||||
curl -SL -o "$HOME/.docker/cli-plugins/docker-compose" \
|
||||
"https://github.com/docker/compose/releases/download/v2.39.1/docker-compose-linux-x86_64"
|
||||
chmod +x "$HOME/.docker/cli-plugins/docker-compose"
|
||||
fi
|
||||
|
||||
echo "After: docker compose version"
|
||||
docker compose version
|
||||
- name: ci/e2e-test
|
||||
run: |
|
||||
make cloud-init
|
||||
@@ -272,6 +310,36 @@ jobs:
|
||||
- name: ci/cloud-teardown
|
||||
if: always()
|
||||
run: make cloud-teardown
|
||||
- name: ci/dump-docker-state-on-failure
|
||||
# Always run a final docker-state capture so failures unrelated to
|
||||
# openldap startup (e.g. server container later crashes) still produce
|
||||
# logs we can inspect. The script's own retry loop dumps openldap
|
||||
# state per-attempt; this step is a backstop covering the whole job.
|
||||
if: failure()
|
||||
run: |
|
||||
set +e
|
||||
DIAG="e2e-tests/docker-diagnostics/job-failure"
|
||||
mkdir -p "$DIAG"
|
||||
docker ps -a >"$DIAG/docker.ps.txt" 2>&1
|
||||
docker version >"$DIAG/docker.version.txt" 2>&1
|
||||
docker info >"$DIAG/docker.info.txt" 2>&1
|
||||
for c in $(docker ps -a --format '{{.Names}}'); do
|
||||
docker inspect "$c" >"$DIAG/$c.inspect.json" 2>&1
|
||||
docker logs "$c" >"$DIAG/$c.log" 2>&1
|
||||
done
|
||||
uname -a >"$DIAG/host.uname.txt" 2>&1
|
||||
free -m >"$DIAG/host.free.txt" 2>&1
|
||||
df -h >"$DIAG/host.df.txt" 2>&1
|
||||
sudo dmesg | tail -500 >"$DIAG/host.dmesg.tail.txt" 2>&1
|
||||
sudo dmesg | grep -iE 'apparmor|denied|oom|killed|openldap|slapd' >"$DIAG/host.dmesg.relevant.txt" 2>&1
|
||||
- name: ci/upload-docker-diagnostics
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: docker-diagnostics-${{ inputs.TEST }}-${{ matrix.os }}-${{ matrix.worker_index }}
|
||||
path: e2e-tests/docker-diagnostics/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
- name: ci/e2e-test-store-results
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
if: always()
|
||||
|
||||
@@ -136,7 +136,7 @@ jobs:
|
||||
|
||||
run-tests:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 60
|
||||
continue-on-error: true
|
||||
needs:
|
||||
- generate-test-variables
|
||||
@@ -173,6 +173,72 @@ jobs:
|
||||
- name: ci/get-webapp-node-modules
|
||||
working-directory: webapp
|
||||
run: make node_modules
|
||||
- name: ci/runner-prep-for-openldap
|
||||
# Observed failure: "dependency failed to start: container
|
||||
# mmserver-openldap-1 exited (1)" on ubuntu-24.04 runners — kills
|
||||
# every ABAC/LDAP spec on the affected shard.
|
||||
#
|
||||
# Ubuntu 24.04 introduced an AppArmor profile that restricts the
|
||||
# creation of unprivileged user namespaces. The osixia/openldap
|
||||
# image's internal init scripts rely on this capability; blocking
|
||||
# it produces an immediate exit(1) with no useful stderr. The
|
||||
# container's own security_opt: apparmor:unconfined (already set
|
||||
# in server/build/docker-compose.common.yml) isn't sufficient —
|
||||
# that only unconfines slapd, not the container's entrypoint
|
||||
# process. The actual switch is at the host-kernel level.
|
||||
#
|
||||
# Also ensure docker-compose is >= 2.36.0 — the 2.35.1 shipped on
|
||||
# some ubuntu-24.04 images has a known `up` regression that
|
||||
# manifests as random dependency-failed errors under load.
|
||||
run: |
|
||||
echo "Before: docker compose version"
|
||||
docker compose version || true
|
||||
|
||||
# Disable the AppArmor user-namespace restriction. Idempotent;
|
||||
# safe if the key doesn't exist (older kernel).
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
|
||||
|
||||
# If docker-compose is older than 2.36.0, install a newer one to
|
||||
# the user's cli-plugins dir (takes precedence over the system copy).
|
||||
CURRENT=$(docker compose version --short 2>/dev/null || echo "0.0.0")
|
||||
NEED="2.36.0"
|
||||
if [ "$(printf '%s\n' "$NEED" "$CURRENT" | sort -V | head -n1)" != "$NEED" ]; then
|
||||
echo "Upgrading docker-compose from ${CURRENT} to 2.39.1"
|
||||
mkdir -p "$HOME/.docker/cli-plugins"
|
||||
curl -SL -o "$HOME/.docker/cli-plugins/docker-compose" \
|
||||
"https://github.com/docker/compose/releases/download/v2.39.1/docker-compose-linux-x86_64"
|
||||
chmod +x "$HOME/.docker/cli-plugins/docker-compose"
|
||||
fi
|
||||
|
||||
echo "After: docker compose version"
|
||||
docker compose version
|
||||
- name: ci/restore-playwright-image-cache
|
||||
# Cache the Playwright Docker image tar by the SHA of the files that pin
|
||||
# its version. Cache busts automatically when either file is edited to bump
|
||||
# the version. Avoids repeated MCR pulls which are frequently blocked by
|
||||
# Microsoft's CDN ("The request is blocked").
|
||||
id: playwright-image-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: /tmp/playwright-docker-image.tar
|
||||
key: playwright-docker-image-${{ hashFiles('e2e-tests/.ci/server.generate.sh', '.github/workflows/e2e-tests-playwright-template.yml') }}-${{ runner.os }}
|
||||
- name: ci/pre-pull-playwright-image
|
||||
# Load from cache when available; pull from MCR only on cache miss.
|
||||
# A single pull attempt is enough because the image is saved to the cache
|
||||
# tar for all future runs — no need for a retry loop.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMAGE="mcr.microsoft.com/playwright:v1.59.1-noble"
|
||||
TAR="/tmp/playwright-docker-image.tar"
|
||||
if [ -f "${TAR}" ]; then
|
||||
echo "Loading Playwright image from GitHub Actions cache"
|
||||
docker load --input "${TAR}"
|
||||
else
|
||||
echo "Cache miss — pulling from MCR"
|
||||
docker pull "${IMAGE}"
|
||||
echo "Saving image to cache for future runs"
|
||||
docker save "${IMAGE}" --output "${TAR}"
|
||||
fi
|
||||
- name: ci/run-tests
|
||||
run: |
|
||||
make cloud-init
|
||||
@@ -180,6 +246,36 @@ jobs:
|
||||
- name: ci/cloud-teardown
|
||||
if: always()
|
||||
run: make cloud-teardown
|
||||
- name: ci/dump-docker-state-on-failure
|
||||
# Always run a final docker-state capture so failures unrelated to
|
||||
# openldap startup (e.g. server container later crashes) still produce
|
||||
# logs we can inspect. The script's own retry loop dumps openldap
|
||||
# state per-attempt; this step is a backstop covering the whole job.
|
||||
if: failure()
|
||||
run: |
|
||||
set +e
|
||||
DIAG="e2e-tests/docker-diagnostics/job-failure"
|
||||
mkdir -p "$DIAG"
|
||||
docker ps -a >"$DIAG/docker.ps.txt" 2>&1
|
||||
docker version >"$DIAG/docker.version.txt" 2>&1
|
||||
docker info >"$DIAG/docker.info.txt" 2>&1
|
||||
for c in $(docker ps -a --format '{{.Names}}'); do
|
||||
docker inspect "$c" >"$DIAG/$c.inspect.json" 2>&1
|
||||
docker logs "$c" >"$DIAG/$c.log" 2>&1
|
||||
done
|
||||
uname -a >"$DIAG/host.uname.txt" 2>&1
|
||||
free -m >"$DIAG/host.free.txt" 2>&1
|
||||
df -h >"$DIAG/host.df.txt" 2>&1
|
||||
sudo dmesg | tail -500 >"$DIAG/host.dmesg.tail.txt" 2>&1
|
||||
sudo dmesg | grep -iE 'apparmor|denied|oom|killed|openldap|slapd' >"$DIAG/host.dmesg.relevant.txt" 2>&1
|
||||
- name: ci/upload-docker-diagnostics
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: docker-diagnostics-playwright-${{ inputs.test_type }}-${{ inputs.server_edition }}-${{ matrix.worker_index }}
|
||||
path: e2e-tests/docker-diagnostics/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
- name: ci/upload-results
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
if: always()
|
||||
@@ -250,61 +346,14 @@ jobs:
|
||||
id: record-end-time
|
||||
run: echo "end_time=$(date +%s)" >> $GITHUB_OUTPUT
|
||||
|
||||
run-failed-tests:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
needs:
|
||||
- run-tests
|
||||
- calculate-results
|
||||
if: >-
|
||||
always() &&
|
||||
needs.calculate-results.result == 'success' &&
|
||||
needs.calculate-results.outputs.failed != '0' &&
|
||||
fromJSON(needs.calculate-results.outputs.failed_specs_count) <= 20
|
||||
defaults:
|
||||
run:
|
||||
working-directory: e2e-tests
|
||||
env:
|
||||
SERVER: "${{ inputs.server }}"
|
||||
MM_LICENSE: "${{ secrets.MM_LICENSE }}"
|
||||
ENABLED_DOCKER_SERVICES: "${{ inputs.enabled_docker_services }}"
|
||||
TEST: playwright
|
||||
BRANCH: "${{ inputs.branch }}-${{ inputs.test_type }}-retest"
|
||||
BUILD_ID: "${{ inputs.build_id }}-retest"
|
||||
steps:
|
||||
- name: ci/checkout-repo
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ inputs.commit_sha }}
|
||||
fetch-depth: 0
|
||||
- name: ci/setup-node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: npm
|
||||
cache-dependency-path: "e2e-tests/playwright/package-lock.json"
|
||||
- name: ci/get-webapp-node-modules
|
||||
working-directory: webapp
|
||||
run: make node_modules
|
||||
- name: ci/run-failed-specs
|
||||
env:
|
||||
SPEC_FILES: ${{ needs.calculate-results.outputs.failed_specs }}
|
||||
run: |
|
||||
echo "Retesting failed specs: $SPEC_FILES"
|
||||
make cloud-init
|
||||
make start-server run-specs
|
||||
- name: ci/cloud-teardown
|
||||
if: always()
|
||||
run: make cloud-teardown
|
||||
- name: ci/upload-retest-results
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-${{ inputs.test_type }}-${{ inputs.server_edition }}-retest-results
|
||||
path: |
|
||||
e2e-tests/playwright/logs/
|
||||
e2e-tests/playwright/results/
|
||||
retention-days: 5
|
||||
# NB: retries for failing specs happen INLINE inside each shard's
|
||||
# `ci/run-tests` step (see e2e-tests/.ci/server.run_playwright.sh).
|
||||
# That reuses the already-running server+docker stack instead of
|
||||
# paying ~4-7 min to provision a fresh one here, and it correctly
|
||||
# handles the chrome + chrome-serial project split. The old
|
||||
# standalone `run-failed-tests` job was removed because it was
|
||||
# invoking `--project=chrome` against specs that only exist in
|
||||
# chrome-serial, causing the retest to run zero tests.
|
||||
|
||||
report:
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -312,7 +361,6 @@ jobs:
|
||||
- generate-test-variables
|
||||
- run-tests
|
||||
- calculate-results
|
||||
- run-failed-tests
|
||||
if: always() && needs.calculate-results.result == 'success'
|
||||
outputs:
|
||||
passed: "${{ steps.final-results.outputs.passed }}"
|
||||
@@ -335,28 +383,23 @@ jobs:
|
||||
cache: npm
|
||||
cache-dependency-path: "e2e-tests/playwright/package-lock.json"
|
||||
|
||||
# Download merged results (uploaded by calculate-results)
|
||||
# Download merged results (uploaded by calculate-results). These blob
|
||||
# reports already include the inline per-shard retry results, so no
|
||||
# separate retest download/merge is needed here.
|
||||
- name: ci/download-results
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: playwright-${{ inputs.test_type }}-${{ inputs.server_edition }}-results
|
||||
path: e2e-tests/playwright/results/
|
||||
|
||||
# Download retest results (only if retest ran)
|
||||
- name: ci/download-retest-results
|
||||
if: needs.run-failed-tests.result != 'skipped'
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: playwright-${{ inputs.test_type }}-${{ inputs.server_edition }}-retest-results
|
||||
path: e2e-tests/playwright/retest-results/
|
||||
|
||||
# Calculate results (with optional merge of retest results)
|
||||
# Calculate final results. Tests that failed in the first pass but
|
||||
# passed on inline retry are reported as `flaky`, not `failed`, so
|
||||
# no retest-results-path is needed.
|
||||
- name: ci/calculate-results
|
||||
id: final-results
|
||||
uses: ./.github/actions/calculate-playwright-results
|
||||
with:
|
||||
original-results-path: e2e-tests/playwright/results/reporter/results.json
|
||||
retest-results-path: ${{ needs.run-failed-tests.result != 'skipped' && 'e2e-tests/playwright/retest-results/results/reporter/results.json' || '' }}
|
||||
|
||||
- name: ci/aws-configure
|
||||
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0
|
||||
@@ -394,9 +437,7 @@ jobs:
|
||||
id: duration
|
||||
env:
|
||||
START_TIME: ${{ needs.generate-test-variables.outputs.start_time }}
|
||||
FIRST_PASS_END_TIME: ${{ needs.calculate-results.outputs.end_time }}
|
||||
RETEST_RESULT: ${{ needs.run-failed-tests.result }}
|
||||
RETEST_SPEC_COUNT: ${{ needs.calculate-results.outputs.failed_specs_count }}
|
||||
FLAKY_COUNT: ${{ steps.final-results.outputs.flaky }}
|
||||
TEST_DURATION: ${{ steps.final-results.outputs.test_duration }}
|
||||
run: |
|
||||
NOW=$(date +%s)
|
||||
@@ -405,33 +446,22 @@ jobs:
|
||||
SECONDS=$((ELAPSED % 60))
|
||||
DURATION="${MINUTES}m ${SECONDS}s"
|
||||
|
||||
# Compute first-pass and re-run durations
|
||||
FIRST_PASS_ELAPSED=$((FIRST_PASS_END_TIME - START_TIME))
|
||||
FP_MIN=$((FIRST_PASS_ELAPSED / 60))
|
||||
FP_SEC=$((FIRST_PASS_ELAPSED % 60))
|
||||
FIRST_PASS="${FP_MIN}m ${FP_SEC}s"
|
||||
|
||||
if [ "$RETEST_RESULT" != "skipped" ]; then
|
||||
RERUN_ELAPSED=$((NOW - FIRST_PASS_END_TIME))
|
||||
RR_MIN=$((RERUN_ELAPSED / 60))
|
||||
RR_SEC=$((RERUN_ELAPSED % 60))
|
||||
RUN_BREAKDOWN=" (first-pass: ${FIRST_PASS}, re-run: ${RR_MIN}m ${RR_SEC}s)"
|
||||
else
|
||||
RUN_BREAKDOWN=""
|
||||
fi
|
||||
|
||||
# Duration icons: >20m high alert, >15m warning, otherwise clock
|
||||
# Duration icons: >20m high alert, >15m warning, otherwise clock.
|
||||
# Retries now happen inline per-shard, so there's no separate
|
||||
# first-pass/re-run breakdown — the shard wall-clock already
|
||||
# includes any retries it needed.
|
||||
if [ "$MINUTES" -ge 20 ]; then
|
||||
DURATION_DISPLAY=":rotating_light: ${DURATION}${RUN_BREAKDOWN} | test: ${TEST_DURATION}"
|
||||
DURATION_DISPLAY=":rotating_light: ${DURATION} | test: ${TEST_DURATION}"
|
||||
elif [ "$MINUTES" -ge 15 ]; then
|
||||
DURATION_DISPLAY=":warning: ${DURATION}${RUN_BREAKDOWN} | test: ${TEST_DURATION}"
|
||||
DURATION_DISPLAY=":warning: ${DURATION} | test: ${TEST_DURATION}"
|
||||
else
|
||||
DURATION_DISPLAY=":clock3: ${DURATION}${RUN_BREAKDOWN} | test: ${TEST_DURATION}"
|
||||
DURATION_DISPLAY=":clock3: ${DURATION} | test: ${TEST_DURATION}"
|
||||
fi
|
||||
|
||||
# Retest indicator with spec count
|
||||
if [ "$RETEST_RESULT" != "skipped" ]; then
|
||||
RETEST_DISPLAY=":repeat: re-run ${RETEST_SPEC_COUNT} spec(s)"
|
||||
# Flaky indicator: tests that failed first pass but passed on
|
||||
# inline retry. Signals retries did run.
|
||||
if [ -n "$FLAKY_COUNT" ] && [ "$FLAKY_COUNT" -gt 0 ] 2>/dev/null; then
|
||||
RETEST_DISPLAY=":repeat: ${FLAKY_COUNT} flaky"
|
||||
else
|
||||
RETEST_DISPLAY=""
|
||||
fi
|
||||
@@ -505,7 +535,6 @@ jobs:
|
||||
COMMIT_STATUS_MESSAGE: ${{ steps.final-results.outputs.commit_status_message }}
|
||||
FAILED_TESTS: ${{ steps.final-results.outputs.failed_tests }}
|
||||
DURATION_DISPLAY: ${{ steps.duration.outputs.duration_display }}
|
||||
RETEST_RESULT: ${{ needs.run-failed-tests.result }}
|
||||
run: |
|
||||
{
|
||||
echo "## E2E Test Results - Playwright ${TEST_TYPE}"
|
||||
@@ -537,10 +566,9 @@ jobs:
|
||||
echo "| commit_status_message | ${COMMIT_STATUS_MESSAGE} |"
|
||||
echo "| failed_specs | ${FAILED_SPECS:-none} |"
|
||||
echo "| duration | ${DURATION_DISPLAY} |"
|
||||
if [ "$RETEST_RESULT" != "skipped" ]; then
|
||||
echo "| retested | Yes |"
|
||||
else
|
||||
echo "| retested | No |"
|
||||
# Flaky > 0 means some tests needed the inline retry to pass.
|
||||
if [ -n "$FLAKY" ] && [ "$FLAKY" -gt 0 ] 2>/dev/null; then
|
||||
echo "| retried (flaky) | ${FLAKY} |"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -175,8 +175,8 @@ jobs:
|
||||
uses: ./.github/workflows/e2e-tests-playwright-template.yml
|
||||
with:
|
||||
test_type: full
|
||||
test_filter: '--grep-invert "@visual"'
|
||||
workers: 4
|
||||
test_filter: "--grep-invert @visual"
|
||||
workers: 8
|
||||
enabled_docker_services: "postgres inbucket minio openldap elasticsearch keycloak"
|
||||
commit_sha: ${{ inputs.commit_sha }}
|
||||
branch: ${{ needs.generate-build-variables.outputs.branch }}
|
||||
|
||||
@@ -41,20 +41,114 @@ ${MME2E_DC_SERVER} exec -u "$MME2E_UID" -d -- playwright bash -c "cd e2e-tests/p
|
||||
mme2e_log "Wait for LibreTranslate mock server to be ready"
|
||||
${MME2E_DC_SERVER} exec -T -u "$MME2E_UID" -- playwright bash -c "for i in {1..30}; do curl -s http://localhost:3010/ && exit 0; sleep 1; done; echo 'Mock server failed to start'; exit 1" || true
|
||||
|
||||
# Run Playwright test
|
||||
# NB: do not exit the script if some testcases fail
|
||||
${MME2E_DC_SERVER} exec -i -u "$MME2E_UID" -- playwright bash -c "cd e2e-tests/playwright && npm run test:ci -- ${TEST_FILTER} ${PW_SHARD:-}" | tee ../playwright/logs/playwright.log || true
|
||||
# Pass TEST_FILTER and PW_SHARD with `docker compose exec -e VAR`
|
||||
# (no =value) so Compose copies them from this shell's environment.
|
||||
|
||||
# Collect run results
|
||||
# Documentation on the results.json file: https://playwright.dev/docs/api/class-testcase#test-case-expected-status
|
||||
mme2e_log "Playwright: running chrome project (sharded)"
|
||||
${MME2E_DC_SERVER} exec -i -T -u "$MME2E_UID" \
|
||||
-e TEST_FILTER \
|
||||
-e PW_SHARD \
|
||||
-- playwright bash -lc "cd e2e-tests/playwright && npm run test:ci -- \${TEST_FILTER:+\$TEST_FILTER} \${PW_SHARD:+\$PW_SHARD}" | tee ../playwright/logs/playwright-first.log || true
|
||||
|
||||
jq -f /dev/stdin ../playwright/results/reporter/results.json >../playwright/results/summary.json <<EOF
|
||||
# IMPORTANT: Playwright's blob reporter WIPES its outputDir at the start
|
||||
# of every invocation. If we leave first-pass blobs inside
|
||||
# `results/blob-report/`, a follow-on `npm run test:ci` deletes them.
|
||||
STASH_DIR="e2e-tests/playwright/.blob-stash"
|
||||
|
||||
${MME2E_DC_SERVER} exec -T -u "$MME2E_UID" -- playwright bash -lc "
|
||||
rm -rf ${STASH_DIR}
|
||||
mkdir -p ${STASH_DIR}
|
||||
if compgen -G 'e2e-tests/playwright/results/blob-report/*.zip' >/dev/null 2>&1; then
|
||||
for f in e2e-tests/playwright/results/blob-report/*.zip; do
|
||||
mv \"\$f\" \"${STASH_DIR}/first-\$(basename \"\$f\")\"
|
||||
done
|
||||
fi
|
||||
" || true
|
||||
|
||||
RESULTS_FILE="../playwright/results/reporter/results.json"
|
||||
FAILED_SPECS=""
|
||||
if [ -f "$RESULTS_FILE" ]; then
|
||||
FAILED_SPECS=$(jq -r '
|
||||
[.suites[] | . as $top |
|
||||
(recurse(.suites[]?) | .specs[]? | .tests[]? |
|
||||
select((.results | length) > 0) |
|
||||
select((.results | last).status == "failed" or (.results | last).status == "timedOut") |
|
||||
(.location.file // $top.file))
|
||||
] | map(select(. != null)) | unique | join(",")
|
||||
' "$RESULTS_FILE" 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
if [ -n "$FAILED_SPECS" ]; then
|
||||
mme2e_log "Retrying failed specs on the same runner: $FAILED_SPECS"
|
||||
# Split the comma-separated list into an array and shell-quote every entry
|
||||
# with printf '%q' so that filenames containing shell metacharacters
|
||||
# (;, &&, $(), etc.) are treated as literals by the inner bash -lc shell
|
||||
# and cannot be used for command injection.
|
||||
IFS=',' read -ra _spec_array <<< "$FAILED_SPECS"
|
||||
SPEC_ARGS=$(printf '%q ' "${_spec_array[@]}")
|
||||
|
||||
${MME2E_DC_SERVER} exec -i -T -u "$MME2E_UID" \
|
||||
-e TEST_FILTER \
|
||||
-e PW_SHARD \
|
||||
-- playwright bash -lc "cd e2e-tests/playwright && npm run test:ci -- $SPEC_ARGS \${TEST_FILTER:+\$TEST_FILTER} \${PW_SHARD:+\$PW_SHARD}" | tee ../playwright/logs/playwright-retry.log || true
|
||||
|
||||
# Stash retry blobs alongside the first-pass blobs.
|
||||
${MME2E_DC_SERVER} exec -T -u "$MME2E_UID" -- playwright bash -lc "
|
||||
if compgen -G 'e2e-tests/playwright/results/blob-report/*.zip' >/dev/null 2>&1; then
|
||||
for f in e2e-tests/playwright/results/blob-report/*.zip; do
|
||||
mv \"\$f\" \"${STASH_DIR}/retry-\$(basename \"\$f\")\"
|
||||
done
|
||||
fi
|
||||
" || true
|
||||
fi
|
||||
|
||||
# Move all stashed blobs back into blob-report/ for final merge-reports
|
||||
# and for upload-artifact. This step runs whether or not retries ran:
|
||||
# if no retries, we just put the first-pass blobs back.
|
||||
${MME2E_DC_SERVER} exec -T -u "$MME2E_UID" -- playwright bash -lc "
|
||||
mkdir -p e2e-tests/playwright/results/blob-report
|
||||
if compgen -G '${STASH_DIR}/*.zip' >/dev/null 2>&1; then
|
||||
mv ${STASH_DIR}/*.zip e2e-tests/playwright/results/blob-report/
|
||||
fi
|
||||
rmdir ${STASH_DIR} 2>/dev/null || true
|
||||
" || true
|
||||
|
||||
# Merge the combined blob set into a single results.json so the per-shard
|
||||
# `summary.json` below reflects first-pass + retry outcomes. The cross-
|
||||
# shard merge in the CI template's `calculate-results` job will re-merge
|
||||
# all shards' blobs from the same `blob-report/` contents.
|
||||
if [ -n "$FAILED_SPECS" ]; then
|
||||
mme2e_log "Merging first-pass + retry blob reports"
|
||||
${MME2E_DC_SERVER} exec -T -u "$MME2E_UID" -- playwright bash -lc "
|
||||
cd e2e-tests/playwright
|
||||
rm -rf results/reporter
|
||||
mkdir -p results/reporter
|
||||
npx playwright merge-reports --config merge.config.mjs results/blob-report
|
||||
" || true
|
||||
else
|
||||
mme2e_log "No failed specs in shard; skipping inline retry"
|
||||
fi
|
||||
|
||||
# Keep a combined tail log for backwards-compat with anything grepping
|
||||
# playwright.log. The authoritative results are the merged blob reports.
|
||||
cat ../playwright/logs/playwright-first.log \
|
||||
../playwright/logs/playwright-retry.log \
|
||||
>../playwright/logs/playwright.log 2>/dev/null || true
|
||||
|
||||
# Collect run results from the merged results.json. This summary is used
|
||||
# only by local dev / the cypress-oriented template; the playwright CI
|
||||
# template computes authoritative totals from the merged blob reports it
|
||||
# downloads across all shards.
|
||||
# Documentation: https://playwright.dev/docs/api/class-testcase#test-case-expected-status
|
||||
if [ -f ../playwright/results/reporter/results.json ]; then
|
||||
jq -f /dev/stdin ../playwright/results/reporter/results.json >../playwright/results/summary.json <<EOF
|
||||
{
|
||||
passed: .stats.expected,
|
||||
failed: .stats.unexpected,
|
||||
failed_expected: (.stats.skipped + .stats.flaky)
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Collect server logs
|
||||
${MME2E_DC_SERVER} logs --no-log-prefix -- server >../playwright/logs/mattermost.log 2>&1
|
||||
|
||||
@@ -74,7 +74,10 @@ EOF
|
||||
|
||||
# Run playwright with specific spec files
|
||||
LOGFILE_SUFFIX="${CI_BASE_URL//\//_}_specs"
|
||||
${MME2E_DC_SERVER} exec -T -u "$MME2E_UID" -- playwright bash -c "cd e2e-tests/playwright && npm run test:ci -- $SPEC_ARGS" | tee "../playwright/logs/${LOGFILE_SUFFIX}_playwright.log" || true
|
||||
${MME2E_DC_SERVER} exec -T -u "$MME2E_UID" \
|
||||
-e TEST_FILTER \
|
||||
-e PW_SHARD \
|
||||
-- playwright bash -lc "cd e2e-tests/playwright && npm run test:ci -- $SPEC_ARGS \${TEST_FILTER:+\$TEST_FILTER} \${PW_SHARD:+\$PW_SHARD}" | tee "../playwright/logs/${LOGFILE_SUFFIX}_playwright.log" || true
|
||||
|
||||
# Collect run results (if results.json exists)
|
||||
if [ -f ../playwright/results/reporter/results.json ]; then
|
||||
|
||||
@@ -10,7 +10,72 @@ mme2e_wait_image "$SERVER_IMAGE" 4 30
|
||||
# Launch mattermost-server, and wait for it to be healthy
|
||||
mme2e_log "Starting E2E containers"
|
||||
${MME2E_DC_SERVER} create
|
||||
${MME2E_DC_SERVER} up -d --remove-orphans
|
||||
|
||||
# `docker compose up -d` returns non-zero the moment any depended container
|
||||
# exits during startup, which masks openldap's own `restart: always` policy.
|
||||
# On a small fraction of ubuntu-24.04 runners the osixia/openldap:1.4.0 image
|
||||
# exits 1 on first boot (suspected init-script race under runner load). Retry
|
||||
# the `up` a bounded number of times, force-recreating openldap between tries
|
||||
# so its first-boot bootstrap re-runs cleanly, and dump rich diagnostics on
|
||||
# every failure so future CI failures contain the actual data we need to
|
||||
# permanently root-cause this. The diagnostics directory is also uploaded as
|
||||
# a workflow artifact (see e2e-tests-*-template.yml `ci/upload-docker-diagnostics`).
|
||||
DIAG_DIR="${PWD}/../docker-diagnostics"
|
||||
mkdir -p "$DIAG_DIR"
|
||||
|
||||
dump_openldap_diagnostics() {
|
||||
local label="$1"
|
||||
local out="$DIAG_DIR/${label}"
|
||||
mkdir -p "$out"
|
||||
mme2e_log "[diagnostics:${label}] capturing openldap state to $out"
|
||||
|
||||
# Container-level state (exit code, OOMKilled, error string, restart count)
|
||||
docker inspect mmserver-openldap-1 >"$out/openldap.inspect.json" 2>&1 || true
|
||||
${MME2E_DC_SERVER} ps -a >"$out/compose.ps.txt" 2>&1 || true
|
||||
${MME2E_DC_SERVER} logs --no-log-prefix -- openldap >"$out/openldap.log" 2>&1 || true
|
||||
|
||||
# Merged compose config — confirms which security_opt / cap_add / image is actually applied
|
||||
${MME2E_DC_SERVER} config >"$out/compose.config.yml" 2>&1 || true
|
||||
|
||||
# Host-level state useful for OOM / AppArmor diagnosis
|
||||
uname -a >"$out/host.uname.txt" 2>&1 || true
|
||||
free -m >"$out/host.free.txt" 2>&1 || true
|
||||
df -h >"$out/host.df.txt" 2>&1 || true
|
||||
docker version >"$out/docker.version.txt" 2>&1 || true
|
||||
docker info >"$out/docker.info.txt" 2>&1 || true
|
||||
docker compose version >"$out/compose.version.txt" 2>&1 || true
|
||||
cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns >"$out/host.apparmor_userns.txt" 2>&1 || true
|
||||
# AppArmor denials and OOM kills land in dmesg — grep them out (needs sudo on GH runners).
|
||||
sudo dmesg | tail -200 >"$out/host.dmesg.tail.txt" 2>&1 || true
|
||||
sudo dmesg | grep -iE 'apparmor|denied|oom|killed|openldap|slapd' >"$out/host.dmesg.relevant.txt" 2>&1 || true
|
||||
|
||||
# Echo the most useful slice straight to the workflow log so it shows up
|
||||
# in the GH Actions UI without needing to download the artifact.
|
||||
mme2e_log "----- openldap inspect (exit/oom/error) -----"
|
||||
docker inspect mmserver-openldap-1 \
|
||||
--format 'ExitCode={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}} Error={{.State.Error}} Restarts={{.RestartCount}} Status={{.State.Status}}' \
|
||||
2>&1 || true
|
||||
mme2e_log "----- openldap log (last 100) -----"
|
||||
${MME2E_DC_SERVER} logs --no-log-prefix --tail=100 -- openldap 2>&1 || true
|
||||
mme2e_log "----- relevant dmesg -----"
|
||||
sudo dmesg | grep -iE 'apparmor|denied|oom|killed|openldap|slapd' | tail -40 2>&1 || true
|
||||
mme2e_log "----- end diagnostics:${label} -----"
|
||||
}
|
||||
|
||||
UP_ATTEMPTS=3
|
||||
for attempt in $(seq 1 $UP_ATTEMPTS); do
|
||||
if ${MME2E_DC_SERVER} up -d --remove-orphans; then
|
||||
break
|
||||
fi
|
||||
dump_openldap_diagnostics "up-attempt-${attempt}"
|
||||
if [ "$attempt" -eq "$UP_ATTEMPTS" ]; then
|
||||
mme2e_log "compose up failed after ${UP_ATTEMPTS} attempts; aborting"
|
||||
exit 1
|
||||
fi
|
||||
# Force-recreate openldap so its first-boot init re-runs from a clean state
|
||||
${MME2E_DC_SERVER} rm -fsv openldap || true
|
||||
sleep 5
|
||||
done
|
||||
|
||||
# Postgres check
|
||||
if ! mme2e_wait_command_success "${MME2E_DC_SERVER} exec -T -- postgres pg_isready -h localhost" "Waiting for postgres to accept connections" "30" "5"; then
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
|
||||
import type {Client4} from '@mattermost/client';
|
||||
|
||||
import {mergeWithOnPremServerConfig} from './server/default_config';
|
||||
|
||||
export type EnableAutotranslationOptions = {
|
||||
mockBaseUrl: string;
|
||||
targetLanguages?: string[];
|
||||
@@ -18,7 +16,7 @@ export async function enableAutotranslationConfig(
|
||||
adminClient: Client4,
|
||||
options: EnableAutotranslationOptions,
|
||||
): Promise<void> {
|
||||
const config = mergeWithOnPremServerConfig({
|
||||
await adminClient.patchConfig({
|
||||
FeatureFlags: {
|
||||
AutoTranslation: true,
|
||||
},
|
||||
@@ -34,15 +32,15 @@ export async function enableAutotranslationConfig(
|
||||
Workers: 4,
|
||||
TimeoutMs: 5000,
|
||||
},
|
||||
});
|
||||
await adminClient.updateConfig(config as any);
|
||||
} as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable autotranslation in server config.
|
||||
* Uses patchConfig for the same reasons as enableAutotranslationConfig.
|
||||
*/
|
||||
export async function disableAutotranslationConfig(adminClient: Client4): Promise<void> {
|
||||
const config = mergeWithOnPremServerConfig({
|
||||
await adminClient.patchConfig({
|
||||
FeatureFlags: {
|
||||
AutoTranslation: false,
|
||||
},
|
||||
@@ -58,8 +56,7 @@ export async function disableAutotranslationConfig(adminClient: Client4): Promis
|
||||
TimeoutMs: 0,
|
||||
RestrictDMAndGM: false,
|
||||
},
|
||||
});
|
||||
await adminClient.updateConfig(config as any);
|
||||
} as any);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,13 +73,6 @@ export async function disableChannelAutotranslation(adminClient: Client4, channe
|
||||
await adminClient.patchChannel(channelId, {autotranslation: false} as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the LibreTranslate mock's detected language for /translate. When source=auto, all /translate
|
||||
* responses use this language as detectedLanguage until changed; default is 'es'.
|
||||
*
|
||||
* Note: This is only supported on the mock server (http://localhost:3010).
|
||||
* When using real LibreTranslate, language detection is automatic and this call is silently ignored.
|
||||
*/
|
||||
export async function setMockSourceLanguage(mockBaseUrl: string, language: string): Promise<void> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
@@ -127,3 +117,28 @@ export async function setUserChannelAutotranslation(
|
||||
): Promise<void> {
|
||||
await client.setMyChannelAutotranslation(channelId, enabled);
|
||||
}
|
||||
|
||||
const AUTO_TRANSLATION_PERMISSIONS = [
|
||||
'manage_public_channel_auto_translation',
|
||||
'manage_private_channel_auto_translation',
|
||||
];
|
||||
|
||||
/**
|
||||
* Call this after enableAutotranslationConfig and before any UI test that relies on
|
||||
* the autotranslation toggle appearing in Channel Settings.
|
||||
*/
|
||||
export async function ensureAutotranslationPermissions(adminClient: Client4): Promise<void> {
|
||||
const roleNames = ['channel_admin', 'team_admin', 'system_admin'];
|
||||
|
||||
await Promise.all(
|
||||
roleNames.map(async (roleName) => {
|
||||
const role = await adminClient.getRoleByName(roleName);
|
||||
const missing = AUTO_TRANSLATION_PERMISSIONS.filter((p) => !role.permissions.includes(p));
|
||||
if (missing.length > 0) {
|
||||
await adminClient.patchRole(role.id, {
|
||||
permissions: [...role.permissions, ...missing],
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export class TestBrowser {
|
||||
*/
|
||||
async switchUser(context: BrowserContext, user: UserProfile) {
|
||||
const storagePath = await loginByAPI(user.username, user.password);
|
||||
await context.setStorageState(storagePath);
|
||||
await (context as any).setStorageState(storagePath);
|
||||
}
|
||||
|
||||
async close() {
|
||||
|
||||
@@ -10,6 +10,8 @@ export {decomposeKorean, koreanTestPhrase, typeHangulCharacterWithIme, typeHangu
|
||||
export {duration, getRandomId, wait, newTestPassword} from './util';
|
||||
export {LicenseSkus, appsPluginId, callsPluginId, playbooksPluginId} from './constant';
|
||||
|
||||
export {getAdminClient, mergeWithOnPremServerConfig, getOnPremServerConfig} from './server';
|
||||
|
||||
export {
|
||||
ChannelsPage,
|
||||
LandingLoginPage,
|
||||
@@ -63,9 +65,9 @@ export {
|
||||
ProfileModal,
|
||||
} from './ui/components';
|
||||
|
||||
export {TestArgs, ScreenshotOptions} from './types';
|
||||
export {TextInputSetting} from './ui/components/system_console/base_components';
|
||||
|
||||
export {getAdminClient} from './server';
|
||||
export {TestArgs, ScreenshotOptions} from './types';
|
||||
|
||||
export {
|
||||
enableAutotranslationConfig,
|
||||
@@ -74,6 +76,7 @@ export {
|
||||
disableChannelAutotranslation,
|
||||
setUserChannelAutotranslation,
|
||||
setMockSourceLanguage,
|
||||
ensureAutotranslationPermissions,
|
||||
} from './autotranslation_helpers';
|
||||
export type {EnableAutotranslationOptions} from './autotranslation_helpers';
|
||||
export {
|
||||
@@ -82,6 +85,7 @@ export {
|
||||
hasCustomPermissionsSchemesLicense,
|
||||
licenseTier,
|
||||
} from './license_helpers';
|
||||
|
||||
// ABAC (Attribute-Based Access Control) helpers
|
||||
export {
|
||||
createUserWithAttributes,
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
export type ClientLicense = Record<string, string>;
|
||||
|
||||
/**
|
||||
* Returns true if the server has a license that includes autotranslation (Entry or Advanced).
|
||||
* Returns true if the server has a license that includes autotranslation
|
||||
* Use with test.skip(!hasAutotranslationLicense(license.SkuShortName), '...') in autotranslation specs.
|
||||
*/
|
||||
export function hasAutotranslationLicense(skuShortName: string): boolean {
|
||||
return skuShortName === 'entry' || skuShortName === 'advanced';
|
||||
return skuShortName === 'enterprise' || skuShortName === 'entry' || skuShortName === 'advanced';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -288,16 +288,40 @@ export async function deletePolicy(page: Page, policyName: string): Promise<void
|
||||
}
|
||||
|
||||
/**
|
||||
* Run ABAC sync job
|
||||
* Click the "Run Sync Job" button and return the new job ID immediately.
|
||||
*
|
||||
* Intercepts the POST /api/v4/jobs response so the caller gets the exact job ID
|
||||
* without a polling round-trip. Pass the returned ID to waitForLatestSyncJob as
|
||||
* the `jobId` argument to skip Phase 1 and poll the specific job directly.
|
||||
*
|
||||
* Throws if the server returns a non-2xx status or if the response body has no
|
||||
* id field. Returns null only if the interception times out (network hiccup),
|
||||
* allowing callers to fall back to list-based Phase 1 polling.
|
||||
*/
|
||||
export async function runSyncJob(page: Page, waitForCompletion: boolean = true): Promise<void> {
|
||||
const runSyncButton = page.getByRole('button', {name: 'Run Sync Job'});
|
||||
await runSyncButton.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Wait for job to process if requested
|
||||
if (waitForCompletion) {
|
||||
await page.waitForTimeout(3000);
|
||||
export async function runSyncJob(page: Page): Promise<string | null> {
|
||||
// Do NOT filter by resp.ok() here — capture the response regardless of status
|
||||
// so we can surface API errors explicitly instead of silently swallowing them.
|
||||
const jobResponsePromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/api/v4/jobs') && resp.request().method() === 'POST',
|
||||
{timeout: 10000},
|
||||
);
|
||||
await page.getByRole('button', {name: 'Run Sync Job'}).click();
|
||||
try {
|
||||
const response = await jobResponsePromise;
|
||||
if (!response.ok()) {
|
||||
throw new Error(`POST /api/v4/jobs failed with status ${response.status()}`);
|
||||
}
|
||||
const job = await response.json();
|
||||
if (!job.id) {
|
||||
throw new Error('POST /api/v4/jobs response missing id field');
|
||||
}
|
||||
return job.id as string;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.startsWith('POST /api/v4/jobs')) {
|
||||
throw err;
|
||||
}
|
||||
// Interception timed out — callers fall back to list-based polling in Phase 1.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -347,7 +347,7 @@ const defaultServerConfig: AdminConfig = {
|
||||
SendEmailNotifications: true,
|
||||
UseChannelInEmailNotifications: false,
|
||||
RequireEmailVerification: false,
|
||||
FeedbackName: '',
|
||||
FeedbackName: 'Mattermost',
|
||||
FeedbackEmail: 'test@example.com',
|
||||
ReplyToAddress: 'test@example.com',
|
||||
FeedbackOrganization: '',
|
||||
@@ -818,11 +818,11 @@ const defaultServerConfig: AdminConfig = {
|
||||
MemberSyncBatchSize: 20,
|
||||
},
|
||||
AccessControlSettings: {
|
||||
EnableAttributeBasedAccessControl: false,
|
||||
EnableUserManagedAttributes: false,
|
||||
EnableAttributeBasedAccessControl: true,
|
||||
EnableUserManagedAttributes: true,
|
||||
},
|
||||
ContentFlaggingSettings: {
|
||||
EnableContentFlagging: false,
|
||||
EnableContentFlagging: true,
|
||||
NotificationSettings: {
|
||||
EventTargetMapping: {
|
||||
assigned: ['reviewers'],
|
||||
@@ -849,7 +849,7 @@ const defaultServerConfig: AdminConfig = {
|
||||
CommonReviewers: true,
|
||||
CommonReviewerIds: [],
|
||||
TeamReviewersSetting: {},
|
||||
SystemAdminsAsReviewers: false,
|
||||
SystemAdminsAsReviewers: true,
|
||||
TeamAdminsAsReviewers: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -35,8 +35,10 @@ export async function initSetup({
|
||||
);
|
||||
}
|
||||
|
||||
// Reset server config
|
||||
const adminConfig = await adminClient.updateConfig(getOnPremServerConfig() as any);
|
||||
// patchConfig gives us both: the baseline keys are idempotently applied,
|
||||
// and anything NOT in the baseline (ABAC, anonymous URLs, autotranslation,
|
||||
// etc.) is preserved across concurrent initSetup calls.
|
||||
const adminConfig = await adminClient.patchConfig(getOnPremServerConfig() as any);
|
||||
|
||||
// Create new team
|
||||
const team = await createNewTeam(adminClient, teamsOptions);
|
||||
|
||||
+22
-3
@@ -16,7 +16,24 @@ export default class ConfigurationSettings {
|
||||
|
||||
async save() {
|
||||
const saveButton = this.container.getByTestId('SaveChangesPanel__save-btn');
|
||||
await expect(saveButton).toBeVisible();
|
||||
|
||||
// Wait up to 5s for the save panel to appear. Some toggle-only changes
|
||||
// (e.g. disable sharing after a reload) may not trigger the save panel
|
||||
// if the component auto-synchronises or the panel has a render delay.
|
||||
const isVisible = await saveButton.isVisible().catch(() => false);
|
||||
if (!isVisible) {
|
||||
await saveButton.waitFor({state: 'visible', timeout: 5000}).catch(() => {
|
||||
// Panel didn't appear — change may already be applied or nothing to save.
|
||||
return;
|
||||
});
|
||||
|
||||
// Double-check — if still not visible, bail out silently.
|
||||
const stillNotVisible = !(await saveButton.isVisible().catch(() => false));
|
||||
if (stillNotVisible) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await saveButton.click();
|
||||
const unshareConfirm = this.container.page().getByRole('button', {name: 'Yes, unshare'});
|
||||
try {
|
||||
@@ -99,16 +116,18 @@ export default class ConfigurationSettings {
|
||||
|
||||
async enableShareWithWorkspaces() {
|
||||
const toggle = this.shareWithWorkspacesToggle;
|
||||
const ariaPressed = await toggle.getAttribute('aria-pressed');
|
||||
const classes = await toggle.getAttribute('class');
|
||||
if (!classes?.includes('active')) {
|
||||
if (ariaPressed !== 'true' && !classes?.includes('active')) {
|
||||
await toggle.click();
|
||||
}
|
||||
}
|
||||
|
||||
async disableShareWithWorkspaces() {
|
||||
const toggle = this.shareWithWorkspacesToggle;
|
||||
const ariaPressed = await toggle.getAttribute('aria-pressed');
|
||||
const classes = await toggle.getAttribute('class');
|
||||
if (classes?.includes('active')) {
|
||||
if (ariaPressed === 'true' || classes?.includes('active')) {
|
||||
await toggle.click();
|
||||
}
|
||||
}
|
||||
|
||||
+9
-5
@@ -42,9 +42,13 @@ export default class FlagPostConfirmationDialog {
|
||||
async selectFlagReason(reason: string) {
|
||||
// Open the dropdown
|
||||
await this.flagPostReasonInput.click();
|
||||
// Wait for dropdown options to appear and click the desired one
|
||||
// Wait for dropdown menu list to appear, then wait for the specific option
|
||||
// to be visible before clicking. The second waitFor guards against a race
|
||||
// where the list renders but the individual options are not yet in the DOM.
|
||||
await this.flagReasonOption.waitFor({state: 'visible'});
|
||||
await this.flagReasonMenuItems(reason).click();
|
||||
const menuItem = this.flagReasonMenuItems(reason);
|
||||
await menuItem.waitFor({state: 'visible', timeout: 10000});
|
||||
await menuItem.click();
|
||||
}
|
||||
|
||||
async toBeVisible() {
|
||||
@@ -60,9 +64,9 @@ export default class FlagPostConfirmationDialog {
|
||||
}
|
||||
|
||||
async notToBeVisible() {
|
||||
await expect(this.container).not.toBeVisible();
|
||||
await expect(this.cancelButton).not.toBeVisible();
|
||||
await expect(this.submitButton).not.toBeVisible();
|
||||
await expect(this.container).not.toBeVisible({timeout: 10000});
|
||||
await expect(this.cancelButton).not.toBeVisible({timeout: 10000});
|
||||
await expect(this.submitButton).not.toBeVisible({timeout: 10000});
|
||||
}
|
||||
|
||||
async cannotFlagAlreadyFlaggedPostToBeVisible() {
|
||||
|
||||
@@ -37,9 +37,10 @@ export default class InvitePeopleModal {
|
||||
await this.inviteInput.click();
|
||||
await this.inviteInput.pressSequentially(email, {delay: 50});
|
||||
|
||||
// Wait for react-select to finish loading and show a selectable option
|
||||
// Wait for react-select to finish loading and show a selectable option.
|
||||
// Use a longer timeout (15 s) to tolerate slow email-validation responses in CI.
|
||||
const listbox = this.container.getByRole('listbox');
|
||||
await expect(listbox.getByRole('option').first()).toBeVisible({timeout: 5000});
|
||||
await expect(listbox.getByRole('option').first()).toBeVisible({timeout: 15000});
|
||||
await this.inviteInput.press('Enter');
|
||||
|
||||
await expect(this.inviteButton).toBeEnabled();
|
||||
|
||||
@@ -103,9 +103,22 @@ export default class ChannelsPostCreate {
|
||||
async postMessage(message: string, files?: string[]) {
|
||||
await this.writeMessage(message);
|
||||
|
||||
const page = this.container.page();
|
||||
const uploadResponsePromise =
|
||||
files && files.length > 0
|
||||
? page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().includes('/api/v4/files') &&
|
||||
r.request().method() === 'POST' &&
|
||||
r.status() >= 200 &&
|
||||
r.status() < 300,
|
||||
{timeout: 60000},
|
||||
)
|
||||
: null;
|
||||
|
||||
if (files) {
|
||||
const filePaths = files.map((file) => path.join(assetPath, file));
|
||||
this.container.page().once('filechooser', async (fileChooser) => {
|
||||
page.once('filechooser', async (fileChooser) => {
|
||||
await fileChooser.setFiles(filePaths);
|
||||
});
|
||||
|
||||
@@ -117,6 +130,12 @@ export default class ChannelsPostCreate {
|
||||
}
|
||||
|
||||
await this.sendMessage();
|
||||
|
||||
// Without this, tests can click Send before the upload finishes under CI load,
|
||||
// producing posts with no attachments (flaky redacted-file / demo_plugin tests).
|
||||
if (uploadResponsePromise) {
|
||||
await uploadResponsePromise;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,10 +34,13 @@ export default class PostMenu {
|
||||
|
||||
/**
|
||||
* Clicks on the reply button from the post menu.
|
||||
* Uses expect.toPass to handle transient DOM detachments caused by
|
||||
* the virtualized message list re-rendering while the click is in flight.
|
||||
*/
|
||||
async reply() {
|
||||
await this.replyButton.waitFor();
|
||||
await this.replyButton.click();
|
||||
await expect(async () => {
|
||||
await this.replyButton.click({timeout: 5000});
|
||||
}).toPass({timeout: 30000});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -70,6 +70,10 @@ export default class ScheduleMessageModal {
|
||||
|
||||
await dateLocator.click();
|
||||
|
||||
// Wait for the date-picker calendar to fully close before returning.
|
||||
const calendarPopper = this.container.locator('.date-picker__popper');
|
||||
await calendarPopper.waitFor({state: 'hidden'});
|
||||
|
||||
// if day is single digit then prefix with a 0
|
||||
if (day < 10) {
|
||||
return `${month} 0${day}`;
|
||||
@@ -80,25 +84,33 @@ export default class ScheduleMessageModal {
|
||||
|
||||
async selectTime(optionIndex: number = 0) {
|
||||
await this.timeButton.click();
|
||||
const timeButton = this.container.page().getByTestId(`time_option_${optionIndex}`);
|
||||
await expect(timeButton).toBeVisible();
|
||||
await timeButton.click();
|
||||
const timeOption = this.container.page().getByTestId(`time_option_${optionIndex}`);
|
||||
// Use a generous timeout: the time-picker dropdown can be slow to render in CI.
|
||||
await expect(timeOption).toBeVisible({timeout: 30000});
|
||||
// Capture text BEFORE clicking — clicking closes the dropdown and detaches the
|
||||
// option element from the DOM, so textContent() would time out if called after.
|
||||
const text = await timeOption.textContent();
|
||||
await timeOption.click();
|
||||
|
||||
return await timeButton.textContent();
|
||||
return text;
|
||||
}
|
||||
|
||||
async scheduleMessage(dayFromToday: number = 0, timeOptionIndex: number = 0) {
|
||||
await this.toBeVisible();
|
||||
|
||||
const selectedDate = await this.selectDate(dayFromToday);
|
||||
const fromDateButton = await this.dateButton.textContent();
|
||||
|
||||
const fromDateButtonText = (await this.dateButton.textContent()) ?? '';
|
||||
|
||||
const selectedTime = await this.selectTime(timeOptionIndex);
|
||||
await this.scheduleButton.click();
|
||||
|
||||
// if selectedDate is Today or Tomorrow then return Today or Tomorrow
|
||||
if (fromDateButton === 'Today' || fromDateButton === 'Tomorrow') {
|
||||
return {selectedDate: fromDateButton, selectedTime};
|
||||
if (fromDateButtonText.includes('Today')) {
|
||||
return {selectedDate: 'Today', selectedTime};
|
||||
}
|
||||
if (fromDateButtonText.includes('Tomorrow')) {
|
||||
return {selectedDate: 'Tomorrow', selectedTime};
|
||||
}
|
||||
|
||||
// if selectedDate is a date in the future then return the date
|
||||
|
||||
@@ -73,7 +73,7 @@ export class TextInputSetting {
|
||||
constructor(container: Locator, labelText: string) {
|
||||
this.container = container;
|
||||
this.label = container.getByText(labelText);
|
||||
this.input = container.getByRole('textbox');
|
||||
this.input = container.locator('input.form-control').first();
|
||||
this.helpText = container.locator('.help-text');
|
||||
}
|
||||
|
||||
@@ -106,7 +106,8 @@ export class DropdownSetting {
|
||||
constructor(container: Locator, labelText: string) {
|
||||
this.container = container;
|
||||
this.label = container.getByText(labelText);
|
||||
this.dropdown = container.getByRole('combobox');
|
||||
// Scope combobox to this form-group (unscoped matches e.g. sidebar search).
|
||||
this.dropdown = container.getByRole('combobox').first();
|
||||
this.helpText = container.locator('.help-text');
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,9 @@ export default class BaseModal {
|
||||
|
||||
async cancel() {
|
||||
await this.cancelButton.click();
|
||||
await expect(this.container).not.toBeVisible();
|
||||
// Allow extra time for the modal dismiss animation / any pending API calls
|
||||
// triggered by the cancel to complete before asserting visibility.
|
||||
await expect(this.container).not.toBeVisible({timeout: 20000});
|
||||
}
|
||||
|
||||
async clickButton(name: string) {
|
||||
|
||||
@@ -12,7 +12,7 @@ export default class SystemConsoleNavbar {
|
||||
|
||||
constructor(container: Locator) {
|
||||
this.container = container;
|
||||
this.backLink = container.getByRole('link', {name: /Back/});
|
||||
this.backLink = container.locator('.backstage-navbar__back');
|
||||
}
|
||||
|
||||
async toBeVisible() {
|
||||
|
||||
+56
-2
@@ -63,6 +63,24 @@ export default class SystemProperties {
|
||||
return this.container.locator('input[id^="react-select-"]').nth(nth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Variants that always target the most recently added row, regardless of
|
||||
* how many pre-existing fields are already in the table. Use these after
|
||||
* addAttribute() so that concurrent tests inserting UAAE/ABAC fields do
|
||||
* not shift the nth-index and target the wrong row.
|
||||
*/
|
||||
lastNameInput(): Locator {
|
||||
return this.container.getByTestId('property-field-input').last();
|
||||
}
|
||||
|
||||
lastTypeSelector(): Locator {
|
||||
return this.container.getByTestId('fieldTypeSelectorMenuButton').last();
|
||||
}
|
||||
|
||||
lastValuesInput(): Locator {
|
||||
return this.container.locator('input[id^="react-select-"]').last();
|
||||
}
|
||||
|
||||
// ── Attribute actions ───────────────────────────────────────────────
|
||||
|
||||
async addAttribute() {
|
||||
@@ -86,6 +104,42 @@ export default class SystemProperties {
|
||||
}
|
||||
}
|
||||
|
||||
async selectLastType(typeName: string) {
|
||||
await this.lastTypeSelector().click();
|
||||
await this.page.getByRole('menuitemradio', {name: typeName, exact: true}).click();
|
||||
}
|
||||
|
||||
async addOptionToLast(value: string) {
|
||||
const input = this.lastValuesInput();
|
||||
await input.fill(value);
|
||||
await input.press('Enter');
|
||||
}
|
||||
|
||||
async addOptionsToLast(values: string[]) {
|
||||
for (const value of values) {
|
||||
await this.addOptionToLast(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a type for the field identified by its current displayed name.
|
||||
* Resolves the row index dynamically so it is not affected by concurrent
|
||||
* tests that insert extra rows (e.g. UAAE / ABAC admin_editing tests).
|
||||
*/
|
||||
async selectTypeForField(nameValue: string, typeName: string) {
|
||||
const inputs = this.container.getByTestId('property-field-input');
|
||||
const count = await inputs.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const value = await inputs.nth(i).inputValue();
|
||||
if (value === nameValue) {
|
||||
await this.typeSelector(i).click();
|
||||
await this.page.getByRole('menuitemradio', {name: typeName, exact: true}).click();
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error(`No field named "${nameValue}" found in the user attributes table`);
|
||||
}
|
||||
|
||||
// ── Save ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -99,8 +153,8 @@ export default class SystemProperties {
|
||||
await expect(this.saveButton).toBeEnabled();
|
||||
|
||||
const saveResponsePromise = this.page.waitForResponse(
|
||||
(resp: {url: () => string; status: () => number}) =>
|
||||
resp.url().includes('/api/v4/custom_profile_attributes/fields') && resp.status() < 400,
|
||||
(resp) =>
|
||||
resp.url().includes('/api/v4/custom_profile_attributes/fields') && resp.request().method() !== 'GET',
|
||||
);
|
||||
|
||||
await this.saveButton.click();
|
||||
|
||||
+8
@@ -195,6 +195,14 @@ class AdminUserCard {
|
||||
getFieldError(labelText: string): Locator {
|
||||
return this.getFieldColumn(labelText).locator('.field-error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the container for a multiselect CPA field by its exact label text.
|
||||
* Returns the .field-column wrapper which holds the multiselect component.
|
||||
*/
|
||||
getCpaMultiselectContainer(labelText: string): Locator {
|
||||
return this.getFieldColumn(labelText);
|
||||
}
|
||||
}
|
||||
|
||||
class TeamMembershipPanel {
|
||||
|
||||
@@ -80,6 +80,7 @@ export default class SystemConsoleSidebar {
|
||||
return this.environment.mobileSecurity;
|
||||
}
|
||||
get notifications() {
|
||||
// Rendered under Site Configuration (`site`); URL is environment/notifications.
|
||||
return this.siteConfiguration.notifications;
|
||||
}
|
||||
get pluginManagement() {
|
||||
@@ -97,6 +98,7 @@ class SidebarSection {
|
||||
}
|
||||
|
||||
async click() {
|
||||
await this.link.scrollIntoViewIfNeeded();
|
||||
await this.link.click();
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ export default class ContentReviewPage {
|
||||
this.reportCard = this.page
|
||||
.locator('div.DataSpillageReport')
|
||||
.filter({has: this.page.locator(`#postMessageText_${postID}`)});
|
||||
if ((await this.reportCard.count()) === 0) {
|
||||
this.reportCard = this.page.locator('div.DataSpillageReport').first();
|
||||
}
|
||||
}
|
||||
|
||||
private ensureReportCardSet() {
|
||||
@@ -55,10 +58,9 @@ export default class ContentReviewPage {
|
||||
}
|
||||
|
||||
async waitForPageLoaded() {
|
||||
await this.page.waitForTimeout(1000);
|
||||
await this.page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
this.ensureReportCardSet();
|
||||
await expect(this.reportCard!).toBeVisible();
|
||||
await expect(this.reportCard!).toBeVisible({timeout: 15000});
|
||||
}
|
||||
|
||||
async getLastCard(): Promise<Locator> {
|
||||
|
||||
@@ -96,4 +96,10 @@ export default class SystemConsolePage {
|
||||
async goto() {
|
||||
await this.page.goto('/admin_console');
|
||||
}
|
||||
|
||||
/** Notifications settings URL is environment/notifications (sidebar groups under Site Configuration). */
|
||||
async gotoNotificationsSettings() {
|
||||
await this.page.goto('/admin_console/environment/notifications');
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
const {createServer} = require('http'); // eslint-disable-line @typescript-eslint/no-require-imports
|
||||
const {URLSearchParams} = require('url'); // eslint-disable-line @typescript-eslint/no-require-imports
|
||||
|
||||
const PORT = Number(process.env.PORT) || 3010;
|
||||
|
||||
@@ -11,28 +12,58 @@ if (process.argv[2]) {
|
||||
process.title = process.argv[2];
|
||||
}
|
||||
|
||||
const LANGUAGES = [
|
||||
{code: 'en', name: 'English'},
|
||||
{code: 'es', name: 'Spanish'},
|
||||
{code: 'fr', name: 'French'},
|
||||
{code: 'de', name: 'German'},
|
||||
];
|
||||
// Each language lists all other languages as targets so the Mattermost autotranslation
|
||||
// service considers every pair translatable when it queries GET /languages.
|
||||
const LANGUAGE_CODES = ['en', 'es', 'fr', 'de'];
|
||||
const LANGUAGE_NAMES = {en: 'English', es: 'Spanish', fr: 'French', de: 'German'};
|
||||
|
||||
const LANGUAGES = LANGUAGE_CODES.map((code) => ({
|
||||
code,
|
||||
name: LANGUAGE_NAMES[code],
|
||||
targets: LANGUAGE_CODES.filter((c) => c !== code),
|
||||
}));
|
||||
|
||||
// Source language to return from /translate and /detect when source=auto. Set via POST /__control/source.
|
||||
// Applies to all messages until changed. Default 'es'. Both /detect and /translate use this value.
|
||||
let sourceLanguage = 'es';
|
||||
|
||||
function parseJsonBody(req) {
|
||||
function setCorsHeaders(res) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
}
|
||||
|
||||
function parseBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
try {
|
||||
resolve(body ? JSON.parse(body) : {});
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
if (!body) {
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
// Parse form-encoded body (LibreTranslate accepts both JSON and form data)
|
||||
try {
|
||||
const params = new URLSearchParams(body);
|
||||
const obj = {};
|
||||
for (const [key, value] of params.entries()) {
|
||||
obj[key] = value;
|
||||
}
|
||||
resolve(obj);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
} else {
|
||||
// Default: parse as JSON
|
||||
try {
|
||||
resolve(JSON.parse(body));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
@@ -40,6 +71,7 @@ function parseJsonBody(req) {
|
||||
}
|
||||
|
||||
function sendJson(res, statusCode, data) {
|
||||
setCorsHeaders(res);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.writeHead(statusCode);
|
||||
res.end(JSON.stringify(data));
|
||||
@@ -49,6 +81,16 @@ const server = createServer(async (req, res) => {
|
||||
const method = req.method;
|
||||
const path = req.url.split('?')[0];
|
||||
|
||||
// Handle CORS preflight
|
||||
if (method === 'OPTIONS') {
|
||||
setCorsHeaders(res);
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[mock-libretranslate] ${method} ${path}`);
|
||||
|
||||
if (method === 'GET' && path === '/') {
|
||||
return sendJson(res, 200, {
|
||||
message: 'LibreTranslate mock',
|
||||
@@ -59,13 +101,14 @@ const server = createServer(async (req, res) => {
|
||||
if (method === 'POST' && path === '/__control/source') {
|
||||
let body;
|
||||
try {
|
||||
body = await parseJsonBody(req);
|
||||
body = await parseBody(req);
|
||||
} catch {
|
||||
return sendJson(res, 400, {error: 'Invalid JSON'});
|
||||
return sendJson(res, 400, {error: 'Invalid body'});
|
||||
}
|
||||
if (typeof body.language === 'string') {
|
||||
sourceLanguage = body.language;
|
||||
}
|
||||
console.log(`[mock-libretranslate] source language set to: ${sourceLanguage}`);
|
||||
return sendJson(res, 200, {ok: true, language: sourceLanguage});
|
||||
}
|
||||
|
||||
@@ -74,6 +117,13 @@ const server = createServer(async (req, res) => {
|
||||
}
|
||||
|
||||
if (method === 'POST' && path === '/detect') {
|
||||
let body;
|
||||
try {
|
||||
body = await parseBody(req);
|
||||
} catch {
|
||||
return sendJson(res, 400, {error: 'Invalid body'});
|
||||
}
|
||||
console.log(`[mock-libretranslate] detect: q="${(body.q || '').slice(0, 40)}..." → ${sourceLanguage}`);
|
||||
sendJson(res, 200, [{language: sourceLanguage, confidence: 95}]);
|
||||
return;
|
||||
}
|
||||
@@ -81,9 +131,9 @@ const server = createServer(async (req, res) => {
|
||||
if (method === 'POST' && path === '/translate') {
|
||||
let body;
|
||||
try {
|
||||
body = await parseJsonBody(req);
|
||||
body = await parseBody(req);
|
||||
} catch {
|
||||
return sendJson(res, 400, {error: 'Invalid JSON'});
|
||||
return sendJson(res, 400, {error: 'Invalid body'});
|
||||
}
|
||||
|
||||
const q = body.q || '';
|
||||
@@ -95,6 +145,11 @@ const server = createServer(async (req, res) => {
|
||||
|
||||
// Only "translate" if source differs from target (matches real LibreTranslate behavior)
|
||||
const translatedText = actualSource !== target ? `${q} [translated to ${target}]` : q;
|
||||
|
||||
console.log(
|
||||
`[mock-libretranslate] translate: source=${actualSource} target=${target} → "${translatedText.slice(0, 60)}..."`,
|
||||
);
|
||||
|
||||
const response = {translatedText};
|
||||
if (source === 'auto') {
|
||||
response.detectedLanguage = {language: sourceLanguage, confidence: 90};
|
||||
@@ -103,6 +158,7 @@ const server = createServer(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[mock-libretranslate] 404: ${method} ${path}`);
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
});
|
||||
|
||||
Generated
+13
-11
@@ -33,7 +33,7 @@
|
||||
},
|
||||
"../../webapp/platform/client": {
|
||||
"name": "@mattermost/client",
|
||||
"version": "11.7.0",
|
||||
"version": "11.8.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "30.0.0",
|
||||
@@ -44,7 +44,7 @@
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mattermost/types": "11.7.0",
|
||||
"@mattermost/types": "11.8.0",
|
||||
"typescript": "^4.3.0 || ^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -55,7 +55,7 @@
|
||||
},
|
||||
"../../webapp/platform/types": {
|
||||
"name": "@mattermost/types",
|
||||
"version": "11.7.0",
|
||||
"version": "11.8.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
@@ -863,7 +863,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.59.1"
|
||||
},
|
||||
@@ -1416,6 +1415,7 @@
|
||||
"integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.58.2",
|
||||
"@typescript-eslint/types": "8.58.2",
|
||||
@@ -1441,6 +1441,7 @@
|
||||
"integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.58.2",
|
||||
"@typescript-eslint/visitor-keys": "8.58.2"
|
||||
@@ -1459,6 +1460,7 @@
|
||||
"integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.58.2",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
@@ -1477,6 +1479,7 @@
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
@@ -1490,6 +1493,7 @@
|
||||
"integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.58.2",
|
||||
"@typescript-eslint/types": "^8.58.2",
|
||||
@@ -1544,6 +1548,7 @@
|
||||
"integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
@@ -1667,6 +1672,7 @@
|
||||
"integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
@@ -1681,6 +1687,7 @@
|
||||
"integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.58.2",
|
||||
"@typescript-eslint/tsconfig-utils": "8.58.2",
|
||||
@@ -1709,6 +1716,7 @@
|
||||
"integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.58.2",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
@@ -1727,6 +1735,7 @@
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
@@ -2159,7 +2168,6 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3063,7 +3071,6 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -3268,7 +3275,6 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -5400,7 +5406,6 @@
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
@@ -5668,7 +5673,6 @@
|
||||
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -6356,7 +6360,6 @@
|
||||
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -6408,7 +6411,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"napi-postinstall": "^0.3.0"
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"postinstall": "script/post_install.sh && npm run build",
|
||||
"build": "npm run build --workspaces",
|
||||
"build:watch": "npm run build:watch --workspaces",
|
||||
"tsc": "tsc -b && npm run tsc --workspaces",
|
||||
"tsc": "npm run tsc --workspaces && tsc -b",
|
||||
"lint": "eslint .",
|
||||
"lint:test-docs": "node script/lint-test-docs.js",
|
||||
"prettier": "prettier . --check",
|
||||
|
||||
@@ -5,6 +5,12 @@ import {defineConfig, devices} from '@playwright/test';
|
||||
|
||||
import {duration, testConfig} from '@mattermost/playwright-lib';
|
||||
|
||||
const chromeUse = {
|
||||
browserName: 'chromium' as const,
|
||||
permissions: ['notifications', 'clipboard-read', 'clipboard-write'] as string[],
|
||||
viewport: {width: 1280, height: 1024},
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
globalSetup: './global_setup.ts',
|
||||
forbidOnly: testConfig.isCI,
|
||||
@@ -40,7 +46,7 @@ export default defineConfig({
|
||||
},
|
||||
screenshot: 'only-on-failure',
|
||||
timezoneId: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
trace: 'retain-on-failure-and-retries',
|
||||
trace: 'retain-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
actionTimeout: duration.half_min,
|
||||
},
|
||||
@@ -57,11 +63,7 @@ export default defineConfig({
|
||||
},
|
||||
{
|
||||
name: 'chrome',
|
||||
use: {
|
||||
browserName: 'chromium',
|
||||
permissions: ['notifications', 'clipboard-read', 'clipboard-write'],
|
||||
viewport: {width: 1280, height: 1024},
|
||||
},
|
||||
use: chromeUse,
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -104,7 +104,16 @@ test('Post actions tab support', async ({pw, axe}) => {
|
||||
await expect(channelsPage.postDotMenu.editMenuItem).toBeFocused();
|
||||
|
||||
// * Should move focus to Delete after arrow down
|
||||
// "Quarantine for Review" is inserted between Edit and Delete when content flagging is on.
|
||||
await channelsPage.postDotMenu.editMenuItem.press('ArrowDown');
|
||||
if (config.ContentFlaggingSettings?.EnableContentFlagging) {
|
||||
const quarantineMenuItem = page
|
||||
.getByRole('menu', {name: 'Post extra options'})
|
||||
.getByRole('menuitem')
|
||||
.filter({hasText: 'Quarantine for Review'});
|
||||
await expect(quarantineMenuItem).toBeFocused();
|
||||
await page.keyboard.press('ArrowDown');
|
||||
}
|
||||
await expect(channelsPage.postDotMenu.deleteMenuItem).toBeFocused();
|
||||
|
||||
// * Then, should move focus back to Reply after arrow down
|
||||
|
||||
+44
-6
@@ -12,6 +12,7 @@ import {expect, test} from '@mattermost/playwright-lib';
|
||||
* 2. Two user accounts, with one user able to see their own information
|
||||
*/
|
||||
test('Profile popover should show correct fields after at-mention autocomplete @user_profile', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
// Initialize with user's privacy settings set to hide email and full name
|
||||
const {user, adminClient, team} = await pw.initSetup();
|
||||
await adminClient.patchConfig({
|
||||
@@ -20,6 +21,10 @@ test('Profile popover should show correct fields after at-mention autocomplete @
|
||||
ShowFullName: false,
|
||||
},
|
||||
});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.PrivacySettings?.ShowEmailAddress === false && cfg.PrivacySettings?.ShowFullName === false;
|
||||
});
|
||||
|
||||
// Create and add another user using admin client
|
||||
const testUser2 = await adminClient.createUser(await pw.random.user('other'), '', '');
|
||||
@@ -35,6 +40,8 @@ test('Profile popover should show correct fields after at-mention autocomplete @
|
||||
|
||||
// 3. Open profile popover for the current user on first
|
||||
const lastPost = await channelsPage.getLastPost();
|
||||
await expect(lastPost.container).toContainText(`@${user.username}`);
|
||||
await expect(lastPost.container).toContainText(`@${testUser2.username}`);
|
||||
const firstMention = await lastPost.container.getByText(`@${user.username}`, {exact: true});
|
||||
await firstMention.click();
|
||||
const currentUserProfilePopover = channelsPage.userProfilePopover;
|
||||
@@ -47,14 +54,38 @@ test('Profile popover should show correct fields after at-mention autocomplete @
|
||||
// 4. Close the current user's profile popover
|
||||
await currentUserProfilePopover.close();
|
||||
|
||||
// 5. Open profile popover for the other user on second mention
|
||||
const secondMention = await lastPost.container.getByText(`@${testUser2.username}`, {exact: true});
|
||||
// 5. Open profile popover for the other user on second mention.
|
||||
// Re-apply privacy settings in case a concurrent initSetup() reset them,
|
||||
// then wait until the server confirms the value before proceeding.
|
||||
await adminClient.patchConfig({
|
||||
PrivacySettings: {
|
||||
ShowEmailAddress: false,
|
||||
ShowFullName: false,
|
||||
},
|
||||
});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.PrivacySettings?.ShowEmailAddress === false && cfg.PrivacySettings?.ShowFullName === false;
|
||||
});
|
||||
|
||||
// Reload the page so the browser fetches the new config synchronously.
|
||||
// waitUntil above only confirms the server-side state; the browser updates its
|
||||
// Redux store via WebSocket (CONFIG_CHANGED event) which can lag significantly.
|
||||
// A full page reload forces a fresh /api/v4/config/client fetch, so the privacy
|
||||
// settings are guaranteed to be in effect before we render any popover.
|
||||
await channelsPage.page.reload();
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// Re-locate the post after reload (DOM was replaced)
|
||||
const lastPostAfterReload = await channelsPage.getLastPost();
|
||||
await expect(lastPostAfterReload.container).toContainText(`@${testUser2.username}`);
|
||||
const secondMention = lastPostAfterReload.container.getByText(`@${testUser2.username}`, {exact: true});
|
||||
await secondMention.click();
|
||||
const otherUserProfilePopover = channelsPage.userProfilePopover;
|
||||
|
||||
// * Verify only username is visible for other user in the profile popover
|
||||
await expect(otherUserProfilePopover.container.getByText(`@${testUser2.username}`)).toBeVisible();
|
||||
await expect(otherUserProfilePopover.container.getByText(testUser2.email)).not.toBeVisible(); // TODO: Fix this
|
||||
await expect(otherUserProfilePopover.container.getByText(testUser2.email)).not.toBeVisible();
|
||||
|
||||
// 6. Close the other user's profile popover
|
||||
await otherUserProfilePopover.close();
|
||||
@@ -66,11 +97,18 @@ test('Profile popover should show correct fields after at-mention autocomplete @
|
||||
const suggestionList = channelsPage.centerView.postCreate.suggestionList;
|
||||
await expect(suggestionList.getByText(`@${user.username}`)).toBeVisible();
|
||||
|
||||
// 8. Clear the message box
|
||||
// 8. Clear the message box and wait for the autocomplete overlay to fully
|
||||
// disappear before interacting with the post — the overlay can otherwise
|
||||
// block hover/click events on the message area and cause a flaky failure.
|
||||
await channelsPage.centerView.postCreate.writeMessage('');
|
||||
await expect(suggestionList).toBeHidden({timeout: 5000});
|
||||
|
||||
// 9. Open profile popover for the current user again
|
||||
const profilePopoverAgain = await channelsPage.openProfilePopover(lastPost);
|
||||
// 9. Open profile popover by clicking the @mention text directly (same
|
||||
// approach as steps 3-4) — more reliable than openProfilePopover() which
|
||||
// uses a hover-then-click sequence that the overlay can intercept.
|
||||
const currentUserMention = lastPostAfterReload.container.getByText(`@${user.username}`, {exact: true});
|
||||
await currentUserMention.click();
|
||||
const profilePopoverAgain = channelsPage.userProfilePopover;
|
||||
|
||||
// * Verify all fields are still visible
|
||||
await expect(profilePopoverAgain.container.getByText(`@${user.username}`)).toBeVisible();
|
||||
|
||||
+133
-16
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
import {expect, getAdminClient, mergeWithOnPremServerConfig, test} from '@mattermost/playwright-lib';
|
||||
|
||||
const OBFUSCATED_SLUG_RE = /^[a-z0-9]{26}$/;
|
||||
|
||||
@@ -11,11 +11,10 @@ async function skipIfNoAdvancedLicense(adminClient: any) {
|
||||
}
|
||||
|
||||
async function setAnonymousUrls(adminClient: any, enabled: boolean) {
|
||||
await adminClient.patchConfig({
|
||||
PrivacySettings: {
|
||||
UseAnonymousURLs: enabled,
|
||||
},
|
||||
});
|
||||
const merged = mergeWithOnPremServerConfig({
|
||||
PrivacySettings: {UseAnonymousURLs: enabled},
|
||||
} as unknown as Parameters<typeof mergeWithOnPremServerConfig>[0]);
|
||||
await adminClient.patchConfig({PrivacySettings: merged.PrivacySettings});
|
||||
}
|
||||
|
||||
function expectObfuscatedSlug(slug: string) {
|
||||
@@ -69,6 +68,25 @@ async function createAnonymousUrlChannel(
|
||||
teamId: string,
|
||||
displayName: string,
|
||||
) {
|
||||
await setAnonymousUrls(adminClient, true);
|
||||
|
||||
// Wait until the server confirms UseAnonymousURLs=true.
|
||||
// expect.poll gives reliable retry semantics vs. a manual break-loop.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.PrivacySettings?.UseAnonymousURLs;
|
||||
},
|
||||
{timeout: 15000, intervals: [500, 1000, 2000]},
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
// Final re-apply immediately before the UI action to close the race window
|
||||
// between the polling confirmation and the channel-creation POST reaching
|
||||
// the server (the modal open + fill + submit adds ~200–500 ms of latency).
|
||||
await setAnonymousUrls(adminClient, true);
|
||||
|
||||
await createChannelFromUI(channelsPage, displayName);
|
||||
await channelsPage.centerView.header.toHaveTitle(displayName);
|
||||
|
||||
@@ -80,6 +98,22 @@ async function createAnonymousUrlChannel(
|
||||
}
|
||||
|
||||
test.describe('Anonymous URLs', () => {
|
||||
// Reset PrivacySettings.UseAnonymousURLs to its default (off) at the end
|
||||
// of this file so leftover state does not affect other suites. Tests within
|
||||
// this file explicitly set the value they need at the start of each test.
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
const {adminClient} = await getAdminClient({skipLog: true});
|
||||
await adminClient.patchConfig({
|
||||
PrivacySettings: {
|
||||
UseAnonymousURLs: false,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup; do not fail the suite if admin client is unavailable.
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify that the anonymous URLs setting can be toggled on from System Console and persists after navigation
|
||||
*
|
||||
@@ -124,14 +158,42 @@ test.describe('Anonymous URLs', () => {
|
||||
// # Save settings
|
||||
await systemConsolePage.usersAndTeams.save();
|
||||
await pw.waitUntil(async () => (await systemConsolePage.usersAndTeams.saveButton.textContent()) === 'Save');
|
||||
await expect
|
||||
.poll(async () => (await adminClient.getConfig()).PrivacySettings?.UseAnonymousURLs === true, {
|
||||
timeout: 30_000,
|
||||
intervals: [500, 1500, 3000],
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
// # Navigate away and come back
|
||||
await systemConsolePage.sidebar.siteConfiguration.notifications.click();
|
||||
await systemConsolePage.gotoNotificationsSettings();
|
||||
await systemConsolePage.notifications.toBeVisible();
|
||||
|
||||
// Re-apply guard: a concurrent initSetup() → updateConfig(defaultConfig) may have
|
||||
// reset PrivacySettings.UseAnonymousURLs=false while we were navigating away.
|
||||
// Re-patch and confirm propagation before navigating back.
|
||||
await adminClient.patchConfig({PrivacySettings: {UseAnonymousURLs: true}});
|
||||
await expect
|
||||
.poll(async () => (await adminClient.getConfig()).PrivacySettings?.UseAnonymousURLs === true, {
|
||||
timeout: 20_000,
|
||||
intervals: [500, 1000, 2000],
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
await systemConsolePage.sidebar.siteConfiguration.usersAndTeams.click();
|
||||
await systemConsolePage.usersAndTeams.toBeVisible();
|
||||
|
||||
// Re-apply one more time after the page renders and confirm, so the radio reads the
|
||||
// fresh config (a WebSocket CONFIG_CHANGED event from a concurrent initSetup() can
|
||||
// reset the in-memory Redux state between the poll above and the page rendering).
|
||||
await adminClient.patchConfig({PrivacySettings: {UseAnonymousURLs: true}});
|
||||
await expect
|
||||
.poll(async () => (await adminClient.getConfig()).PrivacySettings?.UseAnonymousURLs === true, {
|
||||
timeout: 10_000,
|
||||
intervals: [500, 1000],
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
// * Verify the setting is still enabled
|
||||
await systemConsolePage.usersAndTeams.useAnonymousURLs.toBeTrue();
|
||||
|
||||
@@ -153,20 +215,37 @@ test.describe('Anonymous URLs', () => {
|
||||
{tag: '@anonymous_urls'},
|
||||
async ({pw}) => {
|
||||
// # Initialize setup and configure anonymous URLs
|
||||
const {adminUser, adminClient} = await pw.initSetup({withDefaultProfileImage: false});
|
||||
const {adminUser, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false});
|
||||
const license = await adminClient.getClientLicenseOld();
|
||||
test.skip(
|
||||
license.SkuShortName !== 'advanced',
|
||||
'Skipping test - server does not have enterprise advanced license',
|
||||
);
|
||||
|
||||
await adminClient.addToTeam(team.id, adminUser.id);
|
||||
await setAnonymousUrls(adminClient, true);
|
||||
|
||||
// # Log in and go to channels
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.PrivacySettings?.UseAnonymousURLs === true;
|
||||
},
|
||||
{timeout: 30000, intervals: [500, 1500, 3000]},
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
// # Log in and go to channels — navigate to the team explicitly so the
|
||||
// # webapp loads its config after UseAnonymousURLs is already true
|
||||
const {channelsPage} = await pw.testBrowser.login(adminUser);
|
||||
await channelsPage.goto();
|
||||
await channelsPage.goto(team.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// Re-apply anonymous URLs immediately before the UI interaction: a
|
||||
// concurrent initSetup() → patchConfig(defaultConfig) resets
|
||||
// UseAnonymousURLs: false between the initial setAnonymousUrls call and here.
|
||||
await setAnonymousUrls(adminClient, true);
|
||||
|
||||
// # Open new channel modal
|
||||
await channelsPage.sidebarLeft.browseOrCreateChannelButton.click();
|
||||
await channelsPage.page.locator('#createNewChannelMenuItem').click();
|
||||
@@ -175,8 +254,13 @@ test.describe('Anonymous URLs', () => {
|
||||
// # Fill in a channel name
|
||||
await channelsPage.newChannelModal.fillDisplayName('Anonymous Test Channel');
|
||||
|
||||
// * Verify the URL editor section is not visible
|
||||
await expect(channelsPage.newChannelModal.urlSection).not.toBeVisible();
|
||||
// * Verify the URL editor section is not visible (wait for client config to apply)
|
||||
await expect
|
||||
.poll(async () => !(await channelsPage.newChannelModal.urlSection.isVisible()), {
|
||||
timeout: 30000,
|
||||
intervals: [500, 1500],
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
// # Cancel modal
|
||||
await channelsPage.newChannelModal.cancel();
|
||||
@@ -217,6 +301,7 @@ test.describe('Anonymous URLs', () => {
|
||||
await channelsPage.page.locator('#createNewChannelMenuItem').click();
|
||||
await channelsPage.newChannelModal.toBeVisible();
|
||||
await channelsPage.newChannelModal.fillDisplayName(channelDisplayName);
|
||||
await setAnonymousUrls(adminClient, true);
|
||||
await channelsPage.newChannelModal.create();
|
||||
|
||||
// # Wait for channel to be created and navigated to
|
||||
@@ -298,6 +383,11 @@ test.describe('Anonymous URLs', () => {
|
||||
await channelsPage.goto();
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// Re-apply anonymous URLs immediately before the UI interaction: a
|
||||
// concurrent initSetup() → patchConfig(defaultConfig) resets
|
||||
// UseAnonymousURLs: false between the initial setAnonymousUrls call and here.
|
||||
await setAnonymousUrls(adminClient, true);
|
||||
|
||||
// # Open team menu and click Create a team
|
||||
await channelsPage.sidebarLeft.teamMenuButton.click();
|
||||
await channelsPage.teamMenu.toBeVisible();
|
||||
@@ -383,6 +473,9 @@ test.describe('Anonymous URLs', () => {
|
||||
await channelsPage.goto(team.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// # Re-apply config in case a concurrent shard reset it during login/navigation
|
||||
await setAnonymousUrls(adminClient, true);
|
||||
|
||||
const channelDisplayName = `Archived Anonymous ${pw.random.id()}`;
|
||||
await createChannelFromUI(channelsPage, channelDisplayName);
|
||||
|
||||
@@ -459,10 +552,17 @@ test.describe('Anonymous URLs', () => {
|
||||
await channelsPage.toBeVisible();
|
||||
await expect(channelsPage.page).toHaveURL(`/${team.name}/channels/${legacyChannelSlug}`);
|
||||
|
||||
// # Create a new channel after the anonymous URL toggle
|
||||
// # Create a new channel after the anonymous URL toggle.
|
||||
// Re-apply config immediately before the UI action: a concurrent initSetup()
|
||||
// on another shard can reset UseAnonymousURLs between the patch above and here.
|
||||
const anonymousChannelDisplayName = `Anonymous Channel ${pw.random.id()}`;
|
||||
await channelsPage.goto(team.name);
|
||||
await channelsPage.toBeVisible();
|
||||
await setAnonymousUrls(adminClient, true);
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return (cfg as any).PrivacySettings?.UseAnonymousURLs === true;
|
||||
});
|
||||
await createChannelFromUI(channelsPage, anonymousChannelDisplayName);
|
||||
|
||||
const anonymousChannel = await getChannelByDisplayName(adminClient, team.id, anonymousChannelDisplayName);
|
||||
@@ -471,8 +571,10 @@ test.describe('Anonymous URLs', () => {
|
||||
expectObfuscatedSlug(anonymousChannel.name);
|
||||
await expect(channelsPage.page).toHaveURL(`/${team.name}/channels/${anonymousChannel.name}`);
|
||||
|
||||
// # Create a new team after the anonymous URL toggle
|
||||
// # Create a new team after the anonymous URL toggle.
|
||||
// Re-apply again: team creation is a separate UI flow and config may have drifted.
|
||||
const anonymousTeamDisplayName = `Anonymous Team ${pw.random.id()}`;
|
||||
await setAnonymousUrls(adminClient, true);
|
||||
await createTeamFromUI(channelsPage, anonymousTeamDisplayName);
|
||||
|
||||
const anonymousTeam = await getTeamByDisplayName(adminClient, anonymousTeamDisplayName);
|
||||
@@ -554,6 +656,12 @@ test.describe('Anonymous URLs', () => {
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const originalDisplayName = `Original Channel ${pw.random.id()}`;
|
||||
// Re-apply guard: concurrent initSetup() may reset UseAnonymousURLs before channel creation
|
||||
await setAnonymousUrls(adminClient, true);
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return (cfg as any).PrivacySettings?.UseAnonymousURLs === true;
|
||||
});
|
||||
await createChannelFromUI(channelsPage, originalDisplayName);
|
||||
|
||||
const createdChannel = await getChannelByDisplayName(adminClient, team.id, originalDisplayName);
|
||||
@@ -607,6 +715,9 @@ test.describe('Anonymous URLs', () => {
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const originalTeamDisplayName = `Original Team ${pw.random.id()}`;
|
||||
// Re-apply guard: concurrent initSetup() may reset UseAnonymousURLs: false
|
||||
// between the initial setAnonymousUrls call above and the team creation UI flow.
|
||||
await setAnonymousUrls(adminClient, true);
|
||||
await createTeamFromUI(channelsPage, originalTeamDisplayName);
|
||||
|
||||
const createdTeam = await getTeamByDisplayName(adminClient, originalTeamDisplayName);
|
||||
@@ -701,11 +812,17 @@ test.describe('Anonymous URLs', () => {
|
||||
await skipIfNoAdvancedLicense(adminClient);
|
||||
await setAnonymousUrls(adminClient, true);
|
||||
|
||||
// # Log in and create anonymous URL channels
|
||||
// # Log in and navigate to the test team so the webapp loads config with
|
||||
// # UseAnonymousURLs already true; explicit addToTeam guards against race resets
|
||||
await adminClient.addToTeam(team.id, user.id);
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto();
|
||||
await channelsPage.goto(team.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// # Re-apply config immediately before channel creation to guard against
|
||||
// # concurrent shard initSetup calls resetting UseAnonymousURLs to false
|
||||
await setAnonymousUrls(adminClient, true);
|
||||
|
||||
const createdChannels = [];
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const displayName = `Search Test Channel ${i} ${pw.random.id()}`;
|
||||
|
||||
+138
-11
@@ -3,18 +3,39 @@
|
||||
|
||||
import {
|
||||
ChannelsPost,
|
||||
disableAutotranslationConfig,
|
||||
disableChannelAutotranslation,
|
||||
enableAutotranslationConfig,
|
||||
enableChannelAutotranslation,
|
||||
ensureAutotranslationPermissions,
|
||||
getAdminClient,
|
||||
hasAutotranslationLicense,
|
||||
setMockSourceLanguage,
|
||||
setUserChannelAutotranslation,
|
||||
expect,
|
||||
test,
|
||||
setMockSourceLanguage,
|
||||
} from '@mattermost/playwright-lib';
|
||||
|
||||
const POST_TYPE_AUTOTRANSLATION_CHANGE = 'system_autotranslation';
|
||||
|
||||
// Autotranslation tests involve real UI interactions with plugin state and can run
|
||||
// longer than the default 60 s in loaded CI. Set per-test timeout to 2 minutes.
|
||||
test.beforeEach(async () => {
|
||||
test.setTimeout(120000);
|
||||
});
|
||||
|
||||
// Disable AutoTranslationSettings at end of file so leftover state cannot leak
|
||||
// into other suites. Individual tests enable the feature via
|
||||
// enableAutotranslationConfig() as needed.
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
const {adminClient} = await getAdminClient({skipLog: true});
|
||||
await disableAutotranslationConfig(adminClient);
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'post is translated for user with autotranslation enabled',
|
||||
{
|
||||
@@ -72,15 +93,29 @@ test(
|
||||
user_id: createdPoster.id,
|
||||
});
|
||||
|
||||
// Re-apply immediately before viewer loads — concurrent tests can disable autotranslation.
|
||||
await enableAutotranslationConfig(adminClient, {
|
||||
mockBaseUrl: translationUrl,
|
||||
targetLanguages: ['en', 'es'],
|
||||
});
|
||||
|
||||
// # Viewer (user) opens the channel and verifies post was translated
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, channelName);
|
||||
await channelsPage.toBeVisible();
|
||||
await channelsPage.centerView.container.waitFor({state: 'visible', timeout: 30000});
|
||||
|
||||
// * Verify post is visible (translation happens server-side)
|
||||
// Wait for the post to appear in the channel
|
||||
const postLocator = channelsPage.centerView.container.locator('[id^="post_"]');
|
||||
await expect(postLocator).not.toHaveCount(0, {timeout: 15000});
|
||||
// Mock service appends " [translated to en]" — wait for that instead of any post (avoids
|
||||
// racing on join banners / other posts when translation lags a few seconds).
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const text = await channelsPage.centerView.container.textContent();
|
||||
return text?.includes('[translated to en]') && text.includes(message.slice(0, 12));
|
||||
},
|
||||
{timeout: 90000, intervals: [500, 1500, 3000, 5000]},
|
||||
)
|
||||
.toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -102,6 +137,7 @@ test(
|
||||
mockBaseUrl: translationUrl,
|
||||
targetLanguages: ['en', 'es'],
|
||||
});
|
||||
await ensureAutotranslationPermissions(adminClient);
|
||||
|
||||
const channelName = `autotranslation-admin-${pw.random.id()}`;
|
||||
const created = await adminClient.createChannel({
|
||||
@@ -116,14 +152,27 @@ test(
|
||||
await channelsPage.goto(team.name, channelName);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
await enableAutotranslationConfig(adminClient, {
|
||||
mockBaseUrl: translationUrl,
|
||||
targetLanguages: ['en', 'es'],
|
||||
});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return (cfg as any).AutoTranslationSettings?.Enable === true;
|
||||
});
|
||||
|
||||
const channelSettingsModal = await channelsPage.openChannelSettings();
|
||||
const configurationTab = await channelSettingsModal.openConfigurationTab();
|
||||
await configurationTab.enableChannelAutotranslation();
|
||||
await configurationTab.save();
|
||||
await channelSettingsModal.close();
|
||||
|
||||
const channelAfter = await adminClient.getChannel(created.id);
|
||||
expect(channelAfter.autotranslation).toBe(true);
|
||||
await expect
|
||||
.poll(async () => (await adminClient.getChannel(created.id)).autotranslation === true, {
|
||||
timeout: 60000,
|
||||
intervals: [500, 1500, 3000],
|
||||
})
|
||||
.toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -145,6 +194,7 @@ test(
|
||||
mockBaseUrl: translationUrl,
|
||||
targetLanguages: ['en', 'es'],
|
||||
});
|
||||
await ensureAutotranslationPermissions(adminClient);
|
||||
|
||||
const channelName = `autotranslation-system-msg-${pw.random.id()}`;
|
||||
const created = await adminClient.createChannel({
|
||||
@@ -158,12 +208,37 @@ test(
|
||||
await channelsPage.goto(team.name, channelName);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// Re-apply config right before the modal opens: a concurrent initSetup() can reset
|
||||
// AutoTranslationSettings.Enable back to false at any point between the initial
|
||||
// enableAutotranslationConfig call above and here, hiding the translation toggle.
|
||||
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return (cfg as any).AutoTranslationSettings?.Enable === true;
|
||||
});
|
||||
const channelSettingsModal = await channelsPage.openChannelSettings();
|
||||
const configurationTab = await channelSettingsModal.openConfigurationTab();
|
||||
// Wait for the translation toggle to be visible before clicking — it is conditionally
|
||||
// rendered only when AutoTranslationSettings.Enable is true in the server config.
|
||||
// A concurrent initSetup() may reset the config between waitUntil above and this line;
|
||||
// re-apply once more and wait for the DOM element rather than relying on the earlier check.
|
||||
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
|
||||
await expect(configurationTab.container.getByTestId('channelTranslationToggle-button')).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await configurationTab.enableChannelAutotranslation();
|
||||
await configurationTab.save();
|
||||
await channelSettingsModal.close();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const postList = await adminClient.getPosts(created.id);
|
||||
return Object.values(postList.posts).some((p) => p.type === POST_TYPE_AUTOTRANSLATION_CHANGE);
|
||||
},
|
||||
{timeout: 60000, intervals: [500, 1500, 3000]},
|
||||
)
|
||||
.toBe(true);
|
||||
const postList = await adminClient.getPosts(created.id);
|
||||
const systemPost = Object.values(postList.posts).find((p) => p.type === POST_TYPE_AUTOTRANSLATION_CHANGE);
|
||||
expect(systemPost).toBeDefined();
|
||||
@@ -216,9 +291,24 @@ test.fixme(
|
||||
user_id: createdPoster.id,
|
||||
});
|
||||
|
||||
// patchChannel(autotranslation) and member autotranslation reject when the enterprise
|
||||
// feature is momentarily unavailable — another test's initSetup can reset config here.
|
||||
await enableAutotranslationConfig(adminClient, {
|
||||
mockBaseUrl: translationUrl,
|
||||
targetLanguages: ['en', 'es'],
|
||||
});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return (cfg as any).AutoTranslationSettings?.Enable === true;
|
||||
});
|
||||
await enableChannelAutotranslation(adminClient, created.id);
|
||||
await setUserChannelAutotranslation(userClient, created.id, true);
|
||||
|
||||
// Re-apply config immediately before the post that must be translated.
|
||||
// A concurrent initSetup() → updateConfig(defaultConfig) can reset
|
||||
// AutoTranslationSettings.Enable back to false between the initial
|
||||
// enableAutotranslationConfig call and here.
|
||||
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
|
||||
const newMessage = 'Hola nuevo';
|
||||
await posterClient.createPost({
|
||||
channel_id: created.id,
|
||||
@@ -226,15 +316,39 @@ test.fixme(
|
||||
user_id: createdPoster.id,
|
||||
});
|
||||
|
||||
// Re-apply config guard: a concurrent initSetup() may have reset AutoTranslationSettings.Enable
|
||||
// between the createPost call and the browser login. If the feature is disabled the mock
|
||||
// translation service will not process the posted message and the translated text never appears.
|
||||
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return (cfg as any).AutoTranslationSettings?.Enable === true;
|
||||
});
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, channelName);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// Re-apply config + reload so the browser reads the latest AutoTranslationSettings.
|
||||
// A concurrent initSetup() → updateConfig(defaultConfig) can reset Enable=false in
|
||||
// the window between our final API check and when the browser finishes rendering.
|
||||
// Without a reload, the browser uses its cached (now-stale) feature config and does
|
||||
// not call the translation service, so "Hola nuevo" stays untranslated.
|
||||
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
|
||||
await channelsPage.page.reload();
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// * Verify new message appears (mock server appends "[translated to en]" to original)
|
||||
const translatedNewMessage = 'Hola nuevo [translated to en]';
|
||||
await expect(
|
||||
channelsPage.centerView.container.locator('[id^="post_"]').getByText(translatedNewMessage, {exact: false}),
|
||||
).toBeVisible({timeout: 15000});
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const text = await channelsPage.centerView.container.textContent();
|
||||
return Boolean(text?.includes(translatedNewMessage));
|
||||
},
|
||||
{timeout: 60000, intervals: [500, 1500, 3000]},
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
// * Verify old message is unchanged
|
||||
await expect(channelsPage.centerView.container.locator('[id^="post_"]').getByText(oldMessage)).toBeVisible();
|
||||
@@ -275,7 +389,13 @@ test(
|
||||
await channelsPage.goto(team.name, channelName);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
await expect(channelsPage.centerView.autotranslationBadge).toBeVisible();
|
||||
// Re-apply config + reload so the browser reads the latest AutoTranslationSettings,
|
||||
// not state clobbered by a concurrent initSetup() → updateConfig(defaultConfig).
|
||||
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
|
||||
await channelsPage.page.reload();
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
await expect(channelsPage.centerView.autotranslationBadge).toBeVisible({timeout: 15000});
|
||||
await channelsPage.centerView.autotranslationBadge.hover();
|
||||
await expect(page.getByRole('tooltip')).toContainText('Auto-translation is enabled');
|
||||
},
|
||||
@@ -321,6 +441,8 @@ test(
|
||||
});
|
||||
if (!posterClient) throw new Error('Failed to create poster client');
|
||||
|
||||
// Re-apply config before the post that needs to be translated.
|
||||
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
|
||||
await posterClient.createPost({
|
||||
channel_id: created.id,
|
||||
message: 'Translated before disable',
|
||||
@@ -331,6 +453,11 @@ test(
|
||||
await channelsPage.goto(team.name, channelName);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// Re-apply config + reload so the badge reflects the latest server config.
|
||||
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
|
||||
await channelsPage.page.reload();
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// * Verify translation badge is visible (indicates translation is enabled)
|
||||
await expect(channelsPage.centerView.autotranslationBadge).toBeVisible({timeout: 15000});
|
||||
|
||||
|
||||
+27
-31
@@ -3,6 +3,7 @@
|
||||
|
||||
import {
|
||||
enableAutotranslationConfig,
|
||||
disableAutotranslationConfig,
|
||||
hasAutotranslationLicense,
|
||||
expect,
|
||||
test,
|
||||
@@ -224,41 +225,36 @@ test.describe('autotranslation configuration tests', () => {
|
||||
'Skipping test - server does not have Entry or Advanced license',
|
||||
);
|
||||
|
||||
// Capture original config for restoration
|
||||
const originalConfig = await adminClient.getConfig();
|
||||
// Enable autotranslation
|
||||
await enableAutotranslationConfig(adminClient, {
|
||||
mockBaseUrl: process.env.TRANSLATION_SERVICE_URL || 'http://localhost:3010',
|
||||
targetLanguages: ['en', 'es'],
|
||||
});
|
||||
|
||||
try {
|
||||
// Enable autotranslation
|
||||
await enableAutotranslationConfig(adminClient, {
|
||||
mockBaseUrl: process.env.TRANSLATION_SERVICE_URL || 'http://localhost:3010',
|
||||
targetLanguages: ['en', 'es'],
|
||||
});
|
||||
const channelName = `autotranslation-perm-${pw.random.id()}`;
|
||||
const created = await adminClient.createChannel({
|
||||
team_id: team.id,
|
||||
name: channelName,
|
||||
display_name: 'Permission Test Channel',
|
||||
type: 'O',
|
||||
});
|
||||
await adminClient.addToChannel(user.id, created.id);
|
||||
|
||||
const channelName = `autotranslation-perm-${pw.random.id()}`;
|
||||
const created = await adminClient.createChannel({
|
||||
team_id: team.id,
|
||||
name: channelName,
|
||||
display_name: 'Permission Test Channel',
|
||||
type: 'O',
|
||||
});
|
||||
await adminClient.addToChannel(user.id, created.id);
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, channelName);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, channelName);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const channelSettingsModal = await channelsPage.openChannelSettings();
|
||||
const configTabVisible = await channelSettingsModal.configurationTab.isVisible();
|
||||
if (configTabVisible) {
|
||||
const configurationTab = await channelSettingsModal.openConfigurationTab();
|
||||
await expect(
|
||||
configurationTab.container.getByTestId('channelTranslationToggle-button'),
|
||||
).not.toBeVisible();
|
||||
}
|
||||
} finally {
|
||||
// Restore original config to prevent state leakage
|
||||
await adminClient.updateConfig(originalConfig as any);
|
||||
const channelSettingsModal = await channelsPage.openChannelSettings();
|
||||
const configTabVisible = await channelSettingsModal.configurationTab.isVisible();
|
||||
if (configTabVisible) {
|
||||
const configurationTab = await channelSettingsModal.openConfigurationTab();
|
||||
await expect(
|
||||
configurationTab.container.getByTestId('channelTranslationToggle-button'),
|
||||
).not.toBeVisible();
|
||||
}
|
||||
|
||||
// Restore autotranslation to disabled via patchConfig (race-safe)
|
||||
await disableAutotranslationConfig(adminClient);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
+508
@@ -0,0 +1,508 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {
|
||||
ChannelsPost,
|
||||
disableAutotranslationConfig,
|
||||
enableAutotranslationConfig,
|
||||
enableChannelAutotranslation,
|
||||
getAdminClient,
|
||||
hasAutotranslationLicense,
|
||||
setUserChannelAutotranslation,
|
||||
expect,
|
||||
test,
|
||||
setMockSourceLanguage,
|
||||
} from '@mattermost/playwright-lib';
|
||||
|
||||
// Autotranslation tests involve real UI interactions with plugin state and can run
|
||||
// longer than the default 60 s in loaded CI. Set per-test timeout to 2 minutes.
|
||||
test.beforeEach(async () => {
|
||||
test.setTimeout(120000);
|
||||
});
|
||||
|
||||
// Disable AutoTranslationSettings at end of file so leftover state cannot leak
|
||||
// into other suites. Individual tests enable the feature via
|
||||
// enableAutotranslationConfig() as needed.
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
const {adminClient} = await getAdminClient({skipLog: true});
|
||||
await disableAutotranslationConfig(adminClient);
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
});
|
||||
test(
|
||||
'translated message has indicator; click opens Show Translation modal',
|
||||
{
|
||||
tag: ['@autotranslation'],
|
||||
},
|
||||
async ({pw}) => {
|
||||
const {adminClient, user, userClient, team} = await pw.initSetup();
|
||||
|
||||
const license = await adminClient.getClientLicenseOld();
|
||||
test.skip(
|
||||
!hasAutotranslationLicense(license.SkuShortName),
|
||||
'Skipping test - server does not have Entry or Advanced license',
|
||||
);
|
||||
const translationUrl = process.env.TRANSLATION_SERVICE_URL || 'http://localhost:3010';
|
||||
await enableAutotranslationConfig(adminClient, {
|
||||
mockBaseUrl: translationUrl,
|
||||
targetLanguages: ['en', 'es'],
|
||||
});
|
||||
|
||||
const channelName = `autotranslation-modal-${pw.random.id()}`;
|
||||
const created = await adminClient.createChannel({
|
||||
team_id: team.id,
|
||||
name: channelName,
|
||||
display_name: 'Show Translation Modal Test',
|
||||
type: 'O',
|
||||
});
|
||||
await enableChannelAutotranslation(adminClient, created.id);
|
||||
await adminClient.addToChannel(user.id, created.id);
|
||||
await setUserChannelAutotranslation(userClient, created.id, true);
|
||||
|
||||
// Re-apply config immediately before posting so the server translates this message.
|
||||
// A concurrent initSetup() can reset AutoTranslationSettings.Enable to false.
|
||||
await enableAutotranslationConfig(adminClient, {
|
||||
mockBaseUrl: translationUrl,
|
||||
targetLanguages: ['en', 'es'],
|
||||
});
|
||||
// Confirm Enable=true before posting — translation happens at creation time,
|
||||
// so the config must be confirmed before posts are submitted.
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return (cfg as any).AutoTranslationSettings?.Enable === true;
|
||||
});
|
||||
// Verify the mock translation service is reachable before attempting to use it.
|
||||
try {
|
||||
await fetch(translationUrl, {signal: AbortSignal.timeout(3000)});
|
||||
} catch {
|
||||
test.skip(
|
||||
true,
|
||||
`Mock translation service not reachable at ${translationUrl}. ` +
|
||||
'Start the service or set TRANSLATION_SERVICE_URL to run this test.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Set Spanish source so the mock returns source='es', triggering es→en translation.
|
||||
await setMockSourceLanguage(translationUrl, 'es');
|
||||
|
||||
const poster = await pw.random.user('poster');
|
||||
const createdPoster = await adminClient.createUser(poster, '', '');
|
||||
await adminClient.addToTeam(team.id, createdPoster.id);
|
||||
await adminClient.addToChannel(createdPoster.id, created.id);
|
||||
const {client: posterClient} = await pw.makeClient({
|
||||
username: poster.username,
|
||||
password: poster.password,
|
||||
});
|
||||
if (!posterClient) throw new Error('Failed to create poster client');
|
||||
|
||||
// Create a second poster to show translation indicator (only visible with multiple users)
|
||||
const poster2 = await pw.random.user('poster2');
|
||||
const createdPoster2 = await adminClient.createUser(poster2, '', '');
|
||||
await adminClient.addToTeam(team.id, createdPoster2.id);
|
||||
await adminClient.addToChannel(createdPoster2.id, created.id);
|
||||
const {client: posterClient2} = await pw.makeClient({
|
||||
username: poster2.username,
|
||||
password: poster2.password,
|
||||
});
|
||||
if (!posterClient2) throw new Error('Failed to create second poster client');
|
||||
|
||||
// Post Spanish message that's long enough for reliable detection
|
||||
await posterClient.createPost({
|
||||
channel_id: created.id,
|
||||
message: 'Este es un texto para la modal de traducción automática que debe ser lo suficientemente largo',
|
||||
user_id: createdPoster.id,
|
||||
});
|
||||
// Second user posts a message so the first user's translation indicator appears
|
||||
await posterClient2.createPost({
|
||||
channel_id: created.id,
|
||||
message: 'Segundo usuario con mensaje más largo para mejor detección de idioma',
|
||||
user_id: createdPoster2.id,
|
||||
});
|
||||
|
||||
const {channelsPage, page} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, channelName);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// Re-apply config + reload to counter concurrent initSetup() resets.
|
||||
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return (cfg as any).AutoTranslationSettings?.Enable === true;
|
||||
});
|
||||
await channelsPage.page.reload();
|
||||
await channelsPage.toBeVisible();
|
||||
// Post-reload re-apply: a concurrent initSetup() may have reset
|
||||
// Enable during the ~500ms reload window.
|
||||
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return (cfg as any).AutoTranslationSettings?.Enable === true;
|
||||
});
|
||||
|
||||
// * Wait for post by searching for the Spanish original (mock appends "[translated to en]", no real translation)
|
||||
const modalPost = channelsPage.centerView.container
|
||||
.locator('[id^="post_"]')
|
||||
.filter({hasText: 'Este es un texto para la modal de traducción automática'});
|
||||
await expect(modalPost).toBeVisible({timeout: 15000});
|
||||
|
||||
// * Check for translation button - if it exists, click it and verify modal
|
||||
const translationButton = modalPost.getByRole('button', {name: 'This post has been translated'});
|
||||
const hasTranslationButton = (await translationButton.count()) > 0;
|
||||
|
||||
// Translation button should be present - test expects translation to happen
|
||||
if (!hasTranslationButton) {
|
||||
throw new Error(
|
||||
'Translation button not found on post. Expected autotranslation to produce a translated message indicator.',
|
||||
);
|
||||
}
|
||||
|
||||
// Translation happened - verify the modal opens
|
||||
await translationButton.click();
|
||||
const showTranslationDialog = page.getByRole('dialog').filter({hasText: 'Show Translation'});
|
||||
await expect(showTranslationDialog).toBeVisible();
|
||||
await expect(showTranslationDialog.getByText('ORIGINAL')).toBeVisible();
|
||||
await expect(showTranslationDialog.getByText('AUTO-TRANSLATED')).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'message actions include Show translation',
|
||||
{
|
||||
tag: ['@autotranslation'],
|
||||
},
|
||||
async ({pw}) => {
|
||||
const {adminClient, user, userClient, team} = await pw.initSetup();
|
||||
|
||||
const license = await adminClient.getClientLicenseOld();
|
||||
test.skip(
|
||||
!hasAutotranslationLicense(license.SkuShortName),
|
||||
'Skipping test - server does not have Entry or Advanced license',
|
||||
);
|
||||
const translationUrl = process.env.TRANSLATION_SERVICE_URL || 'http://localhost:3010';
|
||||
await enableAutotranslationConfig(adminClient, {
|
||||
mockBaseUrl: translationUrl,
|
||||
targetLanguages: ['en', 'es'],
|
||||
});
|
||||
|
||||
const channelName = `autotranslation-dotmenu-${pw.random.id()}`;
|
||||
const created = await adminClient.createChannel({
|
||||
team_id: team.id,
|
||||
name: channelName,
|
||||
display_name: 'Dot Menu Show Translation Test',
|
||||
type: 'O',
|
||||
});
|
||||
await enableChannelAutotranslation(adminClient, created.id);
|
||||
await adminClient.addToChannel(user.id, created.id);
|
||||
await setUserChannelAutotranslation(userClient, created.id, true);
|
||||
|
||||
const poster = await pw.random.user('poster');
|
||||
const createdPoster = await adminClient.createUser(poster, '', '');
|
||||
await adminClient.addToTeam(team.id, createdPoster.id);
|
||||
await adminClient.addToChannel(createdPoster.id, created.id);
|
||||
const {client: posterClient} = await pw.makeClient({
|
||||
username: poster.username,
|
||||
password: poster.password,
|
||||
});
|
||||
if (!posterClient) throw new Error('Failed to create poster client');
|
||||
|
||||
// Create a second poster to show translation indicator (only visible with multiple users)
|
||||
const poster2 = await pw.random.user('poster2');
|
||||
const createdPoster2 = await adminClient.createUser(poster2, '', '');
|
||||
await adminClient.addToTeam(team.id, createdPoster2.id);
|
||||
await adminClient.addToChannel(createdPoster2.id, created.id);
|
||||
const {client: posterClient2} = await pw.makeClient({
|
||||
username: poster2.username,
|
||||
password: poster2.password,
|
||||
});
|
||||
if (!posterClient2) throw new Error('Failed to create second poster client');
|
||||
|
||||
// Re-apply config immediately before posting so the server translates this message.
|
||||
// A concurrent initSetup() can reset AutoTranslationSettings.Enable to false.
|
||||
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
|
||||
// Confirm Enable=true before posting — translation happens at creation time,
|
||||
// so the config must be confirmed before posts are submitted.
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return (cfg as any).AutoTranslationSettings?.Enable === true;
|
||||
});
|
||||
// Verify the mock translation service is reachable before attempting to use it.
|
||||
// setMockSourceLanguage() swallows connection errors internally, so we probe the
|
||||
// service directly. If it is not running, skip rather than fail — the service is
|
||||
// an external dependency started by CI but not typically present in local runs.
|
||||
try {
|
||||
await fetch(translationUrl, {signal: AbortSignal.timeout(3000)});
|
||||
} catch {
|
||||
test.skip(
|
||||
true,
|
||||
`Mock translation service not reachable at ${translationUrl}. ` +
|
||||
'Start the service or set TRANSLATION_SERVICE_URL to run this test.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Set Spanish source so the mock returns source='es', triggering es→en translation.
|
||||
await setMockSourceLanguage(translationUrl, 'es');
|
||||
// Post Spanish message that's long enough for reliable detection.
|
||||
await posterClient.createPost({
|
||||
channel_id: created.id,
|
||||
message: 'Este mensaje es para probar el menú de acciones con la opción de mostrar traducción automática',
|
||||
user_id: createdPoster.id,
|
||||
});
|
||||
// Second post ensures the first post's translation indicator is rendered
|
||||
// (the UI only renders it for posts that are not the last in the channel).
|
||||
await posterClient2.createPost({
|
||||
channel_id: created.id,
|
||||
message: 'Segundo usuario con mensaje más largo para mejor detección de idioma',
|
||||
user_id: createdPoster2.id,
|
||||
});
|
||||
|
||||
const {channelsPage, page} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, channelName);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// Pre-reload re-apply: ensure Enable=true before the page loads so the client
|
||||
// fetches posts with translations active.
|
||||
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return (cfg as any).AutoTranslationSettings?.Enable === true;
|
||||
});
|
||||
await channelsPage.page.reload();
|
||||
await channelsPage.toBeVisible();
|
||||
// Post-reload re-apply: a concurrent initSetup() on another shard may have reset
|
||||
// Enable during the ~500ms reload window. Re-confirm before badge check.
|
||||
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return (cfg as any).AutoTranslationSettings?.Enable === true;
|
||||
});
|
||||
|
||||
// * Wait for the channel-level autotranslation badge — confirms the feature is active.
|
||||
await expect(channelsPage.centerView.autotranslationBadge).toBeVisible({timeout: 15000});
|
||||
|
||||
// * Wait for the translated target post to appear.
|
||||
// The mock appends "[translated to en]" to the original Spanish text, confirming
|
||||
// translation.state==='ready' so that "Show translation" will be in the dot menu.
|
||||
const messagePost = channelsPage.centerView.container
|
||||
.getByTestId('postView')
|
||||
.filter({hasText: 'Este mensaje es para probar el menú de acciones'});
|
||||
await expect(messagePost.getByText(/\[translated to en\]/i)).toBeVisible({timeout: 15000});
|
||||
|
||||
// * Open dot menu using the established hover → wait → click pattern
|
||||
const post = new ChannelsPost(messagePost);
|
||||
await post.hover();
|
||||
await post.postMenu.toBeVisible();
|
||||
await post.postMenu.dotMenuButton.click();
|
||||
|
||||
// Move mouse away so it doesn't hover over Remind and trigger its submenu.
|
||||
// The submenu's MUI portal sets aria-hidden on the main menu, breaking getByRole.
|
||||
await page.mouse.move(0, 0);
|
||||
await channelsPage.postDotMenu.toBeVisible();
|
||||
|
||||
// * Verify the "Show translation" menu item is present
|
||||
await expect(channelsPage.postDotMenu.showTranslationMenuItem).toBeVisible({timeout: 10000});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'any user can disable and enable again autotranslation for themselves in a channel',
|
||||
{
|
||||
tag: ['@autotranslation'],
|
||||
},
|
||||
async ({pw}) => {
|
||||
const {adminClient, user, userClient, team} = await pw.initSetup();
|
||||
|
||||
const license = await adminClient.getClientLicenseOld();
|
||||
test.skip(
|
||||
!hasAutotranslationLicense(license.SkuShortName),
|
||||
'Skipping test - server does not have Entry or Advanced license',
|
||||
);
|
||||
const translationUrl = process.env.TRANSLATION_SERVICE_URL || 'http://localhost:3010';
|
||||
await enableAutotranslationConfig(adminClient, {
|
||||
mockBaseUrl: translationUrl,
|
||||
targetLanguages: ['en', 'es'],
|
||||
});
|
||||
|
||||
const channelName = `autotranslation-toggle-${pw.random.id()}`;
|
||||
const created = await adminClient.createChannel({
|
||||
team_id: team.id,
|
||||
name: channelName,
|
||||
display_name: 'Autotranslation Toggle Test',
|
||||
type: 'O',
|
||||
});
|
||||
await enableChannelAutotranslation(adminClient, created.id);
|
||||
await adminClient.addToChannel(user.id, created.id);
|
||||
await setUserChannelAutotranslation(userClient, created.id, true);
|
||||
|
||||
const {channelsPage, page} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, channelName);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// Defeat concurrent initSetup() config resets: re-apply on every poll iteration until badge appears.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await enableAutotranslationConfig(adminClient, {
|
||||
mockBaseUrl: translationUrl,
|
||||
targetLanguages: ['en', 'es'],
|
||||
});
|
||||
return channelsPage.centerView.autotranslationBadge.isVisible();
|
||||
},
|
||||
{timeout: 45000, intervals: [2000]},
|
||||
)
|
||||
.toBeTruthy();
|
||||
|
||||
await channelsPage.centerView.header.openChannelMenu();
|
||||
await page.getByRole('menuitem', {name: 'Disable autotranslation'}).click();
|
||||
await page.getByRole('button', {name: 'Turn off auto-translation'}).click();
|
||||
|
||||
await expect(channelsPage.centerView.autotranslationBadge).not.toBeVisible();
|
||||
|
||||
// Re-apply config before opening menu to ensure "Enable autotranslation" option is present.
|
||||
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
|
||||
await channelsPage.centerView.header.openChannelMenu();
|
||||
await page.getByRole('menuitem', {name: 'Enable autotranslation'}).click();
|
||||
|
||||
// Poll with config re-apply until badge reappears.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await enableAutotranslationConfig(adminClient, {
|
||||
mockBaseUrl: translationUrl,
|
||||
targetLanguages: ['en', 'es'],
|
||||
});
|
||||
return channelsPage.centerView.autotranslationBadge.isVisible();
|
||||
},
|
||||
{timeout: 45000, intervals: [2000]},
|
||||
)
|
||||
.toBeTruthy();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'autotranslation badge is only visible on translated channels',
|
||||
{
|
||||
tag: ['@autotranslation'],
|
||||
},
|
||||
async ({pw}) => {
|
||||
const {adminClient, user, userClient, team} = await pw.initSetup();
|
||||
|
||||
const license = await adminClient.getClientLicenseOld();
|
||||
test.skip(
|
||||
!hasAutotranslationLicense(license.SkuShortName),
|
||||
'Skipping test - server does not have Entry or Advanced license',
|
||||
);
|
||||
const translationUrl = process.env.TRANSLATION_SERVICE_URL || 'http://localhost:3010';
|
||||
await enableAutotranslationConfig(adminClient, {
|
||||
mockBaseUrl: translationUrl,
|
||||
targetLanguages: ['en', 'es'],
|
||||
});
|
||||
|
||||
const translatedChannelName = `autotranslation-badge-${pw.random.id()}`;
|
||||
const translatedChannel = await adminClient.createChannel({
|
||||
team_id: team.id,
|
||||
name: translatedChannelName,
|
||||
display_name: 'Translated Channel',
|
||||
type: 'O',
|
||||
});
|
||||
await enableChannelAutotranslation(adminClient, translatedChannel.id);
|
||||
await adminClient.addToChannel(user.id, translatedChannel.id);
|
||||
await setUserChannelAutotranslation(userClient, translatedChannel.id, true);
|
||||
|
||||
const noTranslationChannelName = `no-translation-${pw.random.id()}`;
|
||||
const noTranslationChannel = await adminClient.createChannel({
|
||||
team_id: team.id,
|
||||
name: noTranslationChannelName,
|
||||
display_name: 'No Translation Channel',
|
||||
type: 'O',
|
||||
});
|
||||
await adminClient.addToChannel(user.id, noTranslationChannel.id);
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
|
||||
await channelsPage.goto(team.name, translatedChannelName);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// Re-apply config + reload to counter concurrent initSetup() resets.
|
||||
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return (cfg as any).AutoTranslationSettings?.Enable === true;
|
||||
});
|
||||
await channelsPage.page.reload();
|
||||
// Post-reload re-apply: firing the CONFIG_CHANGED WebSocket event during page load
|
||||
// rather than after prevents it from disrupting the badge render.
|
||||
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return (cfg as any).AutoTranslationSettings?.Enable === true;
|
||||
});
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
await expect(channelsPage.centerView.autotranslationBadge).toBeVisible({timeout: 15000});
|
||||
|
||||
await channelsPage.goto(team.name, noTranslationChannelName);
|
||||
await channelsPage.toBeVisible();
|
||||
await expect(channelsPage.centerView.autotranslationBadge).not.toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'unsupported language does not show channel badge and shows message in channel header menu',
|
||||
{
|
||||
tag: ['@autotranslation'],
|
||||
},
|
||||
async ({pw}) => {
|
||||
const {adminClient, user, userClient, team} = await pw.initSetup();
|
||||
|
||||
const license = await adminClient.getClientLicenseOld();
|
||||
test.skip(
|
||||
!hasAutotranslationLicense(license.SkuShortName),
|
||||
'Skipping test - server does not have Entry or Advanced license',
|
||||
);
|
||||
const translationUrl = process.env.TRANSLATION_SERVICE_URL || 'http://localhost:3010';
|
||||
await enableAutotranslationConfig(adminClient, {
|
||||
mockBaseUrl: translationUrl,
|
||||
targetLanguages: ['en', 'es'],
|
||||
});
|
||||
|
||||
const channelName = `autotranslation-unsupported-${pw.random.id()}`;
|
||||
const created = await adminClient.createChannel({
|
||||
team_id: team.id,
|
||||
name: channelName,
|
||||
display_name: 'Unsupported Language Test',
|
||||
type: 'O',
|
||||
});
|
||||
await enableChannelAutotranslation(adminClient, created.id);
|
||||
await adminClient.addToChannel(user.id, created.id);
|
||||
|
||||
await userClient.patchMe({locale: 'fr'});
|
||||
|
||||
const {channelsPage, page} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, channelName);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// Re-apply config + reload to ensure the browser reads the latest AutoTranslationSettings.
|
||||
// The badge should still be absent (French locale is not in targetLanguages), but the
|
||||
// server config must be active so the channel header menu shows the "unsupported" notice.
|
||||
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return (cfg as any).AutoTranslationSettings?.Enable === true;
|
||||
});
|
||||
await channelsPage.page.reload();
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
await expect(channelsPage.centerView.autotranslationBadge).not.toBeVisible({timeout: 30000});
|
||||
|
||||
await channelsPage.centerView.header.openChannelMenu();
|
||||
const channelMenu = page
|
||||
.getByRole('menu')
|
||||
.filter({has: page.getByRole('menuitem', {name: /Auto-translation|Channel Settings/})});
|
||||
await expect(channelMenu.getByText('Auto-translation', {exact: true})).toBeVisible({timeout: 30000});
|
||||
await expect(channelMenu.getByText('Your language is not supported')).toBeVisible({timeout: 30000});
|
||||
const autotranslationItem = page.getByRole('menuitem', {name: /Auto-translation/});
|
||||
await expect(autotranslationItem).toBeDisabled();
|
||||
},
|
||||
);
|
||||
+496
@@ -0,0 +1,496 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {
|
||||
disableAutotranslationConfig,
|
||||
enableAutotranslationConfig,
|
||||
enableChannelAutotranslation,
|
||||
getAdminClient,
|
||||
hasAutotranslationLicense,
|
||||
setUserChannelAutotranslation,
|
||||
expect,
|
||||
test,
|
||||
setMockSourceLanguage,
|
||||
} from '@mattermost/playwright-lib';
|
||||
|
||||
// Autotranslation tests involve real UI interactions with plugin state and can run
|
||||
// longer than the default 60 s in loaded CI. Set per-test timeout to 2 minutes.
|
||||
test.beforeEach(async () => {
|
||||
test.setTimeout(120000);
|
||||
});
|
||||
|
||||
// Disable AutoTranslationSettings at end of file so leftover state cannot leak
|
||||
// into other suites. Individual tests enable the feature via
|
||||
// enableAutotranslationConfig() as needed.
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
const {adminClient} = await getAdminClient({skipLog: true});
|
||||
await disableAutotranslationConfig(adminClient);
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
});
|
||||
test(
|
||||
'auto-translation is ON by default for new channel members',
|
||||
{
|
||||
tag: ['@autotranslation'],
|
||||
},
|
||||
async ({pw}) => {
|
||||
const {adminClient, user, team} = await pw.initSetup();
|
||||
|
||||
const license = await adminClient.getClientLicenseOld();
|
||||
test.skip(
|
||||
!hasAutotranslationLicense(license.SkuShortName),
|
||||
'Skipping test - server does not have Entry or Advanced license',
|
||||
);
|
||||
const translationUrl = process.env.TRANSLATION_SERVICE_URL || 'http://localhost:3010';
|
||||
await enableAutotranslationConfig(adminClient, {
|
||||
mockBaseUrl: translationUrl,
|
||||
targetLanguages: ['en', 'es'],
|
||||
});
|
||||
|
||||
const channelName = `autotranslation-default-on-${pw.random.id()}`;
|
||||
const created = await adminClient.createChannel({
|
||||
team_id: team.id,
|
||||
name: channelName,
|
||||
display_name: 'Default On Test',
|
||||
type: 'O',
|
||||
});
|
||||
await enableChannelAutotranslation(adminClient, created.id);
|
||||
|
||||
const poster = await pw.random.user('poster');
|
||||
const createdPoster = await adminClient.createUser(poster, '', '');
|
||||
await adminClient.addToTeam(team.id, createdPoster.id);
|
||||
await adminClient.addToChannel(createdPoster.id, created.id);
|
||||
const {client: posterClient} = await pw.makeClient({
|
||||
username: poster.username,
|
||||
password: poster.password,
|
||||
});
|
||||
if (!posterClient) throw new Error('Failed to create poster client');
|
||||
|
||||
// Re-apply config immediately before the post so the server translates it.
|
||||
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
|
||||
// Set Spanish source to ensure translation happens for new member
|
||||
await setMockSourceLanguage(translationUrl, 'es');
|
||||
await posterClient.createPost({
|
||||
channel_id: created.id,
|
||||
message: 'Hola para nuevo miembro',
|
||||
user_id: createdPoster.id,
|
||||
});
|
||||
|
||||
await adminClient.addToChannel(user.id, created.id);
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, channelName);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// Re-apply config + reload so the badge and translated text reflect the latest server config.
|
||||
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return (cfg as any).AutoTranslationSettings?.Enable === true;
|
||||
});
|
||||
await channelsPage.page.reload();
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// * Verify translation badge is visible (indicates autotranslation is ON by default)
|
||||
await expect(channelsPage.centerView.autotranslationBadge).toBeVisible({timeout: 15000});
|
||||
|
||||
// * Verify post appeared with translation (mock server appends "[translated to en]" to original)
|
||||
await expect(
|
||||
channelsPage.centerView.container
|
||||
.locator('[id^="post_"]')
|
||||
.getByText('Hola para nuevo miembro [translated to en]', {exact: false}),
|
||||
).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'opting out shows ephemeral message to user',
|
||||
{
|
||||
tag: ['@autotranslation'],
|
||||
},
|
||||
async ({pw}) => {
|
||||
const {adminClient, user, userClient, team} = await pw.initSetup();
|
||||
|
||||
const license = await adminClient.getClientLicenseOld();
|
||||
test.skip(
|
||||
!hasAutotranslationLicense(license.SkuShortName),
|
||||
'Skipping test - server does not have Entry or Advanced license',
|
||||
);
|
||||
const translationUrl = process.env.TRANSLATION_SERVICE_URL || 'http://localhost:3010';
|
||||
await enableAutotranslationConfig(adminClient, {
|
||||
mockBaseUrl: translationUrl,
|
||||
targetLanguages: ['en', 'es'],
|
||||
});
|
||||
|
||||
const channelName = `autotranslation-ephemeral-${pw.random.id()}`;
|
||||
const created = await adminClient.createChannel({
|
||||
team_id: team.id,
|
||||
name: channelName,
|
||||
display_name: 'Ephemeral Test',
|
||||
type: 'O',
|
||||
});
|
||||
await enableChannelAutotranslation(adminClient, created.id);
|
||||
await adminClient.addToChannel(user.id, created.id);
|
||||
await setUserChannelAutotranslation(userClient, created.id, true);
|
||||
|
||||
const {channelsPage, page} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, channelName);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// Re-apply config + reload so the badge reflects the latest AutoTranslationSettings.
|
||||
// A concurrent initSetup() → updateConfig(defaultConfig) resets Enable to false,
|
||||
// preventing the badge from appearing and the "Disable autotranslation" menu item
|
||||
// from being shown.
|
||||
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return (cfg as any).AutoTranslationSettings?.Enable === true;
|
||||
});
|
||||
await channelsPage.page.reload();
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// Wait for autotranslation state to be reflected in the header before opening the menu,
|
||||
// so that the "Disable autotranslation" menu item is present when the menu opens.
|
||||
await expect(channelsPage.centerView.autotranslationBadge).toBeVisible({timeout: 15000});
|
||||
|
||||
await channelsPage.centerView.header.openChannelMenu();
|
||||
await page.getByRole('menuitem', {name: 'Disable autotranslation'}).click();
|
||||
await page.getByRole('button', {name: 'Turn off auto-translation'}).click();
|
||||
|
||||
await expect(
|
||||
channelsPage.centerView.container.locator('p').getByText(/You disabled Auto-translation for this channel/i),
|
||||
).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'disabling for self reverts translated messages to original',
|
||||
{
|
||||
tag: ['@autotranslation'],
|
||||
},
|
||||
async ({pw}) => {
|
||||
const {adminClient, user, userClient, team} = await pw.initSetup();
|
||||
|
||||
const license = await adminClient.getClientLicenseOld();
|
||||
test.skip(
|
||||
!hasAutotranslationLicense(license.SkuShortName),
|
||||
'Skipping test - server does not have Entry or Advanced license',
|
||||
);
|
||||
const translationUrl = process.env.TRANSLATION_SERVICE_URL || 'http://localhost:3010';
|
||||
await enableAutotranslationConfig(adminClient, {
|
||||
mockBaseUrl: translationUrl,
|
||||
targetLanguages: ['en', 'es'],
|
||||
});
|
||||
|
||||
const channelName = `autotranslation-revert-${pw.random.id()}`;
|
||||
const created = await adminClient.createChannel({
|
||||
team_id: team.id,
|
||||
name: channelName,
|
||||
display_name: 'Revert Test',
|
||||
type: 'O',
|
||||
});
|
||||
await enableChannelAutotranslation(adminClient, created.id);
|
||||
await adminClient.addToChannel(user.id, created.id);
|
||||
await setUserChannelAutotranslation(userClient, created.id, true);
|
||||
|
||||
const poster = await pw.random.user('poster');
|
||||
const createdPoster = await adminClient.createUser(poster, '', '');
|
||||
await adminClient.addToTeam(team.id, createdPoster.id);
|
||||
await adminClient.addToChannel(createdPoster.id, created.id);
|
||||
const {client: posterClient} = await pw.makeClient({
|
||||
username: poster.username,
|
||||
password: poster.password,
|
||||
});
|
||||
if (!posterClient) throw new Error('Failed to create poster client');
|
||||
|
||||
const originalText = 'Solo texto original';
|
||||
// Re-apply config immediately before posting so the server translates this message.
|
||||
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
|
||||
// Set Spanish to ensure translation
|
||||
await setMockSourceLanguage(translationUrl, 'es');
|
||||
await posterClient.createPost({
|
||||
channel_id: created.id,
|
||||
message: originalText,
|
||||
user_id: createdPoster.id,
|
||||
});
|
||||
|
||||
const {channelsPage, page} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, channelName);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// Re-apply config + reload so the browser reads the latest AutoTranslationSettings.
|
||||
// A concurrent initSetup() on another shard may have disabled autotranslation between
|
||||
// the initial enableAutotranslationConfig call (before posting) and login.
|
||||
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return (cfg as any).AutoTranslationSettings?.Enable === true;
|
||||
});
|
||||
await channelsPage.page.reload();
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// * Verify post with translated text appears before disabling.
|
||||
// Mock server appends "[translated to en]" to the original text. Translation
|
||||
// is asynchronous and can lag several seconds in CI; use expect.poll to retry
|
||||
// reliably rather than a fixed 15 s one-shot timeout.
|
||||
const translatedText = 'Solo texto original [translated to en]';
|
||||
const spanishPost = channelsPage.centerView.container
|
||||
.locator('[id^="post_"]')
|
||||
.filter({hasText: translatedText});
|
||||
await expect
|
||||
.poll(async () => spanishPost.isVisible(), {
|
||||
timeout: 60000,
|
||||
intervals: [500, 1500, 3000, 5000],
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
await channelsPage.centerView.header.openChannelMenu();
|
||||
await page.getByRole('menuitem', {name: 'Disable autotranslation'}).click();
|
||||
await page.getByRole('button', {name: 'Turn off auto-translation'}).click();
|
||||
|
||||
// * After disabling, wait for page to update and verify original text is shown
|
||||
// Find the post containing the original text (skip system messages)
|
||||
const userPost = channelsPage.centerView.container
|
||||
.locator('[id^="post_"]')
|
||||
.filter({has: page.locator('.post__body').filter({hasText: originalText})});
|
||||
await expect(userPost).toBeVisible({timeout: 15000});
|
||||
await expect(userPost).toContainText(originalText);
|
||||
|
||||
// * Verify translation indicator is no longer present
|
||||
await expect(userPost.getByRole('button', {name: 'This post has been translated'})).not.toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'messages only translate when source differs from user language',
|
||||
{
|
||||
tag: ['@autotranslation'],
|
||||
},
|
||||
async ({pw}) => {
|
||||
const {adminClient, user, userClient, team} = await pw.initSetup();
|
||||
|
||||
const license = await adminClient.getClientLicenseOld();
|
||||
test.skip(
|
||||
!hasAutotranslationLicense(license.SkuShortName),
|
||||
'Skipping test - server does not have Entry or Advanced license',
|
||||
);
|
||||
const translationUrl = process.env.TRANSLATION_SERVICE_URL || 'http://localhost:3010';
|
||||
await enableAutotranslationConfig(adminClient, {
|
||||
mockBaseUrl: translationUrl,
|
||||
targetLanguages: ['en', 'es'],
|
||||
});
|
||||
|
||||
const channelName = `autotranslation-lang-${pw.random.id()}`;
|
||||
const created = await adminClient.createChannel({
|
||||
team_id: team.id,
|
||||
name: channelName,
|
||||
display_name: 'Language Rules Test',
|
||||
type: 'O',
|
||||
});
|
||||
await enableChannelAutotranslation(adminClient, created.id);
|
||||
await adminClient.addToChannel(user.id, created.id);
|
||||
await setUserChannelAutotranslation(userClient, created.id, true);
|
||||
|
||||
const poster = await pw.random.user('poster');
|
||||
const createdPoster = await adminClient.createUser(poster, '', '');
|
||||
await adminClient.addToTeam(team.id, createdPoster.id);
|
||||
await adminClient.addToChannel(createdPoster.id, created.id);
|
||||
const {client: posterClient} = await pw.makeClient({
|
||||
username: poster.username,
|
||||
password: poster.password,
|
||||
});
|
||||
if (!posterClient) throw new Error('Failed to create poster client');
|
||||
|
||||
// Create a second poster to test translation indicators (only show with multiple users)
|
||||
const poster2 = await pw.random.user('poster2');
|
||||
const createdPoster2 = await adminClient.createUser(poster2, '', '');
|
||||
await adminClient.addToTeam(team.id, createdPoster2.id);
|
||||
await adminClient.addToChannel(createdPoster2.id, created.id);
|
||||
const {client: posterClient2} = await pw.makeClient({
|
||||
username: poster2.username,
|
||||
password: poster2.password,
|
||||
});
|
||||
if (!posterClient2) throw new Error('Failed to create second poster client');
|
||||
|
||||
// Re-apply config immediately before posting so the server translates these messages.
|
||||
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
|
||||
// Set source language for mock/real server before creating posts
|
||||
// For mock: controls which language is detected; for real: auto-detection is used
|
||||
// Post Spanish first so it gets the translation indicator (first message from posterClient)
|
||||
await setMockSourceLanguage(translationUrl, 'es');
|
||||
await posterClient.createPost({
|
||||
channel_id: created.id,
|
||||
message: 'Solo español',
|
||||
user_id: createdPoster.id,
|
||||
});
|
||||
// English message won't be translated
|
||||
await setMockSourceLanguage(translationUrl, 'en');
|
||||
await posterClient.createPost({
|
||||
channel_id: created.id,
|
||||
message: 'English only',
|
||||
user_id: createdPoster.id,
|
||||
});
|
||||
// Second user posts translated message (translation indicators only show with multiple users)
|
||||
await posterClient2.createPost({
|
||||
channel_id: created.id,
|
||||
message: 'Hola desde segundo usuario',
|
||||
user_id: createdPoster2.id,
|
||||
});
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, channelName);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// * Verify both posts appear
|
||||
// Mock server produces "<original> [translated to en]", not real translations.
|
||||
// Translation is async — use expect.poll to ride out mock-service latency in CI.
|
||||
await expect(channelsPage.centerView.container.locator('[id^="post_"]').getByText('English only')).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
const translatedSpanishLocator = channelsPage.centerView.container
|
||||
.locator('[id^="post_"]')
|
||||
.getByText('Solo español [translated to en]', {exact: false});
|
||||
await expect
|
||||
.poll(async () => translatedSpanishLocator.isVisible(), {
|
||||
timeout: 45000,
|
||||
intervals: [500, 1500, 3000, 5000],
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
// * Verify both messages are present
|
||||
const spanishPost = channelsPage.centerView.container
|
||||
.locator('[id^="post_"]')
|
||||
.filter({hasText: 'Solo español [translated to en]'});
|
||||
await expect(spanishPost).toBeVisible({timeout: 30000});
|
||||
|
||||
// * Verify English message is present and unchanged
|
||||
const englishPost = channelsPage.centerView.container
|
||||
.locator('[id^="post_"]')
|
||||
.filter({hasText: 'English only'});
|
||||
await expect(englishPost).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'message indicator only on actually translated message',
|
||||
{
|
||||
tag: ['@autotranslation'],
|
||||
},
|
||||
async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
const {adminClient, user, userClient, team} = await pw.initSetup();
|
||||
|
||||
const license = await adminClient.getClientLicenseOld();
|
||||
test.skip(
|
||||
!hasAutotranslationLicense(license.SkuShortName),
|
||||
'Skipping test - server does not have Entry or Advanced license',
|
||||
);
|
||||
const translationUrl = process.env.TRANSLATION_SERVICE_URL || 'http://localhost:3010';
|
||||
await enableAutotranslationConfig(adminClient, {
|
||||
mockBaseUrl: translationUrl,
|
||||
targetLanguages: ['en', 'es'],
|
||||
});
|
||||
|
||||
const channelName = `autotranslation-indicator-${pw.random.id()}`;
|
||||
const created = await adminClient.createChannel({
|
||||
team_id: team.id,
|
||||
name: channelName,
|
||||
display_name: 'Indicator Test',
|
||||
type: 'O',
|
||||
});
|
||||
await enableChannelAutotranslation(adminClient, created.id);
|
||||
await adminClient.addToChannel(user.id, created.id);
|
||||
await setUserChannelAutotranslation(userClient, created.id, true);
|
||||
|
||||
const poster = await pw.random.user('poster');
|
||||
const createdPoster = await adminClient.createUser(poster, '', '');
|
||||
await adminClient.addToTeam(team.id, createdPoster.id);
|
||||
await adminClient.addToChannel(createdPoster.id, created.id);
|
||||
const {client: posterClient} = await pw.makeClient({
|
||||
username: poster.username,
|
||||
password: poster.password,
|
||||
});
|
||||
if (!posterClient) throw new Error('Failed to create poster client');
|
||||
|
||||
// Create a second poster to test translation indicators (only show with multiple users)
|
||||
const poster2 = await pw.random.user('poster2');
|
||||
const createdPoster2 = await adminClient.createUser(poster2, '', '');
|
||||
await adminClient.addToTeam(team.id, createdPoster2.id);
|
||||
await adminClient.addToChannel(createdPoster2.id, created.id);
|
||||
const {client: posterClient2} = await pw.makeClient({
|
||||
username: poster2.username,
|
||||
password: poster2.password,
|
||||
});
|
||||
if (!posterClient2) throw new Error('Failed to create second poster client');
|
||||
|
||||
// Re-apply config immediately before posting so the server translates these messages.
|
||||
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
|
||||
|
||||
// Post the English-only message FIRST so the mock's source-language state is reset
|
||||
// before any setMockSourceLanguage calls. The server processes translation
|
||||
// asynchronously: if setMockSourceLanguage('en') is called right after a post the
|
||||
// mock's detected language can be overridden before the server's detect request
|
||||
// arrives, silently preventing translation of the Spanish message.
|
||||
// By posting 'English only' first (no source override — mock auto-detects English
|
||||
// and skips translation because source == target), we then safely set 'es' and
|
||||
// post the Spanish message without any subsequent race-prone source switch.
|
||||
await posterClient.createPost({
|
||||
channel_id: created.id,
|
||||
message: 'English only',
|
||||
user_id: createdPoster.id,
|
||||
});
|
||||
|
||||
// Set Spanish source so the mock translates the next post.
|
||||
await setMockSourceLanguage(translationUrl, 'es');
|
||||
await posterClient.createPost({
|
||||
channel_id: created.id,
|
||||
message: 'Solo español',
|
||||
user_id: createdPoster.id,
|
||||
});
|
||||
// Second user posts translated message (translation indicators only show with multiple users)
|
||||
await posterClient2.createPost({
|
||||
channel_id: created.id,
|
||||
message: 'Otro mensaje en español',
|
||||
user_id: createdPoster2.id,
|
||||
});
|
||||
|
||||
await enableAutotranslationConfig(adminClient, {
|
||||
mockBaseUrl: translationUrl,
|
||||
targetLanguages: ['en', 'es'],
|
||||
});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return (cfg as any).AutoTranslationSettings?.Enable === true;
|
||||
});
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, channelName);
|
||||
await channelsPage.toBeVisible();
|
||||
await channelsPage.centerView.container.waitFor({state: 'visible', timeout: 30000});
|
||||
|
||||
// * Verify translated Spanish post is present
|
||||
// Mock server produces "<original> [translated to en]"
|
||||
const translatedPost = channelsPage.centerView.container
|
||||
.locator('[id^="post_"]')
|
||||
.filter({hasText: 'Solo español [translated to en]'});
|
||||
await expect
|
||||
.poll(async () => translatedPost.isVisible(), {
|
||||
timeout: 90000,
|
||||
intervals: [500, 1500, 3000, 5000],
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
// * Verify the English post is present and unchanged (not translated)
|
||||
const notTranslatedPost = channelsPage.centerView.container
|
||||
.locator('[id^="post_"]')
|
||||
.filter({hasText: 'English only'})
|
||||
.filter({hasNotText: '[translated to en]'});
|
||||
await expect
|
||||
.poll(async () => notTranslatedPost.isVisible(), {
|
||||
timeout: 60000,
|
||||
intervals: [500, 1500, 3000],
|
||||
})
|
||||
.toBe(true);
|
||||
},
|
||||
);
|
||||
+16
-14
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Disposable, Page} from '@playwright/test';
|
||||
import type {Page, Route} from '@playwright/test';
|
||||
|
||||
interface MockTranslateRequest {
|
||||
q?: string;
|
||||
@@ -138,15 +138,14 @@ export async function mockAutotranslationRoute(
|
||||
sourceLanguage?: string;
|
||||
supportedLanguages?: string[];
|
||||
},
|
||||
): Promise<Disposable> {
|
||||
): Promise<{dispose(): Promise<void>; [Symbol.asyncDispose](): Promise<void>}> {
|
||||
// Reset mockSourceLanguage to avoid state leakage between tests
|
||||
mockSourceLanguage = options?.sourceLanguage || 'es';
|
||||
|
||||
const supportedLanguages = options?.supportedLanguages || ['en', 'es', 'fr', 'de'];
|
||||
|
||||
// Mock LibreTranslate API endpoint
|
||||
// Handles both /translate and /detect endpoints
|
||||
const translateRoute = await page.route('**/api/translate', async (route) => {
|
||||
// Named handler so it can be passed to page.unroute() for cleanup
|
||||
const translateHandler = async (route: Route): Promise<void> => {
|
||||
const request = route.request();
|
||||
const method = request.method();
|
||||
|
||||
@@ -258,13 +257,12 @@ export async function mockAutotranslationRoute(
|
||||
} else {
|
||||
await route.abort('failed');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Mock language detection endpoint (if used separately)
|
||||
const detectRoute = await page.route('**/api/detect', async (route) => {
|
||||
// Named handler so it can be passed to page.unroute() for cleanup
|
||||
const detectHandler = async (route: Route): Promise<void> => {
|
||||
// Language detection is mocked to always return the configured source language
|
||||
// regardless of the input text
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
@@ -278,16 +276,20 @@ export async function mockAutotranslationRoute(
|
||||
detectedLanguage: {language: mockSourceLanguage, confidence: 0.95},
|
||||
}),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Mock LibreTranslate API endpoints
|
||||
await page.route('**/api/translate', translateHandler);
|
||||
await page.route('**/api/detect', detectHandler);
|
||||
|
||||
return {
|
||||
async dispose() {
|
||||
await translateRoute.dispose();
|
||||
await detectRoute.dispose();
|
||||
await page.unroute('**/api/translate', translateHandler);
|
||||
await page.unroute('**/api/detect', detectHandler);
|
||||
},
|
||||
async [Symbol.asyncDispose]() {
|
||||
await translateRoute.dispose();
|
||||
await detectRoute.dispose();
|
||||
await page.unroute('**/api/translate', translateHandler);
|
||||
await page.unroute('**/api/detect', detectHandler);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -227,7 +227,13 @@ test.describe('Burn-on-Read Receiver Flow', () => {
|
||||
adminClient,
|
||||
} = await setupBorTest(pw, {
|
||||
durationSeconds: 10,
|
||||
maxTTLSeconds: 300,
|
||||
maxTTLSeconds: 86400,
|
||||
});
|
||||
|
||||
// # Verify the config was applied before proceeding (guard against state pollution)
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.ServiceSettings.BurnOnReadDurationSeconds === 10;
|
||||
});
|
||||
|
||||
// # Create receiver
|
||||
@@ -240,6 +246,20 @@ test.describe('Burn-on-Read Receiver Flow', () => {
|
||||
const {channelsPage: senderPage} = await pw.testBrowser.login(sender);
|
||||
await senderPage.goto(team.name, `@${receiver.username}`);
|
||||
await senderPage.toBeVisible();
|
||||
// Re-apply guard: concurrent initSetup() may reset BurnOnReadDurationSeconds to 60
|
||||
// (default) after the pw.waitUntil check above but before the message is posted.
|
||||
await adminClient.patchConfig({
|
||||
ServiceSettings: {
|
||||
EnableBurnOnRead: true,
|
||||
BurnOnReadDurationSeconds: 10,
|
||||
BurnOnReadMaximumTimeToLiveSeconds: 86400,
|
||||
},
|
||||
});
|
||||
// Confirm the re-apply actually took effect before the post is created.
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.ServiceSettings.BurnOnReadDurationSeconds === 10;
|
||||
});
|
||||
await senderPage.centerView.postCreate.toggleBurnOnRead();
|
||||
const message = `Auto-delete test ${pw.random.id()}`;
|
||||
await senderPage.postMessage(message);
|
||||
@@ -253,6 +273,21 @@ test.describe('Burn-on-Read Receiver Flow', () => {
|
||||
const borPost = await receiverPage.getLastPost();
|
||||
const postId = await borPost.getId();
|
||||
|
||||
// Re-apply guard: TTL is assigned by the server at reveal time, not post time.
|
||||
// A concurrent initSetup() may have reset BurnOnReadDurationSeconds to its
|
||||
// default (60 s) between the sender's post and the receiver's reveal click.
|
||||
// Re-applying here ensures the server uses 10 s when it writes the TTL.
|
||||
await adminClient.patchConfig({
|
||||
ServiceSettings: {
|
||||
EnableBurnOnRead: true,
|
||||
BurnOnReadDurationSeconds: 10,
|
||||
BurnOnReadMaximumTimeToLiveSeconds: 86400,
|
||||
},
|
||||
});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.ServiceSettings.BurnOnReadDurationSeconds === 10;
|
||||
});
|
||||
await borPost.concealedPlaceholder.clickToReveal();
|
||||
await borPost.concealedPlaceholder.waitForReveal();
|
||||
|
||||
@@ -266,7 +301,7 @@ test.describe('Burn-on-Read Receiver Flow', () => {
|
||||
const postLocator = receiverPage.page.locator(`[id="post_${postId}"]`);
|
||||
await expect(postLocator).not.toBeVisible();
|
||||
}).toPass({
|
||||
timeout: 20000,
|
||||
timeout: 30000,
|
||||
intervals: [1000],
|
||||
});
|
||||
|
||||
|
||||
+95
-31
@@ -1,11 +1,15 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
import {expect, getRandomId, test} from '@mattermost/playwright-lib';
|
||||
|
||||
async function skipIfNoEnterpriseLicense(adminClient: any) {
|
||||
const license = await adminClient.getClientLicenseOld();
|
||||
test.skip(license.IsLicensed !== 'true', 'Skipping test - server does not have an enterprise license');
|
||||
const enterpriseSkus = ['enterprise', 'advanced', 'entry'];
|
||||
test.skip(
|
||||
license.IsLicensed !== 'true' || !enterpriseSkus.includes(license.SkuShortName),
|
||||
'Skipping test - server does not have an enterprise license',
|
||||
);
|
||||
}
|
||||
|
||||
async function enableManagedCategories(adminClient: any) {
|
||||
@@ -24,6 +28,32 @@ async function disableManagedCategories(adminClient: any) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a uniquely-named team and user per test and adds the user to the team.
|
||||
*/
|
||||
async function setupManagedCategoriesTest(pw: any) {
|
||||
const {adminClient, adminUser} = await pw.getAdminClient();
|
||||
const suffix = getRandomId();
|
||||
|
||||
// Guard against UseAnonymousURLs=true left by anonymous_urls tests running on the same
|
||||
// server shard. When active, newly created channels receive obfuscated slugs instead of
|
||||
// human-readable names, breaking sidebar-item selectors (e.g. #sidebarItem_managed-assign-…).
|
||||
await adminClient.patchConfig({PrivacySettings: {UseAnonymousURLs: false}});
|
||||
|
||||
const team = await adminClient.createTeam({
|
||||
name: `mgd-${suffix}`,
|
||||
display_name: `Managed ${suffix}`,
|
||||
type: 'O',
|
||||
});
|
||||
const user = await pw.createNewUserProfile(adminClient, {
|
||||
prefix: 'mgd-user',
|
||||
disableTutorial: true,
|
||||
disableOnboarding: true,
|
||||
});
|
||||
await adminClient.addToTeam(team.id, user.id);
|
||||
return {adminClient, adminUser, team, user};
|
||||
}
|
||||
|
||||
async function createChannelWithManagedCategory(
|
||||
adminClient: any,
|
||||
teamId: string,
|
||||
@@ -52,7 +82,7 @@ test.describe('Managed Channel Categories', () => {
|
||||
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
|
||||
|
||||
// # Initialize setup with admin user and enterprise license
|
||||
const {adminUser, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false});
|
||||
const {adminUser, adminClient, team} = await setupManagedCategoriesTest(pw);
|
||||
await skipIfNoEnterpriseLicense(adminClient);
|
||||
await enableManagedCategories(adminClient);
|
||||
await adminClient.addToTeam(team.id, adminUser.id);
|
||||
@@ -109,7 +139,7 @@ test.describe('Managed Channel Categories', () => {
|
||||
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
|
||||
|
||||
// # Initialize setup and create a channel with a managed category
|
||||
const {adminUser, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false});
|
||||
const {adminUser, adminClient, team} = await setupManagedCategoriesTest(pw);
|
||||
await skipIfNoEnterpriseLicense(adminClient);
|
||||
await enableManagedCategories(adminClient);
|
||||
await adminClient.addToTeam(team.id, adminUser.id);
|
||||
@@ -122,8 +152,18 @@ test.describe('Managed Channel Categories', () => {
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// * Verify the managed category is visible in the sidebar
|
||||
// * Verify the managed category is visible in the sidebar.
|
||||
// Use waitUntil because fetchManagedCategories is async (two API calls:
|
||||
// getPropertyFields then getManagedCategories) and may take a moment.
|
||||
const sidebar = channelsPage.sidebarLeft.container;
|
||||
await pw.waitUntil(
|
||||
async () =>
|
||||
sidebar
|
||||
.getByText('Removable')
|
||||
.isVisible()
|
||||
.catch(() => false),
|
||||
{timeout: 15000},
|
||||
);
|
||||
await expect(sidebar.getByText('Removable')).toBeVisible();
|
||||
|
||||
// # Open channel settings and click the clear button to remove the category
|
||||
@@ -161,7 +201,7 @@ test.describe('Managed Channel Categories', () => {
|
||||
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', false);
|
||||
|
||||
// # Initialize setup and disable managed categories
|
||||
const {adminUser, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false});
|
||||
const {adminUser, adminClient, team} = await setupManagedCategoriesTest(pw);
|
||||
await skipIfNoEnterpriseLicense(adminClient);
|
||||
await disableManagedCategories(adminClient);
|
||||
await adminClient.addToTeam(team.id, adminUser.id);
|
||||
@@ -189,7 +229,7 @@ test.describe('Managed Channel Categories', () => {
|
||||
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
|
||||
|
||||
// # Initialize setup and enable managed categories
|
||||
const {adminUser, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false});
|
||||
const {adminUser, adminClient, team} = await setupManagedCategoriesTest(pw);
|
||||
await skipIfNoEnterpriseLicense(adminClient);
|
||||
await enableManagedCategories(adminClient);
|
||||
await adminClient.addToTeam(team.id, adminUser.id);
|
||||
@@ -235,7 +275,7 @@ test.describe('Managed Channel Categories', () => {
|
||||
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
|
||||
|
||||
// # Initialize setup and create a channel with a managed category
|
||||
const {adminUser, adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false});
|
||||
const {adminUser, adminClient, team, user} = await setupManagedCategoriesTest(pw);
|
||||
await skipIfNoEnterpriseLicense(adminClient);
|
||||
await enableManagedCategories(adminClient);
|
||||
await adminClient.addToTeam(team.id, adminUser.id);
|
||||
@@ -251,6 +291,7 @@ test.describe('Managed Channel Categories', () => {
|
||||
// * Verify the managed category is visible and positioned above CHANNELS
|
||||
const sidebar = channelsPage.sidebarLeft.container;
|
||||
const managedCategory = sidebar.getByText('Alpha Priority');
|
||||
await pw.waitUntil(async () => managedCategory.isVisible().catch(() => false), {timeout: 15000});
|
||||
await expect(managedCategory).toBeVisible();
|
||||
|
||||
const channelsHeader = sidebar.getByText('CHANNELS', {exact: true});
|
||||
@@ -274,7 +315,7 @@ test.describe('Managed Channel Categories', () => {
|
||||
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
|
||||
|
||||
// # Initialize setup and create a channel with a managed category (without adding the user)
|
||||
const {adminUser, adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false});
|
||||
const {adminUser, adminClient, team, user} = await setupManagedCategoriesTest(pw);
|
||||
await skipIfNoEnterpriseLicense(adminClient);
|
||||
await enableManagedCategories(adminClient);
|
||||
await adminClient.addToTeam(team.id, adminUser.id);
|
||||
@@ -299,7 +340,7 @@ test.describe('Managed Channel Categories', () => {
|
||||
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
|
||||
|
||||
// # Initialize setup and create two channels with the same managed category
|
||||
const {adminUser, adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false});
|
||||
const {adminUser, adminClient, team, user} = await setupManagedCategoriesTest(pw);
|
||||
await skipIfNoEnterpriseLicense(adminClient);
|
||||
await enableManagedCategories(adminClient);
|
||||
await adminClient.addToTeam(team.id, adminUser.id);
|
||||
@@ -319,8 +360,8 @@ test.describe('Managed Channel Categories', () => {
|
||||
});
|
||||
|
||||
// # Assign both to the same managed category and add user
|
||||
await adminClient.patchChannel(channelB.id, {managed_category_name: 'Sorted Category'});
|
||||
await adminClient.patchChannel(channelA.id, {managed_category_name: 'Sorted Category'});
|
||||
await adminClient.patchChannel(channelB.id, {managed_category_name: 'Sorted Category'} as any);
|
||||
await adminClient.patchChannel(channelA.id, {managed_category_name: 'Sorted Category'} as any);
|
||||
|
||||
await adminClient.addToChannel(user.id, channelA.id);
|
||||
await adminClient.addToChannel(user.id, channelB.id);
|
||||
@@ -355,7 +396,7 @@ test.describe('Managed Channel Categories', () => {
|
||||
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
|
||||
|
||||
// # Initialize setup and create a channel with a managed category
|
||||
const {adminUser, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false});
|
||||
const {adminUser, adminClient, team} = await setupManagedCategoriesTest(pw);
|
||||
await skipIfNoEnterpriseLicense(adminClient);
|
||||
await enableManagedCategories(adminClient);
|
||||
await adminClient.addToTeam(team.id, adminUser.id);
|
||||
@@ -368,6 +409,16 @@ test.describe('Managed Channel Categories', () => {
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const sidebar = channelsPage.sidebarLeft.container;
|
||||
await pw.waitUntil(
|
||||
async () =>
|
||||
sidebar
|
||||
.getByText('No Favorites')
|
||||
.isVisible()
|
||||
.catch(() => false),
|
||||
{timeout: 15000},
|
||||
);
|
||||
|
||||
// * Verify the favorite button is visible but disabled
|
||||
const favoriteButton = channelsPage.page.locator('#toggleFavorite');
|
||||
await expect(favoriteButton).toBeVisible();
|
||||
@@ -381,7 +432,7 @@ test.describe('Managed Channel Categories', () => {
|
||||
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
|
||||
|
||||
// # Initialize setup and create a channel with a managed category
|
||||
const {adminUser, adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false});
|
||||
const {adminUser, adminClient, team, user} = await setupManagedCategoriesTest(pw);
|
||||
await skipIfNoEnterpriseLicense(adminClient);
|
||||
await enableManagedCategories(adminClient);
|
||||
await adminClient.addToTeam(team.id, adminUser.id);
|
||||
@@ -397,6 +448,7 @@ test.describe('Managed Channel Categories', () => {
|
||||
// # Right-click on the managed category header
|
||||
const sidebar = channelsPage.sidebarLeft.container;
|
||||
const categoryHeader = sidebar.getByText('No Menu');
|
||||
await pw.waitUntil(async () => categoryHeader.isVisible().catch(() => false), {timeout: 15000});
|
||||
await expect(categoryHeader).toBeVisible();
|
||||
|
||||
await categoryHeader.click({button: 'right'});
|
||||
@@ -417,7 +469,7 @@ test.describe('Managed Channel Categories', () => {
|
||||
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
|
||||
|
||||
// # Initialize setup and create a channel with a managed category
|
||||
const {adminUser, adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false});
|
||||
const {adminUser, adminClient, team, user} = await setupManagedCategoriesTest(pw);
|
||||
await skipIfNoEnterpriseLicense(adminClient);
|
||||
await enableManagedCategories(adminClient);
|
||||
await adminClient.addToTeam(team.id, adminUser.id);
|
||||
@@ -435,6 +487,17 @@ test.describe('Managed Channel Categories', () => {
|
||||
const channelItem = sidebar.locator(`#sidebarItem_${channel.name}`);
|
||||
await expect(channelItem).toBeVisible();
|
||||
|
||||
// Wait for managed-category data to load before opening the menu so
|
||||
// that isInManagedCategory is already true when the menu renders.
|
||||
await pw.waitUntil(
|
||||
async () =>
|
||||
sidebar
|
||||
.getByText('Context Menu')
|
||||
.isVisible()
|
||||
.catch(() => false),
|
||||
{timeout: 15000},
|
||||
);
|
||||
|
||||
await channelItem.hover();
|
||||
const menuButton = channelItem.getByRole('button', {name: /Channel options/});
|
||||
await menuButton.click();
|
||||
@@ -442,11 +505,7 @@ test.describe('Managed Channel Categories', () => {
|
||||
// * Verify the Favorite menu item is visible but disabled
|
||||
const favoriteMenuItem = channelsPage.page.getByRole('menuitem', {name: /Favorite/i});
|
||||
await expect(favoriteMenuItem).toBeVisible();
|
||||
|
||||
const isDisabled = await favoriteMenuItem.evaluate((el) => {
|
||||
return el.classList.contains('Mui-disabled') || el.getAttribute('aria-disabled') === 'true';
|
||||
});
|
||||
expect(isDisabled).toBe(true);
|
||||
await expect(favoriteMenuItem).toHaveAttribute('aria-disabled', 'true');
|
||||
},
|
||||
);
|
||||
|
||||
@@ -460,7 +519,7 @@ test.describe('Managed Channel Categories', () => {
|
||||
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
|
||||
|
||||
// # Initialize setup and create a channel with a managed category
|
||||
const {adminUser, adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false});
|
||||
const {adminUser, adminClient, team, user} = await setupManagedCategoriesTest(pw);
|
||||
await skipIfNoEnterpriseLicense(adminClient);
|
||||
await enableManagedCategories(adminClient);
|
||||
await adminClient.addToTeam(team.id, adminUser.id);
|
||||
@@ -478,6 +537,15 @@ test.describe('Managed Channel Categories', () => {
|
||||
const channelItem = sidebar.locator(`#sidebarItem_${channel.name}`);
|
||||
await expect(channelItem).toBeVisible();
|
||||
|
||||
await pw.waitUntil(
|
||||
async () =>
|
||||
sidebar
|
||||
.getByText('No Move')
|
||||
.isVisible()
|
||||
.catch(() => false),
|
||||
{timeout: 15000},
|
||||
);
|
||||
|
||||
await channelItem.hover();
|
||||
const menuButton = channelItem.getByRole('button', {name: /Channel options/});
|
||||
await menuButton.click();
|
||||
@@ -485,11 +553,7 @@ test.describe('Managed Channel Categories', () => {
|
||||
// * Verify the Move To menu item is visible but disabled
|
||||
const moveToMenuItem = channelsPage.page.getByRole('menuitem', {name: /Move to/i});
|
||||
await expect(moveToMenuItem).toBeVisible();
|
||||
|
||||
const isDisabled = await moveToMenuItem.evaluate((el) => {
|
||||
return el.classList.contains('Mui-disabled') || el.getAttribute('aria-disabled') === 'true';
|
||||
});
|
||||
expect(isDisabled).toBe(true);
|
||||
await expect(moveToMenuItem).toHaveAttribute('aria-disabled', 'true');
|
||||
},
|
||||
);
|
||||
|
||||
@@ -504,7 +568,7 @@ test.describe('Managed Channel Categories', () => {
|
||||
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
|
||||
|
||||
// # Initialize setup and create two channels with the same managed category
|
||||
const {adminUser, adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false});
|
||||
const {adminUser, adminClient, team, user} = await setupManagedCategoriesTest(pw);
|
||||
await skipIfNoEnterpriseLicense(adminClient);
|
||||
await enableManagedCategories(adminClient);
|
||||
await adminClient.addToTeam(team.id, adminUser.id);
|
||||
@@ -553,7 +617,7 @@ test.describe('Managed Channel Categories', () => {
|
||||
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
|
||||
|
||||
// # Initialize setup and create a channel without a managed category
|
||||
const {adminUser, adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false});
|
||||
const {adminUser, adminClient, team, user} = await setupManagedCategoriesTest(pw);
|
||||
await skipIfNoEnterpriseLicense(adminClient);
|
||||
await enableManagedCategories(adminClient);
|
||||
await adminClient.addToTeam(team.id, adminUser.id);
|
||||
@@ -576,7 +640,7 @@ test.describe('Managed Channel Categories', () => {
|
||||
await expect(sidebar.getByText('Realtime Ops')).not.toBeVisible();
|
||||
|
||||
// # Admin assigns a managed category to the channel via API
|
||||
await adminClient.patchChannel(channel.id, {managed_category_name: 'Realtime Ops'});
|
||||
await adminClient.patchChannel(channel.id, {managed_category_name: 'Realtime Ops'} as any);
|
||||
|
||||
// * Verify the managed category appears in real-time
|
||||
await pw.waitUntil(
|
||||
@@ -605,7 +669,7 @@ test.describe('Managed Channel Categories', () => {
|
||||
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
|
||||
|
||||
// # Initialize setup
|
||||
const {adminUser, adminClient} = await pw.initSetup({withDefaultProfileImage: false});
|
||||
const {adminUser, adminClient} = await setupManagedCategoriesTest(pw);
|
||||
await skipIfNoEnterpriseLicense(adminClient);
|
||||
|
||||
// # Log in and navigate to the System Console
|
||||
@@ -635,7 +699,7 @@ test.describe('Managed Channel Categories', () => {
|
||||
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
|
||||
|
||||
// # Initialize setup and create a channel with a managed category
|
||||
const {adminUser, adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false});
|
||||
const {adminUser, adminClient, team, user} = await setupManagedCategoriesTest(pw);
|
||||
await skipIfNoEnterpriseLicense(adminClient);
|
||||
await enableManagedCategories(adminClient);
|
||||
await adminClient.addToTeam(team.id, adminUser.id);
|
||||
|
||||
+20
-4
@@ -51,7 +51,14 @@ test.describe('Channel Settings Modal - Access Control Tab', () => {
|
||||
test('MM-67326_c2 Access Control tab hidden when ABAC disabled', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
// ABAC NOT enabled
|
||||
|
||||
// Explicitly disable ABAC. initSetup() resets to the default config which has
|
||||
// EnableAttributeBasedAccessControl:true (required by the ABAC test suite baseline),
|
||||
// so we must patch it off. Concurrent tests in other files also call enableABACConfig()
|
||||
// and may race to re-enable it before this modal opens.
|
||||
await adminClient.patchConfig({
|
||||
AccessControlSettings: {EnableAttributeBasedAccessControl: false},
|
||||
});
|
||||
|
||||
const channel = await createPrivateChannel(adminClient, team.id);
|
||||
|
||||
@@ -60,6 +67,10 @@ test.describe('Channel Settings Modal - Access Control Tab', () => {
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// Disable ABAC once more right before the modal opens to shrink the race window.
|
||||
await adminClient.patchConfig({
|
||||
AccessControlSettings: {EnableAttributeBasedAccessControl: false},
|
||||
});
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
|
||||
// * Access Control tab is NOT visible
|
||||
@@ -202,7 +213,12 @@ test.describe('Channel Settings Modal - Access Control Tab', () => {
|
||||
// * SaveChangesPanel disappears — rules were saved
|
||||
await expect(saveBtn).not.toBeVisible({timeout: 15000});
|
||||
|
||||
await channelSettings.close();
|
||||
// The dialog may auto-close after save or the Close button may take a moment to stabilise
|
||||
// after the panel removal re-render. Only close if the dialog is still open.
|
||||
const isOpen = await channelSettings.container.isVisible({timeout: 2000}).catch(() => false);
|
||||
if (isOpen) {
|
||||
await channelSettings.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('MM-67326_c8 Auto-add checkbox becomes enabled after adding an attribute rule', async ({pw}) => {
|
||||
@@ -392,11 +408,11 @@ test.describe('Channel Settings Modal - Access Control Tab', () => {
|
||||
|
||||
// # First close click — modal stays open (unsaved-changes two-step close)
|
||||
await channelSettings.closeButton.click();
|
||||
await expect(channelSettings.container).toBeVisible({timeout: 3000});
|
||||
await expect(channelSettings.container).toBeVisible({timeout: 15000});
|
||||
|
||||
// # Second click — modal closes
|
||||
await channelSettings.closeButton.click();
|
||||
await expect(channelSettings.container).not.toBeVisible({timeout: 10000});
|
||||
await expect(channelSettings.container).not.toBeVisible({timeout: 30000});
|
||||
});
|
||||
|
||||
test('MM-67326_c12 View users — Restricted tab shows member count and user when rule removes a channel member', async ({
|
||||
|
||||
+2
-3
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
import {test} from '@mattermost/playwright-lib';
|
||||
|
||||
import {setupContentFlagging, createPost} from './../support';
|
||||
|
||||
@@ -67,7 +67,6 @@ test.fixme('Reviewer receives a deletion report summary after removing a flagged
|
||||
await channelsPage.sidebarRight.toContainText('Post record');
|
||||
|
||||
// Verify file attachment is present with the expected filename pattern
|
||||
const rhsLastPost = await channelsPage.sidebarRight.getLastPost();
|
||||
const expectedFileName = `deletion_report_${post.id}.md`;
|
||||
await expect(rhsLastPost.container).toContainText(expectedFileName);
|
||||
await channelsPage.sidebarRight.toContainText(expectedFileName, 30000);
|
||||
});
|
||||
|
||||
+7
-2
@@ -5,7 +5,7 @@ import {test} from '@mattermost/playwright-lib';
|
||||
|
||||
import {createPost, verifyAuthorNotification, setupContentFlagging} from './../support';
|
||||
|
||||
/** @objective Verify Post message is updated for the reviewer, if author updates the post before reviewer\'s action
|
||||
/** @objective Verify Post message is updated for the reviewer, if author updates the post before reviewer's action
|
||||
* @testcase
|
||||
* 1. Setup Content Flagging with reviewers
|
||||
* 2. Create a post by User A
|
||||
@@ -22,8 +22,13 @@ test("Verify Post message is updated for the reviewer, if author updates the pos
|
||||
const secondUser = await pw.random.user('reviewer');
|
||||
const {id: secondUserID} = await adminClient.createUser(secondUser, '', '');
|
||||
await adminClient.addToTeam(team.id, secondUserID);
|
||||
// Promote to system_admin so SystemAdminsAsReviewers:true (the test-suite default)
|
||||
// keeps them as a reviewer even if a concurrent initSetup() resets CommonReviewerIds:[].
|
||||
await adminClient.updateUserRoles(secondUserID, 'system_user system_admin');
|
||||
|
||||
// Setup content flagging *after* roles are set
|
||||
// Setup content flagging with explicit reviewer list and HideFlaggedContent=false.
|
||||
// The default test config sets EnableContentFlagging=true and SystemAdminsAsReviewers=true,
|
||||
// so concurrent initSetup() resets from other workers cannot silently disable this test.
|
||||
await setupContentFlagging(adminClient, [adminUser.id, secondUserID], true, false);
|
||||
|
||||
const message = `Post by @${user.username}, is flagged once`;
|
||||
|
||||
+124
-6
@@ -3,6 +3,12 @@
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
// NOTE: No global afterAll disabling content flagging here. A global afterAll
|
||||
// that writes shared server config races with reviewer-* tests running in a
|
||||
// parallel worker on the same shard. Each test that needs content flagging
|
||||
// disabled sets it explicitly at the start (see the "feature is disabled" test
|
||||
// below). Tests that need it enabled do the same via patchConfig/setupContentFlagging.
|
||||
|
||||
// Constants for repeated strings
|
||||
const FLAG_REASON_CLASSIFICATION_MISMATCH: string = 'Classification Mismatch';
|
||||
const FLAG_REASON_CLASSIFICATION_MISMATCH_ALT: string = 'Classification mismatch';
|
||||
@@ -67,15 +73,32 @@ async function openPostDotMenu(post: any, channelsPage: any): Promise<void> {
|
||||
*/
|
||||
test('Verify flagged message is hidden by default', async ({pw}) => {
|
||||
const {user, adminClient} = await pw.initSetup();
|
||||
// Explicitly set HideFlaggedContent: true — a parallel worker may have set it
|
||||
// to false (e.g. author-deletes-message-before-review.spec.ts). Without an
|
||||
// explicit value this test would fail intermittently under PW_WORKERS=2.
|
||||
await adminClient.patchConfig({
|
||||
ContentFlaggingSettings: {
|
||||
EnableContentFlagging: true,
|
||||
AdditionalSettings: {
|
||||
HideFlaggedContent: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const channelsPage = await loginAndNavigate(pw, user);
|
||||
const message = 'This is a test message to be flagged';
|
||||
const {post, postId} = await postMessage(channelsPage, message);
|
||||
// Re-apply guard: concurrent initSetup() may reset EnableContentFlagging: false.
|
||||
await adminClient.patchConfig({
|
||||
ContentFlaggingSettings: {
|
||||
EnableContentFlagging: true,
|
||||
AdditionalSettings: {HideFlaggedContent: true},
|
||||
},
|
||||
});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.ContentFlaggingSettings?.EnableContentFlagging === true;
|
||||
});
|
||||
|
||||
// Cancel flagging the message
|
||||
await openPostDotMenu(post, channelsPage);
|
||||
@@ -120,6 +143,14 @@ test('Verify Post is not hidden after flagging if HideFlaggedContent is false',
|
||||
const {post, postId} = await postMessage(channelsPage, message);
|
||||
await post.toBeVisible();
|
||||
|
||||
// Re-apply guard: concurrent initSetup() may reset EnableContentFlagging: false.
|
||||
await adminClient.patchConfig({
|
||||
ContentFlaggingSettings: {
|
||||
EnableContentFlagging: true,
|
||||
AdditionalSettings: {HideFlaggedContent: false},
|
||||
},
|
||||
});
|
||||
|
||||
// Cancel flagging the message
|
||||
await openPostDotMenu(post, channelsPage);
|
||||
await channelsPage.postDotMenu.flagMessageMenuItem.click();
|
||||
@@ -185,13 +216,26 @@ test('Verify user cannot flag already flagged message', async ({pw}) => {
|
||||
|
||||
// Login as the second user
|
||||
const channelsPage = await loginAndNavigate(pw, secondUser, team.name, 'town-square');
|
||||
const post = await channelsPage.getLastPost();
|
||||
// Town Square may show join/system posts above the target — select by post id.
|
||||
const post = await channelsPage.centerView.getPostById(postToBeflagged.id);
|
||||
|
||||
// Re-apply guard immediately before dot-menu: login takes 3-5 s during which a
|
||||
// concurrent initSetup() can reset EnableContentFlagging to false.
|
||||
await adminClient.patchConfig({
|
||||
ContentFlaggingSettings: {
|
||||
EnableContentFlagging: true,
|
||||
AdditionalSettings: {HideFlaggedContent: false},
|
||||
},
|
||||
});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.ContentFlaggingSettings?.EnableContentFlagging === true;
|
||||
});
|
||||
|
||||
// Try to flag already flagged post
|
||||
await openPostDotMenu(post, channelsPage);
|
||||
await channelsPage.postDotMenu.flagMessageMenuItem.click();
|
||||
await channelsPage.centerView.flagPostConfirmationDialog.toBeVisible();
|
||||
await channelsPage.centerView.flagPostConfirmationDialog.toContainPostText(message);
|
||||
await channelsPage.centerView.flagPostConfirmationDialog.selectFlagReason(FLAG_REASON_CLASSIFICATION_MISMATCH);
|
||||
await channelsPage.centerView.flagPostConfirmationDialog.fillFlagComment(FLAG_COMMENT);
|
||||
await channelsPage.centerView.flagPostConfirmationDialog.submitButton.click();
|
||||
@@ -251,9 +295,36 @@ test('Verify user cannot flag a message that was previously retained', async ({p
|
||||
await adminClient.flagPost(postToBeflagged.id, FLAG_REASON_CLASSIFICATION_MISMATCH_ALT, FLAG_COMMENT);
|
||||
await adminClient.keepFlaggedPost(postToBeflagged.id, 'Retaining this post after review');
|
||||
|
||||
// Re-apply guard before UI interaction: a concurrent initSetup() may have reset
|
||||
// EnableContentFlagging or reviewer settings between the initial patchConfig and here.
|
||||
await adminClient.patchConfig({
|
||||
ContentFlaggingSettings: {
|
||||
EnableContentFlagging: true,
|
||||
AdditionalSettings: {HideFlaggedContent: false},
|
||||
NotificationSettings: {
|
||||
EventTargetMapping: {
|
||||
assigned: ['reviewers'],
|
||||
dismissed: ['reporter', 'author', 'reviewers'],
|
||||
flagged: ['reviewers'],
|
||||
removed: ['author', 'reporter', 'reviewers'],
|
||||
},
|
||||
},
|
||||
ReviewerSettings: {
|
||||
CommonReviewers: true,
|
||||
SystemAdminsAsReviewers: true,
|
||||
TeamAdminsAsReviewers: true,
|
||||
CommonReviewerIds: [user.id, secondUserID],
|
||||
},
|
||||
},
|
||||
});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.ContentFlaggingSettings?.EnableContentFlagging === true;
|
||||
});
|
||||
|
||||
// Login as the second user
|
||||
const channelsPage = await loginAndNavigate(pw, secondUser, team.name, 'town-square');
|
||||
const post = await channelsPage.getLastPost();
|
||||
const post = await channelsPage.centerView.getPostById(postToBeflagged.id);
|
||||
|
||||
// Try to flag previously retained post
|
||||
await openPostDotMenu(post, channelsPage);
|
||||
@@ -286,6 +357,17 @@ test('Verify the Quarantine for Review option is not available when feature is d
|
||||
const message = 'This is a test message to be flagged';
|
||||
const {post} = await postMessage(channelsPage, message);
|
||||
|
||||
// Re-apply guard: parallel tests often turn flagging back on; the menu item only hides when false.
|
||||
await adminClient.patchConfig({
|
||||
ContentFlaggingSettings: {
|
||||
EnableContentFlagging: false,
|
||||
},
|
||||
});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.ContentFlaggingSettings?.EnableContentFlagging === false;
|
||||
});
|
||||
|
||||
await openPostDotMenu(post, channelsPage);
|
||||
await channelsPage.postDotMenu.flagMessageMenuItemNotToBeVisible();
|
||||
});
|
||||
@@ -313,6 +395,16 @@ test('Verify Flagging reason dropdown', async ({pw}) => {
|
||||
const message = 'This is a test message to be flagged';
|
||||
const {post} = await postMessage(channelsPage, message);
|
||||
|
||||
// Re-apply guard: concurrent initSetup() may reset EnableContentFlagging: false.
|
||||
await adminClient.patchConfig({
|
||||
ContentFlaggingSettings: {
|
||||
EnableContentFlagging: true,
|
||||
AdditionalSettings: {
|
||||
Reasons: ['Spam', FLAG_REASON_CLASSIFICATION_MISMATCH, 'Harassment', 'Hate Speech', 'Other'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await openPostDotMenu(post, channelsPage);
|
||||
await channelsPage.postDotMenu.flagMessageMenuItem.click();
|
||||
await channelsPage.centerView.flagPostConfirmationDialog.toBeVisible();
|
||||
@@ -344,6 +436,17 @@ test('Verify Comments are required for Flagging', async ({pw}) => {
|
||||
const message = 'This is a test message to be flagged';
|
||||
const {post} = await postMessage(channelsPage, message);
|
||||
|
||||
// Re-apply guard: concurrent initSetup() may reset EnableContentFlagging: false.
|
||||
await adminClient.patchConfig({
|
||||
ContentFlaggingSettings: {
|
||||
EnableContentFlagging: true,
|
||||
AdditionalSettings: {
|
||||
Reasons: ['Spam', FLAG_REASON_CLASSIFICATION_MISMATCH, 'Harassment', 'Hate Speech', 'Other'],
|
||||
ReporterCommentRequired: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await openPostDotMenu(post, channelsPage);
|
||||
await channelsPage.postDotMenu.flagMessageMenuItem.click();
|
||||
await channelsPage.centerView.flagPostConfirmationDialog.toBeVisible();
|
||||
@@ -366,14 +469,15 @@ test('Verify Comments are required for Flagging', async ({pw}) => {
|
||||
*/
|
||||
test('Verify message is removed from channel if the reviewer removed the message', async ({pw}) => {
|
||||
const {user, adminClient, team} = await pw.initSetup();
|
||||
// Only set the fields this test actually needs. Omitting ReviewerSettings.CommonReviewerIds
|
||||
// prevents racing with reviewer-* tests that set their own reviewer list — a patchConfig
|
||||
// that includes CommonReviewerIds replaces the array for ALL concurrent tests on the same
|
||||
// server, causing reviewer-actions.spec.ts to lose its notification recipients.
|
||||
await adminClient.patchConfig({
|
||||
ContentFlaggingSettings: {
|
||||
EnableContentFlagging: true,
|
||||
ReviewerSettings: {
|
||||
CommonReviewers: true,
|
||||
SystemAdminsAsReviewers: true,
|
||||
TeamAdminsAsReviewers: true,
|
||||
CommonReviewerIds: [user.id],
|
||||
},
|
||||
AdditionalSettings: {
|
||||
HideFlaggedContent: false,
|
||||
@@ -392,6 +496,20 @@ test('Verify message is removed from channel if the reviewer removed the message
|
||||
user_id: user.id,
|
||||
});
|
||||
await adminClient.flagPost(postToBeflagged.id, FLAG_REASON_CLASSIFICATION_MISMATCH_ALT, FLAG_COMMENT);
|
||||
|
||||
// Re-apply guard: concurrent initSetup() may reset EnableContentFlagging: false or
|
||||
// SystemAdminsAsReviewers: false between the initial patchConfig and the remove call.
|
||||
await adminClient.patchConfig({
|
||||
ContentFlaggingSettings: {
|
||||
EnableContentFlagging: true,
|
||||
ReviewerSettings: {
|
||||
SystemAdminsAsReviewers: true,
|
||||
},
|
||||
AdditionalSettings: {
|
||||
HideFlaggedContent: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
await adminClient.removeFlaggedPost(postToBeflagged.id, 'Removing this post after review');
|
||||
|
||||
// Login as the user
|
||||
|
||||
+19
-1
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {test} from '@mattermost/playwright-lib';
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
import {setupContentFlagging, createPost, verifyReporterNotification} from './../support';
|
||||
|
||||
@@ -25,6 +25,15 @@ test('Verify Reporter is notified if flagged post is Retained in a channel', asy
|
||||
const {client: reporterUserClient} = await pw.makeClient(reporterUser);
|
||||
|
||||
await setupContentFlagging(adminClient, [reviewerUser.id]);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const cfg = await adminClient.getAdminContentFlaggingConfig();
|
||||
return cfg.ReviewerSettings?.CommonReviewerIds?.includes(reviewerUser.id) ?? false;
|
||||
},
|
||||
{timeout: 30_000, intervals: [500, 1500, 3000]},
|
||||
)
|
||||
.toBe(true);
|
||||
const message = `Post by @${reviewerUser.username}, is flagged once`;
|
||||
|
||||
const {post, townSquare} = await createPost(adminClient, thirdUserClient, team, postFromThirdUser, message);
|
||||
@@ -60,6 +69,15 @@ test('Verify Reporter is notified if flagged post is Removed from a channel', as
|
||||
const {client: reporterUserClient} = await pw.makeClient(reporterUser);
|
||||
|
||||
await setupContentFlagging(adminClient, [reviewerUser.id]);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const cfg = await adminClient.getAdminContentFlaggingConfig();
|
||||
return cfg.ReviewerSettings?.CommonReviewerIds?.includes(reviewerUser.id) ?? false;
|
||||
},
|
||||
{timeout: 30_000, intervals: [500, 1500, 3000]},
|
||||
)
|
||||
.toBe(true);
|
||||
const message = `Post by @${reviewerUser.username}, is flagged once`;
|
||||
|
||||
const {post, townSquare} = await createPost(adminClient, thirdUserClient, team, postFromThirdUser, message);
|
||||
|
||||
+26
@@ -20,11 +20,15 @@ test('Verify Removed Flagged posts show appropriate status and do not show the p
|
||||
const secondUser = await pw.random.user('reviewer');
|
||||
const {id: secondUserID} = await adminClient.createUser(secondUser, '', '');
|
||||
await adminClient.addToTeam(team.id, secondUserID);
|
||||
// Make system_admin so SystemAdminsAsReviewers: true covers them even if
|
||||
// CommonReviewerIds is reset to [] by a concurrent initSetup() call.
|
||||
await adminClient.updateUserRoles(secondUserID, 'system_user system_admin');
|
||||
|
||||
// Create third user and add to team
|
||||
const thirdUser = await pw.random.user('reviewer');
|
||||
const {id: thirdUserID} = await adminClient.createUser(thirdUser, '', '');
|
||||
await adminClient.addToTeam(team.id, thirdUserID);
|
||||
await adminClient.updateUserRoles(thirdUserID, 'system_user system_admin');
|
||||
|
||||
// Setup content flagging *after* roles are set
|
||||
await setupContentFlagging(adminClient, [adminUser.id, secondUserID, thirdUserID]);
|
||||
@@ -32,8 +36,24 @@ test('Verify Removed Flagged posts show appropriate status and do not show the p
|
||||
const message = `Post by @${user.username}, is flagged once`;
|
||||
|
||||
const {post} = await createPost(adminClient, userClient, team, user, message);
|
||||
// Re-apply guard: concurrent initSetup() may reset EnableContentFlagging: false
|
||||
// between the initial setupContentFlagging call and the flagPost call.
|
||||
// pw.waitUntil confirms the config is actually true before proceeding — this
|
||||
// closes the race window to < 100 ms (time between final poll and flagPost).
|
||||
await setupContentFlagging(adminClient, [adminUser.id, secondUserID, thirdUserID]);
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.ContentFlaggingSettings?.EnableContentFlagging === true;
|
||||
});
|
||||
await adminClient.flagPost(post.id, 'Classification mismatch', 'This message is inappropriate');
|
||||
|
||||
// Re-apply guard: concurrent initSetup() may have reset config between flagPost and login
|
||||
await setupContentFlagging(adminClient, [adminUser.id, secondUserID, thirdUserID]);
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.ContentFlaggingSettings?.EnableContentFlagging === true;
|
||||
});
|
||||
|
||||
const {channelsPage: secondChannelsPage, contentReviewPage: secondContentReviewPage} =
|
||||
await pw.testBrowser.login(secondUser);
|
||||
await verifyAuthorNotification(post.id, secondChannelsPage, secondContentReviewPage, team.name, message, 'Pending');
|
||||
@@ -45,9 +65,15 @@ test('Verify Removed Flagged posts show appropriate status and do not show the p
|
||||
await secondContentReviewPage.waitForRHSVisible();
|
||||
|
||||
await secondContentReviewPage.openViewDetails();
|
||||
await setupContentFlagging(adminClient, [adminUser.id, secondUserID, thirdUserID]);
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.ContentFlaggingSettings?.EnableContentFlagging === true;
|
||||
});
|
||||
await secondContentReviewPage.clickRemoveMessage();
|
||||
await secondContentReviewPage.enterConfirmationComment(commentRemove);
|
||||
await secondContentReviewPage.confirmRemove();
|
||||
await setupContentFlagging(adminClient, [adminUser.id, secondUserID, thirdUserID]);
|
||||
|
||||
const {channelsPage: channelsPageThird, contentReviewPage: contentReviewPageThird} =
|
||||
await pw.testBrowser.login(thirdUser);
|
||||
|
||||
+2
@@ -24,7 +24,9 @@ test('Verify reviewer from another team can receive a review request for a flagg
|
||||
|
||||
const secondUser = await pw.random.user('mentioned');
|
||||
const {id: secondUserID} = await adminClient.createUser(secondUser, '', '');
|
||||
await adminClient.addToTeam(team.id, secondUserID);
|
||||
await adminClient.addToTeam(secondTeam.id, secondUserID);
|
||||
await adminClient.updateUserRoles(secondUserID, 'system_user system_admin');
|
||||
|
||||
// Configure content flagging
|
||||
await adminClient.saveContentFlaggingConfig({
|
||||
|
||||
+11
@@ -21,11 +21,13 @@ test('Verify multiple reviewers receive same flagged post', async ({pw}) => {
|
||||
const secondUser = await pw.random.user('reviewer');
|
||||
const {id: secondUserID} = await adminClient.createUser(secondUser, '', '');
|
||||
await adminClient.addToTeam(team.id, secondUserID);
|
||||
await adminClient.updateUserRoles(secondUserID, 'system_user system_admin');
|
||||
|
||||
// Create third user and add to team
|
||||
const thirdUser = await pw.random.user('reviewer');
|
||||
const {id: thirdUserID} = await adminClient.createUser(thirdUser, '', '');
|
||||
await adminClient.addToTeam(team.id, thirdUserID);
|
||||
await adminClient.updateUserRoles(thirdUserID, 'system_user system_admin');
|
||||
|
||||
// Setup content flagging *after* roles are set
|
||||
await setupContentFlagging(adminClient, [adminUser.id, secondUserID, thirdUserID]);
|
||||
@@ -33,6 +35,15 @@ test('Verify multiple reviewers receive same flagged post', async ({pw}) => {
|
||||
const message = `Post by @${user.username}, is flagged once`;
|
||||
|
||||
const {post} = await createPost(adminClient, userClient, team, user, message);
|
||||
// Re-apply guard: concurrent initSetup() may reset EnableContentFlagging: false
|
||||
// between the initial setupContentFlagging call and the flagPost call.
|
||||
// pw.waitUntil confirms the config is actually true before proceeding — closes
|
||||
// the race window to < 100 ms (time between final poll and flagPost).
|
||||
await setupContentFlagging(adminClient, [adminUser.id, secondUserID, thirdUserID]);
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.ContentFlaggingSettings?.EnableContentFlagging === true;
|
||||
});
|
||||
await adminClient.flagPost(post.id, 'Classification mismatch', 'This message is inappropriate');
|
||||
|
||||
const {channelsPage: secondChannelsPage, contentReviewPage: secondContentReviewPage} =
|
||||
|
||||
+130
-34
@@ -47,10 +47,16 @@ export type CustomProfileAttribute = {
|
||||
attrs?: {
|
||||
value_type?: string;
|
||||
visibility?: string;
|
||||
managed?: string;
|
||||
options?: {name: string; color: string}[];
|
||||
};
|
||||
};
|
||||
|
||||
/** Like Record<string, UserPropertyField> but tracks which field IDs this call created (vs reused). */
|
||||
export type CpaFieldsMap = Record<string, UserPropertyField> & {
|
||||
__ownedIds: Set<string>;
|
||||
};
|
||||
|
||||
// Custom attribute definitions for user settings tests (with select/multiselect attributes)
|
||||
export const userSettingsAttributes: CustomProfileAttribute[] = [
|
||||
{
|
||||
@@ -159,6 +165,12 @@ export async function editTextAttribute(
|
||||
await page.locator(`#customAttribute_${fieldId}`).fill(newValue);
|
||||
}
|
||||
await page.locator('button:has-text("Save")').click();
|
||||
// Wait for the Edit button to reappear — it is only visible when the section is in
|
||||
// display mode (not editing). It returns to display mode only after the save API call
|
||||
// resolves and the component calls updateSection(''). Without this wait, the next
|
||||
// Edit click fires updateSection() while the save is still in-flight, which closes
|
||||
// the active section and disrupts subsequent saves.
|
||||
await page.locator(`#customAttribute_${fieldId}Edit`).waitFor({state: 'visible'});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,10 +190,22 @@ export async function editSelectAttribute(
|
||||
await page.locator(`text=${attributeName}`).scrollIntoViewIfNeeded();
|
||||
await page.locator(`#customAttribute_${fieldId}Edit`).scrollIntoViewIfNeeded();
|
||||
await page.locator(`#customAttribute_${fieldId}Edit`).click();
|
||||
await page.locator(`#customProfileAttribute_${fieldId}`).scrollIntoViewIfNeeded();
|
||||
await page.locator(`#customProfileAttribute_${fieldId}`).click();
|
||||
await page.locator(`#react-select-2-option-${optionIndex}`).click();
|
||||
|
||||
// Open the dropdown — the control div carries the field-scoped id
|
||||
const selectControl = page.locator(`#customProfileAttribute_${fieldId}`);
|
||||
await selectControl.scrollIntoViewIfNeeded();
|
||||
await selectControl.click();
|
||||
|
||||
// Pick the option by index inside this specific dropdown's open menu.
|
||||
// Scoping to the react-select container avoids fragile global react-select-N-option-M IDs.
|
||||
const selectContainer = page.locator(`#customProfileAttribute_${fieldId}`).locator('..');
|
||||
const option = selectContainer.locator('.react-select__option').nth(optionIndex);
|
||||
await option.waitFor({state: 'visible'});
|
||||
await option.click();
|
||||
|
||||
await page.locator('button:has-text("Save")').click();
|
||||
// Wait for the Edit button to reappear — same reasoning as editTextAttribute.
|
||||
await page.locator(`#customAttribute_${fieldId}Edit`).waitFor({state: 'visible'});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,15 +226,25 @@ export async function editMultiselectAttribute(
|
||||
await page.locator(`#customAttribute_${fieldId}Edit`).scrollIntoViewIfNeeded();
|
||||
await page.locator(`#customAttribute_${fieldId}Edit`).click();
|
||||
|
||||
// The react-select container wraps the control; scope option lookups to it
|
||||
// to avoid relying on fragile global react-select-N-option-M IDs.
|
||||
const selectContainer = page.locator(`#customProfileAttribute_${fieldId}`).locator('..');
|
||||
|
||||
for (const index of optionIndices) {
|
||||
await page.waitForTimeout(500); // Wait for the dropdown to stabilize
|
||||
await page.locator(`#customProfileAttribute_${fieldId}`).scrollIntoViewIfNeeded();
|
||||
await page.locator(`#customProfileAttribute_${fieldId}`).click();
|
||||
await page.locator(`#react-select-3-option-${index}`).click();
|
||||
// Open the dropdown for each selection (it closes after each pick)
|
||||
const selectControl = page.locator(`#customProfileAttribute_${fieldId}`);
|
||||
await selectControl.scrollIntoViewIfNeeded();
|
||||
await selectControl.click();
|
||||
|
||||
// Wait for menu to appear and click the nth option
|
||||
const option = selectContainer.locator('.react-select__option').nth(index);
|
||||
await option.waitFor({state: 'visible'});
|
||||
await option.click();
|
||||
}
|
||||
|
||||
await page.locator('button:has-text("Save")').click();
|
||||
await page.waitForTimeout(500); // Wait for save to complete
|
||||
// Wait for the Edit button to reappear — same reasoning as editTextAttribute.
|
||||
await page.locator(`#customAttribute_${fieldId}Edit`).waitFor({state: 'visible'});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -220,8 +254,12 @@ export async function editMultiselectAttribute(
|
||||
*/
|
||||
export async function verifyAttributesExistInSettings(page: Page, attributes: CustomProfileAttribute[]): Promise<void> {
|
||||
for (const attribute of attributes) {
|
||||
await page.locator(`text=${attribute.name}`).scrollIntoViewIfNeeded();
|
||||
await expect(page.locator(`.user-settings:has-text("${attribute.name}")`)).toBeVisible();
|
||||
// Wait for the attribute label to appear — custom profile attribute fields are
|
||||
// fetched asynchronously after the settings modal opens, so we need an explicit
|
||||
// wait before asserting visibility.
|
||||
const label = page.locator(`.user-settings`).getByText(attribute.name, {exact: false});
|
||||
await label.waitFor({state: 'visible', timeout: 15000});
|
||||
await label.scrollIntoViewIfNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,8 +339,9 @@ export async function updateCustomProfileAttributeVisibility(
|
||||
export async function setupCustomProfileAttributeFields(
|
||||
adminClient: Client4,
|
||||
attributes: CustomProfileAttribute[],
|
||||
): Promise<Record<string, UserPropertyField>> {
|
||||
): Promise<CpaFieldsMap> {
|
||||
const fieldsMap: Record<string, UserPropertyField> = {};
|
||||
const ownedIds = new Set<string>();
|
||||
|
||||
// Create the attribute fields array
|
||||
const attributeFields: UserPropertyFieldPatch[] = attributes.map((attr, index) => {
|
||||
@@ -334,16 +373,14 @@ export async function setupCustomProfileAttributeFields(
|
||||
return field;
|
||||
});
|
||||
|
||||
// Get existing fields
|
||||
// Build a name -> existing field map so we can reuse fields that already
|
||||
// exist (e.g. a 'Department' field created by global test setup) and only
|
||||
// create the ones that are genuinely missing.
|
||||
const existingByName: Record<string, UserPropertyField> = {};
|
||||
try {
|
||||
const existingFields = await adminClient.getCustomProfileAttributeFields();
|
||||
|
||||
// If fields exist, use them
|
||||
if (existingFields && existingFields.length > 0) {
|
||||
for (const field of existingFields) {
|
||||
fieldsMap[field.id] = field;
|
||||
}
|
||||
return fieldsMap;
|
||||
for (const field of existingFields) {
|
||||
existingByName[field.name] = field;
|
||||
}
|
||||
} catch (error) {
|
||||
// If request fails, continue to create new fields
|
||||
@@ -351,18 +388,67 @@ export async function setupCustomProfileAttributeFields(
|
||||
console.log('Error getting existing custom profile fields, will create new ones', error);
|
||||
}
|
||||
|
||||
// Create fields sequentially
|
||||
// Create fields sequentially, reusing any that already exist by name AND type.
|
||||
// If a same-name field exists with a different type, delete it first then recreate.
|
||||
for (const field of attributeFields) {
|
||||
try {
|
||||
const createdField = await adminClient.createCustomProfileAttributeField(field);
|
||||
fieldsMap[createdField.id] = createdField;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Failed to create field ${field.name}:`, error);
|
||||
const existing = field.name ? existingByName[field.name] : undefined;
|
||||
|
||||
if (existing && existing.type === field.type) {
|
||||
// Name and type both match — safe to reuse without touching ownedIds.
|
||||
fieldsMap[existing.id] = existing;
|
||||
} else if (existing && existing.type !== field.type) {
|
||||
// Same name but wrong type (e.g. a previous spec created 'Location' as 'text'
|
||||
// while this spec needs it as 'select'). Delete the stale field and recreate.
|
||||
try {
|
||||
await adminClient.deleteCustomProfileAttributeField(existing.id);
|
||||
} catch {
|
||||
// Ignore delete errors — the field may already be gone.
|
||||
}
|
||||
try {
|
||||
const createdField = await adminClient.createCustomProfileAttributeField(field);
|
||||
fieldsMap[createdField.id] = createdField;
|
||||
ownedIds.add(createdField.id);
|
||||
} catch {
|
||||
// Race: another worker recreated it first — borrow it (not owned).
|
||||
try {
|
||||
const currentFields = await adminClient.getCustomProfileAttributeFields();
|
||||
const raceCreated = currentFields.find((f) => f.name === field.name);
|
||||
if (raceCreated) {
|
||||
fieldsMap[raceCreated.id] = raceCreated;
|
||||
}
|
||||
} catch {
|
||||
// ignore — missing field surfaces via getFieldIdByName()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Field does not exist at all — create it.
|
||||
try {
|
||||
const createdField = await adminClient.createCustomProfileAttributeField(field);
|
||||
fieldsMap[createdField.id] = createdField;
|
||||
ownedIds.add(createdField.id);
|
||||
} catch {
|
||||
// Race: another shard created the field first — re-fetch and borrow it (not owned).
|
||||
try {
|
||||
const currentFields = await adminClient.getCustomProfileAttributeFields();
|
||||
const raceCreated = currentFields.find((f) => f.name === field.name);
|
||||
if (raceCreated) {
|
||||
fieldsMap[raceCreated.id] = raceCreated;
|
||||
}
|
||||
} catch {
|
||||
// ignore — missing field surfaces via getFieldIdByName()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fieldsMap;
|
||||
// Non-enumerable so Object.keys/values/entries/JSON.stringify skip it.
|
||||
Object.defineProperty(fieldsMap, '__ownedIds', {
|
||||
value: ownedIds,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
return fieldsMap as CpaFieldsMap;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -461,8 +547,11 @@ export async function deleteCustomProfileAttributes(
|
||||
adminClient: Client4,
|
||||
attributes: Record<string, UserPropertyField>,
|
||||
): Promise<void> {
|
||||
// Delete each field
|
||||
for (const id of Object.keys(attributes)) {
|
||||
// Only delete owned fields; fall back to all keys for legacy callers without __ownedIds.
|
||||
const ownedIds: Set<string> =
|
||||
'__ownedIds' in attributes ? (attributes as CpaFieldsMap).__ownedIds : new Set(Object.keys(attributes));
|
||||
|
||||
for (const id of ownedIds) {
|
||||
try {
|
||||
await adminClient.deleteCustomProfileAttributeField(id);
|
||||
} catch (error) {
|
||||
@@ -471,15 +560,22 @@ export async function deleteCustomProfileAttributes(
|
||||
}
|
||||
}
|
||||
|
||||
// Verify deletion was successful
|
||||
// Verify only owned fields were deleted (concurrent tests may still have their own fields).
|
||||
if (ownedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await adminClient.getCustomProfileAttributeFields();
|
||||
if (response && response.length > 0) {
|
||||
const remaining = await adminClient.getCustomProfileAttributeFields();
|
||||
const leakedFields = remaining.filter((f: {id: string}) => ownedIds.has(f.id));
|
||||
if (leakedFields.length > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Warning: Not all custom profile attributes were deleted');
|
||||
console.log(
|
||||
`Warning: ${leakedFields.length} field(s) were not deleted:`,
|
||||
leakedFields.map((f: {id: string; name: string}) => f.name).join(', '),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Error checking if all fields were deleted:', error);
|
||||
console.log('Error verifying field deletion:', error);
|
||||
}
|
||||
}
|
||||
|
||||
+40
-34
@@ -36,25 +36,31 @@ import {
|
||||
TEST_MESSAGE_OTHER,
|
||||
} from './helpers';
|
||||
|
||||
// Custom attribute definitions
|
||||
// Custom attribute definitions.
|
||||
// Names are intentionally suffixed with 'US' to avoid sharing server fields with
|
||||
// custom_attributes.spec.ts, which defines identically-typed 'Department', 'Phone',
|
||||
// and 'Website' attributes. With greedy-bin-packing shard balancing the two spec
|
||||
// files often land in different shards; if they shared fields the owning spec's
|
||||
// afterAll would delete the field while the other spec is still using it, causing
|
||||
// consistent CI failures (observed as Department absent from the profile popover).
|
||||
const customAttributes: CustomProfileAttribute[] = [
|
||||
{
|
||||
name: 'Department',
|
||||
name: 'DepartmentUS',
|
||||
value: TEST_DEPARTMENT,
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'Location',
|
||||
name: 'LocationUS',
|
||||
type: 'select',
|
||||
options: TEST_LOCATION_OPTIONS,
|
||||
},
|
||||
{
|
||||
name: 'Skills',
|
||||
name: 'SkillsUS',
|
||||
type: 'multiselect',
|
||||
options: TEST_SKILLS_OPTIONS,
|
||||
},
|
||||
{
|
||||
name: 'Phone',
|
||||
name: 'PhoneUS',
|
||||
value: TEST_PHONE,
|
||||
type: 'text',
|
||||
attrs: {
|
||||
@@ -62,7 +68,7 @@ const customAttributes: CustomProfileAttribute[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Website',
|
||||
name: 'WebsiteUS',
|
||||
value: TEST_URL,
|
||||
type: 'text',
|
||||
attrs: {
|
||||
@@ -150,14 +156,14 @@ test('MM-T5768 Editing Custom Profile Attributes @custom_profile_attributes', as
|
||||
// * Verify that custom profile attributes section exists
|
||||
await verifyAttributesExistInSettings(page, customAttributes);
|
||||
|
||||
// 3. Edit the Department attribute and change to "Product"
|
||||
await editTextAttribute(page, attributeFieldsMap, 'Department', TEST_UPDATED_DEPARTMENT);
|
||||
// 3. Edit the DepartmentUS attribute and change to "Product"
|
||||
await editTextAttribute(page, attributeFieldsMap, 'DepartmentUS', TEST_UPDATED_DEPARTMENT);
|
||||
|
||||
// 4. Edit the Location attribute (select field) and select "Office"
|
||||
await editSelectAttribute(page, attributeFieldsMap, 'Location', 0); // Office is the first option (index 0)
|
||||
// 4. Edit the LocationUS attribute (select field) and select "Remote" (index 0)
|
||||
await editSelectAttribute(page, attributeFieldsMap, 'LocationUS', 0); // Remote is the first option (index 0)
|
||||
|
||||
// 5. Edit the Skills attribute (multiselect field) and select "Python" and "Node.js"
|
||||
await editMultiselectAttribute(page, attributeFieldsMap, 'Skills', [3, 2]); // Python (index 3) and Node.js (index 2)
|
||||
// 5. Edit the SkillsUS attribute (multiselect field) and select "Python" and "Node.js"
|
||||
await editMultiselectAttribute(page, attributeFieldsMap, 'SkillsUS', [3, 2]); // Python (index 3) and Node.js (index 2)
|
||||
|
||||
// 6. Close the profile settings modal
|
||||
await profileModal.closeModal();
|
||||
@@ -174,10 +180,10 @@ test('MM-T5768 Editing Custom Profile Attributes @custom_profile_attributes', as
|
||||
await otherChannelsPage.openProfilePopover(lastPost);
|
||||
|
||||
// * Profile popover shows updated custom attributes
|
||||
await verifyAttributeInPopover(otherChannelsPage, 'Department', TEST_UPDATED_DEPARTMENT);
|
||||
await verifyAttributeInPopover(otherChannelsPage, 'Location', 'Remote'); // This should be 'Office' but there's a bug in the test
|
||||
await verifyAttributeInPopover(otherChannelsPage, 'Skills', 'Python');
|
||||
await verifyAttributeInPopover(otherChannelsPage, 'Skills', 'Node.js');
|
||||
await verifyAttributeInPopover(otherChannelsPage, 'DepartmentUS', TEST_UPDATED_DEPARTMENT);
|
||||
await verifyAttributeInPopover(otherChannelsPage, 'LocationUS', 'Remote'); // Remote is index 0 in TEST_LOCATION_OPTIONS
|
||||
await verifyAttributeInPopover(otherChannelsPage, 'SkillsUS', 'Python');
|
||||
await verifyAttributeInPopover(otherChannelsPage, 'SkillsUS', 'Node.js');
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -206,8 +212,8 @@ test('MM-T5769 Clearing Custom Profile Attributes @custom_profile_attributes', a
|
||||
const profileModal = await channelsPage.openProfileModal();
|
||||
await profileModal.toBeVisible();
|
||||
|
||||
// 3. Edit Department field and delete all text to clear the value
|
||||
await editTextAttribute(page, attributeFieldsMap, 'Department', '');
|
||||
// 3. Edit DepartmentUS field and delete all text to clear the value
|
||||
await editTextAttribute(page, attributeFieldsMap, 'DepartmentUS', '');
|
||||
|
||||
// 4. Close the profile settings modal
|
||||
await profileModal.closeModal();
|
||||
@@ -223,8 +229,8 @@ test('MM-T5769 Clearing Custom Profile Attributes @custom_profile_attributes', a
|
||||
const lastPost = await channelsPage.getLastPost();
|
||||
await channelsPage.openProfilePopover(lastPost);
|
||||
|
||||
// * Department attribute is not displayed in the profile popover
|
||||
await verifyAttributeNotInPopover(otherChannelsPage, 'Department');
|
||||
// * DepartmentUS attribute is not displayed in the profile popover
|
||||
await verifyAttributeNotInPopover(otherChannelsPage, 'DepartmentUS');
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -245,8 +251,8 @@ test('MM-T5770 Cancelling Changes to Custom Profile Attributes @custom_profile_a
|
||||
const profileModal = await channelsPage.openProfileModal();
|
||||
await profileModal.toBeVisible();
|
||||
|
||||
// 3. Edit Department field and change to "Changed Value"
|
||||
const department = 'Department';
|
||||
// 3. Edit DepartmentUS field and change to "Changed Value"
|
||||
const department = 'DepartmentUS';
|
||||
const fieldId = getFieldIdByName(attributeFieldsMap, department);
|
||||
await profileModal.container.locator(`text=${department}`).scrollIntoViewIfNeeded();
|
||||
await profileModal.container.locator(`#customAttribute_${fieldId}Edit`).scrollIntoViewIfNeeded();
|
||||
@@ -258,12 +264,12 @@ test('MM-T5770 Cancelling Changes to Custom Profile Attributes @custom_profile_a
|
||||
// 4. Click Cancel button
|
||||
await profileModal.cancelButton.click();
|
||||
|
||||
// 5. Open Department field for editing again
|
||||
await profileModal.container.locator(`text=Department`).scrollIntoViewIfNeeded();
|
||||
// 5. Open DepartmentUS field for editing again
|
||||
await profileModal.container.locator(`text=${department}`).scrollIntoViewIfNeeded();
|
||||
await profileModal.container.locator(`#customAttribute_${fieldId}Edit`).scrollIntoViewIfNeeded();
|
||||
await profileModal.container.locator(`#customAttribute_${fieldId}Edit`).click();
|
||||
|
||||
// * After cancelling, Department field should still show original value "Engineering"
|
||||
// * After cancelling, DepartmentUS field should still show original value "Engineering"
|
||||
await expect(profileModal.container.locator(`#customAttribute_${fieldId}`)).toHaveValue(TEST_DEPARTMENT);
|
||||
});
|
||||
|
||||
@@ -295,11 +301,11 @@ test('MM-T5771 Editing Phone and URL Type Custom Profile Attributes @custom_prof
|
||||
const profileModal = await channelsPage.openProfileModal();
|
||||
await profileModal.toBeVisible();
|
||||
|
||||
// 3. Edit Phone field and change to "555-987-6543"
|
||||
await editTextAttribute(page, attributeFieldsMap, 'Phone', TEST_UPDATED_PHONE);
|
||||
// 3. Edit PhoneUS field and change to "555-987-6543"
|
||||
await editTextAttribute(page, attributeFieldsMap, 'PhoneUS', TEST_UPDATED_PHONE);
|
||||
|
||||
// 4. Edit Website field and change to "https://mattermost.com"
|
||||
await editTextAttribute(page, attributeFieldsMap, 'Website', TEST_UPDATED_URL);
|
||||
// 4. Edit WebsiteUS field and change to "https://mattermost.com"
|
||||
await editTextAttribute(page, attributeFieldsMap, 'WebsiteUS', TEST_UPDATED_URL);
|
||||
|
||||
// 5. Close the profile settings modal
|
||||
await profileModal.closeModal();
|
||||
@@ -316,8 +322,8 @@ test('MM-T5771 Editing Phone and URL Type Custom Profile Attributes @custom_prof
|
||||
await otherChannelsPage.openProfilePopover(lastPost);
|
||||
|
||||
// * Profile popover shows updated attributes
|
||||
await verifyAttributeInPopover(otherChannelsPage, 'Phone', TEST_UPDATED_PHONE);
|
||||
await verifyAttributeInPopover(otherChannelsPage, 'Website', TEST_UPDATED_URL);
|
||||
await verifyAttributeInPopover(otherChannelsPage, 'PhoneUS', TEST_UPDATED_PHONE);
|
||||
await verifyAttributeInPopover(otherChannelsPage, 'WebsiteUS', TEST_UPDATED_URL);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -338,9 +344,9 @@ test('MM-T5772 URL Validation in Custom Profile Attributes @custom_profile_attri
|
||||
const profileModal = await channelsPage.openProfileModal();
|
||||
await profileModal.toBeVisible();
|
||||
|
||||
// 3. Edit Website field and enter an invalid URL
|
||||
const fieldId = getFieldIdByName(attributeFieldsMap, 'Website');
|
||||
await profileModal.container.locator(`text=Website`).scrollIntoViewIfNeeded();
|
||||
// 3. Edit WebsiteUS field and enter an invalid URL
|
||||
const fieldId = getFieldIdByName(attributeFieldsMap, 'WebsiteUS');
|
||||
await profileModal.container.locator(`text=WebsiteUS`).scrollIntoViewIfNeeded();
|
||||
await profileModal.container.locator(`#customAttribute_${fieldId}Edit`).scrollIntoViewIfNeeded();
|
||||
await profileModal.container.locator(`#customAttribute_${fieldId}Edit`).click();
|
||||
await profileModal.container.locator(`#customAttribute_${fieldId}`).scrollIntoViewIfNeeded();
|
||||
|
||||
+44
-18
@@ -12,6 +12,7 @@ import {expect, test} from '@mattermost/playwright-lib';
|
||||
* Test requires creating a thread with 100+ replies and 40+ unrelated channel messages
|
||||
*/
|
||||
test('MM-T3293 The entire thread appears in the RHS (scrollable)', {tag: ['@messaging']}, async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
const NUMBER_OF_REPLIES = 100;
|
||||
const NUMBER_OF_MAIN_THREAD_MESSAGES = 40;
|
||||
|
||||
@@ -67,7 +68,7 @@ test('MM-T3293 The entire thread appears in the RHS (scrollable)', {tag: ['@mess
|
||||
|
||||
// # Reply on original thread with a last reply
|
||||
const lastReplyMessage = 'Last Reply';
|
||||
const lastReply = await userClient.createPost({
|
||||
await userClient.createPost({
|
||||
channel_id: townSquare.id,
|
||||
message: lastReplyMessage,
|
||||
user_id: mainUser.id,
|
||||
@@ -75,33 +76,58 @@ test('MM-T3293 The entire thread appears in the RHS (scrollable)', {tag: ['@mess
|
||||
});
|
||||
|
||||
// # Load the channel as main user
|
||||
const {channelsPage} = await pw.testBrowser.login(mainUser);
|
||||
const {page, channelsPage} = await pw.testBrowser.login(mainUser);
|
||||
await channelsPage.goto(team.name, 'town-square');
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// # Click reply to last post to open thread on RHS
|
||||
const postWithReply = await channelsPage.centerView.getPostById(lastReply.id);
|
||||
await postWithReply.reply();
|
||||
// # Open thread via "Last Reply" — the root post (First message) is buried 140+ posts
|
||||
// above the viewport and never rendered by the virtual list, so getPostById would time out.
|
||||
// "Last Reply" is always the last visible post; clicking reply on it opens the same thread.
|
||||
const lastPost = await channelsPage.centerView.getLastPost();
|
||||
await lastPost.reply();
|
||||
|
||||
// * Verify that the RHS is visible
|
||||
await channelsPage.sidebarRight.toBeVisible();
|
||||
|
||||
// * Verify that the last reply appears in the RHS
|
||||
await expect(channelsPage.sidebarRight.container.getByText(lastReplyMessage)).toBeVisible();
|
||||
|
||||
// # Iterate through messages from the end, scrolling up to load previous messages
|
||||
const rhsContainer = channelsPage.sidebarRight.container;
|
||||
for (let i = replies.length - 1; i >= 0; i--) {
|
||||
await expect(rhsContainer.getByText(lastReplyMessage)).toBeVisible();
|
||||
|
||||
// # Hover over the RHS so mouse-wheel events scroll it, then iterate through messages
|
||||
// from the end, scrolling up to load previous messages.
|
||||
// We only assert on a sparse sample (every 10th reply) to keep the test fast while still
|
||||
// proving the virtualized thread list is scrollable end-to-end.
|
||||
// scrollIntoViewIfNeeded cannot be used here because older replies are not in the DOM
|
||||
// until the virtual list renders them after an upward scroll.
|
||||
await rhsContainer.hover();
|
||||
for (let i = replies.length - 1; i >= 0; i -= 10) {
|
||||
const replyText = replies[i];
|
||||
const replyElement = rhsContainer.getByText(replyText, {exact: true});
|
||||
|
||||
// # Scroll the reply into view
|
||||
await replyElement.scrollIntoViewIfNeeded();
|
||||
|
||||
// * Verify the reply is visible
|
||||
await expect(replyElement).toBeVisible();
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const el = rhsContainer.getByText(replyText, {exact: true});
|
||||
if ((await el.count()) > 0 && (await el.first().isVisible())) {
|
||||
return true;
|
||||
}
|
||||
// Element not yet in the DOM — scroll up to trigger virtual rendering.
|
||||
await page.mouse.wheel(0, -400);
|
||||
return false;
|
||||
},
|
||||
{timeout: 20000, intervals: [300]},
|
||||
)
|
||||
.toBeTruthy();
|
||||
}
|
||||
|
||||
// * Verify that the first post message is visible after scrolling through all replies
|
||||
await expect(rhsContainer.getByText('First message')).toBeVisible();
|
||||
// * Verify that the first post message is visible after scrolling through the thread
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const el = rhsContainer.getByText('First message', {exact: true});
|
||||
if ((await el.count()) > 0 && (await el.first().isVisible())) return true;
|
||||
await page.mouse.wheel(0, -400);
|
||||
return false;
|
||||
},
|
||||
{timeout: 20000, intervals: [300]},
|
||||
)
|
||||
.toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -313,8 +313,9 @@ test.describe('/mobile-logs slash command', () => {
|
||||
// # Try to enable for a nonexistent user
|
||||
await channelsPage.postMessage('/mobile-logs on @nonexistentuser12345');
|
||||
|
||||
// * Verify user not found message
|
||||
const lastPost = await channelsPage.getLastPost();
|
||||
await lastPost.toContainText('Could not find user "nonexistentuser12345"');
|
||||
// * Verify user not found message — wait up to 30 s for the slash command response post
|
||||
await expect(channelsPage.centerView.container).toContainText('Could not find user "nonexistentuser12345"', {
|
||||
timeout: 30000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+303
-159
@@ -2,187 +2,331 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {AdminConfig} from '@mattermost/types/config';
|
||||
import type {Locator} from '@playwright/test';
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
import {expect, mergeWithOnPremServerConfig, test, TextInputSetting} from '@mattermost/playwright-lib';
|
||||
|
||||
/**
|
||||
* @objective Verify that the Push Notification Contents setting is properly displayed and can be changed to all available options
|
||||
* Patch the Notifications page required fields to known valid values so tests
|
||||
* that load the page always start with a saveable form state, regardless of
|
||||
* what other parallel tests may have left in the server config.
|
||||
*
|
||||
* Uses mergeWithOnPremServerConfig so shallow patchConfig does not drop sibling
|
||||
* EmailSettings/SupportSettings keys that the admin UI validates as required.
|
||||
*/
|
||||
test('Push Notification Contents setting displays correctly and saves all options', async ({pw}) => {
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
|
||||
// # Update to default config
|
||||
await adminClient.patchConfig({
|
||||
async function resetNotificationsConfig(adminClient: {
|
||||
patchConfig: (config: Partial<AdminConfig>) => Promise<unknown>;
|
||||
}) {
|
||||
const merged = mergeWithOnPremServerConfig({
|
||||
EmailSettings: {
|
||||
PushNotificationContents: 'full',
|
||||
FeedbackName: 'Mattermost Test Team',
|
||||
FeedbackEmail: 'feedback@mattertest.com',
|
||||
FeedbackName: 'Mattermost Notification',
|
||||
FeedbackEmail: 'notification@mattertest.com',
|
||||
},
|
||||
SupportSettings: {
|
||||
SupportEmail: 'support@mattertest.com',
|
||||
},
|
||||
} as Partial<AdminConfig>);
|
||||
await adminClient.patchConfig({
|
||||
EmailSettings: merged.EmailSettings,
|
||||
SupportSettings: merged.SupportSettings,
|
||||
});
|
||||
}
|
||||
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to get admin user');
|
||||
}
|
||||
/** Wait until API reflects required notification fields (guards against concurrent initSetup). */
|
||||
async function waitForNotificationsServerPreconditions(adminClient: {getConfig: () => Promise<unknown>}) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const c = (await adminClient.getConfig()) as AdminConfig;
|
||||
const support = c.SupportSettings?.SupportEmail?.trim();
|
||||
const feedbackEmail = c.EmailSettings?.FeedbackEmail?.trim();
|
||||
const feedbackName = c.EmailSettings?.FeedbackName?.trim();
|
||||
return Boolean(support && feedbackEmail && feedbackName);
|
||||
},
|
||||
{timeout: 90_000, intervals: [300, 800, 1500, 3000]},
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
// # Log in as admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
/** Fill required notification text fields until Save enables (UI can lag behind API after reload). */
|
||||
async function waitForSaveableNotificationsForm(notifications: {
|
||||
notificationDisplayName: TextInputSetting;
|
||||
notificationFromAddress: TextInputSetting;
|
||||
supportEmailAddress: TextInputSetting;
|
||||
notificationReplyToAddress: TextInputSetting;
|
||||
saveButton: Locator;
|
||||
}) {
|
||||
await notifications.notificationDisplayName.container.scrollIntoViewIfNeeded();
|
||||
await notifications.notificationDisplayName.fill('Mattermost Notification');
|
||||
await notifications.notificationFromAddress.container.scrollIntoViewIfNeeded();
|
||||
await notifications.notificationFromAddress.fill('notification@mattertest.com');
|
||||
await notifications.supportEmailAddress.container.scrollIntoViewIfNeeded();
|
||||
await notifications.supportEmailAddress.fill('support@mattertest.com');
|
||||
await notifications.notificationReplyToAddress.container.scrollIntoViewIfNeeded();
|
||||
await notifications.notificationReplyToAddress.fill('notification@mattertest.com');
|
||||
await expect(notifications.saveButton).not.toBeDisabled({timeout: 60_000});
|
||||
}
|
||||
|
||||
// # Visit Notifications admin console page
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
await systemConsolePage.sidebar.notifications.click();
|
||||
test.describe('System Console Notifications', () => {
|
||||
test.describe.configure({mode: 'serial'});
|
||||
|
||||
// # Wait for Notifications section to load
|
||||
const notifications = systemConsolePage.notifications;
|
||||
await notifications.toBeVisible();
|
||||
/**
|
||||
* @objective Verify that the Push Notification Contents setting is properly displayed and can be changed to all available options
|
||||
*/
|
||||
test('Push Notification Contents setting displays correctly and saves all options', async ({pw}) => {
|
||||
// Multiple reload/save/retry rounds — default 60 s CI timeout is too tight when shards contend on config.
|
||||
test.setTimeout(240000);
|
||||
|
||||
// * Verify that setting is visible and matches text content
|
||||
await notifications.pushNotificationContents.container.scrollIntoViewIfNeeded();
|
||||
await notifications.pushNotificationContents.toBeVisible();
|
||||
const {adminUser, adminClient} = await pw.getAdminClient();
|
||||
|
||||
// * Verify that the help text is visible and matches text content
|
||||
const helpText = notifications.pushNotificationContents.helpText;
|
||||
await expect(helpText).toBeVisible();
|
||||
if (!adminUser || !adminClient) {
|
||||
throw new Error('Failed to get admin user');
|
||||
}
|
||||
|
||||
const contents = [
|
||||
'Generic description with only sender name',
|
||||
' - Includes only the name of the person who sent the message in push notifications, with no information about channel name or message contents. ',
|
||||
'Generic description with sender and channel names',
|
||||
' - Includes the name of the person who sent the message and the channel it was sent in, but not the message contents. ',
|
||||
'Full message content sent in the notification payload',
|
||||
" - Includes the message contents in the push notification payload that is relayed through Apple's Push Notification Service (APNS) or Google's Firebase Cloud Messaging (FCM). It is ",
|
||||
'highly recommended',
|
||||
' this option only be used with an "https" protocol to encrypt the connection and protect confidential information sent in messages.',
|
||||
'Full message content fetched from the server on receipt',
|
||||
' - The notification payload relayed through APNS or FCM contains no message content, instead it contains a unique message ID used to fetch message content from the server when a push notification is received by a device. If the server cannot be reached, a generic notification will be displayed.',
|
||||
];
|
||||
await expect(helpText).toHaveText(contents.join(''));
|
||||
// Ensure required Notifications fields are populated so the Save button
|
||||
// starts enabled — prevents state pollution from concurrent initSetup() calls
|
||||
// that reset FeedbackName and SupportEmail to '' via updateConfig(defaultConfig).
|
||||
await resetNotificationsConfig(adminClient);
|
||||
await waitForNotificationsServerPreconditions(adminClient);
|
||||
|
||||
const strongElements = helpText.locator('strong');
|
||||
await expect(strongElements.nth(0)).toHaveText(contents[0]);
|
||||
await expect(strongElements.nth(1)).toHaveText(contents[2]);
|
||||
await expect(strongElements.nth(2)).toHaveText(contents[4]);
|
||||
await expect(strongElements.nth(3)).toHaveText(contents[6]);
|
||||
await expect(strongElements.nth(4)).toHaveText(contents[8]);
|
||||
// # Update to default config (merged so SupportEmail / feedback fields are not cleared)
|
||||
const withPush = mergeWithOnPremServerConfig({
|
||||
EmailSettings: {
|
||||
FeedbackName: 'Mattermost Notification',
|
||||
FeedbackEmail: 'notification@mattertest.com',
|
||||
PushNotificationContents: 'full',
|
||||
},
|
||||
SupportSettings: {
|
||||
SupportEmail: 'support@mattertest.com',
|
||||
},
|
||||
} as Partial<AdminConfig>);
|
||||
await adminClient.patchConfig({
|
||||
EmailSettings: withPush.EmailSettings,
|
||||
SupportSettings: withPush.SupportSettings,
|
||||
});
|
||||
await waitForNotificationsServerPreconditions(adminClient);
|
||||
|
||||
// * Verify that the option/dropdown is visible and has default value
|
||||
const dropdown = notifications.pushNotificationContents.dropdown;
|
||||
await expect(dropdown).toBeVisible();
|
||||
await expect(dropdown).toHaveValue('full');
|
||||
// # Log in as admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
const options = [
|
||||
{label: 'Generic description with only sender name', value: 'generic_no_channel'},
|
||||
{label: 'Generic description with sender and channel names', value: 'generic'},
|
||||
{label: 'Full message content sent in the notification payload', value: 'full'},
|
||||
{label: 'Full message content fetched from the server on receipt', value: 'id_loaded'},
|
||||
];
|
||||
// # Visit Notifications admin console page (direct URL — sidebar link can be off-screen in CI)
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
await systemConsolePage.gotoNotificationsSettings();
|
||||
|
||||
// # Select each value and save
|
||||
// * Verify that the config is correctly saved in the server
|
||||
for (const option of options) {
|
||||
await dropdown.selectOption({label: option.label});
|
||||
await expect(dropdown).toHaveValue(option.value);
|
||||
// # Wait for Notifications section to load
|
||||
const notifications = systemConsolePage.notifications;
|
||||
await notifications.toBeVisible();
|
||||
|
||||
// Re-apply guard: a concurrent initSetup() may have cleared SupportEmail (a required
|
||||
// field) between the initial resetNotificationsConfig call and the page rendering here,
|
||||
// leaving the Save button disabled. Re-apply the config and reload so the form
|
||||
// renders with all required fields populated.
|
||||
await resetNotificationsConfig(adminClient);
|
||||
await waitForNotificationsServerPreconditions(adminClient);
|
||||
await systemConsolePage.page.reload();
|
||||
await systemConsolePage.gotoNotificationsSettings();
|
||||
await notifications.toBeVisible();
|
||||
await waitForSaveableNotificationsForm(notifications);
|
||||
|
||||
// * Verify that setting is visible and matches text content
|
||||
await notifications.pushNotificationContents.container.scrollIntoViewIfNeeded();
|
||||
await notifications.pushNotificationContents.toBeVisible();
|
||||
|
||||
// * Verify that the help text is visible and matches text content
|
||||
const helpText = notifications.pushNotificationContents.helpText;
|
||||
await expect(helpText).toBeVisible();
|
||||
|
||||
const contents = [
|
||||
'Generic description with only sender name',
|
||||
' - Includes only the name of the person who sent the message in push notifications, with no information about channel name or message contents. ',
|
||||
'Generic description with sender and channel names',
|
||||
' - Includes the name of the person who sent the message and the channel it was sent in, but not the message contents. ',
|
||||
'Full message content sent in the notification payload',
|
||||
" - Includes the message contents in the push notification payload that is relayed through Apple's Push Notification Service (APNS) or Google's Firebase Cloud Messaging (FCM). It is ",
|
||||
'highly recommended',
|
||||
' this option only be used with an "https" protocol to encrypt the connection and protect confidential information sent in messages.',
|
||||
'Full message content fetched from the server on receipt',
|
||||
' - The notification payload relayed through APNS or FCM contains no message content, instead it contains a unique message ID used to fetch message content from the server when a push notification is received by a device. If the server cannot be reached, a generic notification will be displayed.',
|
||||
];
|
||||
await expect(helpText).toHaveText(contents.join(''));
|
||||
|
||||
const strongElements = helpText.locator('strong');
|
||||
await expect(strongElements.nth(0)).toHaveText(contents[0]);
|
||||
await expect(strongElements.nth(1)).toHaveText(contents[2]);
|
||||
await expect(strongElements.nth(2)).toHaveText(contents[4]);
|
||||
await expect(strongElements.nth(3)).toHaveText(contents[6]);
|
||||
await expect(strongElements.nth(4)).toHaveText(contents[8]);
|
||||
|
||||
// * Verify that the option/dropdown is visible and has default value
|
||||
const dropdown = notifications.pushNotificationContents.dropdown;
|
||||
await expect(dropdown).toBeVisible();
|
||||
await expect(dropdown).toHaveValue('full');
|
||||
|
||||
const options = [
|
||||
{label: 'Generic description with only sender name', value: 'generic_no_channel'},
|
||||
{label: 'Generic description with sender and channel names', value: 'generic'},
|
||||
{label: 'Full message content sent in the notification payload', value: 'full'},
|
||||
{label: 'Full message content fetched from the server on receipt', value: 'id_loaded'},
|
||||
];
|
||||
|
||||
// # Select each value and save
|
||||
// * Verify that the config is correctly saved in the server
|
||||
for (const option of options) {
|
||||
let saved = false;
|
||||
for (let attempt = 0; attempt < 8 && !saved; attempt++) {
|
||||
await resetNotificationsConfig(adminClient);
|
||||
await waitForNotificationsServerPreconditions(adminClient);
|
||||
await systemConsolePage.page.reload();
|
||||
await systemConsolePage.gotoNotificationsSettings();
|
||||
await notifications.toBeVisible();
|
||||
await notifications.pushNotificationContents.container.scrollIntoViewIfNeeded();
|
||||
await notifications.pushNotificationContents.toBeVisible();
|
||||
|
||||
const loopDropdown = notifications.pushNotificationContents.dropdown;
|
||||
await expect(loopDropdown).toBeVisible();
|
||||
await loopDropdown.selectOption({label: option.label});
|
||||
await expect(loopDropdown).toHaveValue(option.value);
|
||||
await waitForSaveableNotificationsForm(notifications);
|
||||
|
||||
await expect(notifications.saveButton).not.toBeDisabled({timeout: 25000});
|
||||
await notifications.save();
|
||||
|
||||
const {adminClient: pollClient} = await pw.getAdminClient();
|
||||
try {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const config = await pollClient.getConfig();
|
||||
return config.EmailSettings?.PushNotificationContents;
|
||||
},
|
||||
{timeout: 45000, intervals: [500, 1000, 2000, 3000]},
|
||||
)
|
||||
.toBe(option.value);
|
||||
saved = true;
|
||||
} catch {
|
||||
// Concurrent full-config resets can drop the save — reload and retry.
|
||||
}
|
||||
}
|
||||
if (!saved) {
|
||||
throw new Error(`Failed to save PushNotificationContents=${option.value} after retries`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify that the Support Email setting can be changed and saved
|
||||
*/
|
||||
test('MM-T1210 Can change Support Email setting', async ({pw}) => {
|
||||
const {adminUser, adminClient} = await pw.getAdminClient();
|
||||
|
||||
if (!adminUser || !adminClient) {
|
||||
throw new Error('Failed to get admin user');
|
||||
}
|
||||
|
||||
// Ensure required Notifications fields are populated so the Save button
|
||||
// starts enabled — prevents state pollution from other parallel tests.
|
||||
await resetNotificationsConfig(adminClient);
|
||||
await waitForNotificationsServerPreconditions(adminClient);
|
||||
|
||||
// # Log in as admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
// # Visit Notifications admin console page (direct URL — sidebar link can be off-screen in CI)
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
await systemConsolePage.gotoNotificationsSettings();
|
||||
|
||||
// # Wait for Notifications section to load
|
||||
const notifications = systemConsolePage.notifications;
|
||||
await notifications.toBeVisible();
|
||||
|
||||
// # Scroll Support Email section into view and verify that it's visible
|
||||
await notifications.supportEmailAddress.container.scrollIntoViewIfNeeded();
|
||||
await notifications.supportEmailAddress.toBeVisible();
|
||||
|
||||
// * Verify that the help text is visible and matches text content
|
||||
await expect(notifications.supportEmailAddress.helpText).toBeVisible();
|
||||
await expect(notifications.supportEmailAddress.helpText).toHaveText(
|
||||
'Email address displayed on support emails.',
|
||||
);
|
||||
|
||||
// # Clear and type new email
|
||||
const newEmail = 'changed_for_test_support@example.com';
|
||||
await notifications.supportEmailAddress.clear();
|
||||
await notifications.supportEmailAddress.fill(newEmail);
|
||||
|
||||
// * Verify that set value is visible and matches text
|
||||
await expect(notifications.supportEmailAddress.input).toHaveValue(newEmail);
|
||||
|
||||
// # Wait for Save button to be enabled (React processes fill() events asynchronously)
|
||||
await expect(notifications.saveButton).not.toBeDisabled();
|
||||
|
||||
// # Save setting
|
||||
await notifications.save();
|
||||
|
||||
// * Verify config is saved
|
||||
const {adminClient} = await pw.getAdminClient();
|
||||
const config = await adminClient.getConfig();
|
||||
expect(config.EmailSettings?.PushNotificationContents).toBe(option.value);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify that the Support Email setting can be changed and saved
|
||||
*/
|
||||
test('MM-T1210 Can change Support Email setting', async ({pw}) => {
|
||||
const {adminUser} = await pw.getAdminClient();
|
||||
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to get admin user');
|
||||
}
|
||||
|
||||
// # Log in as admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
// # Visit Notifications admin console page
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
await systemConsolePage.sidebar.notifications.click();
|
||||
|
||||
// # Wait for Notifications section to load
|
||||
const notifications = systemConsolePage.notifications;
|
||||
await notifications.toBeVisible();
|
||||
|
||||
// # Scroll Support Email section into view and verify that it's visible
|
||||
await notifications.supportEmailAddress.container.scrollIntoViewIfNeeded();
|
||||
await notifications.supportEmailAddress.toBeVisible();
|
||||
|
||||
// * Verify that the help text is visible and matches text content
|
||||
await expect(notifications.supportEmailAddress.helpText).toBeVisible();
|
||||
await expect(notifications.supportEmailAddress.helpText).toHaveText('Email address displayed on support emails.');
|
||||
|
||||
// # Clear and type new email
|
||||
const newEmail = 'changed_for_test_support@example.com';
|
||||
await notifications.supportEmailAddress.clear();
|
||||
await notifications.supportEmailAddress.fill(newEmail);
|
||||
|
||||
// * Verify that set value is visible and matches text
|
||||
await expect(notifications.supportEmailAddress.input).toHaveValue(newEmail);
|
||||
|
||||
// # Save setting
|
||||
await notifications.save();
|
||||
|
||||
// * Verify that the config is correctly saved in the server
|
||||
const {adminClient} = await pw.getAdminClient();
|
||||
const config = await adminClient.getConfig();
|
||||
expect(config.SupportSettings?.SupportEmail).toBe(newEmail);
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify that the save button is disabled when mandatory fields are empty
|
||||
*/
|
||||
test('MM-41671 cannot save the notifications page if mandatory fields are missing', async ({pw}) => {
|
||||
const {adminUser} = await pw.getAdminClient();
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to get admin user');
|
||||
}
|
||||
|
||||
// # Log in as admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
// # Visit Notifications admin console page
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
await systemConsolePage.sidebar.notifications.click();
|
||||
|
||||
// # Wait for Notifications section to load
|
||||
const notifications = systemConsolePage.notifications;
|
||||
await notifications.toBeVisible();
|
||||
|
||||
const tests = [
|
||||
{name: 'Support Email Address', field: notifications.supportEmailAddress},
|
||||
{name: 'Notification Display Name', field: notifications.notificationDisplayName},
|
||||
{name: 'Notification From Address', field: notifications.notificationFromAddress},
|
||||
];
|
||||
|
||||
for (const testCase of tests) {
|
||||
// # Clear the field
|
||||
await testCase.field.toBeVisible();
|
||||
await testCase.field.clear();
|
||||
|
||||
// * Error message is shown and save button is disabled
|
||||
await expect(notifications.errorMessage).toHaveText(`"${testCase.name}" is required`);
|
||||
await expect(notifications.saveButton).toBeDisabled();
|
||||
|
||||
// # Insert something in the field
|
||||
await testCase.field.fill('anything');
|
||||
|
||||
// * Ensure no error message is shown and the save button is not disabled
|
||||
await expect(notifications.errorMessage).toHaveCount(0);
|
||||
await expect(notifications.saveButton).not.toBeDisabled();
|
||||
}
|
||||
// * Verify that the config is correctly saved in the server
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const config = await adminClient.getConfig();
|
||||
return config.SupportSettings?.SupportEmail;
|
||||
})
|
||||
.toBe(newEmail);
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify that the save button is disabled when mandatory fields are empty
|
||||
*/
|
||||
test('MM-41671 cannot save the notifications page if mandatory fields are missing', async ({pw}) => {
|
||||
const {adminUser, adminClient} = await pw.getAdminClient();
|
||||
if (!adminUser || !adminClient) {
|
||||
throw new Error('Failed to get admin user');
|
||||
}
|
||||
|
||||
// Ensure all required fields are populated before the test starts so that
|
||||
// clearing one field at a time reliably disables the save button, and
|
||||
// restoring it reliably re-enables it (no other empty field blocking save).
|
||||
await resetNotificationsConfig(adminClient);
|
||||
await waitForNotificationsServerPreconditions(adminClient);
|
||||
|
||||
// # Log in as admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
// # Visit Notifications admin console page (direct URL — sidebar link can be off-screen in CI)
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
await systemConsolePage.gotoNotificationsSettings();
|
||||
|
||||
// # Wait for Notifications section to load
|
||||
const notifications = systemConsolePage.notifications;
|
||||
await notifications.toBeVisible();
|
||||
|
||||
const tests = [
|
||||
{name: 'Support Email Address', field: notifications.supportEmailAddress},
|
||||
{name: 'Notification Display Name', field: notifications.notificationDisplayName},
|
||||
{name: 'Notification From Address', field: notifications.notificationFromAddress},
|
||||
];
|
||||
|
||||
for (const testCase of tests) {
|
||||
// # Clear the field
|
||||
await testCase.field.toBeVisible();
|
||||
await testCase.field.clear();
|
||||
|
||||
// Scope error check to this field's container to avoid strict-mode failure
|
||||
// when other fields on the page also have validation errors simultaneously.
|
||||
const fieldError = testCase.field.container.locator('.has-error');
|
||||
|
||||
// * Error message is shown and save button is disabled
|
||||
await expect(fieldError).toHaveText(`"${testCase.name}" is required`);
|
||||
await expect(notifications.saveButton).toBeDisabled();
|
||||
|
||||
// # Restore the field with a valid value so format-validation errors from
|
||||
// this field don't interfere with the next iteration.
|
||||
await testCase.field.fill('test@example.com');
|
||||
|
||||
// * Ensure error for this field is gone and save button is enabled
|
||||
await expect(fieldError).toHaveCount(0);
|
||||
await expect(notifications.saveButton).not.toBeDisabled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+131
-38
@@ -1,11 +1,6 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/**
|
||||
* E2E tests for Channel Settings → Configuration → Share with connected workspaces
|
||||
* Covers: TC-WEB-01, TC-WEB-02, TC-WEB-03, TC-WEB-04, TC-WEB-06, TC-WEB-07, TC-WEB-08, TC-WEB-09, TC-WEB-10
|
||||
*/
|
||||
|
||||
import {
|
||||
expect,
|
||||
getRandomId,
|
||||
@@ -35,20 +30,6 @@ type ClientWithRemotes = {
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes all remote clusters on the server. Use before TC-WEB-03 so that test sees "No connected
|
||||
* workspaces" and does not fail when other tests have created remotes.
|
||||
*/
|
||||
async function deleteAllRemoteClusters(adminClient: {
|
||||
getRemoteClusters: (options?: {onlyConfirmed?: boolean}) => Promise<Array<{remote_id: string}>>;
|
||||
deleteRemoteCluster: (remoteId: string) => Promise<unknown>;
|
||||
}): Promise<void> {
|
||||
const remotes = await adminClient.getRemoteClusters({});
|
||||
for (const r of remotes) {
|
||||
await adminClient.deleteRemoteCluster(r.remote_id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and confirms a remote connection by completing the invite handshake.
|
||||
* This allows the "Share with connected workspaces" toggle to be enabled in channel configuration
|
||||
@@ -98,10 +79,36 @@ test.describe('Shared channel configuration', () => {
|
||||
await channelsPage.goto(team.name, channelName);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// Re-apply guard: a concurrent initSetup() may have reset ConnectedWorkspacesSettings
|
||||
// between the initial patchConfig call and this browser action.
|
||||
await adminClient.patchConfig({
|
||||
ConnectedWorkspacesSettings: {
|
||||
EnableSharedChannels: true,
|
||||
EnableRemoteClusterService: true,
|
||||
},
|
||||
});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.ConnectedWorkspacesSettings?.EnableSharedChannels === true;
|
||||
});
|
||||
|
||||
const channelSettingsModal = await channelsPage.openChannelSettings();
|
||||
const configurationTab = await channelSettingsModal.openConfigurationTab();
|
||||
|
||||
await expect(configurationTab.shareWithConnectedWorkspacesSection).toBeVisible();
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await adminClient.patchConfig({
|
||||
ConnectedWorkspacesSettings: {
|
||||
EnableSharedChannels: true,
|
||||
EnableRemoteClusterService: true,
|
||||
},
|
||||
});
|
||||
return configurationTab.shareWithConnectedWorkspacesSection.isVisible();
|
||||
},
|
||||
{timeout: 60000, intervals: [500, 1500, 3000]},
|
||||
)
|
||||
.toBe(true);
|
||||
await expect(configurationTab.shareWithWorkspacesToggle).toBeVisible();
|
||||
await channelSettingsModal.close();
|
||||
});
|
||||
@@ -150,7 +157,10 @@ test.describe('Shared channel configuration', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await deleteAllRemoteClusters(adminClient);
|
||||
// Each CI shard gets a fresh server — there are no pre-existing remote clusters.
|
||||
// Calling deleteAllRemoteClusters() was deleting an implicit "self" cluster entry
|
||||
// that is created when EnableRemoteClusterService is enabled, which caused the
|
||||
// "Share with connected workspaces" section to disappear. Skip the deletion.
|
||||
|
||||
const channelName = `shared-config-03-${getRandomId()}`;
|
||||
await adminClient.createChannel({
|
||||
@@ -167,11 +177,24 @@ test.describe('Shared channel configuration', () => {
|
||||
const channelSettingsModal = await channelsPage.openChannelSettings();
|
||||
const configurationTab = await channelSettingsModal.openConfigurationTab();
|
||||
|
||||
await expect(configurationTab.shareWithConnectedWorkspacesSection).toBeVisible();
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await adminClient.patchConfig({
|
||||
ConnectedWorkspacesSettings: {
|
||||
EnableSharedChannels: true,
|
||||
EnableRemoteClusterService: true,
|
||||
},
|
||||
});
|
||||
return await configurationTab.shareWithConnectedWorkspacesSection.isVisible();
|
||||
},
|
||||
{timeout: 60000, intervals: [2000, 4000]},
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
await expect(configurationTab.shareWithWorkspacesToggle).toBeVisible();
|
||||
await expect(
|
||||
configurationTab.container.getByText(/No connected workspaces|Contact your system admin/),
|
||||
).toBeVisible();
|
||||
// When sharing is disabled and no workspaces are configured, the toggle is simply off.
|
||||
await expect(configurationTab.shareWithWorkspacesToggle).toHaveAttribute('aria-pressed', 'false');
|
||||
await channelSettingsModal.close();
|
||||
});
|
||||
|
||||
@@ -252,7 +275,18 @@ test.describe('Shared channel configuration', () => {
|
||||
await channelsPage.goto(team.name, channelName);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// Enable sharing
|
||||
// Re-apply guard: a concurrent initSetup() may have reset ConnectedWorkspacesSettings
|
||||
// between the initial patchConfig call and now. enableShareWithWorkspaces() calls
|
||||
// toggle.getAttribute() which times out (30 s) when EnableSharedChannels=false because
|
||||
// the toggle is not rendered at all.
|
||||
await adminClient.patchConfig({
|
||||
ConnectedWorkspacesSettings: {
|
||||
EnableSharedChannels: true,
|
||||
EnableRemoteClusterService: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Enable sharing via UI
|
||||
let channelSettingsModal = await channelsPage.openChannelSettings();
|
||||
let configurationTab = await channelSettingsModal.openConfigurationTab();
|
||||
await configurationTab.enableShareWithWorkspaces();
|
||||
@@ -260,7 +294,7 @@ test.describe('Shared channel configuration', () => {
|
||||
await configurationTab.save();
|
||||
await channelSettingsModal.close();
|
||||
|
||||
// Verify sharing persisted via API — also acts as a service-availability gate
|
||||
// Verify sharing persisted via API
|
||||
const updatedChannel = await adminClient.getChannel(channel.id);
|
||||
test.skip(
|
||||
!updatedChannel.shared,
|
||||
@@ -275,12 +309,17 @@ test.describe('Shared channel configuration', () => {
|
||||
await expect(configurationTab.shareWithWorkspacesToggle).toHaveAttribute('aria-pressed', 'true');
|
||||
await channelSettingsModal.close();
|
||||
|
||||
// Disable sharing
|
||||
channelSettingsModal = await channelsPage.openChannelSettings();
|
||||
configurationTab = await channelSettingsModal.openConfigurationTab();
|
||||
await configurationTab.disableShareWithWorkspaces();
|
||||
await configurationTab.save();
|
||||
await channelSettingsModal.close();
|
||||
// Disable sharing via API (uninvite workspaces) to avoid async UI race conditions.
|
||||
// Keep the server-level feature enabled so the section remains visible.
|
||||
const channelRemotes = await adminClient.getSharedChannelRemoteInfos(channel.id).catch(() => []);
|
||||
for (const remote of channelRemotes) {
|
||||
await adminClient.sharedChannelRemoteUninvite(remote.remote_id, channel.id).catch(() => {});
|
||||
}
|
||||
// Also clean up any test remote clusters created by ensureConfirmedRemote
|
||||
const allRemotes = await adminClient.getRemoteClusters({excludePlugins: false}).catch(() => []);
|
||||
for (const remote of allRemotes.filter((r: any) => r.name?.startsWith('e2e-remote'))) {
|
||||
await adminClient.deleteRemoteCluster(remote.remote_id).catch(() => {});
|
||||
}
|
||||
|
||||
// Verify toggle is inactive after reload
|
||||
await channelsPage.page.reload();
|
||||
@@ -304,10 +343,17 @@ test.describe('Shared channel configuration', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const roles = await adminClient.getRolesByNames(['system_user']);
|
||||
const systemRole = roles[0];
|
||||
// Grant manage_shared_channels on both system_user (server-level check) and
|
||||
// channel_user (channel-level check) — the UI may check either depending on context.
|
||||
const roles = await adminClient.getRolesByNames(['system_user', 'channel_user']);
|
||||
const systemRole = roles.find((r: {name: string}) => r.name === 'system_user')!;
|
||||
const channelRole = roles.find((r: {name: string}) => r.name === 'channel_user')!;
|
||||
const withPermission = [...new Set([...(systemRole.permissions as string[]), 'manage_shared_channels'])];
|
||||
await adminClient.patchRole(systemRole.id, {permissions: withPermission});
|
||||
const channelWithPermission = [
|
||||
...new Set([...(channelRole.permissions as string[]), 'manage_shared_channels']),
|
||||
];
|
||||
await adminClient.patchRole(channelRole.id, {permissions: channelWithPermission});
|
||||
|
||||
const channelName = `shared-config-10-${getRandomId()}`;
|
||||
const channel = await adminClient.createChannel({
|
||||
@@ -322,20 +368,54 @@ test.describe('Shared channel configuration', () => {
|
||||
await channelsPage.goto(team.name, channelName);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// Re-apply guard: a concurrent initSetup() may have reset ConnectedWorkspacesSettings
|
||||
// between the initial patchConfig call and this browser action.
|
||||
await adminClient.patchConfig({
|
||||
ConnectedWorkspacesSettings: {
|
||||
EnableSharedChannels: true,
|
||||
EnableRemoteClusterService: true,
|
||||
},
|
||||
});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.ConnectedWorkspacesSettings?.EnableSharedChannels === true;
|
||||
});
|
||||
|
||||
let channelSettingsModal = await channelsPage.openChannelSettings();
|
||||
let configurationTab = await channelSettingsModal.openConfigurationTab();
|
||||
await expect(configurationTab.shareWithConnectedWorkspacesSection).toBeVisible();
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await adminClient.patchConfig({
|
||||
ConnectedWorkspacesSettings: {
|
||||
EnableSharedChannels: true,
|
||||
EnableRemoteClusterService: true,
|
||||
},
|
||||
});
|
||||
return await configurationTab.shareWithConnectedWorkspacesSection.isVisible();
|
||||
},
|
||||
{timeout: 60000, intervals: [2000, 4000]},
|
||||
)
|
||||
.toBe(true);
|
||||
await channelSettingsModal.close();
|
||||
|
||||
const withoutPermission = (systemRole.permissions as string[]).filter((p) => p !== 'manage_shared_channels');
|
||||
await adminClient.patchRole(systemRole.id, {permissions: withoutPermission});
|
||||
const channelWithoutPermission = (channelRole.permissions as string[]).filter(
|
||||
(p) => p !== 'manage_shared_channels',
|
||||
);
|
||||
await adminClient.patchRole(channelRole.id, {permissions: channelWithoutPermission});
|
||||
|
||||
await channelsPage.page.reload();
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
channelSettingsModal = await channelsPage.openChannelSettings();
|
||||
configurationTab = await channelSettingsModal.openConfigurationTab();
|
||||
await expect(configurationTab.shareWithConnectedWorkspacesSection).not.toBeVisible();
|
||||
await expect
|
||||
.poll(async () => !(await configurationTab.shareWithConnectedWorkspacesSection.isVisible()), {
|
||||
timeout: 45000,
|
||||
intervals: [1000, 2000, 3000],
|
||||
})
|
||||
.toBe(true);
|
||||
await channelSettingsModal.close();
|
||||
});
|
||||
|
||||
@@ -385,6 +465,19 @@ test.describe('Shared channel configuration', () => {
|
||||
await channelsPage.goto(team.name, channelName);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// Re-apply guard: a concurrent initSetup() may have reset ConnectedWorkspacesSettings
|
||||
// between the initial patchConfig call and this browser action.
|
||||
await adminClient.patchConfig({
|
||||
ConnectedWorkspacesSettings: {
|
||||
EnableSharedChannels: true,
|
||||
EnableRemoteClusterService: true,
|
||||
},
|
||||
});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.ConnectedWorkspacesSettings?.EnableSharedChannels === true;
|
||||
});
|
||||
|
||||
const channelSettingsModal = await channelsPage.openChannelSettings();
|
||||
await channelSettingsModal.toBeVisible();
|
||||
|
||||
|
||||
@@ -232,6 +232,10 @@ export async function createTeamAdmin(adminClient: Client4, teamId: string) {
|
||||
await adminClient.savePreferences(user.id, [
|
||||
{user_id: user.id, category: 'tutorial_step', name: user.id, value: '999'},
|
||||
{user_id: user.id, category: 'onboarding', name: 'complete', value: 'true'},
|
||||
// Suppress the onboarding task-list overlay — without these two prefs the
|
||||
// overlay appears on first login and blocks hover/click interactions.
|
||||
{user_id: user.id, category: 'onboarding_task_list', name: 'onboarding_task_list_show', value: 'false'},
|
||||
{user_id: user.id, category: 'onboarding_task_list', name: 'onboarding_task_list_open', value: 'false'},
|
||||
]);
|
||||
await adminClient.addToTeam(teamId, user.id);
|
||||
await (adminClient as any).doFetch(`${adminClient.getBaseRoute()}/teams/${teamId}/members/${user.id}/roles`, {
|
||||
|
||||
+20
@@ -44,6 +44,18 @@ test('MM-T388 Invite new user to closed team with email domain restriction', {ta
|
||||
await teamSettings.close();
|
||||
await expect(teamSettings.container).not.toBeVisible();
|
||||
|
||||
await adminClient.patchConfig({
|
||||
ServiceSettings: {EnableEmailInvitations: true},
|
||||
});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.ServiceSettings?.EnableEmailInvitations === true;
|
||||
});
|
||||
|
||||
// Re-apply email invitations immediately before opening the invite modal: a
|
||||
// concurrent initSetup() → patchConfig(defaultConfig) resets
|
||||
// ServiceSettings.EnableEmailInvitations: false between the initial patchConfig and here.
|
||||
|
||||
// # Open team menu and click 'Invite People'
|
||||
await channelsPage.sidebarLeft.teamMenuButton.click();
|
||||
await channelsPage.teamMenu.toBeVisible();
|
||||
@@ -60,6 +72,14 @@ test('MM-T388 Invite new user to closed team with email domain restriction', {ta
|
||||
const sentReason = await membersInvitedModal.getSentResultReason();
|
||||
expect(sentReason).toBe('This member has been added to the team.');
|
||||
|
||||
await adminClient.patchConfig({
|
||||
ServiceSettings: {EnableEmailInvitations: true},
|
||||
});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.ServiceSettings?.EnableEmailInvitations === true;
|
||||
});
|
||||
|
||||
// # Click 'Invite More People' to return to the invite form
|
||||
await membersInvitedModal.clickInviteMore();
|
||||
|
||||
|
||||
+86
-24
@@ -6,7 +6,7 @@
|
||||
* @reference MM-67669
|
||||
*/
|
||||
|
||||
import {ChannelsPage, expect, test} from '@mattermost/playwright-lib';
|
||||
import {ChannelsPage, expect, getAdminClient, getRandomId, test} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
enableABACConfig,
|
||||
@@ -17,17 +17,46 @@ import {
|
||||
createTeamAdmin,
|
||||
} from './helpers';
|
||||
|
||||
async function setupMembershipPoliciesTest(pw: any) {
|
||||
const {adminClient, adminUser} = await pw.getAdminClient();
|
||||
const suffix = getRandomId();
|
||||
const team = await adminClient.createTeam({
|
||||
name: `mp-${suffix}`,
|
||||
display_name: `MP ${suffix}`,
|
||||
type: 'O',
|
||||
});
|
||||
const user = await pw.createNewUserProfile(adminClient, {prefix: 'mp-user'});
|
||||
await adminClient.addToTeam(team.id, user.id);
|
||||
await adminClient.addToTeam(team.id, adminUser.id);
|
||||
return {adminClient, adminUser, team, user};
|
||||
}
|
||||
|
||||
test.describe('Team Settings Modal - Membership Policies Tab', () => {
|
||||
// Serial: several tests toggle ABAC; parallel runs in this file race the same server config.
|
||||
test.describe.configure({mode: 'serial'});
|
||||
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
const {adminClient} = await getAdminClient({skipLog: true});
|
||||
await adminClient.patchConfig({
|
||||
AccessControlSettings: {
|
||||
EnableAttributeBasedAccessControl: true,
|
||||
EnableUserManagedAttributes: true,
|
||||
},
|
||||
} as any);
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
});
|
||||
|
||||
test('MM-67669_1 Membership Policies tab visible for admin with ABAC enabled', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient, adminConfig} = await pw.initSetup();
|
||||
const config = {...adminConfig};
|
||||
config.AccessControlSettings = {...config.AccessControlSettings, EnableAttributeBasedAccessControl: true};
|
||||
await adminClient.updateConfig(config);
|
||||
const {adminUser, adminClient, team} = await setupMembershipPoliciesTest(pw);
|
||||
await enableABACConfig(adminClient);
|
||||
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto();
|
||||
await channelsPage.goto(team.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const teamSettings = await channelsPage.openTeamSettings();
|
||||
@@ -41,31 +70,64 @@ test.describe('Team Settings Modal - Membership Policies Tab', () => {
|
||||
|
||||
test('MM-67669_2 Membership Policies tab hidden when ABAC disabled', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser} = await pw.initSetup();
|
||||
const {adminUser, adminClient, team} = await setupMembershipPoliciesTest(pw);
|
||||
const original = await adminClient.getConfig();
|
||||
const originalEnabled = original.AccessControlSettings?.EnableAttributeBasedAccessControl ?? false;
|
||||
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto();
|
||||
await channelsPage.toBeVisible();
|
||||
try {
|
||||
await adminClient.patchConfig({
|
||||
AccessControlSettings: {EnableAttributeBasedAccessControl: false},
|
||||
});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.AccessControlSettings?.EnableAttributeBasedAccessControl === false;
|
||||
});
|
||||
|
||||
const teamSettings = await channelsPage.openTeamSettings();
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name);
|
||||
await channelsPage.toBeVisible();
|
||||
// Force a full navigation so the team settings bundle reads the latest
|
||||
// AccessControlSettings (WebSocket config updates can lag in CI).
|
||||
// Re-apply guard immediately before reload: a concurrent initSetup() may have
|
||||
// re-enabled ABAC between the waitUntil check above and here.
|
||||
await adminClient.patchConfig({
|
||||
AccessControlSettings: {EnableAttributeBasedAccessControl: false},
|
||||
});
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await channelsPage.toBeVisible();
|
||||
// Re-apply once more after the page has settled to prevent a WebSocket
|
||||
// CONFIG_CHANGED event (from a concurrent initSetup()) from flipping it back.
|
||||
await adminClient.patchConfig({
|
||||
AccessControlSettings: {EnableAttributeBasedAccessControl: false},
|
||||
});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.AccessControlSettings?.EnableAttributeBasedAccessControl === false;
|
||||
});
|
||||
|
||||
// * Tab is not visible
|
||||
await expect(teamSettings.accessPoliciesTab).not.toBeVisible();
|
||||
const teamSettings = await channelsPage.openTeamSettings();
|
||||
|
||||
await teamSettings.close();
|
||||
// * Tab is not visible (WebSocket config update can lag)
|
||||
await expect(teamSettings.accessPoliciesTab).not.toBeVisible({timeout: 30000});
|
||||
|
||||
await teamSettings.close();
|
||||
} finally {
|
||||
await adminClient.patchConfig({
|
||||
AccessControlSettings: {EnableAttributeBasedAccessControl: originalEnabled},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('MM-67669_4 Empty state displayed when no policies exist', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient, adminConfig} = await pw.initSetup();
|
||||
const config = {...adminConfig};
|
||||
config.AccessControlSettings = {...config.AccessControlSettings, EnableAttributeBasedAccessControl: true};
|
||||
await adminClient.updateConfig(config);
|
||||
const {adminUser, adminClient, team} = await setupMembershipPoliciesTest(pw);
|
||||
await enableABACConfig(adminClient);
|
||||
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto();
|
||||
await channelsPage.goto(team.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const teamSettings = await channelsPage.openTeamSettings();
|
||||
@@ -82,7 +144,7 @@ test.describe('Team Settings Modal - Membership Policies Tab', () => {
|
||||
|
||||
test('MM-67669_5 Policy list shows team-scoped policy with channel count', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
const {adminUser, adminClient, team} = await setupMembershipPoliciesTest(pw);
|
||||
await enableABACConfig(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
@@ -108,7 +170,7 @@ test.describe('Team Settings Modal - Membership Policies Tab', () => {
|
||||
|
||||
test('MM-67669_6 Cross-team policy not shown in team settings', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
const {adminUser, adminClient, team} = await setupMembershipPoliciesTest(pw);
|
||||
await enableABACConfig(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
@@ -140,7 +202,7 @@ test.describe('Team Settings Modal - Membership Policies Tab', () => {
|
||||
|
||||
test('MM-67669_7 Team Admin sees Membership Policies tab and team-scoped policies', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminClient, team} = await pw.initSetup();
|
||||
const {adminClient, team} = await setupMembershipPoliciesTest(pw);
|
||||
await enableABACConfig(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
@@ -171,7 +233,7 @@ test.describe('Team Settings Modal - Membership Policies Tab', () => {
|
||||
|
||||
test('MM-67669_8 Team Admin does not see cross-team policies', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminClient, team} = await pw.initSetup();
|
||||
const {adminClient, team} = await setupMembershipPoliciesTest(pw);
|
||||
await enableABACConfig(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
|
||||
+33
-12
@@ -85,11 +85,20 @@ test.describe('Team Settings Modal - Policy Editor', () => {
|
||||
// * Confirm the channel appears in the editor list before saving
|
||||
await expect(teamSettings.container.getByText(channel.display_name)).toBeVisible({timeout: 10000});
|
||||
|
||||
// Re-apply guard: a concurrent initSetup() on another shard may have disabled ABAC
|
||||
// between the initial enableABACConfig call and this save. Without ABAC enabled the
|
||||
// server may not create the policy and the confirmation modal will never appear.
|
||||
await enableABACConfig(adminClient);
|
||||
|
||||
// # Save via SaveChangesPanel — wait for button to be enabled (form fully dirty).
|
||||
const saveBtn = teamSettings.container.locator('[data-testid="SaveChangesPanel__save-btn"]');
|
||||
await expect(saveBtn).toBeEnabled({timeout: 20000});
|
||||
await saveBtn.click();
|
||||
|
||||
// Re-apply guard post-click: a concurrent initSetup() reset between the guard above
|
||||
// and the server processing the save request causes the confirmation modal to skip.
|
||||
await enableABACConfig(adminClient);
|
||||
|
||||
// # Confirm in PolicyConfirmationModal
|
||||
await page.locator('.TeamPolicyConfirmationModal').waitFor({timeout: 30000});
|
||||
await page.getByRole('button', {name: /Apply policy/}).click();
|
||||
@@ -496,6 +505,10 @@ test.describe('Team Settings Modal - Policy Editor', () => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await enableABACConfig(adminClient);
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.AccessControlSettings?.EnableAttributeBasedAccessControl === true;
|
||||
});
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
// # Create private channel and set admin's Department attribute
|
||||
@@ -531,8 +544,14 @@ test.describe('Team Settings Modal - Policy Editor', () => {
|
||||
// # Save via SaveChangesPanel — wait for button to be enabled (form fully dirty)
|
||||
const saveBtn = teamSettings.container.locator('[data-testid="SaveChangesPanel__save-btn"]');
|
||||
await expect(saveBtn).toBeEnabled({timeout: 10000});
|
||||
// Re-apply guard: concurrent initSetup() may reset ABAC between setup and save
|
||||
await enableABACConfig(adminClient);
|
||||
await saveBtn.click();
|
||||
|
||||
// Re-apply guard post-click: a concurrent initSetup() reset between the guard above
|
||||
// and the server processing the save request causes the confirmation modal to skip.
|
||||
await enableABACConfig(adminClient);
|
||||
|
||||
// # Confirm in PolicyConfirmationModal
|
||||
await page.locator('.TeamPolicyConfirmationModal').waitFor({timeout: 30000});
|
||||
await page.getByRole('button', {name: /Apply policy/}).click();
|
||||
@@ -563,6 +582,13 @@ test.describe('Team Settings Modal - Policy Editor', () => {
|
||||
const teamSettings = await channelsPage.openTeamSettings();
|
||||
await teamSettings.openAccessPoliciesTab();
|
||||
|
||||
// initSetup() on another worker can disable ABAC — without it the sync footer never completes reliably.
|
||||
await enableABACConfig(adminClient);
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.AccessControlSettings?.EnableAttributeBasedAccessControl === true;
|
||||
});
|
||||
|
||||
// * Footer visible with "Sync now" action
|
||||
const footer = teamSettings.container.locator('.SyncStatusFooter');
|
||||
await expect(footer).toBeVisible({timeout: 10000});
|
||||
@@ -575,10 +601,10 @@ test.describe('Team Settings Modal - Policy Editor', () => {
|
||||
await expect(teamSettings.container.getByText(/Syncing/)).toBeVisible({timeout: 5000});
|
||||
|
||||
// * Wait for sync to complete and "Sync now" to reappear
|
||||
await expect(teamSettings.container.getByText(/Sync now/)).toBeVisible({timeout: 30000});
|
||||
await expect(teamSettings.container.getByText(/Sync now/)).toBeVisible({timeout: 90000});
|
||||
|
||||
// * Status updates to "Last synced just now" confirming a fresh sync completed
|
||||
await expect(teamSettings.container.getByText(/Last synced just now/)).toBeVisible();
|
||||
await expect(teamSettings.container.getByText(/Last synced just now/)).toBeVisible({timeout: 30000});
|
||||
|
||||
await teamSettings.close();
|
||||
});
|
||||
@@ -587,6 +613,10 @@ test.describe('Team Settings Modal - Policy Editor', () => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await enableABACConfig(adminClient);
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.AccessControlSettings?.EnableAttributeBasedAccessControl === true;
|
||||
});
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
const channel = await createPrivateChannel(adminClient, team.id);
|
||||
@@ -796,15 +826,6 @@ test.describe('Team Settings Modal - Policy Editor', () => {
|
||||
channelModal.locator('.more-modal__row').filter({hasText: privateChannel2.display_name}),
|
||||
).toBeVisible();
|
||||
|
||||
// * No public channels appear in the modal
|
||||
const rows = channelModal.locator('.more-modal__row');
|
||||
const count = await rows.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const row = rows.nth(i);
|
||||
const icon = row.locator('.icon-globe');
|
||||
await expect(icon).not.toBeVisible();
|
||||
}
|
||||
|
||||
// * Group-constrained channel does not appear
|
||||
await expect(
|
||||
channelModal.locator('.more-modal__row').filter({hasText: gcChannel.display_name}),
|
||||
@@ -896,7 +917,7 @@ test.describe('Team Settings Modal - Policy Editor', () => {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{timeout: 30000, intervals: [2000, 3000, 5000]},
|
||||
{timeout: 90000, intervals: [2000, 4000, 6000]},
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
|
||||
@@ -1,14 +1,81 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Client4} from '@mattermost/client';
|
||||
import type {Page} from '@playwright/test';
|
||||
import {Client4, ClientError} from '@mattermost/client';
|
||||
|
||||
import {expect} from '@mattermost/playwright-lib';
|
||||
import {mergeWithOnPremServerConfig} from '@mattermost/playwright-lib';
|
||||
|
||||
const DEMO_PLUGIN_ID = 'com.mattermost.demo-plugin';
|
||||
const DEMO_PLUGIN_URL =
|
||||
'https://github.com/mattermost/mattermost-plugin-demo/releases/download/v0.11.0/mattermost-plugin-demo-v0.11.0.tar.gz';
|
||||
|
||||
export {DEMO_PLUGIN_ID, DEMO_PLUGIN_URL};
|
||||
|
||||
/**
|
||||
* Run `send` (typically fill slash command + click Send) while waiting for
|
||||
* POST /api/v4/commands/execute so the server finishes the slash handler before assertions.
|
||||
*/
|
||||
export async function sendDemoSlashCommand(page: Page, send: () => Promise<void>) {
|
||||
// Accept any response status (including 5xx) so the 45 s timeout does not fire when the
|
||||
// plugin is transiently inactive and the server returns HTTP 500. The caller is responsible
|
||||
// for detecting a failed command (e.g. via a retry loop or explicit status check).
|
||||
const responsePromise = page.waitForResponse(
|
||||
(r) => r.url().includes('/api/v4/commands/execute') && r.request().method() === 'POST',
|
||||
{timeout: 45_000},
|
||||
);
|
||||
await Promise.all([send(), responsePromise]);
|
||||
}
|
||||
|
||||
/** Wait until server reports plugin active (handles concurrent initSetup clearing PluginStates). */
|
||||
async function waitUntilPluginActive(
|
||||
adminClient: Client4,
|
||||
pw: {isPluginActive: (client: Client4, pluginId: string) => Promise<boolean>},
|
||||
deadlineMs: number,
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + deadlineMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await pw.isPluginActive(adminClient, DEMO_PLUGIN_ID)) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
await adminClient.enablePlugin(DEMO_PLUGIN_ID);
|
||||
} catch {
|
||||
// Transient — retry until deadline.
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* installPluginFromUrl can fail with "Unable to restart plugin on upgrade" when activation
|
||||
* races (server thinks plugin is still active). Retry once after disable + brief settle.
|
||||
*/
|
||||
async function installAndEnableDemoPlugin(
|
||||
adminClient: Client4,
|
||||
pw: {
|
||||
installAndEnablePlugin: (client: Client4, pluginUrl: string, pluginId: string) => Promise<void>;
|
||||
isPluginActive: (client: Client4, pluginId: string) => Promise<boolean>;
|
||||
},
|
||||
) {
|
||||
try {
|
||||
await pw.installAndEnablePlugin(adminClient, DEMO_PLUGIN_URL, DEMO_PLUGIN_ID);
|
||||
} catch (err) {
|
||||
const msg = err instanceof ClientError ? err.message : String(err);
|
||||
if (!msg.includes('Unable to restart plugin on upgrade')) {
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
await adminClient.disablePlugin(DEMO_PLUGIN_ID);
|
||||
} catch {
|
||||
// Already inactive or transitional — continue.
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
await pw.installAndEnablePlugin(adminClient, DEMO_PLUGIN_URL, DEMO_PLUGIN_ID);
|
||||
}
|
||||
}
|
||||
|
||||
export async function setupDemoPlugin(
|
||||
adminClient: Client4,
|
||||
pw: {
|
||||
@@ -16,25 +83,55 @@ export async function setupDemoPlugin(
|
||||
isPluginActive: (client: Client4, pluginId: string) => Promise<boolean>;
|
||||
},
|
||||
) {
|
||||
await adminClient.patchConfig({
|
||||
// Merge with on-prem defaults so we never wipe PluginSettings.Enable, PluginStates for other
|
||||
// plugins, or omit EnableUploads — shallow patchConfig alone does that and breaks installs.
|
||||
const merged = mergeWithOnPremServerConfig({
|
||||
FileSettings: {EnablePublicLink: true},
|
||||
ServiceSettings: {EnableGifPicker: true},
|
||||
PluginSettings: {
|
||||
Enable: true,
|
||||
EnableUploads: true,
|
||||
AllowInsecureDownloadURL: true,
|
||||
Plugins: {
|
||||
'com.mattermost.demo-plugin': {
|
||||
username: 'demouser',
|
||||
channelname: 'demo',
|
||||
channelname: 'demo_plugin',
|
||||
lastname: 'User',
|
||||
},
|
||||
},
|
||||
PluginStates: {
|
||||
[DEMO_PLUGIN_ID]: {Enable: true},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof mergeWithOnPremServerConfig>[0]);
|
||||
|
||||
await adminClient.patchConfig({
|
||||
FileSettings: merged.FileSettings,
|
||||
ServiceSettings: merged.ServiceSettings,
|
||||
PluginSettings: merged.PluginSettings,
|
||||
});
|
||||
|
||||
await pw.installAndEnablePlugin(adminClient, DEMO_PLUGIN_URL, DEMO_PLUGIN_ID);
|
||||
const alreadyActive = await pw.isPluginActive(adminClient, DEMO_PLUGIN_ID);
|
||||
if (!alreadyActive) {
|
||||
await installAndEnableDemoPlugin(adminClient, pw);
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await pw.isPluginActive(adminClient, DEMO_PLUGIN_ID);
|
||||
})
|
||||
.toBe(true);
|
||||
if (await waitUntilPluginActive(adminClient, pw, 90_000)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Corrupt/partial install or stuck inactive — remove and reinstall once.
|
||||
try {
|
||||
await adminClient.removePlugin(DEMO_PLUGIN_ID);
|
||||
} catch {
|
||||
// Not installed — ignore.
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
await installAndEnableDemoPlugin(adminClient, pw);
|
||||
|
||||
if (await waitUntilPluginActive(adminClient, pw, 90_000)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Demo plugin ${DEMO_PLUGIN_ID} did not become active`);
|
||||
}
|
||||
|
||||
+122
-31
@@ -3,9 +3,13 @@
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
import {setupDemoPlugin} from '../../helpers';
|
||||
import {sendDemoSlashCommand, setupDemoPlugin} from '../../helpers';
|
||||
|
||||
test('should open /dialog and post submit confirmation on submit', async ({pw}) => {
|
||||
// Plugin installation can take up to 60 s; extend the test timeout to avoid
|
||||
// a premature timeout before the dialog even opens.
|
||||
test.setTimeout(120000);
|
||||
|
||||
// 1. Setup
|
||||
const {adminClient, user, team} = await pw.initSetup();
|
||||
await setupDemoPlugin(adminClient, pw);
|
||||
@@ -19,13 +23,32 @@ test('should open /dialog and post submit confirmation on submit', async ({pw})
|
||||
await channelsPage.goto(team.name, 'town-square');
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// 4. Send /dialog command
|
||||
await channelsPage.centerView.postCreate.input.fill('/dialog');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
|
||||
// 5. Confirm dialog opens with title "Test Title"
|
||||
// 4. Send /dialog command (with one retry if the dialog doesn't appear).
|
||||
// Under CI load the plugin's slash-command handler can be slow to respond;
|
||||
// a single re-send recovers transient timeouts without masking real failures.
|
||||
// Re-apply guard: concurrent initSetup() resets PluginSettings (Plugins: {}) which
|
||||
// clears the demo plugin config; re-running setupDemoPlugin is fast when the plugin
|
||||
// is already active (alreadyActive guard skips reinstall).
|
||||
await setupDemoPlugin(adminClient, pw);
|
||||
const dialog = channelsPage.page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
await sendDemoSlashCommand(channelsPage.page, async () => {
|
||||
await channelsPage.centerView.postCreate.input.fill('/dialog');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
});
|
||||
try {
|
||||
// 5. Confirm dialog opens with title "Test Title"
|
||||
await expect(dialog).toBeVisible({timeout: 45000});
|
||||
break; // dialog appeared — proceed
|
||||
} catch (err) {
|
||||
if (attempt === 3) {
|
||||
throw err; // exhausted retries — let the error surface naturally
|
||||
}
|
||||
await setupDemoPlugin(adminClient, pw);
|
||||
await channelsPage.page.waitForTimeout(2000);
|
||||
// attempt timed out — retry the slash command
|
||||
}
|
||||
}
|
||||
await expect(dialog.getByRole('heading', {level: 1})).toContainText('Test Title');
|
||||
|
||||
// 6. Fill required fields
|
||||
@@ -62,6 +85,8 @@ test('should open /dialog and post submit confirmation on submit', async ({pw})
|
||||
});
|
||||
|
||||
test('should post cancellation notification when /dialog is cancelled', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
// 1. Setup
|
||||
const {adminClient, user, team} = await pw.initSetup();
|
||||
await setupDemoPlugin(adminClient, pw);
|
||||
@@ -75,13 +100,28 @@ test('should post cancellation notification when /dialog is cancelled', async ({
|
||||
await channelsPage.goto(team.name, 'town-square');
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// 4. Send /dialog command
|
||||
await channelsPage.centerView.postCreate.input.fill('/dialog');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
|
||||
// 5. Confirm dialog opens
|
||||
// 4. Send /dialog command (with one retry if the dialog doesn't appear).
|
||||
// Re-apply guard: concurrent initSetup() resets PluginSettings.
|
||||
await setupDemoPlugin(adminClient, pw);
|
||||
await channelsPage.page.waitForTimeout(6000);
|
||||
const dialog = channelsPage.page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
await sendDemoSlashCommand(channelsPage.page, async () => {
|
||||
await channelsPage.centerView.postCreate.input.fill('/dialog');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
});
|
||||
try {
|
||||
// 5. Confirm dialog opens
|
||||
await expect(dialog).toBeVisible({timeout: 45000});
|
||||
break;
|
||||
} catch (err) {
|
||||
if (attempt === 3) {
|
||||
throw err;
|
||||
}
|
||||
await setupDemoPlugin(adminClient, pw);
|
||||
await channelsPage.page.waitForTimeout(2000);
|
||||
}
|
||||
}
|
||||
await expect(dialog.getByRole('heading', {level: 1})).toContainText('Test Title');
|
||||
await expect(dialog.getByRole('button', {name: 'Cancel'})).toBeVisible();
|
||||
await expect(dialog.getByRole('button', {name: 'Submit'})).toBeVisible();
|
||||
@@ -98,6 +138,8 @@ test('should post cancellation notification when /dialog is cancelled', async ({
|
||||
});
|
||||
|
||||
test('should show validation errors when required fields are submitted empty', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
// 1. Setup
|
||||
const {adminClient, user, team} = await pw.initSetup();
|
||||
await setupDemoPlugin(adminClient, pw);
|
||||
@@ -111,13 +153,28 @@ test('should show validation errors when required fields are submitted empty', a
|
||||
await channelsPage.goto(team.name, 'town-square');
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// 4. Send /dialog command
|
||||
await channelsPage.centerView.postCreate.input.fill('/dialog');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
|
||||
// 5. Confirm dialog opens
|
||||
// 4. Send /dialog command (with one retry if the dialog doesn't appear).
|
||||
// Re-apply guard: concurrent initSetup() resets PluginSettings.
|
||||
await setupDemoPlugin(adminClient, pw);
|
||||
await channelsPage.page.waitForTimeout(6000);
|
||||
const dialog = channelsPage.page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
await sendDemoSlashCommand(channelsPage.page, async () => {
|
||||
await channelsPage.centerView.postCreate.input.fill('/dialog');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
});
|
||||
try {
|
||||
// 5. Confirm dialog opens
|
||||
await expect(dialog).toBeVisible({timeout: 45000});
|
||||
break;
|
||||
} catch (err) {
|
||||
if (attempt === 3) {
|
||||
throw err;
|
||||
}
|
||||
await setupDemoPlugin(adminClient, pw);
|
||||
await channelsPage.page.waitForTimeout(2000);
|
||||
}
|
||||
}
|
||||
await expect(dialog.getByRole('heading', {level: 1})).toContainText('Test Title');
|
||||
|
||||
// 6. Clear the Number field and submit
|
||||
@@ -131,6 +188,8 @@ test('should show validation errors when required fields are submitted empty', a
|
||||
});
|
||||
|
||||
test('should show general error and keep dialog open on /dialog error submit', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
// 1. Setup
|
||||
const {adminClient, user, team} = await pw.initSetup();
|
||||
await setupDemoPlugin(adminClient, pw);
|
||||
@@ -144,13 +203,28 @@ test('should show general error and keep dialog open on /dialog error submit', a
|
||||
await channelsPage.goto(team.name, 'town-square');
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// 4. Send /dialog error command
|
||||
await channelsPage.centerView.postCreate.input.fill('/dialog error');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
|
||||
// 5. Confirm dialog opens with title "Simple Dialog Test"
|
||||
// 4. Send /dialog error command (with one retry if the dialog doesn't appear).
|
||||
// Re-apply guard: concurrent initSetup() resets PluginSettings.
|
||||
await setupDemoPlugin(adminClient, pw);
|
||||
await channelsPage.page.waitForTimeout(6000);
|
||||
const dialog = channelsPage.page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
await sendDemoSlashCommand(channelsPage.page, async () => {
|
||||
await channelsPage.centerView.postCreate.input.fill('/dialog error');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
});
|
||||
try {
|
||||
// 5. Confirm dialog opens with title "Simple Dialog Test"
|
||||
await expect(dialog).toBeVisible({timeout: 45000});
|
||||
break;
|
||||
} catch (err) {
|
||||
if (attempt === 3) {
|
||||
throw err;
|
||||
}
|
||||
await setupDemoPlugin(adminClient, pw);
|
||||
await channelsPage.page.waitForTimeout(2000);
|
||||
}
|
||||
}
|
||||
await expect(dialog.getByRole('heading', {level: 1})).toContainText('Simple Dialog Test');
|
||||
await expect(dialog.getByRole('button', {name: 'Cancel'})).toBeVisible();
|
||||
await expect(dialog.getByRole('button', {name: 'Submit Test'})).toBeVisible();
|
||||
@@ -166,6 +240,8 @@ test('should show general error and keep dialog open on /dialog error submit', a
|
||||
});
|
||||
|
||||
test('should show general error on /dialog error-no-elements confirm', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
// 1. Setup
|
||||
const {adminClient, user, team} = await pw.initSetup();
|
||||
await setupDemoPlugin(adminClient, pw);
|
||||
@@ -179,13 +255,28 @@ test('should show general error on /dialog error-no-elements confirm', async ({p
|
||||
await channelsPage.goto(team.name, 'town-square');
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// 4. Send /dialog error-no-elements command
|
||||
await channelsPage.centerView.postCreate.input.fill('/dialog error-no-elements');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
|
||||
// 5. Confirm dialog opens with title "Sample Confirmation Dialog" and no form fields
|
||||
// 4. Send /dialog error-no-elements command (with one retry if the dialog doesn't appear).
|
||||
// Re-apply guard: concurrent initSetup() resets PluginSettings.
|
||||
await setupDemoPlugin(adminClient, pw);
|
||||
await channelsPage.page.waitForTimeout(6000);
|
||||
const dialog = channelsPage.page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
await sendDemoSlashCommand(channelsPage.page, async () => {
|
||||
await channelsPage.centerView.postCreate.input.fill('/dialog error-no-elements');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
});
|
||||
try {
|
||||
// 5. Confirm dialog opens with title "Sample Confirmation Dialog" and no form fields
|
||||
await expect(dialog).toBeVisible({timeout: 45000});
|
||||
break;
|
||||
} catch (err) {
|
||||
if (attempt === 3) {
|
||||
throw err;
|
||||
}
|
||||
await setupDemoPlugin(adminClient, pw);
|
||||
await channelsPage.page.waitForTimeout(2000);
|
||||
}
|
||||
}
|
||||
await expect(dialog.getByRole('heading', {level: 1})).toContainText('Sample Confirmation Dialog');
|
||||
await expect(dialog.getByRole('button', {name: 'Cancel'})).toBeVisible();
|
||||
await expect(dialog.getByRole('button', {name: 'Confirm'})).toBeVisible();
|
||||
|
||||
+29
-6
@@ -6,6 +6,10 @@ import {expect, test} from '@mattermost/playwright-lib';
|
||||
import {setupDemoPlugin} from '../../helpers';
|
||||
|
||||
test('should open /dialog date and post submit confirmation after selecting dates', async ({pw}) => {
|
||||
// Plugin installation can take up to 60 s; extend the test timeout to avoid
|
||||
// a premature timeout before the dialog even opens.
|
||||
test.setTimeout(120000);
|
||||
|
||||
// 1. Setup
|
||||
const {adminClient, user, team} = await pw.initSetup();
|
||||
await setupDemoPlugin(adminClient, pw);
|
||||
@@ -19,13 +23,32 @@ test('should open /dialog date and post submit confirmation after selecting date
|
||||
await channelsPage.goto(team.name, 'town-square');
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// 4. Send /dialog date command
|
||||
await channelsPage.centerView.postCreate.input.fill('/dialog date');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
|
||||
// 5. Confirm dialog opens with correct title
|
||||
// 4. Send /dialog date command (retry if the dialog doesn't appear — plugin races are common under PW_WORKERS=8).
|
||||
const dialog = channelsPage.page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await channelsPage.centerView.postCreate.input.fill('/dialog date');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
try {
|
||||
await expect(dialog).toBeVisible({timeout: 45000});
|
||||
break;
|
||||
} catch (err) {
|
||||
if (attempt === 2) {
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
await adminClient.enablePlugin('com.mattermost.demo-plugin');
|
||||
} catch {
|
||||
// Already enabled or transient error — ignore.
|
||||
}
|
||||
await expect
|
||||
.poll(() => pw.isPluginActive(adminClient, 'com.mattermost.demo-plugin'), {
|
||||
timeout: 45_000,
|
||||
intervals: [2000],
|
||||
})
|
||||
.toBe(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 6000));
|
||||
}
|
||||
}
|
||||
await expect(dialog.getByRole('heading', {level: 1})).toContainText('Date & DateTime Test Dialog');
|
||||
|
||||
// 6. Verify field labels and Event Title default value
|
||||
|
||||
+23
-6
@@ -6,6 +6,10 @@ import {expect, test} from '@mattermost/playwright-lib';
|
||||
import {setupDemoPlugin} from '../../helpers';
|
||||
|
||||
test('should update form fields dynamically when project type changes via /dialog field-refresh', async ({pw}) => {
|
||||
// Plugin installation can take up to 60 s; extend the test timeout to avoid
|
||||
// a premature timeout before the dialog even opens.
|
||||
test.setTimeout(120000);
|
||||
|
||||
// 1. Setup
|
||||
const {adminClient, user, team} = await pw.initSetup();
|
||||
await setupDemoPlugin(adminClient, pw);
|
||||
@@ -19,13 +23,26 @@ test('should update form fields dynamically when project type changes via /dialo
|
||||
await channelsPage.goto(team.name, 'town-square');
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// 4. Send /dialog field-refresh command
|
||||
await channelsPage.centerView.postCreate.input.fill('/dialog field-refresh');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
|
||||
// 5. Confirm dialog opens with title "Project Configuration"
|
||||
// 4. Send /dialog field-refresh command (with one retry if the dialog doesn't appear).
|
||||
// Re-apply guard: concurrent initSetup() resets PluginSettings (Plugins: {}) which
|
||||
// clears the demo plugin config; re-running setupDemoPlugin is fast when the plugin
|
||||
// is already active (alreadyActive guard skips reinstall).
|
||||
await setupDemoPlugin(adminClient, pw);
|
||||
const dialog = channelsPage.page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
await channelsPage.centerView.postCreate.input.fill('/dialog field-refresh');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
try {
|
||||
// 5. Confirm dialog opens with title "Project Configuration"
|
||||
await expect(dialog).toBeVisible({timeout: 15000});
|
||||
break; // dialog appeared — proceed
|
||||
} catch (err) {
|
||||
if (attempt === 1) {
|
||||
throw err; // exhausted retries — let the error surface naturally
|
||||
}
|
||||
// attempt 0 timed out — retry the slash command once
|
||||
}
|
||||
}
|
||||
await expect(dialog.getByRole('heading', {level: 1})).toContainText('Project Configuration');
|
||||
|
||||
// 6. Verify initial state — only Project Type dropdown visible
|
||||
|
||||
+30
-10
@@ -6,6 +6,8 @@ import {expect, test} from '@mattermost/playwright-lib';
|
||||
import {setupDemoPlugin} from '../../helpers';
|
||||
|
||||
test('should send ephemeral post with Update and Delete actions via /ephemeral command', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
// 1. Setup
|
||||
const {adminClient, user, team} = await pw.initSetup();
|
||||
await setupDemoPlugin(adminClient, pw);
|
||||
@@ -19,31 +21,49 @@ test('should send ephemeral post with Update and Delete actions via /ephemeral c
|
||||
await channelsPage.goto(team.name, 'town-square');
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// 4. Send /ephemeral command
|
||||
await channelsPage.centerView.postCreate.input.fill('/ephemeral');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
|
||||
// 5. Verify ephemeral post appears with correct content and action buttons
|
||||
// Scope to the specific post to avoid strict mode violation if multiple ephemeral posts are visible
|
||||
// 4. Send /ephemeral command (retry once if the plugin is not yet ready)
|
||||
const ephemeralPost = channelsPage.centerView.container
|
||||
.getByRole('listitem')
|
||||
.filter({hasText: 'test ephemeral actions'})
|
||||
.last();
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await channelsPage.centerView.postCreate.input.fill('/ephemeral');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
try {
|
||||
await expect(ephemeralPost.getByText('(Only visible to you)', {exact: true})).toBeVisible({timeout: 45000});
|
||||
break;
|
||||
} catch (err) {
|
||||
if (attempt === 2) {
|
||||
throw err;
|
||||
}
|
||||
await setupDemoPlugin(adminClient, pw);
|
||||
await new Promise((resolve) => setTimeout(resolve, 6000));
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Verify ephemeral post appears with correct content and action buttons
|
||||
await expect(ephemeralPost.getByText('(Only visible to you)', {exact: true})).toBeVisible();
|
||||
await expect(ephemeralPost.getByText('test ephemeral actions', {exact: true})).toBeVisible();
|
||||
await expect(ephemeralPost.getByRole('button', {name: 'Update', exact: true})).toBeVisible();
|
||||
await expect(ephemeralPost.getByRole('button', {name: 'Delete', exact: true})).toBeVisible();
|
||||
|
||||
// 6. Click Update and verify post text and button label change
|
||||
// After clicking Update the text changes — re-find the post by its new content
|
||||
// After clicking Update the text changes — re-find the post by its new content.
|
||||
// The virtual list can re-render immediately after the click, causing a brief DOM
|
||||
// detachment window; wrap the assertion in toPass to ride out that re-render.
|
||||
await ephemeralPost.getByRole('button', {name: 'Update', exact: true}).click();
|
||||
const updatedPost = channelsPage.centerView.container
|
||||
.getByRole('listitem')
|
||||
.filter({hasText: 'updated ephemeral action'})
|
||||
.last();
|
||||
await expect(updatedPost.getByText('updated ephemeral action', {exact: true})).toBeVisible();
|
||||
await expect(updatedPost.getByRole('button', {name: 'Update 1', exact: true})).toBeVisible();
|
||||
await expect(updatedPost.getByRole('button', {name: 'Delete', exact: true})).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => updatedPost.getByText('updated ephemeral action', {exact: true}).isVisible(), {
|
||||
timeout: 30000,
|
||||
intervals: [500, 1000, 2000],
|
||||
})
|
||||
.toBe(true);
|
||||
await expect(updatedPost.getByRole('button', {name: 'Update 1', exact: true})).toBeVisible({timeout: 15000});
|
||||
await expect(updatedPost.getByRole('button', {name: 'Delete', exact: true})).toBeVisible({timeout: 15000});
|
||||
|
||||
// 7. Click Delete and verify post content is removed and buttons are gone
|
||||
// After delete the text changes again — re-find by the new content
|
||||
|
||||
+22
-5
@@ -3,9 +3,10 @@
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
import {setupDemoPlugin} from '../../helpers';
|
||||
import {sendDemoSlashCommand, setupDemoPlugin} from '../../helpers';
|
||||
|
||||
test('should post interactive button and respond with click attribution via /interactive command', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
// 1. Setup
|
||||
const {adminClient, user, team} = await pw.initSetup();
|
||||
await setupDemoPlugin(adminClient, pw);
|
||||
@@ -19,15 +20,31 @@ test('should post interactive button and respond with click attribution via /int
|
||||
await channelsPage.goto(team.name, 'town-square');
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// 4. Send /interactive command
|
||||
await channelsPage.centerView.postCreate.input.fill('/interactive');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
// Re-apply setupDemoPlugin: concurrent initSetup() resets PluginSettings.Plugins = {}
|
||||
await setupDemoPlugin(adminClient, pw);
|
||||
|
||||
// 5. Confirm post appears with 'Test interactive button' and an 'Interactive Button' button
|
||||
// 4. Send /interactive command (retry once if plugin not yet ready)
|
||||
const interactivePost = channelsPage.centerView.container
|
||||
.getByRole('listitem')
|
||||
.filter({hasText: 'Test interactive button'})
|
||||
.last();
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
await sendDemoSlashCommand(channelsPage.page, async () => {
|
||||
await channelsPage.centerView.postCreate.input.fill('/interactive');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
});
|
||||
try {
|
||||
await expect(interactivePost).toBeVisible({timeout: 15000});
|
||||
break;
|
||||
} catch (err) {
|
||||
if (attempt === 3) {
|
||||
throw err;
|
||||
}
|
||||
await setupDemoPlugin(adminClient, pw);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Confirm post appears with 'Test interactive button' and an 'Interactive Button' button
|
||||
await expect(interactivePost).toBeVisible();
|
||||
await expect(interactivePost.getByRole('button', {name: 'Interactive Button'})).toBeVisible();
|
||||
|
||||
|
||||
+56
-10
@@ -1,11 +1,38 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Page} from '@playwright/test';
|
||||
import {Client4} from '@mattermost/client';
|
||||
|
||||
import {expect, getFileFromAsset, test} from '@mattermost/playwright-lib';
|
||||
|
||||
import {setupDemoPlugin} from '../../helpers';
|
||||
import {setupDemoPlugin, DEMO_PLUGIN_ID} from '../../helpers';
|
||||
|
||||
async function sendSlashCommand(page: Page, send: () => Promise<void>, adminClient: Client4): Promise<void> {
|
||||
// Slash commands hit POST /api/v4/commands/execute — not POST /posts (see web client executeCommand).
|
||||
// Retry once if the server returns 500 (plugin transiently inactive between setup and first use).
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const responsePromise = page.waitForResponse(
|
||||
(r) => r.url().includes('/api/v4/commands/execute') && r.request().method() === 'POST',
|
||||
{timeout: 30_000},
|
||||
);
|
||||
const [, response] = await Promise.all([send(), responsePromise]);
|
||||
if (response.ok()) {
|
||||
return;
|
||||
}
|
||||
if (attempt === 0 && response.status() === 500) {
|
||||
// Plugin may have been deactivated by a concurrent initSetup() — re-enable and retry.
|
||||
try {
|
||||
await adminClient.enablePlugin(DEMO_PLUGIN_ID);
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
} catch {
|
||||
// Ignore; retry the slash command anyway.
|
||||
}
|
||||
continue;
|
||||
}
|
||||
expect(response.ok(), `slash command failed: HTTP ${response.status()}`).toBeTruthy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a batch of files to the channel via API and posts them as a single message.
|
||||
@@ -32,6 +59,8 @@ async function uploadAndPostFiles(client: Client4, channelId: string, filenames:
|
||||
}
|
||||
|
||||
test('should list uploaded files with running total via /list_files command', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
// 1. Setup
|
||||
const {adminClient, user, team} = await pw.initSetup();
|
||||
await setupDemoPlugin(adminClient, pw);
|
||||
@@ -50,10 +79,17 @@ test('should list uploaded files with running total via /list_files command', as
|
||||
await channelsPage.goto(team.name, 'list-files-test');
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// 4. Send /list_files with no files — expect 0 count
|
||||
await channelsPage.centerView.postCreate.input.fill('/list_files');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
const page = channelsPage.page;
|
||||
|
||||
// 4. /list_files with no files — wait for server round-trip, then assert bot reply
|
||||
await sendSlashCommand(
|
||||
page,
|
||||
async () => {
|
||||
await channelsPage.centerView.postCreate.input.fill('/list_files');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
},
|
||||
adminClient,
|
||||
);
|
||||
await expect(
|
||||
channelsPage.centerView.container.getByText('Last 0 Files uploaded to this channel', {exact: true}),
|
||||
).toBeVisible();
|
||||
@@ -63,9 +99,14 @@ test('should list uploaded files with running total via /list_files command', as
|
||||
await uploadAndPostFiles(adminClient, createdChannel.id, ['sample_text_file.txt', 'mattermost-icon_128x128.png']);
|
||||
|
||||
// 6. Send /list_files — expect count of 2 and both file names
|
||||
await channelsPage.centerView.postCreate.input.fill('/list_files');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
|
||||
await sendSlashCommand(
|
||||
page,
|
||||
async () => {
|
||||
await channelsPage.centerView.postCreate.input.fill('/list_files');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
},
|
||||
adminClient,
|
||||
);
|
||||
const response2 = channelsPage.centerView.container
|
||||
.getByRole('listitem')
|
||||
.filter({hasText: 'Last 2 Files uploaded to this channel'})
|
||||
@@ -78,9 +119,14 @@ test('should list uploaded files with running total via /list_files command', as
|
||||
await uploadAndPostFiles(adminClient, createdChannel.id, ['mattermost.png', 'archive.zip']);
|
||||
|
||||
// 8. Send /list_files — expect count of 4 and all file names
|
||||
await channelsPage.centerView.postCreate.input.fill('/list_files');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
|
||||
await sendSlashCommand(
|
||||
page,
|
||||
async () => {
|
||||
await channelsPage.centerView.postCreate.input.fill('/list_files');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
},
|
||||
adminClient,
|
||||
);
|
||||
const response4 = channelsPage.centerView.container
|
||||
.getByRole('listitem')
|
||||
.filter({hasText: 'Last 4 Files uploaded to this channel'})
|
||||
|
||||
+53
-7
@@ -3,13 +3,31 @@
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
import {setupDemoPlugin} from '../../helpers';
|
||||
import {sendDemoSlashCommand, setupDemoPlugin} from '../../helpers';
|
||||
|
||||
test.fixme('should toggle hooks on and off via /demo_plugin command', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
// 1. Setup: install and activate the demo plugin
|
||||
const {adminClient, user, team} = await pw.initSetup();
|
||||
await setupDemoPlugin(adminClient, pw);
|
||||
|
||||
// Add test user to the demo_plugin private channel (it's private; not joined by default).
|
||||
// The plugin creates this channel asynchronously on activation, so poll until it exists.
|
||||
let demoChannel: any = null;
|
||||
for (let i = 0; i < 25; i++) {
|
||||
try {
|
||||
demoChannel = await adminClient.getChannelByName(team.id, 'demo_plugin');
|
||||
if (demoChannel?.id) break;
|
||||
} catch {
|
||||
// Channel not yet created — wait and retry
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
if (!demoChannel?.id) {
|
||||
throw new Error('demo_plugin channel was not created within 30s of plugin activation');
|
||||
}
|
||||
await adminClient.addToChannel(user.id, demoChannel.id);
|
||||
|
||||
// 2. Login
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto();
|
||||
@@ -30,10 +48,36 @@ test.fixme('should toggle hooks on and off via /demo_plugin command', async ({pw
|
||||
const lastPost = await channelsPage.centerView.getLastPost();
|
||||
await expect(lastPost.container).not.toContainText('ChannelHasBeenCreated');
|
||||
|
||||
// 5. Disable hooks
|
||||
await channelsPage.centerView.postCreate.input.fill('/demo_plugin false');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
await expect(hookStatus).toHaveText('Disabled');
|
||||
await channelsPage.page.waitForTimeout(6000);
|
||||
|
||||
// 5. Disable hooks (retry if plugin not yet ready)
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
await sendDemoSlashCommand(channelsPage.page, async () => {
|
||||
await channelsPage.centerView.postCreate.input.fill('/demo_plugin false');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
});
|
||||
try {
|
||||
await expect(hookStatus).toHaveText('Disabled', {timeout: 45000});
|
||||
break;
|
||||
} catch (err) {
|
||||
if (attempt === 3) {
|
||||
throw err;
|
||||
}
|
||||
// Re-enable without patchConfig to avoid triggering a plugin restart that
|
||||
// posts new "Demo Plugin: Enabled" messages after our disable command.
|
||||
try {
|
||||
await adminClient.enablePlugin('com.mattermost.demo-plugin');
|
||||
} catch {
|
||||
// Already enabled or transient error — ignore.
|
||||
}
|
||||
await expect
|
||||
.poll(() => pw.isPluginActive(adminClient, 'com.mattermost.demo-plugin'), {
|
||||
timeout: 30_000,
|
||||
intervals: [2000],
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Create first token channel (hooks off)
|
||||
const channel1 = pw.random.channel({
|
||||
@@ -49,8 +93,10 @@ test.fixme('should toggle hooks on and off via /demo_plugin command', async ({pw
|
||||
).not.toBeVisible();
|
||||
|
||||
// 8. Re-enable hooks
|
||||
await channelsPage.centerView.postCreate.input.fill('/demo_plugin true');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
await sendDemoSlashCommand(channelsPage.page, async () => {
|
||||
await channelsPage.centerView.postCreate.input.fill('/demo_plugin true');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
});
|
||||
await expect(hookStatus).toHaveText('Enabled');
|
||||
|
||||
// 9. Create second token channel (hooks on)
|
||||
|
||||
+19
-6
@@ -6,6 +6,7 @@ import {expect, test} from '@mattermost/playwright-lib';
|
||||
import {setupDemoPlugin} from '../../helpers';
|
||||
|
||||
test('should parse user and channel mentions from /show_mentions command text', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
// 1. Setup
|
||||
const {adminClient, user, team} = await pw.initSetup();
|
||||
await setupDemoPlugin(adminClient, pw);
|
||||
@@ -19,17 +20,29 @@ test('should parse user and channel mentions from /show_mentions command text',
|
||||
await channelsPage.goto(team.name, 'town-square');
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// 4. Send /show_mentions with a user mention and a channel mention
|
||||
// sysadmin is a stable known user in every PW environment
|
||||
await channelsPage.centerView.postCreate.input.fill('/show_mentions @sysadmin ~town-square');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
// Re-apply setupDemoPlugin: concurrent initSetup() resets PluginSettings.Plugins = {}
|
||||
await setupDemoPlugin(adminClient, pw);
|
||||
|
||||
// 5. Wait for bot response
|
||||
// 4. Send /show_mentions (retry once if plugin not yet ready)
|
||||
const responsePost = channelsPage.centerView.container
|
||||
.getByRole('listitem')
|
||||
.filter({hasText: 'contains the following different mentions'})
|
||||
.last();
|
||||
await expect(responsePost).toBeVisible();
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
await channelsPage.centerView.postCreate.input.fill('/show_mentions @sysadmin ~town-square');
|
||||
await channelsPage.centerView.postCreate.sendMessage();
|
||||
try {
|
||||
await expect(responsePost).toBeVisible({timeout: 15000});
|
||||
break;
|
||||
} catch (err) {
|
||||
if (attempt === 1) {
|
||||
throw err;
|
||||
}
|
||||
await setupDemoPlugin(adminClient, pw);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Bot response is now visible
|
||||
|
||||
// 6. Assert user mentions section
|
||||
await expect(responsePost.getByRole('heading', {name: 'Mentions to users in the team'})).toBeVisible();
|
||||
|
||||
+98
-55
@@ -5,80 +5,123 @@ import {expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
import {ensureUserAttributes} from '../support';
|
||||
|
||||
/**
|
||||
* Check whether the PermissionPolicies feature flag is enabled at runtime.
|
||||
* Returns true when the server exposes the permission_policies route.
|
||||
*/
|
||||
async function isPermissionPoliciesEnabled(adminClient: any): Promise<boolean> {
|
||||
const config = await adminClient.getConfig();
|
||||
return config.FeatureFlags?.PermissionPolicies === true || config.FeatureFlags?.PermissionPolicies === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* ABAC Basic Operations - Enable/Disable
|
||||
* Tests basic ABAC system-wide enable/disable functionality
|
||||
*/
|
||||
test.describe('ABAC Basic Operations - Enable/Disable', () => {
|
||||
test('MM-T5782 System admin can enable or disable system-wide ABAC', async ({pw}) => {
|
||||
// # Skip test if no license for ABAC
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
// # Set up admin user and login
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
test('MM-T5782 System admin can enable or disable system-wide ABAC', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
// # Ensure user attributes exist BEFORE logging in
|
||||
await ensureUserAttributes(adminClient);
|
||||
// # Skip test if no license for ABAC
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
// # Now login - this ensures the UI will have the attributes loaded
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
// # Set up admin user and login
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
|
||||
// # Navigate to ABAC page
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
await systemConsolePage.sidebar.systemAttributes.attributeBasedAccess.click();
|
||||
// # Ensure user attributes exist BEFORE logging in
|
||||
await ensureUserAttributes(adminClient);
|
||||
|
||||
// * Verify we're on the correct page
|
||||
const abacSection = systemConsolePage.page.getByTestId('sysconsole_section_AttributeBasedAccessControl');
|
||||
await expect(abacSection).toBeVisible();
|
||||
// # Reset ABAC to disabled via API before testing the UI toggle.
|
||||
// Parallel tests may have already enabled it, which would leave the radio
|
||||
// pre-selected and the Save button permanently disabled (no dirty state).
|
||||
await adminClient.patchConfig({
|
||||
AccessControlSettings: {
|
||||
EnableAttributeBasedAccessControl: false,
|
||||
},
|
||||
} as any);
|
||||
|
||||
const enableRadio = systemConsolePage.page.locator(
|
||||
'#AccessControlSettings\\.EnableAttributeBasedAccessControltrue',
|
||||
);
|
||||
const disableRadio = systemConsolePage.page.locator(
|
||||
'#AccessControlSettings\\.EnableAttributeBasedAccessControlfalse',
|
||||
);
|
||||
const saveButton = systemConsolePage.page.getByRole('button', {name: 'Save'});
|
||||
// # Now login - this ensures the UI will have the attributes loaded
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
// # Test enable ABAC
|
||||
await enableRadio.click();
|
||||
await expect(enableRadio).toBeChecked();
|
||||
await saveButton.click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
// # Navigate to ABAC page
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
await systemConsolePage.sidebar.systemAttributes.attributeBasedAccess.click();
|
||||
|
||||
// * Verify the Attribute-Based Access page only has the toggle — no policy management here
|
||||
await expect(systemConsolePage.page.getByRole('button', {name: 'Add policy'})).not.toBeVisible();
|
||||
// Re-apply the ABAC=false reset right before UI interaction: a concurrent
|
||||
// initSetup() on another shard may have re-enabled ABAC between the initial
|
||||
// patchConfig call above and here. If it's already enabled when we click
|
||||
// enableRadio the radio is a no-op and Save stays disabled.
|
||||
await adminClient.patchConfig({
|
||||
AccessControlSettings: {
|
||||
EnableAttributeBasedAccessControl: false,
|
||||
},
|
||||
} as any);
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.AccessControlSettings?.EnableAttributeBasedAccessControl === false;
|
||||
});
|
||||
await systemConsolePage.page.reload();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// * Verify Membership Policies page shows "Add policy" when ABAC is enabled
|
||||
await systemConsolePage.page.goto('/admin_console/system_attributes/membership_policies');
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
await expect(systemConsolePage.page.getByRole('button', {name: 'Add policy'})).toBeVisible();
|
||||
// * Verify we're on the correct page
|
||||
const abacSection = systemConsolePage.page.getByTestId('sysconsole_section_AttributeBasedAccessControl');
|
||||
await expect(abacSection).toBeVisible();
|
||||
|
||||
// * Verify Permission Policies page shows "Add policy" when ABAC is enabled
|
||||
const enableRadio = systemConsolePage.page.locator(
|
||||
'#AccessControlSettings\\.EnableAttributeBasedAccessControltrue',
|
||||
);
|
||||
const disableRadio = systemConsolePage.page.locator(
|
||||
'#AccessControlSettings\\.EnableAttributeBasedAccessControlfalse',
|
||||
);
|
||||
const saveButton = systemConsolePage.page.getByRole('button', {name: 'Save'});
|
||||
|
||||
// # Test enable ABAC
|
||||
await enableRadio.click();
|
||||
await expect(enableRadio).toBeChecked();
|
||||
await saveButton.click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// * Verify the Attribute-Based Access page only has the toggle — no policy management here
|
||||
await expect(systemConsolePage.page.getByRole('button', {name: 'Add policy'})).not.toBeVisible();
|
||||
|
||||
// * Verify Membership Policies page shows "Add policy" when ABAC is enabled
|
||||
// Re-apply enable guard: a concurrent shard may have disabled ABAC between the
|
||||
// save above and this navigation, which would cause a redirect to the license page.
|
||||
await adminClient.patchConfig({AccessControlSettings: {EnableAttributeBasedAccessControl: true}});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.AccessControlSettings?.EnableAttributeBasedAccessControl === true;
|
||||
});
|
||||
await systemConsolePage.page.goto('/admin_console/system_attributes/membership_policies');
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
await expect(systemConsolePage.page.getByRole('button', {name: 'Add policy'})).toBeVisible();
|
||||
|
||||
// * Verify Permission Policies page shows "Add policy" when ABAC is enabled
|
||||
// This section is only testable when the PermissionPolicies feature flag is on.
|
||||
if (await isPermissionPoliciesEnabled(adminClient)) {
|
||||
await systemConsolePage.page.goto('/admin_console/system_attributes/permission_policies');
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
await expect(systemConsolePage.page.getByRole('button', {name: 'Add policy'})).toBeVisible();
|
||||
}
|
||||
|
||||
// # Navigate back to Attribute-Based Access to test disable
|
||||
await systemConsolePage.page.goto('/admin_console/system_attributes/attribute_based_access_control');
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
// # Navigate back to Attribute-Based Access to test disable
|
||||
await systemConsolePage.page.goto('/admin_console/system_attributes/attribute_based_access_control');
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// # Test disable ABAC
|
||||
await disableRadio.click();
|
||||
await expect(disableRadio).toBeChecked();
|
||||
await saveButton.click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
// # Test disable ABAC
|
||||
await disableRadio.click();
|
||||
await expect(disableRadio).toBeChecked();
|
||||
await saveButton.click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// * Verify Membership Policies no longer shows "Add policy" when ABAC is disabled
|
||||
await systemConsolePage.page.goto('/admin_console/system_attributes/membership_policies');
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
await expect(systemConsolePage.page.getByRole('button', {name: 'Add policy'})).not.toBeVisible();
|
||||
// * Verify Membership Policies no longer shows "Add policy" when ABAC is disabled
|
||||
await systemConsolePage.page.goto('/admin_console/system_attributes/membership_policies');
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
await expect(systemConsolePage.page.getByRole('button', {name: 'Add policy'})).not.toBeVisible();
|
||||
|
||||
// # Re-enable ABAC for subsequent tests
|
||||
await systemConsolePage.page.goto('/admin_console/system_attributes/attribute_based_access_control');
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
await enableRadio.click();
|
||||
await saveButton.click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
});
|
||||
// # Re-enable ABAC for subsequent tests via API — avoids the race where a concurrent
|
||||
// shard's initSetup() re-enables ABAC between the disable save and here, leaving the
|
||||
// radio already checked so the UI save button stays disabled.
|
||||
await adminClient.patchConfig({AccessControlSettings: {EnableAttributeBasedAccessControl: true}});
|
||||
});
|
||||
|
||||
+104
-230
@@ -1,9 +1,8 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, test, enableABAC} from '@mattermost/playwright-lib';
|
||||
import {expect, test, enableABAC, getAdminClient, TestBrowser, getRandomId} from '@mattermost/playwright-lib';
|
||||
|
||||
import {getAsset} from '../../../../../asset';
|
||||
import {
|
||||
CustomProfileAttribute,
|
||||
setupCustomProfileAttributeFields,
|
||||
@@ -14,15 +13,17 @@ import {
|
||||
createPermissionPolicy,
|
||||
deletePermissionPolicyByName,
|
||||
enableUserManagedAttributes,
|
||||
ensureUserAttributes,
|
||||
navigateToPermissionPoliciesPage,
|
||||
} from '../support';
|
||||
|
||||
import {setupUserAndChannel} from './helpers';
|
||||
|
||||
/**
|
||||
* ABAC Permission Policies - File Access Runtime Enforcement (MM-64508)
|
||||
* ABAC Permission Policies - Download File Runtime Enforcement (MM-64508)
|
||||
*
|
||||
* Tests that permission policies for download_file_attachment and
|
||||
* upload_file_attachment are correctly enforced in the channel UI.
|
||||
* Tests that permission policies for download_file_attachment are correctly
|
||||
* enforced in the channel UI. Covers the straightforward deny/allow pair, the
|
||||
* attribute-matching flow, and the Burn-on-Read + permalink edge cases.
|
||||
*
|
||||
* CEL strategy:
|
||||
* - DENIED tests: celExpression = 'false' → unconditional deny, no attribute dependency
|
||||
@@ -35,37 +36,6 @@ import {
|
||||
* - Individual tests just set lastPolicyName and do NOT do their own cleanup
|
||||
*/
|
||||
|
||||
// ─── Shared setup helper ─────────────────────────────────────────────────────
|
||||
|
||||
async function setupUserAndChannel(
|
||||
adminClient: any,
|
||||
team: any,
|
||||
): Promise<{
|
||||
testUser: any;
|
||||
channelName: string;
|
||||
channelId: string;
|
||||
}> {
|
||||
// Ensure at least one user attribute field exists so the permission policy
|
||||
// CEL editor's "Switch to Advanced Mode" button is enabled in the UI.
|
||||
await ensureUserAttributes(adminClient, ['Department']);
|
||||
|
||||
const randomId = Math.random().toString(36).substring(2, 9);
|
||||
const username = `user${randomId}`;
|
||||
const testUser = await adminClient.createUser(
|
||||
{email: `${username}@example.com`, username, password: 'Passwd4Testing!'} as any,
|
||||
'',
|
||||
'',
|
||||
);
|
||||
(testUser as any).password = 'Passwd4Testing!';
|
||||
|
||||
await adminClient.addToTeam(team.id, testUser.id);
|
||||
|
||||
const channel = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
await adminClient.addToChannel(testUser.id, channel.id);
|
||||
|
||||
return {testUser, channelName: channel.name, channelId: channel.id};
|
||||
}
|
||||
|
||||
// ─── Download Enforcement ────────────────────────────────────────────────────
|
||||
|
||||
test.describe('ABAC Permission Policies - Download File Enforcement', () => {
|
||||
@@ -102,6 +72,7 @@ test.describe('ABAC Permission Policies - Download File Enforcement', () => {
|
||||
name: lastPolicyName,
|
||||
celExpression: 'false',
|
||||
permissions: ['Download Files'],
|
||||
adminClient,
|
||||
});
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
@@ -141,232 +112,133 @@ test.describe('ABAC Permission Policies - Download File Enforcement', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Upload Enforcement ──────────────────────────────────────────────────────
|
||||
|
||||
test.describe('ABAC Permission Policies - Upload File Enforcement', () => {
|
||||
let lastPolicyName = '';
|
||||
let savedAdminClient: any = null;
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (lastPolicyName && savedAdminClient) {
|
||||
await deletePermissionPolicyByName(savedAdminClient, lastPolicyName);
|
||||
lastPolicyName = '';
|
||||
savedAdminClient = null;
|
||||
}
|
||||
});
|
||||
|
||||
test('MM-T5822 user denied upload sees error when attempting file attachment', async ({pw}) => {
|
||||
test.setTimeout(180000);
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
savedAdminClient = adminClient;
|
||||
const {testUser: deniedUser, channelName} = await setupUserAndChannel(adminClient, team);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await navigateToPermissionPoliciesPage(systemConsolePage.page);
|
||||
|
||||
lastPolicyName = `Upload Deny ${pw.random.id()}`;
|
||||
await createPermissionPolicy(systemConsolePage.page, {
|
||||
name: lastPolicyName,
|
||||
celExpression: 'false',
|
||||
permissions: ['Upload Files'],
|
||||
});
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
const {channelsPage: deniedChannelsPage, page: deniedPage} = await pw.testBrowser.login(deniedUser);
|
||||
await deniedChannelsPage.goto(team.name, channelName);
|
||||
await deniedChannelsPage.toBeVisible();
|
||||
|
||||
deniedPage.once('filechooser', async (fileChooser) => {
|
||||
await fileChooser.setFiles(getAsset('mattermost.png'));
|
||||
});
|
||||
await deniedChannelsPage.centerView.postCreate.attachmentButton.click();
|
||||
|
||||
await expect(deniedPage.getByText(/required access to upload/i)).toBeVisible({timeout: 15000});
|
||||
// error text already asserted above
|
||||
});
|
||||
|
||||
test('MM-T5823 user can attach and send a file when no upload restriction policy exists', async ({pw}) => {
|
||||
test.setTimeout(180000);
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
savedAdminClient = adminClient;
|
||||
const {testUser, channelName} = await setupUserAndChannel(adminClient, team);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
const {channelsPage: userChannelsPage, page: userPage} = await pw.testBrowser.login(testUser);
|
||||
await userChannelsPage.goto(team.name, channelName);
|
||||
await userChannelsPage.toBeVisible();
|
||||
await userChannelsPage.centerView.postCreate.postMessage('Upload test', ['sample_text_file.txt']);
|
||||
|
||||
await expect(userPage.getByText(/required access to upload/i)).not.toBeVisible();
|
||||
await expect(userPage.locator('[data-testid="fileAttachmentList"]').last()).toBeVisible({timeout: 15000});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Combined Enforcement ────────────────────────────────────────────────────
|
||||
|
||||
test.describe('ABAC Permission Policies - Combined File Enforcement', () => {
|
||||
let lastPolicyName = '';
|
||||
let savedAdminClient: any = null;
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (lastPolicyName && savedAdminClient) {
|
||||
await deletePermissionPolicyByName(savedAdminClient, lastPolicyName);
|
||||
lastPolicyName = '';
|
||||
savedAdminClient = null;
|
||||
}
|
||||
});
|
||||
|
||||
test('MM-T5824 user denied both download and upload sees placeholder and cannot upload', async ({pw}) => {
|
||||
test.setTimeout(180000);
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
savedAdminClient = adminClient;
|
||||
const {testUser: deniedUser, channelName} = await setupUserAndChannel(adminClient, team);
|
||||
|
||||
const {channelsPage: adminChannelsPage} = await pw.testBrowser.login(adminUser);
|
||||
await adminChannelsPage.goto(team.name, channelName);
|
||||
await adminChannelsPage.toBeVisible();
|
||||
await adminChannelsPage.centerView.postCreate.postMessage('File for combined test', ['sample_text_file.txt']);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await navigateToPermissionPoliciesPage(systemConsolePage.page);
|
||||
|
||||
lastPolicyName = `Both Deny ${pw.random.id()}`;
|
||||
await createPermissionPolicy(systemConsolePage.page, {
|
||||
name: lastPolicyName,
|
||||
celExpression: 'false',
|
||||
permissions: ['Download Files', 'Upload Files'],
|
||||
});
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
const {channelsPage: deniedChannelsPage, page: deniedPage} = await pw.testBrowser.login(deniedUser);
|
||||
await deniedChannelsPage.goto(team.name, channelName);
|
||||
await deniedChannelsPage.toBeVisible();
|
||||
|
||||
await expect(deniedPage.getByTestId('redactedFilesPlaceholder')).toBeVisible({timeout: 15000});
|
||||
await expect(deniedPage.getByTestId('redactedFilesPlaceholder')).toContainText('Files not available');
|
||||
|
||||
deniedPage.once('filechooser', async (fileChooser) => {
|
||||
await fileChooser.setFiles(getAsset('mattermost.png'));
|
||||
});
|
||||
await deniedChannelsPage.centerView.postCreate.attachmentButton.click();
|
||||
await expect(deniedPage.getByText(/required access to upload/i)).toBeVisible({timeout: 15000});
|
||||
// error text already asserted above
|
||||
});
|
||||
|
||||
test('MM-T5825 user can download and upload files when no restriction policies exist', async ({pw}) => {
|
||||
test.setTimeout(180000);
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
savedAdminClient = adminClient;
|
||||
const {testUser, channelName} = await setupUserAndChannel(adminClient, team);
|
||||
|
||||
const {channelsPage: adminChannelsPage} = await pw.testBrowser.login(adminUser);
|
||||
await adminChannelsPage.goto(team.name, channelName);
|
||||
await adminChannelsPage.toBeVisible();
|
||||
await adminChannelsPage.centerView.postCreate.postMessage('File for allowed test', ['sample_text_file.txt']);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
const {channelsPage: userChannelsPage, page: userPage} = await pw.testBrowser.login(testUser);
|
||||
await userChannelsPage.goto(team.name, channelName);
|
||||
await userChannelsPage.toBeVisible();
|
||||
|
||||
await expect(userPage.locator('[data-testid="fileAttachmentList"]')).toBeVisible({timeout: 15000});
|
||||
await expect(userPage.getByTestId('redactedFilesPlaceholder')).not.toBeVisible();
|
||||
|
||||
await userChannelsPage.centerView.postCreate.postMessage('Upload from user', ['sample_text_file.txt']);
|
||||
await expect(userPage.getByText(/required access to upload/i)).not.toBeVisible();
|
||||
await expect(userPage.locator('[data-testid="fileAttachmentList"]').last()).toBeVisible({timeout: 15000});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Attribute-Based Policy — Matching User ───────────────────────────────────
|
||||
|
||||
test.describe('ABAC Permission Policies - Attribute-Based Access', () => {
|
||||
let lastPolicyName = '';
|
||||
let savedAdminClient: any = null;
|
||||
/**
|
||||
* MM-T5826 split into two tests (_a denied, _b allowed) that share a
|
||||
* beforeAll. The beforeAll pays the 31-second AttributeView gate plus the
|
||||
* policy-creation UI work ONCE. Each test then just logs the relevant user
|
||||
* in and asserts the file visibility.
|
||||
*/
|
||||
test.describe('ABAC Permission Policies - Attribute-Based Access - MM-T5826', () => {
|
||||
let sharedAdminClient: any = null;
|
||||
let sharedPolicyName = '';
|
||||
let sharedTeam: any;
|
||||
let sharedChannelName = '';
|
||||
let userAllowed: Awaited<ReturnType<typeof createUserForABAC>>;
|
||||
let userDenied: Awaited<ReturnType<typeof createUserForABAC>>;
|
||||
let licensed = true;
|
||||
let sharedTestBrowser: TestBrowser | null = null;
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (lastPolicyName && savedAdminClient) {
|
||||
await deletePermissionPolicyByName(savedAdminClient, lastPolicyName);
|
||||
lastPolicyName = '';
|
||||
savedAdminClient = null;
|
||||
test.beforeAll(async ({browser}) => {
|
||||
test.setTimeout(240000);
|
||||
|
||||
const {adminClient, adminUser} = await getAdminClient();
|
||||
if (!adminUser) {
|
||||
throw new Error('Admin user not found — cannot proceed with ABAC file-access tests');
|
||||
}
|
||||
});
|
||||
sharedAdminClient = adminClient;
|
||||
|
||||
test('MM-T5826 user with matching attribute is granted download access by attribute-based policy', async ({pw}) => {
|
||||
test.setTimeout(300000);
|
||||
await pw.skipIfNoLicense();
|
||||
try {
|
||||
const lic = await adminClient.getClientLicenseOld();
|
||||
if (!lic || lic.IsLicensed !== 'true') {
|
||||
licensed = false;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
licensed = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait 31 seconds to guarantee the server-side AttributeView 30-second
|
||||
// refresh gate has expired before creating users with attributes.
|
||||
// Wait 31s to guarantee the server-side AttributeView 30-second refresh
|
||||
// gate has expired before creating users with attributes.
|
||||
await new Promise((resolve) => setTimeout(resolve, 31000));
|
||||
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
savedAdminClient = adminClient;
|
||||
|
||||
await enableUserManagedAttributes(adminClient);
|
||||
const departmentAttr: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
|
||||
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, departmentAttr);
|
||||
|
||||
const userAllowed = await createUserForABAC(adminClient, attributeFieldsMap, [
|
||||
userAllowed = await createUserForABAC(adminClient, attributeFieldsMap, [
|
||||
{name: 'Department', type: 'text', value: 'Engineering'},
|
||||
]);
|
||||
const userDenied = await createUserForABAC(adminClient, attributeFieldsMap, [
|
||||
userDenied = await createUserForABAC(adminClient, attributeFieldsMap, [
|
||||
{name: 'Department', type: 'text', value: 'Sales'},
|
||||
]);
|
||||
|
||||
await adminClient.addToTeam(team.id, userAllowed.id);
|
||||
await adminClient.addToTeam(team.id, userDenied.id);
|
||||
const suffix = getRandomId();
|
||||
sharedTeam = await adminClient.createTeam({
|
||||
name: `abac-dl-${suffix}`,
|
||||
display_name: `ABAC-DL ${suffix}`,
|
||||
type: 'O',
|
||||
} as any);
|
||||
|
||||
const channel = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
await adminClient.addToTeam(sharedTeam.id, userAllowed.id);
|
||||
await adminClient.addToTeam(sharedTeam.id, userDenied.id);
|
||||
|
||||
const channel = await createPrivateChannelForABAC(adminClient, sharedTeam.id);
|
||||
await adminClient.addToChannel(userAllowed.id, channel.id);
|
||||
await adminClient.addToChannel(userDenied.id, channel.id);
|
||||
const channelName = channel.name;
|
||||
sharedChannelName = channel.name;
|
||||
|
||||
const {channelsPage: adminChannelsPage} = await pw.testBrowser.login(adminUser);
|
||||
await adminChannelsPage.goto(team.name, channelName);
|
||||
sharedTestBrowser = new TestBrowser(browser);
|
||||
|
||||
// Admin posts a file in the channel via the UI.
|
||||
const {channelsPage: adminChannelsPage} = await sharedTestBrowser.login(adminUser);
|
||||
await adminChannelsPage.goto(sharedTeam.name, sharedChannelName);
|
||||
await adminChannelsPage.toBeVisible();
|
||||
await adminChannelsPage.centerView.postCreate.postMessage('File attachment post', ['sample_text_file.txt']);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
// Admin opens system console, creates the attribute-based download policy.
|
||||
const {systemConsolePage} = await sharedTestBrowser.login(adminUser);
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await navigateToPermissionPoliciesPage(systemConsolePage.page);
|
||||
|
||||
lastPolicyName = `Dept Download Policy ${pw.random.id()}`;
|
||||
sharedPolicyName = `Dept Download Policy ${getRandomId()}`;
|
||||
await createPermissionPolicy(systemConsolePage.page, {
|
||||
name: lastPolicyName,
|
||||
name: sharedPolicyName,
|
||||
celExpression: 'user.attributes.Department == "Engineering"',
|
||||
permissions: ['Download Files'],
|
||||
adminClient: sharedAdminClient,
|
||||
});
|
||||
});
|
||||
|
||||
// DENIED: Sales user does not match → placeholder shown
|
||||
const {page: deniedPage, channelsPage: deniedChannelsPage} = await pw.testBrowser.login(userDenied as any);
|
||||
await deniedChannelsPage.goto(team.name, channelName);
|
||||
await deniedChannelsPage.toBeVisible();
|
||||
await expect(deniedPage.getByTestId('redactedFilesPlaceholder')).toBeVisible({timeout: 15000});
|
||||
await expect(deniedPage.locator('[data-testid="fileAttachmentList"]')).not.toBeVisible();
|
||||
test.afterAll(async () => {
|
||||
if (sharedPolicyName && sharedAdminClient) {
|
||||
await deletePermissionPolicyByName(sharedAdminClient, sharedPolicyName).catch(() => {});
|
||||
}
|
||||
await sharedTestBrowser?.close().catch(() => {});
|
||||
});
|
||||
|
||||
// ALLOWED: Engineering user matches → file card visible
|
||||
const {page: allowedPage, channelsPage: allowedChannelsPage} = await pw.testBrowser.login(userAllowed as any);
|
||||
await allowedChannelsPage.goto(team.name, channelName);
|
||||
await allowedChannelsPage.toBeVisible();
|
||||
await expect(allowedPage.locator('[data-testid="fileAttachmentList"]')).toBeVisible({timeout: 15000});
|
||||
await expect(allowedPage.getByTestId('redactedFilesPlaceholder')).not.toBeVisible();
|
||||
test('MM-T5826_a user without matching attribute is denied download (Sales → placeholder)', async ({pw}) => {
|
||||
test.setTimeout(60000);
|
||||
test.skip(!licensed, 'No ABAC license');
|
||||
|
||||
const {page, channelsPage} = await pw.testBrowser.login(userDenied as any);
|
||||
await channelsPage.goto(sharedTeam.name, sharedChannelName);
|
||||
await channelsPage.toBeVisible();
|
||||
await expect
|
||||
.poll(() => page.getByTestId('redactedFilesPlaceholder').isVisible(), {
|
||||
timeout: 45000,
|
||||
intervals: [500, 1500, 3000],
|
||||
})
|
||||
.toBe(true);
|
||||
await expect(page.locator('[data-testid="fileAttachmentList"]')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('MM-T5826_b user with matching attribute is granted download (Engineering → file visible)', async ({pw}) => {
|
||||
test.setTimeout(60000);
|
||||
test.skip(!licensed, 'No ABAC license');
|
||||
|
||||
const {page, channelsPage} = await pw.testBrowser.login(userAllowed as any);
|
||||
await channelsPage.goto(sharedTeam.name, sharedChannelName);
|
||||
await channelsPage.toBeVisible();
|
||||
await expect
|
||||
.poll(() => page.locator('[data-testid="fileAttachmentList"]').isVisible(), {
|
||||
timeout: 45000,
|
||||
intervals: [500, 1500, 3000],
|
||||
})
|
||||
.toBe(true);
|
||||
await expect(page.getByTestId('redactedFilesPlaceholder')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -409,6 +281,7 @@ test.describe('ABAC Permission Policies - BOR and Permalink', () => {
|
||||
name: lastPolicyName,
|
||||
celExpression: 'false',
|
||||
permissions: ['Download Files'],
|
||||
adminClient,
|
||||
});
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
@@ -462,6 +335,7 @@ test.describe('ABAC Permission Policies - BOR and Permalink', () => {
|
||||
|
||||
lastPolicyName = `Permalink Download Deny ${pw.random.id()}`;
|
||||
await createPermissionPolicy(systemConsolePage.page, {
|
||||
adminClient,
|
||||
name: lastPolicyName,
|
||||
celExpression: 'false',
|
||||
permissions: ['Download Files'],
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, test, enableABAC} from '@mattermost/playwright-lib';
|
||||
|
||||
import {getAsset} from '../../../../../asset';
|
||||
import {createPermissionPolicy, deletePermissionPolicyByName, navigateToPermissionPoliciesPage} from '../support';
|
||||
|
||||
import {setupUserAndChannel} from './helpers';
|
||||
|
||||
test.describe('ABAC Permission Policies - Upload File Enforcement', () => {
|
||||
let lastPolicyName = '';
|
||||
let savedAdminClient: any = null;
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (lastPolicyName && savedAdminClient) {
|
||||
await deletePermissionPolicyByName(savedAdminClient, lastPolicyName);
|
||||
lastPolicyName = '';
|
||||
savedAdminClient = null;
|
||||
}
|
||||
});
|
||||
|
||||
test('MM-T5822 user denied upload sees error when attempting file attachment', async ({pw}) => {
|
||||
test.setTimeout(180000);
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
savedAdminClient = adminClient;
|
||||
const {testUser: deniedUser, channelName} = await setupUserAndChannel(adminClient, team);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await navigateToPermissionPoliciesPage(systemConsolePage.page);
|
||||
|
||||
lastPolicyName = `Upload Deny ${pw.random.id()}`;
|
||||
await createPermissionPolicy(systemConsolePage.page, {
|
||||
name: lastPolicyName,
|
||||
celExpression: 'false',
|
||||
permissions: ['Upload Files'],
|
||||
});
|
||||
|
||||
// Re-apply ABAC guard: a concurrent initSetup() may have reset
|
||||
// AccessControlSettings.EnableAttributeBasedAccessControl to false between
|
||||
// enableABAC() above and the denied user's login, preventing enforcement.
|
||||
await adminClient.patchConfig({
|
||||
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
|
||||
} as any);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.AccessControlSettings?.EnableAttributeBasedAccessControl === true;
|
||||
},
|
||||
{timeout: 15000, intervals: [500, 1000, 2000]},
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
const {channelsPage: deniedChannelsPage, page: deniedPage} = await pw.testBrowser.login(deniedUser);
|
||||
await deniedChannelsPage.goto(team.name, channelName);
|
||||
await deniedChannelsPage.toBeVisible();
|
||||
|
||||
deniedPage.once('filechooser', async (fileChooser) => {
|
||||
await fileChooser.setFiles(getAsset('mattermost.png'));
|
||||
});
|
||||
await deniedChannelsPage.centerView.postCreate.attachmentButton.click();
|
||||
|
||||
await expect(deniedPage.getByText(/required access to upload/i)).toBeVisible({timeout: 30000});
|
||||
// error text already asserted above
|
||||
});
|
||||
|
||||
test('MM-T5823 user can attach and send a file when no upload restriction policy exists', async ({pw}) => {
|
||||
test.setTimeout(180000);
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
savedAdminClient = adminClient;
|
||||
const {testUser, channelName} = await setupUserAndChannel(adminClient, team);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
const {channelsPage: userChannelsPage, page: userPage} = await pw.testBrowser.login(testUser);
|
||||
await userChannelsPage.goto(team.name, channelName);
|
||||
await userChannelsPage.toBeVisible();
|
||||
await userChannelsPage.centerView.postCreate.postMessage('Upload test', ['sample_text_file.txt']);
|
||||
|
||||
await expect(userPage.getByText(/required access to upload/i)).not.toBeVisible();
|
||||
await expect(userPage.locator('[data-testid="fileAttachmentList"]').last()).toBeVisible({timeout: 15000});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('ABAC Permission Policies - Combined File Enforcement', () => {
|
||||
let lastPolicyName = '';
|
||||
let savedAdminClient: any = null;
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (lastPolicyName && savedAdminClient) {
|
||||
await deletePermissionPolicyByName(savedAdminClient, lastPolicyName);
|
||||
lastPolicyName = '';
|
||||
savedAdminClient = null;
|
||||
}
|
||||
});
|
||||
|
||||
test('MM-T5824 user denied both download and upload sees placeholder and cannot upload', async ({pw}) => {
|
||||
test.setTimeout(180000);
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
savedAdminClient = adminClient;
|
||||
const {testUser: deniedUser, channelName} = await setupUserAndChannel(adminClient, team);
|
||||
|
||||
const {channelsPage: adminChannelsPage} = await pw.testBrowser.login(adminUser);
|
||||
await adminChannelsPage.goto(team.name, channelName);
|
||||
await adminChannelsPage.toBeVisible();
|
||||
await adminChannelsPage.centerView.postCreate.postMessage('File for combined test', ['sample_text_file.txt']);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await navigateToPermissionPoliciesPage(systemConsolePage.page);
|
||||
|
||||
lastPolicyName = `Both Deny ${pw.random.id()}`;
|
||||
await createPermissionPolicy(systemConsolePage.page, {
|
||||
name: lastPolicyName,
|
||||
celExpression: 'false',
|
||||
permissions: ['Download Files', 'Upload Files'],
|
||||
});
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
const {channelsPage: deniedChannelsPage, page: deniedPage} = await pw.testBrowser.login(deniedUser);
|
||||
await deniedChannelsPage.goto(team.name, channelName);
|
||||
await deniedChannelsPage.toBeVisible();
|
||||
|
||||
await expect(deniedPage.getByTestId('redactedFilesPlaceholder')).toBeVisible({timeout: 15000});
|
||||
await expect(deniedPage.getByTestId('redactedFilesPlaceholder')).toContainText('Files not available');
|
||||
|
||||
deniedPage.once('filechooser', async (fileChooser) => {
|
||||
await fileChooser.setFiles(getAsset('mattermost.png'));
|
||||
});
|
||||
await deniedChannelsPage.centerView.postCreate.attachmentButton.click();
|
||||
await expect(deniedPage.getByText(/required access to upload/i)).toBeVisible({timeout: 15000});
|
||||
// error text already asserted above
|
||||
});
|
||||
|
||||
test('MM-T5825 user can download and upload files when no restriction policies exist', async ({pw}) => {
|
||||
test.setTimeout(180000);
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
savedAdminClient = adminClient;
|
||||
const {testUser, channelName} = await setupUserAndChannel(adminClient, team);
|
||||
|
||||
const {channelsPage: adminChannelsPage} = await pw.testBrowser.login(adminUser);
|
||||
await adminChannelsPage.goto(team.name, channelName);
|
||||
await adminChannelsPage.toBeVisible();
|
||||
await adminChannelsPage.centerView.postCreate.postMessage('File for allowed test', ['sample_text_file.txt']);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
const {channelsPage: userChannelsPage, page: userPage} = await pw.testBrowser.login(testUser);
|
||||
await userChannelsPage.goto(team.name, channelName);
|
||||
await userChannelsPage.toBeVisible();
|
||||
|
||||
await expect(userPage.locator('[data-testid="fileAttachmentList"]')).toBeVisible({timeout: 15000});
|
||||
await expect(userPage.getByTestId('redactedFilesPlaceholder')).not.toBeVisible();
|
||||
|
||||
await userChannelsPage.centerView.postCreate.postMessage('Upload from user', ['sample_text_file.txt']);
|
||||
await expect(userPage.getByText(/required access to upload/i)).not.toBeVisible();
|
||||
await expect(userPage.locator('[data-testid="fileAttachmentList"]').last()).toBeVisible({timeout: 15000});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {createPrivateChannelForABAC, ensureUserAttributes} from '../support';
|
||||
|
||||
export async function setupUserAndChannel(
|
||||
adminClient: any,
|
||||
team: any,
|
||||
): Promise<{
|
||||
testUser: any;
|
||||
channelName: string;
|
||||
channelId: string;
|
||||
}> {
|
||||
// Ensure at least one user attribute field exists so the permission policy
|
||||
// CEL editor's "Switch to Advanced Mode" button is enabled in the UI.
|
||||
await ensureUserAttributes(adminClient, ['Department']);
|
||||
|
||||
const randomId = Math.random().toString(36).substring(2, 9);
|
||||
const username = `user${randomId}`;
|
||||
const testUser = await adminClient.createUser(
|
||||
{email: `${username}@example.com`, username, password: 'Passwd4Testing!'} as any,
|
||||
'',
|
||||
'',
|
||||
);
|
||||
(testUser as any).password = 'Passwd4Testing!';
|
||||
|
||||
await adminClient.addToTeam(team.id, testUser.id);
|
||||
|
||||
const channel = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
await adminClient.addToChannel(testUser.id, channel.id);
|
||||
|
||||
return {testUser, channelName: channel.name, channelId: channel.id};
|
||||
}
|
||||
@@ -1,632 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
enableABAC,
|
||||
navigateToABACPage,
|
||||
runSyncJob,
|
||||
verifyUserInChannel,
|
||||
updateUserAttributes,
|
||||
createUserWithAttributes,
|
||||
} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
ensureUserAttributes,
|
||||
createPrivateChannelForABAC,
|
||||
createBasicPolicy,
|
||||
createAdvancedPolicy,
|
||||
activatePolicy,
|
||||
waitForLatestSyncJob,
|
||||
} from '../support';
|
||||
|
||||
/**
|
||||
* ABAC LDAP Integration - Sync
|
||||
* Tests for LDAP sync behavior with ABAC policies
|
||||
*/
|
||||
test.describe('ABAC LDAP Integration - Sync', () => {
|
||||
/**
|
||||
* MM-T5797: LDAP sync - User is auto-added to channel when qualifying attribute syncs to their profile (auto-add true)
|
||||
*
|
||||
* Step 1: Single attribute with `= is` operator
|
||||
* 1. Policy with one attribute (Department == Engineering), auto-add=true exists
|
||||
* 2. User NOT in channel, lacking required attribute
|
||||
*/
|
||||
test('MM-T5797 LDAP sync - User auto-added when attribute syncs (auto-add true)', async ({pw}) => {
|
||||
test.setTimeout(180000);
|
||||
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
// ============================================================
|
||||
// SETUP
|
||||
// ============================================================
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
// Ensure Department attribute exists
|
||||
await ensureUserAttributes(adminClient, ['Department']);
|
||||
|
||||
// ============================================================
|
||||
// STEP 1: Single attribute with == operator, auto-add TRUE
|
||||
// ============================================================
|
||||
|
||||
// Create user with NON-qualifying attribute (simulating LDAP user before sync)
|
||||
const user1 = await createUserWithAttributes(adminClient, {Department: 'Sales'});
|
||||
await adminClient.addToTeam(team.id, user1.id);
|
||||
|
||||
// Create channel and policy
|
||||
const channel1 = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
await enableABAC(systemConsolePage.page);
|
||||
|
||||
const policy1Name = `LDAP AutoAdd Single ${pw.random.id()}`;
|
||||
await createBasicPolicy(systemConsolePage.page, {
|
||||
name: policy1Name,
|
||||
attribute: 'Department',
|
||||
operator: '==',
|
||||
value: 'Engineering',
|
||||
autoSync: true, // Auto-add TRUE
|
||||
channels: [channel1.display_name],
|
||||
});
|
||||
|
||||
// Wait for page to load completely and job table to appear
|
||||
await systemConsolePage.page.waitForTimeout(2000);
|
||||
|
||||
// Activate policy
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
const searchInput = systemConsolePage.page.locator('input[placeholder*="Search" i]').first();
|
||||
await searchInput.waitFor({state: 'visible', timeout: 5000});
|
||||
await searchInput.fill(policy1Name.match(/([a-z0-9]+)$/i)?.[1] || policy1Name);
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
const policyRow1 = systemConsolePage.page.locator('.policy-name').first();
|
||||
const policyId1 = (await policyRow1.getAttribute('id'))?.replace('customDescription-', '');
|
||||
if (policyId1) {
|
||||
await activatePolicy(adminClient, policyId1);
|
||||
}
|
||||
await searchInput.clear();
|
||||
|
||||
// Run initial sync - user should NOT be in channel (doesn't have qualifying attribute)
|
||||
await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
|
||||
const user1InitialCheck = await verifyUserInChannel(adminClient, user1.id, channel1.id);
|
||||
expect(user1InitialCheck).toBe(false);
|
||||
|
||||
// Simulate LDAP sync by updating user's attribute to qualifying value
|
||||
await updateUserAttributes(adminClient, user1.id, {Department: 'Engineering'});
|
||||
|
||||
// Run ABAC sync job to apply policy with new attribute value
|
||||
await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
|
||||
// Verify user IS NOW in channel (auto-added)
|
||||
const user1AfterSync = await verifyUserInChannel(adminClient, user1.id, channel1.id);
|
||||
expect(user1AfterSync).toBe(true);
|
||||
|
||||
// Verify system message
|
||||
const posts1 = await adminClient.getPosts(channel1.id, 0, 10);
|
||||
const postList1 = posts1.order.map((postId: string) => posts1.posts[postId]);
|
||||
const addMessage1 = postList1.find((post: any) => {
|
||||
return post.type === 'system_add_to_channel' && post.props?.addedUserId === user1.id;
|
||||
});
|
||||
if (addMessage1) {
|
||||
// System message found
|
||||
} else {
|
||||
// System message not found (may be disabled in test env)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// STEP 2: Single attribute using "contains" operator
|
||||
// ============================================================
|
||||
|
||||
// Create user with Department that doesn't contain "Eng"
|
||||
const user2 = await createUserWithAttributes(adminClient, {
|
||||
Department: 'Sales', // Doesn't contain "Eng"
|
||||
});
|
||||
await adminClient.addToTeam(team.id, user2.id);
|
||||
|
||||
// Create second channel
|
||||
const channel2 = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
|
||||
// Create policy with contains operator: Department contains "Eng"
|
||||
const policy2Name = `LDAP AutoAdd Contains ${pw.random.id()}`;
|
||||
await createAdvancedPolicy(systemConsolePage.page, {
|
||||
name: policy2Name,
|
||||
celExpression: 'user.attributes.Department.contains("Eng")',
|
||||
autoSync: true, // Auto-add TRUE
|
||||
channels: [channel2.display_name],
|
||||
});
|
||||
|
||||
// Activate policy
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
await searchInput.fill(policy2Name.match(/([a-z0-9]+)$/i)?.[1] || policy2Name);
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
const policyRow2 = systemConsolePage.page.locator('.policy-name').first();
|
||||
const policyId2 = (await policyRow2.getAttribute('id'))?.replace('customDescription-', '');
|
||||
if (policyId2) {
|
||||
await activatePolicy(adminClient, policyId2);
|
||||
}
|
||||
await searchInput.clear();
|
||||
|
||||
// Run initial sync - user should NOT be in channel (has Department but Skills missing Python)
|
||||
await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
|
||||
const user2InitialCheck = await verifyUserInChannel(adminClient, user2.id, channel2.id);
|
||||
expect(user2InitialCheck).toBe(false);
|
||||
|
||||
// Simulate LDAP sync by updating Department to "Engineering" (contains "Eng")
|
||||
await updateUserAttributes(adminClient, user2.id, {Department: 'Engineering'});
|
||||
|
||||
// Run ABAC sync job
|
||||
await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
|
||||
// Verify user IS NOW in channel (auto-added)
|
||||
const user2AfterSync = await verifyUserInChannel(adminClient, user2.id, channel2.id);
|
||||
expect(user2AfterSync).toBe(true);
|
||||
|
||||
// Verify system message
|
||||
const posts2 = await adminClient.getPosts(channel2.id, 0, 10);
|
||||
const postList2 = posts2.order.map((postId: string) => posts2.posts[postId]);
|
||||
const addMessage2 = postList2.find((post: any) => {
|
||||
return post.type === 'system_add_to_channel' && post.props?.addedUserId === user2.id;
|
||||
});
|
||||
if (addMessage2) {
|
||||
// System message found
|
||||
} else {
|
||||
// System message not found (may be disabled in test env)
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* MM-T5798: LDAP sync - User can be added to channel by admin after editing qualifying attribute (auto-add false)
|
||||
*
|
||||
* Step 1: Using `= is` operator
|
||||
* 1. Policy with auto-add=false exists and is applied to a channel
|
||||
* 2. User has wrong attribute value (non-qualifying)
|
||||
* 3. Simulate LDAP sync by updating user's attribute to qualifying value
|
||||
* 4. Run ABAC sync job (updates qualification state but doesn't auto-add due to auto-add=false)
|
||||
* 5. Verify user NOT auto-added
|
||||
* 6. Admin manually adds user to channel
|
||||
*
|
||||
* Step 2: Using `∈ in` operator
|
||||
* 1. Policy with `in` operator exists
|
||||
* 2. User has attribute but not a qualifying value
|
||||
* 3. Simulate LDAP sync by updating to qualifying value
|
||||
* 4. Admin adds user to channel
|
||||
*
|
||||
* Expected:
|
||||
* - User who satisfies policy can be added by admin
|
||||
* - `User added` message posted in channel
|
||||
*
|
||||
* NOTE: This test simulates LDAP attribute sync behavior via API.
|
||||
* In production, attributes would be synced from LDAP server.
|
||||
*/
|
||||
test('MM-T5798 User added by admin after LDAP attribute sync (auto-add false)', async ({pw}) => {
|
||||
// NOTE: This test documents current ABAC behavior with auto-add=false:
|
||||
// - The test verifies that with auto-add=false, sync jobs DON'T automatically add users
|
||||
// - Instead, admin must manually add qualifying users to channels
|
||||
// - However, current implementation requires sync job to run first so server knows who qualifies
|
||||
test.setTimeout(180000);
|
||||
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
// ============================================================
|
||||
// SETUP
|
||||
// ============================================================
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
await ensureUserAttributes(adminClient);
|
||||
|
||||
// ============================================================
|
||||
// STEP 1: Test with `= is` operator
|
||||
// ============================================================
|
||||
|
||||
// Create user with NON-qualifying attribute (simulating LDAP user before sync)
|
||||
const user1 = await createUserWithAttributes(adminClient, {Department: 'Sales'});
|
||||
await adminClient.addToTeam(team.id, user1.id);
|
||||
|
||||
// Create channel and policy
|
||||
const channel1 = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
await enableABAC(systemConsolePage.page);
|
||||
|
||||
const policy1Name = `LDAP Sync Equals ${pw.random.id()}`;
|
||||
await createBasicPolicy(systemConsolePage.page, {
|
||||
name: policy1Name,
|
||||
attribute: 'Department',
|
||||
operator: '==',
|
||||
value: 'Engineering',
|
||||
autoSync: false, // Auto-add FALSE
|
||||
channels: [channel1.display_name],
|
||||
});
|
||||
|
||||
// Activate policy
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
const searchInput = systemConsolePage.page.locator('input[placeholder*="Search" i]').first();
|
||||
await searchInput.waitFor({state: 'visible', timeout: 5000});
|
||||
await searchInput.fill(policy1Name.match(/([a-z0-9]+)$/i)?.[1] || policy1Name);
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
const policyRow1 = systemConsolePage.page.locator('.policy-name').first();
|
||||
const policyId1 = (await policyRow1.getAttribute('id'))?.replace('customDescription-', '');
|
||||
if (policyId1) {
|
||||
await activatePolicy(adminClient, policyId1);
|
||||
}
|
||||
await searchInput.clear();
|
||||
|
||||
// Run initial sync - user should NOT be in channel
|
||||
await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
|
||||
const user1InitialCheck = await verifyUserInChannel(adminClient, user1.id, channel1.id);
|
||||
expect(user1InitialCheck).toBe(false);
|
||||
|
||||
// Simulate LDAP sync by updating user's attribute to qualifying value
|
||||
// In real LDAP scenario, this would happen during LDAP sync from external server
|
||||
await updateUserAttributes(adminClient, user1.id, {Department: 'Engineering'});
|
||||
|
||||
// Run sync job - with auto-add=false, this tests whether users are auto-added or not
|
||||
// The expected behavior: sync job should NOT auto-add users when autoSync=false
|
||||
await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
|
||||
// Verify user behavior after sync
|
||||
const user1AfterSync = await verifyUserInChannel(adminClient, user1.id, channel1.id);
|
||||
|
||||
if (user1AfterSync) {
|
||||
// If user WAS auto-added, this documents current behavior
|
||||
} else {
|
||||
// If user was NOT auto-added, then admin can manually add
|
||||
await adminClient.addToChannel(user1.id, channel1.id);
|
||||
|
||||
const user1AfterAdminAdd = await verifyUserInChannel(adminClient, user1.id, channel1.id);
|
||||
expect(user1AfterAdminAdd).toBe(true);
|
||||
}
|
||||
|
||||
// Final verification
|
||||
const user1Final = await verifyUserInChannel(adminClient, user1.id, channel1.id);
|
||||
expect(user1Final).toBe(true);
|
||||
|
||||
// ============================================================
|
||||
// STEP 2: Test with `∈ in` operator
|
||||
// ============================================================
|
||||
|
||||
// Create user with attribute that has non-qualifying value for 'in' check
|
||||
const user2 = await createUserWithAttributes(adminClient, {Department: 'Marketing'});
|
||||
await adminClient.addToTeam(team.id, user2.id);
|
||||
|
||||
// Create second channel
|
||||
const channel2 = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
|
||||
// Create policy with 'in' operator (user.attributes.Department in ["Engineering", "Product"])
|
||||
const policy2Name = `LDAP Sync In ${pw.random.id()}`;
|
||||
await createAdvancedPolicy(systemConsolePage.page, {
|
||||
name: policy2Name,
|
||||
celExpression: 'user.attributes.Department in ["Engineering", "Product"]',
|
||||
autoSync: false, // Auto-add FALSE
|
||||
channels: [channel2.display_name],
|
||||
});
|
||||
|
||||
// Activate policy
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
await searchInput.fill(policy2Name.match(/([a-z0-9]+)$/i)?.[1] || policy2Name);
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
const policyRow2 = systemConsolePage.page.locator('.policy-name').first();
|
||||
const policyId2 = (await policyRow2.getAttribute('id'))?.replace('customDescription-', '');
|
||||
if (policyId2) {
|
||||
await activatePolicy(adminClient, policyId2);
|
||||
}
|
||||
await searchInput.clear();
|
||||
|
||||
// Run initial sync - user should NOT be in channel
|
||||
await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
|
||||
const user2InitialCheck = await verifyUserInChannel(adminClient, user2.id, channel2.id);
|
||||
expect(user2InitialCheck).toBe(false);
|
||||
|
||||
// Simulate LDAP sync by updating to qualifying value
|
||||
await updateUserAttributes(adminClient, user2.id, {Department: 'Product'});
|
||||
|
||||
// Run sync job - testing same behavior as Step 1
|
||||
await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
|
||||
// Verify user behavior after sync
|
||||
const user2AfterSync = await verifyUserInChannel(adminClient, user2.id, channel2.id);
|
||||
|
||||
if (user2AfterSync) {
|
||||
// User was auto-added
|
||||
} else {
|
||||
await adminClient.addToChannel(user2.id, channel2.id);
|
||||
|
||||
const user2AfterAdminAdd = await verifyUserInChannel(adminClient, user2.id, channel2.id);
|
||||
expect(user2AfterAdminAdd).toBe(true);
|
||||
}
|
||||
|
||||
// Final verification
|
||||
const user2Final = await verifyUserInChannel(adminClient, user2.id, channel2.id);
|
||||
expect(user2Final).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* MM-T5799: LDAP sync - User removed from channel after required attribute removed (auto-add true)
|
||||
*
|
||||
* Step 1: Using `ƒ starts with` operator
|
||||
* 1. Policy with startsWith operator, auto-add=true exists and is applied to a channel
|
||||
* 2. User IN channel with attribute that starts with required value
|
||||
* 3. Simulate LDAP sync by removing the attribute (or changing to non-qualifying value)
|
||||
* 4. Run ABAC sync job
|
||||
*
|
||||
* Expected:
|
||||
* - User who no longer satisfies policy is removed from channel
|
||||
* - `User removed` message posted in channel by System
|
||||
*
|
||||
* Step 2: Two attributes using `= is` operator
|
||||
* 1. Policy with two attributes (both using ==), auto-add=true
|
||||
* 2. User IN channel with both required attributes
|
||||
* 3. Simulate LDAP sync by removing one attribute
|
||||
* 4. Run ABAC sync job
|
||||
*
|
||||
* Expected:
|
||||
* - User who no longer satisfies policy is removed from channel
|
||||
* - `User removed` message posted in channel by System
|
||||
*
|
||||
* NOTE: This test simulates LDAP attribute sync behavior via API.
|
||||
* In production, attributes would be synced from LDAP server.
|
||||
*/
|
||||
test('MM-T5799 LDAP sync - User removed after attribute removed', async ({pw}) => {
|
||||
test.setTimeout(180000);
|
||||
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
// ============================================================
|
||||
// SETUP
|
||||
// ============================================================
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
// Ensure Department attribute exists
|
||||
await ensureUserAttributes(adminClient, ['Department']);
|
||||
|
||||
// ============================================================
|
||||
// STEP 1: Single attribute with startsWith operator
|
||||
// ============================================================
|
||||
|
||||
// Create user with qualifying attribute (Department starts with "Eng")
|
||||
const user1 = await createUserWithAttributes(adminClient, {Department: 'Engineering'});
|
||||
await adminClient.addToTeam(team.id, user1.id);
|
||||
|
||||
// Create channel and policy
|
||||
const channel1 = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
await enableABAC(systemConsolePage.page);
|
||||
|
||||
const policy1Name = `LDAP Remove StartsWith ${pw.random.id()}`;
|
||||
await createAdvancedPolicy(systemConsolePage.page, {
|
||||
name: policy1Name,
|
||||
celExpression: 'user.attributes.Department.startsWith("Eng")',
|
||||
autoSync: true, // Auto-add TRUE
|
||||
channels: [channel1.display_name],
|
||||
});
|
||||
|
||||
// Activate policy
|
||||
await systemConsolePage.page.waitForTimeout(2000);
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
const searchInput = systemConsolePage.page.locator('input[placeholder*="Search" i]').first();
|
||||
await searchInput.waitFor({state: 'visible', timeout: 5000});
|
||||
await searchInput.fill(policy1Name.match(/([a-z0-9]+)$/i)?.[1] || policy1Name);
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
const policyRow1 = systemConsolePage.page.locator('.policy-name').first();
|
||||
const policyId1 = (await policyRow1.getAttribute('id'))?.replace('customDescription-', '');
|
||||
if (policyId1) {
|
||||
await activatePolicy(adminClient, policyId1);
|
||||
}
|
||||
await searchInput.clear();
|
||||
|
||||
// Run sync - user should be AUTO-ADDED (has Department=Engineering which starts with "Eng")
|
||||
await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
|
||||
const user1InitialCheck = await verifyUserInChannel(adminClient, user1.id, channel1.id);
|
||||
expect(user1InitialCheck).toBe(true);
|
||||
|
||||
// Simulate LDAP sync by changing Department to value that doesn't start with "Eng"
|
||||
await updateUserAttributes(adminClient, user1.id, {Department: 'Sales'});
|
||||
|
||||
// Run ABAC sync job to remove user
|
||||
await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
|
||||
// Verify user IS REMOVED from channel
|
||||
const user1AfterSync = await verifyUserInChannel(adminClient, user1.id, channel1.id);
|
||||
expect(user1AfterSync).toBe(false);
|
||||
|
||||
// Verify system message
|
||||
const posts1 = await adminClient.getPosts(channel1.id, 0, 10);
|
||||
const postList1 = posts1.order.map((postId: string) => posts1.posts[postId]);
|
||||
const removeMessage1 = postList1.find((post: any) => {
|
||||
return post.type === 'system_remove_from_channel' && post.props?.removedUserId === user1.id;
|
||||
});
|
||||
if (removeMessage1) {
|
||||
// System message found
|
||||
} else {
|
||||
// System message not found (may be disabled in test env)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// STEP 2: Two attributes using == operator
|
||||
// ============================================================
|
||||
|
||||
// Create user with both qualifying attributes
|
||||
const user2 = await createUserWithAttributes(adminClient, {Department: 'Engineering'});
|
||||
await adminClient.addToTeam(team.id, user2.id);
|
||||
|
||||
// Create second channel
|
||||
const channel2 = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
|
||||
// Create policy with TWO attributes: Department == "Engineering"
|
||||
// Note: Using single attribute with == since we can't reliably set multiple different attribute types
|
||||
const policy2Name = `LDAP Remove TwoAttr ${pw.random.id()}`;
|
||||
await createBasicPolicy(systemConsolePage.page, {
|
||||
name: policy2Name,
|
||||
attribute: 'Department',
|
||||
operator: '==',
|
||||
value: 'Engineering',
|
||||
autoSync: true, // Auto-add TRUE
|
||||
channels: [channel2.display_name],
|
||||
});
|
||||
|
||||
// Activate policy
|
||||
await systemConsolePage.page.waitForTimeout(2000);
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
await searchInput.fill(policy2Name.match(/([a-z0-9]+)$/i)?.[1] || policy2Name);
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
const policyRow2 = systemConsolePage.page.locator('.policy-name').first();
|
||||
const policyId2 = (await policyRow2.getAttribute('id'))?.replace('customDescription-', '');
|
||||
if (policyId2) {
|
||||
await activatePolicy(adminClient, policyId2);
|
||||
}
|
||||
await searchInput.clear();
|
||||
|
||||
// Run initial sync - user should be AUTO-ADDED
|
||||
await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
|
||||
const user2InitialCheck = await verifyUserInChannel(adminClient, user2.id, channel2.id);
|
||||
expect(user2InitialCheck).toBe(true);
|
||||
|
||||
// Simulate LDAP sync by removing the Department attribute (changing to non-qualifying value)
|
||||
await updateUserAttributes(adminClient, user2.id, {Department: 'Sales'});
|
||||
|
||||
// Run ABAC sync job
|
||||
await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
|
||||
// Verify user IS REMOVED from channel
|
||||
const user2AfterSync = await verifyUserInChannel(adminClient, user2.id, channel2.id);
|
||||
expect(user2AfterSync).toBe(false);
|
||||
|
||||
// Verify system message
|
||||
const posts2 = await adminClient.getPosts(channel2.id, 0, 10);
|
||||
const postList2 = posts2.order.map((postId: string) => posts2.posts[postId]);
|
||||
const removeMessage2 = postList2.find((post: any) => {
|
||||
return post.type === 'system_remove_from_channel' && post.props?.removedUserId === user2.id;
|
||||
});
|
||||
if (removeMessage2) {
|
||||
// System message found
|
||||
} else {
|
||||
// System message not found (may be disabled in test env)
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* MM-T5800: Policy enforcement after attribute change
|
||||
* @objective Verify that policy enforcement updates when user attributes change
|
||||
*
|
||||
* This test is similar to MM-T5794 but focuses on the bidirectional nature:
|
||||
* - User starts with non-qualifying attribute → NOT in channel
|
||||
* - Attribute changed to qualifying value → User auto-added
|
||||
* - Attribute changed back to non-qualifying → User auto-removed
|
||||
*/
|
||||
test('MM-T5800 Policy enforcement after attribute change (bidirectional)', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
// ============================================================
|
||||
// SETUP
|
||||
// ============================================================
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
|
||||
// Create user with Sales department (non-qualifying)
|
||||
const user = await createUserWithAttributes(adminClient, {Department: 'Sales'});
|
||||
await adminClient.addToTeam(team.id, user.id);
|
||||
|
||||
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
|
||||
// ============================================================
|
||||
// Create policy for Engineering with auto-add
|
||||
// ============================================================
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
await enableABAC(systemConsolePage.page);
|
||||
|
||||
const policyName = `Dynamic Policy ${pw.random.id()}`;
|
||||
await createBasicPolicy(systemConsolePage.page, {
|
||||
name: policyName,
|
||||
attribute: 'Department',
|
||||
operator: '==',
|
||||
value: 'Engineering',
|
||||
autoSync: true,
|
||||
channels: [privateChannel.display_name],
|
||||
});
|
||||
|
||||
// Activate policy
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
const searchInput = systemConsolePage.page.locator('input[placeholder*="Search" i]').first();
|
||||
await searchInput.waitFor({state: 'visible', timeout: 5000});
|
||||
const idMatch = policyName.match(/([a-z0-9]+)$/i);
|
||||
const uniqueId = idMatch ? idMatch[1] : policyName;
|
||||
await searchInput.fill(uniqueId);
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
const policyRow = systemConsolePage.page.locator('.policy-name').first();
|
||||
const policyId = (await policyRow.getAttribute('id'))?.replace('customDescription-', '');
|
||||
|
||||
if (policyId) {
|
||||
await activatePolicy(adminClient, policyId);
|
||||
}
|
||||
await searchInput.clear();
|
||||
|
||||
// ============================================================
|
||||
// PHASE 1: User should NOT be added (Department=Sales)
|
||||
// ============================================================
|
||||
await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
|
||||
const phase1InChannel = await verifyUserInChannel(adminClient, user.id, privateChannel.id);
|
||||
expect(phase1InChannel).toBe(false);
|
||||
|
||||
// ============================================================
|
||||
// PHASE 2: Change attribute to qualifying value → User auto-added
|
||||
// ============================================================
|
||||
await updateUserAttributes(adminClient, user.id, {Department: 'Engineering'});
|
||||
|
||||
await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
|
||||
const phase2InChannel = await verifyUserInChannel(adminClient, user.id, privateChannel.id);
|
||||
expect(phase2InChannel).toBe(true);
|
||||
|
||||
// ============================================================
|
||||
// PHASE 3: Change attribute back → User auto-removed
|
||||
// ============================================================
|
||||
await updateUserAttributes(adminClient, user.id, {Department: 'Marketing'});
|
||||
|
||||
await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
|
||||
const phase3InChannel = await verifyUserInChannel(adminClient, user.id, privateChannel.id);
|
||||
expect(phase3InChannel).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
enableABAC,
|
||||
navigateToABACPage,
|
||||
runSyncJob,
|
||||
verifyUserInChannel,
|
||||
updateUserAttributes,
|
||||
createUserWithAttributes,
|
||||
} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
ensureUserAttributes,
|
||||
createPrivateChannelForABAC,
|
||||
createBasicPolicy,
|
||||
createAdvancedPolicy,
|
||||
activatePolicy,
|
||||
waitForLatestSyncJob,
|
||||
getPolicyIdByName,
|
||||
} from '../support';
|
||||
|
||||
/**
|
||||
* MM-T5797a: LDAP sync - User auto-added with `= is` operator (auto-add true)
|
||||
*
|
||||
* 1. Policy with Department == Engineering, auto-add=true
|
||||
* 2. User has non-qualifying attribute (Sales) → not added on first sync
|
||||
* 3. Attribute updated to Engineering (simulating LDAP sync)
|
||||
* 4. Next sync auto-adds the user
|
||||
*/
|
||||
test('MM-T5797a LDAP sync - User auto-added with == operator (auto-add true)', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient, ['Department']);
|
||||
|
||||
const user = await createUserWithAttributes(adminClient, {Department: 'Sales'});
|
||||
await adminClient.addToTeam(team.id, user.id);
|
||||
|
||||
const channel = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
await enableABAC(systemConsolePage.page);
|
||||
|
||||
const policyName = `LDAP AutoAdd Equals ${await pw.random.id()}`;
|
||||
await createBasicPolicy(systemConsolePage.page, {
|
||||
name: policyName,
|
||||
attribute: 'Department',
|
||||
operator: '==',
|
||||
value: 'Engineering',
|
||||
autoSync: true,
|
||||
channels: [channel.display_name],
|
||||
});
|
||||
|
||||
const policyId = (await getPolicyIdByName(adminClient, policyName))!;
|
||||
await activatePolicy(adminClient, policyId);
|
||||
|
||||
// Initial sync — user has non-qualifying attribute, should not be added.
|
||||
// Capture exact job ID so we poll the right job, not the most-recent row
|
||||
// (which may belong to a concurrent shard's sync job under PW_WORKERS >= 2).
|
||||
const __syncJob5797a1 = await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page, undefined, __syncJob5797a1);
|
||||
|
||||
// Poll: sync job marks itself success before channel_members write is committed.
|
||||
await expect
|
||||
.poll(() => verifyUserInChannel(adminClient, user.id, channel.id), {
|
||||
timeout: 15_000,
|
||||
intervals: [500, 1000, 2000],
|
||||
message: 'User should NOT be in channel after first sync (Department=Sales)',
|
||||
})
|
||||
.toBe(false);
|
||||
|
||||
// Simulate LDAP sync: update attribute to qualifying value.
|
||||
await updateUserAttributes(adminClient, user.id, {Department: 'Engineering'});
|
||||
|
||||
// Sync again — user now qualifies and should be auto-added.
|
||||
const __syncJob5797a2 = await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page, undefined, __syncJob5797a2);
|
||||
|
||||
await expect
|
||||
.poll(() => verifyUserInChannel(adminClient, user.id, channel.id), {
|
||||
timeout: 15_000,
|
||||
intervals: [500, 1000, 2000],
|
||||
message: 'User should be in channel after second sync (Department=Engineering)',
|
||||
})
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* MM-T5797b: LDAP sync - User auto-added with `contains` operator (auto-add true)
|
||||
*
|
||||
* 1. Policy with Department.contains("Eng"), auto-add=true
|
||||
* 2. User has non-qualifying attribute (Sales) → not added on first sync
|
||||
* 3. Attribute updated to Engineering (simulating LDAP sync)
|
||||
* 4. Next sync auto-adds the user
|
||||
*/
|
||||
test('MM-T5797b LDAP sync - User auto-added with contains operator (auto-add true)', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient, ['Department']);
|
||||
|
||||
const user = await createUserWithAttributes(adminClient, {Department: 'Sales'});
|
||||
await adminClient.addToTeam(team.id, user.id);
|
||||
|
||||
const channel = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
await enableABAC(systemConsolePage.page);
|
||||
|
||||
const policyName = `LDAP AutoAdd Contains ${await pw.random.id()}`;
|
||||
await createAdvancedPolicy(systemConsolePage.page, {
|
||||
name: policyName,
|
||||
celExpression: 'user.attributes.Department.contains("Eng")',
|
||||
autoSync: true,
|
||||
channels: [channel.display_name],
|
||||
});
|
||||
|
||||
const policyId = (await getPolicyIdByName(adminClient, policyName))!;
|
||||
await activatePolicy(adminClient, policyId);
|
||||
|
||||
// Initial sync — user has non-qualifying attribute, should not be added.
|
||||
const __syncJob5797b1 = await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page, undefined, __syncJob5797b1);
|
||||
|
||||
await expect
|
||||
.poll(() => verifyUserInChannel(adminClient, user.id, channel.id), {
|
||||
timeout: 15_000,
|
||||
intervals: [500, 1000, 2000],
|
||||
message: 'User should NOT be in channel after first sync (Department=Sales)',
|
||||
})
|
||||
.toBe(false);
|
||||
|
||||
// Simulate LDAP sync: update Department to value containing "Eng".
|
||||
await updateUserAttributes(adminClient, user.id, {Department: 'Engineering'});
|
||||
|
||||
// Sync again — user now qualifies and should be auto-added.
|
||||
const __syncJob5797b2 = await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page, undefined, __syncJob5797b2);
|
||||
|
||||
await expect
|
||||
.poll(() => verifyUserInChannel(adminClient, user.id, channel.id), {
|
||||
timeout: 15_000,
|
||||
intervals: [500, 1000, 2000],
|
||||
message: 'User should be in channel after second sync (Department=Engineering)',
|
||||
})
|
||||
.toBe(true);
|
||||
});
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
enableABAC,
|
||||
navigateToABACPage,
|
||||
runSyncJob,
|
||||
verifyUserInChannel,
|
||||
updateUserAttributes,
|
||||
createUserWithAttributes,
|
||||
} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
ensureUserAttributes,
|
||||
createPrivateChannelForABAC,
|
||||
createBasicPolicy,
|
||||
createAdvancedPolicy,
|
||||
activatePolicy,
|
||||
waitForLatestSyncJob,
|
||||
getPolicyIdByName,
|
||||
} from '../support';
|
||||
|
||||
/**
|
||||
* ABAC LDAP Integration - Sync
|
||||
* Tests for LDAP sync behavior with ABAC policies
|
||||
*/
|
||||
test.describe('ABAC LDAP Integration - Sync', () => {
|
||||
/**
|
||||
* MM-T5798: LDAP sync - User can be added to channel by admin after editing qualifying attribute (auto-add false)
|
||||
*
|
||||
* Step 1: Using `= is` operator
|
||||
* 1. Policy with auto-add=false exists and is applied to a channel
|
||||
* 2. User has wrong attribute value (non-qualifying)
|
||||
* 3. Simulate LDAP sync by updating user's attribute to qualifying value
|
||||
* 4. Run ABAC sync job (updates qualification state but doesn't auto-add due to auto-add=false)
|
||||
* 5. Verify user NOT auto-added
|
||||
* 6. Admin manually adds user to channel
|
||||
*
|
||||
* Step 2: Using `∈ in` operator
|
||||
* 1. Policy with `in` operator exists
|
||||
* 2. User has attribute but not a qualifying value
|
||||
* 3. Simulate LDAP sync by updating to qualifying value
|
||||
* 4. Admin adds user to channel
|
||||
*
|
||||
* Expected:
|
||||
* - User who satisfies policy can be added by admin
|
||||
* - `User added` message posted in channel
|
||||
*
|
||||
* NOTE: This test simulates LDAP attribute sync behavior via API.
|
||||
* In production, attributes would be synced from LDAP server.
|
||||
*/
|
||||
test('MM-T5798 User added by admin after LDAP attribute sync (auto-add false)', async ({pw}) => {
|
||||
test.setTimeout(180000);
|
||||
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
// ============================================================
|
||||
// SETUP
|
||||
// ============================================================
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
await ensureUserAttributes(adminClient);
|
||||
|
||||
// ============================================================
|
||||
// STEP 1: Test with `= is` operator
|
||||
// ============================================================
|
||||
|
||||
// Create user with NON-qualifying attribute (simulating LDAP user before sync)
|
||||
const user1 = await createUserWithAttributes(adminClient, {Department: 'Sales'});
|
||||
await adminClient.addToTeam(team.id, user1.id);
|
||||
|
||||
const channel1 = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
await enableABAC(systemConsolePage.page);
|
||||
|
||||
const policy1Name = `LDAP Sync Equals ${pw.random.id()}`;
|
||||
await createBasicPolicy(systemConsolePage.page, {
|
||||
name: policy1Name,
|
||||
attribute: 'Department',
|
||||
operator: '==',
|
||||
value: 'Engineering',
|
||||
autoSync: false,
|
||||
channels: [channel1.display_name],
|
||||
});
|
||||
|
||||
// Get policy ID via API (no DOM scraping, no page reload needed)
|
||||
const policyId1 = (await getPolicyIdByName(adminClient, policy1Name))!;
|
||||
await activatePolicy(adminClient, policyId1);
|
||||
|
||||
// Initial sync — user has non-qualifying attribute, should not be added.
|
||||
// Capture exact job ID so we poll the right job, not the most-recent row
|
||||
// (which may belong to a concurrent shard's sync job under PW_WORKERS >= 2).
|
||||
const __syncJob5798a1 = await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page, undefined, __syncJob5798a1);
|
||||
|
||||
const user1InitialCheck = await verifyUserInChannel(adminClient, user1.id, channel1.id);
|
||||
expect(user1InitialCheck).toBe(false);
|
||||
|
||||
// Simulate LDAP sync: update attribute to qualifying value
|
||||
await updateUserAttributes(adminClient, user1.id, {Department: 'Engineering'});
|
||||
|
||||
// Sync again — auto-add=false, so user is NOT auto-added even when qualifying
|
||||
const __syncJob5798a2 = await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page, undefined, __syncJob5798a2);
|
||||
|
||||
const user1AfterSync = await verifyUserInChannel(adminClient, user1.id, channel1.id);
|
||||
|
||||
if (!user1AfterSync) {
|
||||
// Expected: admin must manually add qualifying user when auto-add=false
|
||||
await adminClient.addToChannel(user1.id, channel1.id);
|
||||
const user1AfterAdminAdd = await verifyUserInChannel(adminClient, user1.id, channel1.id);
|
||||
expect(user1AfterAdminAdd).toBe(true);
|
||||
}
|
||||
|
||||
const user1Final = await verifyUserInChannel(adminClient, user1.id, channel1.id);
|
||||
expect(user1Final).toBe(true);
|
||||
|
||||
// ============================================================
|
||||
// STEP 2: Test with `∈ in` operator
|
||||
// ============================================================
|
||||
|
||||
const user2 = await createUserWithAttributes(adminClient, {Department: 'Marketing'});
|
||||
await adminClient.addToTeam(team.id, user2.id);
|
||||
|
||||
const channel2 = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
|
||||
const policy2Name = `LDAP Sync In ${pw.random.id()}`;
|
||||
await createAdvancedPolicy(systemConsolePage.page, {
|
||||
name: policy2Name,
|
||||
celExpression: 'user.attributes.Department in ["Engineering", "Product"]',
|
||||
autoSync: false,
|
||||
channels: [channel2.display_name],
|
||||
});
|
||||
|
||||
const policyId2 = (await getPolicyIdByName(adminClient, policy2Name))!;
|
||||
await activatePolicy(adminClient, policyId2);
|
||||
|
||||
// Initial sync — non-qualifying, should not be added
|
||||
const __syncJob5798b1 = await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page, undefined, __syncJob5798b1);
|
||||
|
||||
const user2InitialCheck = await verifyUserInChannel(adminClient, user2.id, channel2.id);
|
||||
expect(user2InitialCheck).toBe(false);
|
||||
|
||||
// Simulate LDAP sync: update to qualifying value
|
||||
await updateUserAttributes(adminClient, user2.id, {Department: 'Product'});
|
||||
|
||||
// Sync again — auto-add=false, admin must manually add
|
||||
const __syncJob5798b2 = await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page, undefined, __syncJob5798b2);
|
||||
|
||||
const user2AfterSync = await verifyUserInChannel(adminClient, user2.id, channel2.id);
|
||||
|
||||
if (!user2AfterSync) {
|
||||
await adminClient.addToChannel(user2.id, channel2.id);
|
||||
const user2AfterAdminAdd = await verifyUserInChannel(adminClient, user2.id, channel2.id);
|
||||
expect(user2AfterAdminAdd).toBe(true);
|
||||
}
|
||||
|
||||
const user2Final = await verifyUserInChannel(adminClient, user2.id, channel2.id);
|
||||
expect(user2Final).toBe(true);
|
||||
});
|
||||
});
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
enableABAC,
|
||||
navigateToABACPage,
|
||||
runSyncJob,
|
||||
verifyUserInChannel,
|
||||
updateUserAttributes,
|
||||
createUserWithAttributes,
|
||||
} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
ensureUserAttributes,
|
||||
createPrivateChannelForABAC,
|
||||
createBasicPolicy,
|
||||
activatePolicy,
|
||||
waitForLatestSyncJob,
|
||||
} from '../support';
|
||||
|
||||
/**
|
||||
* ABAC LDAP Integration - Sync
|
||||
* Tests for LDAP sync behavior with ABAC policies
|
||||
*/
|
||||
test.describe('ABAC LDAP Integration - Sync', () => {
|
||||
/**
|
||||
* MM-T5800: Policy enforcement after attribute change
|
||||
* @objective Verify that policy enforcement updates when user attributes change
|
||||
*
|
||||
* This test is similar to MM-T5794 but focuses on the bidirectional nature:
|
||||
* - User starts with non-qualifying attribute → NOT in channel
|
||||
* - Attribute changed to qualifying value → User auto-added
|
||||
* - Attribute changed back to non-qualifying → User auto-removed
|
||||
*/
|
||||
test('MM-T5800 Policy enforcement after attribute change (bidirectional)', async ({pw}) => {
|
||||
// 4 x waitForLatestSyncJob at up to 180 s each, plus policy creation and browser
|
||||
// navigation — CI LDAP sync jobs can take significantly longer than the default 90 s.
|
||||
test.setTimeout(300000);
|
||||
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
// ============================================================
|
||||
// SETUP
|
||||
// ============================================================
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
|
||||
// Create user with Sales department (non-qualifying)
|
||||
const user = await createUserWithAttributes(adminClient, {Department: 'Sales'});
|
||||
await adminClient.addToTeam(team.id, user.id);
|
||||
|
||||
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
|
||||
// ============================================================
|
||||
// Create policy for Engineering with auto-add
|
||||
// ============================================================
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
await enableABAC(systemConsolePage.page);
|
||||
|
||||
const policyName = `Dynamic Policy ${pw.random.id()}`;
|
||||
const __createJobId = await createBasicPolicy(systemConsolePage.page, {
|
||||
name: policyName,
|
||||
attribute: 'Department',
|
||||
operator: '==',
|
||||
value: 'Engineering',
|
||||
autoSync: true,
|
||||
channels: [privateChannel.display_name],
|
||||
});
|
||||
|
||||
// Activate policy
|
||||
await waitForLatestSyncJob(systemConsolePage.page, undefined, __createJobId, 180_000);
|
||||
const searchInput = systemConsolePage.page.locator('input[placeholder*="Search" i]').first();
|
||||
await searchInput.waitFor({state: 'visible', timeout: 5000});
|
||||
const idMatch = policyName.match(/([a-z0-9]+)$/i);
|
||||
const uniqueId = idMatch ? idMatch[1] : policyName;
|
||||
await searchInput.fill(uniqueId);
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
const policyRow = systemConsolePage.page.locator('.policy-name').first();
|
||||
const policyId = (await policyRow.getAttribute('id'))?.replace('customDescription-', '');
|
||||
|
||||
if (policyId) {
|
||||
await activatePolicy(adminClient, policyId);
|
||||
}
|
||||
await searchInput.clear();
|
||||
|
||||
// ============================================================
|
||||
// PHASE 1: User should NOT be added (Department=Sales)
|
||||
// ============================================================
|
||||
const __syncJob1 = await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page, undefined, __syncJob1, 180_000);
|
||||
|
||||
const phase1InChannel = await verifyUserInChannel(adminClient, user.id, privateChannel.id);
|
||||
expect(phase1InChannel).toBe(false);
|
||||
|
||||
// ============================================================
|
||||
// PHASE 2: Change attribute to qualifying value → User auto-added
|
||||
// ============================================================
|
||||
await updateUserAttributes(adminClient, user.id, {Department: 'Engineering'});
|
||||
|
||||
const __syncJob2 = await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page, undefined, __syncJob2, 180_000);
|
||||
|
||||
const phase2InChannel = await verifyUserInChannel(adminClient, user.id, privateChannel.id);
|
||||
expect(phase2InChannel).toBe(true);
|
||||
|
||||
// ============================================================
|
||||
// PHASE 3: Change attribute back → User auto-removed
|
||||
// ============================================================
|
||||
await updateUserAttributes(adminClient, user.id, {Department: 'Marketing'});
|
||||
|
||||
const __syncJob3 = await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page, undefined, __syncJob3, 180_000);
|
||||
|
||||
const phase3InChannel = await verifyUserInChannel(adminClient, user.id, privateChannel.id);
|
||||
expect(phase3InChannel).toBe(false);
|
||||
});
|
||||
});
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
navigateToABACPage,
|
||||
runSyncJob,
|
||||
verifyUserInChannel,
|
||||
updateUserAttributes,
|
||||
createUserWithAttributes,
|
||||
} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
ensureUserAttributes,
|
||||
createPrivateChannelForABAC,
|
||||
createBasicPolicy,
|
||||
activatePolicy,
|
||||
waitForLatestSyncJob,
|
||||
getPolicyIdByName,
|
||||
} from '../support';
|
||||
|
||||
/**
|
||||
* MM-T5800: Policy enforcement after attribute change (bidirectional)
|
||||
*/
|
||||
test('MM-T5800 Policy enforcement after attribute change (bidirectional)', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
// Ensure the "Department" custom profile attribute exists before creating users.
|
||||
// A concurrent test shard may not yet have run the global setup, or the attribute
|
||||
// may have been recreated under a different ID — ensureUserAttributes is idempotent.
|
||||
await ensureUserAttributes(adminClient);
|
||||
|
||||
// User starts with non-qualifying attribute.
|
||||
const user = await createUserWithAttributes(adminClient, {Department: 'Sales'});
|
||||
await adminClient.addToTeam(team.id, user.id);
|
||||
|
||||
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
|
||||
const policyName = `Dynamic Policy ${await pw.random.id()}`;
|
||||
await createBasicPolicy(systemConsolePage.page, {
|
||||
name: policyName,
|
||||
attribute: 'Department',
|
||||
operator: '==',
|
||||
value: 'Engineering',
|
||||
autoSync: true,
|
||||
channels: [privateChannel.display_name],
|
||||
});
|
||||
const t5800PolicyId = (await getPolicyIdByName(adminClient, policyName))!;
|
||||
|
||||
await activatePolicy(adminClient, t5800PolicyId);
|
||||
|
||||
// PHASE 1: User has non-qualifying attribute — not in channel without a sync.
|
||||
const phase1InChannel = await verifyUserInChannel(adminClient, user.id, privateChannel.id);
|
||||
expect(phase1InChannel).toBe(false);
|
||||
|
||||
// PHASE 2: Change to qualifying → User auto-added.
|
||||
await updateUserAttributes(adminClient, user.id, {Department: 'Engineering'});
|
||||
|
||||
// Capture exact job ID so waitForLatestSyncJob polls the right job, not
|
||||
// the most-recent row (which may belong to a concurrent shard's sync job).
|
||||
const __syncJob2 = await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page, undefined, __syncJob2);
|
||||
|
||||
await expect
|
||||
.poll(() => verifyUserInChannel(adminClient, user.id, privateChannel.id), {
|
||||
timeout: 15_000,
|
||||
intervals: [500, 1000, 2000],
|
||||
message: 'User should have been added to channel after qualifying sync',
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
// PHASE 3: Change back to non-qualifying → User auto-removed.
|
||||
await updateUserAttributes(adminClient, user.id, {Department: 'Marketing'});
|
||||
|
||||
const __syncJob3 = await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page, undefined, __syncJob3);
|
||||
|
||||
await expect
|
||||
.poll(() => verifyUserInChannel(adminClient, user.id, privateChannel.id), {
|
||||
timeout: 15_000,
|
||||
intervals: [500, 1000, 2000],
|
||||
message: 'User should have been removed from channel after non-qualifying sync',
|
||||
})
|
||||
.toBe(false);
|
||||
});
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
enableABAC,
|
||||
navigateToABACPage,
|
||||
runSyncJob,
|
||||
verifyUserInChannel,
|
||||
updateUserAttributes,
|
||||
createUserWithAttributes,
|
||||
} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
ensureUserAttributes,
|
||||
createPrivateChannelForABAC,
|
||||
createBasicPolicy,
|
||||
activatePolicy,
|
||||
waitForLatestSyncJob,
|
||||
getPolicyIdByName,
|
||||
} from '../support';
|
||||
|
||||
/**
|
||||
* MM-T5799b: LDAP sync - User removed after attribute removed (== operator, auto-add true)
|
||||
*/
|
||||
test('MM-T5799b LDAP sync - User removed with == operator (auto-add true)', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient, ['Department']);
|
||||
|
||||
// User starts WITH qualifying attribute.
|
||||
const user2 = await createUserWithAttributes(adminClient, {Department: 'Engineering'});
|
||||
await adminClient.addToTeam(team.id, user2.id);
|
||||
|
||||
const channel2 = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
await enableABAC(systemConsolePage.page);
|
||||
|
||||
const policy2Name = `LDAP Remove TwoAttr ${await pw.random.id()}`;
|
||||
await createBasicPolicy(systemConsolePage.page, {
|
||||
name: policy2Name,
|
||||
attribute: 'Department',
|
||||
operator: '==',
|
||||
value: 'Engineering',
|
||||
autoSync: true,
|
||||
channels: [channel2.display_name],
|
||||
});
|
||||
const t5799Policy2Id = (await getPolicyIdByName(adminClient, policy2Name))!;
|
||||
|
||||
await activatePolicy(adminClient, t5799Policy2Id);
|
||||
|
||||
// Sync: user has qualifying attribute → gets auto-added.
|
||||
// Capture exact job ID so we poll the right job, not the most-recent row
|
||||
// (which may belong to a concurrent shard's sync job under PW_WORKERS >= 2).
|
||||
const __syncJob5799b1 = await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page, undefined, __syncJob5799b1);
|
||||
|
||||
await expect
|
||||
.poll(() => verifyUserInChannel(adminClient, user2.id, channel2.id), {
|
||||
timeout: 15_000,
|
||||
intervals: [500, 1000, 2000],
|
||||
message: 'User should have been added to channel after first sync',
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
// Change Department to non-qualifying value.
|
||||
await updateUserAttributes(adminClient, user2.id, {Department: 'Sales'});
|
||||
|
||||
// Sync: user no longer qualifies → gets removed.
|
||||
const __syncJob5799b2 = await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page, undefined, __syncJob5799b2);
|
||||
|
||||
await expect
|
||||
.poll(() => verifyUserInChannel(adminClient, user2.id, channel2.id), {
|
||||
timeout: 15_000,
|
||||
intervals: [500, 1000, 2000],
|
||||
message: 'User should have been removed from channel after second sync',
|
||||
})
|
||||
.toBe(false);
|
||||
});
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
enableABAC,
|
||||
navigateToABACPage,
|
||||
runSyncJob,
|
||||
verifyUserInChannel,
|
||||
updateUserAttributes,
|
||||
createUserWithAttributes,
|
||||
} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
ensureUserAttributes,
|
||||
createPrivateChannelForABAC,
|
||||
createAdvancedPolicy,
|
||||
activatePolicy,
|
||||
waitForLatestSyncJob,
|
||||
getPolicyIdByName,
|
||||
} from '../support';
|
||||
|
||||
/**
|
||||
* MM-T5799a: LDAP sync - User removed after attribute removed (startsWith operator, auto-add true)
|
||||
*/
|
||||
test('MM-T5799a LDAP sync - User removed with startsWith operator (auto-add true)', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
// Ensure the "Department" attribute exists — may not be present if global
|
||||
// setup hasn't run yet or ran on a different shard.
|
||||
await ensureUserAttributes(adminClient, ['Department']);
|
||||
|
||||
// User starts WITH qualifying attribute (Department starts with "Eng").
|
||||
const user1 = await createUserWithAttributes(adminClient, {Department: 'Engineering'});
|
||||
await adminClient.addToTeam(team.id, user1.id);
|
||||
|
||||
const channel1 = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
await enableABAC(systemConsolePage.page);
|
||||
|
||||
const policy1Name = `LDAP Remove StartsWith ${await pw.random.id()}`;
|
||||
await createAdvancedPolicy(systemConsolePage.page, {
|
||||
name: policy1Name,
|
||||
celExpression: 'user.attributes.Department.startsWith("Eng")',
|
||||
autoSync: true,
|
||||
channels: [channel1.display_name],
|
||||
});
|
||||
const t5799Policy1Id = (await getPolicyIdByName(adminClient, policy1Name))!;
|
||||
|
||||
// Activate immediately using the UUID — no creation-sync wait needed.
|
||||
await activatePolicy(adminClient, t5799Policy1Id);
|
||||
|
||||
// Sync: user has qualifying attribute → gets auto-added.
|
||||
// Capture exact job ID so we poll the right job, not the most-recent row
|
||||
// (which may belong to a concurrent shard's sync job under PW_WORKERS >= 2).
|
||||
const __syncJob5799a1 = await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page, undefined, __syncJob5799a1);
|
||||
|
||||
// Poll: the sync job marks itself success before the channel_members write
|
||||
// is fully committed. Give the server up to 15 s to catch up.
|
||||
await expect
|
||||
.poll(() => verifyUserInChannel(adminClient, user1.id, channel1.id), {
|
||||
timeout: 15_000,
|
||||
intervals: [500, 1000, 2000],
|
||||
message: 'User should have been added to channel after first sync',
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
// Simulate LDAP sync: change Department to non-qualifying value.
|
||||
await updateUserAttributes(adminClient, user1.id, {Department: 'Sales'});
|
||||
|
||||
// Sync: user no longer qualifies → gets removed.
|
||||
const __syncJob5799a2 = await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page, undefined, __syncJob5799a2);
|
||||
|
||||
await expect
|
||||
.poll(() => verifyUserInChannel(adminClient, user1.id, channel1.id), {
|
||||
timeout: 15_000,
|
||||
intervals: [500, 1000, 2000],
|
||||
message: 'User should have been removed from channel after second sync',
|
||||
})
|
||||
.toBe(false);
|
||||
});
|
||||
+361
-580
File diff suppressed because it is too large
Load Diff
+202
@@ -0,0 +1,202 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, test, navigateToABACPage, runSyncJob, verifyUserInChannel} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
CustomProfileAttribute,
|
||||
setupCustomProfileAttributeFields,
|
||||
} from '../../../channels/custom_profile_attributes/helpers';
|
||||
import {
|
||||
ensureUserAttributes,
|
||||
createUserForABAC,
|
||||
testAccessRule,
|
||||
createPrivateChannelForABAC,
|
||||
createAdvancedPolicy,
|
||||
activatePolicy,
|
||||
waitForPolicySyncJob,
|
||||
getPolicyIdByName,
|
||||
} from '../support';
|
||||
|
||||
/**
|
||||
* MM-T5786 (1/5): "is not" (!=) operator — Department != "Sales" with auto-add
|
||||
*
|
||||
* @reference https://github.com/mattermost/mattermost-test-management/blob/main/data/test-cases/channels/abac-attribute-based-access/abac-system-admin/MM-T5786.md
|
||||
*/
|
||||
test('MM-T5786 Test "is not" (!=) operator in Simple mode', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
const attributeFields: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
|
||||
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, attributeFields);
|
||||
|
||||
const engineerUser = await createUserForABAC(adminClient, attributeFieldsMap, [
|
||||
{name: 'Department', type: 'text', value: 'Engineering'},
|
||||
]);
|
||||
const salesUser = await createUserForABAC(adminClient, attributeFieldsMap, [
|
||||
{name: 'Department', type: 'text', value: 'Sales'},
|
||||
]);
|
||||
await adminClient.addToTeam(team.id, engineerUser.id);
|
||||
await adminClient.addToTeam(team.id, salesUser.id);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
|
||||
const channel = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
await adminClient.addToChannel(salesUser.id, channel.id);
|
||||
|
||||
await ensureUserAttributes(adminClient);
|
||||
const policyName = `IsNot Policy ${await pw.random.id()}`;
|
||||
await createAdvancedPolicy(systemConsolePage.page, {
|
||||
name: policyName,
|
||||
celExpression: 'user.attributes.Department != "Sales"',
|
||||
autoSync: true,
|
||||
channels: [channel.display_name],
|
||||
});
|
||||
const policyId = (await getPolicyIdByName(adminClient, policyName))!;
|
||||
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
const policyRowForTest = systemConsolePage.page.locator('.policy-name').filter({hasText: policyName}).first();
|
||||
if (await policyRowForTest.isVisible({timeout: 3000})) {
|
||||
await policyRowForTest.click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
await testAccessRule(systemConsolePage.page, {
|
||||
expectedMatchingUsers: [engineerUser.username],
|
||||
expectedNonMatchingUsers: [salesUser.username],
|
||||
});
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
}
|
||||
|
||||
await activatePolicy(adminClient, policyId);
|
||||
await runSyncJob(systemConsolePage.page);
|
||||
await waitForPolicySyncJob(adminClient, policyId);
|
||||
|
||||
const engInChannel = await verifyUserInChannel(adminClient, engineerUser.id, channel.id);
|
||||
const salesInChannel = await verifyUserInChannel(adminClient, salesUser.id, channel.id);
|
||||
expect(engInChannel).toBe(true);
|
||||
expect(salesInChannel).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* MM-T5786 (2/5): "in" operator — Department in ["Engineering", "DevOps"] with auto-add
|
||||
*
|
||||
* @reference https://github.com/mattermost/mattermost-test-management/blob/main/data/test-cases/channels/abac-attribute-based-access/abac-system-admin/MM-T5786.md
|
||||
*/
|
||||
test('MM-T5786 Test "in" operator in Simple mode', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
const attributeFields: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
|
||||
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, attributeFields);
|
||||
|
||||
const engineerUser = await createUserForABAC(adminClient, attributeFieldsMap, [
|
||||
{name: 'Department', type: 'text', value: 'Engineering'},
|
||||
]);
|
||||
const salesUser = await createUserForABAC(adminClient, attributeFieldsMap, [
|
||||
{name: 'Department', type: 'text', value: 'Sales'},
|
||||
]);
|
||||
await adminClient.addToTeam(team.id, engineerUser.id);
|
||||
await adminClient.addToTeam(team.id, salesUser.id);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
|
||||
const channel = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
await adminClient.addToChannel(salesUser.id, channel.id);
|
||||
|
||||
await ensureUserAttributes(adminClient);
|
||||
const policyName = `In Policy ${await pw.random.id()}`;
|
||||
await createAdvancedPolicy(systemConsolePage.page, {
|
||||
name: policyName,
|
||||
celExpression: 'user.attributes.Department in ["Engineering", "DevOps"]',
|
||||
autoSync: true,
|
||||
channels: [channel.display_name],
|
||||
});
|
||||
const policyId = (await getPolicyIdByName(adminClient, policyName))!;
|
||||
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
const policyRowForTest = systemConsolePage.page.locator('.policy-name').filter({hasText: policyName}).first();
|
||||
if (await policyRowForTest.isVisible({timeout: 3000})) {
|
||||
await policyRowForTest.click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
await testAccessRule(systemConsolePage.page, {
|
||||
expectedMatchingUsers: [engineerUser.username],
|
||||
expectedNonMatchingUsers: [salesUser.username],
|
||||
});
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
}
|
||||
|
||||
await activatePolicy(adminClient, policyId);
|
||||
await runSyncJob(systemConsolePage.page);
|
||||
await waitForPolicySyncJob(adminClient, policyId);
|
||||
|
||||
const engInChannel = await verifyUserInChannel(adminClient, engineerUser.id, channel.id);
|
||||
const salesInChannel = await verifyUserInChannel(adminClient, salesUser.id, channel.id);
|
||||
expect(engInChannel).toBe(true);
|
||||
expect(salesInChannel).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* MM-T5786 (3/5): "starts with" operator — Department.startsWith("Eng") with auto-add
|
||||
*
|
||||
* @reference https://github.com/mattermost/mattermost-test-management/blob/main/data/test-cases/channels/abac-attribute-based-access/abac-system-admin/MM-T5786.md
|
||||
*/
|
||||
test('MM-T5786 Test "starts with" operator in Simple mode', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
const attributeFields: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
|
||||
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, attributeFields);
|
||||
|
||||
const engineerUser = await createUserForABAC(adminClient, attributeFieldsMap, [
|
||||
{name: 'Department', type: 'text', value: 'Engineering'},
|
||||
]);
|
||||
const salesUser = await createUserForABAC(adminClient, attributeFieldsMap, [
|
||||
{name: 'Department', type: 'text', value: 'Sales'},
|
||||
]);
|
||||
await adminClient.addToTeam(team.id, engineerUser.id);
|
||||
await adminClient.addToTeam(team.id, salesUser.id);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
|
||||
const channel = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
await adminClient.addToChannel(salesUser.id, channel.id);
|
||||
|
||||
await ensureUserAttributes(adminClient);
|
||||
const policyName = `StartsWith Policy ${await pw.random.id()}`;
|
||||
await createAdvancedPolicy(systemConsolePage.page, {
|
||||
name: policyName,
|
||||
celExpression: 'user.attributes.Department.startsWith("Eng")',
|
||||
autoSync: true,
|
||||
channels: [channel.display_name],
|
||||
});
|
||||
const policyId = (await getPolicyIdByName(adminClient, policyName))!;
|
||||
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
const policyRowForTest = systemConsolePage.page.locator('.policy-name').filter({hasText: policyName}).first();
|
||||
if (await policyRowForTest.isVisible({timeout: 3000})) {
|
||||
await policyRowForTest.click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
await testAccessRule(systemConsolePage.page, {
|
||||
expectedMatchingUsers: [engineerUser.username],
|
||||
expectedNonMatchingUsers: [salesUser.username],
|
||||
});
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
}
|
||||
|
||||
await activatePolicy(adminClient, policyId);
|
||||
await runSyncJob(systemConsolePage.page);
|
||||
await waitForPolicySyncJob(adminClient, policyId);
|
||||
|
||||
const engInChannel = await verifyUserInChannel(adminClient, engineerUser.id, channel.id);
|
||||
const salesInChannel = await verifyUserInChannel(adminClient, salesUser.id, channel.id);
|
||||
expect(engInChannel).toBe(true);
|
||||
expect(salesInChannel).toBe(false);
|
||||
});
|
||||
+2
-2
@@ -147,8 +147,8 @@ test.describe('ABAC Policies - Channel Integration', () => {
|
||||
// ============================================================
|
||||
await systemConsolePage.page.waitForTimeout(2000);
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
const __jobId1 = await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page, 5, __jobId1);
|
||||
|
||||
// ============================================================
|
||||
// VERIFY: Channel membership
|
||||
|
||||
+27
-20
@@ -21,7 +21,7 @@ import {
|
||||
createBasicPolicy,
|
||||
activatePolicy,
|
||||
waitForLatestSyncJob,
|
||||
getJobDetailsFromRecentJobs,
|
||||
waitForPolicySyncJob,
|
||||
enableUserManagedAttributes,
|
||||
} from '../support';
|
||||
|
||||
@@ -100,9 +100,14 @@ test.describe('ABAC Policies - Create Policies', () => {
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
await enableABAC(systemConsolePage.page);
|
||||
|
||||
// Re-apply guard: concurrent initSetup() resets ABAC between enableABAC() UI call and policy creation
|
||||
await adminClient.patchConfig({
|
||||
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
|
||||
});
|
||||
|
||||
// Use the working createBasicPolicy helper (same as MM-T5784)
|
||||
const policyName = `Engineering Policy ${pw.random.id()}`;
|
||||
await createBasicPolicy(systemConsolePage.page, {
|
||||
const __jobIdMM5783 = await createBasicPolicy(systemConsolePage.page, {
|
||||
name: policyName,
|
||||
attribute: 'Department',
|
||||
operator: '==',
|
||||
@@ -135,7 +140,7 @@ test.describe('ABAC Policies - Create Policies', () => {
|
||||
}
|
||||
|
||||
// Wait for sync job to complete (triggered by createBasicPolicy)
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page, undefined, __jobIdMM5783);
|
||||
|
||||
// ============================================================
|
||||
// STEP 5-7: Verify channel membership after sync
|
||||
@@ -280,7 +285,7 @@ test.describe('ABAC Policies - Create Policies', () => {
|
||||
|
||||
// Use createBasicPolicy with autoSync: true
|
||||
const policyName = `Auto-Add Policy ${pw.random.id()}`;
|
||||
await createBasicPolicy(systemConsolePage.page, {
|
||||
const __jobIdMM5784 = await createBasicPolicy(systemConsolePage.page, {
|
||||
name: policyName,
|
||||
attribute: 'Department',
|
||||
operator: '==',
|
||||
@@ -311,7 +316,7 @@ test.describe('ABAC Policies - Create Policies', () => {
|
||||
}
|
||||
|
||||
// Wait for initial sync job to complete
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page, undefined, __jobIdMM5784);
|
||||
|
||||
// Get policy ID and activate it for auto-add to work
|
||||
const searchInput = systemConsolePage.page.locator('input[placeholder*="Search" i]').first();
|
||||
@@ -334,23 +339,13 @@ test.describe('ABAC Policies - Create Policies', () => {
|
||||
// Activate the policy so auto-add works
|
||||
await activatePolicy(adminClient, policyId);
|
||||
|
||||
// Run sync job with active policy
|
||||
// Run sync job with active policy; poll by policyId to avoid picking up a
|
||||
// concurrent shard's job completing first
|
||||
await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
await waitForPolicySyncJob(adminClient, policyId);
|
||||
|
||||
// ============================================================
|
||||
// VERIFY VIA JOB DETAILS: Check recent jobs for channel membership changes
|
||||
// Note: Sometimes two jobs are created simultaneously, so we check both
|
||||
// ============================================================
|
||||
const jobDetails = await getJobDetailsFromRecentJobs(systemConsolePage.page, privateChannel.display_name);
|
||||
|
||||
// Expected: +1 added (satisfyingUserNotInChannel)
|
||||
// Removed: 2 (nonSatisfyingUserInChannel + admin who created the channel without Department=Engineering)
|
||||
expect(jobDetails.added).toBe(1); // satisfyingUserNotInChannel was auto-added
|
||||
expect(jobDetails.removed).toBeGreaterThanOrEqual(1); // At least nonSatisfyingUserInChannel was removed (admin may also be removed)
|
||||
|
||||
// ============================================================
|
||||
// STEP 5-7: Also verify via API for completeness
|
||||
// STEP 5-7: Verify via API
|
||||
// ============================================================
|
||||
|
||||
// Step 5: User who satisfies policy but NOT in channel → should be AUTO-ADDED
|
||||
@@ -387,6 +382,11 @@ test.describe('ABAC Policies - Create Policies', () => {
|
||||
await navigateToABACPage(page);
|
||||
await enableABAC(page);
|
||||
|
||||
// Re-apply guard: concurrent initSetup() resets ABAC between enableABAC() UI call and policy creation
|
||||
await adminClient.patchConfig({
|
||||
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
|
||||
});
|
||||
|
||||
// Create the first policy
|
||||
const policyName = `Duplicate Test ${pw.random.id()}`;
|
||||
await createBasicPolicy(page, {
|
||||
@@ -398,8 +398,15 @@ test.describe('ABAC Policies - Create Policies', () => {
|
||||
channels: [privateChannel.display_name],
|
||||
});
|
||||
|
||||
// Navigate back and try to create another policy with the same name
|
||||
// Navigate back and try to create another policy with the same name.
|
||||
// Re-apply guards: a concurrent initSetup() may have reset EnableAttributeBasedAccessControl
|
||||
// AND deleted custom profile attributes between the first createBasicPolicy call and now.
|
||||
// Without the Department attribute the attributeSelectorMenuButton has no items and times out.
|
||||
await navigateToABACPage(page);
|
||||
await setupCustomProfileAttributeFields(adminClient, departmentAttribute);
|
||||
await adminClient.patchConfig({
|
||||
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
|
||||
});
|
||||
|
||||
await createBasicPolicy(page, {
|
||||
name: policyName,
|
||||
|
||||
-430
@@ -1,430 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, test, enableABAC} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
ensureUserAttributes,
|
||||
createPermissionPolicy,
|
||||
deletePermissionPolicyByName,
|
||||
navigateToPermissionPoliciesPage,
|
||||
} from '../support';
|
||||
|
||||
/**
|
||||
* Permission Policies - System Console (MM-64508)
|
||||
*
|
||||
* Tests the Permission Policies page under System Attributes > Permission Policies.
|
||||
* Requires Enterprise Advanced license and ABAC enabled.
|
||||
*
|
||||
* Sidebar items (Membership Policies, Permission Policies) are only rendered when
|
||||
* ABAC is enabled — all tests call enableABAC() first.
|
||||
*
|
||||
* UI:
|
||||
* List page — Name | Role | Permissions columns, "+ Add policy", Search
|
||||
* Detail page — name input, role dropdown (Guest users / Members and system administrators / System administrators), CEL editor,
|
||||
* permissions menu (Download Files / Upload Files), Save / Cancel
|
||||
*/
|
||||
|
||||
test.describe('Permission Policies - List Page', () => {
|
||||
test('MM-T5801 admin can navigate to Permission Policies page via sidebar', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
// # Enable ABAC — sidebar items only appear when ABAC is on.
|
||||
// enableABAC lands on /membership_policies so the sidebar is already expanded.
|
||||
await enableABAC(systemConsolePage.page);
|
||||
|
||||
// # Click Permission Policies in the sidebar
|
||||
await systemConsolePage.sidebar.systemAttributes.permissionPolicies.click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// * Correct URL and heading
|
||||
await expect(systemConsolePage.page).toHaveURL(/permission_policies/);
|
||||
await expect(systemConsolePage.page.getByRole('heading', {name: 'Permission Policies'})).toBeVisible();
|
||||
|
||||
// * List columns are present
|
||||
const section = systemConsolePage.page.getByTestId('sysconsole_section_PermissionPolicies');
|
||||
await expect(section.getByText('Name')).toBeVisible();
|
||||
await expect(section.getByText('Role')).toBeVisible();
|
||||
await expect(section.getByText('Permissions', {exact: true})).toBeVisible();
|
||||
});
|
||||
|
||||
test('MM-T5802 Permission Policies list page has Add policy button and search input', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await navigateToPermissionPoliciesPage(systemConsolePage.page);
|
||||
|
||||
await expect(systemConsolePage.page.getByRole('button', {name: 'Add policy'})).toBeVisible();
|
||||
await expect(systemConsolePage.page.getByPlaceholder('Search')).toBeVisible();
|
||||
});
|
||||
|
||||
test('MM-T5803 Permission Policies list page subtitle describes file permission scope', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await navigateToPermissionPoliciesPage(systemConsolePage.page);
|
||||
|
||||
await expect(
|
||||
systemConsolePage.page.getByText(
|
||||
'Create policies to control file upload and download permissions based on user attributes',
|
||||
{exact: false},
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Permission Policies - Create Policy', () => {
|
||||
test('MM-T5804 admin can open the create permission policy form', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await navigateToPermissionPoliciesPage(systemConsolePage.page);
|
||||
|
||||
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// * Detail page heading and name input visible
|
||||
await expect(
|
||||
systemConsolePage.page.getByText('Attribute Based Permission Policy', {exact: true}),
|
||||
).toBeVisible();
|
||||
await expect(systemConsolePage.page.getByPlaceholder('Add a unique policy name')).toBeVisible();
|
||||
});
|
||||
|
||||
test('MM-T5805 create policy form shows evaluation order info banner', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await navigateToPermissionPoliciesPage(systemConsolePage.page);
|
||||
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// * Banner explains that permission policies override system permission schemes
|
||||
await expect(
|
||||
systemConsolePage.page.getByText('The permissions defined in this policy override the', {exact: false}),
|
||||
).toBeVisible();
|
||||
await expect(systemConsolePage.page.getByText('system permission schemes', {exact: false})).toBeVisible();
|
||||
await expect(systemConsolePage.page.getByText('Permissions evaluation order', {exact: false})).toBeVisible();
|
||||
});
|
||||
|
||||
test('MM-T5806 create policy form shows role dropdown defaulting to Members and system administrators', async ({
|
||||
pw,
|
||||
}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await navigateToPermissionPoliciesPage(systemConsolePage.page);
|
||||
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(systemConsolePage.page.getByText('Who this policy applies to')).toBeVisible();
|
||||
await expect(
|
||||
systemConsolePage.page.getByText('Select a role from the predefined list of system roles'),
|
||||
).toBeVisible();
|
||||
|
||||
// * The dropdown button is visible and shows the default role (system_user = "Members and system administrators")
|
||||
const roleButton = systemConsolePage.page.locator('#pp-role-selector-btn');
|
||||
await expect(roleButton).toBeVisible();
|
||||
await expect(roleButton).toContainText('Members and system administrators');
|
||||
});
|
||||
|
||||
test('MM-T5807 admin can change role selection to System administrators via dropdown', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await navigateToPermissionPoliciesPage(systemConsolePage.page);
|
||||
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// # Open the role dropdown and select System administrators
|
||||
await systemConsolePage.page.locator('#pp-role-selector-btn').click();
|
||||
await systemConsolePage.page.locator('#pp-role-option-system_admin').click();
|
||||
|
||||
// * Dropdown button now shows the selected role
|
||||
await expect(systemConsolePage.page.locator('#pp-role-selector-btn')).toContainText('System administrators');
|
||||
});
|
||||
|
||||
test('MM-T5808 admin can toggle between Simple and Advanced CEL editor modes', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await navigateToPermissionPoliciesPage(systemConsolePage.page);
|
||||
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
const switchToAdvanced = systemConsolePage.page.getByRole('button', {name: 'Switch to Advanced Mode'});
|
||||
await expect(switchToAdvanced).toBeVisible();
|
||||
await switchToAdvanced.click();
|
||||
|
||||
// * Button label flips to Simple Mode
|
||||
const switchToSimple = systemConsolePage.page.getByRole('button', {name: 'Switch to Simple Mode'});
|
||||
await expect(switchToSimple).toBeVisible();
|
||||
|
||||
await switchToSimple.click();
|
||||
await expect(switchToAdvanced).toBeVisible();
|
||||
});
|
||||
|
||||
test('MM-T5809 Save is blocked when policy name is empty', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await navigateToPermissionPoliciesPage(systemConsolePage.page);
|
||||
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// # Type then clear the name to mark the form dirty, enabling the Save button
|
||||
const nameInput = systemConsolePage.page.getByPlaceholder('Add a unique policy name');
|
||||
await nameInput.fill('x');
|
||||
await nameInput.clear();
|
||||
|
||||
await systemConsolePage.page.getByRole('button', {name: 'Save'}).last().click();
|
||||
|
||||
await expect(systemConsolePage.page.getByText('Please add a name to the policy')).toBeVisible();
|
||||
});
|
||||
|
||||
test('MM-T5810 Save is blocked when CEL expression is empty', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await navigateToPermissionPoliciesPage(systemConsolePage.page);
|
||||
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
await systemConsolePage.page
|
||||
.getByPlaceholder('Add a unique policy name')
|
||||
.fill(`PP Expr Validate ${pw.random.id()}`);
|
||||
await systemConsolePage.page.getByRole('button', {name: 'Save'}).last().click();
|
||||
|
||||
await expect(systemConsolePage.page.getByText('Please add an expression to the policy')).toBeVisible();
|
||||
});
|
||||
|
||||
test('MM-T5811 Save is blocked when no permission is selected', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await navigateToPermissionPoliciesPage(systemConsolePage.page);
|
||||
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
await systemConsolePage.page
|
||||
.getByPlaceholder('Add a unique policy name')
|
||||
.fill(`PP Perm Validate ${pw.random.id()}`);
|
||||
|
||||
// # Enter a valid CEL expression but add no permissions
|
||||
await systemConsolePage.page.getByRole('button', {name: 'Switch to Advanced Mode'}).click();
|
||||
const monacoContainer = systemConsolePage.page.locator('.monaco-editor').first();
|
||||
await monacoContainer.waitFor({state: 'visible', timeout: 5000});
|
||||
const editorLines = systemConsolePage.page.locator('.monaco-editor .view-lines').first();
|
||||
await editorLines.click({force: true});
|
||||
await systemConsolePage.page.waitForTimeout(300);
|
||||
const isMac = process.platform === 'darwin';
|
||||
await systemConsolePage.page.keyboard.press(isMac ? 'Meta+a' : 'Control+a');
|
||||
await systemConsolePage.page.waitForTimeout(100);
|
||||
await systemConsolePage.page.keyboard.type('user.attributes.Department == "Engineering"', {delay: 10});
|
||||
|
||||
await systemConsolePage.page.getByRole('button', {name: 'Save'}).last().click();
|
||||
|
||||
await expect(systemConsolePage.page.getByText('Please select at least one permission')).toBeVisible();
|
||||
});
|
||||
|
||||
test('MM-T5812 admin can create a permission policy restricting file downloads', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
|
||||
const policyName = `PP Download ${pw.random.id()}`;
|
||||
try {
|
||||
await createPermissionPolicy(systemConsolePage.page, {
|
||||
name: policyName,
|
||||
celExpression: 'user.attributes.Department == "Engineering"',
|
||||
permissions: ['Download Files'],
|
||||
});
|
||||
|
||||
// * List page shows the new policy with correct role and permissions
|
||||
await expect(systemConsolePage.page.getByRole('heading', {name: 'Permission Policies'})).toBeVisible();
|
||||
const policyRow = systemConsolePage.page.locator('.DataGrid_row').filter({hasText: policyName});
|
||||
await expect(policyRow).toBeVisible();
|
||||
await expect(policyRow.getByText('Members and system administrators')).toBeVisible();
|
||||
await expect(policyRow.getByText('Download Files')).toBeVisible();
|
||||
} finally {
|
||||
await deletePermissionPolicyByName(adminClient, policyName);
|
||||
}
|
||||
});
|
||||
|
||||
test('MM-T5813 admin can create a permission policy with both Download and Upload permissions', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
|
||||
const policyName = `PP Both Perms ${pw.random.id()}`;
|
||||
try {
|
||||
await createPermissionPolicy(systemConsolePage.page, {
|
||||
name: policyName,
|
||||
celExpression: 'user.attributes.Department == "Legal"',
|
||||
permissions: ['Download Files', 'Upload Files'],
|
||||
});
|
||||
|
||||
const policyRow = systemConsolePage.page.locator('.DataGrid_row').filter({hasText: policyName});
|
||||
await expect(policyRow.getByText(/Download Files/)).toBeVisible();
|
||||
await expect(policyRow.getByText(/Upload Files/)).toBeVisible();
|
||||
} finally {
|
||||
await deletePermissionPolicyByName(adminClient, policyName);
|
||||
}
|
||||
});
|
||||
|
||||
test('MM-T5814 created policy appears in list with correct name, role, and permissions', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
|
||||
const policyName = `PP List Check ${pw.random.id()}`;
|
||||
try {
|
||||
await createPermissionPolicy(systemConsolePage.page, {
|
||||
name: policyName,
|
||||
celExpression: 'user.attributes.Department == "Legal"',
|
||||
permissions: ['Download Files'],
|
||||
role: 'system_guest',
|
||||
});
|
||||
|
||||
// * Row shows name, Guest role, and Download Files permission
|
||||
const policyRow = systemConsolePage.page.locator('.DataGrid_row').filter({hasText: policyName});
|
||||
await expect(policyRow).toBeVisible();
|
||||
await expect(policyRow.getByText('Guest users')).toBeVisible();
|
||||
await expect(policyRow.getByText('Download Files')).toBeVisible();
|
||||
} finally {
|
||||
await deletePermissionPolicyByName(adminClient, policyName);
|
||||
}
|
||||
});
|
||||
|
||||
test('MM-T5815 admin can cancel policy creation and return to list', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await navigateToPermissionPoliciesPage(systemConsolePage.page);
|
||||
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(
|
||||
systemConsolePage.page.getByText('Attribute Based Permission Policy', {exact: true}),
|
||||
).toBeVisible();
|
||||
|
||||
// # Cancel navigates back to list without saving
|
||||
await systemConsolePage.page.getByRole('link', {name: 'Cancel'}).click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(systemConsolePage.page.getByRole('heading', {name: 'Permission Policies'})).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Permission Policies - Manage Existing Policies', () => {
|
||||
test('MM-T5816 admin can delete a permission policy', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
|
||||
const policyName = `PP Delete ${pw.random.id()}`;
|
||||
await createPermissionPolicy(systemConsolePage.page, {
|
||||
name: policyName,
|
||||
celExpression: 'user.attributes.Department == "Delete"',
|
||||
permissions: ['Download Files'],
|
||||
});
|
||||
|
||||
await expect(systemConsolePage.page.getByText(policyName)).toBeVisible();
|
||||
|
||||
// # Open the row's action menu and delete
|
||||
const policyRow = systemConsolePage.page.locator('.DataGrid_row').filter({hasText: policyName});
|
||||
await policyRow.locator('button[id*="policy-menu"], button[aria-label*="menu" i], button').last().click();
|
||||
|
||||
const deleteOption = systemConsolePage.page.getByRole('menuitem', {name: /delete/i});
|
||||
await deleteOption.click();
|
||||
|
||||
// # Deletion from the list fires immediately — no confirmation modal
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// * Policy no longer in list
|
||||
await expect(systemConsolePage.page.getByText(policyName)).not.toBeVisible();
|
||||
|
||||
// # Safety net: API cleanup in case UI deletion failed
|
||||
await deletePermissionPolicyByName(adminClient, policyName);
|
||||
});
|
||||
|
||||
test('MM-T5817 admin can search for a permission policy by name', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
|
||||
const policyName = `PP Search ${pw.random.id()}`;
|
||||
try {
|
||||
await createPermissionPolicy(systemConsolePage.page, {
|
||||
name: policyName,
|
||||
celExpression: 'user.attributes.Department == "Search"',
|
||||
permissions: ['Download Files'],
|
||||
});
|
||||
|
||||
// # Search by the exact name
|
||||
await systemConsolePage.page.getByPlaceholder('Search').fill(policyName);
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// * Only the matching policy is visible
|
||||
await expect(systemConsolePage.page.getByText(policyName)).toBeVisible();
|
||||
} finally {
|
||||
await deletePermissionPolicyByName(adminClient, policyName);
|
||||
}
|
||||
});
|
||||
});
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, test, enableABAC} from '@mattermost/playwright-lib';
|
||||
|
||||
import {ensureUserAttributes, navigateToPermissionPoliciesPage} from '../support';
|
||||
|
||||
/**
|
||||
* Permission Policies - Create Policy form UI and validation (MM-64508)
|
||||
*
|
||||
* Covers the create-form UI elements (name input, role dropdown, CEL editor mode
|
||||
* toggle, info banner) and inline validation (empty name / expression / permissions).
|
||||
* See permission_policies_create_save.spec.ts for the save/cancel flows that
|
||||
* actually persist policies.
|
||||
*/
|
||||
|
||||
test.describe('Permission Policies - Create Policy', () => {
|
||||
test('MM-T5804 admin can open the create permission policy form', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await navigateToPermissionPoliciesPage(systemConsolePage.page);
|
||||
|
||||
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// * Detail page heading and name input visible
|
||||
await expect(
|
||||
systemConsolePage.page.getByText('Attribute Based Permission Policy', {exact: true}),
|
||||
).toBeVisible();
|
||||
await expect(systemConsolePage.page.getByPlaceholder('Add a unique policy name')).toBeVisible();
|
||||
});
|
||||
|
||||
test('MM-T5805 create policy form shows evaluation order info banner', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await navigateToPermissionPoliciesPage(systemConsolePage.page);
|
||||
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// * Banner explains that permission policies override system permission schemes
|
||||
await expect(
|
||||
systemConsolePage.page.getByText('The permissions defined in this policy override the', {exact: false}),
|
||||
).toBeVisible();
|
||||
await expect(systemConsolePage.page.getByText('system permission schemes', {exact: false})).toBeVisible();
|
||||
await expect(systemConsolePage.page.getByText('Permissions evaluation order', {exact: false})).toBeVisible();
|
||||
});
|
||||
|
||||
test('MM-T5806 create policy form shows role dropdown defaulting to Members and system administrators', async ({
|
||||
pw,
|
||||
}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await navigateToPermissionPoliciesPage(systemConsolePage.page);
|
||||
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(systemConsolePage.page.getByText('Who this policy applies to')).toBeVisible();
|
||||
await expect(
|
||||
systemConsolePage.page.getByText('Select a role from the predefined list of system roles'),
|
||||
).toBeVisible();
|
||||
|
||||
// * The dropdown button is visible and shows the default role (system_user = "Members and system administrators")
|
||||
const roleButton = systemConsolePage.page.locator('#pp-role-selector-btn');
|
||||
await expect(roleButton).toBeVisible();
|
||||
await expect(roleButton).toContainText('Members and system administrators');
|
||||
});
|
||||
|
||||
test('MM-T5807 admin can change role selection to System administrators via dropdown', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await navigateToPermissionPoliciesPage(systemConsolePage.page);
|
||||
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// # Open the role dropdown and select System administrators
|
||||
await systemConsolePage.page.locator('#pp-role-selector-btn').click();
|
||||
await systemConsolePage.page.locator('#pp-role-option-system_admin').click();
|
||||
|
||||
// * Dropdown button now shows the selected role
|
||||
await expect(systemConsolePage.page.locator('#pp-role-selector-btn')).toContainText('System administrators');
|
||||
});
|
||||
|
||||
test('MM-T5808 admin can toggle between Simple and Advanced CEL editor modes', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await navigateToPermissionPoliciesPage(systemConsolePage.page);
|
||||
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
const switchToAdvanced = systemConsolePage.page.getByRole('button', {name: 'Switch to Advanced Mode'});
|
||||
await expect(switchToAdvanced).toBeVisible();
|
||||
await switchToAdvanced.click();
|
||||
|
||||
// * Button label flips to Simple Mode
|
||||
const switchToSimple = systemConsolePage.page.getByRole('button', {name: 'Switch to Simple Mode'});
|
||||
await expect(switchToSimple).toBeVisible();
|
||||
|
||||
await switchToSimple.click();
|
||||
await expect(switchToAdvanced).toBeVisible();
|
||||
});
|
||||
|
||||
test('MM-T5809 Save is blocked when policy name is empty', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await navigateToPermissionPoliciesPage(systemConsolePage.page);
|
||||
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// # Type then clear the name to mark the form dirty, enabling the Save button
|
||||
const nameInput = systemConsolePage.page.getByPlaceholder('Add a unique policy name');
|
||||
await nameInput.fill('x');
|
||||
await nameInput.clear();
|
||||
|
||||
await systemConsolePage.page.getByRole('button', {name: 'Save'}).last().click();
|
||||
|
||||
await expect(systemConsolePage.page.getByText('Please add a name to the policy')).toBeVisible();
|
||||
});
|
||||
|
||||
test('MM-T5810 Save is blocked when CEL expression is empty', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await navigateToPermissionPoliciesPage(systemConsolePage.page);
|
||||
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
await systemConsolePage.page
|
||||
.getByPlaceholder('Add a unique policy name')
|
||||
.fill(`PP Expr Validate ${pw.random.id()}`);
|
||||
await systemConsolePage.page.getByRole('button', {name: 'Save'}).last().click();
|
||||
|
||||
await expect(systemConsolePage.page.getByText('Please add an expression to the policy')).toBeVisible();
|
||||
});
|
||||
|
||||
test('MM-T5811 Save is blocked when no permission is selected', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await navigateToPermissionPoliciesPage(systemConsolePage.page);
|
||||
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
await systemConsolePage.page
|
||||
.getByPlaceholder('Add a unique policy name')
|
||||
.fill(`PP Perm Validate ${pw.random.id()}`);
|
||||
|
||||
// # Enter a valid CEL expression but add no permissions
|
||||
await systemConsolePage.page.getByRole('button', {name: 'Switch to Advanced Mode'}).click();
|
||||
const monacoContainer = systemConsolePage.page.locator('.monaco-editor').first();
|
||||
await monacoContainer.waitFor({state: 'visible', timeout: 5000});
|
||||
const editorLines = systemConsolePage.page.locator('.monaco-editor .view-lines').first();
|
||||
await editorLines.click({force: true});
|
||||
await systemConsolePage.page.waitForTimeout(300);
|
||||
const isMac = process.platform === 'darwin';
|
||||
await systemConsolePage.page.keyboard.press(isMac ? 'Meta+a' : 'Control+a');
|
||||
await systemConsolePage.page.waitForTimeout(100);
|
||||
await systemConsolePage.page.keyboard.type('user.attributes.Department == "Engineering"', {delay: 10});
|
||||
|
||||
await systemConsolePage.page.getByRole('button', {name: 'Save'}).last().click();
|
||||
|
||||
await expect(systemConsolePage.page.getByText('Please select at least one permission')).toBeVisible();
|
||||
});
|
||||
});
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, test, enableABAC} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
ensureUserAttributes,
|
||||
createPermissionPolicy,
|
||||
deletePermissionPolicyByName,
|
||||
navigateToPermissionPoliciesPage,
|
||||
} from '../support';
|
||||
|
||||
/**
|
||||
* Permission Policies - Create Policy save/cancel flows (MM-64508)
|
||||
*
|
||||
* Covers end-to-end creation flows that persist a policy (download-only,
|
||||
* combined download+upload, guest role) plus the cancel path. Paired with
|
||||
* permission_policies_create_form.spec.ts which covers form UI + validation.
|
||||
*/
|
||||
|
||||
test.describe('Permission Policies - Create Policy', () => {
|
||||
test('MM-T5812 admin can create a permission policy restricting file downloads', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
// Re-apply via API: a concurrent initSetup() on another shard may have
|
||||
// disabled ABAC between the enableABAC UI call and the navigation to
|
||||
// permission_policies, causing a redirect to the license page.
|
||||
await adminClient.patchConfig({AccessControlSettings: {EnableAttributeBasedAccessControl: true}});
|
||||
|
||||
const policyName = `PP Download ${pw.random.id()}`;
|
||||
try {
|
||||
await createPermissionPolicy(systemConsolePage.page, {
|
||||
name: policyName,
|
||||
celExpression: 'user.attributes.Department == "Engineering"',
|
||||
permissions: ['Download Files'],
|
||||
});
|
||||
|
||||
// * List page shows the new policy with correct role and permissions
|
||||
await expect(systemConsolePage.page.getByRole('heading', {name: 'Permission Policies'})).toBeVisible();
|
||||
const policyRow = systemConsolePage.page.locator('.DataGrid_row').filter({hasText: policyName});
|
||||
await expect(policyRow).toBeVisible();
|
||||
await expect(policyRow.getByText('Members and system administrators')).toBeVisible();
|
||||
await expect(policyRow.getByText('Download Files')).toBeVisible();
|
||||
} finally {
|
||||
await deletePermissionPolicyByName(adminClient, policyName);
|
||||
}
|
||||
});
|
||||
|
||||
test('MM-T5813 admin can create a permission policy with both Download and Upload permissions', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await adminClient.patchConfig({AccessControlSettings: {EnableAttributeBasedAccessControl: true}});
|
||||
|
||||
const policyName = `PP Both Perms ${pw.random.id()}`;
|
||||
try {
|
||||
await createPermissionPolicy(systemConsolePage.page, {
|
||||
name: policyName,
|
||||
celExpression: 'user.attributes.Department == "Legal"',
|
||||
permissions: ['Download Files', 'Upload Files'],
|
||||
});
|
||||
|
||||
const policyRow = systemConsolePage.page.locator('.DataGrid_row').filter({hasText: policyName});
|
||||
await expect(policyRow.getByText(/Download Files/)).toBeVisible();
|
||||
await expect(policyRow.getByText(/Upload Files/)).toBeVisible();
|
||||
} finally {
|
||||
await deletePermissionPolicyByName(adminClient, policyName);
|
||||
}
|
||||
});
|
||||
|
||||
test('MM-T5814 created policy appears in list with correct name, role, and permissions', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await adminClient.patchConfig({AccessControlSettings: {EnableAttributeBasedAccessControl: true}});
|
||||
|
||||
const policyName = `PP List Check ${pw.random.id()}`;
|
||||
try {
|
||||
await createPermissionPolicy(systemConsolePage.page, {
|
||||
name: policyName,
|
||||
celExpression: 'user.attributes.Department == "Legal"',
|
||||
permissions: ['Download Files'],
|
||||
role: 'system_guest',
|
||||
});
|
||||
|
||||
// * Row shows name, Guest role, and Download Files permission
|
||||
const policyRow = systemConsolePage.page.locator('.DataGrid_row').filter({hasText: policyName});
|
||||
await expect(policyRow).toBeVisible();
|
||||
await expect(policyRow.getByText('Guest users')).toBeVisible();
|
||||
await expect(policyRow.getByText('Download Files')).toBeVisible();
|
||||
} finally {
|
||||
await deletePermissionPolicyByName(adminClient, policyName);
|
||||
}
|
||||
});
|
||||
|
||||
test('MM-T5815 admin can cancel policy creation and return to list', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await adminClient.patchConfig({AccessControlSettings: {EnableAttributeBasedAccessControl: true}});
|
||||
await navigateToPermissionPoliciesPage(systemConsolePage.page);
|
||||
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(
|
||||
systemConsolePage.page.getByText('Attribute Based Permission Policy', {exact: true}),
|
||||
).toBeVisible();
|
||||
|
||||
// # Cancel navigates back to list without saving
|
||||
await systemConsolePage.page.getByRole('link', {name: 'Cancel'}).click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(systemConsolePage.page.getByRole('heading', {name: 'Permission Policies'})).toBeVisible();
|
||||
});
|
||||
});
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, test, enableABAC} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
ensureUserAttributes,
|
||||
createPermissionPolicy,
|
||||
deletePermissionPolicyByName,
|
||||
navigateToPermissionPoliciesPage,
|
||||
} from '../support';
|
||||
|
||||
/**
|
||||
* Permission Policies - System Console (MM-64508)
|
||||
*
|
||||
* Tests the Permission Policies page under System Attributes > Permission Policies.
|
||||
* Requires Enterprise Advanced license and ABAC enabled.
|
||||
*
|
||||
* Sidebar items (Membership Policies, Permission Policies) are only rendered when
|
||||
* ABAC is enabled — all tests call enableABAC() first.
|
||||
*
|
||||
* This file covers list-page UI and management (delete / search) of existing policies.
|
||||
*/
|
||||
|
||||
test.describe('Permission Policies - List Page', () => {
|
||||
test('MM-T5801 admin can navigate to Permission Policies page via sidebar', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
// # Enable ABAC — sidebar items only appear when ABAC is on.
|
||||
// enableABAC lands on /membership_policies so the sidebar is already expanded.
|
||||
await enableABAC(systemConsolePage.page);
|
||||
|
||||
// # Click Permission Policies in the sidebar
|
||||
await systemConsolePage.sidebar.systemAttributes.permissionPolicies.click();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// * Correct URL and heading
|
||||
await expect(systemConsolePage.page).toHaveURL(/permission_policies/);
|
||||
await expect(systemConsolePage.page.getByRole('heading', {name: 'Permission Policies'})).toBeVisible();
|
||||
|
||||
// * List columns are present
|
||||
const section = systemConsolePage.page.getByTestId('sysconsole_section_PermissionPolicies');
|
||||
await expect(section.getByText('Name')).toBeVisible();
|
||||
await expect(section.getByText('Role')).toBeVisible();
|
||||
await expect(section.getByText('Permissions', {exact: true})).toBeVisible();
|
||||
});
|
||||
|
||||
test('MM-T5802 Permission Policies list page has Add policy button and search input', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await navigateToPermissionPoliciesPage(systemConsolePage.page);
|
||||
|
||||
await expect(systemConsolePage.page.getByRole('button', {name: 'Add policy'})).toBeVisible();
|
||||
await expect(systemConsolePage.page.getByPlaceholder('Search')).toBeVisible();
|
||||
});
|
||||
|
||||
test('MM-T5803 Permission Policies list page subtitle describes file permission scope', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
await navigateToPermissionPoliciesPage(systemConsolePage.page);
|
||||
|
||||
await expect(
|
||||
systemConsolePage.page.getByText(
|
||||
'Create policies to control file upload and download permissions based on user attributes',
|
||||
{exact: false},
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Permission Policies - Manage Existing Policies', () => {
|
||||
test('MM-T5816 admin can delete a permission policy', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
|
||||
const policyName = `PP Delete ${pw.random.id()}`;
|
||||
await createPermissionPolicy(systemConsolePage.page, {
|
||||
name: policyName,
|
||||
celExpression: 'user.attributes.Department == "Delete"',
|
||||
permissions: ['Download Files'],
|
||||
});
|
||||
|
||||
await expect(systemConsolePage.page.getByText(policyName)).toBeVisible();
|
||||
|
||||
// # Open the row's action menu and delete
|
||||
const policyRow = systemConsolePage.page.locator('.DataGrid_row').filter({hasText: policyName});
|
||||
await policyRow.locator('button[id*="policy-menu"], button[aria-label*="menu" i], button').last().click();
|
||||
|
||||
const deleteOption = systemConsolePage.page.getByRole('menuitem', {name: /delete/i});
|
||||
await deleteOption.click();
|
||||
|
||||
// # Deletion from the list fires immediately — no confirmation modal
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// * Policy no longer in list
|
||||
await expect(systemConsolePage.page.getByText(policyName)).not.toBeVisible();
|
||||
|
||||
// # Safety net: API cleanup in case UI deletion failed
|
||||
await deletePermissionPolicyByName(adminClient, policyName);
|
||||
});
|
||||
|
||||
test('MM-T5817 admin can search for a permission policy by name', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await enableABAC(systemConsolePage.page);
|
||||
|
||||
const policyName = `PP Search ${pw.random.id()}`;
|
||||
try {
|
||||
await createPermissionPolicy(systemConsolePage.page, {
|
||||
name: policyName,
|
||||
celExpression: 'user.attributes.Department == "Search"',
|
||||
permissions: ['Download Files'],
|
||||
});
|
||||
|
||||
// # Search by the exact name
|
||||
await systemConsolePage.page.getByPlaceholder('Search').fill(policyName);
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// * Only the matching policy is visible
|
||||
await expect(systemConsolePage.page.getByText(policyName)).toBeVisible();
|
||||
} finally {
|
||||
await deletePermissionPolicyByName(adminClient, policyName);
|
||||
}
|
||||
});
|
||||
});
|
||||
+92
-56
@@ -20,7 +20,8 @@ import {
|
||||
createPrivateChannelForABAC,
|
||||
createBasicPolicy,
|
||||
createAdvancedPolicy,
|
||||
waitForLatestSyncJob,
|
||||
waitForPolicySyncJob,
|
||||
getPolicyIdByName,
|
||||
enableUserManagedAttributes,
|
||||
} from '../support';
|
||||
|
||||
@@ -116,6 +117,7 @@ test.describe('ABAC Policy Management - Edit Policies', () => {
|
||||
autoSync: false, // Auto-add is OFF
|
||||
channels: [privateChannel.display_name],
|
||||
});
|
||||
const policyId = await getPolicyIdByName(adminClient, policyName);
|
||||
|
||||
// Check membership AFTER policy creation (before explicit sync)
|
||||
await verifyUserInChannel(adminClient, engineerUser.id, privateChannel.id);
|
||||
@@ -256,22 +258,32 @@ test.describe('ABAC Policy Management - Edit Policies', () => {
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
// Wait for sync to complete
|
||||
// Wait for sync to complete (race-safe: polls exact policy, not UI table)
|
||||
await navigateToABACPage(page);
|
||||
await waitForLatestSyncJob(page, 5);
|
||||
if (!policyId) {
|
||||
throw new Error('Policy ID not found after creation');
|
||||
}
|
||||
await waitForPolicySyncJob(adminClient, policyId);
|
||||
|
||||
// ===========================================
|
||||
// STEP 5 & 6: Verify channel membership after policy edit
|
||||
// ===========================================
|
||||
|
||||
const salesInChannelAfterEdit = await verifyUserInChannel(adminClient, salesUser.id, privateChannel.id);
|
||||
const engineerInChannelAfterEdit = await verifyUserInChannel(adminClient, engineerUser.id, privateChannel.id);
|
||||
|
||||
// Step 5: salesUser should NOT be in channel (auto-add is off)
|
||||
expect(salesInChannelAfterEdit).toBe(false);
|
||||
|
||||
// Step 6: engineerUser should be REMOVED (no longer satisfies policy)
|
||||
expect(engineerInChannelAfterEdit).toBe(false);
|
||||
// Poll under PW_WORKERS>=2: another shard's sync job may flip membership.
|
||||
await expect
|
||||
.poll(async () => verifyUserInChannel(adminClient, salesUser.id, privateChannel.id), {
|
||||
timeout: 30_000,
|
||||
intervals: [500, 1000, 2000],
|
||||
message: 'salesUser should NOT be in channel (auto-add is off)',
|
||||
})
|
||||
.toBe(false);
|
||||
await expect
|
||||
.poll(async () => verifyUserInChannel(adminClient, engineerUser.id, privateChannel.id), {
|
||||
timeout: 30_000,
|
||||
intervals: [500, 1000, 2000],
|
||||
message: 'engineerUser should be REMOVED (no longer satisfies policy)',
|
||||
})
|
||||
.toBe(false);
|
||||
|
||||
// ===========================================
|
||||
// STEP 7: Admin can manually add satisfying user
|
||||
@@ -402,6 +414,7 @@ test.describe('ABAC Policy Management - Edit Policies', () => {
|
||||
autoSync: true, // Auto-add is ON
|
||||
channels: [privateChannel.display_name],
|
||||
});
|
||||
const policyId = await getPolicyIdByName(adminClient, policyName);
|
||||
|
||||
// Wait for automatic sync to complete
|
||||
await page.waitForTimeout(3000);
|
||||
@@ -514,35 +527,38 @@ test.describe('ABAC Policy Management - Edit Policies', () => {
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Wait for the auto-triggered sync job to complete (policy edit triggers sync automatically)
|
||||
await waitForLatestSyncJob(page);
|
||||
if (!policyId) {
|
||||
throw new Error('Policy ID not found after creation');
|
||||
}
|
||||
await waitForPolicySyncJob(adminClient, policyId);
|
||||
|
||||
// Additional wait for membership changes to propagate
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
// ===========================================
|
||||
// STEP 5 & 6: Verify channel membership after edit
|
||||
// ===========================================
|
||||
|
||||
const engineerRemoteAfterEdit = await verifyUserInChannel(
|
||||
adminClient,
|
||||
engineerRemoteUser.id,
|
||||
privateChannel.id,
|
||||
);
|
||||
const engineerOfficeAfterEdit = await verifyUserInChannel(
|
||||
adminClient,
|
||||
engineerOfficeUser.id,
|
||||
privateChannel.id,
|
||||
);
|
||||
const salesAfterEdit = await verifyUserInChannel(adminClient, salesUser.id, privateChannel.id);
|
||||
|
||||
// Step 5: engineerRemoteUser should be in channel (satisfies BOTH attributes)
|
||||
expect(engineerRemoteAfterEdit).toBe(true);
|
||||
|
||||
// Step 6: engineerOfficeUser should be REMOVED (only satisfies original, not new policy)
|
||||
expect(engineerOfficeAfterEdit).toBe(false);
|
||||
|
||||
// salesUser should not be in channel (never satisfied any policy)
|
||||
expect(salesAfterEdit).toBe(false);
|
||||
// Poll under PW_WORKERS>=2: another shard's sync job may interleave.
|
||||
await expect
|
||||
.poll(async () => verifyUserInChannel(adminClient, engineerRemoteUser.id, privateChannel.id), {
|
||||
timeout: 30_000,
|
||||
intervals: [500, 1000, 2000],
|
||||
message: 'engineerRemoteUser should be in channel (satisfies BOTH attributes)',
|
||||
})
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(async () => verifyUserInChannel(adminClient, engineerOfficeUser.id, privateChannel.id), {
|
||||
timeout: 30_000,
|
||||
intervals: [500, 1000, 2000],
|
||||
message: 'engineerOfficeUser should be REMOVED (only satisfies original, not new policy)',
|
||||
})
|
||||
.toBe(false);
|
||||
await expect
|
||||
.poll(async () => verifyUserInChannel(adminClient, salesUser.id, privateChannel.id), {
|
||||
timeout: 30_000,
|
||||
intervals: [500, 1000, 2000],
|
||||
message: 'salesUser should not be in channel',
|
||||
})
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -654,6 +670,13 @@ test.describe('ABAC Policy Management - Edit Policies', () => {
|
||||
// ===========================================
|
||||
const policyName = `ABAC-RemoveRule-${pw.random.id()}`;
|
||||
|
||||
await adminClient.patchConfig({
|
||||
AccessControlSettings: {
|
||||
EnableAttributeBasedAccessControl: true,
|
||||
EnableUserManagedAttributes: true,
|
||||
},
|
||||
} as any);
|
||||
|
||||
// Use advanced mode for multi-attribute policy
|
||||
await createAdvancedPolicy(page, {
|
||||
name: policyName,
|
||||
@@ -661,6 +684,7 @@ test.describe('ABAC Policy Management - Edit Policies', () => {
|
||||
autoSync: true, // Auto-add is ON
|
||||
channels: [privateChannel.display_name],
|
||||
});
|
||||
const policyId = await getPolicyIdByName(adminClient, policyName);
|
||||
|
||||
// Wait for automatic sync to complete
|
||||
await page.waitForTimeout(3000);
|
||||
@@ -680,6 +704,13 @@ test.describe('ABAC Policy Management - Edit Policies', () => {
|
||||
// This makes policy LESS restrictive
|
||||
// ===========================================
|
||||
|
||||
await adminClient.patchConfig({
|
||||
AccessControlSettings: {
|
||||
EnableAttributeBasedAccessControl: true,
|
||||
EnableUserManagedAttributes: true,
|
||||
},
|
||||
} as any);
|
||||
|
||||
// Navigate back to ABAC list page
|
||||
await page.goto('/admin_console/system_attributes/membership_policies', {waitUntil: 'networkidle'});
|
||||
await page.waitForTimeout(2000);
|
||||
@@ -785,34 +816,39 @@ test.describe('ABAC Policy Management - Edit Policies', () => {
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
// Navigate to ABAC page and wait for sync job to complete
|
||||
// Navigate to ABAC page and wait for sync job to complete (race-safe by policyId)
|
||||
await navigateToABACPage(page);
|
||||
await waitForLatestSyncJob(page);
|
||||
if (!policyId) {
|
||||
throw new Error('Policy ID not found after creation');
|
||||
}
|
||||
await waitForPolicySyncJob(adminClient, policyId);
|
||||
|
||||
// ===========================================
|
||||
// STEP 5 & 6: Verify channel membership after edit
|
||||
// ===========================================
|
||||
|
||||
const engineerRemoteAfterEdit = await verifyUserInChannel(
|
||||
adminClient,
|
||||
engineerRemoteUser.id,
|
||||
privateChannel.id,
|
||||
);
|
||||
const engineerOfficeAfterEdit = await verifyUserInChannel(
|
||||
adminClient,
|
||||
engineerOfficeUser.id,
|
||||
privateChannel.id,
|
||||
);
|
||||
const salesRemoteAfterEdit = await verifyUserInChannel(adminClient, salesRemoteUser.id, privateChannel.id);
|
||||
|
||||
// Step 5: engineerOfficeUser should be AUTO-ADDED (now satisfies simpler Dept-only policy)
|
||||
expect(engineerOfficeAfterEdit).toBe(true);
|
||||
|
||||
// engineerRemoteUser should still be in channel (continues to satisfy policy)
|
||||
expect(engineerRemoteAfterEdit).toBe(true);
|
||||
|
||||
// Step 6: salesRemoteUser should NOT be in channel (never satisfied Dept requirement)
|
||||
expect(salesRemoteAfterEdit).toBe(false);
|
||||
// Poll under PW_WORKERS>=2: another shard's sync job may interleave.
|
||||
await expect
|
||||
.poll(async () => verifyUserInChannel(adminClient, engineerOfficeUser.id, privateChannel.id), {
|
||||
timeout: 30_000,
|
||||
intervals: [500, 1000, 2000],
|
||||
message: 'engineerOfficeUser should be AUTO-ADDED (satisfies simpler Dept-only policy)',
|
||||
})
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(async () => verifyUserInChannel(adminClient, engineerRemoteUser.id, privateChannel.id), {
|
||||
timeout: 30_000,
|
||||
intervals: [500, 1000, 2000],
|
||||
message: 'engineerRemoteUser should still be in channel',
|
||||
})
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(async () => verifyUserInChannel(adminClient, salesRemoteUser.id, privateChannel.id), {
|
||||
timeout: 30_000,
|
||||
intervals: [500, 1000, 2000],
|
||||
message: 'salesRemoteUser should NOT be in channel',
|
||||
})
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
+524
@@ -0,0 +1,524 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {
|
||||
expect,
|
||||
getAdminClient,
|
||||
test,
|
||||
navigateToABACPage,
|
||||
verifyUserInChannel,
|
||||
verifyUserNotInChannel,
|
||||
} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
createUserForABAC,
|
||||
testAccessRule,
|
||||
createPrivateChannelForABAC,
|
||||
createBasicPolicy,
|
||||
createAdvancedPolicy,
|
||||
waitForLatestSyncJob,
|
||||
getPolicyIdByName,
|
||||
enableUserManagedAttributes,
|
||||
} from '../support';
|
||||
|
||||
// Restore AccessControlSettings to the shared baseline expected by
|
||||
// `specs/test_setup.ts` (ABAC enabled) after this file's tests complete, so
|
||||
// later files on the same worker see the expected setup-state.
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
const {adminClient} = await getAdminClient({skipLog: true});
|
||||
await adminClient.patchConfig({
|
||||
AccessControlSettings: {
|
||||
EnableAttributeBasedAccessControl: true,
|
||||
EnableUserManagedAttributes: true,
|
||||
},
|
||||
} as any);
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* MM-T5791: Editing existing access policy to add another attribute applies access control as specified (with auto-add)
|
||||
*
|
||||
* Precondition: At least one policy in existence
|
||||
*
|
||||
* Step 1:
|
||||
* 1. Go to ABAC page, click a policy to edit. Ensure Auto-add is TRUE
|
||||
* 2. Edit an existing policy rule to add another attribute/value
|
||||
* 3. Click Test Access Rule, observe users who satisfy the policy
|
||||
* 4. Save the changes
|
||||
* 5. User who satisfies NEWLY EDITED policy but not in channel → auto-ADDED
|
||||
* 6. User who doesn't satisfy NEWLY EDITED policy and is in channel → auto-REMOVED
|
||||
*
|
||||
* Expected:
|
||||
* - User satisfying new multi-attribute policy IS auto-added
|
||||
* - User not satisfying new policy IS auto-removed
|
||||
*/
|
||||
test('MM-T5791 Editing policy to add attribute with auto-add enabled', async ({pw}) => {
|
||||
test.setTimeout(180000);
|
||||
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
// Use ensure-exists pattern - non-destructive, safe for parallel test runs
|
||||
const existingFields = await adminClient.getCustomProfileAttributeFields();
|
||||
const attributeFieldsMap: Record<string, any> = {};
|
||||
for (const field of existingFields) {
|
||||
attributeFieldsMap[field.id] = field;
|
||||
}
|
||||
if (!existingFields.some((f: any) => f.name === 'Office')) {
|
||||
const officeField = await adminClient.createCustomProfileAttributeField({
|
||||
name: 'Office',
|
||||
type: 'text',
|
||||
attrs: {managed: 'admin', visibility: 'when_set', sort_order: 1},
|
||||
} as any);
|
||||
attributeFieldsMap[officeField.id] = officeField;
|
||||
}
|
||||
|
||||
// Wait for attributes to be indexed
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Create users:
|
||||
// 1. engineerRemoteUser: Dept=Engineering, Office=Remote → satisfies BOTH (after edit)
|
||||
// 2. engineerOfficeUser: Dept=Engineering, Office=HQ → satisfies ORIGINAL only, NOT the edited policy
|
||||
// 3. salesUser: Dept=Sales → doesn't satisfy any policy
|
||||
|
||||
const engineerRemoteUser = await createUserForABAC(adminClient, attributeFieldsMap, [
|
||||
{name: 'Department', type: 'text', value: 'Engineering'},
|
||||
{name: 'Office', type: 'text', value: 'Remote'},
|
||||
]);
|
||||
|
||||
const engineerOfficeUser = await createUserForABAC(adminClient, attributeFieldsMap, [
|
||||
{name: 'Department', type: 'text', value: 'Engineering'},
|
||||
{name: 'Office', type: 'text', value: 'HQ'},
|
||||
]);
|
||||
|
||||
const salesUser = await createUserForABAC(adminClient, attributeFieldsMap, [
|
||||
{name: 'Department', type: 'text', value: 'Sales'},
|
||||
]);
|
||||
|
||||
// Add users to team
|
||||
await adminClient.addToTeam(team.id, engineerRemoteUser.id);
|
||||
await adminClient.addToTeam(team.id, engineerOfficeUser.id);
|
||||
await adminClient.addToTeam(team.id, salesUser.id);
|
||||
|
||||
// Create channel and add engineerOfficeUser (satisfies original policy)
|
||||
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
await adminClient.addToChannel(engineerOfficeUser.id, privateChannel.id);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
const page = systemConsolePage.page;
|
||||
|
||||
await navigateToABACPage(page);
|
||||
|
||||
// ===========================================
|
||||
// PRECONDITION: Create ORIGINAL policy with ONE attribute (Department=Engineering)
|
||||
// Auto-add ON so users are auto-added
|
||||
// ===========================================
|
||||
const policyName = `ABAC-AddAttr-Test-${await pw.random.id()}`;
|
||||
|
||||
await createBasicPolicy(page, {
|
||||
name: policyName,
|
||||
attribute: 'Department',
|
||||
operator: '==',
|
||||
value: 'Engineering',
|
||||
autoSync: true, // Auto-add is ON
|
||||
channels: [privateChannel.display_name],
|
||||
});
|
||||
(await getPolicyIdByName(adminClient, policyName))!;
|
||||
|
||||
// Wait for automatic sync to complete
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify initial state after original policy sync
|
||||
await verifyUserInChannel(adminClient, engineerRemoteUser.id, privateChannel.id);
|
||||
await verifyUserInChannel(adminClient, engineerOfficeUser.id, privateChannel.id);
|
||||
|
||||
// ===========================================
|
||||
// STEP 1-2: Edit policy to ADD another attribute (Office=Remote)
|
||||
// New expression: Department=Engineering AND Office=Remote
|
||||
// ===========================================
|
||||
|
||||
// Navigate back to ABAC list page
|
||||
await page.goto('/admin_console/system_attributes/membership_policies', {waitUntil: 'networkidle'});
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Verify we're on the list page by checking for "Add policy" button
|
||||
const addPolicyButton = page.getByRole('button', {name: 'Add policy'});
|
||||
await addPolicyButton.waitFor({state: 'visible', timeout: 10000});
|
||||
|
||||
// Try to find the policy row first without search
|
||||
const policyRowLocator = page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first();
|
||||
let isPolicyVisible = await policyRowLocator.isVisible({timeout: 3000}).catch(() => false);
|
||||
|
||||
// If not visible, use search
|
||||
if (!isPolicyVisible) {
|
||||
const policySearchInput = page
|
||||
.locator('.DataGrid input[type="text"], input[placeholder*="Search policies" i]')
|
||||
.first();
|
||||
if (await policySearchInput.isVisible({timeout: 3000})) {
|
||||
await policySearchInput.fill(policyName);
|
||||
}
|
||||
// Re-bind and poll — grid refresh under parallel load may be delayed.
|
||||
await expect
|
||||
.poll(() => policyRowLocator.isVisible(), {
|
||||
timeout: 20_000,
|
||||
message: `policy "${policyName}" should appear in grid after search`,
|
||||
})
|
||||
.toBe(true);
|
||||
isPolicyVisible = true;
|
||||
}
|
||||
|
||||
// Click policy to edit
|
||||
await policyRowLocator.waitFor({state: 'visible', timeout: 15000});
|
||||
await policyRowLocator.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check if "Add attribute" button is disabled (means attributes not loaded)
|
||||
const addAttributeButtonCheck = page.getByRole('button', {name: /add attribute/i});
|
||||
if (await addAttributeButtonCheck.isVisible({timeout: 2000})) {
|
||||
const isDisabled = await addAttributeButtonCheck.isDisabled();
|
||||
if (isDisabled) {
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Stay in Simple Mode and add a second attribute row
|
||||
const addAttributeButton = page.getByRole('button', {name: /add attribute/i});
|
||||
await addAttributeButton.waitFor({state: 'visible', timeout: 5000});
|
||||
await addAttributeButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// The attribute dropdown opens automatically after clicking "Add attribute"
|
||||
const attributeMenu = page.locator('[id^="attribute-selector-menu"]');
|
||||
await expect
|
||||
.poll(() => attributeMenu.isVisible(), {
|
||||
timeout: 15_000,
|
||||
message: 'attribute dropdown should appear',
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
const officeOption = attributeMenu.locator('li:has-text("Office")').first();
|
||||
await expect
|
||||
.poll(() => officeOption.isVisible(), {
|
||||
timeout: 15_000,
|
||||
message: 'Office option should be visible in attribute dropdown',
|
||||
})
|
||||
.toBe(true);
|
||||
await officeOption.click({force: true});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Select operator "==" (is)
|
||||
const operatorButton = page.locator('[data-testid="operatorSelectorMenuButton"]').last();
|
||||
await operatorButton.waitFor({state: 'visible', timeout: 10_000});
|
||||
await operatorButton.click({force: true});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const operatorOption = page.locator('[id^="operator-selector-menu"] li:has-text("is")').first();
|
||||
await operatorOption.click({force: true});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Fill value "Remote"
|
||||
const valueInput = page.locator('.values-editor__simple-input').last();
|
||||
await valueInput.waitFor({state: 'visible', timeout: 10_000});
|
||||
await valueInput.fill('Remote');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// ===========================================
|
||||
// STEP 3: Test Access Rule
|
||||
// ===========================================
|
||||
await testAccessRule(page);
|
||||
|
||||
// ===========================================
|
||||
// STEP 4: Save the changes
|
||||
// ===========================================
|
||||
|
||||
// Intercept the sync-job POST triggered by "Apply policy" so we can poll the
|
||||
// exact job ID instead of using the racy UI-table path.
|
||||
const editSyncJobIdPromise = page
|
||||
.waitForResponse((r) => r.url().includes('/api/v4/jobs') && r.request().method() === 'POST', {timeout: 15_000})
|
||||
.then(async (r) => (r.ok() ? (((await r.json()) as {id?: string}).id ?? null) : null))
|
||||
.catch(() => null);
|
||||
|
||||
const saveButton = page.getByRole('button', {name: 'Save'});
|
||||
await saveButton.waitFor({state: 'visible', timeout: 5000});
|
||||
await saveButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Handle "Apply policy" confirmation if it appears
|
||||
const applyPolicyButton = page.getByRole('button', {name: /apply policy/i});
|
||||
if (await applyPolicyButton.isVisible({timeout: 3000})) {
|
||||
await applyPolicyButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
// Navigate to ABAC page and wait for auto-triggered sync job
|
||||
await navigateToABACPage(page);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Wait for the auto-triggered sync job to complete (policy edit triggers sync automatically)
|
||||
const editSyncJobId = await editSyncJobIdPromise;
|
||||
await waitForLatestSyncJob(page, 10, editSyncJobId);
|
||||
|
||||
// ===========================================
|
||||
// STEP 5 & 6: Verify channel membership after edit
|
||||
// ===========================================
|
||||
|
||||
// Poll under PW_WORKERS>=2: another shard's sync job may briefly change
|
||||
// membership after we read it, so we retry until stable.
|
||||
await expect
|
||||
.poll(async () => verifyUserInChannel(adminClient, engineerRemoteUser.id, privateChannel.id), {
|
||||
timeout: 30_000,
|
||||
intervals: [500, 1000, 2000],
|
||||
message: 'engineerRemoteUser should be in channel (satisfies BOTH attributes)',
|
||||
})
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(async () => verifyUserInChannel(adminClient, engineerOfficeUser.id, privateChannel.id), {
|
||||
timeout: 30_000,
|
||||
intervals: [500, 1000, 2000],
|
||||
message: 'engineerOfficeUser should be REMOVED (does not satisfy new policy)',
|
||||
})
|
||||
.toBe(false);
|
||||
await expect
|
||||
.poll(async () => verifyUserInChannel(adminClient, salesUser.id, privateChannel.id), {
|
||||
timeout: 30_000,
|
||||
intervals: [500, 1000, 2000],
|
||||
message: 'salesUser should not be in channel',
|
||||
})
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* MM-T5792: Editing existing access policy to remove one of the rules applies access control as specified (with auto-add)
|
||||
*
|
||||
* Precondition: At least one policy with MULTIPLE rules in existence
|
||||
*
|
||||
* Step 1:
|
||||
* 1. Go to ABAC page, click a policy to edit. Ensure Auto-add is TRUE
|
||||
* 2. Edit policy to REMOVE one of the rules (attribute/value)
|
||||
* 3. Click Test Access Rule, observe users who satisfy the policy
|
||||
* 4. Save the changes
|
||||
* 5. User who satisfies newly edited (simpler) policy but not in channel → auto-ADDED
|
||||
* 6. User who no longer satisfies newly edited policy and is in channel → auto-REMOVED
|
||||
*
|
||||
* This is the OPPOSITE of MM-T5791:
|
||||
* - MM-T5791: ADD rule → policy MORE restrictive
|
||||
* - MM-T5792: REMOVE rule → policy LESS restrictive
|
||||
*/
|
||||
test('MM-T5792 Editing policy to remove attribute rule with auto-add enabled', async ({pw}) => {
|
||||
test.setTimeout(180000);
|
||||
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
// Use ensure-exists pattern - non-destructive, safe for parallel test runs
|
||||
const existingFields = await adminClient.getCustomProfileAttributeFields();
|
||||
const attributeFieldsMap: Record<string, any> = {};
|
||||
for (const field of existingFields) {
|
||||
attributeFieldsMap[field.id] = field;
|
||||
}
|
||||
if (!existingFields.some((f: any) => f.name === 'Office')) {
|
||||
const officeField = await adminClient.createCustomProfileAttributeField({
|
||||
name: 'Office',
|
||||
type: 'text',
|
||||
attrs: {managed: 'admin', visibility: 'when_set', sort_order: 1},
|
||||
} as any);
|
||||
attributeFieldsMap[officeField.id] = officeField;
|
||||
}
|
||||
|
||||
// Wait for attributes to be indexed
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
await enableUserManagedAttributes(adminClient);
|
||||
|
||||
// Create users:
|
||||
// 1. engineerRemoteUser: Dept=Engineering, Office=Remote → satisfies ORIGINAL (both rules)
|
||||
// 2. engineerOfficeUser: Dept=Engineering, Office=HQ → satisfies EDITED policy (Dept only)
|
||||
// 3. salesRemoteUser: Dept=Sales, Office=Remote → doesn't satisfy (wrong Dept)
|
||||
|
||||
const engineerRemoteUser = await createUserForABAC(adminClient, attributeFieldsMap, [
|
||||
{name: 'Department', type: 'text', value: 'Engineering'},
|
||||
{name: 'Office', type: 'text', value: 'Remote'},
|
||||
]);
|
||||
|
||||
const engineerOfficeUser = await createUserForABAC(adminClient, attributeFieldsMap, [
|
||||
{name: 'Department', type: 'text', value: 'Engineering'},
|
||||
{name: 'Office', type: 'text', value: 'HQ'},
|
||||
]);
|
||||
|
||||
const salesRemoteUser = await createUserForABAC(adminClient, attributeFieldsMap, [
|
||||
{name: 'Department', type: 'text', value: 'Sales'},
|
||||
{name: 'Office', type: 'text', value: 'Remote'},
|
||||
]);
|
||||
|
||||
// Add users to team
|
||||
await adminClient.addToTeam(team.id, engineerRemoteUser.id);
|
||||
await adminClient.addToTeam(team.id, engineerOfficeUser.id);
|
||||
await adminClient.addToTeam(team.id, salesRemoteUser.id);
|
||||
|
||||
// Create channel and add salesRemoteUser (does NOT satisfy any policy)
|
||||
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
await adminClient.addToChannel(salesRemoteUser.id, privateChannel.id);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
const page = systemConsolePage.page;
|
||||
|
||||
await navigateToABACPage(page);
|
||||
|
||||
// ===========================================
|
||||
// PRECONDITION: Create ORIGINAL policy with TWO attributes
|
||||
// Department=Engineering AND Office=Remote, Auto-add ON
|
||||
// ===========================================
|
||||
const policyName = `ABAC-RemoveRule-${await pw.random.id()}`;
|
||||
|
||||
await createAdvancedPolicy(page, {
|
||||
name: policyName,
|
||||
celExpression: 'user.attributes.Department == "Engineering" && user.attributes.Office == "Remote"',
|
||||
autoSync: true, // Auto-add is ON
|
||||
channels: [privateChannel.display_name],
|
||||
});
|
||||
(await getPolicyIdByName(adminClient, policyName))!;
|
||||
|
||||
// Wait for automatic sync to complete
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify initial state after original policy sync
|
||||
await verifyUserInChannel(adminClient, engineerRemoteUser.id, privateChannel.id);
|
||||
await verifyUserNotInChannel(adminClient, engineerOfficeUser.id, privateChannel.id);
|
||||
await verifyUserNotInChannel(adminClient, salesRemoteUser.id, privateChannel.id);
|
||||
|
||||
// ===========================================
|
||||
// STEP 1-2: Edit policy to REMOVE Location rule
|
||||
// New expression: Department=Engineering (only)
|
||||
// ===========================================
|
||||
await page.goto('/admin_console/system_attributes/membership_policies', {waitUntil: 'networkidle'});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const addPolicyButton = page.getByRole('button', {name: 'Add policy'});
|
||||
await addPolicyButton.waitFor({state: 'visible', timeout: 10000});
|
||||
|
||||
// Wait for the exact policy row to appear with retry — under parallel load the
|
||||
// grid update from the server may lag behind the page load.
|
||||
const policyRowLocator = page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first();
|
||||
const found = await policyRowLocator.isVisible({timeout: 3000}).catch(() => false);
|
||||
|
||||
if (!found) {
|
||||
const policySearchInput = page
|
||||
.locator('.DataGrid input[type="text"], input[placeholder*="Search policies" i]')
|
||||
.first();
|
||||
if (await policySearchInput.isVisible({timeout: 3000})) {
|
||||
await policySearchInput.fill(policyName);
|
||||
}
|
||||
const policyRow = () => page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first();
|
||||
await expect
|
||||
.poll(() => policyRow().isVisible(), {
|
||||
timeout: 45_000,
|
||||
intervals: [500, 1000, 2000, 3000],
|
||||
message: `policy "${policyName}" should appear in grid after search`,
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
await page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first().click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify Auto-add is ON
|
||||
const autoAddCheckbox = page.locator('#auto-add-header-checkbox');
|
||||
if (await autoAddCheckbox.isVisible({timeout: 3000})) {
|
||||
const isChecked = await autoAddCheckbox.isChecked();
|
||||
if (!isChecked) {
|
||||
await autoAddCheckbox.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the Office rule in Simple mode (table editor). Opening an Advanced-created policy
|
||||
// can leave the UI in CEL mode; "Switch to Advanced Mode" stays disabled while attributes load.
|
||||
const switchToSimpleButton = page.getByRole('button', {name: /switch to simple mode/i});
|
||||
if (await switchToSimpleButton.isVisible({timeout: 5000}).catch(() => false)) {
|
||||
await expect(switchToSimpleButton).toBeEnabled({timeout: 60_000});
|
||||
await switchToSimpleButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
const officeRowRemove = page
|
||||
.locator('.table-editor__row')
|
||||
.filter({hasText: 'Office'})
|
||||
.getByRole('button', {name: 'Remove row'});
|
||||
await expect(officeRowRemove).toBeVisible({timeout: 15_000});
|
||||
await officeRowRemove.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// ===========================================
|
||||
// STEP 3: Test Access Rule
|
||||
// ===========================================
|
||||
await testAccessRule(page);
|
||||
|
||||
// ===========================================
|
||||
// STEP 4: Save the changes
|
||||
// ===========================================
|
||||
|
||||
// Intercept the sync-job POST triggered by "Apply policy" so we can poll the
|
||||
// exact job ID instead of using the racy UI-table path.
|
||||
const editSyncJobIdPromiseT5792 = page
|
||||
.waitForResponse((r) => r.url().includes('/api/v4/jobs') && r.request().method() === 'POST', {timeout: 15_000})
|
||||
.then(async (r) => (r.ok() ? (((await r.json()) as {id?: string}).id ?? null) : null))
|
||||
.catch(() => null);
|
||||
|
||||
const saveButton = page.getByRole('button', {name: 'Save'});
|
||||
await saveButton.waitFor({state: 'visible', timeout: 5000});
|
||||
await saveButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const applyPolicyButton = page.getByRole('button', {name: /apply policy/i});
|
||||
if (await applyPolicyButton.isVisible({timeout: 3000})) {
|
||||
await applyPolicyButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
await navigateToABACPage(page);
|
||||
const editSyncJobIdT5792 = await editSyncJobIdPromiseT5792;
|
||||
await waitForLatestSyncJob(page, 10, editSyncJobIdT5792);
|
||||
|
||||
// ===========================================
|
||||
// STEP 5 & 6: Verify channel membership after edit
|
||||
// ===========================================
|
||||
|
||||
// Re-apply guard: a concurrent initSetup() may have reset ABAC between the policy save
|
||||
// and the sync job completing. Without ABAC enabled the sync job is a no-op.
|
||||
await adminClient.patchConfig({
|
||||
AccessControlSettings: {
|
||||
EnableAttributeBasedAccessControl: true,
|
||||
EnableUserManagedAttributes: true,
|
||||
},
|
||||
} as any);
|
||||
|
||||
// Poll under PW_WORKERS>=2: other shards' sync jobs may briefly change membership.
|
||||
await expect
|
||||
.poll(async () => verifyUserInChannel(adminClient, engineerOfficeUser.id, privateChannel.id), {
|
||||
timeout: 30_000,
|
||||
intervals: [500, 1000, 2000],
|
||||
message: 'engineerOfficeUser should be AUTO-ADDED (satisfies simpler Dept-only policy)',
|
||||
})
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(async () => verifyUserInChannel(adminClient, engineerRemoteUser.id, privateChannel.id), {
|
||||
timeout: 30_000,
|
||||
intervals: [500, 1000, 2000],
|
||||
message: 'engineerRemoteUser should still be in channel',
|
||||
})
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(async () => verifyUserInChannel(adminClient, salesRemoteUser.id, privateChannel.id), {
|
||||
timeout: 30_000,
|
||||
intervals: [500, 1000, 2000],
|
||||
message: 'salesRemoteUser should NOT be in channel',
|
||||
})
|
||||
.toBe(false);
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
* These functions are used across multiple ABAC test files to reduce duplication
|
||||
*/
|
||||
|
||||
import type {Page} from '@playwright/test';
|
||||
import {expect, type Page} from '@playwright/test';
|
||||
import type {Client4} from '@mattermost/client';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
@@ -77,16 +77,30 @@ export async function createUserAttributeField(client: Client4, name: string, ty
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable user-managed attributes config
|
||||
* Membership policy UI loads CPA fields from GET .../cel/autocomplete/fields.
|
||||
* Fail fast here instead of timing out on disabled "Test access rule" when fields lag.
|
||||
*/
|
||||
export async function assertAccessControlAutocompleteContains(
|
||||
adminClient: Client4,
|
||||
fieldNames: string[],
|
||||
): Promise<void> {
|
||||
const fields = await adminClient.getAccessControlFields('', 100);
|
||||
const names = new Set(fields.map((f) => f.name));
|
||||
for (const n of fieldNames) {
|
||||
expect(
|
||||
names.has(n),
|
||||
`ABAC autocomplete API missing "${n}" — policy editor will treat attributes as unusable. Got: ${[...names].join(', ')}`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
export async function enableUserManagedAttributes(client: Client4): Promise<void> {
|
||||
try {
|
||||
const config = await client.getConfig();
|
||||
if (config.AccessControlSettings?.EnableUserManagedAttributes !== true) {
|
||||
config.AccessControlSettings = config.AccessControlSettings || {};
|
||||
config.AccessControlSettings.EnableUserManagedAttributes = true;
|
||||
await client.updateConfig(config);
|
||||
}
|
||||
await client.patchConfig({
|
||||
AccessControlSettings: {
|
||||
EnableUserManagedAttributes: true,
|
||||
},
|
||||
} as any);
|
||||
} catch {
|
||||
// console.warn('Failed to enable EnableUserManagedAttributes:', _error.message || String(_error));
|
||||
}
|
||||
@@ -215,8 +229,9 @@ export async function testAccessRule(
|
||||
searchForUser?: string; // optional: search for a specific user in the modal
|
||||
} = {},
|
||||
): Promise<TestAccessRuleResult> {
|
||||
const testButton = page.locator('button').filter({hasText: 'Test access rule'});
|
||||
await testButton.waitFor({state: 'visible', timeout: 5000});
|
||||
const testButton = page.getByRole('button', {name: /test access rule/i});
|
||||
await expect(testButton).toBeVisible({timeout: 10_000});
|
||||
await expect(testButton).toBeEnabled({timeout: 15_000});
|
||||
await testButton.click();
|
||||
|
||||
const modal = page.locator('[role="dialog"], .modal').filter({hasText: 'Access Rule Test Results'});
|
||||
@@ -342,6 +357,11 @@ export async function createPrivateChannelForABAC(client: Client4, teamId: strin
|
||||
/**
|
||||
* Create basic policy using Table Editor (Simple mode)
|
||||
*/
|
||||
/**
|
||||
* Returns the sync job ID triggered by the "Apply policy" confirmation, or null
|
||||
* when no channels are assigned (no sync is triggered). Pass the returned ID to
|
||||
* waitForLatestSyncJob so you get race-safe job polling instead of UI table scraping.
|
||||
*/
|
||||
export async function createBasicPolicy(
|
||||
page: Page,
|
||||
options: {
|
||||
@@ -352,7 +372,7 @@ export async function createBasicPolicy(
|
||||
autoSync?: boolean;
|
||||
channels?: string[];
|
||||
},
|
||||
): Promise<void> {
|
||||
): Promise<string | null> {
|
||||
// Ensure we are on the Membership Policies page before looking for "Add policy".
|
||||
// The ABAC settings page was split: the enable/disable toggle is now on
|
||||
// /attribute_based_access_control while the policy list lives on /membership_policies.
|
||||
@@ -371,12 +391,12 @@ export async function createBasicPolicy(
|
||||
await nameInput.waitFor({state: 'visible', timeout: 10000});
|
||||
await nameInput.fill(options.name);
|
||||
|
||||
// Check if "Add attribute" button is disabled (means no attributes loaded)
|
||||
// If so, reload the page to fetch the newly created attributes
|
||||
// Check if "Add attribute" button is disabled (means no attributes loaded).
|
||||
// If so, reload the page to fetch the newly created attributes, then wait
|
||||
// up to ~10 s for the button to become enabled before proceeding.
|
||||
const addAttributeButton = page.getByRole('button', {name: /add attribute/i});
|
||||
if (await addAttributeButton.isVisible({timeout: 2000})) {
|
||||
const isDisabled = await addAttributeButton.isDisabled();
|
||||
if (isDisabled) {
|
||||
if (await addAttributeButton.isDisabled()) {
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
@@ -384,70 +404,87 @@ export async function createBasicPolicy(
|
||||
const nameInputAfterReload = page.locator('#admin\\.access_control\\.policy\\.edit_policy\\.policyName');
|
||||
await nameInputAfterReload.waitFor({state: 'visible', timeout: 10000});
|
||||
await nameInputAfterReload.fill(options.name);
|
||||
|
||||
// Wait for attributes to become available (up to 10 s in 2 s increments)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (!(await addAttributeButton.isDisabled())) break;
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fill attribute, operator, value in table editor
|
||||
// Fill attribute, operator, value in table editor.
|
||||
// Track whether we successfully added a row — only proceed with attribute/operator/value
|
||||
// selection if we did. When attributes are unavailable (e.g. wiped by a concurrent
|
||||
// initSetup()) the "Add attribute" button stays disabled and no row is created, so
|
||||
// attributeSelectorMenuButton will never appear. Skipping the section lets the test
|
||||
// fall through to Save, where server-side validation (e.g. duplicate-name check) still runs.
|
||||
let clickedAddAttribute = false;
|
||||
if (await addAttributeButton.isVisible({timeout: 2000})) {
|
||||
const isDisabled = await addAttributeButton.isDisabled();
|
||||
if (!isDisabled) {
|
||||
await addAttributeButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
clickedAddAttribute = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Select attribute
|
||||
const attributeMenu = page.locator('[id^="attribute-selector-menu"]');
|
||||
const menuIsOpen = await attributeMenu.isVisible({timeout: 2000});
|
||||
// Select attribute (only when a row was actually created above)
|
||||
if (clickedAddAttribute) {
|
||||
const attributeMenu = page.locator('[id^="attribute-selector-menu"]');
|
||||
const menuIsOpen = await attributeMenu.isVisible({timeout: 2000});
|
||||
|
||||
if (!menuIsOpen) {
|
||||
const attributeButton = page.locator('[data-testid="attributeSelectorMenuButton"]').first();
|
||||
await attributeButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
if (!menuIsOpen) {
|
||||
const attributeButton = page.locator('[data-testid="attributeSelectorMenuButton"]').first();
|
||||
await attributeButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
const attributeOption = page.locator(`[id^="attribute-selector-menu"] li:has-text("${options.attribute}")`).first();
|
||||
await attributeOption.click({force: true});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Select operator
|
||||
const operatorButton = page.locator('[data-testid="operatorSelectorMenuButton"]').first();
|
||||
await operatorButton.waitFor({state: 'visible', timeout: 5000});
|
||||
await operatorButton.click({force: true});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const operatorMap: Record<string, string> = {
|
||||
'==': 'is',
|
||||
'!=': 'is not',
|
||||
in: 'is one of',
|
||||
contains: 'contains',
|
||||
startsWith: 'starts with',
|
||||
endsWith: 'ends with',
|
||||
};
|
||||
const operatorText = operatorMap[options.operator] || options.operator;
|
||||
const operatorOption = page.locator(`[id^="operator-selector-menu"] li:has-text("${operatorText}")`).first();
|
||||
await operatorOption.click({force: true});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Fill value
|
||||
if (options.operator === 'in') {
|
||||
// Multi-value operator
|
||||
const valueButton = page.locator('[data-testid="valueSelectorMenuButton"]').first();
|
||||
await valueButton.waitFor({state: 'visible', timeout: 10000});
|
||||
await valueButton.click({force: true});
|
||||
const attributeOption = page
|
||||
.locator(`[id^="attribute-selector-menu"] li:has-text("${options.attribute}")`)
|
||||
.first();
|
||||
await attributeOption.click({force: true});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const valueInput = page.locator('input[type="text"]').last();
|
||||
await valueInput.fill(options.value);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(300);
|
||||
} else {
|
||||
// Single-value operator
|
||||
const valueInput = page.locator('.values-editor__simple-input, input[placeholder*="Add value" i]').first();
|
||||
await valueInput.waitFor({state: 'visible', timeout: 10000});
|
||||
await valueInput.fill(options.value);
|
||||
// Select operator
|
||||
const operatorButton = page.locator('[data-testid="operatorSelectorMenuButton"]').first();
|
||||
await operatorButton.waitFor({state: 'visible', timeout: 5000});
|
||||
await operatorButton.click({force: true});
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
const operatorMap: Record<string, string> = {
|
||||
'==': 'is',
|
||||
'!=': 'is not',
|
||||
in: 'is one of',
|
||||
contains: 'contains',
|
||||
startsWith: 'starts with',
|
||||
endsWith: 'ends with',
|
||||
};
|
||||
const operatorText = operatorMap[options.operator] || options.operator;
|
||||
const operatorOption = page.locator(`[id^="operator-selector-menu"] li:has-text("${operatorText}")`).first();
|
||||
await operatorOption.click({force: true});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Fill value
|
||||
if (options.operator === 'in') {
|
||||
// Multi-value operator
|
||||
const valueButton = page.locator('[data-testid="valueSelectorMenuButton"]').first();
|
||||
await valueButton.waitFor({state: 'visible', timeout: 10000});
|
||||
await valueButton.click({force: true});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const valueInput = page.locator('input[type="text"]').last();
|
||||
await valueInput.fill(options.value);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(300);
|
||||
} else {
|
||||
// Single-value operator
|
||||
const valueInput = page.locator('.values-editor__simple-input, input[placeholder*="Add value" i]').first();
|
||||
await valueInput.waitFor({state: 'visible', timeout: 10000});
|
||||
await valueInput.fill(options.value);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
} // end if (clickedAddAttribute)
|
||||
|
||||
// Assign channels if specified
|
||||
if (options.channels && options.channels.length > 0) {
|
||||
@@ -491,7 +528,7 @@ export async function createBasicPolicy(
|
||||
}
|
||||
}
|
||||
|
||||
// Save policy and confirm
|
||||
// Save policy and confirm, intercepting the sync job ID triggered by Apply.
|
||||
const saveButton = page.getByRole('button', {name: 'Save'});
|
||||
await saveButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
@@ -500,13 +537,24 @@ export async function createBasicPolicy(
|
||||
const applyPolicyButton = page.getByRole('button', {name: /apply policy/i});
|
||||
const applyVisible = await applyPolicyButton.isVisible({timeout: 3000}).catch(() => false);
|
||||
if (applyVisible) {
|
||||
// Arm the response interceptor BEFORE the click so we never miss the POST.
|
||||
const jobResponsePromise = page
|
||||
.waitForResponse((r) => r.url().includes('/api/v4/jobs') && r.request().method() === 'POST', {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.then(async (r) => (r.ok() ? (((await r.json()) as {id?: string}).id ?? null) : null))
|
||||
.catch(() => null);
|
||||
|
||||
await applyPolicyButton.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
} else {
|
||||
// No channels assigned, just wait for save to complete
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
return jobResponsePromise;
|
||||
}
|
||||
|
||||
// No channels assigned — no sync job is triggered.
|
||||
await page.waitForLoadState('networkidle');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -520,7 +568,7 @@ export async function createMultiAttributePolicy(
|
||||
autoSync?: boolean;
|
||||
channels?: string[];
|
||||
},
|
||||
): Promise<void> {
|
||||
): Promise<string | null> {
|
||||
if (!page.url().includes('/membership_policies')) {
|
||||
await page.goto('/admin_console/system_attributes/membership_policies');
|
||||
await page.waitForLoadState('networkidle');
|
||||
@@ -660,21 +708,29 @@ export async function createMultiAttributePolicy(
|
||||
}
|
||||
}
|
||||
|
||||
// Save policy and confirm
|
||||
// Save policy and confirm, intercepting the sync job ID triggered by Apply.
|
||||
const saveButton = page.getByRole('button', {name: 'Save'});
|
||||
await saveButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Click "Apply policy" button in confirmation modal
|
||||
const applyPolicyButton = page.getByRole('button', {name: /apply policy/i});
|
||||
await applyPolicyButton.waitFor({state: 'visible', timeout: 5000});
|
||||
|
||||
const jobResponsePromise = page
|
||||
.waitForResponse((r) => r.url().includes('/api/v4/jobs') && r.request().method() === 'POST', {timeout: 10_000})
|
||||
.then(async (r) => (r.ok() ? (((await r.json()) as {id?: string}).id ?? null) : null))
|
||||
.catch(() => null);
|
||||
|
||||
await applyPolicyButton.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
return jobResponsePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create advanced policy using CEL Editor (Advanced mode)
|
||||
* Create advanced policy using CEL Editor (Advanced mode).
|
||||
* Returns the sync job ID triggered by "Apply policy", or null when no channels
|
||||
* are assigned. Pass to waitForLatestSyncJob for race-safe job polling.
|
||||
*/
|
||||
export async function createAdvancedPolicy(
|
||||
page: Page,
|
||||
@@ -684,7 +740,7 @@ export async function createAdvancedPolicy(
|
||||
autoSync?: boolean;
|
||||
channels?: string[];
|
||||
},
|
||||
): Promise<void> {
|
||||
): Promise<string | null> {
|
||||
if (!page.url().includes('/membership_policies')) {
|
||||
await page.goto('/admin_console/system_attributes/membership_policies');
|
||||
await page.waitForLoadState('networkidle');
|
||||
@@ -700,9 +756,11 @@ export async function createAdvancedPolicy(
|
||||
await nameInput.waitFor({state: 'visible', timeout: 10000});
|
||||
await nameInput.fill(options.name);
|
||||
|
||||
// Switch to Advanced mode
|
||||
// Switch to Advanced mode — the button can stay disabled until the policy editor
|
||||
// finishes loading (slow under parallel CI); wait instead of racing a 2s visibility check.
|
||||
const advancedModeButton = page.getByRole('button', {name: /advanced/i});
|
||||
if (await advancedModeButton.isVisible({timeout: 2000})) {
|
||||
if (await advancedModeButton.isVisible({timeout: 5000}).catch(() => false)) {
|
||||
await expect(advancedModeButton).toBeEnabled({timeout: 60_000});
|
||||
await advancedModeButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
@@ -823,14 +881,21 @@ export async function createAdvancedPolicy(
|
||||
const applyPolicyButton = page.getByRole('button', {name: /apply policy/i});
|
||||
const applyVisible = await applyPolicyButton.isVisible({timeout: 10000}).catch(() => false);
|
||||
|
||||
if (applyVisible) {
|
||||
await applyPolicyButton.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
} else {
|
||||
// console.error(`❌ Apply Policy button not found`);
|
||||
if (!applyVisible) {
|
||||
throw new Error(`Apply Policy button not visible after Save`);
|
||||
}
|
||||
|
||||
// Arm the response interceptor BEFORE the click so we never miss the POST.
|
||||
const jobResponsePromise = page
|
||||
.waitForResponse((r) => r.url().includes('/api/v4/jobs') && r.request().method() === 'POST', {timeout: 10_000})
|
||||
.then(async (r) => (r.ok() ? (((await r.json()) as {id?: string}).id ?? null) : null))
|
||||
.catch(() => null);
|
||||
|
||||
await applyPolicyButton.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
return jobResponsePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -842,35 +907,127 @@ export async function activatePolicy(client: Client4, policyId: string): Promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for sync job to complete and get the latest job row
|
||||
* Wait for a sync job to complete.
|
||||
*
|
||||
* When `expectedJobId` is supplied (obtained from `runSyncJob()` which
|
||||
* intercepts the POST /api/v4/jobs response), polls GET /api/v4/jobs/{id}
|
||||
* directly — race-free under PW_WORKERS >= 2 because it checks the exact
|
||||
* job, not the first row of a shared list.
|
||||
*
|
||||
* When `expectedJobId` is not supplied, falls back to reading the first row
|
||||
* of the UI sync-jobs table (racy under concurrency; avoid when possible by
|
||||
* passing the ID returned from `runSyncJob()` or `createBasicPolicy()`).
|
||||
*
|
||||
* Both paths use `expect.poll` with 500 ms intervals and a 30 s timeout so
|
||||
* individual CI jobs that are delayed in the queue don't cause false failures.
|
||||
*/
|
||||
export async function waitForLatestSyncJob(page: Page, maxRetries: number = 5): Promise<any> {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
// Wait a bit for the job to process
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Reload the page to get fresh data
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Get the first (latest) job row
|
||||
const latestJobRow = page.locator('tr.clickable').first();
|
||||
|
||||
if (await latestJobRow.isVisible({timeout: 3000})) {
|
||||
// Check the status
|
||||
const statusCell = latestJobRow.locator('td').first();
|
||||
const status = await statusCell.textContent();
|
||||
|
||||
if (status?.trim() === 'Success') {
|
||||
return latestJobRow;
|
||||
} else if (status?.trim() === 'Error' || status?.trim() === 'Failed') {
|
||||
throw new Error(`Sync job failed with status: ${status?.trim()}`);
|
||||
}
|
||||
}
|
||||
export async function waitForLatestSyncJob(
|
||||
page: Page,
|
||||
_retries?: number,
|
||||
expectedJobId?: string | null,
|
||||
timeoutMs: number = 90_000,
|
||||
): Promise<any> {
|
||||
// ── Race-safe path: poll the exact job by ID ──────────────────────────
|
||||
if (expectedJobId) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
try {
|
||||
const job: any = await page.evaluate(async (id: string) => {
|
||||
const resp = await fetch(`/api/v4/jobs/${encodeURIComponent(id)}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!resp.ok) return {status: `http_${resp.status}`};
|
||||
return resp.json();
|
||||
}, expectedJobId);
|
||||
const status = (job?.status ?? '').toLowerCase();
|
||||
if (['error', 'failed', 'canceled', 'cancel_requested'].includes(status)) {
|
||||
throw new Error(`Sync job ${expectedJobId} failed: ${status}`);
|
||||
}
|
||||
return status;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.startsWith('Sync job')) throw err;
|
||||
return 'pending'; // network hiccup — keep polling
|
||||
}
|
||||
},
|
||||
{
|
||||
timeout: timeoutMs,
|
||||
intervals: [500, 500, 500, 1000, 1000, 2000],
|
||||
message: `Sync job ${expectedJobId} did not reach success within ${timeoutMs / 1000} s`,
|
||||
},
|
||||
)
|
||||
.toBe('success');
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Sync job did not complete after ${maxRetries} retries`);
|
||||
// ── Legacy path: read the first row of the sync-jobs table ───────────
|
||||
// RACY under PW_WORKERS >= 2 — use the jobId path when possible.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
const latestJobRow = page.locator('tr.clickable').first();
|
||||
if (!(await latestJobRow.isVisible({timeout: 3000}).catch(() => false))) {
|
||||
return 'no_jobs';
|
||||
}
|
||||
const status = (await latestJobRow.locator('td').first().textContent()) ?? '';
|
||||
const s = status.trim().toLowerCase();
|
||||
if (s === 'error' || s === 'failed') {
|
||||
throw new Error(`Sync job failed with status: ${status.trim()}`);
|
||||
}
|
||||
return s;
|
||||
},
|
||||
{
|
||||
timeout: 90_000,
|
||||
intervals: [2000, 2000, 3000, 3000],
|
||||
message: 'Sync job did not complete within 90 s (legacy path)',
|
||||
},
|
||||
)
|
||||
.toBe('success');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a policy-specific access_control_sync job to complete.
|
||||
*
|
||||
* Queries the server API directly with a policy_id filter so it is race-safe
|
||||
* under PW_WORKERS >= 2: another shard's sync job cannot be mistaken for ours.
|
||||
*
|
||||
* Uses `expect.poll` with 500 ms intervals and a 30 s timeout so jobs that are
|
||||
* briefly delayed in the queue do not cause spurious failures.
|
||||
*/
|
||||
export async function waitForPolicySyncJob(client: Client4, policyId: string): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
try {
|
||||
const jobs: any[] = await (client as any).doFetch(
|
||||
`${client.getBaseRoute()}/jobs/type/access_control_sync?policy_id=${encodeURIComponent(policyId)}&page=0&per_page=5`,
|
||||
{method: 'GET'},
|
||||
);
|
||||
if (!Array.isArray(jobs) || jobs.length === 0) return 'pending';
|
||||
// Sort by create_at descending so jobs[0] is the latest.
|
||||
// The API does not guarantee order, so without this sort
|
||||
// jobs[0] can be an older already-successful job, causing
|
||||
// us to return early before the newest sync has finished.
|
||||
jobs.sort((a: any, b: any) => (b.create_at ?? 0) - (a.create_at ?? 0));
|
||||
const status: string = jobs[0].status ?? 'pending';
|
||||
if (status === 'error' || status === 'canceled' || status === 'cancel_requested') {
|
||||
throw new Error(`Policy sync job failed: ${status}`);
|
||||
}
|
||||
return status;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.startsWith('Policy sync job')) throw err;
|
||||
return 'pending'; // network hiccup — keep polling
|
||||
}
|
||||
},
|
||||
{
|
||||
timeout: 30_000,
|
||||
intervals: [500, 500, 500, 1000, 1000, 2000],
|
||||
message: `Policy sync job for ${policyId} did not reach success within 30 s`,
|
||||
},
|
||||
)
|
||||
.toBe('success');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1041,7 +1198,7 @@ export async function getPolicyIdByName(
|
||||
policyName: string,
|
||||
retries: number = 3,
|
||||
): Promise<string | null> {
|
||||
const searchUrl = `${client.getBaseRoute()}/access_control/policies/search`;
|
||||
const searchUrl = `${client.getBaseRoute()}/access_control_policies/search`;
|
||||
|
||||
// Extract the base name without the random ID suffix for search
|
||||
// e.g., "Auto-Add Policy 48b0141" -> "Auto-Add Policy"
|
||||
@@ -1113,8 +1270,15 @@ export async function createPermissionPolicy(
|
||||
celExpression: string;
|
||||
permissions: Array<'Download Files' | 'Upload Files'>;
|
||||
role?: 'system_guest' | 'system_user' | 'system_admin';
|
||||
adminClient?: Client4;
|
||||
},
|
||||
): Promise<void> {
|
||||
// Ensure user attributes exist — a parallel test may have deleted all CPA fields,
|
||||
// which disables the "Switch to Advanced Mode" button in the permission policy editor.
|
||||
if (options.adminClient) {
|
||||
await ensureUserAttributes(options.adminClient);
|
||||
}
|
||||
|
||||
await navigateToPermissionPoliciesPage(page);
|
||||
|
||||
const addPolicyButton = page.getByRole('button', {name: 'Add policy'});
|
||||
@@ -1131,8 +1295,29 @@ export async function createPermissionPolicy(
|
||||
await page.locator(`#pp-role-option-${options.role}`).click();
|
||||
}
|
||||
|
||||
// Switch to Advanced (CEL) mode and enter expression
|
||||
await page.getByRole('button', {name: 'Switch to Advanced Mode'}).click();
|
||||
// Switch to Advanced (CEL) mode and enter expression.
|
||||
// The button is disabled when no user-attribute fields exist. If another test's
|
||||
// afterEach deleted all CPA fields between our ensureUserAttributes call and now,
|
||||
// re-create them and reload the "Add policy" form before clicking.
|
||||
const switchBtn = page.getByRole('button', {name: 'Switch to Advanced Mode'});
|
||||
if (await switchBtn.isDisabled()) {
|
||||
if (options.adminClient) {
|
||||
await ensureUserAttributes(options.adminClient);
|
||||
}
|
||||
await navigateToPermissionPoliciesPage(page);
|
||||
const addPolicyRetry = page.getByRole('button', {name: 'Add policy'});
|
||||
await addPolicyRetry.waitFor({state: 'visible', timeout: 15000});
|
||||
await addPolicyRetry.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
// Re-fill policy name and role after the form reload.
|
||||
await page.getByPlaceholder('Add a unique policy name').fill(options.name);
|
||||
if (options.role && options.role !== 'system_user') {
|
||||
await page.locator('#pp-role-selector-btn').click();
|
||||
await page.locator(`#pp-role-option-${options.role}`).click();
|
||||
}
|
||||
}
|
||||
await expect(switchBtn).toBeEnabled({timeout: 10000});
|
||||
await switchBtn.click();
|
||||
|
||||
const monacoContainer = page.locator('.monaco-editor').first();
|
||||
await monacoContainer.waitFor({state: 'visible', timeout: 5000});
|
||||
|
||||
-436
@@ -1,436 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
enableABAC,
|
||||
navigateToABACPage,
|
||||
runSyncJob,
|
||||
verifyUserInChannel,
|
||||
updateUserAttributes,
|
||||
createUserWithAttributes,
|
||||
} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
CustomProfileAttribute,
|
||||
setupCustomProfileAttributeFields,
|
||||
} from '../../../channels/custom_profile_attributes/helpers';
|
||||
import {
|
||||
ensureUserAttributes,
|
||||
createUserForABAC,
|
||||
createPrivateChannelForABAC,
|
||||
createBasicPolicy,
|
||||
activatePolicy,
|
||||
waitForLatestSyncJob,
|
||||
enableUserManagedAttributes,
|
||||
} from '../support';
|
||||
|
||||
/**
|
||||
* ABAC User Attributes - Attribute Changes
|
||||
* Tests for user attribute changes affecting ABAC policies
|
||||
*/
|
||||
test.describe('ABAC User Attributes - Attribute Changes', () => {
|
||||
/**
|
||||
* MM-T5794: User is auto-added to channel when a qualifying attribute is added to their profile (auto-add true)
|
||||
*
|
||||
* Step 1:
|
||||
* With at least one access policy in existence on the server, set to auto-add, and applied to a channel:
|
||||
* 1. As system admin make a note of the attribute needed for a user to be auto-added to a channel
|
||||
* 2. As a user not in the channel and not having the required attribute
|
||||
* 3. Click user's own profile picture top right and select Profile
|
||||
* 4. Scroll down to the required custom attribute, click Edit, and add the required value
|
||||
*/
|
||||
test('MM-T5794 User auto-added when qualifying attribute is added to profile', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
// ============================================================
|
||||
// SETUP: Create attribute, policy, and channel
|
||||
// ============================================================
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
// Setup attributes (using ensureUserAttributes like MM-T5800 does)
|
||||
await ensureUserAttributes(adminClient);
|
||||
|
||||
// Create test user with NON-qualifying Department attribute (same pattern as MM-T5800)
|
||||
// MM-T5800 creates user with Department=Sales, then changes to Engineering
|
||||
// We do the same: Start with Sales (non-qualifying), then change to Engineering (qualifying)
|
||||
const testUser = await createUserWithAttributes(adminClient, {Department: 'Sales'});
|
||||
await adminClient.addToTeam(team.id, testUser.id);
|
||||
|
||||
// Create private channel
|
||||
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
|
||||
// ============================================================
|
||||
// STEP 1: Create ABAC policy with auto-add enabled
|
||||
// Policy requirement: Department == "Engineering"
|
||||
// ============================================================
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
await enableABAC(systemConsolePage.page);
|
||||
|
||||
const policyName = `Engineering Access ${pw.random.id()}`;
|
||||
await createBasicPolicy(systemConsolePage.page, {
|
||||
name: policyName,
|
||||
attribute: 'Department',
|
||||
operator: '==',
|
||||
value: 'Engineering',
|
||||
autoSync: true, // ✅ Auto-add enabled
|
||||
channels: [privateChannel.display_name],
|
||||
});
|
||||
|
||||
// Activate policy (EXACT same pattern as MM-T5800)
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
const searchInput = systemConsolePage.page.locator('input[placeholder*="Search" i]').first();
|
||||
await searchInput.waitFor({state: 'visible', timeout: 5000});
|
||||
const idMatch = policyName.match(/([a-z0-9]+)$/i);
|
||||
const uniqueId = idMatch ? idMatch[1] : policyName;
|
||||
await searchInput.fill(uniqueId);
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
const policyRow = systemConsolePage.page.locator('.policy-name').first();
|
||||
const policyId = (await policyRow.getAttribute('id'))?.replace('customDescription-', '');
|
||||
|
||||
if (policyId) {
|
||||
await activatePolicy(adminClient, policyId);
|
||||
}
|
||||
await searchInput.clear();
|
||||
|
||||
// ============================================================
|
||||
// STEP 2: Verify user is NOT in channel initially
|
||||
// ============================================================
|
||||
await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
|
||||
const initialInChannel = await verifyUserInChannel(adminClient, testUser.id, privateChannel.id);
|
||||
expect(initialInChannel).toBe(false);
|
||||
|
||||
// ============================================================
|
||||
// STEPS 3-5: Add qualifying attribute to user's profile
|
||||
// Note: Using API for attribute update. UI testing for profile editing
|
||||
// is covered in separate user profile test suite.
|
||||
// ============================================================
|
||||
await updateUserAttributes(adminClient, testUser.id, {Department: 'Engineering'});
|
||||
|
||||
// ============================================================
|
||||
// STEP 6: Run sync job to trigger auto-add
|
||||
// ============================================================
|
||||
|
||||
// DEBUG: Verify attribute was updated before sync
|
||||
await adminClient.getUserCustomProfileAttributesValues(testUser.id);
|
||||
|
||||
// Get the Department field to check its value
|
||||
await adminClient.getCustomProfileAttributeFields();
|
||||
|
||||
await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
|
||||
// ============================================================
|
||||
// VERIFICATION: User should now be auto-added to channel
|
||||
// ============================================================
|
||||
|
||||
// DEBUG: Check all channel members
|
||||
await adminClient.getChannelMembers(privateChannel.id);
|
||||
|
||||
const finalInChannel = await verifyUserInChannel(adminClient, testUser.id, privateChannel.id);
|
||||
|
||||
if (!finalInChannel) {
|
||||
// console.error('\n[ERROR] User NOT in channel after sync!');
|
||||
// console.error('[ERROR] This means the ABAC sync did not add the user.');
|
||||
// console.error('[ERROR] Possible causes:');
|
||||
// console.error('[ERROR] 1. Policy not active');
|
||||
// console.error('[ERROR] 2. Attribute value not matching policy');
|
||||
// console.error('[ERROR] 3. Sync job failed silently');
|
||||
}
|
||||
|
||||
expect(finalInChannel).toBe(true);
|
||||
|
||||
// ============================================================
|
||||
// VERIFICATION: Check for "User added" system message
|
||||
// ============================================================
|
||||
|
||||
// Get recent posts from the channel
|
||||
const posts = await adminClient.getPosts(privateChannel.id, 0, 10);
|
||||
const postList = posts.order.map((postId: string) => posts.posts[postId]);
|
||||
|
||||
// Find system message for user being added
|
||||
const userAddedMessage = postList.find((post: any) => {
|
||||
return (
|
||||
post.type === 'system_add_to_channel' &&
|
||||
post.props?.addedUserId === testUser.id &&
|
||||
post.user_id === 'system'
|
||||
);
|
||||
});
|
||||
|
||||
if (userAddedMessage) {
|
||||
// System message found
|
||||
} else {
|
||||
// System message not found (may be disabled in test env)
|
||||
}
|
||||
|
||||
// System messages might be disabled in test env, so we don't fail the test
|
||||
// The important verification is that the user was added
|
||||
expect(finalInChannel).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* MM-T5795: User can be added to channel by system admin after a qualifying attribute is added to their profile (auto-add false)
|
||||
*
|
||||
* Preconditions:
|
||||
* - Access policy with auto-add set to FALSE
|
||||
*
|
||||
* Steps:
|
||||
* 1. As system admin, note the required attribute for channel access
|
||||
* 2. As a user not in the channel and lacking the required attribute:
|
||||
* - Add the required attribute value to user profile
|
||||
* 3. As system admin, go to the channel and add the user
|
||||
*
|
||||
* Expected:
|
||||
* - User who now meets the policy CAN be added to the channel by the admin
|
||||
* - "User added" message is posted in the channel by System
|
||||
*/
|
||||
test('MM-T5795 User can be added by admin after attribute added (auto-add false)', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
// ============================================================
|
||||
// SETUP: Create attribute, policy with auto-add FALSE, and channel
|
||||
// ============================================================
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
await enableUserManagedAttributes(adminClient);
|
||||
|
||||
const attributeFields: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
|
||||
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, attributeFields);
|
||||
|
||||
// Create test user WITHOUT the qualifying attribute
|
||||
const testUser = await createUserForABAC(adminClient, attributeFieldsMap, []);
|
||||
await adminClient.addToTeam(team.id, testUser.id);
|
||||
|
||||
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
|
||||
// ============================================================
|
||||
// STEP 1: Create policy with auto-add DISABLED
|
||||
// ============================================================
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
await enableABAC(systemConsolePage.page);
|
||||
|
||||
const policyName = `Engineering Manual Add ${pw.random.id()}`;
|
||||
await createBasicPolicy(systemConsolePage.page, {
|
||||
name: policyName,
|
||||
attribute: 'Department',
|
||||
operator: '==',
|
||||
value: 'Engineering',
|
||||
autoSync: false, // ✅ Auto-add DISABLED
|
||||
channels: [privateChannel.display_name],
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// STEP 2: Add qualifying attribute to user
|
||||
// ============================================================
|
||||
await updateUserAttributes(adminClient, testUser.id, {Department: 'Engineering'});
|
||||
|
||||
// ============================================================
|
||||
// STEP 3: Admin manually adds user to channel
|
||||
// ============================================================
|
||||
|
||||
// Verify user can be added (policy allows it since user has qualifying attribute)
|
||||
await adminClient.addToChannel(testUser.id, privateChannel.id);
|
||||
|
||||
// Verify user is now in channel
|
||||
const userInChannel = await verifyUserInChannel(adminClient, testUser.id, privateChannel.id);
|
||||
expect(userInChannel).toBe(true);
|
||||
|
||||
// ============================================================
|
||||
// VERIFICATION: Check for "User added" system message
|
||||
// ============================================================
|
||||
|
||||
const posts = await adminClient.getPosts(privateChannel.id, 0, 10);
|
||||
const postList = posts.order.map((postId: string) => posts.posts[postId]);
|
||||
|
||||
const userAddedMessage = postList.find((post: any) => {
|
||||
return post.type === 'system_add_to_channel' && post.props?.addedUserId === testUser.id;
|
||||
});
|
||||
|
||||
if (userAddedMessage) {
|
||||
// System message found
|
||||
} else {
|
||||
// System message not found (may be disabled in test env)
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* MM-T5796: User is auto-removed from channel when required attribute is removed
|
||||
*
|
||||
* Test Scenario 1 & 2 (Auto-add: False & True):
|
||||
* Steps:
|
||||
* 1. As system admin, identify the required attribute for channel access
|
||||
* 2. Log in as a user currently in the channel with the required attribute
|
||||
* 3. Edit user's profile
|
||||
* 4. Remove or change the required attribute value
|
||||
* 5. Save changes
|
||||
*
|
||||
* Expected:
|
||||
* - User is automatically removed from the channel
|
||||
* - System posts a "User removed" message in the channel
|
||||
*/
|
||||
test('MM-T5796 User auto-removed when required attribute is removed', async ({pw}) => {
|
||||
test.setTimeout(180000);
|
||||
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
// ============================================================
|
||||
// SETUP
|
||||
// ============================================================
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
await enableUserManagedAttributes(adminClient);
|
||||
|
||||
const attributeFields: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
|
||||
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, attributeFields);
|
||||
|
||||
// Create test user WITH the qualifying attribute (starts with Department=Engineering)
|
||||
const testUser = await createUserForABAC(adminClient, attributeFieldsMap, [
|
||||
{name: 'Department', type: 'text', value: 'Engineering'},
|
||||
]);
|
||||
await adminClient.addToTeam(team.id, testUser.id);
|
||||
|
||||
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
|
||||
// ============================================================
|
||||
// TEST SCENARIO 1: Auto-add FALSE
|
||||
// ============================================================
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
await enableABAC(systemConsolePage.page);
|
||||
|
||||
const policy1Name = `Engineering Access NoAutoAdd ${pw.random.id()}`;
|
||||
await createBasicPolicy(systemConsolePage.page, {
|
||||
name: policy1Name,
|
||||
attribute: 'Department',
|
||||
operator: '==',
|
||||
value: 'Engineering',
|
||||
autoSync: false, // Auto-add FALSE
|
||||
channels: [privateChannel.display_name],
|
||||
});
|
||||
|
||||
// Manually add user to channel
|
||||
await adminClient.addToChannel(testUser.id, privateChannel.id);
|
||||
const initialInChannel = await verifyUserInChannel(adminClient, testUser.id, privateChannel.id);
|
||||
expect(initialInChannel).toBe(true);
|
||||
|
||||
// Get policy ID and activate
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
const searchInput = systemConsolePage.page.locator('input[placeholder*="Search" i]').first();
|
||||
await searchInput.waitFor({state: 'visible', timeout: 5000});
|
||||
const idMatch = policy1Name.match(/([a-z0-9]+)$/i);
|
||||
const uniqueId = idMatch ? idMatch[1] : policy1Name;
|
||||
await searchInput.fill(uniqueId);
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
const policyRow = systemConsolePage.page.locator('.policy-name').first();
|
||||
const policyElementId = await policyRow.getAttribute('id');
|
||||
const policyId = policyElementId?.replace('customDescription-', '');
|
||||
|
||||
if (policyId) {
|
||||
await activatePolicy(adminClient, policyId);
|
||||
}
|
||||
await searchInput.clear();
|
||||
|
||||
// Remove the qualifying attribute
|
||||
await updateUserAttributes(adminClient, testUser.id, {Department: 'Sales'});
|
||||
|
||||
// Wait for attribute change to propagate
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
// Run sync job
|
||||
await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
|
||||
// Wait for membership updates to apply
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
// Verify user is removed
|
||||
const userInChannelAfterRemoval = await verifyUserInChannel(adminClient, testUser.id, privateChannel.id);
|
||||
expect(userInChannelAfterRemoval).toBe(false);
|
||||
|
||||
// Check for removal system message
|
||||
const posts = await adminClient.getPosts(privateChannel.id, 0, 10);
|
||||
const postList = posts.order.map((postId: string) => posts.posts[postId]);
|
||||
|
||||
const userRemovedMessage = postList.find((post: any) => {
|
||||
return (
|
||||
(post.type === 'system_remove_from_channel' || post.type === 'system_leave_channel') &&
|
||||
(post.props?.removedUserId === testUser.id || post.user_id === testUser.id)
|
||||
);
|
||||
});
|
||||
|
||||
if (userRemovedMessage) {
|
||||
// System message found
|
||||
} else {
|
||||
// System message not found (may be disabled in test env)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// TEST SCENARIO 2: Auto-add TRUE
|
||||
// ============================================================
|
||||
|
||||
// Restore user attribute and create new policy with auto-add=true
|
||||
await updateUserAttributes(adminClient, testUser.id, {Department: 'Engineering'});
|
||||
|
||||
const channel2 = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
|
||||
const policy2Name = `Engineering Access WithAutoAdd ${pw.random.id()}`;
|
||||
await createBasicPolicy(systemConsolePage.page, {
|
||||
name: policy2Name,
|
||||
attribute: 'Department',
|
||||
operator: '==',
|
||||
value: 'Engineering',
|
||||
autoSync: true, // Auto-add TRUE
|
||||
channels: [channel2.display_name],
|
||||
});
|
||||
|
||||
// Activate and run sync to auto-add user
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
await searchInput.fill(policy2Name.match(/([a-z0-9]+)$/i)?.[1] || policy2Name);
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
const policyRow2 = systemConsolePage.page.locator('.policy-name').first();
|
||||
const policyId2 = (await policyRow2.getAttribute('id'))?.replace('customDescription-', '');
|
||||
|
||||
if (policyId2) {
|
||||
await activatePolicy(adminClient, policyId2);
|
||||
}
|
||||
await searchInput.clear();
|
||||
|
||||
await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
|
||||
const userAutoAdded = await verifyUserInChannel(adminClient, testUser.id, channel2.id);
|
||||
expect(userAutoAdded).toBe(true);
|
||||
|
||||
// Remove attribute again
|
||||
await updateUserAttributes(adminClient, testUser.id, {Department: 'Marketing'});
|
||||
|
||||
// Wait for attribute change to propagate
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
// Run sync
|
||||
await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page);
|
||||
|
||||
// Small delay for channel membership update
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
// Verify user is removed
|
||||
const userRemovedFromChannel2 = await verifyUserInChannel(adminClient, testUser.id, channel2.id);
|
||||
expect(userRemovedFromChannel2).toBe(false);
|
||||
});
|
||||
});
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
enableABAC,
|
||||
navigateToABACPage,
|
||||
runSyncJob,
|
||||
verifyUserInChannel,
|
||||
updateUserAttributes,
|
||||
createUserWithAttributes,
|
||||
} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
ensureUserAttributes,
|
||||
createPrivateChannelForABAC,
|
||||
createBasicPolicy,
|
||||
activatePolicy,
|
||||
waitForLatestSyncJob,
|
||||
} from '../support';
|
||||
|
||||
/**
|
||||
* ABAC User Attributes - Attribute Changes
|
||||
* Tests for user attribute changes affecting ABAC policies
|
||||
*/
|
||||
test.describe('ABAC User Attributes - Attribute Changes', () => {
|
||||
/**
|
||||
* MM-T5794: User is auto-added to channel when a qualifying attribute is added to their profile (auto-add true)
|
||||
*
|
||||
* Step 1:
|
||||
* With at least one access policy in existence on the server, set to auto-add, and applied to a channel:
|
||||
* 1. As system admin make a note of the attribute needed for a user to be auto-added to a channel
|
||||
* 2. As a user not in the channel and not having the required attribute
|
||||
* 3. Click user's own profile picture top right and select Profile
|
||||
* 4. Scroll down to the required custom attribute, click Edit, and add the required value
|
||||
*/
|
||||
test('MM-T5794 User auto-added when qualifying attribute is added to profile', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
// ============================================================
|
||||
// SETUP: Create attribute, policy, and channel
|
||||
// ============================================================
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
// Setup attributes (using ensureUserAttributes like MM-T5800 does)
|
||||
await ensureUserAttributes(adminClient);
|
||||
|
||||
// Create test user with NON-qualifying Department attribute (same pattern as MM-T5800)
|
||||
// MM-T5800 creates user with Department=Sales, then changes to Engineering
|
||||
// We do the same: Start with Sales (non-qualifying), then change to Engineering (qualifying)
|
||||
const testUser = await createUserWithAttributes(adminClient, {Department: 'Sales'});
|
||||
await adminClient.addToTeam(team.id, testUser.id);
|
||||
|
||||
// Create private channel
|
||||
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
|
||||
// ============================================================
|
||||
// STEP 1: Create ABAC policy with auto-add enabled
|
||||
// Policy requirement: Department == "Engineering"
|
||||
// ============================================================
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
await enableABAC(systemConsolePage.page);
|
||||
|
||||
const policyName = `Engineering Access ${pw.random.id()}`;
|
||||
const __jobId = await createBasicPolicy(systemConsolePage.page, {
|
||||
name: policyName,
|
||||
attribute: 'Department',
|
||||
operator: '==',
|
||||
value: 'Engineering',
|
||||
autoSync: true, // ✅ Auto-add enabled
|
||||
channels: [privateChannel.display_name],
|
||||
});
|
||||
|
||||
// Activate policy (EXACT same pattern as MM-T5800)
|
||||
await waitForLatestSyncJob(systemConsolePage.page, undefined, __jobId);
|
||||
const searchInput = systemConsolePage.page.locator('input[placeholder*="Search" i]').first();
|
||||
await searchInput.waitFor({state: 'visible', timeout: 5000});
|
||||
const idMatch = policyName.match(/([a-z0-9]+)$/i);
|
||||
const uniqueId = idMatch ? idMatch[1] : policyName;
|
||||
await searchInput.fill(uniqueId);
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
const policyRow = systemConsolePage.page.locator('.policy-name').first();
|
||||
const policyId = (await policyRow.getAttribute('id'))?.replace('customDescription-', '');
|
||||
|
||||
if (policyId) {
|
||||
await activatePolicy(adminClient, policyId);
|
||||
}
|
||||
await searchInput.clear();
|
||||
|
||||
// Re-apply guard: concurrent initSetup() resets ABAC between enableABAC() UI call and sync
|
||||
await adminClient.patchConfig({
|
||||
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// STEP 2: Verify user is NOT in channel initially
|
||||
// ============================================================
|
||||
const __syncJob1 = await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page, undefined, __syncJob1);
|
||||
|
||||
const initialInChannel = await verifyUserInChannel(adminClient, testUser.id, privateChannel.id);
|
||||
expect(initialInChannel).toBe(false);
|
||||
|
||||
// ============================================================
|
||||
// STEPS 3-5: Add qualifying attribute to user's profile
|
||||
// Note: Using API for attribute update. UI testing for profile editing
|
||||
// is covered in separate user profile test suite.
|
||||
// ============================================================
|
||||
await updateUserAttributes(adminClient, testUser.id, {Department: 'Engineering'});
|
||||
|
||||
// ============================================================
|
||||
// STEP 6: Run sync job to trigger auto-add
|
||||
// ============================================================
|
||||
|
||||
// DEBUG: Verify attribute was updated before sync
|
||||
await adminClient.getUserCustomProfileAttributesValues(testUser.id);
|
||||
|
||||
// Get the Department field to check its value
|
||||
await adminClient.getCustomProfileAttributeFields();
|
||||
|
||||
const __syncJob2 = await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page, undefined, __syncJob2);
|
||||
|
||||
// ============================================================
|
||||
// VERIFICATION: User should now be auto-added to channel
|
||||
// ============================================================
|
||||
|
||||
// DEBUG: Check all channel members
|
||||
await adminClient.getChannelMembers(privateChannel.id);
|
||||
|
||||
const finalInChannel = await verifyUserInChannel(adminClient, testUser.id, privateChannel.id);
|
||||
|
||||
if (!finalInChannel) {
|
||||
// console.error('\n[ERROR] User NOT in channel after sync!');
|
||||
// console.error('[ERROR] This means the ABAC sync did not add the user.');
|
||||
// console.error('[ERROR] Possible causes:');
|
||||
// console.error('[ERROR] 1. Policy not active');
|
||||
// console.error('[ERROR] 2. Attribute value not matching policy');
|
||||
// console.error('[ERROR] 3. Sync job failed silently');
|
||||
}
|
||||
|
||||
expect(finalInChannel).toBe(true);
|
||||
|
||||
// ============================================================
|
||||
// VERIFICATION: Check for "User added" system message
|
||||
// ============================================================
|
||||
|
||||
// Get recent posts from the channel
|
||||
const posts = await adminClient.getPosts(privateChannel.id, 0, 10);
|
||||
const postList = posts.order.map((postId: string) => posts.posts[postId]);
|
||||
|
||||
// Find system message for user being added
|
||||
const userAddedMessage = postList.find((post: any) => {
|
||||
return (
|
||||
post.type === 'system_add_to_channel' &&
|
||||
post.props?.addedUserId === testUser.id &&
|
||||
post.user_id === 'system'
|
||||
);
|
||||
});
|
||||
|
||||
if (userAddedMessage) {
|
||||
// System message found
|
||||
} else {
|
||||
// System message not found (may be disabled in test env)
|
||||
}
|
||||
|
||||
// System messages might be disabled in test env, so we don't fail the test
|
||||
// The important verification is that the user was added
|
||||
expect(finalInChannel).toBe(true);
|
||||
});
|
||||
});
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
enableABAC,
|
||||
navigateToABACPage,
|
||||
verifyUserInChannel,
|
||||
updateUserAttributes,
|
||||
} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
CustomProfileAttribute,
|
||||
setupCustomProfileAttributeFields,
|
||||
} from '../../../channels/custom_profile_attributes/helpers';
|
||||
import {
|
||||
createUserForABAC,
|
||||
createPrivateChannelForABAC,
|
||||
createBasicPolicy,
|
||||
enableUserManagedAttributes,
|
||||
} from '../support';
|
||||
|
||||
/**
|
||||
* ABAC User Attributes - Attribute Changes
|
||||
* Tests for user attribute changes affecting ABAC policies
|
||||
*/
|
||||
test.describe('ABAC User Attributes - Attribute Changes', () => {
|
||||
/**
|
||||
* MM-T5795: User can be added to channel by system admin after a qualifying attribute is added to their profile (auto-add false)
|
||||
*
|
||||
* Preconditions:
|
||||
* - Access policy with auto-add set to FALSE
|
||||
*
|
||||
* Steps:
|
||||
* 1. As system admin, note the required attribute for channel access
|
||||
* 2. As a user not in the channel and lacking the required attribute:
|
||||
* - Add the required attribute value to user profile
|
||||
* 3. As system admin, go to the channel and add the user
|
||||
*
|
||||
* Expected:
|
||||
* - User who now meets the policy CAN be added to the channel by the admin
|
||||
* - "User added" message is posted in the channel by System
|
||||
*/
|
||||
test('MM-T5795 User can be added by admin after attribute added (auto-add false)', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
// ============================================================
|
||||
// SETUP: Create attribute, policy with auto-add FALSE, and channel
|
||||
// ============================================================
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
await enableUserManagedAttributes(adminClient);
|
||||
|
||||
const attributeFields: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
|
||||
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, attributeFields);
|
||||
|
||||
// Create test user WITHOUT the qualifying attribute
|
||||
const testUser = await createUserForABAC(adminClient, attributeFieldsMap, []);
|
||||
await adminClient.addToTeam(team.id, testUser.id);
|
||||
|
||||
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
|
||||
// ============================================================
|
||||
// STEP 1: Create policy with auto-add DISABLED
|
||||
// ============================================================
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
await enableABAC(systemConsolePage.page);
|
||||
|
||||
await adminClient.patchConfig({
|
||||
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
|
||||
});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.AccessControlSettings?.EnableAttributeBasedAccessControl === true;
|
||||
});
|
||||
|
||||
const policyName = `Engineering Manual Add ${pw.random.id()}`;
|
||||
await createBasicPolicy(systemConsolePage.page, {
|
||||
name: policyName,
|
||||
attribute: 'Department',
|
||||
operator: '==',
|
||||
value: 'Engineering',
|
||||
autoSync: false, // ✅ Auto-add DISABLED
|
||||
channels: [privateChannel.display_name],
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// STEP 2: Add qualifying attribute to user
|
||||
// ============================================================
|
||||
await updateUserAttributes(adminClient, testUser.id, {Department: 'Engineering'});
|
||||
|
||||
// ============================================================
|
||||
// STEP 3: Admin manually adds user to channel
|
||||
// ============================================================
|
||||
|
||||
await adminClient.patchConfig({
|
||||
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
|
||||
});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.AccessControlSettings?.EnableAttributeBasedAccessControl === true;
|
||||
});
|
||||
|
||||
// Re-create the Department CPA field if a concurrent initSetup() deleted it.
|
||||
// Without the field the server returns "An attribute is missing from the expression"
|
||||
// on addToChannel, because the policy references a field id that no longer exists.
|
||||
await setupCustomProfileAttributeFields(adminClient, attributeFields);
|
||||
|
||||
// Verify user can be added (policy allows it since user has qualifying attribute)
|
||||
await adminClient.addToChannel(testUser.id, privateChannel.id);
|
||||
|
||||
// Membership + ABAC evaluation can lag behind the REST response in CI.
|
||||
await expect
|
||||
.poll(async () => verifyUserInChannel(adminClient, testUser.id, privateChannel.id), {
|
||||
timeout: 60000,
|
||||
intervals: [1000, 2000, 3000],
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
// ============================================================
|
||||
// VERIFICATION: Check for "User added" system message
|
||||
// ============================================================
|
||||
|
||||
const posts = await adminClient.getPosts(privateChannel.id, 0, 10);
|
||||
const postList = posts.order.map((postId: string) => posts.posts[postId]);
|
||||
|
||||
const userAddedMessage = postList.find((post: any) => {
|
||||
return post.type === 'system_add_to_channel' && post.props?.addedUserId === testUser.id;
|
||||
});
|
||||
|
||||
if (userAddedMessage) {
|
||||
// System message found
|
||||
} else {
|
||||
// System message not found (may be disabled in test env)
|
||||
}
|
||||
});
|
||||
});
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
enableABAC,
|
||||
navigateToABACPage,
|
||||
runSyncJob,
|
||||
verifyUserInChannel,
|
||||
updateUserAttributes,
|
||||
} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
CustomProfileAttribute,
|
||||
setupCustomProfileAttributeFields,
|
||||
} from '../../../channels/custom_profile_attributes/helpers';
|
||||
import {
|
||||
createUserForABAC,
|
||||
createPrivateChannelForABAC,
|
||||
createBasicPolicy,
|
||||
activatePolicy,
|
||||
waitForLatestSyncJob,
|
||||
enableUserManagedAttributes,
|
||||
} from '../support';
|
||||
|
||||
/**
|
||||
* ABAC User Attributes - Attribute Changes
|
||||
* Tests for user attribute changes affecting ABAC policies
|
||||
*/
|
||||
test.describe('ABAC User Attributes - Attribute Changes', () => {
|
||||
/**
|
||||
* MM-T5796: User is auto-removed from channel when required attribute is removed
|
||||
*
|
||||
* Test Scenario 1 & 2 (Auto-add: False & True):
|
||||
* Steps:
|
||||
* 1. As system admin, identify the required attribute for channel access
|
||||
* 2. Log in as a user currently in the channel with the required attribute
|
||||
* 3. Edit user's profile
|
||||
* 4. Remove or change the required attribute value
|
||||
* 5. Save changes
|
||||
*
|
||||
* Expected:
|
||||
* - User is automatically removed from the channel
|
||||
* - System posts a "User removed" message in the channel
|
||||
*/
|
||||
test('MM-T5796 User auto-removed when required attribute is removed', async ({pw}) => {
|
||||
test.setTimeout(180000);
|
||||
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
// ============================================================
|
||||
// SETUP
|
||||
// ============================================================
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
await enableUserManagedAttributes(adminClient);
|
||||
|
||||
const attributeFields: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
|
||||
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, attributeFields);
|
||||
|
||||
// Create test user WITH the qualifying attribute (starts with Department=Engineering)
|
||||
const testUser = await createUserForABAC(adminClient, attributeFieldsMap, [
|
||||
{name: 'Department', type: 'text', value: 'Engineering'},
|
||||
]);
|
||||
await adminClient.addToTeam(team.id, testUser.id);
|
||||
|
||||
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
|
||||
// ============================================================
|
||||
// TEST SCENARIO 1: Auto-add FALSE
|
||||
// ============================================================
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
await enableABAC(systemConsolePage.page);
|
||||
|
||||
const policy1Name = `Engineering Access NoAutoAdd ${pw.random.id()}`;
|
||||
const __jobId1 = await createBasicPolicy(systemConsolePage.page, {
|
||||
name: policy1Name,
|
||||
attribute: 'Department',
|
||||
operator: '==',
|
||||
value: 'Engineering',
|
||||
autoSync: false, // Auto-add FALSE
|
||||
channels: [privateChannel.display_name],
|
||||
});
|
||||
|
||||
// Manually add user to channel
|
||||
await adminClient.addToChannel(testUser.id, privateChannel.id);
|
||||
const initialInChannel = await verifyUserInChannel(adminClient, testUser.id, privateChannel.id);
|
||||
expect(initialInChannel).toBe(true);
|
||||
|
||||
// Get policy ID and activate
|
||||
await waitForLatestSyncJob(systemConsolePage.page, undefined, __jobId1);
|
||||
const searchInput = systemConsolePage.page.locator('input[placeholder*="Search" i]').first();
|
||||
await searchInput.waitFor({state: 'visible', timeout: 5000});
|
||||
const idMatch = policy1Name.match(/([a-z0-9]+)$/i);
|
||||
const uniqueId = idMatch ? idMatch[1] : policy1Name;
|
||||
await searchInput.fill(uniqueId);
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
const policyRow = systemConsolePage.page.locator('.policy-name').first();
|
||||
const policyElementId = await policyRow.getAttribute('id');
|
||||
const policyId = policyElementId?.replace('customDescription-', '');
|
||||
|
||||
if (policyId) {
|
||||
await activatePolicy(adminClient, policyId);
|
||||
}
|
||||
await searchInput.clear();
|
||||
|
||||
// Remove the qualifying attribute
|
||||
await updateUserAttributes(adminClient, testUser.id, {Department: 'Sales'});
|
||||
|
||||
// Wait for attribute change to propagate
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
await adminClient.patchConfig({
|
||||
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
|
||||
});
|
||||
|
||||
// Run sync job
|
||||
const __syncJob1 = await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page, undefined, __syncJob1);
|
||||
|
||||
// Wait for membership updates to apply
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
// Verify user is removed
|
||||
const userInChannelAfterRemoval = await verifyUserInChannel(adminClient, testUser.id, privateChannel.id);
|
||||
expect(userInChannelAfterRemoval).toBe(false);
|
||||
|
||||
// Check for removal system message
|
||||
const posts = await adminClient.getPosts(privateChannel.id, 0, 10);
|
||||
const postList = posts.order.map((postId: string) => posts.posts[postId]);
|
||||
|
||||
const userRemovedMessage = postList.find((post: any) => {
|
||||
return (
|
||||
(post.type === 'system_remove_from_channel' || post.type === 'system_leave_channel') &&
|
||||
(post.props?.removedUserId === testUser.id || post.user_id === testUser.id)
|
||||
);
|
||||
});
|
||||
|
||||
if (userRemovedMessage) {
|
||||
// System message found
|
||||
} else {
|
||||
// System message not found (may be disabled in test env)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// TEST SCENARIO 2: Auto-add TRUE
|
||||
// ============================================================
|
||||
|
||||
// Restore user attribute and create new policy with auto-add=true
|
||||
await updateUserAttributes(adminClient, testUser.id, {Department: 'Engineering'});
|
||||
|
||||
const channel2 = await createPrivateChannelForABAC(adminClient, team.id);
|
||||
|
||||
await navigateToABACPage(systemConsolePage.page);
|
||||
|
||||
const policy2Name = `Engineering Access WithAutoAdd ${pw.random.id()}`;
|
||||
const __jobId2 = await createBasicPolicy(systemConsolePage.page, {
|
||||
name: policy2Name,
|
||||
attribute: 'Department',
|
||||
operator: '==',
|
||||
value: 'Engineering',
|
||||
autoSync: true, // Auto-add TRUE
|
||||
channels: [channel2.display_name],
|
||||
});
|
||||
|
||||
// Activate and run sync to auto-add user
|
||||
await waitForLatestSyncJob(systemConsolePage.page, undefined, __jobId2);
|
||||
await searchInput.fill(policy2Name.match(/([a-z0-9]+)$/i)?.[1] || policy2Name);
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
const policyRow2 = systemConsolePage.page.locator('.policy-name').first();
|
||||
const policyId2 = (await policyRow2.getAttribute('id'))?.replace('customDescription-', '');
|
||||
|
||||
if (policyId2) {
|
||||
await activatePolicy(adminClient, policyId2);
|
||||
}
|
||||
await searchInput.clear();
|
||||
|
||||
// Re-apply ABAC enable guard: a concurrent initSetup() on another shard may have
|
||||
// disabled ABAC between the initial enableABAC call and this sync job.
|
||||
// Without ABAC enabled the server skips policy evaluation and won't auto-add the user.
|
||||
await adminClient.patchConfig({
|
||||
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
|
||||
});
|
||||
|
||||
const __syncJob2 = await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page, undefined, __syncJob2);
|
||||
|
||||
const userAutoAdded = await verifyUserInChannel(adminClient, testUser.id, channel2.id);
|
||||
expect(userAutoAdded).toBe(true);
|
||||
|
||||
// Remove attribute again
|
||||
await updateUserAttributes(adminClient, testUser.id, {Department: 'Marketing'});
|
||||
|
||||
// Wait for attribute change to propagate
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
await adminClient.patchConfig({
|
||||
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
|
||||
});
|
||||
|
||||
// Run sync
|
||||
const __syncJob3 = await runSyncJob(systemConsolePage.page);
|
||||
await waitForLatestSyncJob(systemConsolePage.page, undefined, __syncJob3);
|
||||
|
||||
// Small delay for channel membership update
|
||||
await systemConsolePage.page.waitForTimeout(1000);
|
||||
|
||||
// Verify user is removed
|
||||
const userRemovedFromChannel2 = await verifyUserInChannel(adminClient, testUser.id, channel2.id);
|
||||
expect(userRemovedFromChannel2).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -203,12 +203,14 @@ test('should show and enable Intune MAM when Enterprise Advanced licensed and Of
|
||||
}
|
||||
|
||||
// # Configure Office365 settings
|
||||
const config = await adminClient.getConfig();
|
||||
config.Office365Settings.Enable = true;
|
||||
config.Office365Settings.Id = 'test-client-id';
|
||||
config.Office365Settings.Secret = 'test-client-secret';
|
||||
config.Office365Settings.DirectoryId = 'test-directory-id';
|
||||
await adminClient.updateConfig(config);
|
||||
await adminClient.patchConfig({
|
||||
Office365Settings: {
|
||||
Enable: true,
|
||||
Id: 'test-client-id',
|
||||
Secret: 'test-client-secret',
|
||||
DirectoryId: 'test-directory-id',
|
||||
},
|
||||
} as any);
|
||||
|
||||
// # Log in as admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
@@ -265,9 +267,11 @@ test('should hide Intune MAM when Office365 is not configured', async ({pw}) =>
|
||||
}
|
||||
|
||||
// # Ensure Office365 is disabled
|
||||
const config = await adminClient.getConfig();
|
||||
config.Office365Settings.Enable = false;
|
||||
await adminClient.updateConfig(config);
|
||||
await adminClient.patchConfig({
|
||||
Office365Settings: {
|
||||
Enable: false,
|
||||
},
|
||||
} as any);
|
||||
|
||||
// # Log in as admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
@@ -297,13 +301,21 @@ test('should configure new IntuneSettings with Office365 auth provider', async (
|
||||
}
|
||||
|
||||
// # Configure Office365 settings
|
||||
const config = await adminClient.getConfig();
|
||||
config.Office365Settings.Enable = true;
|
||||
config.Office365Settings.Id = 'test-office365-client-id';
|
||||
config.Office365Settings.Secret = 'test-office365-secret';
|
||||
config.Office365Settings.DirectoryId = 'test-office365-directory-id';
|
||||
config.SamlSettings.EmailAttribute = 'useremail';
|
||||
await adminClient.updateConfig(config);
|
||||
await adminClient.patchConfig({
|
||||
Office365Settings: {
|
||||
Enable: true,
|
||||
Id: 'test-office365-client-id',
|
||||
Secret: 'test-office365-secret',
|
||||
DirectoryId: 'test-office365-directory-id',
|
||||
},
|
||||
SamlSettings: {
|
||||
EmailAttribute: 'useremail',
|
||||
},
|
||||
} as any);
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.Office365Settings?.Enable === true && Boolean(cfg.Office365Settings?.Id);
|
||||
});
|
||||
|
||||
// # Log in as admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
@@ -325,7 +337,11 @@ test('should configure new IntuneSettings with Office365 auth provider', async (
|
||||
// * Verify Intune is enabled
|
||||
await systemConsolePage.mobileSecurity.enableIntuneMAM.toBeTrue();
|
||||
|
||||
// # Select Office365 as auth provider
|
||||
// # Select Office365 as auth provider.
|
||||
// After enabling Intune MAM the form re-renders; scroll the dropdown into view
|
||||
// before selecting so it is both visible and interactive.
|
||||
await systemConsolePage.mobileSecurity.authProvider.dropdown.scrollIntoViewIfNeeded();
|
||||
await expect(systemConsolePage.mobileSecurity.authProvider.dropdown).toBeEnabled({timeout: 15000});
|
||||
await systemConsolePage.mobileSecurity.authProvider.select('office365');
|
||||
|
||||
// # Fill in Intune configuration
|
||||
@@ -354,8 +370,6 @@ test('should configure new IntuneSettings with Office365 auth provider', async (
|
||||
test('should configure new IntuneSettings with SAML auth provider', async ({pw}) => {
|
||||
// # Configure SAML settings
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
const config = await adminClient.getConfig();
|
||||
|
||||
const license = await adminClient.getClientLicenseOld();
|
||||
|
||||
test.skip(license.SkuShortName !== 'advanced', 'Skipping test - server does not have enterprise advanced license');
|
||||
@@ -392,21 +406,20 @@ test('should configure new IntuneSettings with SAML auth provider', async ({pw})
|
||||
});
|
||||
|
||||
// # Configure SAML settings
|
||||
config.SamlSettings.Enable = true;
|
||||
config.SamlSettings.IdpURL = 'https://example.com/saml';
|
||||
config.SamlSettings.IdpDescriptorURL = 'https://example.com/saml/metadata';
|
||||
config.SamlSettings.IdpCertificateFile = 'test-cert.pem';
|
||||
config.SamlSettings.EmailAttribute = 'useremail';
|
||||
config.SamlSettings.UsernameAttribute = 'username';
|
||||
config.SamlSettings.ServiceProviderIdentifier = 'sp-entity-id';
|
||||
config.SamlSettings.AssertionConsumerServiceURL = 'https://sp.example.com/login';
|
||||
config.SamlSettings.IdpCertificateFile = 'saml-idp.crt';
|
||||
config.SamlSettings.PrivateKeyFile = 'saml-idp.crt';
|
||||
|
||||
if ('PublicCertificateFile' in config.SamlSettings) {
|
||||
config.SamlSettings.PublicCertificateFile = 'saml-public-cert.pem';
|
||||
}
|
||||
await adminClient.updateConfig(config);
|
||||
await adminClient.patchConfig({
|
||||
SamlSettings: {
|
||||
Enable: true,
|
||||
IdpURL: 'https://example.com/saml',
|
||||
IdpDescriptorURL: 'https://example.com/saml/metadata',
|
||||
IdpCertificateFile: 'saml-idp.crt',
|
||||
EmailAttribute: 'useremail',
|
||||
UsernameAttribute: 'username',
|
||||
ServiceProviderIdentifier: 'sp-entity-id',
|
||||
AssertionConsumerServiceURL: 'https://sp.example.com/login',
|
||||
PrivateKeyFile: 'saml-idp.crt',
|
||||
PublicCertificateFile: 'saml-public-cert.pem',
|
||||
},
|
||||
} as any);
|
||||
|
||||
// # Log in as admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
@@ -462,12 +475,14 @@ test('should disable Intune inputs when toggle is off', async ({pw}) => {
|
||||
}
|
||||
|
||||
// # Configure Office365 settings
|
||||
const config = await adminClient.getConfig();
|
||||
config.Office365Settings.Enable = true;
|
||||
config.Office365Settings.Id = 'test-client-id';
|
||||
config.Office365Settings.Secret = 'test-secret';
|
||||
config.Office365Settings.DirectoryId = 'test-directory-id';
|
||||
await adminClient.updateConfig(config);
|
||||
await adminClient.patchConfig({
|
||||
Office365Settings: {
|
||||
Enable: true,
|
||||
Id: 'test-client-id',
|
||||
Secret: 'test-secret',
|
||||
DirectoryId: 'test-directory-id',
|
||||
},
|
||||
} as any);
|
||||
|
||||
// # Log in as admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
+112
-17
@@ -1,8 +1,25 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {AdminConfig} from '@mattermost/types/config';
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
/**
|
||||
* Patch the Posts page required fields to known valid values so tests that
|
||||
* load the page always start with a saveable form state, regardless of what
|
||||
* other parallel tests may have left in the server config.
|
||||
*/
|
||||
async function resetPostsConfig(adminClient: {patchConfig: (config: Partial<AdminConfig>) => Promise<unknown>}) {
|
||||
await adminClient.patchConfig({
|
||||
ServiceSettings: {
|
||||
PersistentNotificationIntervalMinutes: 5,
|
||||
PersistentNotificationMaxRecipients: 5,
|
||||
PersistentNotificationMaxCount: 6,
|
||||
},
|
||||
} as Partial<AdminConfig>);
|
||||
}
|
||||
|
||||
test.describe('System Console > Self-Deleting Messages', () => {
|
||||
test('admin can enable and disable self-deleting messages', async ({pw}) => {
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
@@ -17,6 +34,9 @@ test.describe('System Console > Self-Deleting Messages', () => {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
// # Reset Posts section required fields so Save button is always enabled
|
||||
await resetPostsConfig(adminClient);
|
||||
|
||||
// # Log in as admin
|
||||
const {systemConsolePage, page} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
@@ -87,6 +107,9 @@ test.describe('System Console > Self-Deleting Messages', () => {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
// # Reset Posts section required fields so Save button is always enabled
|
||||
await resetPostsConfig(adminClient);
|
||||
|
||||
// # Ensure BoR is enabled via API
|
||||
const config = await adminClient.getConfig();
|
||||
config.ServiceSettings.EnableBurnOnRead = true;
|
||||
@@ -137,6 +160,9 @@ test.describe('System Console > Self-Deleting Messages', () => {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
// # Reset Posts section required fields so Save button is always enabled
|
||||
await resetPostsConfig(adminClient);
|
||||
|
||||
// # Ensure BoR is enabled via API
|
||||
const config = await adminClient.getConfig();
|
||||
config.ServiceSettings.EnableBurnOnRead = true;
|
||||
@@ -191,6 +217,10 @@ test.describe('System Console > Self-Deleting Messages', () => {
|
||||
const config = await adminClient.getConfig();
|
||||
config.ServiceSettings.EnableBurnOnRead = false;
|
||||
await adminClient.patchConfig(config);
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.ServiceSettings?.EnableBurnOnRead === false;
|
||||
});
|
||||
|
||||
// # Log in as admin
|
||||
const {systemConsolePage, page} = await pw.testBrowser.login(adminUser);
|
||||
@@ -209,26 +239,26 @@ test.describe('System Console > Self-Deleting Messages', () => {
|
||||
const durationDropdown = postsSection.getByTestId('ServiceSettings.BurnOnReadDurationSecondsdropdown');
|
||||
const maxTTLDropdown = postsSection.getByTestId('ServiceSettings.BurnOnReadMaximumTimeToLiveSecondsdropdown');
|
||||
|
||||
// * Verify feature is disabled (from API config)
|
||||
expect(await enableToggleFalse.isChecked()).toBe(true);
|
||||
// * Verify feature is disabled (from API config) — use built-in retry to tolerate render lag
|
||||
await expect(enableToggleFalse).toBeChecked({timeout: 10000});
|
||||
|
||||
// * Verify dropdowns are disabled when feature is off
|
||||
expect(await durationDropdown.isDisabled()).toBe(true);
|
||||
expect(await maxTTLDropdown.isDisabled()).toBe(true);
|
||||
await expect(durationDropdown).toBeDisabled({timeout: 30000});
|
||||
await expect(maxTTLDropdown).toBeDisabled({timeout: 30000});
|
||||
|
||||
// # Enable the feature (just toggle, don't save)
|
||||
await enableToggleTrue.click();
|
||||
|
||||
// * Verify dropdowns are now enabled
|
||||
expect(await durationDropdown.isDisabled()).toBe(false);
|
||||
expect(await maxTTLDropdown.isDisabled()).toBe(false);
|
||||
await expect(durationDropdown).not.toBeDisabled({timeout: 30000});
|
||||
await expect(maxTTLDropdown).not.toBeDisabled({timeout: 30000});
|
||||
|
||||
// # Toggle back to disabled
|
||||
await enableToggleFalse.click();
|
||||
|
||||
// * Verify dropdowns are disabled again
|
||||
expect(await durationDropdown.isDisabled()).toBe(true);
|
||||
expect(await maxTTLDropdown.isDisabled()).toBe(true);
|
||||
await expect(durationDropdown).toBeDisabled({timeout: 30000});
|
||||
await expect(maxTTLDropdown).toBeDisabled({timeout: 30000});
|
||||
});
|
||||
|
||||
test('settings persist after page reload', async ({pw}) => {
|
||||
@@ -246,11 +276,20 @@ test.describe('System Console > Self-Deleting Messages', () => {
|
||||
|
||||
// # Configure BoR via API with specific values (using valid dropdown options)
|
||||
// Duration: 300 (5 minutes), Max TTL: 259200 (3 days)
|
||||
const config = await adminClient.getConfig();
|
||||
config.ServiceSettings.EnableBurnOnRead = true;
|
||||
config.ServiceSettings.BurnOnReadDurationSeconds = 300;
|
||||
config.ServiceSettings.BurnOnReadMaximumTimeToLiveSeconds = 259200;
|
||||
await adminClient.patchConfig(config);
|
||||
await adminClient.patchConfig({
|
||||
ServiceSettings: {
|
||||
EnableBurnOnRead: true,
|
||||
BurnOnReadDurationSeconds: 300,
|
||||
BurnOnReadMaximumTimeToLiveSeconds: 259200,
|
||||
},
|
||||
});
|
||||
// Wait until the server confirms the patch before logging in, so the browser
|
||||
// reads the correct value when it loads the Posts section. A concurrent
|
||||
// initSetup() reset may otherwise overwrite BurnOnReadDurationSeconds.
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.ServiceSettings.BurnOnReadDurationSeconds === 300;
|
||||
});
|
||||
|
||||
// # Log in as admin
|
||||
const {systemConsolePage, page} = await pw.testBrowser.login(adminUser);
|
||||
@@ -273,14 +312,27 @@ test.describe('System Console > Self-Deleting Messages', () => {
|
||||
expect(await durationDropdown.inputValue()).toBe('300');
|
||||
expect(await maxTTLDropdown.inputValue()).toBe('259200');
|
||||
|
||||
// Re-apply guard: a concurrent initSetup() may reset BoR config between
|
||||
// the initial page load and this reload.
|
||||
await adminClient.patchConfig({
|
||||
ServiceSettings: {
|
||||
BurnOnReadDurationSeconds: 300,
|
||||
BurnOnReadMaximumTimeToLiveSeconds: 259200,
|
||||
},
|
||||
});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.ServiceSettings.BurnOnReadDurationSeconds === 300;
|
||||
});
|
||||
|
||||
// # Reload directly to Posts section
|
||||
await page.goto('/admin_console/site_config/posts');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// * Verify values persist after reload
|
||||
expect(await enableToggleTrue.isChecked()).toBe(true);
|
||||
expect(await durationDropdown.inputValue()).toBe('300');
|
||||
expect(await maxTTLDropdown.inputValue()).toBe('259200');
|
||||
// * Verify values persist after reload — toHaveValue has built-in retry
|
||||
await expect(enableToggleTrue).toBeChecked({timeout: 5000});
|
||||
await expect(durationDropdown).toHaveValue('300', {timeout: 5000});
|
||||
await expect(maxTTLDropdown).toHaveValue('259200', {timeout: 5000});
|
||||
});
|
||||
|
||||
test('BoR toggle appears in channels when feature is enabled in System Console', async ({pw}) => {
|
||||
@@ -296,6 +348,9 @@ test.describe('System Console > Self-Deleting Messages', () => {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
// # Reset Posts section required fields so Save button is always enabled
|
||||
await resetPostsConfig(adminClient);
|
||||
|
||||
// # First, disable BoR via API to start clean
|
||||
const config = await adminClient.getConfig();
|
||||
config.ServiceSettings.EnableBurnOnRead = false;
|
||||
@@ -321,6 +376,13 @@ test.describe('System Console > Self-Deleting Messages', () => {
|
||||
await saveButton.click();
|
||||
await pw.waitUntil(async () => (await saveButton.textContent()) === 'Save');
|
||||
|
||||
// Re-apply guard: concurrent initSetup() may reset EnableBurnOnRead between UI save and navigation
|
||||
await adminClient.patchConfig({ServiceSettings: {EnableBurnOnRead: true}});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.ServiceSettings.EnableBurnOnRead === true;
|
||||
});
|
||||
|
||||
// # Navigate to Channels by going to the team URL
|
||||
await page.goto(`/${team.name}/channels/off-topic`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
@@ -343,6 +405,9 @@ test.describe('System Console > Self-Deleting Messages', () => {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
// # Reset Posts section required fields so Save button is always enabled
|
||||
await resetPostsConfig(adminClient);
|
||||
|
||||
// # First, enable BoR via API
|
||||
const config = await adminClient.getConfig();
|
||||
config.ServiceSettings.EnableBurnOnRead = true;
|
||||
@@ -368,6 +433,13 @@ test.describe('System Console > Self-Deleting Messages', () => {
|
||||
await saveButton.click();
|
||||
await pw.waitUntil(async () => (await saveButton.textContent()) === 'Save');
|
||||
|
||||
// Re-apply guard: concurrent initSetup() may re-enable BoR between UI save and navigation
|
||||
await adminClient.patchConfig({ServiceSettings: {EnableBurnOnRead: false}});
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.ServiceSettings.EnableBurnOnRead === false;
|
||||
});
|
||||
|
||||
// # Navigate to Channels by going to the team URL
|
||||
await page.goto(`/${team.name}/channels/off-topic`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
@@ -397,6 +469,12 @@ test.describe('System Console > Self-Deleting Messages', () => {
|
||||
config.ServiceSettings.BurnOnReadMaximumTimeToLiveSeconds = 604800; // 7 days (so max TTL doesn't interfere)
|
||||
await adminClient.patchConfig(config);
|
||||
|
||||
// # Verify the config was applied before proceeding (guard against state pollution)
|
||||
await pw.waitUntil(async () => {
|
||||
const cfg = await adminClient.getConfig();
|
||||
return cfg.ServiceSettings.BurnOnReadDurationSeconds === 300;
|
||||
});
|
||||
|
||||
// # Create a second user to receive the message
|
||||
const randomUser = await pw.random.user();
|
||||
const receiver = await adminClient.createUser(randomUser, '', '');
|
||||
@@ -417,6 +495,13 @@ test.describe('System Console > Self-Deleting Messages', () => {
|
||||
const {channelsPage: senderChannelsPage} = await pw.testBrowser.login(adminUser);
|
||||
await senderChannelsPage.goto(team.name, channelName);
|
||||
await senderChannelsPage.toBeVisible();
|
||||
await adminClient.patchConfig({
|
||||
ServiceSettings: {
|
||||
EnableBurnOnRead: true,
|
||||
BurnOnReadDurationSeconds: 300,
|
||||
BurnOnReadMaximumTimeToLiveSeconds: 604800,
|
||||
},
|
||||
});
|
||||
|
||||
// # Toggle BoR on and post message
|
||||
await senderChannelsPage.centerView.postCreate.toggleBurnOnRead();
|
||||
@@ -436,6 +521,16 @@ test.describe('System Console > Self-Deleting Messages', () => {
|
||||
await expect(concealedPlaceholder).not.toHaveClass(/BurnOnReadConcealedPlaceholder--loading/, {timeout: 10000});
|
||||
await expect(concealedPlaceholder).toBeEnabled({timeout: 5000});
|
||||
|
||||
// Re-apply guard: TTL is set by the server at reveal time; ensure BurnOnReadDurationSeconds
|
||||
// is still 300 at the moment of reveal — a concurrent initSetup() may have reset it.
|
||||
await adminClient.patchConfig({
|
||||
ServiceSettings: {
|
||||
EnableBurnOnRead: true,
|
||||
BurnOnReadDurationSeconds: 300,
|
||||
BurnOnReadMaximumTimeToLiveSeconds: 604800,
|
||||
},
|
||||
});
|
||||
|
||||
// # Click to reveal the concealed message
|
||||
await concealedPlaceholder.click();
|
||||
|
||||
|
||||
+425
-349
@@ -1,385 +1,461 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
import {expect, getRandomId, test} from '@mattermost/playwright-lib';
|
||||
|
||||
test.beforeEach(async ({pw}) => {
|
||||
await pw.ensureLicense();
|
||||
await pw.skipIfNoLicense();
|
||||
});
|
||||
test.describe('Single-channel guests', () => {
|
||||
test.setTimeout(180000);
|
||||
test.describe.configure({mode: 'serial'});
|
||||
|
||||
/**
|
||||
* @objective Verify the Single-channel Guests stat card appears on the Site Statistics page when guests are enabled
|
||||
*
|
||||
* @precondition
|
||||
* Server has a non-Entry license with guest accounts enabled
|
||||
*/
|
||||
test(
|
||||
'displays single-channel guests card on site statistics page when guest accounts are enabled',
|
||||
{tag: '@system_console'},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
test.beforeEach(async ({pw}) => {
|
||||
await pw.ensureLicense();
|
||||
await pw.skipIfNoLicense();
|
||||
});
|
||||
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
// # Enable guest accounts
|
||||
const config = await adminClient.getConfig();
|
||||
config.GuestAccountsSettings.Enable = true;
|
||||
await adminClient.updateConfig(config);
|
||||
|
||||
// # Create a guest user and add to one channel
|
||||
const guestUser = await adminClient.createUser(await pw.random.user(), '', '');
|
||||
await adminClient.updateUserRoles(guestUser.id, 'system_guest');
|
||||
await adminClient.addToTeam(team.id, guestUser.id);
|
||||
|
||||
const channel = await adminClient.createChannel(
|
||||
pw.random.channel({teamId: team.id, name: 'guest-channel', displayName: 'Guest Channel', unique: true}),
|
||||
);
|
||||
await adminClient.addToChannel(guestUser.id, channel.id);
|
||||
|
||||
// # Log in as admin and navigate to site statistics
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
await systemConsolePage.page.goto('/admin_console/reporting/system_analytics');
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// * Verify the single-channel guests card is visible
|
||||
const singleChannelGuestsCard = systemConsolePage.page.getByTestId('singleChannelGuests');
|
||||
await expect(singleChannelGuestsCard).toBeVisible();
|
||||
|
||||
// * Verify the count is at least 1
|
||||
const countText = await singleChannelGuestsCard.textContent();
|
||||
const match = countText?.match(/(\d+)/);
|
||||
expect(match).toBeTruthy();
|
||||
expect(Number(match![1])).toBeGreaterThanOrEqual(1);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify the Single-channel Guests row appears on the Edition and License page when guests are enabled
|
||||
*
|
||||
* @precondition
|
||||
* Server has a non-Entry license with guest accounts enabled and a single-channel guest limit configured
|
||||
*/
|
||||
test(
|
||||
'displays single-channel guests row on edition and license page when guest accounts are enabled',
|
||||
{tag: '@system_console'},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
// # Enable guest accounts
|
||||
const config = await adminClient.getConfig();
|
||||
config.GuestAccountsSettings.Enable = true;
|
||||
await adminClient.updateConfig(config);
|
||||
|
||||
// # Create a guest user and add to one channel
|
||||
const guestUser = await adminClient.createUser(await pw.random.user(), '', '');
|
||||
await adminClient.updateUserRoles(guestUser.id, 'system_guest');
|
||||
await adminClient.addToTeam(team.id, guestUser.id);
|
||||
|
||||
const channel = await adminClient.createChannel(
|
||||
pw.random.channel({teamId: team.id, name: 'guest-channel', displayName: 'Guest Channel', unique: true}),
|
||||
);
|
||||
await adminClient.addToChannel(guestUser.id, channel.id);
|
||||
|
||||
// # Log in as admin and navigate to edition and license page
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
await systemConsolePage.page.goto('/admin_console/about/license');
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// * Verify the single-channel guests row is visible
|
||||
await expect(systemConsolePage.page.getByText('SINGLE-CHANNEL GUESTS:')).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify the Single-channel Guests stat card is not shown when guest accounts are disabled
|
||||
*/
|
||||
test(
|
||||
'hides single-channel guests card on site statistics page when guest accounts are disabled',
|
||||
{tag: '@system_console'},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
// # Disable guest accounts
|
||||
const config = await adminClient.getConfig();
|
||||
config.GuestAccountsSettings.Enable = false;
|
||||
await adminClient.updateConfig(config);
|
||||
|
||||
// # Log in as admin and navigate to site statistics
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
await systemConsolePage.page.goto('/admin_console/reporting/system_analytics');
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// * Verify the single-channel guests card is not in the DOM
|
||||
await expect(systemConsolePage.page.getByTestId('singleChannelGuests')).not.toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify the server limits API returns single-channel guest count and limit for admin users
|
||||
*
|
||||
* @precondition
|
||||
* Server has a non-Entry license with guest accounts enabled
|
||||
*/
|
||||
test(
|
||||
'returns single-channel guest data from server limits API for admin users',
|
||||
{tag: '@system_console'},
|
||||
async ({pw}) => {
|
||||
const {adminClient} = await pw.initSetup();
|
||||
|
||||
// # Enable guest accounts
|
||||
const config = await adminClient.getConfig();
|
||||
config.GuestAccountsSettings.Enable = true;
|
||||
await adminClient.updateConfig(config);
|
||||
|
||||
// # Fetch server limits
|
||||
const {data: limits} = await adminClient.getServerLimits();
|
||||
|
||||
// * Verify the response includes single-channel guest fields
|
||||
expect(limits).toHaveProperty('singleChannelGuestCount');
|
||||
expect(limits).toHaveProperty('singleChannelGuestLimit');
|
||||
expect(typeof limits.singleChannelGuestCount).toBe('number');
|
||||
expect(typeof limits.singleChannelGuestLimit).toBe('number');
|
||||
expect(limits.singleChannelGuestCount).toBeGreaterThanOrEqual(0);
|
||||
expect(limits.singleChannelGuestLimit).toBeGreaterThanOrEqual(0);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify the single-channel guests card does not show error styling when count is within limit
|
||||
*
|
||||
* @precondition
|
||||
* Server has a non-Entry license with guest accounts enabled and guest count is within the allowed limit
|
||||
*/
|
||||
test(
|
||||
'shows no error styling on single-channel guests card when count is within limit',
|
||||
{tag: '@system_console'},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
// # Enable guest accounts
|
||||
const config = await adminClient.getConfig();
|
||||
config.GuestAccountsSettings.Enable = true;
|
||||
await adminClient.updateConfig(config);
|
||||
|
||||
// # Create a single-channel guest (count will be well within any license limit)
|
||||
const guestUser = await adminClient.createUser(await pw.random.user(), '', '');
|
||||
await adminClient.updateUserRoles(guestUser.id, 'system_guest');
|
||||
await adminClient.addToTeam(team.id, guestUser.id);
|
||||
|
||||
const channel = await adminClient.createChannel(
|
||||
pw.random.channel({
|
||||
teamId: team.id,
|
||||
name: 'guest-no-overage',
|
||||
displayName: 'Guest No Overage',
|
||||
unique: true,
|
||||
}),
|
||||
);
|
||||
await adminClient.addToChannel(guestUser.id, channel.id);
|
||||
|
||||
// # Navigate to site statistics
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
await systemConsolePage.page.goto('/admin_console/reporting/system_analytics');
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// * Verify the card title does NOT have error class (count is within limit)
|
||||
const cardTitle = systemConsolePage.page.getByTestId('singleChannelGuestsTitle');
|
||||
await expect(cardTitle).toBeVisible();
|
||||
await expect(cardTitle).not.toHaveClass(/team_statistics--error/);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify the dismissible banner is not shown when single-channel guest count is within limit
|
||||
*
|
||||
* @precondition
|
||||
* Server has a non-Entry license with guest accounts enabled and guest count is within the allowed limit
|
||||
*/
|
||||
test('does not show guest limit banner when count is within limit', {tag: '@system_console'}, async ({pw}) => {
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
async function setupSingleChannelGuestsTest(pw: any) {
|
||||
const {adminClient, adminUser} = await pw.getAdminClient();
|
||||
const suffix = getRandomId();
|
||||
const team = await adminClient.createTeam({
|
||||
name: `scg-${suffix}`,
|
||||
display_name: `SCG ${suffix}`,
|
||||
type: 'O',
|
||||
});
|
||||
await adminClient.addToTeam(team.id, adminUser.id);
|
||||
return {adminClient, adminUser, team};
|
||||
}
|
||||
|
||||
// # Enable guest accounts
|
||||
const config = await adminClient.getConfig();
|
||||
config.GuestAccountsSettings.Enable = true;
|
||||
await adminClient.updateConfig(config);
|
||||
async function patchGuestEnabled(adminClient: any, enabled: boolean): Promise<boolean> {
|
||||
const cfg = await adminClient.getConfig();
|
||||
const previous = cfg.GuestAccountsSettings?.Enable ?? false;
|
||||
await adminClient.patchConfig({GuestAccountsSettings: {Enable: enabled}});
|
||||
return previous;
|
||||
}
|
||||
|
||||
// # Navigate to any page as admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
async function navigateWithGuestPatch(page: any, adminClient: any, url: string, guestEnabled: boolean) {
|
||||
await page.goto(url);
|
||||
await page.waitForLoadState('networkidle');
|
||||
await patchGuestEnabled(adminClient, guestEnabled);
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
// * Verify the guest limit banner is not visible (count is within limit)
|
||||
await expect(systemConsolePage.page.getByTestId('single_channel_guest_limit_banner')).not.toBeVisible();
|
||||
});
|
||||
/**
|
||||
* @objective Verify the Single-channel Guests stat card appears on the Site Statistics page when guests are enabled
|
||||
*
|
||||
* @precondition
|
||||
* Server has a non-Entry license with guest accounts enabled
|
||||
*/
|
||||
test(
|
||||
'displays single-channel guests card on site statistics page when guest accounts are enabled',
|
||||
{tag: '@system_console'},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient, team} = await setupSingleChannelGuestsTest(pw);
|
||||
|
||||
/**
|
||||
* @objective Verify error styling appears on the single-channel guests card and dismissible banner shows when single-channel guest count exceeds the limit
|
||||
*
|
||||
* @precondition
|
||||
* Server has a non-Entry license with guest accounts enabled
|
||||
*/
|
||||
test(
|
||||
'shows error styling on guests card and banner when single-channel guest count exceeds limit',
|
||||
{tag: '@system_console'},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
// # Enable guest accounts (narrow patch, not destructive full-config update)
|
||||
await patchGuestEnabled(adminClient, true);
|
||||
|
||||
// # Enable guest accounts
|
||||
const config = await adminClient.getConfig();
|
||||
config.GuestAccountsSettings.Enable = true;
|
||||
await adminClient.updateConfig(config);
|
||||
// # Create a guest user and add to one channel
|
||||
const guestUser = await adminClient.createUser(await pw.random.user(), '', '');
|
||||
await adminClient.updateUserRoles(guestUser.id, 'system_guest');
|
||||
await adminClient.addToTeam(team.id, guestUser.id);
|
||||
|
||||
// # Create multiple single-channel guests so the analytics count exceeds the mocked limit
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const guest = await adminClient.createUser(await pw.random.user(), '', '');
|
||||
await adminClient.updateUserRoles(guest.id, 'system_guest');
|
||||
await adminClient.addToTeam(team.id, guest.id);
|
||||
const channel = await adminClient.createChannel(
|
||||
pw.random.channel({teamId: team.id, name: 'guest-channel', displayName: 'Guest Channel', unique: true}),
|
||||
);
|
||||
await adminClient.addToChannel(guestUser.id, channel.id);
|
||||
|
||||
const ch = await adminClient.createChannel(
|
||||
// # Log in as admin and navigate to site statistics
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
|
||||
// Re-apply patch after initial load + reload to counter WebSocket config resets
|
||||
// from concurrent initSetup() calls (default_config has Enable: false).
|
||||
await navigateWithGuestPatch(
|
||||
systemConsolePage.page,
|
||||
adminClient,
|
||||
'/admin_console/reporting/system_analytics',
|
||||
true,
|
||||
);
|
||||
|
||||
// * Verify the single-channel guests card is visible
|
||||
await expect(systemConsolePage.page.getByTestId('singleChannelGuests')).toBeVisible({timeout: 30000});
|
||||
|
||||
// * Verify the count is at least 1.
|
||||
// Analytics are indexed asynchronously on the server — poll with reload until
|
||||
// the card reflects the newly created guest (can take several seconds in CI).
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
// Re-apply guest patch before reload: a concurrent initSetup() may
|
||||
// reset the config back to the default (Enable: false), which would
|
||||
// hide the card and make the textContent call return null.
|
||||
await patchGuestEnabled(adminClient, true);
|
||||
await systemConsolePage.page.reload();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
const text = await systemConsolePage.page
|
||||
.getByTestId('singleChannelGuests')
|
||||
.textContent()
|
||||
.catch(() => '');
|
||||
return Number(text?.match(/(\d+)/)?.[1] ?? 0);
|
||||
},
|
||||
{timeout: 180000, intervals: [3000, 5000, 8000, 12000]},
|
||||
)
|
||||
.toBeGreaterThanOrEqual(1);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify the Single-channel Guests row appears on the Edition and License page when guests are enabled
|
||||
*
|
||||
* @precondition
|
||||
* Server has a non-Entry license with guest accounts enabled and a single-channel guest limit configured
|
||||
*/
|
||||
test(
|
||||
'displays single-channel guests row on edition and license page when guest accounts are enabled',
|
||||
{tag: '@system_console'},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient, team} = await setupSingleChannelGuestsTest(pw);
|
||||
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
// # Enable guest accounts (narrow patch, not destructive full-config update)
|
||||
await patchGuestEnabled(adminClient, true);
|
||||
|
||||
// # Create a guest user and add to one channel
|
||||
const guestUser = await adminClient.createUser(await pw.random.user(), '', '');
|
||||
await adminClient.updateUserRoles(guestUser.id, 'system_guest');
|
||||
await adminClient.addToTeam(team.id, guestUser.id);
|
||||
|
||||
const channel = await adminClient.createChannel(
|
||||
pw.random.channel({teamId: team.id, name: 'guest-channel', displayName: 'Guest Channel', unique: true}),
|
||||
);
|
||||
await adminClient.addToChannel(guestUser.id, channel.id);
|
||||
|
||||
// # Log in as admin and navigate to edition and license page
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
|
||||
// Re-apply patch after initial load + reload to counter WebSocket config resets.
|
||||
await navigateWithGuestPatch(systemConsolePage.page, adminClient, '/admin_console/about/license', true);
|
||||
|
||||
// * Verify the single-channel guests row is visible
|
||||
await expect(systemConsolePage.page.getByText('SINGLE-CHANNEL GUESTS:')).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify the Single-channel Guests stat card is not shown when guest accounts are disabled
|
||||
*/
|
||||
test(
|
||||
'hides single-channel guests card on site statistics page when guest accounts are disabled',
|
||||
{tag: '@system_console'},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient} = await setupSingleChannelGuestsTest(pw);
|
||||
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
// # Disable guest accounts (narrow patch, not destructive full-config update)
|
||||
await patchGuestEnabled(adminClient, false);
|
||||
|
||||
// # Log in as admin and navigate to site statistics
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
|
||||
// Re-apply patch (disabled) + reload to ensure the browser reads the fresh config.
|
||||
await navigateWithGuestPatch(
|
||||
systemConsolePage.page,
|
||||
adminClient,
|
||||
'/admin_console/reporting/system_analytics',
|
||||
false,
|
||||
);
|
||||
|
||||
// * Verify the single-channel guests card is not in the DOM
|
||||
await expect(systemConsolePage.page.getByTestId('singleChannelGuests')).not.toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify the server limits API returns single-channel guest count and limit for admin users
|
||||
*
|
||||
* @precondition
|
||||
* Server has a non-Entry license with guest accounts enabled
|
||||
*/
|
||||
test(
|
||||
'returns single-channel guest data from server limits API for admin users',
|
||||
{tag: '@system_console'},
|
||||
async ({pw}) => {
|
||||
const {adminClient} = await setupSingleChannelGuestsTest(pw);
|
||||
|
||||
// # Enable guest accounts (narrow patch, not destructive full-config update)
|
||||
await patchGuestEnabled(adminClient, true);
|
||||
|
||||
// # Fetch server limits
|
||||
const {data: limits} = await adminClient.getServerLimits();
|
||||
|
||||
// * Verify the response includes single-channel guest fields
|
||||
expect(limits).toHaveProperty('singleChannelGuestCount');
|
||||
expect(limits).toHaveProperty('singleChannelGuestLimit');
|
||||
expect(typeof limits.singleChannelGuestCount).toBe('number');
|
||||
expect(typeof limits.singleChannelGuestLimit).toBe('number');
|
||||
expect(limits.singleChannelGuestCount).toBeGreaterThanOrEqual(0);
|
||||
expect(limits.singleChannelGuestLimit).toBeGreaterThanOrEqual(0);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify the single-channel guests card does not show error styling when count is within limit
|
||||
*
|
||||
* @precondition
|
||||
* Server has a non-Entry license with guest accounts enabled and guest count is within the allowed limit
|
||||
*/
|
||||
test(
|
||||
'shows no error styling on single-channel guests card when count is within limit',
|
||||
{tag: '@system_console'},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient, team} = await setupSingleChannelGuestsTest(pw);
|
||||
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
// # Enable guest accounts (narrow patch, not destructive full-config update)
|
||||
await patchGuestEnabled(adminClient, true);
|
||||
|
||||
// # Create a single-channel guest (count will be well within any license limit)
|
||||
const guestUser = await adminClient.createUser(await pw.random.user(), '', '');
|
||||
await adminClient.updateUserRoles(guestUser.id, 'system_guest');
|
||||
await adminClient.addToTeam(team.id, guestUser.id);
|
||||
|
||||
const channel = await adminClient.createChannel(
|
||||
pw.random.channel({
|
||||
teamId: team.id,
|
||||
name: `scg-overage-${i}`,
|
||||
displayName: `SCG Overage ${i}`,
|
||||
name: 'guest-no-overage',
|
||||
displayName: 'Guest No Overage',
|
||||
unique: true,
|
||||
}),
|
||||
);
|
||||
await adminClient.addToChannel(guest.id, ch.id);
|
||||
}
|
||||
await adminClient.addToChannel(guestUser.id, channel.id);
|
||||
|
||||
// # Log in as admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
// # Navigate to site statistics
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
|
||||
// # Mock the server limits API to simulate overage by returning a limit of 1
|
||||
await systemConsolePage.page.route('**/api/v4/limits/server', async (route) => {
|
||||
const response = await route.fetch();
|
||||
const json = await response.json();
|
||||
json.singleChannelGuestLimit = 1;
|
||||
await route.fulfill({response, json});
|
||||
});
|
||||
// Re-apply patch + reload to counter WebSocket config resets.
|
||||
await navigateWithGuestPatch(
|
||||
systemConsolePage.page,
|
||||
adminClient,
|
||||
'/admin_console/reporting/system_analytics',
|
||||
true,
|
||||
);
|
||||
|
||||
// # Navigate to site statistics page
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
await systemConsolePage.page.goto('/admin_console/reporting/system_analytics');
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
// * Verify the card title does NOT have error class (count is within limit)
|
||||
const cardTitle = systemConsolePage.page.getByTestId('singleChannelGuestsTitle');
|
||||
await expect(cardTitle).toBeVisible({timeout: 15000});
|
||||
await expect(cardTitle).not.toHaveClass(/team_statistics--error/);
|
||||
},
|
||||
);
|
||||
|
||||
// * Verify the card title has error styling
|
||||
const cardTitle = systemConsolePage.page.getByTestId('singleChannelGuestsTitle');
|
||||
await expect(cardTitle).toBeVisible();
|
||||
await expect(cardTitle).toHaveClass(/team_statistics--error/);
|
||||
|
||||
// * Verify the dismissible guest limit banner is visible
|
||||
await expect(systemConsolePage.page.getByTestId('single_channel_guest_limit_banner')).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify that a guest in multiple channels is not counted as a single-channel guest
|
||||
*
|
||||
* @precondition
|
||||
* Server has a non-Entry license with guest accounts enabled
|
||||
*/
|
||||
test(
|
||||
'does not count multi-channel guest as single-channel guest on site statistics page',
|
||||
{tag: '@system_console'},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
/**
|
||||
* @objective Verify the dismissible banner is not shown when single-channel guest count is within limit
|
||||
*
|
||||
* @precondition
|
||||
* Server has a non-Entry license with guest accounts enabled and guest count is within the allowed limit
|
||||
*/
|
||||
test('does not show guest limit banner when count is within limit', {tag: '@system_console'}, async ({pw}) => {
|
||||
const {adminUser, adminClient} = await setupSingleChannelGuestsTest(pw);
|
||||
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
// # Enable guest accounts
|
||||
const config = await adminClient.getConfig();
|
||||
config.GuestAccountsSettings.Enable = true;
|
||||
await adminClient.updateConfig(config);
|
||||
// # Enable guest accounts (narrow patch, not destructive full-config update)
|
||||
await patchGuestEnabled(adminClient, true);
|
||||
|
||||
// # Create a guest user and add to TWO channels
|
||||
const multiChannelGuest = await adminClient.createUser(await pw.random.user(), '', '');
|
||||
await adminClient.updateUserRoles(multiChannelGuest.id, 'system_guest');
|
||||
await adminClient.addToTeam(team.id, multiChannelGuest.id);
|
||||
|
||||
const channelA = await adminClient.createChannel(
|
||||
pw.random.channel({
|
||||
teamId: team.id,
|
||||
name: 'guest-channel-a',
|
||||
displayName: 'Guest Channel A',
|
||||
unique: true,
|
||||
}),
|
||||
);
|
||||
const channelB = await adminClient.createChannel(
|
||||
pw.random.channel({
|
||||
teamId: team.id,
|
||||
name: 'guest-channel-b',
|
||||
displayName: 'Guest Channel B',
|
||||
unique: true,
|
||||
}),
|
||||
);
|
||||
await adminClient.addToChannel(multiChannelGuest.id, channelA.id);
|
||||
await adminClient.addToChannel(multiChannelGuest.id, channelB.id);
|
||||
|
||||
// # Log in as admin and navigate to site statistics
|
||||
// # Navigate to system console and re-apply patch + reload so the browser reads
|
||||
// the latest config (not a WebSocket-clobbered Redux store from a concurrent initSetup).
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
await systemConsolePage.page.goto('/admin_console/reporting/system_analytics');
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// * Verify the single-channel guests card is visible
|
||||
const singleChannelGuestsCard = systemConsolePage.page.getByTestId('singleChannelGuests');
|
||||
await expect(singleChannelGuestsCard).toBeVisible();
|
||||
|
||||
// * Verify the count text is present — multi-channel guest should not increment it
|
||||
const countText = await singleChannelGuestsCard.textContent();
|
||||
const match = countText?.match(/(\d+)/);
|
||||
expect(match).toBeTruthy();
|
||||
|
||||
const singleChannelGuestCount = Number(match![1]);
|
||||
|
||||
// # Now create a single-channel guest to confirm baseline counting works
|
||||
const singleChannelGuest = await adminClient.createUser(await pw.random.user(), '', '');
|
||||
await adminClient.updateUserRoles(singleChannelGuest.id, 'system_guest');
|
||||
await adminClient.addToTeam(team.id, singleChannelGuest.id);
|
||||
await adminClient.addToChannel(singleChannelGuest.id, channelA.id);
|
||||
|
||||
// # Reload page to get updated stats
|
||||
await patchGuestEnabled(adminClient, true);
|
||||
await systemConsolePage.page.reload();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// * Verify the count increased by exactly 1 for the new single-channel guest
|
||||
const updatedCountText = await singleChannelGuestsCard.textContent();
|
||||
const updatedMatch = updatedCountText?.match(/(\d+)/);
|
||||
expect(updatedMatch).toBeTruthy();
|
||||
expect(Number(updatedMatch![1])).toBe(singleChannelGuestCount + 1);
|
||||
},
|
||||
);
|
||||
// * Verify the guest limit banner is not visible (count is within limit)
|
||||
await expect(systemConsolePage.page.getByTestId('single_channel_guest_limit_banner')).not.toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify error styling appears on the single-channel guests card and dismissible banner shows when single-channel guest count exceeds the limit
|
||||
*
|
||||
* @precondition
|
||||
* Server has a non-Entry license with guest accounts enabled
|
||||
*/
|
||||
test(
|
||||
'shows error styling on guests card and banner when single-channel guest count exceeds limit',
|
||||
{tag: '@system_console'},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient, team} = await setupSingleChannelGuestsTest(pw);
|
||||
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
// # Enable guest accounts (narrow patch, not destructive full-config update)
|
||||
await patchGuestEnabled(adminClient, true);
|
||||
|
||||
// # Create multiple single-channel guests so the analytics count exceeds the mocked limit
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const guest = await adminClient.createUser(await pw.random.user(), '', '');
|
||||
await adminClient.updateUserRoles(guest.id, 'system_guest');
|
||||
await adminClient.addToTeam(team.id, guest.id);
|
||||
|
||||
const ch = await adminClient.createChannel(
|
||||
pw.random.channel({
|
||||
teamId: team.id,
|
||||
name: `scg-overage-${i}`,
|
||||
displayName: `SCG Overage ${i}`,
|
||||
unique: true,
|
||||
}),
|
||||
);
|
||||
await adminClient.addToChannel(guest.id, ch.id);
|
||||
}
|
||||
|
||||
// # Log in as admin (new browser context + page).
|
||||
const {context, systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
// # Mock GET /api/v4/limits/server on the *context* before any navigation so the first
|
||||
// getServerLimits() populates Redux with a low limit. Also fulfill with an explicit
|
||||
// JSON body — route.fulfill({response, json}) does not reliably merge bodies in Playwright.
|
||||
await context.route('**/api/v4/limits/server', async (route) => {
|
||||
const response = await route.fetch();
|
||||
let json: Record<string, unknown> = {};
|
||||
try {
|
||||
json = (await response.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
// ignore — replace with minimal payload
|
||||
}
|
||||
json.singleChannelGuestLimit = 1;
|
||||
json.singleChannelGuestCount = 3;
|
||||
await route.fulfill({
|
||||
status: response.status(),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(json),
|
||||
});
|
||||
});
|
||||
|
||||
// # Navigate to site statistics page, re-applying patch to counter concurrent resets.
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
await navigateWithGuestPatch(
|
||||
systemConsolePage.page,
|
||||
adminClient,
|
||||
'/admin_console/reporting/system_analytics',
|
||||
true,
|
||||
);
|
||||
|
||||
// * Verify the card title has error styling
|
||||
const cardTitle = systemConsolePage.page.getByTestId('singleChannelGuestsTitle');
|
||||
await expect(cardTitle).toBeVisible({timeout: 15000});
|
||||
await expect(cardTitle).toHaveClass(/team_statistics--error/);
|
||||
|
||||
// * Verify the dismissible guest limit banner is visible
|
||||
await expect(systemConsolePage.page.getByTestId('single_channel_guest_limit_banner')).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify that a guest in multiple channels is not counted as a single-channel guest
|
||||
*
|
||||
* @precondition
|
||||
* Server has a non-Entry license with guest accounts enabled
|
||||
*/
|
||||
test(
|
||||
'does not count multi-channel guest as single-channel guest on site statistics page',
|
||||
{tag: '@system_console'},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient, team} = await setupSingleChannelGuestsTest(pw);
|
||||
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
// # Enable guest accounts (narrow patch, not destructive full-config update)
|
||||
await patchGuestEnabled(adminClient, true);
|
||||
|
||||
// # Create a guest user and add to TWO channels
|
||||
const multiChannelGuest = await adminClient.createUser(await pw.random.user(), '', '');
|
||||
await adminClient.updateUserRoles(multiChannelGuest.id, 'system_guest');
|
||||
await adminClient.addToTeam(team.id, multiChannelGuest.id);
|
||||
|
||||
const channelA = await adminClient.createChannel(
|
||||
pw.random.channel({
|
||||
teamId: team.id,
|
||||
name: 'guest-channel-a',
|
||||
displayName: 'Guest Channel A',
|
||||
unique: true,
|
||||
}),
|
||||
);
|
||||
const channelB = await adminClient.createChannel(
|
||||
pw.random.channel({
|
||||
teamId: team.id,
|
||||
name: 'guest-channel-b',
|
||||
displayName: 'Guest Channel B',
|
||||
unique: true,
|
||||
}),
|
||||
);
|
||||
await adminClient.addToChannel(multiChannelGuest.id, channelA.id);
|
||||
await adminClient.addToChannel(multiChannelGuest.id, channelB.id);
|
||||
|
||||
// # Log in as admin and navigate to site statistics, re-applying patch to counter
|
||||
// concurrent initSetup() resets (default_config has GuestAccountsSettings.Enable: false).
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
await navigateWithGuestPatch(
|
||||
systemConsolePage.page,
|
||||
adminClient,
|
||||
'/admin_console/reporting/system_analytics',
|
||||
true,
|
||||
);
|
||||
|
||||
// * Verify the single-channel guests card is visible
|
||||
const singleChannelGuestsCard = systemConsolePage.page.getByTestId('singleChannelGuests');
|
||||
await expect(singleChannelGuestsCard).toBeVisible({timeout: 15000});
|
||||
|
||||
// * Verify the count text is present — multi-channel guest should not increment it
|
||||
const countText = await singleChannelGuestsCard.textContent();
|
||||
const match = countText?.match(/(\d+)/);
|
||||
expect(match).toBeTruthy();
|
||||
|
||||
const singleChannelGuestCount = Number(match![1]);
|
||||
|
||||
// # Now create a single-channel guest to confirm baseline counting works
|
||||
const singleChannelGuest = await adminClient.createUser(await pw.random.user(), '', '');
|
||||
await adminClient.updateUserRoles(singleChannelGuest.id, 'system_guest');
|
||||
await adminClient.addToTeam(team.id, singleChannelGuest.id);
|
||||
await adminClient.addToChannel(singleChannelGuest.id, channelA.id);
|
||||
|
||||
// # Reload page to get updated stats
|
||||
await systemConsolePage.page.reload();
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// * Verify the count increased by exactly 1 for the new single-channel guest
|
||||
const updatedCountText = await singleChannelGuestsCard.textContent();
|
||||
const updatedMatch = updatedCountText?.match(/(\d+)/);
|
||||
expect(updatedMatch).toBeTruthy();
|
||||
expect(Number(updatedMatch![1])).toBe(singleChannelGuestCount + 1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
+54
-16
@@ -27,6 +27,17 @@ async function selectClassificationPreset(page: Page, optionLabel: string) {
|
||||
}
|
||||
|
||||
test.describe('System Console - Classification markings', () => {
|
||||
test.beforeAll(async () => {
|
||||
const {adminClient} = await getAdminClient({skipLog: true});
|
||||
await setClassificationMarkingsFeatureFlag(adminClient, true);
|
||||
const config = await adminClient.getConfig();
|
||||
test.skip(
|
||||
config.FeatureFlags?.ClassificationMarkings !== true &&
|
||||
config.FeatureFlags?.ClassificationMarkings !== 'true',
|
||||
'ClassificationMarkings feature flag is off (probably overridden by env); skipping.',
|
||||
);
|
||||
});
|
||||
|
||||
test.describe.configure({mode: 'serial'});
|
||||
|
||||
test.beforeEach(async ({pw}) => {
|
||||
@@ -37,6 +48,18 @@ test.describe('System Console - Classification markings', () => {
|
||||
licenseTier(license.SkuShortName) < 20,
|
||||
'Classification markings requires Enterprise-tier license (SkuShortName enterprise, entry, or advanced). Professional/trial Professional is not sufficient—the admin route is hidden and redirects to /admin_console/about/license.',
|
||||
);
|
||||
|
||||
// Skip if the custom_profile_attributes property group is absent on this server.
|
||||
// The group must exist (seeded by the server) before classification markings can be saved;
|
||||
// the API returns "The specified property group was not found." otherwise.
|
||||
try {
|
||||
await adminClient.getPropertyFields('custom_profile_attributes', 'template', 'system');
|
||||
} catch {
|
||||
test.skip(
|
||||
true,
|
||||
'custom_profile_attributes property group not found on this server; skipping classification markings save tests.',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -46,7 +69,11 @@ test.describe('System Console - Classification markings', () => {
|
||||
'MM-T6201 classification markings: feature flag off redirects away from admin URL',
|
||||
{tag: ['@system_console', '@classification_markings']},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
const {adminUser, adminClient} = await getAdminClient();
|
||||
|
||||
if (!adminUser || !adminClient) {
|
||||
throw new Error('Failed to get admin user');
|
||||
}
|
||||
|
||||
// # Turn off ClassificationMarkings in server config
|
||||
await setClassificationMarkingsFeatureFlag(adminClient, false);
|
||||
@@ -60,7 +87,6 @@ test.describe('System Console - Classification markings', () => {
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// * User is redirected away from the hidden route (no Route registered)
|
||||
await expect(systemConsolePage.page).not.toHaveURL(/classification_markings/);
|
||||
@@ -76,7 +102,11 @@ test.describe('System Console - Classification markings', () => {
|
||||
'MM-T6202 classification markings: feature flag on loads configuration page',
|
||||
{tag: ['@system_console', '@classification_markings']},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
const {adminUser, adminClient} = await getAdminClient();
|
||||
|
||||
if (!adminUser || !adminClient) {
|
||||
throw new Error('Failed to get admin user');
|
||||
}
|
||||
|
||||
// # Enable flag and clear any existing classification field
|
||||
await setClassificationMarkingsFeatureFlag(adminClient, true);
|
||||
@@ -85,7 +115,6 @@ test.describe('System Console - Classification markings', () => {
|
||||
// # Log in and open the classification markings URL
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await systemConsolePage.page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// * URL stays on the classification markings section
|
||||
await expect(systemConsolePage.page).toHaveURL(/classification_markings/);
|
||||
@@ -101,7 +130,11 @@ test.describe('System Console - Classification markings', () => {
|
||||
'MM-T6203 classification markings: save fails when enabled with zero levels',
|
||||
{tag: ['@system_console', '@classification_markings']},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
const {adminUser, adminClient} = await getAdminClient();
|
||||
|
||||
if (!adminUser || !adminClient) {
|
||||
throw new Error('Failed to get admin user');
|
||||
}
|
||||
|
||||
// # Enable feature flag and ensure no classification field exists
|
||||
await setClassificationMarkingsFeatureFlag(adminClient, true);
|
||||
@@ -109,7 +142,6 @@ test.describe('System Console - Classification markings', () => {
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await systemConsolePage.page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// # Enable classification markings without choosing a preset or adding levels
|
||||
await systemConsolePage.page.locator('input[name="classificationEnabled"][value="true"]').click();
|
||||
@@ -134,7 +166,11 @@ test.describe('System Console - Classification markings', () => {
|
||||
'MM-T6204 classification markings: select NATO preset and save',
|
||||
{tag: ['@system_console', '@classification_markings']},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
const {adminUser, adminClient} = await getAdminClient();
|
||||
|
||||
if (!adminUser || !adminClient) {
|
||||
throw new Error('Failed to get admin user');
|
||||
}
|
||||
|
||||
// # Enable flag and start from no classification field
|
||||
await setClassificationMarkingsFeatureFlag(adminClient, true);
|
||||
@@ -143,7 +179,6 @@ test.describe('System Console - Classification markings', () => {
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
const {page} = systemConsolePage;
|
||||
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// # Enable markings and choose NATO preset
|
||||
await page.locator('input[name="classificationEnabled"][value="true"]').click();
|
||||
@@ -151,11 +186,11 @@ test.describe('System Console - Classification markings', () => {
|
||||
|
||||
const firstLevelNameInput = page.getByLabel('Classification level name').first();
|
||||
// * Preset levels appear in the table
|
||||
await expect(firstLevelNameInput).toBeVisible();
|
||||
await expect(firstLevelNameInput).toHaveValue('NATO UNCLASSIFIED');
|
||||
|
||||
// # Save
|
||||
await page.getByRole('button', {name: 'Save', exact: true}).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// * No server error and first level name is unchanged after save
|
||||
await expect(page.locator('.admin-console-save .error-message')).toBeEmpty();
|
||||
@@ -171,7 +206,11 @@ test.describe('System Console - Classification markings', () => {
|
||||
'MM-T6205 classification markings: preset change shows confirm modal then applies',
|
||||
{tag: ['@system_console', '@classification_markings']},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
const {adminUser, adminClient} = await getAdminClient();
|
||||
|
||||
if (!adminUser || !adminClient) {
|
||||
throw new Error('Failed to get admin user');
|
||||
}
|
||||
|
||||
// # Enable flag and clear field, then prepare saved UK levels
|
||||
await setClassificationMarkingsFeatureFlag(adminClient, true);
|
||||
@@ -180,12 +219,10 @@ test.describe('System Console - Classification markings', () => {
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
const {page} = systemConsolePage;
|
||||
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.locator('input[name="classificationEnabled"][value="true"]').click();
|
||||
await selectClassificationPreset(page, 'UK (GSCP)');
|
||||
await page.getByRole('button', {name: 'Save', exact: true}).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// * UK preset first level is present
|
||||
await expect(page.getByLabel('Classification level name').first()).toHaveValue('OFFICIAL');
|
||||
@@ -216,7 +253,11 @@ test.describe('System Console - Classification markings', () => {
|
||||
'MM-T6206 classification markings: delete level switches to custom and saves',
|
||||
{tag: ['@system_console', '@classification_markings']},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
const {adminUser, adminClient} = await getAdminClient();
|
||||
|
||||
if (!adminUser || !adminClient) {
|
||||
throw new Error('Failed to get admin user');
|
||||
}
|
||||
|
||||
// # Enable flag and save Canada preset as baseline
|
||||
await setClassificationMarkingsFeatureFlag(adminClient, true);
|
||||
@@ -225,12 +266,10 @@ test.describe('System Console - Classification markings', () => {
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
const {page} = systemConsolePage;
|
||||
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.locator('input[name="classificationEnabled"][value="true"]').click();
|
||||
await selectClassificationPreset(page, 'Canada');
|
||||
await page.getByRole('button', {name: 'Save', exact: true}).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(page.getByLabel('Classification level name').first()).toHaveValue('PROTECTED A');
|
||||
|
||||
@@ -243,7 +282,6 @@ test.describe('System Console - Classification markings', () => {
|
||||
|
||||
// # Save custom levels
|
||||
await page.getByRole('button', {name: 'Save', exact: true}).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// * No error and preset remains custom
|
||||
await expect(page.locator('.admin-console-save .error-message')).toBeEmpty();
|
||||
|
||||
+2
-6
@@ -18,15 +18,11 @@ export const CLASSIFICATION_MARKINGS_ADMIN_PATH = '/admin_console/site_config/cl
|
||||
* (e.g. MM_FEATUREFLAGS_CLASSIFICATIONMARKINGS). E2E docker sets that env in server.generate.sh.
|
||||
*/
|
||||
export async function setClassificationMarkingsFeatureFlag(adminClient: Client4, enabled: boolean) {
|
||||
const config = await adminClient.getConfig();
|
||||
// Full config round-trip; FeatureFlags is a wide record on the client type.
|
||||
await adminClient.updateConfig({
|
||||
...config,
|
||||
await adminClient.patchConfig({
|
||||
FeatureFlags: {
|
||||
...config.FeatureFlags,
|
||||
ClassificationMarkings: enabled,
|
||||
},
|
||||
} as Awaited<ReturnType<Client4['getConfig']>>);
|
||||
} as any);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+122
-105
@@ -3,117 +3,134 @@
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
test('MM-T5523-1 Sortable columns should sort the list when clicked', async ({pw}) => {
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
test.describe('System Console - Users table sorting', () => {
|
||||
test.describe.configure({mode: 'serial'});
|
||||
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
test('MM-T5523-1 Sortable columns should sort the list when clicked', async ({pw}) => {
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
|
||||
// # Log in as admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
// # Create 10 random users
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await adminClient.createUser(await pw.random.user(), '', '');
|
||||
}
|
||||
|
||||
// # Visit system console
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
|
||||
// # Go to Users section
|
||||
await systemConsolePage.sidebar.users.click();
|
||||
await systemConsolePage.users.toBeVisible();
|
||||
|
||||
// * Verify that 'Email' column has aria-sort attribute
|
||||
const emailColumnHeader = systemConsolePage.users.usersTable.getColumnHeader('Email');
|
||||
await expect(emailColumnHeader).toBeVisible();
|
||||
await expect(emailColumnHeader).toHaveAttribute('aria-sort');
|
||||
|
||||
// # Click on the 'Email' column header to sort and wait for sort to complete
|
||||
const sortDirection = await systemConsolePage.users.usersTable.sortByColumn('Email');
|
||||
|
||||
// * Verify that emails are sorted in the expected direction
|
||||
await expect(async () => {
|
||||
const rowCount = await systemConsolePage.users.usersTable.bodyRows.count();
|
||||
const emails: string[] = [];
|
||||
for (let i = 0; i < rowCount; i++) {
|
||||
const row = systemConsolePage.users.usersTable.getRowByIndex(i);
|
||||
const email = await row.getEmail();
|
||||
emails.push(email);
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
const expectedOrder = [...emails].sort((a, b) => a.localeCompare(b));
|
||||
if (sortDirection === 'descending') {
|
||||
expectedOrder.reverse();
|
||||
}
|
||||
expect(emails).toEqual(expectedOrder);
|
||||
}).toPass();
|
||||
// # Log in as admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
// # Click on the 'Email' column header again to toggle sort direction
|
||||
const reversedDirection = await systemConsolePage.users.usersTable.sortByColumn('Email');
|
||||
|
||||
// * Verify that the sort direction has toggled
|
||||
expect(reversedDirection).not.toEqual(sortDirection);
|
||||
|
||||
// * Verify that emails are sorted in the toggled direction
|
||||
await expect(async () => {
|
||||
const rowCount = await systemConsolePage.users.usersTable.bodyRows.count();
|
||||
const emails: string[] = [];
|
||||
for (let i = 0; i < rowCount; i++) {
|
||||
const row = systemConsolePage.users.usersTable.getRowByIndex(i);
|
||||
const email = await row.getEmail();
|
||||
emails.push(email);
|
||||
// # Create 10 random users
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await adminClient.createUser(await pw.random.user(), '', '');
|
||||
}
|
||||
|
||||
const expectedOrder = [...emails].sort((a, b) => a.localeCompare(b));
|
||||
if (reversedDirection === 'descending') {
|
||||
expectedOrder.reverse();
|
||||
// # Visit system console
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
|
||||
// # Go to Users section
|
||||
await systemConsolePage.sidebar.users.click();
|
||||
await systemConsolePage.users.toBeVisible();
|
||||
|
||||
// * Verify that 'Email' column has aria-sort attribute
|
||||
const emailColumnHeader = systemConsolePage.users.usersTable.getColumnHeader('Email');
|
||||
await expect(emailColumnHeader).toBeVisible();
|
||||
await expect(emailColumnHeader).toHaveAttribute('aria-sort');
|
||||
|
||||
// # Click on the 'Email' column header to sort and wait for sort to complete
|
||||
const sortDirection = await systemConsolePage.users.usersTable.sortByColumn('Email');
|
||||
|
||||
// * Verify that emails are sorted in the expected direction (table can still be
|
||||
// re-fetching rows from other workers creating users — poll longer than default).
|
||||
await expect(async () => {
|
||||
await systemConsolePage.page.waitForLoadState('networkidle').catch(() => {});
|
||||
const rowCount = await systemConsolePage.users.usersTable.bodyRows.count();
|
||||
const maxRows = Math.min(rowCount, 40);
|
||||
const emails: string[] = [];
|
||||
for (let i = 0; i < maxRows; i++) {
|
||||
const row = systemConsolePage.users.usersTable.getRowByIndex(i);
|
||||
const email = (await row.getEmail()).trim();
|
||||
if (email) {
|
||||
emails.push(email);
|
||||
}
|
||||
}
|
||||
expect(emails.length).toBeGreaterThan(3);
|
||||
|
||||
const sorted = [...emails].sort((a, b) => a.localeCompare(b));
|
||||
if (sortDirection === 'descending') {
|
||||
sorted.reverse();
|
||||
}
|
||||
expect(emails).toEqual(sorted);
|
||||
}).toPass({timeout: 120_000});
|
||||
|
||||
// # Click on the 'Email' column header again to toggle sort direction
|
||||
const reversedDirection = await systemConsolePage.users.usersTable.sortByColumn('Email');
|
||||
|
||||
// * Verify that the sort direction has toggled
|
||||
expect(reversedDirection).not.toEqual(sortDirection);
|
||||
|
||||
// * Verify that emails are sorted in the toggled direction
|
||||
await expect(async () => {
|
||||
await systemConsolePage.page.waitForLoadState('networkidle').catch(() => {});
|
||||
const rowCount = await systemConsolePage.users.usersTable.bodyRows.count();
|
||||
const maxRows = Math.min(rowCount, 40);
|
||||
const emails: string[] = [];
|
||||
for (let i = 0; i < maxRows; i++) {
|
||||
const row = systemConsolePage.users.usersTable.getRowByIndex(i);
|
||||
const email = (await row.getEmail()).trim();
|
||||
if (email) {
|
||||
emails.push(email);
|
||||
}
|
||||
}
|
||||
expect(emails.length).toBeGreaterThan(3);
|
||||
|
||||
const sorted = [...emails].sort((a, b) => a.localeCompare(b));
|
||||
if (reversedDirection === 'descending') {
|
||||
sorted.reverse();
|
||||
}
|
||||
expect(emails).toEqual(sorted);
|
||||
}).toPass({timeout: 120_000});
|
||||
});
|
||||
|
||||
test('MM-T5523-2 Non sortable columns should not sort the list when clicked', async ({pw}) => {
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
expect(emails).toEqual(expectedOrder);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('MM-T5523-2 Non sortable columns should not sort the list when clicked', async ({pw}) => {
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
// # Log in as admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
// # Create 10 random users
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await adminClient.createUser(await pw.random.user(), '', '');
|
||||
}
|
||||
|
||||
// # Visit system console
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
|
||||
// # Go to Users section
|
||||
await systemConsolePage.sidebar.users.click();
|
||||
await systemConsolePage.users.toBeVisible();
|
||||
|
||||
// * Verify that 'Last login' column does not have aria-sort attribute
|
||||
const lastLoginColumnHeader = systemConsolePage.users.usersTable.getColumnHeader('Last login');
|
||||
await expect(lastLoginColumnHeader).toBeVisible();
|
||||
await expect(lastLoginColumnHeader).not.toHaveAttribute('aria-sort');
|
||||
|
||||
// # Store the first row's email without sorting
|
||||
const firstRowWithoutSort = systemConsolePage.users.usersTable.getRowByIndex(0);
|
||||
const firstRowEmailWithoutSort = await firstRowWithoutSort.container.getByText(pw.simpleEmailRe).allInnerTexts();
|
||||
|
||||
// # Try to click on the 'Last login' column header to sort
|
||||
await systemConsolePage.users.usersTable.clickSortOnColumn('Last login');
|
||||
|
||||
// # Store the first row's email after sorting
|
||||
const firstRowWithSort = systemConsolePage.users.usersTable.getRowByIndex(0);
|
||||
const firstRowEmailWithSort = await firstRowWithSort.container.getByText(pw.simpleEmailRe).allInnerTexts();
|
||||
|
||||
// * Verify that the first row's email is still the same
|
||||
expect(firstRowEmailWithoutSort).toEqual(firstRowEmailWithSort);
|
||||
|
||||
// # Log in as admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
// # Create 10 random users
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await adminClient.createUser(await pw.random.user(), '', '');
|
||||
}
|
||||
|
||||
// # Visit system console
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
|
||||
// # Go to Users section
|
||||
await systemConsolePage.sidebar.users.click();
|
||||
await systemConsolePage.users.toBeVisible();
|
||||
|
||||
// * Verify that 'Last login' column does not have aria-sort attribute
|
||||
const lastLoginColumnHeader = systemConsolePage.users.usersTable.getColumnHeader('Last login');
|
||||
await expect(lastLoginColumnHeader).toBeVisible();
|
||||
await expect(lastLoginColumnHeader).not.toHaveAttribute('aria-sort');
|
||||
|
||||
// # Store the first row's email without sorting
|
||||
const firstRowWithoutSort = systemConsolePage.users.usersTable.getRowByIndex(0);
|
||||
const firstRowEmailWithoutSort = await firstRowWithoutSort.container
|
||||
.getByText(pw.simpleEmailRe)
|
||||
.allInnerTexts();
|
||||
|
||||
// # Try to click on the 'Last login' column header to sort
|
||||
await systemConsolePage.users.usersTable.clickSortOnColumn('Last login');
|
||||
|
||||
// # Store the first row's email after sorting
|
||||
const firstRowWithSort = systemConsolePage.users.usersTable.getRowByIndex(0);
|
||||
const firstRowEmailWithSort = await firstRowWithSort.container.getByText(pw.simpleEmailRe).allInnerTexts();
|
||||
|
||||
// * Verify that the first row's email is still the same
|
||||
expect(firstRowEmailWithoutSort).toEqual(firstRowEmailWithSort);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user