mirror of
https://github.com/mattermost/mattermost.git
synced 2026-05-25 02:48:37 -04:00
Merge branch 'master' into demo-plugin-webapp-tests
This commit is contained in:
commit
df4eaf3541
730 changed files with 74400 additions and 17060 deletions
|
|
@ -17,7 +17,7 @@ The Docker build context is `.cursor/` only. The Dockerfile intentionally does n
|
|||
## Runtime Hooks
|
||||
|
||||
- `cloud-agent-install.sh` runs after Cursor checks out the repo. It refreshes nvm, installs agent-browser browsers, verifies Cursor's multi-repo `mattermost/enterprise` checkout, runs `server` Go dependency hydration, installs webapp dependencies, and runs Playwright `npm ci`.
|
||||
- `cloud-agent-start.sh` materializes `.cursor/cursor.md` as `.cursor/AGENTS.md`, fixes current-session Docker socket access, then starts Docker and waits until `docker info` and `docker compose version` succeed.
|
||||
- `cloud-agent-start.sh` materializes `.cursor/cursor.md` as `.cursor/AGENTS.md`, fixes current-session Docker socket access, starts Docker, waits until `docker info` and `docker compose version` succeed, then logs in to Docker Hub when credentials are configured.
|
||||
|
||||
The environment declares `github.com/mattermost/enterprise` in `repositoryDependencies` so Cursor can provide it as part of the multi-repo workspace. Cursor currently clones the repositories as siblings, such as `/agent/repos/mattermost` and `/agent/repos/enterprise`, which matches `server/Makefile`'s default `../../enterprise` path. The install hook does not clone, pull, or symlink enterprise.
|
||||
|
||||
|
|
@ -33,4 +33,7 @@ Set these environment variables to `true` to shorten startup for narrow tasks:
|
|||
|
||||
## Expected Secrets
|
||||
|
||||
- AWS uploads use the standard AWS CLI environment variables provided to the Cloud Agent: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_S3_BUCKET_NAME`. The image only supplies the `aws` binary.
|
||||
Configure these in the [Cursor Cloud Agents dashboard](https://cursor.com/dashboard/cloud-agents) as environment-scoped secrets for the Mattermost Cloud Agent environment.
|
||||
|
||||
- AWS uploads use the standard AWS CLI environment variables: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_S3_BUCKET_NAME`. The image only supplies the `aws` binary.
|
||||
- Docker Hub pulls use the same variable names as CI: `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN`. The start hook runs `docker login` after `dockerd` is ready. Mark `DOCKERHUB_TOKEN` as **redacted** in the dashboard. When both are set, agents can pull the full default `make start-docker` image set without hitting anonymous rate limits.
|
||||
|
|
|
|||
|
|
@ -58,7 +58,8 @@ The Mattermost server is expected at `http://localhost:8065`. The webapp dev ser
|
|||
```
|
||||
|
||||
- When the server starts and `MM_LICENSE` is present in the environment, the server applies that license automatically. If `MM_LICENSE` is not set, starting the server automatically applies an Entry license, which provides nearly all functionality needed for development.
|
||||
- `ENABLED_DOCKER_SERVICES='postgres redis'` avoids optional local-dev services such as Prometheus, Grafana, Loki, Minio, Azurite, and OpenLDAP. This is useful in Cloud when Docker Hub rate limits block the default `make start-docker` dependency set.
|
||||
- When `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` are configured as Cloud Agent secrets, `cloud-agent-start.sh` logs in to Docker Hub and the full default `make start-docker` dependency set can be used without trimming services.
|
||||
- `ENABLED_DOCKER_SERVICES='postgres redis'` avoids optional local-dev services such as Prometheus, Grafana, Loki, Minio, Azurite, and OpenLDAP. Use this fallback when Docker Hub credentials are unavailable and anonymous pulls hit rate limits.
|
||||
- If the first-user signup UI is flaky but the server is already healthy, seed local state with `mmctl` and then log in through the browser:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -34,6 +34,21 @@ ensure_docker_socket_access() {
|
|||
fi
|
||||
}
|
||||
|
||||
docker_login_if_configured() {
|
||||
if [ -z "${DOCKERHUB_USERNAME:-}" ] || [ -z "${DOCKERHUB_TOKEN:-}" ]; then
|
||||
log "Docker Hub credentials not configured; anonymous pulls may hit rate limits."
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Logging in to Docker Hub as ${DOCKERHUB_USERNAME}."
|
||||
if echo "${DOCKERHUB_TOKEN}" | docker login -u "${DOCKERHUB_USERNAME}" --password-stdin >/tmp/docker-login.log 2>&1; then
|
||||
log "Docker Hub login succeeded."
|
||||
else
|
||||
log "Docker Hub login failed; see /tmp/docker-login.log."
|
||||
tail -n 20 /tmp/docker-login.log >&2 || true
|
||||
fi
|
||||
}
|
||||
|
||||
if [ -f /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 >/dev/null 2>&1 || \
|
||||
log "Could not relax AppArmor user namespace restriction; openldap-based tests may need a larger host profile."
|
||||
|
|
@ -44,6 +59,7 @@ ensure_docker_socket_access
|
|||
if docker info >/dev/null 2>&1; then
|
||||
log "Docker is already running."
|
||||
docker compose version
|
||||
docker_login_if_configured
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
|
@ -64,6 +80,7 @@ for _ in {1..60}; do
|
|||
log "Docker is ready."
|
||||
docker version
|
||||
docker compose version
|
||||
docker_login_if_configured
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
|
|
|||
112
.github/e2e-tests-workflows.md
vendored
112
.github/e2e-tests-workflows.md
vendored
|
|
@ -16,25 +16,119 @@ All pipelines follow the **smoke-then-full** pattern: smoke tests run first, ful
|
|||
|
||||
```
|
||||
.github/workflows/
|
||||
├── e2e-tests-ci.yml # PR orchestrator
|
||||
├── e2e-tests-on-merge.yml # Merge orchestrator (master/release branches)
|
||||
├── e2e-tests-on-release.yml # Release cut orchestrator
|
||||
├── e2e-tests-cypress.yml # Shared wrapper: cypress smoke -> full
|
||||
├── e2e-tests-playwright.yml # Shared wrapper: playwright smoke -> full
|
||||
├── e2e-tests-cypress-template.yml # Template: actual cypress test execution
|
||||
└── e2e-tests-playwright-template.yml # Template: actual playwright test execution
|
||||
├── e2e-tests-ci.yml # PR orchestrator
|
||||
├── e2e-tests-on-merge.yml # Merge orchestrator (master/release branches)
|
||||
├── e2e-tests-on-release.yml # Release cut orchestrator
|
||||
├── e2e-tests-cypress.yml # Shared wrapper: routes to v1 or v2 template
|
||||
├── e2e-tests-playwright.yml # Shared wrapper: routes to v1 or v2 template
|
||||
├── e2e-tests-cypress-template-v2.yml # Active: cypress + test-system-io dispatch
|
||||
├── e2e-tests-playwright-template-v2.yml # Active: playwright + test-system-io dispatch
|
||||
├── e2e-tests-cypress-template.yml # Deprecated v1 (legacy in-job execution)
|
||||
└── e2e-tests-playwright-template.yml # Deprecated v1 (legacy in-job execution)
|
||||
```
|
||||
|
||||
> **v1 templates are deprecated.** They remain available behind a feature flag during cutover but receive no further changes. New work targets the v2 templates exclusively. The wrappers route by `vars.E2E_USE_TEST_IO_DISPATCH` — `'true'` selects v2, anything else falls back to v1.
|
||||
|
||||
### Call hierarchy
|
||||
|
||||
```
|
||||
e2e-tests-ci.yml ─────────────────┐
|
||||
e2e-tests-on-merge.yml ───────────┤──► e2e-tests-cypress.yml ──► e2e-tests-cypress-template.yml
|
||||
e2e-tests-on-release.yml ─────────┘ e2e-tests-playwright.yml ──► e2e-tests-playwright-template.yml
|
||||
e2e-tests-on-merge.yml ───────────┤──► e2e-tests-cypress.yml ─────┐
|
||||
e2e-tests-on-release.yml ─────────┘ e2e-tests-playwright.yml ──┤
|
||||
│
|
||||
┌──────────────────────────┘
|
||||
│ routes on E2E_USE_TEST_IO_DISPATCH
|
||||
▼
|
||||
v2 (active) ──► e2e-tests-{cypress,playwright}-template-v2.yml
|
||||
v1 (legacy) ──► e2e-tests-{cypress,playwright}-template.yml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Workflow Architecture (v2)
|
||||
|
||||
v2 splits the template into five jobs — `prepare-run`, `prep-deps`, `dispatch-begin`, `workers` (matrix), and `report` — and pushes spec-level execution to [Test System IO](https://github.com/mattermost/mattermost-test-system-io) so workers stay thin and identical.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ Template v2: e2e-tests-{cypress,playwright}-template-v2.yml │
|
||||
│ │
|
||||
│ ┌───────────────────┐ ┌──────────────────────────────┐ │
|
||||
│ │ prepare-run │ │ prep-deps │ │
|
||||
│ │ (1 runner) │ parallel │ (1 runner) │ │
|
||||
│ │ │ ◄────────────► │ │ │
|
||||
│ │ • build workers │ │ Cypress: │ │
|
||||
│ │ matrix [1..N] │ │ • cypress/node_modules │ │
|
||||
│ │ • compute commit │ │ • ~/.cache/Cypress (binary)│ │
|
||||
│ │ status context │ │ │ │
|
||||
│ │ • emit composite │ │ Playwright: │ │
|
||||
│ │ identity │ │ • webapp/platform/{client, │ │
|
||||
│ │ │ │ types}/{lib,node_mod} │ │
|
||||
│ │ │ │ • playwright/node_modules │ │
|
||||
│ │ │ │ • playwright/lib/dist │ │
|
||||
│ │ │ │ • ~/.cache/ms-playwright │ │
|
||||
│ │ │ │ (chromium only) │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ │ → saved to actions/cache │ │
|
||||
│ └─────────┬─────────┘ └───────────────┬──────────────┘ │
|
||||
│ │ │ │
|
||||
│ │ ▼ │
|
||||
│ │ ┌──────────────────────────────┐ │
|
||||
│ │ │ dispatch-begin │ │
|
||||
│ │ │ • register run with │ │
|
||||
│ │ │ Test System IO │ │
|
||||
│ │ │ • runs immediately before │ │
|
||||
│ │ │ workers to minimise the │ │
|
||||
│ │ │ inactivity-timeout window │ │
|
||||
│ │ └───────────────┬──────────────┘ │
|
||||
│ │ │ │
|
||||
│ └────────────────────┬─────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ workers (matrix, fail-fast: false) │ │
|
||||
│ │ Cypress full: N=40 | Playwright full: N=10 │ │
|
||||
│ │ │ │
|
||||
│ │ each worker, in parallel: │ │
|
||||
│ │ 1. sparse-checkout actions + full checkout-repo │ │
|
||||
│ │ 2. setup-node │ │
|
||||
│ │ 3. restore caches ◄─── actions/cache (from prep-deps) │ │
|
||||
│ │ (fail-on-cache-miss: true) │ │
|
||||
│ │ 4. cloud-init + start-server (docker compose stack) │ │
|
||||
│ │ 5. prepare-cypress | prepare-playwright (run setup project) │ │
|
||||
│ │ 6. dispatch-run ──────────────────────────────────┐ │ │
|
||||
│ │ (pulls specs from Test System IO, runs locally, │ │ │
|
||||
│ │ posts result, loops until queue is empty) │ │ │
|
||||
│ │ 7. cloud-teardown │ │ │
|
||||
│ └────────────────────┬────────────────────────────────────┼───────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ │ │
|
||||
│ ┌─────────────────────────────────────────────────┐ │ │
|
||||
│ │ report │ │ │
|
||||
│ │ • pull aggregated results from Test System IO │ ◄────┘ │
|
||||
│ │ • post commit status │ │
|
||||
│ │ • send webhook notification │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────────────────┬─┘
|
||||
│
|
||||
┌───────────────────────────▼───┐
|
||||
│ Test System IO (external) │
|
||||
│ • spec-level dispatch │
|
||||
│ • result aggregation │
|
||||
│ • retry orchestration │
|
||||
└───────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key properties
|
||||
|
||||
- **Spec-level vs. job-level parallelism.** The matrix sizes the runner pool; Test System IO does the spec assignment. Slow specs don't block a worker — fast workers keep pulling the next spec from the queue.
|
||||
- **Cache-only workers.** `prep-deps` installs once per workflow run and saves to `actions/cache`. Every worker restores with `fail-on-cache-miss: true` and runs zero `npm ci`. Eliminates the 40-way `EEXIST/ENOENT` race in npm's shared cacache writer.
|
||||
- **dispatch-begin runs late.** It depends on `prep-deps` so the gap between Test System IO run registration and the first worker calling `dispatch-run` is just per-worker setup (~3–5 min). Registering earlier risks the run timing out before any worker checks in, bulk-failing every spec.
|
||||
- **Playwright slim slice.** Playwright only consumes `@mattermost/client` and `@mattermost/types` from webapp, so prep-deps caches just those two packages' built `lib/` and `node_modules` (~10–30 MB) instead of the full `webapp/node_modules` tree (~1–2 GB).
|
||||
- **Browser/binary caches.** Cypress caches `~/.cache/Cypress` (cypress binary lives outside node_modules); playwright caches `~/.cache/ms-playwright` (chromium only). Both keyed on the framework's lockfile so they invalidate on version bumps.
|
||||
- **No retry plumbing in the template.** Test System IO handles per-spec retries; the workflow only sees aggregated results.
|
||||
|
||||
---
|
||||
|
||||
## Pipeline 1: PR (`e2e-tests-ci.yml`)
|
||||
|
||||
Runs E2E tests for every PR commit after the enterprise docker image is built. Fails if the commit is not associated with an open PR.
|
||||
|
|
|
|||
609
.github/scripts/check_config_changes_ci.py
vendored
Normal file
609
.github/scripts/check_config_changes_ci.py
vendored
Normal file
|
|
@ -0,0 +1,609 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
.github/scripts/check_config_changes_ci.py
|
||||
|
||||
CI script that detects notable changes across several Mattermost source files
|
||||
and appends structured release-note entries to the PR description.
|
||||
|
||||
Checkers
|
||||
────────
|
||||
1. config.go — exported struct field additions/removals
|
||||
2. api4/ — API endpoint additions/removals (Handle() calls)
|
||||
3. audit_events.go — AuditEvent* constant additions/removals
|
||||
4. Dockerfile.buildenv — Go (base-image) version changes
|
||||
|
||||
All inputs come from environment variables set by the GitHub Actions workflow:
|
||||
GITHUB_TOKEN — built-in Actions token (pull-requests: write scope)
|
||||
PR_NUMBER — pull request number
|
||||
BASE_SHA — base commit SHA
|
||||
HEAD_SHA — head commit SHA
|
||||
REPO — owner/repo (e.g. mattermost/mattermost)
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import subprocess
|
||||
import requests
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
# ── Environment ────────────────────────────────────────────────────────────────
|
||||
|
||||
GITHUB_TOKEN = os.environ["GITHUB_TOKEN"]
|
||||
PR_NUMBER = int(os.environ["PR_NUMBER"])
|
||||
BASE_SHA = os.environ["BASE_SHA"]
|
||||
HEAD_SHA = os.environ["HEAD_SHA"]
|
||||
REPO = os.environ.get("REPO", "mattermost/mattermost")
|
||||
|
||||
BASE_URL = "https://api.github.com"
|
||||
HEADERS = {
|
||||
"Authorization": f"token {GITHUB_TOKEN}",
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
}
|
||||
|
||||
# Timeout for all GitHub API requests: (connect seconds, read seconds).
|
||||
# Prevents the workflow from hanging indefinitely on a slow/unresponsive API.
|
||||
_TIMEOUT = (5, 30)
|
||||
|
||||
# Paths watched by this script (must align with `paths:` in the workflow YAML)
|
||||
WATCHED_PATHS = [
|
||||
"server/public/model/config.go",
|
||||
"server/channels/api4/",
|
||||
"server/public/model/audit_events.go",
|
||||
"server/build/Dockerfile.buildenv",
|
||||
]
|
||||
|
||||
|
||||
# ── Data types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class CheckResult:
|
||||
"""Holds the findings from one checker."""
|
||||
label: str # Section heading, e.g. "`config.json` Changes"
|
||||
additions: list = field(default_factory=list)
|
||||
removals: list = field(default_factory=list)
|
||||
changes: list = field(default_factory=list) # for free-form entries (version bumps)
|
||||
|
||||
def has_findings(self) -> bool:
|
||||
return bool(self.additions or self.removals or self.changes)
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
lines = [f"### {self.label}"]
|
||||
if self.additions:
|
||||
lines.append("**Added:** " + ", ".join(self.additions))
|
||||
if self.removals:
|
||||
lines.append("**Removed:** " + ", ".join(self.removals))
|
||||
for change in self.changes:
|
||||
lines.append(change)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── Diff helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def get_full_patch() -> str:
|
||||
"""Return unified diff for all watched paths between base and head."""
|
||||
result = subprocess.run(
|
||||
["git", "diff", f"{BASE_SHA}...{HEAD_SHA}", "--"] + WATCHED_PATHS,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def split_patch_by_file(full_patch: str) -> dict[str, str]:
|
||||
"""
|
||||
Split a multi-file unified diff into {filename: patch} mapping.
|
||||
Filenames are the b-side (new) path, stripped of the 'b/' prefix.
|
||||
"""
|
||||
patches: dict[str, str] = {}
|
||||
current_file: Optional[str] = None
|
||||
current_lines: list[str] = []
|
||||
|
||||
for line in full_patch.splitlines(keepends=True):
|
||||
if line.startswith("diff --git "):
|
||||
if current_file:
|
||||
patches[current_file] = "".join(current_lines)
|
||||
current_lines = [line]
|
||||
# Extract filename from "diff --git a/foo b/foo"
|
||||
m = re.search(r" b/(.+)$", line.rstrip())
|
||||
current_file = m.group(1) if m else None
|
||||
else:
|
||||
current_lines.append(line)
|
||||
|
||||
if current_file:
|
||||
patches[current_file] = "".join(current_lines)
|
||||
|
||||
return patches
|
||||
|
||||
|
||||
def file_at(ref: str, path: str) -> str:
|
||||
"""Return the full contents of `path` at git ref `ref`, or '' if absent."""
|
||||
try:
|
||||
return subprocess.run(
|
||||
["git", "show", f"{ref}:{path}"],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout
|
||||
except subprocess.CalledProcessError:
|
||||
return ""
|
||||
|
||||
|
||||
def _compute_merge_base() -> str:
|
||||
"""Resolve the merge-base of BASE_SHA and HEAD_SHA.
|
||||
|
||||
Per-checker comparisons must use this rather than BASE_SHA. BASE_SHA is the
|
||||
tip of the target branch at PR-event time; if that branch advances on a
|
||||
watched file after the PR diverges, comparing branch-tip vs target-tip
|
||||
would attribute those upstream edits to this PR (false add/remove).
|
||||
`git diff A...B` already does this implicitly; the per-file snapshots must
|
||||
match.
|
||||
"""
|
||||
return subprocess.run(
|
||||
["git", "merge-base", BASE_SHA, HEAD_SHA],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
MERGE_BASE = _compute_merge_base()
|
||||
|
||||
|
||||
# ── Checker 1 — config.go ──────────────────────────────────────────────────────
|
||||
|
||||
_CONFIG_PATH = "server/public/model/config.go"
|
||||
_STRUCT_DECL_RE = re.compile(r"^type\s+(\w+)\s+struct\s*\{")
|
||||
_FIELD_LINE_RE = re.compile(r"^\t([A-Z][A-Za-z0-9_]*)\s+\S")
|
||||
|
||||
|
||||
def _scan_struct_fields(src: str) -> set[tuple[str, str]]:
|
||||
"""
|
||||
Walk Go source and return {(StructName, FieldName)} for every exported
|
||||
field in every struct.
|
||||
|
||||
Uses a brace-depth stack so nested anonymous structs, interface bodies,
|
||||
and function literals don't corrupt the enclosing struct context.
|
||||
Named type declarations cannot be nested in Go, so the struct_stack
|
||||
never grows beyond one entry for named structs.
|
||||
"""
|
||||
fields: set[tuple[str, str]] = set()
|
||||
# Each entry: (struct_name, brace_depth_when_opened)
|
||||
struct_stack: list[tuple[str, int]] = []
|
||||
depth = 0
|
||||
|
||||
for line in src.splitlines():
|
||||
sm = _STRUCT_DECL_RE.match(line)
|
||||
if sm:
|
||||
# Record depth *before* counting this line's braces
|
||||
struct_stack.append((sm.group(1), depth))
|
||||
|
||||
depth += line.count("{") - line.count("}")
|
||||
|
||||
# Pop any structs whose closing brace has been passed
|
||||
while struct_stack and depth <= struct_stack[-1][1]:
|
||||
struct_stack.pop()
|
||||
|
||||
# Record fields only when we're directly inside exactly one named struct
|
||||
if len(struct_stack) == 1:
|
||||
fm = _FIELD_LINE_RE.match(line)
|
||||
if fm:
|
||||
fields.add((struct_stack[0][0], fm.group(1)))
|
||||
|
||||
return fields
|
||||
|
||||
|
||||
def check_config(patches: dict[str, str]) -> CheckResult:
|
||||
"""
|
||||
Detect exported Go struct field additions/removals in config.go.
|
||||
|
||||
Compares full-file snapshots at BASE_SHA and HEAD_SHA so that fields
|
||||
are always attributed to the correct struct regardless of which diff
|
||||
hunks are present.
|
||||
"""
|
||||
result = CheckResult(label="`config.json` Field Changes")
|
||||
if _CONFIG_PATH not in patches:
|
||||
return result
|
||||
|
||||
base_fields = _scan_struct_fields(file_at(MERGE_BASE, _CONFIG_PATH))
|
||||
head_fields = _scan_struct_fields(file_at(HEAD_SHA, _CONFIG_PATH))
|
||||
|
||||
added = head_fields - base_fields
|
||||
removed = base_fields - head_fields
|
||||
|
||||
result.additions = sorted(f"`{s}.{f}`" for s, f in added)
|
||||
result.removals = sorted(f"`{s}.{f}`" for s, f in removed)
|
||||
return result
|
||||
|
||||
|
||||
# ── Checker 2 — api4/ ─────────────────────────────────────────────────────────
|
||||
|
||||
# Matches Handle() route registrations after whitespace-collapsing the source.
|
||||
# Whitespace collapse makes multi-line declarations single-searchable.
|
||||
# Group 1: path Group 2: handler func Group 3: raw Methods(...) content
|
||||
#
|
||||
# The wrapper pattern uses [^)]* so it tolerates any middleware arguments
|
||||
# (e.g. r.APIHandler(...), r.ApiSessionRequired(..., isLocal=true), etc.)
|
||||
# without having to enumerate every possible wrapper signature.
|
||||
_HANDLE_RE = re.compile(
|
||||
r'\.Handle\("([^"]*)"' # path
|
||||
r',\s*[^)]*\((\w+)\)\)' # wrapper(...handlerFunc))
|
||||
r'\.Methods\(([^)]+)\)', # .Methods(one or more methods)
|
||||
)
|
||||
|
||||
_METHOD_RE = re.compile(r'(?:http\.Method)?(\w+)')
|
||||
_HTTP_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}
|
||||
|
||||
|
||||
def _parse_methods(raw: str) -> list[str]:
|
||||
"""Split raw Methods(...) content into individual uppercase HTTP verbs.
|
||||
|
||||
Filters against the set of known HTTP methods so that incidental
|
||||
identifiers (handler names, constants, etc.) that happen to appear
|
||||
inside Methods(...) don't produce spurious results.
|
||||
"""
|
||||
return [
|
||||
verb
|
||||
for token in raw.split(",")
|
||||
if (m := _METHOD_RE.search(token.strip()))
|
||||
and (verb := m.group(1).upper()) in _HTTP_METHODS
|
||||
]
|
||||
|
||||
|
||||
def _format_endpoint(path: str, handler: str, method: str) -> str:
|
||||
return f"`{method.upper()} {path or '/'}` (`{handler}`)"
|
||||
|
||||
|
||||
def _parse_endpoints(src: str) -> set[tuple[str, str, str]]:
|
||||
"""
|
||||
Parse Handle() registrations from a Go source file.
|
||||
|
||||
Whitespace-collapses the entire file first so multi-line declarations
|
||||
(e.g. the 18 in group.go) are matched as a single token sequence.
|
||||
Returns {(path, handler, method)} tuples.
|
||||
"""
|
||||
blob = " ".join(src.split())
|
||||
endpoints: set[tuple[str, str, str]] = set()
|
||||
for m in _HANDLE_RE.finditer(blob):
|
||||
path, handler, methods_raw = m.group(1), m.group(2), m.group(3)
|
||||
for method in _parse_methods(methods_raw):
|
||||
endpoints.add((path or "/", handler, method))
|
||||
return endpoints
|
||||
|
||||
|
||||
def check_api(patches: dict[str, str]) -> CheckResult:
|
||||
"""
|
||||
Detect API endpoint additions/removals in the api4/ directory.
|
||||
|
||||
Compares full-file snapshots at BASE_SHA and HEAD_SHA via set arithmetic,
|
||||
so multi-line and multi-method registrations are handled correctly.
|
||||
"""
|
||||
result = CheckResult(label="API Changes (`api4`)")
|
||||
|
||||
api4_patches = {
|
||||
fname: patch
|
||||
for fname, patch in patches.items()
|
||||
if fname.startswith("server/channels/api4/") and fname.endswith(".go")
|
||||
}
|
||||
if not api4_patches:
|
||||
return result
|
||||
|
||||
added_eps: set[tuple[str, str, str]] = set()
|
||||
removed_eps: set[tuple[str, str, str]] = set()
|
||||
|
||||
for fname, patch in api4_patches.items():
|
||||
base_eps = _parse_endpoints(file_at(MERGE_BASE, fname))
|
||||
head_eps = _parse_endpoints(file_at(HEAD_SHA, fname))
|
||||
added_eps |= head_eps - base_eps
|
||||
removed_eps |= base_eps - head_eps
|
||||
|
||||
# Anchor the check to avoid false positives from unrelated source text
|
||||
if re.search(r"^new file mode \d+", patch, re.MULTILINE):
|
||||
result.changes.append(f"🆕 New API file: `{fname.split('/')[-1]}`")
|
||||
if re.search(r"^deleted file mode \d+", patch, re.MULTILINE):
|
||||
result.changes.append(f"🗑️ Removed API file: `{fname.split('/')[-1]}`")
|
||||
|
||||
result.additions = sorted(_format_endpoint(p, h, m) for p, h, m in added_eps)
|
||||
result.removals = sorted(_format_endpoint(p, h, m) for p, h, m in removed_eps)
|
||||
return result
|
||||
|
||||
|
||||
# ── Checker 3 — audit_events.go ───────────────────────────────────────────────
|
||||
|
||||
_AUDIT_EVENT_PATH = "server/public/model/audit_events.go"
|
||||
_AUDIT_CONST_RE = re.compile(r"^\t(AuditEvent\w+)\s*=")
|
||||
|
||||
|
||||
def _parse_audit_events(src: str) -> set[str]:
|
||||
return {m.group(1) for line in src.splitlines() if (m := _AUDIT_CONST_RE.match(line))}
|
||||
|
||||
|
||||
def check_audit_events(patches: dict[str, str]) -> CheckResult:
|
||||
"""
|
||||
Detect AuditEvent* constant additions/removals.
|
||||
|
||||
Uses full-file snapshots at BASE_SHA/HEAD_SHA so reorderings and
|
||||
cross-constant name collisions don't produce false results.
|
||||
"""
|
||||
result = CheckResult(label="Audit Log Event Changes")
|
||||
if _AUDIT_EVENT_PATH not in patches:
|
||||
return result
|
||||
|
||||
base_events = _parse_audit_events(file_at(MERGE_BASE, _AUDIT_EVENT_PATH))
|
||||
head_events = _parse_audit_events(file_at(HEAD_SHA, _AUDIT_EVENT_PATH))
|
||||
|
||||
result.additions = sorted(f"`{e}`" for e in head_events - base_events)
|
||||
result.removals = sorted(f"`{e}`" for e in base_events - head_events)
|
||||
return result
|
||||
|
||||
|
||||
# ── Checker 4 — Dockerfile.buildenv (Go version) ──────────────────────────────
|
||||
|
||||
# The Go version lives in the base image tag, e.g.:
|
||||
# FROM mattermost/golang-bullseye:1.25.8@sha256:...
|
||||
_DOCKERFILE_PATH = "server/build/Dockerfile.buildenv"
|
||||
_IMAGE_VER_RE = re.compile(r"^FROM \S+:([0-9]+\.[0-9]+(?:\.[0-9]+)?)")
|
||||
|
||||
|
||||
def _parse_go_version(src: str) -> Optional[str]:
|
||||
for line in src.splitlines():
|
||||
m = _IMAGE_VER_RE.match(line.strip())
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def check_go_version(patches: dict[str, str]) -> CheckResult:
|
||||
"""
|
||||
Detect Go runtime version changes via the base image tag.
|
||||
|
||||
Uses full-file snapshots so the version is read from the actual file
|
||||
state at each ref rather than reconstructed from patch lines.
|
||||
"""
|
||||
result = CheckResult(label="Go Runtime Version")
|
||||
if _DOCKERFILE_PATH not in patches:
|
||||
return result
|
||||
|
||||
old_ver = _parse_go_version(file_at(MERGE_BASE, _DOCKERFILE_PATH))
|
||||
new_ver = _parse_go_version(file_at(HEAD_SHA, _DOCKERFILE_PATH))
|
||||
|
||||
if old_ver and new_ver and old_ver != new_ver:
|
||||
result.changes.append(f"Go updated: `{old_ver}` → `{new_ver}`")
|
||||
elif new_ver and not old_ver:
|
||||
result.additions.append(f"`{new_ver}`")
|
||||
return result
|
||||
|
||||
|
||||
# ── PR description helpers ─────────────────────────────────────────────────────
|
||||
|
||||
# Matches lines that were auto-generated by this script so they can be stripped
|
||||
# before re-injecting a fresh set on subsequent commits.
|
||||
_AUTO_LINE_RE = re.compile(
|
||||
r"^(Added|Removed) `[^`]+`.*(configuration setting|API endpoint|audit log event)\."
|
||||
r"|^Go runtime updated from \S+ to \S+\."
|
||||
r"|^Go runtime set to `[^`]+`\."
|
||||
r"|^🆕 New API file:"
|
||||
r"|^🗑️ Removed API file:"
|
||||
)
|
||||
|
||||
# Matches placeholder content inside a release-note fence that means "nothing
|
||||
# to report yet" (e.g. NONE, N/A, ---). When detected, we replace the
|
||||
# placeholder rather than appending alongside it.
|
||||
_PLACEHOLDER_RE = re.compile(r"^\s*(?:NONE|N/?A|-+)\s*$", re.IGNORECASE)
|
||||
|
||||
|
||||
def _format_lines(result: CheckResult) -> list[str]:
|
||||
"""Produce natural-language lines for one checker result."""
|
||||
lines = []
|
||||
|
||||
if "`config.json`" in result.label:
|
||||
for item in result.additions:
|
||||
lines.append(f"Added {item} configuration setting.")
|
||||
for item in result.removals:
|
||||
lines.append(f"Removed {item} configuration setting.")
|
||||
|
||||
elif "API Changes" in result.label:
|
||||
for item in result.additions:
|
||||
lines.append(f"Added {item} API endpoint.")
|
||||
for item in result.removals:
|
||||
lines.append(f"Removed {item} API endpoint.")
|
||||
lines.extend(result.changes) # new/deleted file entries
|
||||
|
||||
elif "Audit" in result.label:
|
||||
for item in result.additions:
|
||||
lines.append(f"Added {item} audit log event.")
|
||||
for item in result.removals:
|
||||
lines.append(f"Removed {item} audit log event.")
|
||||
|
||||
elif "Go Runtime" in result.label:
|
||||
for item in result.additions:
|
||||
# item is e.g. "`1.22`" — strip backticks for the prose form
|
||||
lines.append(f"Go runtime set to {item}.")
|
||||
for c in result.changes:
|
||||
# c arrives as "Go updated: `1.21` → `1.22`" — rewrite it
|
||||
m = re.search(r"`([^`]+)`\s*→\s*`([^`]+)`", c)
|
||||
if m:
|
||||
lines.append(f"Go runtime updated from {m.group(1)} to {m.group(2)}.")
|
||||
else:
|
||||
lines.append(c)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def build_pr_note(results: list[CheckResult]) -> str:
|
||||
"""Assemble all findings into a clean plain-text block."""
|
||||
lines = []
|
||||
for r in results:
|
||||
if r.has_findings():
|
||||
lines.extend(_format_lines(r))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def strip_old_note(body: str) -> str:
|
||||
"""
|
||||
Remove previously auto-generated lines from the PR description.
|
||||
|
||||
Primary path — lines inside the ```release-note ... ``` fence.
|
||||
Fallback path — auto-generated lines that were appended outside any fence
|
||||
(e.g. via the ## Release Notes section on earlier runs).
|
||||
|
||||
Lines are identified by pattern rather than visible markers, so the PR
|
||||
description stays clean for human readers.
|
||||
"""
|
||||
def _clean_fence(m: re.Match) -> str:
|
||||
open_tag, content, close_tag = m.group(1), m.group(2), m.group(3)
|
||||
cleaned_lines = [
|
||||
line for line in content.split("\n")
|
||||
if not _AUTO_LINE_RE.match(line.strip())
|
||||
]
|
||||
return open_tag + "\n".join(cleaned_lines) + close_tag
|
||||
|
||||
cleaned = re.sub(
|
||||
r"(```release-note)(.*?)(```)",
|
||||
_clean_fence,
|
||||
body or "",
|
||||
flags=re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Fallback: strip any auto-generated lines that appear outside a fence
|
||||
# (written by an older version of this script or via the header-inject path).
|
||||
cleaned_lines = [
|
||||
line for line in cleaned.splitlines()
|
||||
if not _AUTO_LINE_RE.match(line.strip())
|
||||
]
|
||||
return "\n".join(cleaned_lines).rstrip()
|
||||
|
||||
|
||||
def inject_note(body: str, note: str) -> str:
|
||||
"""
|
||||
Insert `note` using this priority order:
|
||||
|
||||
1. INSIDE the ```release-note block, before its closing ```
|
||||
(Mattermost convention — keeps everything in one place for reviewers)
|
||||
2. After a recognised release-notes section header (## Release Notes, etc.)
|
||||
3. Fallback: append a new ## Release Notes section at the end
|
||||
"""
|
||||
body = strip_old_note(body)
|
||||
if not note:
|
||||
return body
|
||||
|
||||
# 1. Mattermost-style ```release-note ... ``` block — inject INSIDE the fence.
|
||||
# If the fence currently contains only a placeholder (NONE / N/A / ---),
|
||||
# replace the placeholder rather than appending alongside it.
|
||||
release_note_block = re.search(
|
||||
r"(```release-note)(.*?)(```)",
|
||||
body,
|
||||
flags=re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
if release_note_block:
|
||||
open_tag = release_note_block.group(1)
|
||||
content = release_note_block.group(2) # everything between the fences
|
||||
close_tag = release_note_block.group(3)
|
||||
block_start = release_note_block.start()
|
||||
block_end = release_note_block.end()
|
||||
|
||||
# Strip leading/trailing newlines inside the fence for comparison
|
||||
inner = content.strip()
|
||||
if _PLACEHOLDER_RE.match(inner):
|
||||
# Replace the entire fence with a fresh one
|
||||
new_block = f"{open_tag}\n{note}\n{close_tag}"
|
||||
else:
|
||||
# Append before the closing fence
|
||||
new_block = open_tag + content + note + "\n" + close_tag
|
||||
|
||||
return body[:block_start] + new_block + body[block_end:]
|
||||
|
||||
# 2. Markdown section headers
|
||||
for header in ["## Release Notes", "## Changelog", "## What Changed", "## What's Changed"]:
|
||||
if header.lower() in body.lower():
|
||||
idx = body.lower().index(header.lower()) + len(header)
|
||||
return body[:idx] + "\n\n" + note + body[idx:]
|
||||
|
||||
# 3. Fallback — append
|
||||
return body + "\n\n## Release Notes\n\n" + note
|
||||
|
||||
|
||||
# ── GitHub API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def get_pr_body() -> str:
|
||||
r = requests.get(
|
||||
f"{BASE_URL}/repos/{REPO}/pulls/{PR_NUMBER}",
|
||||
headers=HEADERS,
|
||||
timeout=_TIMEOUT,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json().get("body") or ""
|
||||
|
||||
|
||||
def update_pr_body(new_body: str) -> None:
|
||||
r = requests.patch(
|
||||
f"{BASE_URL}/repos/{REPO}/pulls/{PR_NUMBER}",
|
||||
headers=HEADERS,
|
||||
json={"body": new_body},
|
||||
timeout=_TIMEOUT,
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
|
||||
# ── Main ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
print(f"📋 PR #{PR_NUMBER} | base {BASE_SHA[:8]} → head {HEAD_SHA[:8]}")
|
||||
print("🔍 Collecting diffs …")
|
||||
|
||||
full_patch = get_full_patch()
|
||||
if not full_patch.strip():
|
||||
print("ℹ️ No changes in watched paths. Nothing to do.")
|
||||
return
|
||||
|
||||
patches = split_patch_by_file(full_patch)
|
||||
print(f" {len(patches)} file(s) changed in watched paths.\n")
|
||||
|
||||
# Run all checkers
|
||||
checkers = [
|
||||
check_config,
|
||||
check_api,
|
||||
check_audit_events,
|
||||
check_go_version,
|
||||
]
|
||||
results: list[CheckResult] = [fn(patches) for fn in checkers]
|
||||
|
||||
for r in results:
|
||||
if r.has_findings():
|
||||
print(f" ✅ {r.label}")
|
||||
if r.additions:
|
||||
print(f" Added: {', '.join(r.additions)}")
|
||||
if r.removals:
|
||||
print(f" Removed: {', '.join(r.removals)}")
|
||||
for c in r.changes:
|
||||
print(f" {c}")
|
||||
else:
|
||||
print(f" – {r.label}: no changes")
|
||||
|
||||
note = build_pr_note(results)
|
||||
if not note:
|
||||
print("\nℹ️ No notable changes found across all checkers.")
|
||||
return
|
||||
|
||||
print("\n🔄 Fetching PR description …")
|
||||
body = get_pr_body()
|
||||
new_body = inject_note(body, note)
|
||||
|
||||
if new_body == body:
|
||||
print("ℹ️ PR description already up to date — no changes needed.")
|
||||
return
|
||||
|
||||
update_pr_body(new_body)
|
||||
print(f"✅ PR #{PR_NUMBER} description updated.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"❌ git diff failed:\n{e.stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except requests.HTTPError as e:
|
||||
# Avoid dumping the full response body (can be large / noisy).
|
||||
# status + reason gives enough context for debugging (e.g. "403 Forbidden").
|
||||
reason = e.response.reason or "unknown"
|
||||
print(f"❌ GitHub API error: {e.response.status_code} {reason}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
33
.github/workflows/build-opensearch-image.yml
vendored
33
.github/workflows/build-opensearch-image.yml
vendored
|
|
@ -1,33 +0,0 @@
|
|||
name: Opensearch Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- server/build/Dockerfile.opensearch
|
||||
- .github/workflows/build-opensearch-image.yml
|
||||
|
||||
jobs:
|
||||
build-image:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: opensearch/checkout-repo
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: opensearch/docker-login
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_DEV_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_DEV_TOKEN }}
|
||||
|
||||
- name: opensearch/build-and-push
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
provenance: false
|
||||
file: server/build/Dockerfile.opensearch
|
||||
push: true
|
||||
pull: true
|
||||
tags: mattermostdevelopment/mattermost-opensearch:2.7.0
|
||||
build-args: |
|
||||
OPENSEARCH_VERSION=2.7.0
|
||||
5
.github/workflows/build-server-image.yml
vendored
5
.github/workflows/build-server-image.yml
vendored
|
|
@ -4,6 +4,7 @@ on:
|
|||
push:
|
||||
branches:
|
||||
- master
|
||||
- release-*
|
||||
paths:
|
||||
- server/build/Dockerfile.buildenv
|
||||
- server/build/Dockerfile.buildenv-fips
|
||||
|
|
@ -57,7 +58,7 @@ jobs:
|
|||
echo "GO_VERSION=${GO_VERSION}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: buildenv/push
|
||||
if: github.ref == 'refs/heads/master'
|
||||
if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/release-')
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
provenance: false
|
||||
|
|
@ -103,7 +104,7 @@ jobs:
|
|||
echo "GO_VERSION=${GO_VERSION}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: buildenv/push
|
||||
if: github.ref == 'refs/heads/master'
|
||||
if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/release-')
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
provenance: false
|
||||
|
|
|
|||
66
.github/workflows/config-change-checker.yml
vendored
Normal file
66
.github/workflows/config-change-checker.yml
vendored
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# .github/workflows/config-change-checker.yml
|
||||
#
|
||||
# Automatically detects notable additions/removals across four source files
|
||||
# and appends structured release-note entries to the PR description under
|
||||
# the "## Release Notes" section.
|
||||
#
|
||||
# Tracked files / directories:
|
||||
# • server/public/model/config.go — config struct field changes
|
||||
# • server/channels/api4/ — API endpoint additions/removals
|
||||
# • server/public/model/audit_events.go — audit log event constant changes
|
||||
# • server/build/Dockerfile.buildenv — Go runtime version changes
|
||||
#
|
||||
# No secrets needed — uses the built-in GITHUB_TOKEN.
|
||||
|
||||
name: Config Change Checker
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- 'server/public/model/config.go'
|
||||
- 'server/channels/api4/**'
|
||||
- 'server/public/model/audit_events.go'
|
||||
- 'server/build/Dockerfile.buildenv'
|
||||
|
||||
# Cancel any in-progress run for the same PR when a new commit is pushed.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-release-notes:
|
||||
name: Detect release-note-worthy changes
|
||||
runs-on: ubuntu-latest
|
||||
# Skip bot-authored PRs (Dependabot, mattermost-bot, etc.) — they will
|
||||
# not touch these paths intentionally and cannot receive description updates
|
||||
# via GITHUB_TOKEN anyway (fork-like restrictions apply to most bots).
|
||||
if: github.event.pull_request.user.type != 'Bot'
|
||||
|
||||
permissions:
|
||||
pull-requests: write # needed to update the PR description
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Fetch enough history to diff against the base branch
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install requests==2.32.3 --quiet
|
||||
|
||||
- name: Detect changes and update PR description
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: python3 .github/scripts/check_config_changes_ci.py
|
||||
6
.github/workflows/e2e-tests-check.yml
vendored
6
.github/workflows/e2e-tests-check.yml
vendored
|
|
@ -25,6 +25,12 @@ jobs:
|
|||
cache-dependency-path: |
|
||||
e2e-tests/cypress/package-lock.json
|
||||
e2e-tests/playwright/package-lock.json
|
||||
webapp/package-lock.json
|
||||
- name: ci/npm-cache-verify
|
||||
# Heal any partial/dangling entries left in the restored ~/.npm cache
|
||||
# before running `npm ci`. Avoids the intermittent EEXIST/ENOENT
|
||||
# failures in npm's cacache writer.
|
||||
run: npm cache verify
|
||||
|
||||
# Cypress check
|
||||
- name: ci/cypress/npm-install
|
||||
|
|
|
|||
163
.github/workflows/e2e-tests-cypress-template-v2.yml
vendored
163
.github/workflows/e2e-tests-cypress-template-v2.yml
vendored
|
|
@ -130,35 +130,17 @@ permissions:
|
|||
contents: read
|
||||
statuses: write
|
||||
id-token: write
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
SERVER_IMAGE: "${{ inputs.server_image_repo }}/${{ inputs.server_edition == 'fips' && 'mattermost-enterprise-fips-edition' || inputs.server_edition == 'team' && 'mattermost-team-edition' || 'mattermost-enterprise-edition' }}:${{ inputs.server_image_tag }}"
|
||||
|
||||
jobs:
|
||||
update-initial-status:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
statuses: write
|
||||
steps:
|
||||
- name: ci/set-initial-status
|
||||
uses: mattermost/actions/delivery/update-commit-status@f324ac89b05cc3511cb06e60642ac2fb829f0a63
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
repository_full_name: ${{ github.repository }}
|
||||
commit_sha: ${{ inputs.commit_sha }}
|
||||
context: ${{ inputs.context_name }}
|
||||
description: "tests running, image_tag:${{ inputs.server_image_tag }}${{ inputs.server_image_aliases && format(' ({0})', inputs.server_image_aliases) || '' }}"
|
||||
status: pending
|
||||
|
||||
dispatch-begin:
|
||||
prepare-run:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
outputs:
|
||||
composite-identity-json: ${{ steps.composite-identity.outputs.composite-identity-json }}
|
||||
workers-matrix: ${{ steps.matrix.outputs.workers }}
|
||||
|
|
@ -211,13 +193,62 @@ jobs:
|
|||
run: |
|
||||
echo "workers=$(jq -nc --argjson n ${{ inputs.workers }} '[range(1; $n+1)]')" >> $GITHUB_OUTPUT
|
||||
echo "start_time=$(date +%s)" >> $GITHUB_OUTPUT
|
||||
|
||||
# Install cypress node_modules once, then workers restore from cache.
|
||||
prep-deps:
|
||||
name: prep-deps
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: ci/checkout-repo
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ inputs.commit_sha }}
|
||||
fetch-depth: 1
|
||||
- name: ci/setup-node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
- name: ci/cache-cypress-deps
|
||||
# node_modules + the cypress binary (downloaded to ~/.cache/Cypress by
|
||||
# cypress's postinstall, not into node_modules). Both must be cached;
|
||||
# otherwise workers see "cypress npm package installed but binary missing".
|
||||
id: cache-cypress
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
e2e-tests/cypress/node_modules
|
||||
~/.cache/Cypress
|
||||
key: e2e-cypress-deps-${{ runner.os }}-${{ hashFiles('e2e-tests/cypress/package-lock.json') }}
|
||||
- name: ci/install-cypress-deps
|
||||
if: steps.cache-cypress.outputs.cache-hit != 'true'
|
||||
working-directory: e2e-tests/cypress
|
||||
run: npm ci
|
||||
|
||||
# Register the Test System IO run AFTER prep-deps so workers reach
|
||||
# dispatch-run within Test System IO's inactivity window.
|
||||
dispatch-begin:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [prepare-run, prep-deps]
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
statuses: write
|
||||
steps:
|
||||
- name: ci/checkout-repo
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ inputs.commit_sha }}
|
||||
fetch-depth: 1
|
||||
- name: ci/dispatch-begin
|
||||
uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-dispatch-begin@main
|
||||
uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-dispatch-begin@a2ea7f005484c28fedf51e16645f6d3bd683fd63 # 2026-05-16
|
||||
with:
|
||||
use-staging: ${{ vars.E2E_USE_STAGING_TEST_IO_URL != 'false' }}
|
||||
framework: cypress
|
||||
repo-dir: ${{ github.workspace }}
|
||||
composite-identity: ${{ steps.composite-identity.outputs.composite-identity-json }}
|
||||
composite-identity: ${{ needs.prepare-run.outputs.composite-identity-json }}
|
||||
total-reports-expected: ${{ inputs.workers }}
|
||||
retest-on-fail: ${{ inputs.retest_on_fail }}
|
||||
cypress-stage: ${{ inputs.cypress_stage }}
|
||||
|
|
@ -226,25 +257,25 @@ jobs:
|
|||
cypress-skip-on: ${{ inputs.cypress_skip_on }}
|
||||
cypress-sort-first: ${{ inputs.cypress_sort_first }}
|
||||
cypress-sort-last: ${{ inputs.cypress_sort_last }}
|
||||
post-pr-comment: 'true'
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
context-name: ${{ inputs.context_name }}
|
||||
server-image: ${{ env.SERVER_IMAGE }}
|
||||
commit-status-context: ${{ inputs.context_name }}
|
||||
image-tag: ${{ inputs.server_image_tag }}
|
||||
image-aliases: ${{ inputs.server_image_aliases }}
|
||||
|
||||
workers:
|
||||
name: dispatch-run-${{ matrix.worker_index }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
needs: dispatch-begin
|
||||
needs: [prepare-run, dispatch-begin]
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
worker_index: ${{ fromJSON(needs.dispatch-begin.outputs.workers-matrix) }}
|
||||
worker_index: ${{ fromJSON(needs.prepare-run.outputs.workers-matrix) }}
|
||||
env:
|
||||
COMPOSITE_IDENTITY: ${{ needs.dispatch-begin.outputs.composite-identity-json }}
|
||||
COMPOSITE_IDENTITY: ${{ needs.prepare-run.outputs.composite-identity-json }}
|
||||
SERVER: "${{ inputs.server }}"
|
||||
MM_LICENSE: "${{ secrets.MM_LICENSE }}"
|
||||
ENABLED_DOCKER_SERVICES: "${{ inputs.enabled_docker_services }}"
|
||||
|
|
@ -278,29 +309,26 @@ jobs:
|
|||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: npm
|
||||
cache-dependency-path: "e2e-tests/cypress/package-lock.json"
|
||||
- name: ci/get-webapp-node-modules
|
||||
working-directory: webapp
|
||||
run: make node_modules
|
||||
- name: ci/restore-cypress-deps
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
e2e-tests/cypress/node_modules
|
||||
~/.cache/Cypress
|
||||
key: e2e-cypress-deps-${{ runner.os }}-${{ hashFiles('e2e-tests/cypress/package-lock.json') }}
|
||||
fail-on-cache-miss: true
|
||||
- name: ci/cloud-init
|
||||
working-directory: e2e-tests
|
||||
run: make cloud-init
|
||||
- name: ci/start-server
|
||||
working-directory: e2e-tests
|
||||
run: make start-server
|
||||
# `npm ci` in the host context replaces the container-built native
|
||||
# binaries with host-built ones, since the dispatch adapter spawns
|
||||
# `npx cypress run` on the host.
|
||||
- name: ci/prepare-cypress
|
||||
working-directory: e2e-tests/cypress
|
||||
run: npm ci
|
||||
- name: ci/dispatch-run
|
||||
uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-dispatch-run@main
|
||||
uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-dispatch-run@a2ea7f005484c28fedf51e16645f6d3bd683fd63 # 2026-05-16
|
||||
with:
|
||||
use-staging: ${{ vars.E2E_USE_STAGING_TEST_IO_URL != 'false' }}
|
||||
framework: cypress
|
||||
composite-identity: ${{ needs.dispatch-begin.outputs.composite-identity-json }}
|
||||
composite-identity: ${{ needs.prepare-run.outputs.composite-identity-json }}
|
||||
repo-dir: ${{ github.workspace }}
|
||||
artifacts-root: ${{ github.workspace }}/worker-artifacts
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -324,12 +352,12 @@ jobs:
|
|||
|
||||
report:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [dispatch-begin, workers]
|
||||
needs: [prepare-run, dispatch-begin, workers]
|
||||
if: always()
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
outputs:
|
||||
commit_status_description: ${{ steps.summary.outputs.commit_status_description }}
|
||||
webhook_payload: ${{ steps.summary.outputs.webhook_payload }}
|
||||
|
|
@ -339,10 +367,10 @@ jobs:
|
|||
- name: ci/run-summary
|
||||
id: summary
|
||||
continue-on-error: true
|
||||
uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-summary@main
|
||||
uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-summary@a2ea7f005484c28fedf51e16645f6d3bd683fd63 # 2026-05-16
|
||||
with:
|
||||
use-staging: ${{ vars.E2E_USE_STAGING_TEST_IO_URL != 'false' }}
|
||||
composite-identity: ${{ needs.dispatch-begin.outputs.composite-identity-json }}
|
||||
composite-identity: ${{ needs.prepare-run.outputs.composite-identity-json }}
|
||||
framework: cypress
|
||||
report-type: ${{ inputs.report_type }}
|
||||
image-tag: ${{ inputs.server_image_tag }}
|
||||
|
|
@ -350,8 +378,7 @@ jobs:
|
|||
server-image: ${{ env.SERVER_IMAGE }}
|
||||
pr-number: ${{ inputs.pr_number }}
|
||||
ref-branch: ${{ inputs.ref_branch }}
|
||||
context-name: ${{ inputs.context_name }}
|
||||
post-pr-comment: 'true'
|
||||
commit-status-context: ${{ inputs.context_name }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: ci/publish-webhook
|
||||
if: inputs.enable_reporting && env.REPORT_WEBHOOK_URL != ''
|
||||
|
|
@ -365,45 +392,3 @@ jobs:
|
|||
SUMMARY_OUTCOME: ${{ steps.summary.outcome }}
|
||||
run: |
|
||||
[ "$SUMMARY_OUTCOME" = "success" ]
|
||||
|
||||
update-success-status:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
statuses: write
|
||||
if: always() && needs.report.result == 'success'
|
||||
needs:
|
||||
- dispatch-begin
|
||||
- report
|
||||
steps:
|
||||
- uses: mattermost/actions/delivery/update-commit-status@f324ac89b05cc3511cb06e60642ac2fb829f0a63
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
repository_full_name: ${{ github.repository }}
|
||||
commit_sha: ${{ inputs.commit_sha }}
|
||||
context: ${{ inputs.context_name }}
|
||||
description: ${{ needs.report.outputs.commit_status_description }}
|
||||
status: success
|
||||
target_url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
|
||||
update-failure-status:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
statuses: write
|
||||
if: always() && needs.report.result != 'success'
|
||||
needs:
|
||||
- dispatch-begin
|
||||
- report
|
||||
steps:
|
||||
- uses: mattermost/actions/delivery/update-commit-status@f324ac89b05cc3511cb06e60642ac2fb829f0a63
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
repository_full_name: ${{ github.repository }}
|
||||
commit_sha: ${{ inputs.commit_sha }}
|
||||
context: ${{ inputs.context_name }}
|
||||
description: ${{ needs.report.outputs.commit_status_description }}
|
||||
status: failure
|
||||
target_url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
|
|
|
|||
|
|
@ -96,35 +96,17 @@ permissions:
|
|||
contents: read
|
||||
statuses: write
|
||||
id-token: write
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
SERVER_IMAGE: "${{ inputs.server_image_repo }}/${{ inputs.server_edition == 'fips' && 'mattermost-enterprise-fips-edition' || inputs.server_edition == 'team' && 'mattermost-team-edition' || 'mattermost-enterprise-edition' }}:${{ inputs.server_image_tag }}"
|
||||
|
||||
jobs:
|
||||
update-initial-status:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
statuses: write
|
||||
steps:
|
||||
- name: ci/set-initial-status
|
||||
uses: mattermost/actions/delivery/update-commit-status@f324ac89b05cc3511cb06e60642ac2fb829f0a63
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
repository_full_name: ${{ github.repository }}
|
||||
commit_sha: ${{ inputs.commit_sha }}
|
||||
context: ${{ inputs.context_name }}
|
||||
description: "tests running, image_tag:${{ inputs.server_image_tag }}${{ inputs.server_image_aliases && format(' ({0})', inputs.server_image_aliases) || '' }}"
|
||||
status: pending
|
||||
|
||||
dispatch-begin:
|
||||
prepare-run:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
outputs:
|
||||
composite-identity-json: ${{ steps.composite-identity.outputs.composite-identity-json }}
|
||||
workers-matrix: ${{ steps.matrix.outputs.workers }}
|
||||
|
|
@ -176,35 +158,126 @@ jobs:
|
|||
run: |
|
||||
echo "workers=$(jq -nc --argjson n ${{ inputs.workers }} '[range(1; $n+1)]')" >> $GITHUB_OUTPUT
|
||||
echo "start_time=$(date +%s)" >> $GITHUB_OUTPUT
|
||||
|
||||
# Build @mattermost/client + @mattermost/types and install playwright deps once,
|
||||
# then workers restore from cache. Playwright only consumes those two packages
|
||||
# from webapp, so we cache just their built lib/ instead of all of webapp/node_modules.
|
||||
prep-deps:
|
||||
name: prep-deps
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: ci/checkout-repo
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ inputs.commit_sha }}
|
||||
fetch-depth: 1
|
||||
- name: ci/setup-node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
- name: ci/cache-platform-pkgs
|
||||
# `webapp/node_modules/@mattermost/{client,types}` are the workspace
|
||||
# symlinks Node walks up to find when platform/client requires
|
||||
# @mattermost/types. Without them, module resolution fails inside
|
||||
# the slim slice.
|
||||
id: cache-platform-pkgs
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
webapp/node_modules/@mattermost/client
|
||||
webapp/node_modules/@mattermost/types
|
||||
webapp/platform/client/lib
|
||||
webapp/platform/client/node_modules
|
||||
webapp/platform/types/lib
|
||||
webapp/platform/types/node_modules
|
||||
key: e2e-platform-pkgs-${{ runner.os }}-${{ hashFiles('webapp/package-lock.json', 'webapp/platform/client/src/**', 'webapp/platform/client/tsconfig*.json', 'webapp/platform/types/src/**', 'webapp/platform/types/tsconfig*.json') }}
|
||||
- name: ci/build-platform-pkgs
|
||||
# Full webapp install is needed for tsc + workspace linking; the
|
||||
# postinstall builds platform/{client,types}/lib. We only cache those.
|
||||
if: steps.cache-platform-pkgs.outputs.cache-hit != 'true'
|
||||
working-directory: webapp
|
||||
run: make node_modules
|
||||
- name: ci/cache-playwright-deps
|
||||
# Caches node_modules + the rolled-up @mattermost/playwright-lib dist
|
||||
# so workers don't re-run rollup on every job.
|
||||
id: cache-playwright
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
e2e-tests/playwright/node_modules
|
||||
e2e-tests/playwright/lib/dist
|
||||
e2e-tests/playwright/lib/node_modules
|
||||
key: e2e-playwright-deps-${{ runner.os }}-${{ hashFiles('e2e-tests/playwright/package-lock.json', 'e2e-tests/playwright/lib/src/**', 'e2e-tests/playwright/lib/package.json', 'e2e-tests/playwright/lib/rollup.config.js', 'e2e-tests/playwright/lib/tsconfig.json') }}
|
||||
- name: ci/install-playwright-deps
|
||||
# `npm ci` creates symlinks at node_modules/@mattermost/{client,types}
|
||||
# → webapp/platform/{client,types}; targets must already be built.
|
||||
# The postinstall then builds lib/dist via rollup. Skip browser
|
||||
# download here — chromium is cached separately below.
|
||||
if: steps.cache-playwright.outputs.cache-hit != 'true'
|
||||
working-directory: e2e-tests/playwright
|
||||
env:
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1"
|
||||
run: npm ci
|
||||
- name: ci/cache-playwright-browsers
|
||||
# Cache chromium binary (~150MB) keyed on the playwright lockfile so a
|
||||
# version bump invalidates. Restored by workers; no docker image needed.
|
||||
id: cache-pw-browsers
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('e2e-tests/playwright/package-lock.json') }}
|
||||
- name: ci/install-playwright-chromium
|
||||
if: steps.cache-pw-browsers.outputs.cache-hit != 'true'
|
||||
working-directory: e2e-tests/playwright
|
||||
run: npx playwright install chromium
|
||||
|
||||
# Register the Test System IO run AFTER prep-deps so workers reach
|
||||
# dispatch-run within Test System IO's inactivity window.
|
||||
dispatch-begin:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [prepare-run, prep-deps]
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
statuses: write
|
||||
steps:
|
||||
- name: ci/checkout-repo
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ inputs.commit_sha }}
|
||||
fetch-depth: 1
|
||||
- name: ci/dispatch-begin
|
||||
uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-dispatch-begin@main
|
||||
uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-dispatch-begin@a2ea7f005484c28fedf51e16645f6d3bd683fd63 # 2026-05-16
|
||||
with:
|
||||
use-staging: ${{ vars.E2E_USE_STAGING_TEST_IO_URL != 'false' }}
|
||||
framework: playwright
|
||||
repo-dir: ${{ github.workspace }}
|
||||
composite-identity: ${{ steps.composite-identity.outputs.composite-identity-json }}
|
||||
composite-identity: ${{ needs.prepare-run.outputs.composite-identity-json }}
|
||||
total-reports-expected: ${{ inputs.workers }}
|
||||
retest-on-fail: ${{ inputs.retest_on_fail }}
|
||||
playwright-project: ${{ inputs.playwright_project }}
|
||||
post-pr-comment: 'true'
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
context-name: ${{ inputs.context_name }}
|
||||
server-image: ${{ env.SERVER_IMAGE }}
|
||||
commit-status-context: ${{ inputs.context_name }}
|
||||
image-tag: ${{ inputs.server_image_tag }}
|
||||
image-aliases: ${{ inputs.server_image_aliases }}
|
||||
|
||||
workers:
|
||||
name: dispatch-run-${{ matrix.worker_index }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
needs: dispatch-begin
|
||||
needs: [prepare-run, dispatch-begin]
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
worker_index: ${{ fromJSON(needs.dispatch-begin.outputs.workers-matrix) }}
|
||||
worker_index: ${{ fromJSON(needs.prepare-run.outputs.workers-matrix) }}
|
||||
env:
|
||||
COMPOSITE_IDENTITY: ${{ needs.dispatch-begin.outputs.composite-identity-json }}
|
||||
COMPOSITE_IDENTITY: ${{ needs.prepare-run.outputs.composite-identity-json }}
|
||||
SERVER: "${{ inputs.server }}"
|
||||
MM_LICENSE: "${{ secrets.MM_LICENSE }}"
|
||||
ENABLED_DOCKER_SERVICES: "${{ inputs.enabled_docker_services }}"
|
||||
|
|
@ -232,31 +305,53 @@ jobs:
|
|||
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/restore-platform-pkgs
|
||||
# Built lib/ for @mattermost/client and @mattermost/types, plus the
|
||||
# webapp workspace symlinks under webapp/node_modules/@mattermost/.
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
webapp/node_modules/@mattermost/client
|
||||
webapp/node_modules/@mattermost/types
|
||||
webapp/platform/client/lib
|
||||
webapp/platform/client/node_modules
|
||||
webapp/platform/types/lib
|
||||
webapp/platform/types/node_modules
|
||||
key: e2e-platform-pkgs-${{ runner.os }}-${{ hashFiles('webapp/package-lock.json', 'webapp/platform/client/src/**', 'webapp/platform/client/tsconfig*.json', 'webapp/platform/types/src/**', 'webapp/platform/types/tsconfig*.json') }}
|
||||
fail-on-cache-miss: true
|
||||
- name: ci/restore-playwright-deps
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
e2e-tests/playwright/node_modules
|
||||
e2e-tests/playwright/lib/dist
|
||||
e2e-tests/playwright/lib/node_modules
|
||||
key: e2e-playwright-deps-${{ runner.os }}-${{ hashFiles('e2e-tests/playwright/package-lock.json', 'e2e-tests/playwright/lib/src/**', 'e2e-tests/playwright/lib/package.json', 'e2e-tests/playwright/lib/rollup.config.js', 'e2e-tests/playwright/lib/tsconfig.json') }}
|
||||
fail-on-cache-miss: true
|
||||
- name: ci/restore-playwright-browsers
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('e2e-tests/playwright/package-lock.json') }}
|
||||
fail-on-cache-miss: true
|
||||
- name: ci/cloud-init
|
||||
working-directory: e2e-tests
|
||||
run: make cloud-init
|
||||
- name: ci/start-server
|
||||
working-directory: e2e-tests
|
||||
run: make start-server
|
||||
# Build once + run the `setup` project so per-spec dispatches can
|
||||
# pass --no-deps and skip plugin-load + server-deployment checks.
|
||||
# Run the `setup` project so per-spec dispatches can pass --no-deps
|
||||
# and skip plugin-load + server-deployment checks. node_modules,
|
||||
# lib/dist, and chromium are all restored from cache.
|
||||
- name: ci/prepare-playwright
|
||||
working-directory: e2e-tests/playwright
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
npx playwright test --project=setup
|
||||
run: npx playwright test --project=setup
|
||||
- name: ci/dispatch-run
|
||||
uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-dispatch-run@main
|
||||
uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-dispatch-run@a2ea7f005484c28fedf51e16645f6d3bd683fd63 # 2026-05-16
|
||||
with:
|
||||
use-staging: ${{ vars.E2E_USE_STAGING_TEST_IO_URL != 'false' }}
|
||||
framework: playwright
|
||||
composite-identity: ${{ needs.dispatch-begin.outputs.composite-identity-json }}
|
||||
composite-identity: ${{ needs.prepare-run.outputs.composite-identity-json }}
|
||||
repo-dir: ${{ github.workspace }}
|
||||
artifacts-root: ${{ github.workspace }}/worker-artifacts
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -281,12 +376,12 @@ jobs:
|
|||
|
||||
report:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [dispatch-begin, workers]
|
||||
needs: [prepare-run, dispatch-begin, workers]
|
||||
if: always()
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
outputs:
|
||||
commit_status_description: ${{ steps.summary.outputs.commit_status_description }}
|
||||
webhook_payload: ${{ steps.summary.outputs.webhook_payload }}
|
||||
|
|
@ -296,10 +391,10 @@ jobs:
|
|||
- name: ci/run-summary
|
||||
id: summary
|
||||
continue-on-error: true
|
||||
uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-summary@main
|
||||
uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-summary@a2ea7f005484c28fedf51e16645f6d3bd683fd63 # 2026-05-16
|
||||
with:
|
||||
use-staging: ${{ vars.E2E_USE_STAGING_TEST_IO_URL != 'false' }}
|
||||
composite-identity: ${{ needs.dispatch-begin.outputs.composite-identity-json }}
|
||||
composite-identity: ${{ needs.prepare-run.outputs.composite-identity-json }}
|
||||
framework: playwright
|
||||
report-type: ${{ inputs.report_type }}
|
||||
image-tag: ${{ inputs.server_image_tag }}
|
||||
|
|
@ -307,8 +402,7 @@ jobs:
|
|||
server-image: ${{ env.SERVER_IMAGE }}
|
||||
pr-number: ${{ inputs.pr_number }}
|
||||
ref-branch: ${{ inputs.ref_branch }}
|
||||
context-name: ${{ inputs.context_name }}
|
||||
post-pr-comment: 'true'
|
||||
commit-status-context: ${{ inputs.context_name }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: ci/publish-webhook
|
||||
if: inputs.enable_reporting && env.REPORT_WEBHOOK_URL != ''
|
||||
|
|
@ -322,45 +416,3 @@ jobs:
|
|||
SUMMARY_OUTCOME: ${{ steps.summary.outcome }}
|
||||
run: |
|
||||
[ "$SUMMARY_OUTCOME" = "success" ]
|
||||
|
||||
update-success-status:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
statuses: write
|
||||
if: always() && needs.report.result == 'success'
|
||||
needs:
|
||||
- dispatch-begin
|
||||
- report
|
||||
steps:
|
||||
- uses: mattermost/actions/delivery/update-commit-status@f324ac89b05cc3511cb06e60642ac2fb829f0a63
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
repository_full_name: ${{ github.repository }}
|
||||
commit_sha: ${{ inputs.commit_sha }}
|
||||
context: ${{ inputs.context_name }}
|
||||
description: ${{ needs.report.outputs.commit_status_description }}
|
||||
status: success
|
||||
target_url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
|
||||
update-failure-status:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
statuses: write
|
||||
if: always() && needs.report.result != 'success'
|
||||
needs:
|
||||
- dispatch-begin
|
||||
- report
|
||||
steps:
|
||||
- uses: mattermost/actions/delivery/update-commit-status@f324ac89b05cc3511cb06e60642ac2fb829f0a63
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
repository_full_name: ${{ github.repository }}
|
||||
commit_sha: ${{ inputs.commit_sha }}
|
||||
context: ${{ inputs.context_name }}
|
||||
description: ${{ needs.report.outputs.commit_status_description }}
|
||||
status: failure
|
||||
target_url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
|
|
|
|||
|
|
@ -179,7 +179,14 @@ jobs:
|
|||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: npm
|
||||
cache-dependency-path: "e2e-tests/playwright/package-lock.json"
|
||||
cache-dependency-path: |
|
||||
e2e-tests/playwright/package-lock.json
|
||||
webapp/package-lock.json
|
||||
- name: ci/npm-cache-verify
|
||||
# Heal any partial/dangling entries left in the restored ~/.npm cache
|
||||
# before running `npm ci`. Avoids the intermittent EEXIST/ENOENT
|
||||
# failures in npm's cacache writer.
|
||||
run: npm cache verify
|
||||
- name: ci/get-webapp-node-modules
|
||||
working-directory: webapp
|
||||
run: make node_modules
|
||||
|
|
|
|||
|
|
@ -21,8 +21,7 @@ jobs:
|
|||
github.event.issue.pull_request &&
|
||||
startsWith(github.event.comment.body, '/test-analysis-override') &&
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)
|
||||
# Pin to a commit SHA once the reusable workflow is stable. Using @main during initial rollout.
|
||||
uses: mattermost/mattermost-test-automation-toolkit/.github/workflows/pr-test-analysis-override.yml@main
|
||||
uses: mattermost/mattermost-test-automation-toolkit/.github/workflows/pr-test-analysis-override.yml@93d73f4f101e10dc03c7ed6b76b35eb5ff5babb7 # 2026-05-16
|
||||
with:
|
||||
pr_number: ${{ github.event.issue.number }}
|
||||
target_repo: mattermost/mattermost
|
||||
|
|
|
|||
3
.github/workflows/pr-test-analysis.yml
vendored
3
.github/workflows/pr-test-analysis.yml
vendored
|
|
@ -36,8 +36,7 @@ jobs:
|
|||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.pull_request.draft == false &&
|
||||
github.event.pull_request.head.repo.full_name == 'mattermost/mattermost')
|
||||
# Pin to a commit SHA once the reusable workflow is stable. Using @main during initial rollout.
|
||||
uses: mattermost/mattermost-test-automation-toolkit/.github/workflows/pr-test-analysis.yml@main
|
||||
uses: mattermost/mattermost-test-automation-toolkit/.github/workflows/pr-test-analysis.yml@93d73f4f101e10dc03c7ed6b76b35eb5ff5babb7 # 2026-05-16
|
||||
with:
|
||||
pr_number: ${{ github.event.pull_request.number || inputs.pr_number }}
|
||||
target_repo: mattermost/mattermost
|
||||
|
|
|
|||
29
.github/workflows/server-ci-report.yml
vendored
29
.github/workflows/server-ci-report.yml
vendored
|
|
@ -124,3 +124,32 @@ jobs:
|
|||
repo: context.repo.repo,
|
||||
body: body
|
||||
})
|
||||
|
||||
- name: Report retried tests to flaky-test webhook (pull request)
|
||||
if: >-
|
||||
steps.report.outputs.flaky_summary != '<table><tr><th>Test</th><th>Retries</th></tr></table>'
|
||||
&& steps.report.outputs.failed == '0'
|
||||
&& github.event.workflow_run.event == 'pull_request'
|
||||
&& env.WEBHOOK_URL_FLAKY_TEST != ''
|
||||
&& env.WEBHOOK_AUTH_TOKEN_FLAKY_TEST != ''
|
||||
continue-on-error: true
|
||||
env:
|
||||
WEBHOOK_URL_FLAKY_TEST: ${{ secrets.WEBHOOK_URL_FLAKY_TEST }}
|
||||
WEBHOOK_AUTH_TOKEN_FLAKY_TEST: ${{ secrets.WEBHOOK_AUTH_TOKEN_FLAKY_TEST }}
|
||||
FLAKY_SUMMARY: ${{ steps.report.outputs.flaky_summary }}
|
||||
PR_NUMBER: ${{ steps.incoming-pr.outputs.NUMBER }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg repo "$REPO" \
|
||||
--arg pr_number "$PR_NUMBER" \
|
||||
--arg flaky_summary "$FLAKY_SUMMARY" \
|
||||
'{repo:$repo, pr_number:$pr_number, flaky_summary:$flaky_summary}')
|
||||
|
||||
curl -X POST -fsSL \
|
||||
--connect-timeout 5 \
|
||||
--max-time 30 \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $WEBHOOK_AUTH_TOKEN_FLAKY_TEST" \
|
||||
-d "$PAYLOAD" \
|
||||
"$WEBHOOK_URL_FLAKY_TEST"
|
||||
|
|
|
|||
17
.github/workflows/server-ci.yml
vendored
17
.github/workflows/server-ci.yml
vendored
|
|
@ -5,6 +5,7 @@
|
|||
# If you rename this workflow, be sure to update those workflows as well.
|
||||
name: Server CI
|
||||
on:
|
||||
workflow_dispatch: # Allow manual/API triggering for linked plugin CI
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
|
@ -247,6 +248,7 @@ jobs:
|
|||
artifact-pattern: postgres-server-test-logs-shard-*
|
||||
artifact-name: postgres-server-test-logs
|
||||
save-timing-cache: true
|
||||
all-shards-passed: ${{ needs.test-postgres-normal.result == 'success' }}
|
||||
|
||||
test-elasticsearch-v8:
|
||||
name: Elasticsearch v8 Compatibility
|
||||
|
|
@ -263,6 +265,21 @@ jobs:
|
|||
elasticsearch-version: "8.9.0"
|
||||
test-target: "test-server-elasticsearch"
|
||||
|
||||
test-opensearch-v2:
|
||||
name: OpenSearch v2 Compatibility
|
||||
needs: go
|
||||
uses: ./.github/workflows/server-test-template.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
name: OpenSearch v2 Compatibility
|
||||
datasource: postgres://mmuser:mostest@postgres:5432/mattermost_test?sslmode=disable&connect_timeout=10
|
||||
drivername: postgres
|
||||
logsartifact: opensearch-v2-server-test-logs
|
||||
go-version: ${{ needs.go.outputs.version }}
|
||||
fips-enabled: false
|
||||
opensearch-version: "2.19.0"
|
||||
test-target: "test-server-opensearch"
|
||||
|
||||
# FIPS tests: run on PRs when go.mod changed or branch name contains "fips".
|
||||
# Sharded for fast iteration. Weekly workflow provides regular full coverage.
|
||||
test-postgres-normal-fips:
|
||||
|
|
|
|||
15
.github/workflows/server-test-merge-template.yml
vendored
15
.github/workflows/server-test-merge-template.yml
vendored
|
|
@ -16,6 +16,11 @@ on:
|
|||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
all-shards-passed:
|
||||
description: "Whether every upstream shard succeeded. Used to gate the timing-cache save so a single shard failure doesn't poison the cache with missing-package data."
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
merge:
|
||||
|
|
@ -79,11 +84,17 @@ jobs:
|
|||
echo "has_timing=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Only save when every upstream shard succeeded. If even one shard
|
||||
# failed/was killed, its gotestsum.json is missing and the merged report
|
||||
# has no timings for that shard's packages — saving that would poison
|
||||
# future shard splits (missing packages default to 1ms, all bin-pack
|
||||
# onto the lightest shard, overloading it and repeating the failure).
|
||||
- name: Save test timing cache
|
||||
if: inputs.save-timing-cache && steps.timing-prep.outputs.has_timing == 'true' && github.ref_name == github.event.repository.default_branch
|
||||
if: inputs.save-timing-cache && inputs.all-shards-passed && steps.timing-prep.outputs.has_timing == 'true' && github.ref_name == github.event.repository.default_branch
|
||||
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
server/prev-report.xml
|
||||
server/prev-gotestsum.json
|
||||
key: server-test-timing-master-${{ github.run_id }}
|
||||
# The v2 prefix matches the v2 restore prefix in server-test-template.yml.
|
||||
key: server-test-timing-v2-master-${{ github.run_id }}
|
||||
|
|
|
|||
15
.github/workflows/server-test-template.yml
vendored
15
.github/workflows/server-test-template.yml
vendored
|
|
@ -37,6 +37,10 @@ on:
|
|||
required: false
|
||||
type: string
|
||||
default: "9.0.0"
|
||||
opensearch-version:
|
||||
required: false
|
||||
type: string
|
||||
default: "3.0.0"
|
||||
test-target:
|
||||
required: false
|
||||
type: string
|
||||
|
|
@ -93,9 +97,15 @@ jobs:
|
|||
server/prev-gotestsum.json
|
||||
# Always restore from master — timing is only saved on the default
|
||||
# branch and is stable enough for shard balancing.
|
||||
key: server-test-timing-master
|
||||
# NOTE: the v2 prefix invalidates pre-existing caches that were
|
||||
# poisoned by shard failures (a killed shard loses its gotestsum.json,
|
||||
# so the merged report was missing those packages' timings; on the
|
||||
# next run they all defaulted to 1ms and bin-packed onto the lightest
|
||||
# shard, overloading it and perpetuating the cycle). See also the
|
||||
# all-shards-passed guard in server-test-merge-template.yml.
|
||||
key: server-test-timing-v2-master
|
||||
restore-keys: |
|
||||
server-test-timing-
|
||||
server-test-timing-v2-
|
||||
|
||||
- name: Setup BUILD_IMAGE
|
||||
id: build
|
||||
|
|
@ -116,6 +126,7 @@ jobs:
|
|||
- name: Run docker compose
|
||||
env:
|
||||
ELASTICSEARCH_VERSION: ${{ inputs.elasticsearch-version }}
|
||||
OPENSEARCH_VERSION: ${{ inputs.opensearch-version }}
|
||||
POSTGRES_PASSWORD: ${{ inputs.fips-enabled && 'mostest-fips-test' || 'mostest' }}
|
||||
run: |
|
||||
cd server/build
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -160,6 +160,7 @@ docker-compose.override.yaml
|
|||
.notice-work/
|
||||
.aider*
|
||||
.env
|
||||
.envrc
|
||||
.planning/
|
||||
|
||||
**/CLAUDE.local.md
|
||||
|
|
|
|||
186
NOTICE.txt
186
NOTICE.txt
|
|
@ -2570,6 +2570,78 @@ The above copyright notice and this permission notice shall be included in all c
|
|||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Azure/azure-sdk-for-go
|
||||
|
||||
This product contains 'Azure/azure-sdk-for-go' by Microsoft Azure.
|
||||
|
||||
This repository is for active development of the Azure SDK for Go. For consumers of the SDK we recommend visiting our public developer docs at:
|
||||
|
||||
* HOMEPAGE:
|
||||
* https://docs.microsoft.com/azure/developer/go/
|
||||
|
||||
* LICENSE: MIT License
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Azure/azure-sdk-for-go
|
||||
|
||||
This product contains 'Azure/azure-sdk-for-go' by Microsoft Azure.
|
||||
|
||||
This repository is for active development of the Azure SDK for Go. For consumers of the SDK we recommend visiting our public developer docs at:
|
||||
|
||||
* HOMEPAGE:
|
||||
* https://docs.microsoft.com/azure/developer/go/
|
||||
|
||||
* LICENSE: MIT License
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Go
|
||||
|
|
@ -2846,42 +2918,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|||
THE SOFTWARE.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## anthonynsimon/bild
|
||||
|
||||
This product contains 'anthonynsimon/bild' by anthonynsimon.
|
||||
|
||||
Image processing algorithms in pure Go
|
||||
|
||||
* HOMEPAGE:
|
||||
* https://github.com/anthonynsimon/bild
|
||||
|
||||
* LICENSE: MIT License
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2016-2024 Anthony Simon
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## aws/aws-sdk-go-v2
|
||||
|
|
@ -4074,6 +4110,42 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|||
THE SOFTWARE.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## boxes-ltd/imaging
|
||||
|
||||
This product contains 'boxes-ltd/imaging' by Boxes Ltd.
|
||||
|
||||
Imaging is a simple image processing package for Go
|
||||
|
||||
* HOMEPAGE:
|
||||
* https://github.com/boxes-ltd/imaging
|
||||
|
||||
* LICENSE: MIT License
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2012 Grigory Dryapak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## buffer
|
||||
|
|
@ -5840,6 +5912,48 @@ GoMock is a mocking framework for the Go programming language.
|
|||
limitations under the License.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## google/uuid
|
||||
|
||||
This product contains 'google/uuid' by Google.
|
||||
|
||||
Go package for UUIDs based on RFC 4122 and DCE 1.1: Authentication and Security Services.
|
||||
|
||||
* HOMEPAGE:
|
||||
* https://github.com/google/uuid
|
||||
|
||||
* LICENSE: BSD 3-Clause "New" or "Revised" License
|
||||
|
||||
Copyright (c) 2009,2014 Google Inc. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following disclaimer
|
||||
in the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
* Neither the name of Google Inc. nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## gorilla/handlers
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
module github.com/mattermost/mattermost/api/internal
|
||||
|
||||
go 1.20
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/pb33f/libopenapi v0.9.6
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e
|
||||
github.com/pb33f/libopenapi v0.36.4
|
||||
golang.org/x/tools v0.45.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
||||
golang.org/x/mod v0.3.0 // indirect
|
||||
golang.org/x/net v0.2.0 // indirect
|
||||
golang.org/x/sync v0.1.0 // indirect
|
||||
golang.org/x/sys v0.2.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/buger/jsonparser v1.2.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/pb33f/jsonpath v0.8.2 // indirect
|
||||
github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,137 +1,28 @@
|
|||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/buger/jsonparser v1.2.0 h1:4EFcvK1kD4jyj6YqNK6skK6w+y7FHHBR+XBCtxwu/6g=
|
||||
github.com/buger/jsonparser v1.2.0/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
|
||||
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
||||
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
|
||||
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||
github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw=
|
||||
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
|
||||
github.com/pb33f/libopenapi v0.9.6 h1:PqNqdBk0lqr/luxDLv8HPKFEJ4i0zf/hpyXqQ4r8jbM=
|
||||
github.com/pb33f/libopenapi v0.9.6/go.mod h1:8lr9sjsI5uZxtiEvHgg1A9/p/70briQ5WUGoJiuTFPc=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/pb33f/jsonpath v0.8.2 h1:Ou4C7zjYClBm97dfZjDCjdZGusJoynv/vrtiEKNfj2Y=
|
||||
github.com/pb33f/jsonpath v0.8.2/go.mod h1:zBV5LJW4OQOPatmQE2QdKpGQJvhDTlE5IEj6ASaRNTo=
|
||||
github.com/pb33f/libopenapi v0.36.4 h1:oGDGjHpCyaj55RG0i0TLB3N3MEGIsGsM1aD7iInfZ8A=
|
||||
github.com/pb33f/libopenapi v0.36.4/go.mod h1:MsDdUlQ1CdrIDO5v26JfgBxQs7kcaOUEpMP3EqU6bI4=
|
||||
github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
|
||||
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU=
|
||||
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e h1:4nW4NLDYnU28ojHaHO8OVxFHk/aQ33U01a9cjED+pzE=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import (
|
|||
|
||||
"github.com/pb33f/libopenapi"
|
||||
v3high "github.com/pb33f/libopenapi/datamodel/high/v3"
|
||||
"github.com/pb33f/libopenapi/orderedmap"
|
||||
"go.yaml.in/yaml/v4"
|
||||
"golang.org/x/tools/imports"
|
||||
)
|
||||
|
||||
|
|
@ -52,23 +54,17 @@ func main() {
|
|||
log.Fatalf("Failed to parse OpenAPI spec: %s", err)
|
||||
}
|
||||
|
||||
v3Model, errors := document.BuildV3Model()
|
||||
if len(errors) > 0 {
|
||||
for i := range errors {
|
||||
log.Printf("error: %s\n", errors[i])
|
||||
}
|
||||
log.Fatalf("cannot create v3 model from document: %d errors reported", len(errors))
|
||||
v3Model, err := document.BuildV3Model()
|
||||
if err != nil {
|
||||
log.Fatalf("cannot create v3 model from document: %s", err)
|
||||
}
|
||||
|
||||
applyExamples(v3Model, exampleTmpl)
|
||||
|
||||
// Re-render the file with the injected examples.
|
||||
newDocument, _, _, errors := document.RenderAndReload()
|
||||
if len(errors) > 0 {
|
||||
for _, err := range errors {
|
||||
log.Printf("error: %s\n", err)
|
||||
}
|
||||
log.Fatalf("cannot render document: %d errors reported", len(errors))
|
||||
newDocument, _, _, err := document.RenderAndReload()
|
||||
if err != nil {
|
||||
log.Fatalf("cannot render document: %s", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(filename, newDocument, 0644)
|
||||
|
|
@ -83,7 +79,7 @@ func applyExamples(v3Model *libopenapi.DocumentModel[v3high.Document], tmpl *tem
|
|||
log.Fatalf("Failed to parse example funcs: %s", err)
|
||||
}
|
||||
|
||||
for _, path := range v3Model.Model.Paths.PathItems {
|
||||
for path := range v3Model.Model.Paths.PathItems.ValuesFromOldest() {
|
||||
applyExample(tmpl, fileSet, modelFuncs, path.Get)
|
||||
applyExample(tmpl, fileSet, modelFuncs, path.Post)
|
||||
applyExample(tmpl, fileSet, modelFuncs, path.Delete)
|
||||
|
|
@ -149,15 +145,22 @@ func applyExample(tmpl *template.Template, fileSet *token.FileSet, exampleFuncs
|
|||
}
|
||||
|
||||
// Inject the resulting code sample
|
||||
operation.Extensions["x-codeSamples"] = []struct {
|
||||
Lang string
|
||||
Source string
|
||||
}{
|
||||
{
|
||||
Lang: "Go",
|
||||
Source: string(example),
|
||||
},
|
||||
type codeSample struct {
|
||||
Lang string `yaml:"lang"`
|
||||
Source string `yaml:"source"`
|
||||
}
|
||||
yamlBytes, err := yaml.Marshal([]codeSample{{Lang: "Go", Source: string(example)}})
|
||||
if err != nil {
|
||||
log.Fatalf("failed to marshal x-codeSamples: %v", err)
|
||||
}
|
||||
var samplesNode yaml.Node
|
||||
if err := yaml.Unmarshal(yamlBytes, &samplesNode); err != nil {
|
||||
log.Fatalf("failed to create yaml node for x-codeSamples: %v", err)
|
||||
}
|
||||
if operation.Extensions == nil {
|
||||
operation.Extensions = orderedmap.New[string, *yaml.Node]()
|
||||
}
|
||||
operation.Extensions.Set("x-codeSamples", samplesNode.Content[0])
|
||||
}
|
||||
|
||||
type modelFunc struct {
|
||||
|
|
|
|||
|
|
@ -144,6 +144,57 @@
|
|||
$ref: "#/components/responses/Forbidden"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalServerError"
|
||||
/api/v4/access_control_policies/cel/simulate_users:
|
||||
post:
|
||||
tags:
|
||||
- access control
|
||||
summary: Simulate an access control policy decision for an explicit user list
|
||||
description: |
|
||||
Runs the dual-lane PDP simulation against a draft (unsaved) access
|
||||
control policy for an explicit set of users (with optional per-user
|
||||
session-attribute overrides). The server compiles the draft
|
||||
in-memory, layers on persisted higher-scoped permission policies,
|
||||
and returns per-user, per-action ALLOW/DENY decisions plus blame
|
||||
attribution for any deny.
|
||||
|
||||
Backs the picker-driven "Simulate access" UX in the System Console
|
||||
and Channel Settings so authors can see how a draft interacts with
|
||||
persisted higher-scoped policies before saving.
|
||||
|
||||
Gated by the `PermissionPolicies` feature flag and the Enterprise
|
||||
Advanced license. Returns 501 (Not Implemented) when either is
|
||||
missing.
|
||||
|
||||
##### Permissions
|
||||
Must have the `manage_system` permission, OR be a team admin with
|
||||
`manage_team_access_rules` on the request's `team_id` (when any
|
||||
provided `channel_id` resolves to a channel in that team), OR be a
|
||||
channel admin with `manage_channel_access_rules` on the request's
|
||||
`channel_id`.
|
||||
operationId: SimulateAccessControlPolicyForUsers
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PolicySimulationByUsersParams"
|
||||
responses:
|
||||
"200":
|
||||
description: Per-user, per-action simulation results.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PolicySimulationResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalServerError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplemented"
|
||||
/api/v4/access_control_policies/search:
|
||||
post:
|
||||
tags:
|
||||
|
|
|
|||
|
|
@ -2963,43 +2963,6 @@
|
|||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
"/api/v4/sharedchannels/{channel_id}/remotes":
|
||||
get:
|
||||
tags:
|
||||
- channels
|
||||
summary: Get remote clusters for a shared channel
|
||||
description: |
|
||||
Gets the remote clusters information for a shared channel.
|
||||
|
||||
__Minimum server version__: 10.10
|
||||
|
||||
##### Permissions
|
||||
Must be authenticated and have the `read_channel` permission for the channel.
|
||||
operationId: GetSharedChannelRemotes
|
||||
parameters:
|
||||
- name: channel_id
|
||||
in: path
|
||||
description: Channel GUID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Remote clusters retrieval successful
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/RemoteClusterInfo"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"/api/v4/channels/{channel_id}/common_teams":
|
||||
get:
|
||||
tags:
|
||||
|
|
|
|||
|
|
@ -1151,6 +1151,10 @@ components:
|
|||
description: The time in milliseconds a incoming webhook was deleted
|
||||
type: integer
|
||||
format: int64
|
||||
last_used:
|
||||
description: The time in milliseconds this incoming webhook was last used to post a message
|
||||
type: integer
|
||||
format: int64
|
||||
channel_id:
|
||||
description: The ID of a public channel or private group that receives the
|
||||
webhook payloads
|
||||
|
|
@ -4816,6 +4820,261 @@ components:
|
|||
total_count:
|
||||
type: integer
|
||||
description: The total number of users affected.
|
||||
PolicySimulationUserOverride:
|
||||
type: object
|
||||
description: |
|
||||
Per-user payload for the picker-driven `/cel/simulate_users` endpoint.
|
||||
The simulator resolves each user's profile attributes from CPA storage
|
||||
and then layers session context on top: first the requesting admin's
|
||||
active-session snapshot (when `use_active_session` is true), then the
|
||||
explicit `session_overrides` map.
|
||||
required:
|
||||
- user_id
|
||||
properties:
|
||||
user_id:
|
||||
type: string
|
||||
description: ID of the user to evaluate the draft policy against.
|
||||
use_active_session:
|
||||
type: boolean
|
||||
description: |
|
||||
When true, inject the requesting admin's `session.*` attributes
|
||||
(network_status, device_managed, ip_range, etc.) into this user's
|
||||
evaluation context. Forward-compatible with future PDP work that
|
||||
populates session attributes on the request context.
|
||||
session_overrides:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: |
|
||||
Replaces individual `session.*` attributes for this user only.
|
||||
Applied on top of the active-session snapshot when both are set,
|
||||
so a "configure session" panel can shadow specific values without
|
||||
discarding the rest of the active session.
|
||||
PolicySimulationByUsersParams:
|
||||
type: object
|
||||
description: |
|
||||
Request body for `/access_control_policies/cel/simulate_users`. The
|
||||
draft policy is compiled in-memory only — nothing is persisted.
|
||||
required:
|
||||
- policy
|
||||
- actions
|
||||
- users
|
||||
properties:
|
||||
policy:
|
||||
$ref: "#/components/schemas/AccessControlPolicy"
|
||||
actions:
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
type: string
|
||||
description: |
|
||||
Permission actions to simulate (e.g. `upload_file_attachment`,
|
||||
`download_file_attachment`). At least one action is required —
|
||||
the picker UX only makes sense once an action is in scope. The
|
||||
backend rejects empty arrays with `app.pap.simulate.missing_actions`
|
||||
(HTTP 400); `minItems` lets OpenAPI tooling catch that earlier
|
||||
on the client.
|
||||
rule_name:
|
||||
type: string
|
||||
description: |
|
||||
Identifies which rule in `policy.rules` the author is editing
|
||||
(used for blame attribution). When set, denies originating from
|
||||
this rule are tagged `source=this_rule`; other denies in the same
|
||||
draft are tagged `source=sibling_rule`.
|
||||
channel_id:
|
||||
type: string
|
||||
description: |
|
||||
Provides resource context for delegated channel admins and for
|
||||
resource-lane evaluation when `policy.type == "channel"`.
|
||||
team_id:
|
||||
type: string
|
||||
description: Provides team context for team-level delegated admins.
|
||||
users:
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
$ref: "#/components/schemas/PolicySimulationUserOverride"
|
||||
description: |
|
||||
Explicit user list to evaluate, with per-user session-attribute
|
||||
overrides. At least one user is required (the backend rejects
|
||||
empty arrays with `app.pap.simulate.missing_users`).
|
||||
evaluation_scope:
|
||||
type: string
|
||||
enum: [all, this_rule]
|
||||
description: |
|
||||
Selects whether the simulator considers only the rule under
|
||||
simulation (`this_rule`) or co-evaluates every contributing
|
||||
program (`all`). Empty defaults to `this_rule` on the server.
|
||||
`this_rule` is the authoring-time "what does this rule alone
|
||||
do?" view: useful for iterating on a single rule without
|
||||
sibling rules shadowing or compensating for it. `all` mirrors
|
||||
the live PDP at request time.
|
||||
PolicySimulationBlame:
|
||||
type: object
|
||||
description: |
|
||||
Attributes a deny decision back to the rule or policy that caused it.
|
||||
properties:
|
||||
source:
|
||||
type: string
|
||||
enum:
|
||||
- this_rule
|
||||
- sibling_rule
|
||||
- sibling_saved
|
||||
- peer_policy
|
||||
- system_permission
|
||||
- channel_policy
|
||||
- no_applicable_policy
|
||||
description: |
|
||||
Origin of the blamed contribution.
|
||||
* `this_rule` — the rule the author is currently editing.
|
||||
* `sibling_rule` — another rule in the same draft policy.
|
||||
* `sibling_saved` — recorded on ALLOW decisions where the
|
||||
author's own rule alone would have denied; a sibling rule
|
||||
flipped the verdict.
|
||||
* `peer_policy` — a different policy at the SAME scope as the
|
||||
draft. Carries `expression` / `evaluation_tree`.
|
||||
* `system_permission` — a higher-scoped system permission policy.
|
||||
Expression / tree are stripped to avoid leaking the contents
|
||||
of policies outside the editing scope.
|
||||
* `channel_policy` — a higher-scoped channel policy. Same
|
||||
privacy stripping as `system_permission`.
|
||||
* `no_applicable_policy` — no policy at any scope contributed a
|
||||
decision (vacuous allow).
|
||||
outcome:
|
||||
type: string
|
||||
enum:
|
||||
- deny
|
||||
- allow
|
||||
description: |
|
||||
Per-blame verdict. Most blame entries describe the deny that
|
||||
produced the overall decision (`deny`); the simulator also
|
||||
emits informational `allow` entries so the picker can show
|
||||
"your draft rule allowed this user" alongside any peer
|
||||
policies that actually caused the deny. Consumers that only
|
||||
care about deny attribution should filter to `deny`. Empty
|
||||
(omitted) is treated as `deny` for backward compatibility
|
||||
with simulator builds that pre-date this field.
|
||||
policy_id:
|
||||
type: string
|
||||
description: |
|
||||
ID of the contributing policy. Empty when the deny originated
|
||||
from the draft itself (no persisted ID exists yet).
|
||||
policy_name:
|
||||
type: string
|
||||
description: Human-readable name of the contributing policy.
|
||||
rule_name:
|
||||
type: string
|
||||
description: Name of the contributing rule.
|
||||
role:
|
||||
type: string
|
||||
description: |
|
||||
Scoped role (`system_admin` / `system_user` / `channel_admin` /
|
||||
…) the contributing rule targets. Useful for explaining
|
||||
role-chain fallbacks.
|
||||
expression:
|
||||
type: string
|
||||
description: |
|
||||
CEL text of the contributing rule. Only populated for blame
|
||||
entries at the draft's own scope (`this_rule`, `sibling_rule`,
|
||||
`sibling_saved`, `peer_policy`).
|
||||
evaluation_tree:
|
||||
type: object
|
||||
description: |
|
||||
Recursive per-node breakdown of the contributing rule, mirroring
|
||||
the boolean shape of the CEL expression's AST. Same scope-privacy
|
||||
rule as `expression`. The picker renders it as a structured
|
||||
AND/OR/NOT tree highlighting which sub-expression(s) drove the
|
||||
deny.
|
||||
PolicySimulationActionDecision:
|
||||
type: object
|
||||
description: Per-action verdict for one user (or one session).
|
||||
properties:
|
||||
decision:
|
||||
type: boolean
|
||||
description: |
|
||||
`true` means ALLOW, `false` means DENY. Pending evaluations
|
||||
(rare) surface as `false` paired with an empty `blame` array.
|
||||
blame:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/PolicySimulationBlame"
|
||||
description: |
|
||||
Ordered blame entries for the decision. The first entry is the
|
||||
primary blame (what the picker renders on the chip); subsequent
|
||||
entries describe other contributing policies the user can drill
|
||||
into via the "Decision details" view.
|
||||
PolicySimulationSession:
|
||||
type: object
|
||||
description: Per-session verdict for one user.
|
||||
properties:
|
||||
device:
|
||||
type: string
|
||||
network:
|
||||
type: string
|
||||
last_active_at:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Last-active timestamp in milliseconds since epoch.
|
||||
decisions:
|
||||
type: object
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/PolicySimulationActionDecision"
|
||||
description: Per-action verdicts for this specific session.
|
||||
attributes:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: |
|
||||
Session-attribute snapshot used when evaluating this session
|
||||
(network_status, device_managed, ip_range, etc.). Surfaced in
|
||||
the per-row "Decision details" view.
|
||||
PolicySimulationUserResult:
|
||||
type: object
|
||||
properties:
|
||||
user:
|
||||
$ref: "#/components/schemas/User"
|
||||
decisions:
|
||||
type: object
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/PolicySimulationActionDecision"
|
||||
description: |
|
||||
Per-action verdicts for the user. When `sessions` is populated
|
||||
this represents the "headline" decision (e.g. from the
|
||||
most-recently-active session) so the picker can render a
|
||||
single chip without consulting `sessions`.
|
||||
sessions:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/PolicySimulationSession"
|
||||
description: |
|
||||
Optional per-session breakdown. When populated the picker
|
||||
renders a Recent activity expand row revealing one decision
|
||||
chip per session. Empty/undefined falls back to a single
|
||||
user-level chip.
|
||||
attributes:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: |
|
||||
User profile attribute snapshot used when evaluating this user
|
||||
(department, region, clearance, etc.).
|
||||
PolicySimulationResponse:
|
||||
type: object
|
||||
description: |
|
||||
Body returned by `/cel/simulate_users`. Per-user, per-action
|
||||
verdicts plus blame attribution for any deny.
|
||||
properties:
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/PolicySimulationUserResult"
|
||||
total:
|
||||
type: integer
|
||||
format: int64
|
||||
description: |
|
||||
Total number of users evaluated (matches `results.length` for
|
||||
the picker endpoint since the caller supplies the user list
|
||||
explicitly).
|
||||
ChannelSearch: # Added based on dataretention.yaml and access_control.go usage
|
||||
type: object
|
||||
properties:
|
||||
|
|
|
|||
|
|
@ -47,21 +47,21 @@
|
|||
description: The ID of the target
|
||||
permission_field:
|
||||
type: string
|
||||
enum: [none, sysadmin, member]
|
||||
enum: [none, sysadmin, member, admin]
|
||||
description: >
|
||||
Permission level for editing the field definition.
|
||||
Only system admins can set this; ignored for non-admin users.
|
||||
default: member
|
||||
permission_values:
|
||||
type: string
|
||||
enum: [none, sysadmin, member]
|
||||
enum: [none, sysadmin, member, admin]
|
||||
description: >
|
||||
Permission level for setting values on objects.
|
||||
Only system admins can set this; ignored for non-admin users.
|
||||
default: member
|
||||
permission_options:
|
||||
type: string
|
||||
enum: [none, sysadmin, member]
|
||||
enum: [none, sysadmin, member, admin]
|
||||
description: >
|
||||
Permission level for managing options on select/multiselect fields.
|
||||
Only system admins can set this; ignored for non-admin users.
|
||||
|
|
|
|||
|
|
@ -1534,6 +1534,54 @@
|
|||
$ref: "#/components/responses/Unauthorized"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
/api/v4/users/auth_data:
|
||||
get:
|
||||
tags:
|
||||
- users
|
||||
summary: Get a user by auth data
|
||||
description: >
|
||||
Get a user by their external auth data identifier. The `value` is
|
||||
matched against what is stored in `Users.AuthData`, which for most
|
||||
identity providers is the identifier as the provider issues it.
|
||||
|
||||
|
||||
The exception is Active Directory `objectGUID`: under
|
||||
`auth_service: ldap` it is stored as the LDAP filter hex-escape
|
||||
form (e.g. `\61\14\e1\d1\c5\35\18\4a\b6\60\d6\78\50\fd\0d\5d`),
|
||||
and under `auth_service: saml` it is stored as the standard
|
||||
Base64 of the same bytes (e.g. `YRTh0cU1GEq2YNZ4UP0NXQ==`). Use
|
||||
the form matching the user's current `AuthService`.
|
||||
|
||||
|
||||
##### Permissions
|
||||
|
||||
Must be a system admin.
|
||||
operationId: GetUserByAuthData
|
||||
parameters:
|
||||
- name: value
|
||||
in: query
|
||||
description: >
|
||||
The user's AuthData as stored in `Users.AuthData`. Must be
|
||||
URL-encoded; in particular, Base64 `+` characters must be sent
|
||||
as `%2B` so they are not decoded as spaces.
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: User retrieval successful
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/User"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
/api/v4/users/password/reset:
|
||||
post:
|
||||
tags:
|
||||
|
|
|
|||
|
|
@ -27,9 +27,9 @@ export default defineConfig({
|
|||
viewportWidth: 1300,
|
||||
allowCypressEnv: false,
|
||||
expose: {
|
||||
adminEmail: 'sysadmin@sample.mattermost.com',
|
||||
adminUsername: 'sysadmin',
|
||||
adminPassword: 'Sys@dmin-sample1',
|
||||
adminEmail: process.env.CYPRESS_adminEmail || 'sysadmin@sample.mattermost.com',
|
||||
adminUsername: process.env.CYPRESS_adminUsername || 'sysadmin',
|
||||
adminPassword: process.env.CYPRESS_adminPassword || 'Sys@dmin-sample1',
|
||||
allowedUntrustedInternalConnections: 'localhost',
|
||||
cwsURL: 'http://localhost:8076',
|
||||
cwsAPIURL: 'http://localhost:8076',
|
||||
|
|
|
|||
|
|
@ -16,11 +16,16 @@ describe('Verify Accessibility Support in Different Images', () => {
|
|||
let otherUser;
|
||||
|
||||
before(() => {
|
||||
cy.apiInitSetup().then(({offTopicUrl, user}) => {
|
||||
otherUser = user;
|
||||
cy.apiInitSetup().then(({team, offTopicUrl, user}) => {
|
||||
cy.apiCreateUser({prefix: 'other'}).then(({user: user1}) => {
|
||||
otherUser = user1;
|
||||
return cy.apiAddUserToTeam(team.id, otherUser.id);
|
||||
}).then(() => {
|
||||
cy.apiLogin(user);
|
||||
|
||||
// Visit the Off Topic channel
|
||||
cy.visit(offTopicUrl);
|
||||
// Visit the Off Topic channel
|
||||
cy.visit(offTopicUrl);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -16,9 +16,14 @@ import {AdminConfig} from '@mattermost/types/config';
|
|||
* Note: This test requires Enterprise license to be uploaded
|
||||
*/
|
||||
const testSamlMetadataUrl = 'http://test_saml_metadata_url';
|
||||
const testSamlMetadataSuccessUrl = 'http://test_saml_metadata_success_url';
|
||||
const testIdpURL = 'http://test_idp_url';
|
||||
const testIdpDescriptorURL = 'http://test_idp_descriptor_url';
|
||||
const testFetchedIdpURL = 'http://test_fetched_idp_url';
|
||||
const testFetchedIdpDescriptorURL = 'http://test_fetched_idp_descriptor_url';
|
||||
const testIdpPublicCertificate = 'MIICozCCAYsCBgGNzWfMwjANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDDAptYXR0ZXJtb3N0';
|
||||
const getSamlMetadataErrorMessage = 'SAML Metadata URL did not connect and pull data successfully';
|
||||
const getSamlMetadataSuccessMessage = 'SAML Metadata retrieved successfully. Two fields and one certificate have been updated';
|
||||
|
||||
let config: AdminConfig;
|
||||
|
||||
|
|
@ -82,4 +87,58 @@ describe('SystemConsole->SAML 2.0 - Get Metadata from Idp Flow', () => {
|
|||
// * Verify that we can successfully save the settings (we have not affected previous state)
|
||||
cy.get('#saveSetting').click();
|
||||
});
|
||||
|
||||
it('fetches metadata and sets the IdP certificate from Idp Metadata Url', () => {
|
||||
cy.apiUpdateConfig({
|
||||
SamlSettings: {
|
||||
Enable: true,
|
||||
IdpMetadataURL: testSamlMetadataSuccessUrl,
|
||||
IdpURL: testIdpURL,
|
||||
IdpDescriptorURL: testIdpDescriptorURL,
|
||||
AssertionConsumerServiceURL: Cypress.config('baseUrl') + '/login/sso/saml',
|
||||
ServiceProviderIdentifier: Cypress.config('baseUrl') + '/login/sso/saml',
|
||||
},
|
||||
});
|
||||
|
||||
cy.visit('/admin_console/authentication/saml');
|
||||
|
||||
cy.intercept('POST', '**/api/v4/saml/metadatafromidp', (req) => {
|
||||
req.reply({
|
||||
statusCode: 200,
|
||||
body: {
|
||||
idp_url: testFetchedIdpURL,
|
||||
idp_descriptor_url: testFetchedIdpDescriptorURL,
|
||||
idp_public_certificate: testIdpPublicCertificate,
|
||||
},
|
||||
});
|
||||
}).as('getSamlMetadataFromIdp');
|
||||
|
||||
cy.intercept('POST', '**/api/v4/saml/certificate/idp', (req) => {
|
||||
expect(req.headers['content-type']).to.eq('application/x-pem-file');
|
||||
expect(req.body).to.eq(testIdpPublicCertificate);
|
||||
|
||||
req.reply({
|
||||
statusCode: 200,
|
||||
body: {status: 'OK'},
|
||||
});
|
||||
}).as('setSamlIdpCertificateFromMetadata');
|
||||
|
||||
// # Click on the Get SAML Metadata Button
|
||||
cy.get('#getSamlMetadataFromIDPButton button').scrollIntoView().should('be.visible').and('be.enabled').click();
|
||||
|
||||
// * Verify that the metadata and certificate endpoints are called
|
||||
cy.wait('@getSamlMetadataFromIdp');
|
||||
cy.wait('@setSamlIdpCertificateFromMetadata');
|
||||
|
||||
// * Verify that the IdP URL fields have been updated
|
||||
cy.findByTestId('SamlSettings.IdpURLinput').should('have.value', testFetchedIdpURL);
|
||||
cy.findByTestId('SamlSettings.IdpDescriptorURLinput').should('have.value', testFetchedIdpDescriptorURL);
|
||||
|
||||
// * Verify that the success message reflects the updated fields and certificate
|
||||
cy.get('#getSamlMetadataFromIDPButton').should('be.visible').contains(getSamlMetadataSuccessMessage);
|
||||
|
||||
// * Verify that the IdP certificate row shows the remove certificate view
|
||||
cy.contains('.remove-filename', 'saml-idp.crt').should('be.visible');
|
||||
cy.contains('button', 'Remove Identity Provider Certificate').should('be.visible');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
// ***************************************************************
|
||||
|
||||
// Stage: @prod
|
||||
// Group: @channels @messaging
|
||||
// Group: @channels @messaging @collapsed_reply_threads
|
||||
|
||||
import timeouts from '@/fixtures/timeouts';
|
||||
|
||||
|
|
@ -177,6 +177,57 @@ describe('Messaging', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('MM-T4261_3 One-Click Reactions in Global Threads view show 3 emojis', () => {
|
||||
// # Re-enable emoji picker (MM-T4261_2 disables it) and configure CRT server-side
|
||||
cy.apiAdminLogin();
|
||||
cy.apiUpdateConfig({
|
||||
ServiceSettings: {
|
||||
EnableEmojiPicker: true,
|
||||
ThreadAutoFollow: true,
|
||||
CollapsedThreads: 'default_off',
|
||||
},
|
||||
});
|
||||
|
||||
// # Create a fresh user with CRT turned on so threads appear in the Threads view
|
||||
cy.apiCreateUser({prefix: 'crtUser'}).then(({user: crtUser}) => {
|
||||
cy.apiAddUserToTeam(testTeam.id, crtUser.id);
|
||||
cy.apiSaveCRTPreference(crtUser.id, 'on');
|
||||
cy.apiLogin(crtUser);
|
||||
});
|
||||
|
||||
cy.visit(offTopicPath);
|
||||
|
||||
// # Enable one-click reactions for this user
|
||||
cy.uiOpenSettingsModal('Display').within(() => {
|
||||
cy.findByText('Display', {timeout: timeouts.ONE_MIN}).click();
|
||||
cy.findByText('Quick reactions on messages').click();
|
||||
cy.findByLabelText('On').click();
|
||||
cy.uiSaveAndClose();
|
||||
});
|
||||
|
||||
// # Post a root message and a reply so a followed thread exists
|
||||
cy.apiGetChannelByName(testTeam.name, 'off-topic').then(({channel}) => {
|
||||
cy.apiCreatePost(channel.id, 'Root post for Global Threads emoji test', '', {}).then((rootResp) => {
|
||||
const rootPostId = rootResp.body.id;
|
||||
|
||||
cy.apiCreatePost(channel.id, 'Reply to follow the thread', rootPostId, {});
|
||||
|
||||
// # Navigate to Global Threads
|
||||
cy.uiClickSidebarItem('threads');
|
||||
|
||||
// # Click the thread to open the full-width thread panel
|
||||
cy.get('div.ThreadItem').should('have.lengthOf.at.least', 1).first().click();
|
||||
|
||||
// * Root post is visible in the thread pane
|
||||
cy.get(`#rhsPost_${rootPostId}`).should('be.visible');
|
||||
|
||||
// * Hovering over the post in Global Threads shows 3 quick reaction emojis —
|
||||
// the same count as the center channel, not the 1 shown in a narrow RHS sidebar.
|
||||
validateQuickReactions(rootPostId, 'GLOBAL_THREADS', defaultEmojis);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function validateQuickReactions(postId, location, emojis) {
|
||||
let idPrefix;
|
||||
let numReactions = 3;
|
||||
|
|
@ -186,7 +237,7 @@ describe('Messaging', () => {
|
|||
} else if (location === 'RHS_ROOT' || location === 'RHS_COMMENT') {
|
||||
idPrefix = 'rhsPost';
|
||||
numReactions = 1;
|
||||
} else if (location === 'RHS_EXPANDED') {
|
||||
} else if (location === 'GLOBAL_THREADS') {
|
||||
idPrefix = 'rhsPost';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -256,7 +256,7 @@ describe('Environment', () => {
|
|||
|
||||
cy.get('#TestS3Connection').scrollIntoView().should('be.visible').within(() => {
|
||||
cy.findByText('Test Connection').should('be.visible').click().wait(TIMEOUTS.ONE_SEC);
|
||||
waitForAlert('Connection unsuccessful: Unable to connect to S3. Verify your Amazon S3 connection authorization parameters and authentication settings.');
|
||||
waitForAlert('Connection unsuccessful: Unable to authenticate against the file storage backend. Verify your credentials and authentication settings.');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ describe('SupportSettings', () => {
|
|||
[
|
||||
{text: 'Ask the community', link: SupportSettings.ASK_COMMUNITY_LINK},
|
||||
{text: 'Mattermost user guide', link: SupportSettings.MATTERMOST_USER_GUIDE},
|
||||
{text: 'Report a problem', link: SupportSettings.REPORT_A_PROBLEM_LINK},
|
||||
{text: 'Report a problem', link: 'mailto:reportaproblem@mattermost.com'},
|
||||
{text: 'Keyboard shortcuts'},
|
||||
].forEach(({text, link}) => {
|
||||
if (link) {
|
||||
|
|
|
|||
|
|
@ -28,14 +28,14 @@ describe('Customization', () => {
|
|||
});
|
||||
|
||||
it('MM-T1214 - Can change Report a Problem Link setting', () => {
|
||||
// * Verify Report a Problem link label is visible and matches the text
|
||||
cy.findByTestId('SupportSettings.ReportAProblemLinklabel').scrollIntoView().should('be.visible').and('have.text', 'Report a Problem Link:');
|
||||
// # Select 'Custom link' from the Report a Problem dropdown
|
||||
cy.findByTestId('SupportSettings.ReportAProblemTypedropdown').scrollIntoView().select('Custom link');
|
||||
|
||||
// * Verify Report a Problem link input box has default value. The default value depends on the setup before running the test.
|
||||
cy.findByTestId('SupportSettings.ReportAProblemLinkinput').should('have.value', origConfig.SupportSettings.ReportAProblemLink);
|
||||
// * Verify Report a Problem link label is visible and matches the text
|
||||
cy.findByTestId('SupportSettings.ReportAProblemLinklabel').scrollIntoView().should('be.visible').and('have.text', 'Custom Report a Problem Link:');
|
||||
|
||||
// * Verify Report a Problem link help text is visible and matches the text
|
||||
cy.findByTestId('SupportSettings.ReportAProblemLinkhelp-text').find('span').should('be.visible').and('have.text', 'The URL for the Report a Problem link in the Help Menu. If this field is empty, the link is removed from the Help Menu.');
|
||||
cy.findByTestId('SupportSettings.ReportAProblemLinkhelp-text').find('span').should('be.visible').and('have.text', 'Enter the URL that users will be directed to when they choose "Report a Problem".');
|
||||
|
||||
// # Enter a problem link
|
||||
const reportAProblemLink = 'https://mattermost.com/pl/report-a-bug';
|
||||
|
|
|
|||
|
|
@ -254,6 +254,7 @@ function sysadminSetup(user) {
|
|||
|
||||
function resetUserPreference(userId) {
|
||||
cy.apiSaveTeammateNameDisplayPreference('username');
|
||||
cy.apiSaveMessageDisplayPreference('clean');
|
||||
cy.apiSaveLinkPreviewsPreference('true');
|
||||
cy.apiSaveCollapsePreviewsPreference('false');
|
||||
cy.apiSaveClockDisplayModeTo24HourPreference(false);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ export const ABOUT_LINK = 'https://mattermost.com/pl/about-mattermost';
|
|||
export const ASK_COMMUNITY_LINK = 'https://mattermost.com/pl/default-ask-mattermost-community/';
|
||||
export const HELP_LINK = 'https://mattermost.com/pl/help/';
|
||||
export const PRIVACY_POLICY_LINK = 'https://mattermost.com/pl/privacy-policy/';
|
||||
export const REPORT_A_PROBLEM_LINK = 'https://mattermost.com/pl/report-a-bug';
|
||||
export const TERMS_OF_SERVICE_LINK = 'https://mattermost.com/pl/terms-of-use/';
|
||||
export const MATTERMOST_USER_GUIDE = 'https://docs.mattermost.com/guides/use-mattermost.html';
|
||||
|
||||
|
|
@ -24,7 +23,6 @@ export const SupportSettings = {
|
|||
ASK_COMMUNITY_LINK,
|
||||
HELP_LINK,
|
||||
PRIVACY_POLICY_LINK,
|
||||
REPORT_A_PROBLEM_LINK,
|
||||
TERMS_OF_SERVICE_LINK,
|
||||
MATTERMOST_USER_GUIDE,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -779,7 +779,6 @@ const defaultServerConfig: AdminConfig = {
|
|||
AttributeBasedAccessControl: true,
|
||||
PermissionPolicies: true,
|
||||
ContentFlagging: true,
|
||||
InteractiveDialogAppsForm: true,
|
||||
EnableMattermostEntry: true,
|
||||
MobileSSOCodeExchange: false,
|
||||
AutoTranslation: true,
|
||||
|
|
@ -790,6 +789,7 @@ const defaultServerConfig: AdminConfig = {
|
|||
IntegratedBoards: false,
|
||||
CJKSearch: false,
|
||||
ManagedChannelCategories: false,
|
||||
MobileEphemeralMode: true,
|
||||
},
|
||||
ImportSettings: {
|
||||
Directory: './import',
|
||||
|
|
@ -868,4 +868,10 @@ const defaultServerConfig: AdminConfig = {
|
|||
LLMServiceID: '',
|
||||
},
|
||||
},
|
||||
MobileEphemeralModeSettings: {
|
||||
Enable: false,
|
||||
DisconnectionTimeoutSeconds: 60,
|
||||
OfflinePersistenceTimerHours: 24,
|
||||
AutoCacheCleanupDays: 7,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
import {Locator, expect} from '@playwright/test';
|
||||
|
||||
export default class DirectChannelsModal {
|
||||
readonly container;
|
||||
|
||||
readonly goButton;
|
||||
readonly results;
|
||||
readonly searchInput;
|
||||
|
||||
constructor(container: Locator) {
|
||||
this.container = container;
|
||||
|
||||
this.goButton = container.getByRole('button', {name: 'Go'});
|
||||
this.results = container.locator('.more-modal__list');
|
||||
this.searchInput = container.getByRole('combobox', {name: 'Search for people'});
|
||||
}
|
||||
|
||||
async toBeVisible() {
|
||||
await expect(this.container).toBeVisible();
|
||||
}
|
||||
|
||||
async selectUser(user: UserProfile) {
|
||||
await this.fillSearchInput(user.username);
|
||||
|
||||
// This may fail if there's too many group channels containing the provided user
|
||||
const row = this.results
|
||||
.locator('.more-modal__row:not(:has(.more-modal__gm-icon))')
|
||||
.getByText(`@${user.username}`, {exact: false});
|
||||
|
||||
await row.click();
|
||||
|
||||
await expect(this.container.getByRole('button', {name: `Remove ${user.username}`})).toBeVisible();
|
||||
}
|
||||
|
||||
async toHaveNUsersSelected(count: number) {
|
||||
await expect(this.results.locator('.react-select_multi-value')).toHaveCount(count);
|
||||
}
|
||||
|
||||
async goToChannel() {
|
||||
await this.goButton.click();
|
||||
|
||||
await expect(this.container).not.toBeAttached();
|
||||
}
|
||||
|
||||
async toHaveNResults(count: number) {
|
||||
await expect(this.results.locator('.more-modal__row')).toHaveCount(count);
|
||||
}
|
||||
|
||||
async fillSearchInput(text: string) {
|
||||
await this.searchInput.fill(text);
|
||||
}
|
||||
|
||||
async toHaveUserAsNthResult(user: UserProfile, index: number) {
|
||||
const row = this.results.locator('.more-modal__row').nth(index);
|
||||
|
||||
await expect(row).toContainText(`@${user.username}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ export default class ChannelsSidebarLeft {
|
|||
readonly findChannelButton;
|
||||
readonly scheduledPostBadge;
|
||||
readonly unreadChannelFilter;
|
||||
readonly openDirectMessageButton;
|
||||
|
||||
constructor(container: Locator) {
|
||||
this.container = container;
|
||||
|
|
@ -20,6 +21,7 @@ export default class ChannelsSidebarLeft {
|
|||
this.findChannelButton = container.getByRole('button', {name: 'Find Channels'});
|
||||
this.scheduledPostBadge = container.locator('span.scheduledPostBadge');
|
||||
this.unreadChannelFilter = container.locator('.SidebarFilters_filterButton');
|
||||
this.openDirectMessageButton = container.getByRole('button', {name: 'Write a direct message'});
|
||||
}
|
||||
|
||||
async toBeVisible() {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import ChannelsSidebarRight from './channels/sidebar_right';
|
|||
import DeletePostConfirmationDialog from './channels/delete_post_confirmation_dialog';
|
||||
import DeletePostModal from './channels/delete_post_modal';
|
||||
import DeleteScheduledPostModal from './channels/delete_scheduled_post_modal';
|
||||
import DirectChannelsModal from './channels/direct_channels_modal';
|
||||
import DraftPost from './channels/draft_post';
|
||||
import EmojiGifPicker from './channels/emoji_gif_picker';
|
||||
import FindChannelsModal from './channels/find_channels_modal';
|
||||
|
|
@ -53,7 +54,13 @@ import BurnOnReadTimerChip from './channels/burn_on_read_timer_chip';
|
|||
import BurnOnReadConcealedPlaceholder from './channels/burn_on_read_concealed_placeholder';
|
||||
import BurnOnReadConfirmationModal from './channels/burn_on_read_confirmation_modal';
|
||||
// System Console Components
|
||||
import {AdminSectionPanel, DropdownSetting, RadioSetting, TextInputSetting} from './system_console/base_components';
|
||||
import {
|
||||
AdminSectionPanel,
|
||||
DropdownSetting,
|
||||
NumberInputSetting,
|
||||
RadioSetting,
|
||||
TextInputSetting,
|
||||
} from './system_console/base_components';
|
||||
import DelegatedGranularAdministration from './system_console/sections/user_management/delegated_granular_administration';
|
||||
import UserDetail from './system_console/sections/user_management/user_detail';
|
||||
import EditionAndLicense from './system_console/sections/about/edition_and_license';
|
||||
|
|
@ -89,6 +96,7 @@ const components = {
|
|||
DeletePostConfirmationDialog,
|
||||
DeletePostModal,
|
||||
DeleteScheduledPostModal,
|
||||
DirectChannelsModal,
|
||||
DraftPost,
|
||||
EmojiGifPicker,
|
||||
FindChannelsModal,
|
||||
|
|
@ -130,6 +138,7 @@ const components = {
|
|||
EditionAndLicense,
|
||||
MobileSecurity,
|
||||
Notifications,
|
||||
NumberInputSetting,
|
||||
RadioSetting,
|
||||
UsersAndTeams,
|
||||
SystemConsoleFeatureDiscovery,
|
||||
|
|
@ -172,6 +181,7 @@ export {
|
|||
FlagPostConfirmationDialog,
|
||||
NewChannelModal,
|
||||
BrowseChannelsModal,
|
||||
DirectChannelsModal,
|
||||
GenericConfirmModal,
|
||||
InvitePeopleModal,
|
||||
MembersInvitedModal,
|
||||
|
|
@ -207,6 +217,7 @@ export {
|
|||
EditionAndLicense,
|
||||
MobileSecurity,
|
||||
Notifications,
|
||||
NumberInputSetting,
|
||||
RadioSetting,
|
||||
UsersAndTeams,
|
||||
SystemConsoleFeatureDiscovery,
|
||||
|
|
|
|||
|
|
@ -94,6 +94,40 @@ export class TextInputSetting {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Number Input Setting - represents a number input field
|
||||
* Uses getByRole('spinbutton') since <input type="number"> has ARIA role spinbutton
|
||||
*/
|
||||
export class NumberInputSetting {
|
||||
readonly container: Locator;
|
||||
readonly label: Locator;
|
||||
readonly input: Locator;
|
||||
readonly helpText: Locator;
|
||||
|
||||
constructor(container: Locator, labelText: string) {
|
||||
this.container = container;
|
||||
this.label = container.getByText(labelText);
|
||||
this.input = container.getByRole('spinbutton');
|
||||
this.helpText = container.locator('.help-text');
|
||||
}
|
||||
|
||||
async fill(value: string) {
|
||||
await this.input.fill(value);
|
||||
}
|
||||
|
||||
async getValue(): Promise<string> {
|
||||
return (await this.input.inputValue()) ?? '';
|
||||
}
|
||||
|
||||
async clear() {
|
||||
await this.input.clear();
|
||||
}
|
||||
|
||||
async toBeVisible() {
|
||||
await expect(this.container).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown Setting - represents a select dropdown
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -3,7 +3,13 @@
|
|||
|
||||
import {Locator, expect} from '@playwright/test';
|
||||
|
||||
import {RadioSetting, TextInputSetting, DropdownSetting, AdminSectionPanel} from '../../base_components';
|
||||
import {
|
||||
RadioSetting,
|
||||
TextInputSetting,
|
||||
NumberInputSetting,
|
||||
DropdownSetting,
|
||||
AdminSectionPanel,
|
||||
} from '../../base_components';
|
||||
|
||||
/**
|
||||
* System Console -> Environment -> Mobile Security
|
||||
|
|
@ -17,6 +23,7 @@ export default class MobileSecurity {
|
|||
// Panels
|
||||
readonly generalMobileSecurity: GeneralMobileSecurityPanel;
|
||||
readonly microsoftIntune: MicrosoftIntunePanel;
|
||||
readonly mobileEphemeralMode: MobileEphemeralModePanel;
|
||||
|
||||
// Save section
|
||||
readonly saveButton: Locator;
|
||||
|
|
@ -33,6 +40,9 @@ export default class MobileSecurity {
|
|||
this.microsoftIntune = new MicrosoftIntunePanel(
|
||||
container.locator('.AdminSectionPanel').filter({hasText: 'Microsoft Intune'}),
|
||||
);
|
||||
this.mobileEphemeralMode = new MobileEphemeralModePanel(
|
||||
container.locator('.AdminSectionPanel').filter({hasText: 'Mobile Ephemeral Mode'}),
|
||||
);
|
||||
|
||||
this.saveButton = container.getByRole('button', {name: 'Save'});
|
||||
this.errorMessage = container.locator('.error-message');
|
||||
|
|
@ -77,6 +87,20 @@ export default class MobileSecurity {
|
|||
get clientId() {
|
||||
return this.microsoftIntune.clientId;
|
||||
}
|
||||
|
||||
// Convenience shortcuts for Mobile Ephemeral Mode settings
|
||||
get enableMobileEphemeralMode() {
|
||||
return this.mobileEphemeralMode.enableMobileEphemeralMode;
|
||||
}
|
||||
get disconnectionTimeout() {
|
||||
return this.mobileEphemeralMode.disconnectionTimeout;
|
||||
}
|
||||
get offlinePersistenceTimer() {
|
||||
return this.mobileEphemeralMode.offlinePersistenceTimer;
|
||||
}
|
||||
get autoCacheCleanup() {
|
||||
return this.mobileEphemeralMode.autoCacheCleanup;
|
||||
}
|
||||
}
|
||||
|
||||
class GeneralMobileSecurityPanel extends AdminSectionPanel {
|
||||
|
|
@ -105,6 +129,33 @@ class GeneralMobileSecurityPanel extends AdminSectionPanel {
|
|||
}
|
||||
}
|
||||
|
||||
class MobileEphemeralModePanel extends AdminSectionPanel {
|
||||
readonly enableMobileEphemeralMode: RadioSetting;
|
||||
readonly disconnectionTimeout: NumberInputSetting;
|
||||
readonly offlinePersistenceTimer: NumberInputSetting;
|
||||
readonly autoCacheCleanup: NumberInputSetting;
|
||||
|
||||
constructor(container: Locator) {
|
||||
super(container, 'Mobile Ephemeral Mode');
|
||||
|
||||
this.enableMobileEphemeralMode = new RadioSetting(
|
||||
this.body.getByRole('group', {name: /Enable Mobile Ephemeral Mode/}),
|
||||
);
|
||||
this.disconnectionTimeout = new NumberInputSetting(
|
||||
this.body.locator('.form-group').filter({hasText: 'Disconnection Timeout (seconds):'}),
|
||||
'Disconnection Timeout (seconds):',
|
||||
);
|
||||
this.offlinePersistenceTimer = new NumberInputSetting(
|
||||
this.body.locator('.form-group').filter({hasText: 'Offline Persistence Timer (hours):'}),
|
||||
'Offline Persistence Timer (hours):',
|
||||
);
|
||||
this.autoCacheCleanup = new NumberInputSetting(
|
||||
this.body.locator('.form-group').filter({hasText: 'Auto Cache Cleanup (days):'}),
|
||||
'Auto Cache Cleanup (days):',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MicrosoftIntunePanel extends AdminSectionPanel {
|
||||
readonly enableIntuneMAM: RadioSetting;
|
||||
readonly authProvider: DropdownSetting;
|
||||
|
|
|
|||
|
|
@ -51,6 +51,17 @@ export default class SystemProperties {
|
|||
return this.container.locator(`input[value="${value}"]`);
|
||||
}
|
||||
|
||||
displayNameInput(nth: number): Locator {
|
||||
return this.container.getByTestId('property-display-name-input').nth(nth);
|
||||
}
|
||||
|
||||
displayNameInputNear(identifierValue: string): Locator {
|
||||
return this.container
|
||||
.locator('tr')
|
||||
.filter({has: this.nameInputByValue(identifierValue)})
|
||||
.getByTestId('property-display-name-input');
|
||||
}
|
||||
|
||||
typeSelector(nth: number): Locator {
|
||||
return this.container.getByTestId('fieldTypeSelectorMenuButton').nth(nth);
|
||||
}
|
||||
|
|
@ -73,6 +84,10 @@ export default class SystemProperties {
|
|||
return this.container.getByTestId('property-field-input').last();
|
||||
}
|
||||
|
||||
lastDisplayNameInput(): Locator {
|
||||
return this.container.getByTestId('property-display-name-input').last();
|
||||
}
|
||||
|
||||
lastTypeSelector(): Locator {
|
||||
return this.container.getByTestId('fieldTypeSelectorMenuButton').last();
|
||||
}
|
||||
|
|
@ -209,7 +224,31 @@ export default class SystemProperties {
|
|||
|
||||
// ── Validation ──────────────────────────────────────────────────────
|
||||
|
||||
validationMessage(text: string): Locator {
|
||||
identifierValidationError(): Locator {
|
||||
return this.container.getByTestId('property-field-validation-error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the in-cell error icon for the row whose Name input currently
|
||||
* equals `nameValue`. Use this to assert a *specific* row is highlighted
|
||||
* (rather than `identifierValidationError()` which matches any row).
|
||||
*/
|
||||
cellErrorIconForField(nameValue: string): Locator {
|
||||
return this.container
|
||||
.locator('tr')
|
||||
.filter({has: this.nameInputByValue(nameValue)})
|
||||
.getByTestId('property-field-validation-error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the warning AlertBanner whose title text matches `title`.
|
||||
* Banners stack below the table; one per unique error type.
|
||||
*/
|
||||
validationBannerByTitle(title: string | RegExp): Locator {
|
||||
return this.container.locator('.AlertBanner').filter({hasText: title});
|
||||
}
|
||||
|
||||
validationMessage(text: string | RegExp): Locator {
|
||||
return this.container.getByText(text);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export default class ChannelsPage {
|
|||
readonly findChannelsModal;
|
||||
readonly newChannelModal;
|
||||
readonly browseChannelsModal;
|
||||
readonly directChannelsModal;
|
||||
public invitePeopleModal: InvitePeopleModal | undefined;
|
||||
public membersInvitedModal: MembersInvitedModal | undefined;
|
||||
readonly profileModal;
|
||||
|
|
@ -77,6 +78,9 @@ export default class ChannelsPage {
|
|||
this.findChannelsModal = new components.FindChannelsModal(page.getByRole('dialog', {name: 'Find Channels'}));
|
||||
this.newChannelModal = new NewChannelModal(page.getByRole('dialog', {name: 'Create a new channel'}));
|
||||
this.browseChannelsModal = new BrowseChannelsModal(page.getByRole('dialog', {name: 'Browse Channels'}));
|
||||
this.directChannelsModal = new components.DirectChannelsModal(
|
||||
page.getByRole('dialog', {name: 'Direct Messages'}),
|
||||
);
|
||||
this.profileModal = new components.ProfileModal(page.getByRole('dialog', {name: 'Profile'}));
|
||||
this.settingsModal = new components.SettingsModal(page.getByRole('dialog', {name: 'Settings'}));
|
||||
this.teamSettingsModal = new components.TeamSettingsModal(page.getByRole('dialog', {name: 'Team Settings'}));
|
||||
|
|
@ -242,6 +246,13 @@ export default class ChannelsPage {
|
|||
return this.browseChannelsModal;
|
||||
}
|
||||
|
||||
async openDirectChannelsModal() {
|
||||
await this.sidebarLeft.openDirectMessageButton.click();
|
||||
await this.directChannelsModal.toBeVisible();
|
||||
|
||||
return this.directChannelsModal;
|
||||
}
|
||||
|
||||
async openCreateTeamForm(): Promise<CreateTeamForm> {
|
||||
await this.sidebarLeft.teamMenuButton.click();
|
||||
await this.teamMenu.toBeVisible();
|
||||
|
|
|
|||
|
|
@ -17,6 +17,12 @@ export default class ContentReviewPage {
|
|||
readonly confirmRemoveMessageButton: Locator;
|
||||
readonly confirmKeepMessageButton: Locator;
|
||||
readonly confirmationModalComment: Locator;
|
||||
readonly downloadReportCheckbox: Locator;
|
||||
readonly formContinueButton: Locator;
|
||||
readonly removePermanentlyButton: Locator;
|
||||
readonly keepPermanentlyButton: Locator;
|
||||
readonly removeWithoutReportButton: Locator;
|
||||
readonly generatedSection: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
|
|
@ -33,6 +39,16 @@ export default class ContentReviewPage {
|
|||
this.confirmationModalComment = this.postActionConformationModal.getByTestId(
|
||||
'RemoveFlaggedMessageConfirmationModal__comment',
|
||||
);
|
||||
this.downloadReportCheckbox = this.postActionConformationModal.getByTestId('download-report-checkbox');
|
||||
this.formContinueButton = this.postActionConformationModal.getByRole('button', {name: 'Continue'});
|
||||
this.removePermanentlyButton = this.postActionConformationModal.getByRole('button', {
|
||||
name: 'Remove permanently',
|
||||
});
|
||||
this.keepPermanentlyButton = this.postActionConformationModal.getByRole('button', {name: 'Keep permanently'});
|
||||
this.removeWithoutReportButton = this.postActionConformationModal.getByRole('button', {
|
||||
name: 'Remove without report',
|
||||
});
|
||||
this.generatedSection = this.postActionConformationModal.getByTestId('generated-section');
|
||||
}
|
||||
|
||||
async setReportCardByPostID(postID: string) {
|
||||
|
|
@ -175,4 +191,35 @@ export default class ContentReviewPage {
|
|||
await this.confirmKeepMessageButton.click();
|
||||
await this.postActionConformationModal.waitFor({state: 'hidden'});
|
||||
}
|
||||
|
||||
/**
|
||||
* From the form step, advance to the report-generated step
|
||||
* (downloadReport checkbox is on by default).
|
||||
*/
|
||||
async submitFormAndWaitForReport() {
|
||||
await this.formContinueButton.click();
|
||||
await expect(this.generatedSection).toBeVisible({timeout: 30000});
|
||||
}
|
||||
|
||||
async confirmRemovePermanently() {
|
||||
await this.removePermanentlyButton.click();
|
||||
await this.postActionConformationModal.waitFor({state: 'hidden'});
|
||||
}
|
||||
|
||||
async confirmKeepPermanently() {
|
||||
await this.keepPermanentlyButton.click();
|
||||
await this.postActionConformationModal.waitFor({state: 'hidden'});
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip-report path: uncheck the download checkbox, submit the form to reach
|
||||
* the skip-confirm step, then confirm removal without a report.
|
||||
*/
|
||||
async confirmRemoveWithoutReport() {
|
||||
await this.downloadReportCheckbox.uncheck();
|
||||
await this.confirmRemoveMessageButton.click();
|
||||
await expect(this.removeWithoutReportButton).toBeVisible({timeout: 10000});
|
||||
await this.removeWithoutReportButton.click();
|
||||
await this.postActionConformationModal.waitFor({state: 'hidden'});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,381 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/**
|
||||
* Channel Classification E2E tests.
|
||||
* Tests the classification level assignment feature on both new and existing channels.
|
||||
*
|
||||
* Prerequisites: Enterprise-tier license + ClassificationMarkings feature flag enabled.
|
||||
*/
|
||||
|
||||
import {expect, test, getAdminClient, licenseTier} from '@mattermost/playwright-lib';
|
||||
import type {PlaywrightExtended} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
TEST_LEVELS,
|
||||
setClassificationMarkingsFeatureFlag,
|
||||
setupClassificationWithChannelField,
|
||||
deleteClassificationFieldsIfExist,
|
||||
} from './helpers';
|
||||
import type {ClassificationLevel} from './helpers';
|
||||
|
||||
let classificationLevels: ClassificationLevel[] = [];
|
||||
let setupComplete = false;
|
||||
|
||||
// Teams created by pw.initSetup() in each test are tracked here and deleted in
|
||||
// afterEach so local environments don't accumulate stale teams across runs.
|
||||
const createdTeamIds: string[] = [];
|
||||
|
||||
async function initSetupTracked(pw: PlaywrightExtended) {
|
||||
const setup = await pw.initSetup();
|
||||
createdTeamIds.push(setup.team.id);
|
||||
return setup;
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const {adminClient} = await getAdminClient();
|
||||
const license = await adminClient.getClientLicenseOld();
|
||||
if (licenseTier(license.SkuShortName) < 20) {
|
||||
return;
|
||||
}
|
||||
|
||||
await setClassificationMarkingsFeatureFlag(adminClient, true);
|
||||
const setup = await setupClassificationWithChannelField(adminClient);
|
||||
classificationLevels = setup.levels;
|
||||
setupComplete = true;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (!setupComplete) {
|
||||
return;
|
||||
}
|
||||
const {adminClient} = await getAdminClient();
|
||||
try {
|
||||
await deleteClassificationFieldsIfExist(adminClient);
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
const {adminClient} = await getAdminClient();
|
||||
const license = await adminClient.getClientLicenseOld();
|
||||
test.skip(licenseTier(license.SkuShortName) < 20, 'Channel classification requires Enterprise-tier license');
|
||||
test.skip(!setupComplete, 'Classification levels were not set up');
|
||||
|
||||
const config = await adminClient.getConfig();
|
||||
test.skip(
|
||||
config.FeatureFlags.ClassificationMarkings !== true,
|
||||
'ClassificationMarkings feature flag could not be enabled',
|
||||
);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (createdTeamIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const ids = createdTeamIds.splice(0);
|
||||
try {
|
||||
const {adminClient} = await getAdminClient({skipLog: true});
|
||||
await Promise.allSettled(ids.map((id) => adminClient.deleteTeam(id)));
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Channel Classification - New channel creation', () => {
|
||||
test('Enabling classification toggle without selecting values prevents channel creation', async ({pw}) => {
|
||||
const {adminUser, team} = await initSetupTracked(pw);
|
||||
const {channelsPage} = await pw.testBrowser.login(adminUser);
|
||||
await channelsPage.goto(team.name);
|
||||
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
|
||||
|
||||
const newChannelModal = await channelsPage.openNewChannelModal();
|
||||
await newChannelModal.fillDisplayName(`test-${pw.random.id()}`);
|
||||
await newChannelModal.publicTypeButton.click();
|
||||
|
||||
// Create button should be enabled before toggling classification
|
||||
await expect(newChannelModal.createButton).toBeEnabled();
|
||||
|
||||
// Enable classification toggle
|
||||
const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
|
||||
await classificationToggle.click();
|
||||
|
||||
// Create button should be disabled (no classification level selected, no banner text)
|
||||
await expect(newChannelModal.createButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('Classification dropdown displays the correct levels from the template', async ({pw}) => {
|
||||
const {adminUser, team} = await initSetupTracked(pw);
|
||||
const {channelsPage} = await pw.testBrowser.login(adminUser);
|
||||
await channelsPage.goto(team.name);
|
||||
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
|
||||
|
||||
const newChannelModal = await channelsPage.openNewChannelModal();
|
||||
await newChannelModal.fillDisplayName(`test-${pw.random.id()}`);
|
||||
|
||||
// Enable classification toggle
|
||||
const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
|
||||
await classificationToggle.click();
|
||||
|
||||
// Open the classification dropdown
|
||||
const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel');
|
||||
await dropdownContainer.click();
|
||||
|
||||
// Verify all test levels are present in the dropdown menu
|
||||
const menu = channelsPage.page.locator('.DropDown__menu');
|
||||
await expect(menu).toBeVisible();
|
||||
for (const level of TEST_LEVELS) {
|
||||
await expect(menu.getByText(level.name, {exact: true})).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('User can append text to the Banner Text field after selecting a classification', async ({pw}) => {
|
||||
const {adminUser, team} = await initSetupTracked(pw);
|
||||
const {channelsPage} = await pw.testBrowser.login(adminUser);
|
||||
await channelsPage.goto(team.name);
|
||||
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
|
||||
|
||||
const selectedLevel = classificationLevels.find((l) => l.name === 'SECRET');
|
||||
expect(selectedLevel).toBeDefined();
|
||||
|
||||
const newChannelModal = await channelsPage.openNewChannelModal();
|
||||
await newChannelModal.fillDisplayName(`test-${pw.random.id()}`);
|
||||
|
||||
// Enable classification toggle
|
||||
const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
|
||||
await classificationToggle.click();
|
||||
|
||||
// Select a classification level
|
||||
const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel');
|
||||
await dropdownContainer.click();
|
||||
const menu = channelsPage.page.locator('.DropDown__menu');
|
||||
await menu.getByText(selectedLevel!.name, {exact: true}).click();
|
||||
|
||||
// Banner text should be auto-populated with the bold level name
|
||||
const bannerTextbox = channelsPage.page.locator('#channel_classification_banner_text');
|
||||
await expect(bannerTextbox).toBeVisible();
|
||||
const currentValue = await bannerTextbox.inputValue();
|
||||
expect(currentValue).toContain(selectedLevel!.name);
|
||||
|
||||
// Append custom text to the banner
|
||||
await bannerTextbox.click();
|
||||
await bannerTextbox.press('End');
|
||||
await bannerTextbox.pressSequentially(' - Custom suffix');
|
||||
|
||||
const updatedValue = await bannerTextbox.inputValue();
|
||||
expect(updatedValue).toContain('Custom suffix');
|
||||
});
|
||||
|
||||
test('Creating channel with classification shows banner with correct color', async ({pw}) => {
|
||||
const {adminUser, team} = await initSetupTracked(pw);
|
||||
const {channelsPage} = await pw.testBrowser.login(adminUser);
|
||||
await channelsPage.goto(team.name);
|
||||
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
|
||||
|
||||
const selectedLevel = classificationLevels.find((l) => l.name === 'SECRET');
|
||||
expect(selectedLevel).toBeDefined();
|
||||
|
||||
const newChannelModal = await channelsPage.openNewChannelModal();
|
||||
await newChannelModal.fillDisplayName(`classified-${pw.random.id()}`);
|
||||
await newChannelModal.publicTypeButton.click();
|
||||
|
||||
// Enable classification toggle
|
||||
const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
|
||||
await classificationToggle.click();
|
||||
|
||||
// Select the classification level
|
||||
const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel');
|
||||
await dropdownContainer.click();
|
||||
const menu = channelsPage.page.locator('.DropDown__menu');
|
||||
await menu.getByText(selectedLevel!.name, {exact: true}).click();
|
||||
|
||||
// Wait for banner text to auto-populate, then create the channel
|
||||
const bannerTextbox = channelsPage.page.locator('#channel_classification_banner_text');
|
||||
await expect(bannerTextbox).toBeVisible();
|
||||
await expect(bannerTextbox).not.toHaveValue('');
|
||||
|
||||
await newChannelModal.create();
|
||||
|
||||
// Should be redirected to the new channel and center view loads
|
||||
await expect(channelsPage.page).toHaveURL(/\/channels\//, {timeout: 30000});
|
||||
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 30000});
|
||||
|
||||
// Channel banner should be visible (allow extra time for property value fetch)
|
||||
const banner = channelsPage.page.getByTestId('channel_banner_container');
|
||||
await expect(banner).toBeVisible({timeout: 30000});
|
||||
|
||||
// Verify the banner has the correct background color
|
||||
const actualBackgroundColor = await banner.evaluate((el) => {
|
||||
return window.getComputedStyle(el).getPropertyValue('background-color');
|
||||
});
|
||||
const expectedRgb = hexToRgb(selectedLevel!.color);
|
||||
expect(actualBackgroundColor).toBe(expectedRgb);
|
||||
|
||||
// Verify the banner contains the classification level name (rendered from **SECRET** markdown)
|
||||
await expect(banner).toContainText(selectedLevel!.name);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Channel Classification - Existing channel settings', () => {
|
||||
test('Classification toggle can be enabled from channel settings', async ({pw}) => {
|
||||
const {adminUser, team, adminClient} = await initSetupTracked(pw);
|
||||
|
||||
const channel = await adminClient.createChannel(
|
||||
pw.random.channel({teamId: team.id, name: `cls-${pw.random.id()}`, displayName: `Cls ${pw.random.id()}`}),
|
||||
);
|
||||
await adminClient.addToChannel(adminUser.id, channel.id);
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(adminUser);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
|
||||
|
||||
const channelSettingsModal = await channelsPage.openChannelSettings();
|
||||
await channelSettingsModal.openConfigurationTab();
|
||||
|
||||
// The classification toggle should be available
|
||||
const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
|
||||
await expect(classificationToggle).toBeVisible();
|
||||
|
||||
// Toggle it on
|
||||
const classes = await classificationToggle.getAttribute('class');
|
||||
if (!classes?.includes('active')) {
|
||||
await classificationToggle.click();
|
||||
}
|
||||
|
||||
// Toggle should now be active
|
||||
await expect(classificationToggle).toHaveClass(/active/);
|
||||
});
|
||||
|
||||
test('Classification level can be set once toggle is enabled', async ({pw}) => {
|
||||
const {adminUser, team, adminClient} = await initSetupTracked(pw);
|
||||
|
||||
const channel = await adminClient.createChannel(
|
||||
pw.random.channel({teamId: team.id, name: `cls-${pw.random.id()}`, displayName: `Cls ${pw.random.id()}`}),
|
||||
);
|
||||
await adminClient.addToChannel(adminUser.id, channel.id);
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(adminUser);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
|
||||
|
||||
const channelSettingsModal = await channelsPage.openChannelSettings();
|
||||
await channelSettingsModal.openConfigurationTab();
|
||||
|
||||
// Enable classification toggle
|
||||
const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
|
||||
await classificationToggle.click();
|
||||
|
||||
// Classification level dropdown should be visible
|
||||
const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel');
|
||||
await expect(dropdownContainer).toBeVisible();
|
||||
|
||||
// Open dropdown and select a level
|
||||
await dropdownContainer.click();
|
||||
const menu = channelsPage.page.locator('.DropDown__menu');
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
const selectedLevel = classificationLevels.find((l) => l.name === 'CONFIDENTIAL');
|
||||
expect(selectedLevel).toBeDefined();
|
||||
await menu.getByText(selectedLevel!.name, {exact: true}).click();
|
||||
|
||||
// The dropdown should now show the selected value
|
||||
await expect(dropdownContainer.getByText(selectedLevel!.name, {exact: true})).toBeVisible();
|
||||
});
|
||||
|
||||
test('Selecting classification locks banner toggle active and disabled, with matching color', async ({pw}) => {
|
||||
const {adminUser, team, adminClient} = await initSetupTracked(pw);
|
||||
|
||||
const channel = await adminClient.createChannel(
|
||||
pw.random.channel({teamId: team.id, name: `cls-${pw.random.id()}`, displayName: `Cls ${pw.random.id()}`}),
|
||||
);
|
||||
await adminClient.addToChannel(adminUser.id, channel.id);
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(adminUser);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
|
||||
|
||||
const channelSettingsModal = await channelsPage.openChannelSettings();
|
||||
await channelSettingsModal.openConfigurationTab();
|
||||
|
||||
// Enable classification and select a level
|
||||
const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
|
||||
await classificationToggle.click();
|
||||
|
||||
const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel');
|
||||
await dropdownContainer.click();
|
||||
|
||||
const selectedLevel = classificationLevels.find((l) => l.name === 'SECRET');
|
||||
expect(selectedLevel).toBeDefined();
|
||||
const menu = channelsPage.page.locator('.DropDown__menu');
|
||||
await menu.getByText(selectedLevel!.name, {exact: true}).click();
|
||||
|
||||
// The channel banner toggle should now be forced active and disabled
|
||||
const bannerToggle = channelsPage.page.getByTestId('channelBannerToggle-button');
|
||||
await expect(bannerToggle).toBeVisible();
|
||||
await expect(bannerToggle).toHaveClass(/active/);
|
||||
await expect(bannerToggle).toBeDisabled();
|
||||
|
||||
// Banner color input should be locked to the classification color
|
||||
const colorInput = channelsPage.page.locator('#channel_banner_banner_background_color_picker-inputColorValue');
|
||||
await expect(colorInput).toBeVisible();
|
||||
const colorValue = await colorInput.inputValue();
|
||||
expect(colorValue.toLowerCase().replace('#', '')).toBe(selectedLevel!.color.toLowerCase().replace('#', ''));
|
||||
});
|
||||
|
||||
test('Editing banner text and saving updates the banner in real time', async ({pw}) => {
|
||||
const {adminUser, team, adminClient} = await initSetupTracked(pw);
|
||||
|
||||
const channel = await adminClient.createChannel(
|
||||
pw.random.channel({teamId: team.id, name: `cls-${pw.random.id()}`, displayName: `Cls ${pw.random.id()}`}),
|
||||
);
|
||||
await adminClient.addToChannel(adminUser.id, channel.id);
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(adminUser);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
|
||||
|
||||
const channelSettingsModal = await channelsPage.openChannelSettings();
|
||||
const configurationTab = await channelSettingsModal.openConfigurationTab();
|
||||
|
||||
// Enable classification and select a level
|
||||
const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
|
||||
await classificationToggle.click();
|
||||
|
||||
const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel');
|
||||
await dropdownContainer.click();
|
||||
|
||||
const selectedLevel = classificationLevels.find((l) => l.name === 'TOP SECRET');
|
||||
expect(selectedLevel).toBeDefined();
|
||||
const menu = channelsPage.page.locator('.DropDown__menu');
|
||||
await menu.getByText(selectedLevel!.name, {exact: true}).click();
|
||||
|
||||
// Edit the banner text to a custom value
|
||||
const customBannerText = 'TOP SECRET - Handle via COMINT channels only';
|
||||
const bannerTextbox = channelsPage.page.locator('#channel_banner_banner_text_textbox');
|
||||
await expect(bannerTextbox).toBeVisible();
|
||||
await bannerTextbox.fill(customBannerText);
|
||||
|
||||
// Save the changes
|
||||
await configurationTab.save();
|
||||
await channelSettingsModal.close();
|
||||
|
||||
// The channel banner should now show the custom text with the classification color
|
||||
const banner = channelsPage.page.getByTestId('channel_banner_container');
|
||||
await expect(banner).toBeVisible({timeout: 30000});
|
||||
await expect(banner).toContainText(customBannerText);
|
||||
|
||||
const actualBackgroundColor = await banner.evaluate((el) => {
|
||||
return window.getComputedStyle(el).getPropertyValue('background-color');
|
||||
});
|
||||
expect(actualBackgroundColor).toBe(hexToRgb(selectedLevel!.color));
|
||||
});
|
||||
});
|
||||
|
||||
function hexToRgb(hex: string): string {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (!result) {
|
||||
return hex;
|
||||
}
|
||||
return `rgb(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)})`;
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Client4} from '@mattermost/client';
|
||||
|
||||
const PROPERTY_GROUP = 'access_control';
|
||||
const TEMPLATE_OBJECT_TYPE = 'template';
|
||||
const CHANNEL_OBJECT_TYPE = 'channel';
|
||||
const TARGET_TYPE = 'system';
|
||||
const CLASSIFICATION_FIELD_NAME = 'classification';
|
||||
const CHANNEL_LINKED_FIELD_NAME = 'channel_classification';
|
||||
|
||||
export const TEST_LEVELS = [
|
||||
{name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
|
||||
{name: 'CONFIDENTIAL', color: '#0033A0', rank: 2},
|
||||
{name: 'SECRET', color: '#C8102E', rank: 3},
|
||||
{name: 'TOP SECRET', color: '#FF8C00', rank: 4},
|
||||
];
|
||||
|
||||
/**
|
||||
* Sets the ClassificationMarkings feature flag via server config.
|
||||
*/
|
||||
export async function setClassificationMarkingsFeatureFlag(adminClient: Client4, enabled: boolean) {
|
||||
const config = await adminClient.getConfig();
|
||||
await adminClient.updateConfig({
|
||||
...config,
|
||||
FeatureFlags: {
|
||||
...config.FeatureFlags,
|
||||
ClassificationMarkings: enabled,
|
||||
},
|
||||
} as Awaited<ReturnType<Client4['getConfig']>>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes existing classification fields (channel linked, system linked, and template)
|
||||
* to provide a clean slate.
|
||||
*/
|
||||
export async function deleteClassificationFieldsIfExist(adminClient: Client4) {
|
||||
// Delete channel linked fields first
|
||||
try {
|
||||
const channelFields = await adminClient.getPropertyFields(PROPERTY_GROUP, CHANNEL_OBJECT_TYPE, TARGET_TYPE, '');
|
||||
for (const f of channelFields.filter((f) => f.name === CHANNEL_LINKED_FIELD_NAME && f.delete_at === 0)) {
|
||||
await adminClient.deletePropertyField(PROPERTY_GROUP, CHANNEL_OBJECT_TYPE, f.id);
|
||||
}
|
||||
} catch {
|
||||
// May not exist
|
||||
}
|
||||
|
||||
// Delete system linked fields
|
||||
for (const objectType of ['system', 'user'] as const) {
|
||||
try {
|
||||
const linkedFields = await adminClient.getPropertyFields(PROPERTY_GROUP, objectType, TARGET_TYPE, '');
|
||||
for (const f of linkedFields.filter(
|
||||
(f) => f.name === 'system_classification' && f.delete_at === 0 && f.linked_field_id,
|
||||
)) {
|
||||
await adminClient.deletePropertyField(PROPERTY_GROUP, objectType, f.id);
|
||||
}
|
||||
} catch {
|
||||
// May not exist
|
||||
}
|
||||
}
|
||||
|
||||
// Delete template fields
|
||||
try {
|
||||
const fields = await adminClient.getPropertyFields(PROPERTY_GROUP, TEMPLATE_OBJECT_TYPE, TARGET_TYPE);
|
||||
for (const f of fields.filter((f) => f.name === CLASSIFICATION_FIELD_NAME && f.delete_at === 0)) {
|
||||
await adminClient.deletePropertyField(PROPERTY_GROUP, TEMPLATE_OBJECT_TYPE, f.id);
|
||||
}
|
||||
} catch {
|
||||
// May not exist
|
||||
}
|
||||
}
|
||||
|
||||
export type ClassificationLevel = {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
rank: number;
|
||||
};
|
||||
|
||||
export type SetupResult = {
|
||||
templateFieldId: string;
|
||||
channelFieldId: string;
|
||||
levels: ClassificationLevel[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates the full classification setup: template field + channel linked field.
|
||||
* Returns the created fields and the resolved levels (with server-assigned IDs).
|
||||
*/
|
||||
export async function setupClassificationWithChannelField(
|
||||
adminClient: Client4,
|
||||
levels: Array<{name: string; color: string; rank: number}> = TEST_LEVELS,
|
||||
): Promise<SetupResult> {
|
||||
await deleteClassificationFieldsIfExist(adminClient);
|
||||
|
||||
// Create template field
|
||||
const templateField = await adminClient.createPropertyField(PROPERTY_GROUP, TEMPLATE_OBJECT_TYPE, {
|
||||
name: CLASSIFICATION_FIELD_NAME,
|
||||
type: 'select',
|
||||
target_type: TARGET_TYPE,
|
||||
target_id: '',
|
||||
attrs: {
|
||||
options: levels.map((l) => ({id: '', name: l.name, color: l.color, rank: l.rank})),
|
||||
},
|
||||
permission_field: 'admin',
|
||||
permission_values: 'admin',
|
||||
permission_options: 'admin',
|
||||
} as Parameters<Client4['createPropertyField']>[2]);
|
||||
|
||||
// Create channel linked field
|
||||
const channelField = await adminClient.createPropertyField(PROPERTY_GROUP, CHANNEL_OBJECT_TYPE, {
|
||||
name: CHANNEL_LINKED_FIELD_NAME,
|
||||
type: 'select',
|
||||
target_type: TARGET_TYPE,
|
||||
target_id: '',
|
||||
linked_field_id: templateField.id,
|
||||
} as Parameters<Client4['createPropertyField']>[2]);
|
||||
|
||||
// Resolve levels with server-assigned IDs
|
||||
const options = (templateField.attrs?.options ?? []) as ClassificationLevel[];
|
||||
const resolvedLevels = options.sort((a, b) => a.rank - b.rank);
|
||||
|
||||
return {templateFieldId: templateField.id, channelFieldId: channelField.id, levels: resolvedLevels};
|
||||
}
|
||||
|
|
@ -1,14 +1,38 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
import {expect, test, getAdminClient} from '@mattermost/playwright-lib';
|
||||
import type {PlaywrightExtended} from '@mattermost/playwright-lib';
|
||||
|
||||
// Teams created by pw.initSetup() in each test are tracked here and deleted in
|
||||
// afterEach so local environments don't accumulate stale teams across runs.
|
||||
const createdTeamIds: string[] = [];
|
||||
|
||||
async function initSetupTracked(pw: PlaywrightExtended) {
|
||||
const setup = await pw.initSetup();
|
||||
createdTeamIds.push(setup.team.id);
|
||||
return setup;
|
||||
}
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (createdTeamIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const ids = createdTeamIds.splice(0);
|
||||
try {
|
||||
const {adminClient} = await getAdminClient({skipLog: true});
|
||||
await Promise.allSettled(ids.map((id) => adminClient.deleteTeam(id)));
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'sidebar icon updates from globe to lock when channel converted to private via API',
|
||||
{tag: ['@channels', '@channel_privacy']},
|
||||
async ({pw}) => {
|
||||
// # Initialize setup
|
||||
const {adminClient, user, team} = await pw.initSetup();
|
||||
const {adminClient, user, team} = await initSetupTracked(pw);
|
||||
|
||||
// # Create a public channel
|
||||
const channel = await adminClient.createChannel(
|
||||
|
|
@ -47,7 +71,7 @@ test(
|
|||
{tag: ['@channels', '@channel_privacy']},
|
||||
async ({pw}) => {
|
||||
// # Initialize setup
|
||||
const {adminClient, user, team} = await pw.initSetup();
|
||||
const {adminClient, user, team} = await initSetupTracked(pw);
|
||||
|
||||
// # Create a private channel
|
||||
const channel = await adminClient.createChannel(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/**
|
||||
* @objective E2E coverage for the v0.4 Permissions Policy tab in Channel Settings:
|
||||
* - Tab visibility is gated by ABAC + license + the PermissionPolicies
|
||||
* umbrella + the ChannelPermissionPolicies sub-flag.
|
||||
* - The list view exposes Add rule, search, and a paginated rules table.
|
||||
* - Adding a permission rule (name, role, actions) and committing returns to the list.
|
||||
* - Per-rule expression uses the same TableEditor as Membership Policy, but
|
||||
* re-labelled "Simulate rules" — the button opens the dual-lane
|
||||
* SimulateAccessModal instead of the legacy expression-only one (additionally
|
||||
* gated by the PolicySimulation feature flag).
|
||||
* - Duplicate rule names surface a save-time error.
|
||||
*
|
||||
* @reference Channel-scoped permission policies (v0.4)
|
||||
*
|
||||
* These tests skip themselves at runtime when the PermissionPolicies umbrella
|
||||
* OR the ChannelPermissionPolicies sub-flag is not enabled on the server —
|
||||
* the tab is invisible in either case and the workflow is not exercised.
|
||||
* Run with `MM_FEATUREFLAGS_PERMISSIONPOLICIES=true` AND
|
||||
* `MM_FEATUREFLAGS_CHANNELPERMISSIONPOLICIES=true`. The "Simulate rules"
|
||||
* button additionally requires `MM_FEATUREFLAGS_POLICYSIMULATION=true`.
|
||||
*/
|
||||
|
||||
import {ChannelsPage, expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
import {enableABACConfig, ensureDepartmentAttribute, createPrivateChannel} from '../team_settings/helpers';
|
||||
|
||||
test.describe('Channel Settings Modal - Permissions Policy tab (v0.4)', () => {
|
||||
test.beforeEach(async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
// Skip the suite when either flag is OFF on the server rather
|
||||
// than relying on the tab's UI presence as a proxy — a
|
||||
// UI-based guard would silently mask a regression in the tab
|
||||
// visibility logic itself. Both flags must be on for the tab
|
||||
// to render (the channel-scope sub-flag depends on the
|
||||
// umbrella, mirroring `IsChannelPermissionPoliciesEnabled` on
|
||||
// the server).
|
||||
await pw.skipIfFeatureFlagNotSet('PermissionPolicies', true);
|
||||
await pw.skipIfFeatureFlagNotSet('ChannelPermissionPolicies', true);
|
||||
});
|
||||
|
||||
test('MM-PP_v0_4_c1 Permissions Policy tab visible on private channel when feature flag enabled', async ({pw}) => {
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await enableABACConfig(adminClient);
|
||||
|
||||
const channel = await createPrivateChannel(adminClient, team.id);
|
||||
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
const permissionsTab = channelSettings.container.getByTestId('permissions_policy-tab-button');
|
||||
|
||||
// Suite-level feature-flag guard already covers PermissionPolicies;
|
||||
// assert visibility unconditionally so a regression in the tab's
|
||||
// render gate would surface as a test failure.
|
||||
await expect(permissionsTab).toBeVisible();
|
||||
|
||||
// # Open Permissions Policy
|
||||
await permissionsTab.click();
|
||||
|
||||
// * The list view renders: header, search, table, Add rule button.
|
||||
await expect(channelSettings.container.locator('.ChannelSettingsModal__permissionsPolicyTab')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(channelSettings.container.getByTestId('permissions-policy-add-rule')).toBeVisible();
|
||||
await expect(channelSettings.container.getByTestId('permissions-policy-search')).toBeVisible();
|
||||
await expect(channelSettings.container.getByTestId('permissions-policy-rules-table')).toBeVisible();
|
||||
|
||||
await channelSettings.close();
|
||||
});
|
||||
|
||||
test('MM-PP_v0_4_c2 Add rule opens the editor with sections in the expected order, Cancel returns to the list', async ({
|
||||
pw,
|
||||
}) => {
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await enableABACConfig(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
const channel = await createPrivateChannel(adminClient, team.id);
|
||||
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
const permissionsTab = channelSettings.container.getByTestId('permissions_policy-tab-button');
|
||||
await expect(permissionsTab).toBeVisible();
|
||||
await permissionsTab.click();
|
||||
|
||||
const tab = channelSettings.container.locator('.ChannelSettingsModal__permissionsPolicyTab');
|
||||
await expect(tab).toBeVisible({timeout: 10000});
|
||||
|
||||
// # Click "Add rule" → editor view appears
|
||||
await tab.getByTestId('permissions-policy-add-rule').click();
|
||||
const editor = channelSettings.container.getByTestId('permissions-policy-editor');
|
||||
await expect(editor).toBeVisible({timeout: 5000});
|
||||
|
||||
// * Mirror system-console layout: name → role → user-attribute conditions → permissions list.
|
||||
const expressionSection = editor.getByTestId('permissions-policy-editor-expression-section');
|
||||
const permissionsSection = editor.getByTestId('permissions-policy-editor-permissions-section');
|
||||
await expect(expressionSection).toBeVisible();
|
||||
await expect(permissionsSection).toBeVisible();
|
||||
|
||||
// Permissions list must render BELOW the user-attribute conditions
|
||||
// (TableEditor) so we match the System Console policy editor ordering.
|
||||
const expressionBox = await expressionSection.boundingBox();
|
||||
const permissionsBox = await permissionsSection.boundingBox();
|
||||
expect(expressionBox).not.toBeNull();
|
||||
expect(permissionsBox).not.toBeNull();
|
||||
expect(permissionsBox?.y ?? 0).toBeGreaterThan((expressionBox?.y ?? 0) + (expressionBox?.height ?? 0) - 1);
|
||||
|
||||
// # Cancel → back to list view
|
||||
await channelSettings.container.getByTestId('permissions-policy-editor-cancel').click();
|
||||
await expect(tab).toBeVisible({timeout: 5000});
|
||||
await expect(channelSettings.container.getByTestId('permissions-policy-editor')).not.toBeVisible();
|
||||
|
||||
await channelSettings.close();
|
||||
});
|
||||
|
||||
test('MM-PP_v0_4_c3 Editor validates: missing name surfaces an inline error', async ({pw}) => {
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await enableABACConfig(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
const channel = await createPrivateChannel(adminClient, team.id);
|
||||
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
const permissionsTab = channelSettings.container.getByTestId('permissions_policy-tab-button');
|
||||
await expect(permissionsTab).toBeVisible();
|
||||
await permissionsTab.click();
|
||||
|
||||
const tab = channelSettings.container.locator('.ChannelSettingsModal__permissionsPolicyTab');
|
||||
await expect(tab).toBeVisible({timeout: 10000});
|
||||
|
||||
// # Open the editor without filling the name
|
||||
await tab.getByTestId('permissions-policy-add-rule').click();
|
||||
await channelSettings.container.getByTestId('permissions-policy-editor-save').click();
|
||||
|
||||
// * Inline error mentions name uniqueness/required.
|
||||
await expect(channelSettings.container.getByTestId('permissions-policy-editor-error')).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
await channelSettings.close();
|
||||
});
|
||||
});
|
||||
|
|
@ -14,8 +14,8 @@ import {setupContentFlagging, createPost} from './../support';
|
|||
* 4. Login as the reviewer and navigate to the content review DM
|
||||
* 5. Verify the deletion report summary table is posted in the reviewer's thread
|
||||
*/
|
||||
test.fixme('Reviewer receives a deletion report summary after removing a flagged post', async ({pw}) => {
|
||||
const {adminClient, team, user: reviewerUser, userClient: reviewerUserClient} = await pw.initSetup();
|
||||
test('Reviewer receives a deletion report summary after removing a flagged post', async ({pw}) => {
|
||||
const {adminClient, team, user: reviewerUser} = await pw.initSetup();
|
||||
|
||||
// Create author user..
|
||||
const authorUser = await pw.random.user('author');
|
||||
|
|
@ -28,18 +28,27 @@ test.fixme('Reviewer receives a deletion report summary after removing a flagged
|
|||
const message = `Sensitive 2 post by @${authorUser.username} to be removed`;
|
||||
const {post} = await createPost(adminClient, authorUserClient, team, authorUser, message);
|
||||
|
||||
// Flag and remove the post
|
||||
// Flag the post
|
||||
await adminClient.flagPost(post.id, 'Classification mismatch', 'This message contains sensitive data');
|
||||
|
||||
// Login as reviewer and navigate to content review DM
|
||||
const {channelsPage} = await pw.testBrowser.login(reviewerUser);
|
||||
const {channelsPage, contentReviewPage} = await pw.testBrowser.login(reviewerUser);
|
||||
await channelsPage.goto(team.name, '@content-review');
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const lastPost = await channelsPage.centerView.getLastPost();
|
||||
await lastPost.toContainText(message);
|
||||
|
||||
await reviewerUserClient.removeFlaggedPost(post.id, 'Removing: data spillage confirmed');
|
||||
// Remove the post via the UI. The remove flow still routes through the
|
||||
// skip-confirm step (destructive action), unlike the keep flow which now
|
||||
// bypasses it.
|
||||
await contentReviewPage.setReportCardByPostID(post.id);
|
||||
await contentReviewPage.openViewDetails();
|
||||
await contentReviewPage.waitForRHSVisible();
|
||||
await contentReviewPage.openViewDetails();
|
||||
await contentReviewPage.clickRemoveMessage();
|
||||
await contentReviewPage.enterConfirmationComment('Removing: data spillage confirmed');
|
||||
await contentReviewPage.confirmRemoveWithoutReport();
|
||||
|
||||
await channelsPage.goto(team.name, '@content-review');
|
||||
await channelsPage.toBeVisible();
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@ import {test} from '@mattermost/playwright-lib';
|
|||
|
||||
import {setupContentFlagging, createPost, verifyAuthorNotification} from './../support';
|
||||
|
||||
/** @objective Verify Retained and Removed Flagged posts do not appear in RHS after once reviewed
|
||||
/** @objective Verify Removed Flagged posts show appropriate status and do not show the post message
|
||||
* @testcase
|
||||
* 1. Create three users and add them as reviewers to a team
|
||||
* 2. Setup content flagging with the three users as reviewers
|
||||
* 3. Create a post and flag it
|
||||
* 4. As Reviewer 1, Retain the flagged post and verify the status is updated to 'Retained'
|
||||
* 5. As Reviewer 2, Verify the flagged post status is 'Retained'
|
||||
* 4. As Reviewer 1, walk through the multi-step removal flow (form → report generated → remove permanently)
|
||||
* 5. As Reviewer 2, verify the flagged post status is 'Removed' and the message has been replaced
|
||||
*/
|
||||
test('Verify Removed Flagged posts show appropriate status and do not show the post message', async ({pw}) => {
|
||||
const {adminClient, team, user, userClient, adminUser} = await pw.initSetup();
|
||||
|
|
@ -72,7 +72,10 @@ test('Verify Removed Flagged posts show appropriate status and do not show the p
|
|||
});
|
||||
await secondContentReviewPage.clickRemoveMessage();
|
||||
await secondContentReviewPage.enterConfirmationComment(commentRemove);
|
||||
await secondContentReviewPage.confirmRemove();
|
||||
|
||||
// New multi-step flow: Continue → wait for report to generate → Remove permanently
|
||||
await secondContentReviewPage.submitFormAndWaitForReport();
|
||||
await secondContentReviewPage.confirmRemovePermanently();
|
||||
await setupContentFlagging(adminClient, [adminUser.id, secondUserID, thirdUserID]);
|
||||
|
||||
const {channelsPage: channelsPageThird, contentReviewPage: contentReviewPageThird} =
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export type CustomProfileAttribute = {
|
|||
visibility?: string;
|
||||
managed?: string;
|
||||
options?: {name: string; color: string}[];
|
||||
display_name?: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Channel} from '@mattermost/types/channels';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
import type {Page} from '@playwright/test';
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
/**
|
||||
* @objective Verify that a group message whose channel has fallen out of the sidebar (because the user
|
||||
* has more DMs/GMs than the configured "Number of direct messages to show" limit) still appears in the
|
||||
* Direct Messages modal with its members fully loaded — i.e. with a non-zero member count and the
|
||||
* participant usernames as its name.
|
||||
*/
|
||||
test(
|
||||
"MM-65058 Direct Messages modal should load group members for GMs which haven't been loaded otherwise",
|
||||
{tag: '@direct_messages'},
|
||||
async ({pw}) => {
|
||||
const {adminClient, user, userClient, team} = await pw.initSetup({withDefaultProfileImage: false});
|
||||
|
||||
// Use a lower visible DM limit than the UI normally lets you use to speed up this test
|
||||
const totalGms = 2;
|
||||
const visibleLimit = 1;
|
||||
|
||||
// # Limit the user's visible DMs/GMs in the sidebar so one GM falls off the sidebar
|
||||
await userClient.savePreferences(user.id, [
|
||||
{
|
||||
user_id: user.id,
|
||||
category: 'sidebar_settings',
|
||||
name: 'limit_visible_dms_gms',
|
||||
value: visibleLimit.toString(),
|
||||
},
|
||||
]);
|
||||
|
||||
// # Create enough users to populate 11 GMs with unique users
|
||||
const users = [];
|
||||
for (let i = 0; i < totalGms * 2; i++) {
|
||||
const user = await pw.createNewUserProfile(adminClient, {prefix: `mm65058gm${i}`});
|
||||
users.push(user);
|
||||
}
|
||||
|
||||
// # Log the user in and open the channels page
|
||||
const {page, channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, 'town-square');
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// # Create 11 GMs using the Direct Channels modal
|
||||
const gmChannels = [];
|
||||
for (let i = 0; i < totalGms; i++) {
|
||||
const memberA = users[i * 2];
|
||||
const memberB = users[i * 2 + 1];
|
||||
|
||||
// # Open the modal
|
||||
const dialog = await channelsPage.openDirectChannelsModal();
|
||||
|
||||
// # Select the users and create the channel
|
||||
await dialog.selectUser(memberA);
|
||||
await dialog.selectUser(memberB);
|
||||
await dialog.goToChannel();
|
||||
|
||||
// # Make a post in the channel to ensure that it has a last_post_at value
|
||||
await channelsPage.postMessage(`gm message ${i}`);
|
||||
|
||||
// # Save the channel's information for later
|
||||
gmChannels.push({
|
||||
channel: await getCurrentChannel(page),
|
||||
members: [memberA, memberB],
|
||||
});
|
||||
}
|
||||
|
||||
const targetGm = gmChannels[0];
|
||||
const otherGms = gmChannels.slice(1);
|
||||
|
||||
// # Refresh the app and go back to Town Square
|
||||
await channelsPage.goto(team.name, 'town-square');
|
||||
|
||||
// * Verify the target GM is not present in the sidebar to ensure that the sidebar hasn't loaded it
|
||||
await expect(page.locator(`#sidebarItem_${targetGm.channel.name}`)).toHaveCount(0);
|
||||
|
||||
// * Wait until the other GMs are loaded and present in the sidebar
|
||||
for (const otherGm of otherGms) {
|
||||
const otherGmEntry = page.locator(`#sidebarItem_${otherGm.channel.name}`);
|
||||
|
||||
await expect(otherGmEntry).toHaveCount(1);
|
||||
await expect(otherGmEntry).toContainText(gmChannelDisplayName(otherGm.members));
|
||||
}
|
||||
|
||||
// * Verify that the members of the target GM haven't been loaded and the members of other GMs have
|
||||
await assertChannelUsersNotLoaded(page, targetGm.channel.id);
|
||||
for (const otherGm of otherGms) {
|
||||
await assertChannelUsersLoaded(page, otherGm.channel.id, otherGm.members);
|
||||
}
|
||||
|
||||
// # Open the Direct Messages modal again
|
||||
const dialog = await channelsPage.openDirectChannelsModal();
|
||||
|
||||
// # Wait for the list to populate
|
||||
const rows = dialog.container.locator('#multiSelectList .more-modal__row');
|
||||
await expect.poll(async () => rows.count()).toBeGreaterThanOrEqual(totalGms);
|
||||
|
||||
// * Verify the modal contains an entry for every GM the user has, including the one that fell
|
||||
// * out of the sidebar
|
||||
for (const {channel, members} of gmChannels) {
|
||||
// Each GM row renders the member usernames joined by ', '. We use the second member's
|
||||
// username (which is unique per GM) to locate the corresponding row.
|
||||
const usernameMarker = `@${members[1].username}`;
|
||||
const gmRow = rows.filter({hasText: usernameMarker});
|
||||
|
||||
// * Verify the row is rendered
|
||||
await expect(gmRow, `expected to find a row in the DM modal for GM ${channel.id}`).toHaveCount(1);
|
||||
|
||||
// * Verify the GM icon shows the correct member count (channel members minus current user)
|
||||
await expect(
|
||||
gmRow.locator('.more-modal__gm-icon'),
|
||||
`expected GM ${channel.id} to show a member count of ${members.length}`,
|
||||
).toHaveText(members.length.toString());
|
||||
|
||||
// * Verify the row's name section includes every participant's username
|
||||
const nameContainer = gmRow.locator('.more-modal__name');
|
||||
for (const participant of members) {
|
||||
await expect(
|
||||
nameContainer,
|
||||
`expected GM ${channel.id} to include @${participant.username} in its name`,
|
||||
).toContainText(`@${participant.username}`);
|
||||
}
|
||||
}
|
||||
|
||||
// * Double check that the members of the target GM have been loaded now
|
||||
await assertChannelUsersLoaded(page, targetGm.channel.id, targetGm.members);
|
||||
},
|
||||
);
|
||||
|
||||
async function getCurrentChannel(page: Page) {
|
||||
return await page.evaluate<Channel>(
|
||||
'store.getState().entities.channels.channels[store.getState().entities.channels.currentChannelId]',
|
||||
);
|
||||
}
|
||||
|
||||
function gmChannelDisplayName(users: UserProfile[]) {
|
||||
return users
|
||||
.toSorted((a, b) => {
|
||||
return a.username.localeCompare(b.username, undefined, {numeric: true});
|
||||
})
|
||||
.map((user) => user.username)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
async function assertChannelUsersLoaded(page: Page, channelId: string, expectedUsers: UserProfile[]) {
|
||||
// profilesInChannel contains Sets which aren't serializable for return from page.evaluate
|
||||
const loadedIds = await page.evaluate(
|
||||
`Array.from(store.getState().entities.users.profilesInChannel['${channelId}'])`,
|
||||
);
|
||||
|
||||
await expect(loadedIds).toHaveLength(expectedUsers.length);
|
||||
await expect(loadedIds).toEqual(expect.arrayContaining(expectedUsers.map((user) => user.id)));
|
||||
}
|
||||
|
||||
async function assertChannelUsersNotLoaded(page: Page, channelId: string) {
|
||||
const loadedIds = await page.evaluate(`store.getState().entities.users.profilesInChannel['${channelId}']`);
|
||||
|
||||
await expect(loadedIds).toBeUndefined();
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
/**
|
||||
* @objective Verify that webpack-bundled static image assets are not broken by
|
||||
* the image-minimizer-webpack-plugin (sharp) migration.
|
||||
*
|
||||
* Sharp runs at build time and compresses PNG/JPEG/SVG assets bundled into the
|
||||
* app. If it mis-encodes a file the asset will either 404, return a wrong
|
||||
* content-type, or decode to a zero-width image in the browser.
|
||||
*
|
||||
* This test catches that by reloading the page and asserting:
|
||||
* - no static asset request returns 4xx/5xx
|
||||
* - no <img> in the DOM has naturalWidth === 0 (failed to decode)
|
||||
*/
|
||||
|
||||
test('app loads without any broken image assets on the main channel view', {tag: '@image_assets'}, async ({pw}) => {
|
||||
const {user} = await pw.initSetup();
|
||||
const {page, channelsPage} = await pw.testBrowser.login(user);
|
||||
|
||||
await channelsPage.goto();
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// # Collect image/font load errors on reload so the response listener is
|
||||
// active before any requests fire.
|
||||
const failedImageUrls: string[] = [];
|
||||
page.on('response', (response) => {
|
||||
const url = response.url();
|
||||
const isImage = /\.(png|jpg|jpeg|svg|gif|woff2|woff)(\?|$)/.test(url);
|
||||
if (isImage && response.status() >= 400) {
|
||||
failedImageUrls.push(`${response.status()} ${url}`);
|
||||
}
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// * No image/font requests should return 4xx or 5xx
|
||||
expect(failedImageUrls, `Failed asset requests:\n${failedImageUrls.join('\n')}`).toHaveLength(0);
|
||||
|
||||
// * No <img> element should have naturalWidth === 0 (means the file was
|
||||
// served but the browser could not decode it — typical of a sharp corruption)
|
||||
const brokenImages = await page.evaluate(() => {
|
||||
return Array.from(document.querySelectorAll('img'))
|
||||
.filter((img) => img.complete && img.naturalWidth === 0 && Boolean(img.src))
|
||||
.map((img) => img.src);
|
||||
});
|
||||
|
||||
expect(brokenImages, `Broken <img> elements found:\n${brokenImages.join('\n')}`).toHaveLength(0);
|
||||
});
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
/**
|
||||
* @objective Verify that the pdfjs cmaps path fix in webpack.config.js works.
|
||||
*
|
||||
* Context: PR #35810 changed the copy-webpack-plugin entry for pdfjs cmaps from
|
||||
* a fragile relative path to one resolved via require.resolve():
|
||||
*
|
||||
* Before: {from: '../node_modules/pdfjs-dist/cmaps', to: 'cmaps'}
|
||||
* After: {from: path.join(path.dirname(require.resolve('pdfjs-dist/package.json')), 'cmaps'), to: 'cmaps'}
|
||||
*
|
||||
* The old path broke with npm workspace hoisting — pdfjs-dist gets installed at
|
||||
* the root node_modules, not channels/node_modules, so the relative path resolves
|
||||
* to nothing and copy-webpack-plugin silently copies zero files.
|
||||
*
|
||||
* A 404 on identity-h means the cmaps directory was not copied to /static/cmaps/.
|
||||
*/
|
||||
|
||||
test('pdfjs cmaps are copied to /static/cmaps/ and served correctly', {tag: '@pdf_preview'}, async ({pw}) => {
|
||||
const {user} = await pw.initSetup();
|
||||
const {page} = await pw.testBrowser.login(user);
|
||||
|
||||
const baseUrl = new URL(page.url()).origin;
|
||||
|
||||
// # identity-h is a standard CMap that pdfjs requests for non-Latin PDFs.
|
||||
// Its presence confirms copy-webpack-plugin found and copied the cmaps directory.
|
||||
const cmapUrl = `${baseUrl}/static/cmaps/identity-h`;
|
||||
const response = await page.request.get(cmapUrl);
|
||||
|
||||
// * 200 = require.resolve() found the right path and cmaps were copied.
|
||||
// * 404 = the path in webpack.config.js is wrong or copy-webpack-plugin skipped it.
|
||||
expect(
|
||||
response.status(),
|
||||
`CMap not found at ${cmapUrl} — pdfjs cmaps were not copied to /static/cmaps/. ` +
|
||||
'Check the copy-webpack-plugin entry in webpack.config.js.',
|
||||
).toBe(200);
|
||||
|
||||
const body = await response.body();
|
||||
expect(body.length, 'identity-h CMap file must not be empty').toBeGreaterThan(0);
|
||||
});
|
||||
|
|
@ -53,9 +53,7 @@ test.describe('Permission Policies - Create Policy', () => {
|
|||
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,
|
||||
}) => {
|
||||
test('MM-T5806 create policy form shows role dropdown defaulting to Members', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
|
|
@ -71,10 +69,17 @@ test.describe('Permission Policies - Create Policy', () => {
|
|||
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")
|
||||
// * The dropdown button is visible and shows the default role
|
||||
// (system_user). The label was shortened from "Members and
|
||||
// system administrators" to just "Members" in the UX pass;
|
||||
// the "system admins fall back when no admin-specific rule
|
||||
// exists" semantics moved into the role's description copy.
|
||||
// Use an exact-text matcher so a regression to the longer
|
||||
// "Members and system administrators" label fails the test
|
||||
// instead of silently passing the substring check.
|
||||
const roleButton = systemConsolePage.page.locator('#pp-role-selector-btn');
|
||||
await expect(roleButton).toBeVisible();
|
||||
await expect(roleButton).toContainText('Members and system administrators');
|
||||
await expect(roleButton).toHaveText('Members');
|
||||
});
|
||||
|
||||
test('MM-T5807 admin can change role selection to System administrators via dropdown', async ({pw}) => {
|
||||
|
|
|
|||
|
|
@ -44,7 +44,11 @@ test.describe('Permission Policies - Create Policy', () => {
|
|||
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();
|
||||
// Role label was shortened from "Members and system administrators"
|
||||
// to just "Members" in the UX pass; the "system admins fall back
|
||||
// when no admin-specific rule exists" semantics moved into the
|
||||
// role's description copy.
|
||||
await expect(policyRow.getByText('Members', {exact: true})).toBeVisible();
|
||||
await expect(policyRow.getByText('Download Files')).toBeVisible();
|
||||
} finally {
|
||||
await deletePermissionPolicyByName(adminClient, policyName);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,179 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, test, enableABAC, navigateToABACPage} from '@mattermost/playwright-lib';
|
||||
|
||||
import {createBasicPolicy, getPolicyIdByName} from '../support';
|
||||
|
||||
/**
|
||||
* @objective E2E coverage for membership policy edit action navigation:
|
||||
* - Clicking Edit in the policy row action menu navigates to the membership policy editor
|
||||
*
|
||||
* @reference MM-68958: Fix membership policy edit action navigation
|
||||
*/
|
||||
test.describe('ABAC Policy Management - Edit Action Navigation', () => {
|
||||
/**
|
||||
* MM-68958: Edit action in policy row menu navigates to membership policy editor
|
||||
*
|
||||
* Steps:
|
||||
* 1. Enable ABAC and create a membership policy
|
||||
* 2. Navigate to System Console > Membership Policies
|
||||
* 3. Open a policy row's three-dot action menu
|
||||
* 4. Click Edit
|
||||
* 5. Verify the URL is /admin_console/system_attributes/membership_policies/edit_policy/<policy_id>
|
||||
*/
|
||||
test('MM-68958 Edit action navigates to membership policy editor', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
// Create a test channel for the policy
|
||||
const channelName = `abac-edit-nav-test-${pw.random.id()}`;
|
||||
const privateChannel = await adminClient.createChannel({
|
||||
team_id: team.id,
|
||||
name: channelName.toLowerCase().replace(/[^a-z0-9-]/g, ''),
|
||||
display_name: channelName,
|
||||
type: 'P',
|
||||
});
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
const page = systemConsolePage.page;
|
||||
|
||||
await navigateToABACPage(page);
|
||||
await enableABAC(page);
|
||||
|
||||
// Create a basic membership policy
|
||||
const policyName = `Edit-Nav-Test-${pw.random.id()}`;
|
||||
|
||||
await createBasicPolicy(page, {
|
||||
name: policyName,
|
||||
attribute: 'Department',
|
||||
operator: '==',
|
||||
value: 'Engineering',
|
||||
autoSync: false,
|
||||
channels: [privateChannel.display_name],
|
||||
});
|
||||
|
||||
// Get the policy ID from the backend
|
||||
const policyId = await getPolicyIdByName(adminClient, policyName);
|
||||
expect(policyId, 'Policy should be created and have an ID').toBeTruthy();
|
||||
|
||||
// Navigate to Membership Policies list page
|
||||
await page.goto('/admin_console/system_attributes/membership_policies', {waitUntil: 'networkidle'});
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Search for the policy to ensure it's visible
|
||||
const policySearchInput = page.locator('input[placeholder*="Search" i]').first();
|
||||
if (await policySearchInput.isVisible({timeout: 3000})) {
|
||||
await policySearchInput.fill(policyName);
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
// Find the policy row
|
||||
const policyRowLocator = page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first();
|
||||
await policyRowLocator.waitFor({state: 'visible', timeout: 10000});
|
||||
|
||||
// Open the three-dot action menu for the policy
|
||||
const actionMenuButton = policyRowLocator
|
||||
.locator('button[aria-label*="Actions" i], button:has(i.icon-dots-vertical)')
|
||||
.first();
|
||||
await actionMenuButton.waitFor({state: 'visible', timeout: 5000});
|
||||
await actionMenuButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Click the Edit menu item
|
||||
const editMenuItem = page.locator(`[id*="policy-menu-edit-${policyId}"]`).first();
|
||||
await editMenuItem.waitFor({state: 'visible', timeout: 5000});
|
||||
|
||||
// Click Edit and wait for navigation
|
||||
await editMenuItem.click();
|
||||
|
||||
// Wait for the URL to change to the edit policy page
|
||||
await page.waitForURL(`**/admin_console/system_attributes/membership_policies/edit_policy/${policyId}`, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Verify we're on the edit policy page by checking the URL
|
||||
const currentURL = page.url();
|
||||
expect(currentURL).toContain(`/admin_console/system_attributes/membership_policies/edit_policy/${policyId}`);
|
||||
|
||||
// Additional verification: Check that the policy editor is loaded
|
||||
// The policy editor should have the policy name visible
|
||||
const policyNameInput = page.locator('input[placeholder*="name" i], input[value*="Edit-Nav-Test"]').first();
|
||||
await expect(policyNameInput).toBeVisible({timeout: 10000});
|
||||
});
|
||||
|
||||
/**
|
||||
* MM-68958: Row click also navigates to membership policy editor
|
||||
*
|
||||
* This test verifies that clicking the row (not just the Edit action) also navigates correctly.
|
||||
* This behavior should have been working before, but we verify it still works after the fix.
|
||||
*/
|
||||
test('Row click navigates to membership policy editor', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
// Create a test channel for the policy
|
||||
const channelName = `abac-row-click-test-${pw.random.id()}`;
|
||||
const privateChannel = await adminClient.createChannel({
|
||||
team_id: team.id,
|
||||
name: channelName.toLowerCase().replace(/[^a-z0-9-]/g, ''),
|
||||
display_name: channelName,
|
||||
type: 'P',
|
||||
});
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
const page = systemConsolePage.page;
|
||||
|
||||
await navigateToABACPage(page);
|
||||
await enableABAC(page);
|
||||
|
||||
// Create a basic membership policy
|
||||
const policyName = `Row-Click-Test-${pw.random.id()}`;
|
||||
|
||||
await createBasicPolicy(page, {
|
||||
name: policyName,
|
||||
attribute: 'Department',
|
||||
operator: '==',
|
||||
value: 'Sales',
|
||||
autoSync: false,
|
||||
channels: [privateChannel.display_name],
|
||||
});
|
||||
|
||||
// Get the policy ID from the backend
|
||||
const policyId = await getPolicyIdByName(adminClient, policyName);
|
||||
expect(policyId, 'Policy should be created and have an ID').toBeTruthy();
|
||||
|
||||
// Navigate to Membership Policies list page
|
||||
await page.goto('/admin_console/system_attributes/membership_policies', {waitUntil: 'networkidle'});
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Search for the policy to ensure it's visible
|
||||
const policySearchInput = page.locator('input[placeholder*="Search" i]').first();
|
||||
if (await policySearchInput.isVisible({timeout: 3000})) {
|
||||
await policySearchInput.fill(policyName);
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
// Find the policy row
|
||||
const policyRowLocator = page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first();
|
||||
await policyRowLocator.waitFor({state: 'visible', timeout: 10000});
|
||||
|
||||
// Click the row (not the action menu)
|
||||
await policyRowLocator.click();
|
||||
|
||||
// Wait for the URL to change to the edit policy page
|
||||
await page.waitForURL(`**/admin_console/system_attributes/membership_policies/edit_policy/${policyId}`, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Verify we're on the edit policy page
|
||||
const currentURL = page.url();
|
||||
expect(currentURL).toContain(`/admin_console/system_attributes/membership_policies/edit_policy/${policyId}`);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Client4} from '@mattermost/client';
|
||||
import type {UserPropertyField} from '@mattermost/types/properties';
|
||||
|
||||
import {expect, test, enableABAC, navigateToABACPage} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
CustomProfileAttribute,
|
||||
deleteCustomProfileAttributes,
|
||||
setupCustomProfileAttributeFields,
|
||||
} from '../../../channels/custom_profile_attributes/helpers';
|
||||
import {getPolicyIdByName} from '../support';
|
||||
|
||||
type FieldsMap = Record<string, UserPropertyField>;
|
||||
|
||||
async function clearExistingFields(client: Client4): Promise<void> {
|
||||
try {
|
||||
const existing = await client.getCustomProfileAttributeFields();
|
||||
if (existing?.length) {
|
||||
const map: FieldsMap = {};
|
||||
for (const f of existing) {
|
||||
map[f.id] = f;
|
||||
}
|
||||
await deleteCustomProfileAttributes(client, map);
|
||||
}
|
||||
} catch {
|
||||
// No fields to clean up
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('ABAC Attribute Selector - display_name rendering and filtering', () => {
|
||||
/**
|
||||
* @objective Verify the attribute selector renders display_name when set,
|
||||
* falls back to `name`, filters on both, and persists the CEL identifier
|
||||
* in saved policy expressions.
|
||||
*
|
||||
* @precondition
|
||||
* Two admin-managed CPA fields seeded via REST: `dept_head` with
|
||||
* display_name 'Department Head', and `office` with no display_name.
|
||||
*/
|
||||
test(
|
||||
'renders and filters by display_name while persisting CEL identifier',
|
||||
{tag: '@user_attributes'},
|
||||
async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
await pw.ensureLicense();
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
|
||||
await clearExistingFields(adminClient);
|
||||
|
||||
const seedAttributes: CustomProfileAttribute[] = [
|
||||
{
|
||||
name: 'dept_head',
|
||||
type: 'text',
|
||||
attrs: {
|
||||
display_name: 'Department Head',
|
||||
visibility: 'when_set',
|
||||
managed: 'admin',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'office',
|
||||
type: 'text',
|
||||
attrs: {
|
||||
visibility: 'when_set',
|
||||
managed: 'admin',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, seedAttributes);
|
||||
|
||||
const policyName = `Display Name Selector ${pw.random.id()}`;
|
||||
let policyId: string | null = null;
|
||||
|
||||
try {
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
const {page} = systemConsolePage;
|
||||
|
||||
await navigateToABACPage(page);
|
||||
await enableABAC(page);
|
||||
|
||||
// # Open the new-policy form
|
||||
await page.getByRole('button', {name: 'Add policy'}).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const nameInput = page.locator('#admin\\.access_control\\.policy\\.edit_policy\\.policyName');
|
||||
await nameInput.waitFor({state: 'visible', timeout: 10000});
|
||||
await nameInput.fill(policyName);
|
||||
|
||||
// # Add an attribute rule and open its selector
|
||||
const addAttributeButton = page.getByRole('button', {name: /add attribute/i});
|
||||
await expect(addAttributeButton).toBeEnabled({timeout: 10000});
|
||||
await addAttributeButton.click();
|
||||
|
||||
const attributeButton = page.locator('[data-testid="attributeSelectorMenuButton"]').first();
|
||||
await attributeButton.waitFor({state: 'visible', timeout: 5000});
|
||||
|
||||
const attributeMenu = page.locator('[id^="attribute-selector-menu"]');
|
||||
|
||||
if (!(await attributeMenu.isVisible({timeout: 1000}).catch(() => false))) {
|
||||
await attributeButton.click();
|
||||
}
|
||||
await attributeMenu.waitFor({state: 'visible', timeout: 5000});
|
||||
|
||||
const deptHeadItem = page.locator('[id^="attribute-selector-menu"] li:has-text("Department Head")');
|
||||
const officeItem = page.locator('[id^="attribute-selector-menu"] li:has-text("office")');
|
||||
|
||||
// * Both fields render: 'Department Head' (display_name) and 'office' (name fallback)
|
||||
await expect(deptHeadItem).toBeVisible();
|
||||
await expect(officeItem).toBeVisible();
|
||||
|
||||
const filterInput = attributeMenu.locator('.attribute-selector-search input');
|
||||
await filterInput.waitFor({state: 'visible', timeout: 5000});
|
||||
|
||||
// * Filter by display_name keeps only 'Department Head'
|
||||
await filterInput.fill('department');
|
||||
await expect(deptHeadItem).toBeVisible();
|
||||
await expect(officeItem).toHaveCount(0);
|
||||
|
||||
// * Filter by CEL identifier keeps only 'Department Head'
|
||||
await filterInput.fill('');
|
||||
await filterInput.fill('dept_head');
|
||||
await expect(deptHeadItem).toBeVisible();
|
||||
await expect(officeItem).toHaveCount(0);
|
||||
|
||||
// * Filter on the no-display_name field's `name` keeps only 'office'
|
||||
await filterInput.fill('');
|
||||
await filterInput.fill('office');
|
||||
await expect(officeItem).toBeVisible();
|
||||
await expect(deptHeadItem).toHaveCount(0);
|
||||
|
||||
// # Select 'Department Head'
|
||||
await filterInput.fill('');
|
||||
await deptHeadItem.first().click({force: true});
|
||||
|
||||
// * The trigger button shows display_name, not the CEL identifier
|
||||
await expect(attributeButton).toContainText('Department Head', {timeout: 5000});
|
||||
|
||||
// # Wait for the attribute-selector popover to close before opening the next menu
|
||||
const attributeMenuPopover = page.locator('[id^="attribute-selector-menu"]');
|
||||
await attributeMenuPopover.waitFor({state: 'hidden', timeout: 5000});
|
||||
|
||||
const operatorButton = page.locator('[data-testid="operatorSelectorMenuButton"]').first();
|
||||
await operatorButton.waitFor({state: 'visible', timeout: 5000});
|
||||
await operatorButton.click();
|
||||
|
||||
const operatorMenu = page.locator('[id^="operator-selector-menu"]');
|
||||
await operatorMenu.waitFor({state: 'visible', timeout: 5000});
|
||||
await operatorMenu.locator('li:has-text("is")').first().click();
|
||||
|
||||
const valueInput = page.locator('.values-editor__simple-input').first();
|
||||
await valueInput.waitFor({state: 'visible', timeout: 10000});
|
||||
await valueInput.fill('engineering');
|
||||
await valueInput.press('Tab');
|
||||
|
||||
const saveButton = page.getByRole('button', {name: 'Save'});
|
||||
await expect(saveButton).toBeEnabled({timeout: 10000});
|
||||
|
||||
// # Click Save and wait on the create-policy PUT (button unmounts on navigate)
|
||||
const createPolicyResponse = page.waitForResponse(
|
||||
(r) => /\/access_control_policies(?:\?|$)/.test(r.url()) && r.request().method() === 'PUT',
|
||||
{timeout: 15000},
|
||||
);
|
||||
await saveButton.click();
|
||||
const createResponse = await createPolicyResponse;
|
||||
expect(createResponse.ok()).toBe(true);
|
||||
|
||||
await page.waitForURL(/\/admin_console\/system_attributes\/membership_policies/, {timeout: 10000});
|
||||
|
||||
// * The persisted CEL uses the canonical identifier, not display_name
|
||||
policyId = await getPolicyIdByName(adminClient, policyName);
|
||||
expect(policyId).not.toBeNull();
|
||||
|
||||
const policy = await (adminClient as any).doFetch(
|
||||
`${adminClient.getBaseRoute()}/access_control_policies/${policyId}`,
|
||||
{method: 'GET'},
|
||||
);
|
||||
|
||||
const rules = (policy?.rules || []) as Array<{actions?: string[]; expression?: string}>;
|
||||
const membershipRule = rules.find((r) => r.actions?.includes('membership')) || rules[0];
|
||||
|
||||
expect(membershipRule).toBeDefined();
|
||||
expect(membershipRule?.expression || '').toContain('user.attributes.dept_head');
|
||||
expect(membershipRule?.expression || '').not.toContain('Department Head');
|
||||
} finally {
|
||||
if (policyId) {
|
||||
try {
|
||||
await (adminClient as any).doFetch(
|
||||
`${adminClient.getBaseRoute()}/access_control_policies/${policyId}`,
|
||||
{method: 'DELETE'},
|
||||
);
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
await deleteCustomProfileAttributes(adminClient, fieldsMap);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -507,3 +507,231 @@ test('should disable Intune inputs when toggle is off', async ({pw}) => {
|
|||
expect(await systemConsolePage.mobileSecurity.tenantId.input.isDisabled()).toBe(false);
|
||||
expect(await systemConsolePage.mobileSecurity.clientId.input.isDisabled()).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify timer settings are disabled when Mobile Ephemeral Mode is not enabled, and become editable when enabled
|
||||
*/
|
||||
test(
|
||||
'should disable Mobile Ephemeral Mode sub-settings when toggle is off and enable them when toggle is on',
|
||||
{tag: '@mobile_ephemeral_mode'},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
|
||||
const license = await adminClient.getClientLicenseOld();
|
||||
|
||||
test.skip(
|
||||
license.SkuShortName !== 'advanced',
|
||||
'Skipping test - server does not have enterprise advanced license',
|
||||
);
|
||||
|
||||
const config = await adminClient.getConfig();
|
||||
test.skip(
|
||||
config.FeatureFlags.MobileEphemeralMode !== true && config.FeatureFlags.MobileEphemeralMode !== 'true',
|
||||
'Skipping test - MobileEphemeralMode feature flag is not enabled on the server',
|
||||
);
|
||||
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
// # Log in as admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
// # Visit system console
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
|
||||
// # Go to Mobile Security section
|
||||
await systemConsolePage.sidebar.mobileSecurity.click();
|
||||
await systemConsolePage.mobileSecurity.toBeVisible();
|
||||
|
||||
// * Verify Mobile Ephemeral Mode toggle is off by default
|
||||
await systemConsolePage.mobileSecurity.enableMobileEphemeralMode.toBeFalse();
|
||||
|
||||
// * Verify all sub-settings are disabled
|
||||
expect(await systemConsolePage.mobileSecurity.disconnectionTimeout.input.isDisabled()).toBe(true);
|
||||
expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.input.isDisabled()).toBe(true);
|
||||
expect(await systemConsolePage.mobileSecurity.autoCacheCleanup.input.isDisabled()).toBe(true);
|
||||
|
||||
// # Enable Mobile Ephemeral Mode toggle
|
||||
await systemConsolePage.mobileSecurity.enableMobileEphemeralMode.selectTrue();
|
||||
|
||||
// * Verify all sub-settings are now enabled
|
||||
expect(await systemConsolePage.mobileSecurity.disconnectionTimeout.input.isDisabled()).toBe(false);
|
||||
expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.input.isDisabled()).toBe(false);
|
||||
expect(await systemConsolePage.mobileSecurity.autoCacheCleanup.input.isDisabled()).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify all Mobile Ephemeral Mode settings persist after save and navigation
|
||||
*/
|
||||
test(
|
||||
'should save and persist all Mobile Ephemeral Mode settings after navigation',
|
||||
{tag: '@mobile_ephemeral_mode'},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
|
||||
const license = await adminClient.getClientLicenseOld();
|
||||
|
||||
test.skip(
|
||||
license.SkuShortName !== 'advanced',
|
||||
'Skipping test - server does not have enterprise advanced license',
|
||||
);
|
||||
|
||||
const config = await adminClient.getConfig();
|
||||
test.skip(
|
||||
config.FeatureFlags.MobileEphemeralMode !== true && config.FeatureFlags.MobileEphemeralMode !== 'true',
|
||||
'Skipping test - MobileEphemeralMode feature flag is not enabled on the server',
|
||||
);
|
||||
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
// # Enable Mobile Ephemeral Mode setting via config API
|
||||
config.MobileEphemeralModeSettings.Enable = true;
|
||||
await adminClient.updateConfig(config);
|
||||
|
||||
// # Log in as admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
// # Visit system console
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
|
||||
// # Go to Mobile Security section
|
||||
await systemConsolePage.sidebar.mobileSecurity.click();
|
||||
await systemConsolePage.mobileSecurity.toBeVisible();
|
||||
|
||||
// # Set custom values
|
||||
await systemConsolePage.mobileSecurity.disconnectionTimeout.fill('120');
|
||||
await systemConsolePage.mobileSecurity.offlinePersistenceTimer.fill('48');
|
||||
await systemConsolePage.mobileSecurity.autoCacheCleanup.fill('14');
|
||||
|
||||
// # Save settings
|
||||
await systemConsolePage.mobileSecurity.save();
|
||||
await pw.waitUntil(async () => (await systemConsolePage.mobileSecurity.saveButton.textContent()) === 'Save');
|
||||
|
||||
// # Navigate away and back
|
||||
await systemConsolePage.sidebar.users.click();
|
||||
await systemConsolePage.users.toBeVisible();
|
||||
await systemConsolePage.sidebar.mobileSecurity.click();
|
||||
await systemConsolePage.mobileSecurity.toBeVisible();
|
||||
|
||||
// * Verify Mobile Ephemeral Mode is still enabled
|
||||
await systemConsolePage.mobileSecurity.enableMobileEphemeralMode.toBeTrue();
|
||||
|
||||
// * Verify all values persisted correctly
|
||||
expect(await systemConsolePage.mobileSecurity.disconnectionTimeout.getValue()).toBe('120');
|
||||
expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.getValue()).toBe('48');
|
||||
expect(await systemConsolePage.mobileSecurity.autoCacheCleanup.getValue()).toBe('14');
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify offline persistence timer is disabled when auto cache cleanup is set to 0 (zero-persistence mode)
|
||||
*/
|
||||
test(
|
||||
'should disable offline persistence timer when auto cache cleanup is set to zero',
|
||||
{tag: '@mobile_ephemeral_mode'},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
|
||||
const license = await adminClient.getClientLicenseOld();
|
||||
|
||||
test.skip(
|
||||
license.SkuShortName !== 'advanced',
|
||||
'Skipping test - server does not have enterprise advanced license',
|
||||
);
|
||||
|
||||
const config = await adminClient.getConfig();
|
||||
test.skip(
|
||||
config.FeatureFlags.MobileEphemeralMode !== true && config.FeatureFlags.MobileEphemeralMode !== 'true',
|
||||
'Skipping test - MobileEphemeralMode feature flag is not enabled on the server',
|
||||
);
|
||||
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
// # Enable Mobile Ephemeral Mode setting via config API
|
||||
config.MobileEphemeralModeSettings.Enable = true;
|
||||
await adminClient.updateConfig(config);
|
||||
|
||||
// # Log in as admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
// # Visit system console
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
|
||||
// # Go to Mobile Security section
|
||||
await systemConsolePage.sidebar.mobileSecurity.click();
|
||||
await systemConsolePage.mobileSecurity.toBeVisible();
|
||||
|
||||
// * Verify offline persistence timer is enabled
|
||||
expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.input.isDisabled()).toBe(false);
|
||||
|
||||
// # Set auto cache cleanup to 0
|
||||
await systemConsolePage.mobileSecurity.autoCacheCleanup.clear();
|
||||
await systemConsolePage.mobileSecurity.autoCacheCleanup.fill('0');
|
||||
|
||||
// * Verify offline persistence timer is now disabled
|
||||
expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.input.isDisabled()).toBe(true);
|
||||
|
||||
// # Set auto cache cleanup back to 7
|
||||
await systemConsolePage.mobileSecurity.autoCacheCleanup.clear();
|
||||
await systemConsolePage.mobileSecurity.autoCacheCleanup.fill('7');
|
||||
|
||||
// * Verify offline persistence timer is enabled again
|
||||
expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.input.isDisabled()).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify Mobile Ephemeral Mode settings show correct defaults on first enable
|
||||
*/
|
||||
test(
|
||||
'should show correct default values when Mobile Ephemeral Mode is first enabled',
|
||||
{tag: '@mobile_ephemeral_mode'},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
|
||||
const license = await adminClient.getClientLicenseOld();
|
||||
|
||||
test.skip(
|
||||
license.SkuShortName !== 'advanced',
|
||||
'Skipping test - server does not have enterprise advanced license',
|
||||
);
|
||||
|
||||
const config = await adminClient.getConfig();
|
||||
test.skip(
|
||||
config.FeatureFlags.MobileEphemeralMode !== true && config.FeatureFlags.MobileEphemeralMode !== 'true',
|
||||
'Skipping test - MobileEphemeralMode feature flag is not enabled on the server',
|
||||
);
|
||||
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
// # Log in as admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
// # Visit system console
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
|
||||
// # Go to Mobile Security section
|
||||
await systemConsolePage.sidebar.mobileSecurity.click();
|
||||
await systemConsolePage.mobileSecurity.toBeVisible();
|
||||
|
||||
// # Enable Mobile Ephemeral Mode
|
||||
await systemConsolePage.mobileSecurity.enableMobileEphemeralMode.selectTrue();
|
||||
|
||||
// * Verify default values
|
||||
expect(await systemConsolePage.mobileSecurity.disconnectionTimeout.getValue()).toBe('60');
|
||||
expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.getValue()).toBe('24');
|
||||
expect(await systemConsolePage.mobileSecurity.autoCacheCleanup.getValue()).toBe('7');
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -401,10 +401,10 @@ test.describe('System Console - Classification markings', () => {
|
|||
await setupClassificationFieldWithGlobalBanner(
|
||||
adminClient,
|
||||
[
|
||||
{id: 'nato-unclassified', name: 'NATO UNCLASSIFIED', color: '#007A33', rank: 1},
|
||||
{id: 'natounclassified0000000000', name: 'NATO UNCLASSIFIED', color: '#007A33', rank: 1},
|
||||
{id: 'nato-restricted', name: 'NATO RESTRICTED', color: '#FFD700', rank: 2},
|
||||
],
|
||||
{levelId: 'nato-unclassified', enabled: true, placement: 'top'},
|
||||
{levelId: 'natounclassified0000000000', enabled: true, placement: 'top'},
|
||||
);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
|
@ -457,10 +457,10 @@ test.describe('System Console - Classification markings', () => {
|
|||
await setupClassificationFieldWithGlobalBanner(
|
||||
adminClient,
|
||||
[
|
||||
{id: 'lvl-unclassified', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
|
||||
{id: 'lvl-confidential', name: 'CONFIDENTIAL', color: '#FFD700', rank: 2},
|
||||
{id: 'lvlunclassified00000000000', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
|
||||
{id: 'lvlconfidential00000000000', name: 'CONFIDENTIAL', color: '#FFD700', rank: 2},
|
||||
],
|
||||
{levelId: 'lvl-unclassified', enabled: true, placement: 'top'},
|
||||
{levelId: 'lvlunclassified00000000000', enabled: true, placement: 'top'},
|
||||
);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
|
@ -498,6 +498,73 @@ test.describe('System Console - Classification markings', () => {
|
|||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify that modifying a preset's levels (rename, delete, add) automatically
|
||||
* switches the dropdown to "Custom classification levels", and selecting a real preset
|
||||
* again removes the Custom option from the dropdown.
|
||||
*/
|
||||
test(
|
||||
'MM-T6212 classification markings: modifying a preset switches dropdown to Custom',
|
||||
{tag: ['@system_console', '@classification_markings']},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
|
||||
await setClassificationMarkingsFeatureFlag(adminClient, true);
|
||||
await deleteClassificationMarkingsFieldIfExists(adminClient);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
const {page} = systemConsolePage;
|
||||
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// # Enable classification markings and select NATO preset
|
||||
await page.locator('input[name="classificationEnabled"][value="true"]').click();
|
||||
await selectClassificationPreset(page, 'NATO');
|
||||
|
||||
const presetControl = page.getByTestId('classificationPreset');
|
||||
await expect(presetControl).toContainText('NATO');
|
||||
|
||||
// # Rename the first level — this should switch to Custom
|
||||
const firstLevelInput = page.getByLabel('Classification level name').first();
|
||||
await firstLevelInput.clear();
|
||||
await firstLevelInput.fill('MY CUSTOM LEVEL');
|
||||
|
||||
// * Preset dropdown should now show "Custom classification levels"
|
||||
await expect(presetControl).toContainText('Custom classification levels');
|
||||
|
||||
// # Open the preset dropdown and verify "Custom classification levels" is listed
|
||||
await presetControl.click();
|
||||
const menu = page.locator('.DropDown__menu');
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu.getByText('Custom classification levels', {exact: true})).toBeVisible();
|
||||
|
||||
// # Select a real preset (Canada) — should show the confirmation modal
|
||||
await menu.getByText('Canada', {exact: true}).click();
|
||||
await expect(page.getByText('Change classification preset?')).toBeVisible();
|
||||
|
||||
// # Confirm the preset change
|
||||
await page.getByRole('button', {name: 'Change preset'}).click();
|
||||
|
||||
// * Dropdown now shows Canada, no longer Custom
|
||||
await expect(presetControl).toContainText('Canada');
|
||||
|
||||
// # Open the dropdown again and verify Custom is no longer listed
|
||||
await presetControl.click();
|
||||
const menuAfterSwitch = page.locator('.DropDown__menu');
|
||||
await expect(menuAfterSwitch).toBeVisible();
|
||||
await expect(menuAfterSwitch.getByText('Custom classification levels', {exact: true})).not.toBeVisible();
|
||||
|
||||
// # Close menu by pressing Escape
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// # Delete a level from the Canada preset
|
||||
await page.getByRole('button', {name: 'Delete level'}).first().click();
|
||||
|
||||
// * Should switch back to Custom
|
||||
await expect(presetControl).toContainText('Custom classification levels');
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Validate that saving with global banner enabled but no level selected shows an error.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type {Client4} from '@mattermost/client';
|
|||
|
||||
// Canonical values: webapp/channels/src/components/admin_console/classification_markings/utils/index.ts
|
||||
// (cross-package import not feasible between e2e-tests and webapp)
|
||||
const PROPERTY_GROUP = 'classification_markings';
|
||||
const PROPERTY_GROUP = 'access_control';
|
||||
const OBJECT_TYPE = 'template';
|
||||
const LINKED_OBJECT_TYPE = 'system';
|
||||
const TARGET_TYPE = 'system';
|
||||
|
|
@ -35,6 +35,16 @@ export async function setClassificationMarkingsFeatureFlag(adminClient: Client4,
|
|||
* (clean slate for E2E). Linked field is deleted first to avoid deletion-protection errors.
|
||||
*/
|
||||
export async function deleteClassificationMarkingsFieldIfExists(adminClient: Client4) {
|
||||
// Delete channel linked fields first (created by channel classification tests).
|
||||
try {
|
||||
const channelFields = await adminClient.getPropertyFields(PROPERTY_GROUP, 'channel', TARGET_TYPE, '');
|
||||
for (const f of channelFields.filter((f) => f.name === 'channel_classification' && f.delete_at === 0)) {
|
||||
await adminClient.deletePropertyField(PROPERTY_GROUP, 'channel', f.id);
|
||||
}
|
||||
} catch {
|
||||
// May not exist; ignore.
|
||||
}
|
||||
|
||||
// Clean up both the current 'system' object type and the legacy 'user' object type
|
||||
// to handle stale data from earlier versions of the feature.
|
||||
for (const objectType of [LINKED_OBJECT_TYPE, 'user'] as const) {
|
||||
|
|
@ -91,11 +101,10 @@ export async function setupClassificationField(
|
|||
target_id: '',
|
||||
attrs: {
|
||||
options: levels.map((l) => ({id: l.id ?? '', name: l.name, color: l.color, rank: l.rank})),
|
||||
managed: 'admin',
|
||||
},
|
||||
permission_field: 'sysadmin',
|
||||
permission_values: 'sysadmin',
|
||||
permission_options: 'sysadmin',
|
||||
permission_field: 'admin',
|
||||
permission_values: 'admin',
|
||||
permission_options: 'admin',
|
||||
} as Parameters<Client4['createPropertyField']>[2]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -85,8 +85,8 @@ test.describe('Global Classification Banner', () => {
|
|||
await setupClassificationFieldWithGlobalBanner(
|
||||
adminClient,
|
||||
[
|
||||
{id: 'lvl-unclassified', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
|
||||
{id: 'lvl-secret', name: 'SECRET', color: '#C8102E', rank: 2},
|
||||
{id: 'lvlunclassified00000000000', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
|
||||
{id: 'lvlsecret00000000000000000', name: 'SECRET', color: '#C8102E', rank: 2},
|
||||
],
|
||||
{levelId: '', enabled: false, placement: 'top'},
|
||||
);
|
||||
|
|
@ -149,10 +149,10 @@ test.describe('Global Classification Banner', () => {
|
|||
await setupClassificationFieldWithGlobalBanner(
|
||||
adminClient,
|
||||
[
|
||||
{id: 'lvl-unclassified', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
|
||||
{id: 'lvl-secret', name: 'SECRET', color: '#C8102E', rank: 2},
|
||||
{id: 'lvlunclassified00000000000', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
|
||||
{id: 'lvlsecret00000000000000000', name: 'SECRET', color: '#C8102E', rank: 2},
|
||||
],
|
||||
{levelId: 'lvl-secret', enabled: true, placement: 'top'},
|
||||
{levelId: 'lvlsecret00000000000000000', enabled: true, placement: 'top'},
|
||||
);
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(adminUser);
|
||||
|
|
@ -184,8 +184,8 @@ test.describe('Global Classification Banner', () => {
|
|||
|
||||
await setupClassificationFieldWithGlobalBanner(
|
||||
adminClient,
|
||||
[{id: 'lvl-top-secret', name: 'TOP SECRET', color: '#FCE83A', rank: 1}],
|
||||
{levelId: 'lvl-top-secret', enabled: true, placement: 'top_and_bottom'},
|
||||
[{id: 'lvltopsecret00000000000000', name: 'TOP SECRET', color: '#FCE83A', rank: 1}],
|
||||
{levelId: 'lvltopsecret00000000000000', enabled: true, placement: 'top_and_bottom'},
|
||||
);
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(adminUser);
|
||||
|
|
@ -222,8 +222,8 @@ test.describe('Global Classification Banner', () => {
|
|||
|
||||
await setupClassificationFieldWithGlobalBanner(
|
||||
adminClient,
|
||||
[{id: 'lvl-confidential', name: 'CONFIDENTIAL', color: '#FFD700', rank: 1}],
|
||||
{levelId: 'lvl-confidential', enabled: true, placement: 'top'},
|
||||
[{id: 'lvlconfidential00000000000', name: 'CONFIDENTIAL', color: '#FFD700', rank: 1}],
|
||||
{levelId: 'lvlconfidential00000000000', enabled: true, placement: 'top'},
|
||||
);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
|
@ -251,8 +251,8 @@ test.describe('Global Classification Banner', () => {
|
|||
|
||||
await setupClassificationFieldWithGlobalBanner(
|
||||
adminClient,
|
||||
[{id: 'lvl-restricted', name: 'RESTRICTED', color: '#FF8C00', rank: 1}],
|
||||
{levelId: 'lvl-restricted', enabled: true, placement: 'top'},
|
||||
[{id: 'lvlrestricted0000000000000', name: 'RESTRICTED', color: '#FF8C00', rank: 1}],
|
||||
{levelId: 'lvlrestricted0000000000000', enabled: true, placement: 'top'},
|
||||
);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
|
@ -289,9 +289,9 @@ test.describe('Global Classification Banner', () => {
|
|||
|
||||
await setupClassificationFieldWithGlobalBanner(
|
||||
adminClient,
|
||||
[{id: 'lvl-secret', name: 'SECRET', color: '#C8102E', rank: 1}],
|
||||
[{id: 'lvlsecret00000000000000000', name: 'SECRET', color: '#C8102E', rank: 1}],
|
||||
{
|
||||
levelId: 'lvl-secret',
|
||||
levelId: 'lvlsecret00000000000000000',
|
||||
enabled: true,
|
||||
placement: 'top',
|
||||
},
|
||||
|
|
@ -334,8 +334,8 @@ test.describe('Global Classification Banner', () => {
|
|||
|
||||
await setupClassificationFieldWithGlobalBanner(
|
||||
adminClient,
|
||||
[{id: 'lvl-top-secret', name: 'TOP SECRET', color: '#FF0000', rank: 1}],
|
||||
{levelId: 'lvl-top-secret', enabled: true, placement: 'top_and_bottom'},
|
||||
[{id: 'lvltopsecret00000000000000', name: 'TOP SECRET', color: '#FF0000', rank: 1}],
|
||||
{levelId: 'lvltopsecret00000000000000', enabled: true, placement: 'top_and_bottom'},
|
||||
);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
|
@ -376,10 +376,10 @@ test.describe('Global Classification Banner', () => {
|
|||
await setupClassificationFieldWithGlobalBanner(
|
||||
adminClient,
|
||||
[
|
||||
{id: 'lvl-unclassified', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
|
||||
{id: 'lvl-secret', name: 'SECRET', color: '#C8102E', rank: 2},
|
||||
{id: 'lvlunclassified00000000000', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
|
||||
{id: 'lvlsecret00000000000000000', name: 'SECRET', color: '#C8102E', rank: 2},
|
||||
],
|
||||
{levelId: 'lvl-unclassified', enabled: true, placement: 'top'},
|
||||
{levelId: 'lvlunclassified00000000000', enabled: true, placement: 'top'},
|
||||
);
|
||||
|
||||
// Login the non-admin user
|
||||
|
|
@ -395,10 +395,10 @@ test.describe('Global Classification Banner', () => {
|
|||
await setupClassificationFieldWithGlobalBanner(
|
||||
adminClient,
|
||||
[
|
||||
{id: 'lvl-unclassified', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
|
||||
{id: 'lvl-secret', name: 'SECRET', color: '#C8102E', rank: 2},
|
||||
{id: 'lvlunclassified00000000000', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
|
||||
{id: 'lvlsecret00000000000000000', name: 'SECRET', color: '#C8102E', rank: 2},
|
||||
],
|
||||
{levelId: 'lvl-secret', enabled: true, placement: 'top'},
|
||||
{levelId: 'lvlsecret00000000000000000', enabled: true, placement: 'top'},
|
||||
);
|
||||
|
||||
// The non-admin user should see the updated banner via websocket
|
||||
|
|
@ -425,8 +425,8 @@ test.describe('Global Classification Banner', () => {
|
|||
// Light background (#FFFFFF) — text should be dark (#000000)
|
||||
await setupClassificationFieldWithGlobalBanner(
|
||||
adminClient,
|
||||
[{id: 'lvl-unclassified', name: 'UNCLASSIFIED', color: '#FFFFFF', rank: 1}],
|
||||
{levelId: 'lvl-unclassified', enabled: true, placement: 'top'},
|
||||
[{id: 'lvlunclassified00000000000', name: 'UNCLASSIFIED', color: '#FFFFFF', rank: 1}],
|
||||
{levelId: 'lvlunclassified00000000000', enabled: true, placement: 'top'},
|
||||
);
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(adminUser);
|
||||
|
|
@ -440,8 +440,8 @@ test.describe('Global Classification Banner', () => {
|
|||
// Dark background (#000000) — text should be white (#FFFFFF)
|
||||
await setupClassificationFieldWithGlobalBanner(
|
||||
adminClient,
|
||||
[{id: 'lvl-top-secret', name: 'TOP SECRET', color: '#000000', rank: 1}],
|
||||
{levelId: 'lvl-top-secret', enabled: true, placement: 'top'},
|
||||
[{id: 'lvltopsecret00000000000000', name: 'TOP SECRET', color: '#000000', rank: 1}],
|
||||
{levelId: 'lvltopsecret00000000000000', enabled: true, placement: 'top'},
|
||||
);
|
||||
|
||||
await channelsPage.page.reload();
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ test.describe('System Console - Users table sorting', () => {
|
|||
}
|
||||
expect(emails.length).toBeGreaterThan(3);
|
||||
|
||||
const sorted = [...emails].sort((a, b) => a.localeCompare(b));
|
||||
const sorted = [...emails].sort((a, b) => a.localeCompare(b, undefined, {ignorePunctuation: true}));
|
||||
if (sortDirection === 'descending') {
|
||||
sorted.reverse();
|
||||
}
|
||||
|
|
@ -81,7 +81,7 @@ test.describe('System Console - Users table sorting', () => {
|
|||
}
|
||||
expect(emails.length).toBeGreaterThan(3);
|
||||
|
||||
const sorted = [...emails].sort((a, b) => a.localeCompare(b));
|
||||
const sorted = [...emails].sort((a, b) => a.localeCompare(b, undefined, {ignorePunctuation: true}));
|
||||
if (reversedDirection === 'descending') {
|
||||
sorted.reverse();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,15 @@ let cpaFieldNames: {
|
|||
skills: string;
|
||||
};
|
||||
|
||||
/** Rendered label for each CPA field: attrs.display_name when set, else field.name. */
|
||||
let cpaDisplayLabels: {
|
||||
department: string;
|
||||
workEmail: string;
|
||||
personalWebsite: string;
|
||||
location: string;
|
||||
skills: string;
|
||||
};
|
||||
|
||||
let testUserAttributes: CustomProfileAttribute[];
|
||||
|
||||
let team: Team;
|
||||
|
|
@ -76,6 +85,14 @@ test.describe('System Console - Admin User Profile Editing', () => {
|
|||
location: `UAAE_Location_${suffix}`,
|
||||
skills: `UAAE_Skills_${suffix}`,
|
||||
};
|
||||
// Mirror display_name values from testUserAttributes; absent display_name falls back to name.
|
||||
cpaDisplayLabels = {
|
||||
department: cpaFieldNames.department,
|
||||
workEmail: 'Work Email',
|
||||
personalWebsite: 'Personal Website',
|
||||
location: cpaFieldNames.location,
|
||||
skills: cpaFieldNames.skills,
|
||||
};
|
||||
testUserAttributes = [
|
||||
{
|
||||
name: cpaFieldNames.department,
|
||||
|
|
@ -92,6 +109,7 @@ test.describe('System Console - Admin User Profile Editing', () => {
|
|||
attrs: {
|
||||
value_type: 'email',
|
||||
visibility: 'when_set',
|
||||
display_name: 'Work Email',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -101,6 +119,7 @@ test.describe('System Console - Admin User Profile Editing', () => {
|
|||
attrs: {
|
||||
value_type: 'url',
|
||||
visibility: 'when_set',
|
||||
display_name: 'Personal Website',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -242,8 +261,8 @@ test.describe('System Console - Admin User Profile Editing', () => {
|
|||
await systemConsolePage.page.waitForURL(`**/admin_console/user_management/user/${testUser.id}`);
|
||||
await systemConsolePage!.users.userDetail.userCard.container.waitFor({state: 'visible'});
|
||||
const {userCard} = systemConsolePage!.users.userDetail;
|
||||
await expect(userCard.getFieldInputByExactLabel(cpaFieldNames.department)).toBeVisible({timeout: 30_000});
|
||||
await expect(userCard.getFieldInputByExactLabel(cpaFieldNames.workEmail)).toBeVisible({timeout: 30_000});
|
||||
await expect(userCard.getFieldInputByExactLabel(cpaDisplayLabels.department)).toBeVisible({timeout: 30_000});
|
||||
await expect(userCard.getFieldInputByExactLabel(cpaDisplayLabels.workEmail)).toBeVisible({timeout: 30_000});
|
||||
|
||||
// Remove the intercept now that field visibility is confirmed.
|
||||
// Keeping it active through the test body would intercept the save API call
|
||||
|
|
@ -271,7 +290,7 @@ test.describe('System Console - Admin User Profile Editing', () => {
|
|||
const {userCard} = userDetail;
|
||||
|
||||
// # Find and edit Department field (custom text attribute)
|
||||
const departmentInput = userCard.getFieldInputByExactLabel(cpaFieldNames.department);
|
||||
const departmentInput = userCard.getFieldInputByExactLabel(cpaDisplayLabels.department);
|
||||
await departmentInput.clear();
|
||||
await departmentInput.fill('Marketing');
|
||||
|
||||
|
|
@ -300,9 +319,8 @@ test.describe('System Console - Admin User Profile Editing', () => {
|
|||
|
||||
// * Verify custom user attributes are present
|
||||
for (const field of testUserAttributes) {
|
||||
await expect(
|
||||
systemConsolePage!.page.locator('label').filter({hasText: new RegExp(field.name)}),
|
||||
).toBeVisible();
|
||||
const label = field.attrs?.display_name || field.name;
|
||||
await expect(systemConsolePage!.page.locator('label').filter({hasText: label})).toBeVisible();
|
||||
}
|
||||
|
||||
// * Verify we have input fields (at least 4-5 total)
|
||||
|
|
@ -339,7 +357,7 @@ test.describe('System Console - Admin User Profile Editing', () => {
|
|||
const {userCard} = userDetail;
|
||||
|
||||
// # Find Location select field
|
||||
const locationSelect = userCard.getSelectByExactLabel(cpaFieldNames.location);
|
||||
const locationSelect = userCard.getSelectByExactLabel(cpaDisplayLabels.location);
|
||||
|
||||
// # Get the first available option (since we can't predict the option value/ID)
|
||||
const firstOption = await locationSelect.locator('option').nth(1); // Skip the default "Select an option"
|
||||
|
|
@ -363,11 +381,11 @@ test.describe('System Console - Admin User Profile Editing', () => {
|
|||
const {userCard} = userDetail;
|
||||
|
||||
// * Verify Skills multiselect component is displayed
|
||||
const skillsColumn = userCard.getCpaMultiselectContainer(cpaFieldNames.skills);
|
||||
const skillsColumn = userCard.getCpaMultiselectContainer(cpaDisplayLabels.skills);
|
||||
await expect(skillsColumn).toBeVisible();
|
||||
|
||||
// # Make a change to a different field to trigger save state
|
||||
const departmentInput = userCard.getFieldInputByExactLabel(cpaFieldNames.department);
|
||||
const departmentInput = userCard.getFieldInputByExactLabel(cpaDisplayLabels.department);
|
||||
await departmentInput.fill('Engineering Updated');
|
||||
|
||||
// # Verify save button becomes enabled
|
||||
|
|
@ -407,7 +425,7 @@ test.describe('System Console - Admin User Profile Editing', () => {
|
|||
});
|
||||
try {
|
||||
// # Find CPA email field (Work Email)
|
||||
const workEmailInput = userCard.getFieldInputByExactLabel(cpaFieldNames.workEmail);
|
||||
const workEmailInput = userCard.getFieldInputByExactLabel(cpaDisplayLabels.workEmail);
|
||||
await workEmailInput.scrollIntoViewIfNeeded();
|
||||
const originalEmail = await workEmailInput.inputValue();
|
||||
|
||||
|
|
@ -416,7 +434,7 @@ test.describe('System Console - Admin User Profile Editing', () => {
|
|||
await workEmailInput.fill('not-an-email');
|
||||
|
||||
// * Verify inline validation error appears
|
||||
const fieldError = userCard.getFieldError(cpaFieldNames.workEmail);
|
||||
const fieldError = userCard.getFieldError(cpaDisplayLabels.workEmail);
|
||||
await expect(fieldError).toBeVisible({timeout: 30000});
|
||||
await expect(fieldError).toContainText('Invalid email address');
|
||||
|
||||
|
|
@ -461,7 +479,7 @@ test.describe('System Console - Admin User Profile Editing', () => {
|
|||
});
|
||||
try {
|
||||
// # Find custom URL field (Personal Website)
|
||||
const urlInput = userCard.getFieldInputByExactLabel(cpaFieldNames.personalWebsite);
|
||||
const urlInput = userCard.getFieldInputByExactLabel(cpaDisplayLabels.personalWebsite);
|
||||
const originalUrl = await urlInput.inputValue();
|
||||
|
||||
// # Enter invalid URL (specifically the one mentioned: "<%>")
|
||||
|
|
@ -469,7 +487,7 @@ test.describe('System Console - Admin User Profile Editing', () => {
|
|||
await urlInput.fill('<%>');
|
||||
|
||||
// * Verify inline validation error appears
|
||||
const fieldError = userCard.getFieldError(cpaFieldNames.personalWebsite);
|
||||
const fieldError = userCard.getFieldError(cpaDisplayLabels.personalWebsite);
|
||||
await expect(fieldError).toBeVisible();
|
||||
await expect(fieldError).toContainText('Invalid URL');
|
||||
|
||||
|
|
@ -511,14 +529,14 @@ test.describe('System Console - Admin User Profile Editing', () => {
|
|||
});
|
||||
try {
|
||||
// # Find custom email field (Work Email)
|
||||
const workEmailInput = userCard.getFieldInputByExactLabel(cpaFieldNames.workEmail);
|
||||
const workEmailInput = userCard.getFieldInputByExactLabel(cpaDisplayLabels.workEmail);
|
||||
|
||||
// # Enter invalid email
|
||||
await workEmailInput.clear();
|
||||
await workEmailInput.fill('not-an-email-either');
|
||||
|
||||
// * Verify inline validation error appears
|
||||
const fieldError = userCard.getFieldError(cpaFieldNames.workEmail);
|
||||
const fieldError = userCard.getFieldError(cpaDisplayLabels.workEmail);
|
||||
await expect(fieldError).toBeVisible();
|
||||
await expect(fieldError).toContainText('Invalid email address');
|
||||
|
||||
|
|
@ -541,7 +559,7 @@ test.describe('System Console - Admin User Profile Editing', () => {
|
|||
await expect(userDetail.cancelButton).not.toBeVisible();
|
||||
|
||||
// # Make a change to trigger save needed state
|
||||
const departmentInput = userCard.getFieldInputByExactLabel(cpaFieldNames.department);
|
||||
const departmentInput = userCard.getFieldInputByExactLabel(cpaDisplayLabels.department);
|
||||
const originalValue = await departmentInput.inputValue();
|
||||
await departmentInput.clear();
|
||||
await departmentInput.fill('Changed Value');
|
||||
|
|
@ -573,7 +591,7 @@ test.describe('System Console - Admin User Profile Editing', () => {
|
|||
await userCard.emailInput.clear();
|
||||
await userCard.emailInput.fill(newEmail);
|
||||
|
||||
const departmentInput = userCard.getFieldInputByExactLabel(cpaFieldNames.department);
|
||||
const departmentInput = userCard.getFieldInputByExactLabel(cpaDisplayLabels.department);
|
||||
await departmentInput.clear();
|
||||
await departmentInput.fill('Sales');
|
||||
|
||||
|
|
|
|||
|
|
@ -165,14 +165,14 @@ test.describe('System Console - User Attributes Management', () => {
|
|||
await expect(nameInput).toBeVisible();
|
||||
|
||||
// # Type attribute name (must be a valid CEL identifier — no spaces)
|
||||
await nameInput.fill('Test_Department');
|
||||
await nameInput.fill('test_department');
|
||||
await nameInput.blur();
|
||||
|
||||
await sp.saveAndWaitForSettled();
|
||||
|
||||
// * Verify the field was created by fetching from API
|
||||
const fieldsMap = await getFieldsMap(adminClient);
|
||||
const createdField = Object.values(fieldsMap).find((f) => f.name === 'Test_Department');
|
||||
const createdField = Object.values(fieldsMap).find((f) => f.name === 'test_department');
|
||||
expect(createdField).toBeDefined();
|
||||
expect(createdField!.type).toBe('text');
|
||||
|
||||
|
|
@ -195,7 +195,7 @@ test.describe('System Console - User Attributes Management', () => {
|
|||
|
||||
// # Type attribute name (must be a valid CEL identifier — no spaces)
|
||||
const nameInput = sp.lastNameInput();
|
||||
await nameInput.fill('Office_Location');
|
||||
await nameInput.fill('office_location');
|
||||
await nameInput.blur();
|
||||
|
||||
// # Change type to Select (use selectLastType so the index stays correct
|
||||
|
|
@ -212,7 +212,7 @@ test.describe('System Console - User Attributes Management', () => {
|
|||
|
||||
// * Verify field was created with correct type via API
|
||||
const fieldsMap = await getFieldsMap(adminClient);
|
||||
const createdField = Object.values(fieldsMap).find((f) => f.name === 'Office_Location');
|
||||
const createdField = Object.values(fieldsMap).find((f) => f.name === 'office_location');
|
||||
expect(createdField).toBeDefined();
|
||||
expect(createdField!.type).toBe('select');
|
||||
expect(createdField!.attrs.options).toBeDefined();
|
||||
|
|
@ -233,16 +233,16 @@ test.describe('System Console - User Attributes Management', () => {
|
|||
const sp = systemConsolePage.systemProperties;
|
||||
|
||||
// # Create an attribute via API
|
||||
const attributes: CustomProfileAttribute[] = [{name: 'Old_Name', type: 'text'}];
|
||||
const attributes: CustomProfileAttribute[] = [{name: 'old_name', type: 'text'}];
|
||||
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, attributes);
|
||||
|
||||
// # Navigate to User Attributes page
|
||||
await sp.goto();
|
||||
|
||||
const nameInputLocator = sp.nameInputByValue('Old_Name');
|
||||
await expect(nameInputLocator).toBeVisible();
|
||||
await nameInputLocator.focus();
|
||||
await nameInputLocator.fill('New_Name');
|
||||
const nameInput = sp.nameInputByValue('old_name');
|
||||
await expect(nameInput).toBeVisible();
|
||||
await nameInput.focus();
|
||||
await nameInput.fill('new_name');
|
||||
// blur via keyboard — the CSS-attribute locator no longer matches
|
||||
// after fill() so calling .blur() on it would time out.
|
||||
await sp.page.keyboard.press('Tab');
|
||||
|
|
@ -251,7 +251,7 @@ test.describe('System Console - User Attributes Management', () => {
|
|||
|
||||
// * Verify field was updated via API
|
||||
const updatedMap = await getFieldsMap(adminClient);
|
||||
expect(Object.values(updatedMap).find((f) => f.name === 'New_Name')).toBeDefined();
|
||||
expect(Object.values(updatedMap).find((f) => f.name === 'new_name')).toBeDefined();
|
||||
|
||||
await cleanupFields(adminClient, {...fieldsMap, ...updatedMap});
|
||||
});
|
||||
|
|
@ -268,7 +268,7 @@ test.describe('System Console - User Attributes Management', () => {
|
|||
const sp = systemConsolePage.systemProperties;
|
||||
|
||||
// # Create an attribute via API
|
||||
const attributes: CustomProfileAttribute[] = [{name: 'To_Delete', type: 'text'}];
|
||||
const attributes: CustomProfileAttribute[] = [{name: 'to_delete', type: 'text'}];
|
||||
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, attributes);
|
||||
const fieldId = Object.keys(fieldsMap)[0];
|
||||
|
||||
|
|
@ -276,7 +276,7 @@ test.describe('System Console - User Attributes Management', () => {
|
|||
await sp.goto();
|
||||
|
||||
// * Verify the attribute exists
|
||||
await expect(sp.nameInputByValue('To_Delete')).toBeVisible();
|
||||
await expect(sp.nameInputByValue('to_delete')).toBeVisible();
|
||||
|
||||
// # Open dot menu for the field
|
||||
await sp.openDotMenu(fieldId);
|
||||
|
|
@ -291,7 +291,7 @@ test.describe('System Console - User Attributes Management', () => {
|
|||
|
||||
// * Verify field was deleted via API
|
||||
const updatedMap = await getFieldsMap(adminClient);
|
||||
expect(Object.values(updatedMap).find((f) => f.name === 'To_Delete')).toBeUndefined();
|
||||
expect(Object.values(updatedMap).find((f) => f.name === 'to_delete')).toBeUndefined();
|
||||
|
||||
await cleanupFields(adminClient, updatedMap);
|
||||
});
|
||||
|
|
@ -321,13 +321,10 @@ test.describe('System Console - User Attributes Management', () => {
|
|||
// # Click "Duplicate attribute"
|
||||
await sp.duplicateAttribute();
|
||||
|
||||
// * Verify a copy row appeared (server generates "Original (copy)" as the default name)
|
||||
await expect(sp.nameInputByValue('Original (copy)')).toBeVisible();
|
||||
// * Verify a copy row appeared with "_copy" suffix in the name
|
||||
await expect(sp.nameInputByValue('Original_copy')).toBeVisible();
|
||||
|
||||
// # Rename the copy to a valid CEL identifier.
|
||||
// "Original (copy)" contains spaces and parentheses which the server rejects with 422.
|
||||
// Use lastNameInput() for the fill/blur — it's position-based (.last()) so it stays
|
||||
// valid after the value changes, unlike the value-based nameInputByValue locator.
|
||||
// # Rename the copy to a valid CEL identifier (server rejects spaces/parens with 422)
|
||||
const copyInput = sp.lastNameInput();
|
||||
await copyInput.fill('Original_copy');
|
||||
await copyInput.blur();
|
||||
|
|
@ -354,7 +351,7 @@ test.describe('System Console - User Attributes Management', () => {
|
|||
const sp = systemConsolePage.systemProperties;
|
||||
|
||||
// # Create an attribute via API
|
||||
const attributes: CustomProfileAttribute[] = [{name: 'Visibility_Test', type: 'text'}];
|
||||
const attributes: CustomProfileAttribute[] = [{name: 'visibility_test', type: 'text'}];
|
||||
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, attributes);
|
||||
const fieldId = Object.keys(fieldsMap)[0];
|
||||
|
||||
|
|
@ -371,7 +368,7 @@ test.describe('System Console - User Attributes Management', () => {
|
|||
|
||||
// * Verify visibility was updated via API
|
||||
const updatedMap = await getFieldsMap(adminClient);
|
||||
const updatedField = Object.values(updatedMap).find((f) => f.name === 'Visibility_Test');
|
||||
const updatedField = Object.values(updatedMap).find((f) => f.name === 'visibility_test');
|
||||
expect(updatedField).toBeDefined();
|
||||
expect(updatedField!.attrs.visibility).toBe('hidden');
|
||||
|
||||
|
|
@ -390,7 +387,7 @@ test.describe('System Console - User Attributes Management', () => {
|
|||
const sp = systemConsolePage.systemProperties;
|
||||
|
||||
// # Create an attribute via API
|
||||
const attributes: CustomProfileAttribute[] = [{name: 'Editable_Test', type: 'text'}];
|
||||
const attributes: CustomProfileAttribute[] = [{name: 'editable_test', type: 'text'}];
|
||||
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, attributes);
|
||||
const fieldId = Object.keys(fieldsMap)[0];
|
||||
|
||||
|
|
@ -420,7 +417,7 @@ test.describe('System Console - User Attributes Management', () => {
|
|||
await expect
|
||||
.poll(async () => {
|
||||
const map = await getFieldsMap(adminClient);
|
||||
return Object.values(map).find((f) => f.name === 'Editable_Test');
|
||||
return Object.values(map).find((f) => f.name === 'editable_test');
|
||||
})
|
||||
.toMatchObject({attrs: {managed: 'admin'}});
|
||||
|
||||
|
|
@ -428,8 +425,10 @@ test.describe('System Console - User Attributes Management', () => {
|
|||
});
|
||||
|
||||
/**
|
||||
* @objective Verify that leaving an attribute name empty shows a validation
|
||||
* warning and disables the Save button.
|
||||
* @objective Verify that clearing the auto-derived CEL identifier (Name)
|
||||
* after entering a Display Name shows the empty-name validation warning
|
||||
* in both the offending Name cell (red icon) and a banner below the table,
|
||||
* and that Save remains disabled.
|
||||
*/
|
||||
test('shows validation warning for empty attribute name', {tag: '@user_attributes'}, async ({pw}) => {
|
||||
const {systemConsolePage} = await setupTest(pw);
|
||||
|
|
@ -441,56 +440,130 @@ test.describe('System Console - User Attributes Management', () => {
|
|||
// # Add a new attribute
|
||||
await sp.addAttribute();
|
||||
|
||||
// # Clear the auto-focused name input (leave it empty).
|
||||
// Use lastNameInput() so concurrent UAAE/ABAC rows don't shift the index.
|
||||
// # Fill Display Name so the Name field auto-derives as snake_case
|
||||
const displayNameInput = sp.lastDisplayNameInput();
|
||||
await displayNameInput.fill('Job Title');
|
||||
await displayNameInput.blur();
|
||||
|
||||
// * Verify the Name field auto-populated with the snake_case identifier
|
||||
const nameInput = sp.lastNameInput();
|
||||
await expect(nameInput).toHaveValue('job_title');
|
||||
|
||||
// # Clear the auto-derived identifier and blur to trigger the empty-name warning
|
||||
await nameInput.clear();
|
||||
await nameInput.blur();
|
||||
|
||||
// * Verify validation warning about empty name is shown
|
||||
await expect(sp.validationMessage('Please enter an attribute name.')).toBeVisible();
|
||||
// * Verify the in-cell error icon is rendered for the offending row
|
||||
await expect(sp.identifierValidationError()).toBeVisible();
|
||||
|
||||
// * Verify the bottom banner with the title and body copy is rendered
|
||||
await expect(sp.validationBannerByTitle('Please enter an attribute name.')).toBeVisible();
|
||||
await expect(sp.validationBannerByTitle(/missing a Name/)).toBeVisible();
|
||||
|
||||
// * Verify Save button is disabled due to validation error
|
||||
await expect(sp.saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify that entering a duplicate attribute name shows a "must be
|
||||
* unique" warning and disables the Save button.
|
||||
*
|
||||
* @precondition
|
||||
* A custom profile attribute named "UniqueName_<timestamp>" exists via API setup.
|
||||
* @objective Verify that two pending fields with the same Name surface the
|
||||
* `name_unique` validation: both rows display the in-cell error icon, a
|
||||
* single banner appears below the table, and Save stays disabled.
|
||||
*/
|
||||
test.fixme('shows validation warning for duplicate attribute names', {tag: '@user_attributes'}, async ({pw}) => {
|
||||
const {adminClient, systemConsolePage} = await setupTest(pw);
|
||||
test('shows validation warning for duplicate attribute names', {tag: '@user_attributes'}, async ({pw}) => {
|
||||
const {systemConsolePage} = await setupTest(pw);
|
||||
const sp = systemConsolePage.systemProperties;
|
||||
|
||||
// # Create an attribute via API (name must be a valid CEL identifier — no spaces)
|
||||
const uniqueDupName = `UniqueName_${Date.now()}`;
|
||||
const attributes: CustomProfileAttribute[] = [{name: uniqueDupName, type: 'text'}];
|
||||
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, attributes);
|
||||
const dupName = `dup_${Date.now()}`;
|
||||
|
||||
// # Navigate to User Attributes page
|
||||
await sp.goto();
|
||||
|
||||
// # Add a new attribute with the same name
|
||||
// # Add the first new attribute with the duplicate name. We commit the
|
||||
// # value via lastNameInput() here, then resolve the row by value below
|
||||
// # so subsequent addAttribute() calls don't cause `.last()` to slide
|
||||
// # onto the second row.
|
||||
await sp.addAttribute();
|
||||
await sp.lastNameInput().fill(dupName);
|
||||
await sp.lastNameInput().blur();
|
||||
|
||||
// Use lastNameInput() so concurrent UAAE/ABAC rows don't shift the index.
|
||||
const newNameInput = sp.lastNameInput();
|
||||
await newNameInput.clear();
|
||||
await newNameInput.fill(uniqueDupName);
|
||||
await newNameInput.blur();
|
||||
// # Add the second new attribute with the same name (triggers name_unique)
|
||||
await sp.addAttribute();
|
||||
const secondNameInput = sp.lastNameInput();
|
||||
await secondNameInput.fill(dupName);
|
||||
await secondNameInput.blur();
|
||||
|
||||
// * Verify validation warning about duplicate name is shown
|
||||
await expect(sp.validationMessage('Attribute names must be unique.').first()).toBeVisible();
|
||||
// * Both rows should display the in-cell error icon
|
||||
await expect(sp.identifierValidationError()).toHaveCount(2);
|
||||
|
||||
// * A single name_unique banner is rendered below the table
|
||||
await expect(sp.validationBannerByTitle('Attribute names must be unique.')).toHaveCount(1);
|
||||
await expect(sp.validationBannerByTitle(/share the same Name/)).toBeVisible();
|
||||
|
||||
// * Verify Save button is disabled
|
||||
await expect(sp.saveButton).toBeDisabled();
|
||||
|
||||
await cleanupFields(adminClient, fieldsMap);
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify the `name_taken` validation path: renaming a persisted
|
||||
* field to free its name, then renaming a second persisted field to take
|
||||
* the freed name, surfaces the in-cell icon and bottom banner. This is the
|
||||
* only sequence that reaches the `NameTaken` branch — duplicate-pending
|
||||
* fields trigger `NameUnique` first.
|
||||
*
|
||||
* @precondition
|
||||
* Two custom profile attributes (`taken_a`, `taken_b`) exist via API setup.
|
||||
*/
|
||||
test(
|
||||
'shows validation warning when a persisted attribute name is taken',
|
||||
{tag: '@user_attributes'},
|
||||
async ({pw}) => {
|
||||
const {adminClient, systemConsolePage} = await setupTest(pw);
|
||||
const sp = systemConsolePage.systemProperties;
|
||||
|
||||
// # Create two persisted attributes via API
|
||||
const uid = Date.now();
|
||||
const nameA = `taken_a_${uid}`;
|
||||
const nameB = `taken_b_${uid}`;
|
||||
const freedName = `taken_freed_${uid}`;
|
||||
const attributes: CustomProfileAttribute[] = [
|
||||
{name: nameA, type: 'text'},
|
||||
{name: nameB, type: 'text'},
|
||||
];
|
||||
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, attributes);
|
||||
|
||||
try {
|
||||
// # Navigate to User Attributes page
|
||||
await sp.goto();
|
||||
|
||||
// # Rename A first — frees the original `taken_a_*` name
|
||||
const inputA = sp.nameInputByValue(nameA);
|
||||
await expect(inputA).toBeVisible();
|
||||
await inputA.focus();
|
||||
await inputA.fill(freedName);
|
||||
await sp.page.keyboard.press('Tab');
|
||||
|
||||
// # Rename B to the now-freed name — reaches the NameTaken branch
|
||||
const inputB = sp.nameInputByValue(nameB);
|
||||
await expect(inputB).toBeVisible();
|
||||
await inputB.focus();
|
||||
await inputB.fill(nameA);
|
||||
await sp.page.keyboard.press('Tab');
|
||||
|
||||
// * Verify the offending row carries the in-cell error icon
|
||||
await expect(sp.identifierValidationError()).toHaveCount(1);
|
||||
|
||||
// * Verify the bottom banner with the name_taken title and body
|
||||
await expect(sp.validationBannerByTitle('Attribute name already taken.')).toBeVisible();
|
||||
await expect(sp.validationBannerByTitle(/already used by another attribute/)).toBeVisible();
|
||||
|
||||
// * Verify Save button is disabled
|
||||
await expect(sp.saveButton).toBeDisabled();
|
||||
} finally {
|
||||
await cleanupFields(adminClient, fieldsMap);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify changing an attribute type from Text to Phone via the type
|
||||
* selector saves the updated value_type to the server.
|
||||
|
|
@ -503,7 +576,7 @@ test.describe('System Console - User Attributes Management', () => {
|
|||
const sp = systemConsolePage.systemProperties;
|
||||
|
||||
// # Create a text attribute via API
|
||||
const attributes: CustomProfileAttribute[] = [{name: 'Contact_Number', type: 'text'}];
|
||||
const attributes: CustomProfileAttribute[] = [{name: 'contact_number', type: 'text'}];
|
||||
await setupCustomProfileAttributeFields(adminClient, attributes);
|
||||
|
||||
// # Navigate to User Attributes page
|
||||
|
|
@ -512,13 +585,13 @@ test.describe('System Console - User Attributes Management', () => {
|
|||
// # Select "Phone" type for the Contact_Number field.
|
||||
// Use selectTypeForField() — resolves the row index by name so concurrent
|
||||
// UAAE/ABAC rows don't shift the positional index.
|
||||
await sp.selectTypeForField('Contact_Number', 'Phone');
|
||||
await sp.selectTypeForField('contact_number', 'Phone');
|
||||
|
||||
await sp.saveAndWaitForSettled();
|
||||
|
||||
// * Verify field type was updated via API
|
||||
const updatedMap = await getFieldsMap(adminClient);
|
||||
const updatedField = Object.values(updatedMap).find((f) => f.name === 'Contact_Number');
|
||||
const updatedField = Object.values(updatedMap).find((f) => f.name === 'contact_number');
|
||||
expect(updatedField).toBeDefined();
|
||||
expect(updatedField!.type).toBe('text');
|
||||
expect(updatedField!.attrs.value_type).toBe('phone');
|
||||
|
|
@ -581,21 +654,21 @@ test.describe('System Console - User Attributes Management', () => {
|
|||
// # Create first attribute (text) — use lastNameInput() after each addAttribute()
|
||||
await sp.addAttribute();
|
||||
const firstInput = sp.lastNameInput();
|
||||
await firstInput.fill('Job_Title');
|
||||
await firstInput.fill('job_title');
|
||||
await firstInput.blur();
|
||||
|
||||
// # Create second attribute (text)
|
||||
await sp.addAttribute();
|
||||
const secondInput = sp.lastNameInput();
|
||||
await secondInput.fill('Team_Name');
|
||||
await secondInput.fill('team_name');
|
||||
await secondInput.blur();
|
||||
|
||||
await sp.saveAndWaitForSettled();
|
||||
|
||||
// * Verify both fields were created via API
|
||||
const fieldsMap = await getFieldsMap(adminClient);
|
||||
expect(Object.values(fieldsMap).find((f) => f.name === 'Job_Title')).toBeDefined();
|
||||
expect(Object.values(fieldsMap).find((f) => f.name === 'Team_Name')).toBeDefined();
|
||||
expect(Object.values(fieldsMap).find((f) => f.name === 'job_title')).toBeDefined();
|
||||
expect(Object.values(fieldsMap).find((f) => f.name === 'team_name')).toBeDefined();
|
||||
|
||||
await cleanupFields(adminClient, fieldsMap);
|
||||
});
|
||||
|
|
@ -612,20 +685,20 @@ test.describe('System Console - User Attributes Management', () => {
|
|||
const sp = systemConsolePage.systemProperties;
|
||||
|
||||
// # Create an attribute via API
|
||||
await setupCustomProfileAttributeFields(adminClient, [{name: 'Persistent_Field', type: 'text'}]);
|
||||
await setupCustomProfileAttributeFields(adminClient, [{name: 'persistent_field', type: 'text'}]);
|
||||
|
||||
// # Navigate to User Attributes page
|
||||
await sp.goto();
|
||||
|
||||
// * Verify attribute exists
|
||||
await expect(sp.nameInputByValue('Persistent_Field')).toBeVisible();
|
||||
await expect(sp.nameInputByValue('persistent_field')).toBeVisible();
|
||||
|
||||
// # Edit the name using a value-based locator so concurrent UAAE/ABAC rows
|
||||
// don't shift a positional index to the wrong field.
|
||||
const nameInput = sp.nameInputByValue('Persistent_Field');
|
||||
await expect(nameInput).toHaveValue('Persistent_Field');
|
||||
const nameInput = sp.nameInputByValue('persistent_field');
|
||||
await expect(nameInput).toHaveValue('persistent_field');
|
||||
await nameInput.focus();
|
||||
await nameInput.fill('Updated_Persistent');
|
||||
await nameInput.fill('updated_persistent');
|
||||
// blur via keyboard — the value-based locator is stale after fill()
|
||||
await sp.page.keyboard.press('Tab');
|
||||
|
||||
|
|
@ -635,11 +708,60 @@ test.describe('System Console - User Attributes Management', () => {
|
|||
await sp.goto();
|
||||
|
||||
// * Verify the updated name persisted
|
||||
await expect(sp.nameInputByValue('Updated_Persistent')).toBeVisible();
|
||||
await expect(sp.nameInputByValue('updated_persistent')).toBeVisible();
|
||||
|
||||
await cleanupFields(adminClient, await getFieldsMap(adminClient));
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify that two distinct name validation errors produce two
|
||||
* stacked banners below the table (one per type), both offending rows
|
||||
* carry the in-cell error icon, and that fixing one row removes only the
|
||||
* matching banner while the other persists.
|
||||
*/
|
||||
test('stacks one banner per distinct validation error type', {tag: '@user_attributes'}, async ({pw}) => {
|
||||
const {systemConsolePage} = await setupTest(pw);
|
||||
const sp = systemConsolePage.systemProperties;
|
||||
|
||||
// # Navigate to User Attributes page
|
||||
await sp.goto();
|
||||
|
||||
// # Row 1: trigger name_required (fill Display Name, clear auto-Name, blur)
|
||||
await sp.addAttribute();
|
||||
const firstDisplayName = sp.lastDisplayNameInput();
|
||||
await firstDisplayName.fill('Job Title');
|
||||
await firstDisplayName.blur();
|
||||
const firstNameInput = sp.lastNameInput();
|
||||
await expect(firstNameInput).toHaveValue('job_title');
|
||||
await firstNameInput.clear();
|
||||
await firstNameInput.blur();
|
||||
|
||||
// # Row 2: trigger name_invalid_cel (reserved CEL keyword)
|
||||
await sp.addAttribute();
|
||||
const secondNameInput = sp.lastNameInput();
|
||||
await secondNameInput.fill('true');
|
||||
await secondNameInput.blur();
|
||||
|
||||
// * Both banners stack at the bottom of the table
|
||||
await expect(sp.validationBannerByTitle('Please enter an attribute name.')).toBeVisible();
|
||||
await expect(sp.validationBannerByTitle(/Identifier must start with a letter or underscore/)).toBeVisible();
|
||||
|
||||
// * Both offending Name cells carry the in-cell error icon
|
||||
await expect(sp.identifierValidationError()).toHaveCount(2);
|
||||
|
||||
// * Save button stays disabled while any banner is present
|
||||
await expect(sp.saveButton).toBeDisabled();
|
||||
|
||||
// # Fix the second row (give it a valid name) — only the CEL banner should disappear
|
||||
await secondNameInput.fill('valid_name');
|
||||
await secondNameInput.blur();
|
||||
|
||||
await expect(sp.validationBannerByTitle(/Identifier must start with a letter or underscore/)).toHaveCount(0);
|
||||
await expect(sp.validationBannerByTitle('Please enter an attribute name.')).toBeVisible();
|
||||
await expect(sp.identifierValidationError()).toHaveCount(1);
|
||||
await expect(sp.saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify that deleting a newly added (unsaved) attribute removes
|
||||
* the row without a confirmation modal.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,221 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Client4} from '@mattermost/client';
|
||||
import type {UserPropertyField} from '@mattermost/types/properties';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
import {expect, test, testConfig} from '@mattermost/playwright-lib';
|
||||
import type {PlaywrightExtended, SystemConsolePage} from '@mattermost/playwright-lib';
|
||||
|
||||
import {setupCustomProfileAttributeValuesForUser} from '../../channels/custom_profile_attributes/helpers';
|
||||
|
||||
type AdminUser = UserProfile & {password: string};
|
||||
|
||||
const IDENTIFIER_VALIDATION_MESSAGE =
|
||||
'Identifier must start with a letter or underscore and contain only letters, numbers, and underscores. Reserved CEL words are not allowed.';
|
||||
|
||||
interface TestContext {
|
||||
adminClient: Client4;
|
||||
adminUser: AdminUser;
|
||||
systemConsolePage: SystemConsolePage;
|
||||
}
|
||||
|
||||
async function createAdminClient(): Promise<{adminClient: Client4; adminUser: AdminUser}> {
|
||||
const adminClient = new Client4();
|
||||
adminClient.setUrl(testConfig.baseURL);
|
||||
|
||||
const loggedInUser = await adminClient.login(testConfig.adminUsername, testConfig.adminPassword);
|
||||
const adminUser = {
|
||||
...loggedInUser,
|
||||
password: testConfig.adminPassword,
|
||||
} as AdminUser;
|
||||
|
||||
return {adminClient, adminUser};
|
||||
}
|
||||
|
||||
async function setupTest(pw: PlaywrightExtended): Promise<TestContext> {
|
||||
await pw.ensureLicense();
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminClient, adminUser} = await createAdminClient();
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
|
||||
return {adminClient, adminUser, systemConsolePage};
|
||||
}
|
||||
|
||||
async function getTownSquareRoute(adminClient: Client4) {
|
||||
const teams = await adminClient.getMyTeams();
|
||||
expect(teams.length).toBeGreaterThan(0);
|
||||
|
||||
const team = teams[0];
|
||||
const channel = await adminClient.getChannelByName(team.id, 'town-square');
|
||||
|
||||
return {
|
||||
teamName: team.name,
|
||||
channelName: channel.name,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe('System Console - User Attributes display names', () => {
|
||||
/**
|
||||
* @objective Verify that a CPA field's display_name is rendered as the user-facing
|
||||
* label in the user attributes table, the profile popover, account settings, and
|
||||
* the admin user detail page while the identifier remains unchanged in the API.
|
||||
*/
|
||||
test('renders display_name across admin and self-service surfaces', {tag: '@user_attributes'}, async ({pw}) => {
|
||||
const {adminClient, adminUser, systemConsolePage} = await setupTest(pw);
|
||||
const sp = systemConsolePage.systemProperties;
|
||||
|
||||
const uid = Date.now();
|
||||
const identifier = `department_${uid}`;
|
||||
const displayName = `Department ${uid}`;
|
||||
const attributeValue = 'Engineering';
|
||||
|
||||
let createdField: UserPropertyField | undefined;
|
||||
|
||||
try {
|
||||
// # Navigate to User Attributes and create a new field with a display name
|
||||
await sp.goto();
|
||||
|
||||
// * Verify the table exposes both Name and Display Name column headers
|
||||
await expect(sp.container.getByRole('columnheader', {name: 'Display Name', exact: true})).toBeVisible();
|
||||
await expect(sp.container.getByRole('columnheader', {name: 'Name', exact: true})).toBeVisible();
|
||||
|
||||
// # Add a new row and fill identifier + display name
|
||||
await sp.addAttribute();
|
||||
await sp.lastNameInput().fill(identifier);
|
||||
await sp.lastNameInput().blur();
|
||||
await sp.lastDisplayNameInput().fill(displayName);
|
||||
await sp.lastDisplayNameInput().blur();
|
||||
|
||||
await sp.saveAndWaitForSettled();
|
||||
|
||||
const fields = await adminClient.getCustomProfileAttributeFields();
|
||||
createdField = fields.find((field) => field.name === identifier);
|
||||
|
||||
expect(createdField).toBeDefined();
|
||||
expect(createdField?.attrs?.display_name).toBe(displayName);
|
||||
|
||||
// # Set a value for sysadmin and open the self profile popover in Channels
|
||||
await setupCustomProfileAttributeValuesForUser(
|
||||
adminClient,
|
||||
[{name: identifier, value: attributeValue, type: 'text'}],
|
||||
{[createdField!.id]: createdField!},
|
||||
adminUser.id,
|
||||
);
|
||||
|
||||
const {teamName, channelName} = await getTownSquareRoute(adminClient);
|
||||
const {channelsPage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await channelsPage.goto(teamName, channelName);
|
||||
await channelsPage.postMessage(`phase-5-display-name-${uid}`);
|
||||
|
||||
const lastPost = await channelsPage.getLastPost();
|
||||
await channelsPage.openProfilePopover(lastPost);
|
||||
|
||||
// * Verify the profile popover and account settings render display_name
|
||||
await expect(
|
||||
channelsPage.page.locator(`#user-popover__custom_attributes-title-${createdField!.id}`),
|
||||
).toHaveText(displayName);
|
||||
|
||||
await channelsPage.userProfilePopover.close();
|
||||
|
||||
const profileModal = await channelsPage.openProfileModal();
|
||||
const section = profileModal.container.locator('.section-min').filter({hasText: displayName});
|
||||
await expect(section).toBeVisible();
|
||||
|
||||
const editButton = profileModal.container.locator(`#customAttribute_${createdField!.id}Edit`);
|
||||
await editButton.scrollIntoViewIfNeeded();
|
||||
await editButton.click();
|
||||
|
||||
const settingsInput = profileModal.container.locator(`#customAttribute_${createdField!.id}`);
|
||||
await expect(settingsInput).toHaveAttribute('aria-label', displayName);
|
||||
await profileModal.closeModal();
|
||||
|
||||
// # Open the admin user detail page for sysadmin
|
||||
await systemConsolePage.page.goto(`/admin_console/user_management/user/${adminUser.id}`);
|
||||
await systemConsolePage.users.userDetail.toBeVisible();
|
||||
|
||||
// * Verify the admin user detail label also uses display_name
|
||||
await expect(
|
||||
systemConsolePage.page.getByTestId(`user-detail-custom-attribute-label-${createdField!.id}`),
|
||||
).toContainText(displayName);
|
||||
} finally {
|
||||
if (createdField) {
|
||||
await adminClient.deleteCustomProfileAttributeField(createdField.id).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify that invalid CPA identifiers are blocked client-side before
|
||||
* any create-field API request is issued, and that a valid identifier clears the
|
||||
* warning and can be saved successfully.
|
||||
*/
|
||||
test('blocks invalid identifiers before API submission', {tag: '@user_attributes'}, async ({pw}) => {
|
||||
const {adminClient, systemConsolePage} = await setupTest(pw);
|
||||
const sp = systemConsolePage.systemProperties;
|
||||
const {page} = systemConsolePage;
|
||||
|
||||
const apiPosts: string[] = [];
|
||||
const validIdentifier = `my_field_${Date.now()}`;
|
||||
|
||||
page.on('request', (request) => {
|
||||
if (request.method() === 'POST' && request.url().includes('/api/v4/custom_profile_attributes/fields')) {
|
||||
apiPosts.push(request.url());
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
// # Add an attribute and exercise invalid identifier inputs in the table.
|
||||
// Use lastNameInput() — not positional nameInput(0) — so a leftover row from
|
||||
// a prior test attempt (or a concurrent UAAE/ABAC suite) cannot shift the
|
||||
// index and cause us to rename someone else's field instead of populating
|
||||
// the row we just added.
|
||||
await sp.goto();
|
||||
await sp.addAttribute();
|
||||
|
||||
const invalidIdentifiers = ['in', 'true', 'for'];
|
||||
for (const invalidIdentifier of invalidIdentifiers) {
|
||||
await sp.lastNameInput().fill(invalidIdentifier);
|
||||
await sp.lastNameInput().blur();
|
||||
|
||||
// * Verify the in-cell error icon and bottom banner are rendered,
|
||||
// and Save stays disabled before any POST is issued.
|
||||
await expect(sp.identifierValidationError()).toBeVisible();
|
||||
await expect(sp.validationBannerByTitle(IDENTIFIER_VALIDATION_MESSAGE)).toBeVisible();
|
||||
await expect(sp.validationBannerByTitle(/not a valid identifier/)).toBeVisible();
|
||||
await expect(sp.saveButton).toBeDisabled();
|
||||
}
|
||||
|
||||
expect(apiPosts).toHaveLength(0);
|
||||
|
||||
// # Correct the identifier to a valid CEL-safe name and save it
|
||||
await sp.lastNameInput().fill(validIdentifier);
|
||||
await sp.lastNameInput().blur();
|
||||
|
||||
// * Verify the warning clears and the field can be created successfully
|
||||
await expect(sp.identifierValidationError()).not.toBeVisible();
|
||||
await expect(sp.saveButton).toBeEnabled();
|
||||
|
||||
await sp.saveAndWaitForSettled();
|
||||
|
||||
const fields = await adminClient.getCustomProfileAttributeFields();
|
||||
const createdField = fields.find((field) => field.name === validIdentifier);
|
||||
expect(createdField).toBeDefined();
|
||||
} finally {
|
||||
// Look up the field by name rather than relying on a captured `createdField`
|
||||
// — the assertions above can throw before that variable is assigned, and we
|
||||
// still want to remove the server-side field so retries start from a clean slate.
|
||||
const fields = await adminClient.getCustomProfileAttributeFields().catch(() => []);
|
||||
const leftover = fields.find((field) => field.name === validIdentifier);
|
||||
if (leftover) {
|
||||
await adminClient.deleteCustomProfileAttributeField(leftover.id).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1 +1 @@
|
|||
1.26.2
|
||||
1.26.3
|
||||
|
|
|
|||
|
|
@ -1,20 +1,84 @@
|
|||
version: "2"
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- bidichk
|
||||
- errcheck
|
||||
- govet
|
||||
- ineffassign
|
||||
- makezero
|
||||
- misspell
|
||||
- modernize
|
||||
- revive
|
||||
- staticcheck
|
||||
- unconvert
|
||||
- unqueryvet
|
||||
- unused
|
||||
- whitespace
|
||||
default: all
|
||||
disable:
|
||||
- bodyclose
|
||||
- canonicalheader
|
||||
- containedctx # storing context.Context in a struct is an established pattern here
|
||||
- contextcheck
|
||||
- cyclop
|
||||
- depguard
|
||||
- dogsled # test helpers return many values; blank-heavy destructuring is idiomatic
|
||||
- dupl
|
||||
- dupword
|
||||
- embeddedstructfieldcheck
|
||||
- err113
|
||||
- errchkjson
|
||||
- errname
|
||||
- errorlint
|
||||
- exhaustive
|
||||
- exhaustruct
|
||||
- forbidigo
|
||||
- forcetypeassert
|
||||
- funcorder
|
||||
- funlen
|
||||
- gocheckcompilerdirectives # //go:fix is a valid directive in Go 1.24+; linter doesn't know it yet
|
||||
- gochecknoglobals
|
||||
- gochecknoinits
|
||||
- gocognit
|
||||
- goconst
|
||||
- gocritic
|
||||
- gocyclo
|
||||
- godoclint
|
||||
- godot
|
||||
- godox
|
||||
- gomoddirectives # replace directives in go.mod are intentional forks
|
||||
- gomodguard # deprecated since v2.12.0; replaced by gomodguard_v2 (enabled via default: all)
|
||||
- goprintffuncname # Ephemeral → Ephemeralf rename is a plugin API breaking change; deferred
|
||||
- gosec
|
||||
- gosmopolitan
|
||||
- iface # identical job interfaces are intentional — type-safe scheduling without coupling
|
||||
- inamedparam
|
||||
- interfacebloat
|
||||
- intrange
|
||||
- iotamixing # const blocks intentionally mix iota with explicit values (ABI stability, ASCII)
|
||||
- ireturn
|
||||
- lll
|
||||
- maintidx
|
||||
- mnd
|
||||
- musttag
|
||||
- nakedret
|
||||
- nestif
|
||||
- nilerr # intentionally dropping errors is common here (graceful degradation, security non-disclosure, fallbacks)
|
||||
- nilnil
|
||||
- nlreturn
|
||||
- noctx
|
||||
- noinlineerr
|
||||
- nolintlint
|
||||
- nonamedreturns
|
||||
- paralleltest
|
||||
- perfsprint
|
||||
- prealloc
|
||||
- predeclared # variable named 'copy' is intentional; already suppressed for revive
|
||||
- promlinter # metric renames are a breaking change; deferred
|
||||
- protogetter
|
||||
- recvcheck
|
||||
- sqlclosecheck # wrapper functions return *sqlx.Rows to callers who close them; not a real leak
|
||||
- tagalign
|
||||
- tagliatelle
|
||||
- testableexamples
|
||||
- testifylint
|
||||
- testpackage
|
||||
- thelper
|
||||
- tparallel
|
||||
- unparam
|
||||
- usestdlibvars
|
||||
- usetesting
|
||||
- varnamelen
|
||||
- wastedassign
|
||||
- wrapcheck
|
||||
- wsl
|
||||
- wsl_v5
|
||||
settings:
|
||||
govet:
|
||||
disable:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
.PHONY: build package run stop run-client run-server run-node run-haserver stop-haserver stop-client stop-server restart restart-server restart-client restart-haserver start-docker update-docker clean-dist clean nuke check-style check-client-style check-server-style check-unit-tests test dist run-client-tests setup-run-client-tests cleanup-run-client-tests test-client build-linux build-osx build-freebsd build-windows package-prep package-linux package-osx package-windows internal-test-web-client vet run-server-for-web-client-tests diff-config prepackaged-plugins prepackaged-binaries test-server test-server-ee test-server-elasticsearch test-server-quick test-server-race test-mmctl-unit test-mmctl-e2e test-mmctl test-mmctl-coverage mmctl-build mmctl-docs new-migration migrations-extract test-public mocks-public run-server-faketime
|
||||
.PHONY: build package run stop run-client run-server run-node run-haserver stop-haserver stop-client stop-server restart restart-server restart-client restart-haserver start-docker update-docker clean-dist clean nuke check-style check-client-style check-server-style check-unit-tests test dist run-client-tests setup-run-client-tests cleanup-run-client-tests test-client build-linux build-osx build-freebsd build-windows package-prep package-linux package-osx package-windows internal-test-web-client vet run-server-for-web-client-tests diff-config prepackaged-plugins prepackaged-binaries test-server test-server-ee test-server-elasticsearch test-server-opensearch test-server-quick test-server-race test-mmctl-unit test-mmctl-e2e test-mmctl test-mmctl-coverage mmctl-build mmctl-docs new-migration migrations-extract test-public mocks-public run-server-faketime
|
||||
|
||||
ROOT := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
||||
|
||||
|
|
@ -158,15 +158,15 @@ TEMPLATES_DIR=templates
|
|||
|
||||
# Plugins Packages
|
||||
PLUGIN_PACKAGES ?= $(PLUGIN_PACKAGES:)
|
||||
PLUGIN_PACKAGES += mattermost-plugin-calls-v1.11.4
|
||||
PLUGIN_PACKAGES += mattermost-plugin-calls-v1.11.5
|
||||
PLUGIN_PACKAGES += mattermost-plugin-github-v2.7.1
|
||||
PLUGIN_PACKAGES += mattermost-plugin-gitlab-v1.12.2
|
||||
PLUGIN_PACKAGES += mattermost-plugin-jira-v4.7.0
|
||||
PLUGIN_PACKAGES += mattermost-plugin-playbooks-v2.8.1
|
||||
PLUGIN_PACKAGES += mattermost-plugin-playbooks-v2.9.0
|
||||
PLUGIN_PACKAGES += mattermost-plugin-servicenow-v2.4.0
|
||||
PLUGIN_PACKAGES += mattermost-plugin-zoom-v1.13.0
|
||||
PLUGIN_PACKAGES += mattermost-plugin-agents-v2.0.3
|
||||
PLUGIN_PACKAGES += mattermost-plugin-boards-v9.2.4
|
||||
PLUGIN_PACKAGES += mattermost-plugin-boards-v9.2.5
|
||||
PLUGIN_PACKAGES += mattermost-plugin-user-survey-v1.1.1
|
||||
PLUGIN_PACKAGES += mattermost-plugin-mscalendar-v1.6.1
|
||||
PLUGIN_PACKAGES += mattermost-plugin-msteams-meetings-v2.4.1
|
||||
|
|
@ -178,9 +178,9 @@ PLUGIN_PACKAGES += mattermost-plugin-channel-export-v1.3.0
|
|||
# download the package from to work. This will no longer be needed when we unify
|
||||
# the way we pre-package FIPS and non-FIPS plugins.
|
||||
ifeq ($(FIPS_ENABLED),true)
|
||||
PLUGIN_PACKAGES = mattermost-plugin-playbooks-v2.8.1%2Bac0a223-fips
|
||||
PLUGIN_PACKAGES = mattermost-plugin-playbooks-v2.9.0%2Bdfb5b30-fips
|
||||
PLUGIN_PACKAGES += mattermost-plugin-agents-v2.0.3%2Bcab391a-fips
|
||||
PLUGIN_PACKAGES += mattermost-plugin-boards-v9.2.4%2B5855fe1-fips
|
||||
PLUGIN_PACKAGES += mattermost-plugin-boards-v9.2.5%2Bf4fc5d6-fips
|
||||
endif
|
||||
|
||||
EE_PACKAGES=$(shell $(GO) list $(BUILD_ENTERPRISE_DIR)/...)
|
||||
|
|
@ -328,7 +328,7 @@ golang-versions: ## Install Golang versions used for compatibility testing (e.g.
|
|||
export GO_COMPATIBILITY_TEST_VERSIONS="${GO_COMPATIBILITY_TEST_VERSIONS}"
|
||||
|
||||
golangci-lint: setup-go-work ## Run golangci-lint on codebase
|
||||
$(GO) install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4
|
||||
$(GO) install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
||||
ifeq ($(BUILD_ENTERPRISE_READY),true)
|
||||
$(GOBIN)/golangci-lint run ./... ./public/... $(BUILD_ENTERPRISE_DIR)/...
|
||||
else
|
||||
|
|
@ -508,6 +508,15 @@ test-server-elasticsearch: check-prereqs-enterprise start-docker gotestsum ## Ru
|
|||
@echo Running only Elasticsearch tests
|
||||
$(GOBIN)/gotestsum --rerun-fails=3 --packages="$(ES_PACKAGES)" -- $(GOFLAGS) -timeout=20m
|
||||
|
||||
OS_PACKAGES=$(shell $(GO) list ./enterprise/elasticsearch/opensearch/...)
|
||||
|
||||
test-server-opensearch: export GOTESTSUM_FORMAT := $(GOTESTSUM_FORMAT)
|
||||
test-server-opensearch: export GOTESTSUM_JUNITFILE := $(GOTESTSUM_JUNITFILE)
|
||||
test-server-opensearch: export GOTESTSUM_JSONFILE := $(GOTESTSUM_JSONFILE)
|
||||
test-server-opensearch: check-prereqs-enterprise start-docker gotestsum ## Runs OpenSearch tests.
|
||||
@echo Running only OpenSearch tests
|
||||
$(GOBIN)/gotestsum --rerun-fails=3 --packages="$(OS_PACKAGES)" -- $(GOFLAGS) -timeout=20m
|
||||
|
||||
test-server-quick: export GOTESTSUM_FORMAT := $(GOTESTSUM_FORMAT)
|
||||
test-server-quick: export GOTESTSUM_JUNITFILE := $(GOTESTSUM_JUNITFILE)
|
||||
test-server-quick: export GOTESTSUM_JSONFILE := $(GOTESTSUM_JSONFILE)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
FROM mattermost/golang-bullseye:1.26.2@sha256:fc2cc64f035c74f14b0e5921971bcda4b6dbd281430d3cbfb0bf539ebb1bacd5
|
||||
FROM mattermost/golang-bullseye:1.26.3@sha256:3ae112b7dc291665c5582b9d768fc2adb4cdc3afbbd3fc82e03a10cd711e1a60
|
||||
ARG NODE_VERSION=20.11.1
|
||||
|
||||
RUN apt-get update && apt-get install -y make git apt-transport-https ca-certificates curl software-properties-common build-essential zip xmlsec1 jq pgloader gnupg
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
FROM cgr.dev/mattermost.com/go-msft-fips:1.26.2-dev@sha256:cdd5bd448fcf61654893846572f006aff7349aa21027de590fc2bd020f8126f1
|
||||
FROM cgr.dev/mattermost.com/go-msft-fips:1.26.3-dev@sha256:48ab99fede7fb33e132a0636072971e1ec4a69520865bfa1e4b517ee9cfdef34
|
||||
ARG NODE_VERSION=20.11.1
|
||||
|
||||
RUN apk add curl ca-certificates mailcap unrtf wv poppler-utils tzdata gpg xmlsec
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
ARG OPENSEARCH_VERSION=2.7.0
|
||||
ARG OPENSEARCH_VERSION=3.0.0
|
||||
FROM opensearchproject/opensearch:$OPENSEARCH_VERSION
|
||||
|
||||
RUN /usr/share/opensearch/bin/opensearch-plugin install analysis-icu analysis-nori analysis-kuromoji analysis-smartcn
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ services:
|
|||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile.opensearch
|
||||
args:
|
||||
OPENSEARCH_VERSION: ${OPENSEARCH_VERSION:-3.0.0}
|
||||
networks:
|
||||
- mm-test
|
||||
environment:
|
||||
|
|
@ -96,7 +98,9 @@ services:
|
|||
transport.host: "127.0.0.1"
|
||||
discovery.type: single-node
|
||||
plugins.security.disabled: "true"
|
||||
ES_JAVA_OPTS: "-Xms512m -Xmx512m"
|
||||
DISABLE_INSTALL_DEMO_CONFIG: "true"
|
||||
OPENSEARCH_INITIAL_ADMIN_PASSWORD: "Test@dmin_123"
|
||||
OPENSEARCH_JAVA_OPTS: "-Xms512m -Xmx512m"
|
||||
redis:
|
||||
image: "redis:7.4.0"
|
||||
logging: *default-logging
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import (
|
|||
)
|
||||
|
||||
// shouldRedactExpressions reports whether raw CEL expressions should be masked for this caller.
|
||||
// Returns true when both ABAC and attribute-value masking are enabled. Callers reading raw expressions
|
||||
// Masking is attribute-based, not permission-based: system admins who do not hold all values
|
||||
// in a policy must also receive redacted raw expressions.
|
||||
func shouldRedactExpressions(c *Context) bool {
|
||||
return c.App.Config().FeatureFlags.AttributeBasedAccessControl &&
|
||||
|
|
@ -32,6 +32,7 @@ func (api *API) InitAccessControlPolicy() {
|
|||
|
||||
api.BaseRoutes.AccessControlPolicies.Handle("/cel/check", api.APISessionRequired(checkExpression)).Methods(http.MethodPost)
|
||||
api.BaseRoutes.AccessControlPolicies.Handle("/cel/test", api.APISessionRequired(testExpression)).Methods(http.MethodPost)
|
||||
api.BaseRoutes.AccessControlPolicies.Handle("/cel/simulate_users", api.APISessionRequired(simulatePolicyForUsers)).Methods(http.MethodPost)
|
||||
api.BaseRoutes.AccessControlPolicies.Handle("/cel/validate_requester", api.APISessionRequired(validateExpressionAgainstRequester)).Methods(http.MethodPost)
|
||||
api.BaseRoutes.AccessControlPolicies.Handle("/cel/autocomplete/fields", api.APISessionRequired(getFieldsAutocomplete)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.AccessControlPolicies.Handle("/cel/visual_ast", api.APISessionRequired(convertToVisualAST)).Methods(http.MethodPost)
|
||||
|
|
@ -57,6 +58,19 @@ func createAccessControlPolicy(c *Context, w http.ResponseWriter, r *http.Reques
|
|||
return
|
||||
}
|
||||
|
||||
// Channel-scope policies are always available, but a channel policy
|
||||
// that carries a permission-rule action (upload_file_attachment,
|
||||
// download_file_attachment) is gated behind the channel-level
|
||||
// sub-flag — that's the toggle that exposes the Channel Settings →
|
||||
// Permissions Policy tab on the frontend. Membership-only channel
|
||||
// policies stay unaffected. Helper enforces the PermissionPolicies
|
||||
// umbrella too, so a request slipping in with the sub-flag on but
|
||||
// the umbrella off is also rejected here.
|
||||
if policy.Type == model.AccessControlPolicyTypeChannel && policy.HasPermissionRuleAction() && !c.App.Config().FeatureFlags.IsChannelPermissionPoliciesEnabled() {
|
||||
c.Err = model.NewAppError("createAccessControlPolicy", "api.access_control_policy.channel_permission_policies.feature_disabled", nil, "", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventCreateAccessControlPolicy, model.AuditStatusFail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
model.AddEventParameterAuditableToAuditRec(auditRec, "requested", &policy)
|
||||
|
|
@ -141,6 +155,10 @@ func createAccessControlPolicy(c *Context, w http.ResponseWriter, r *http.Reques
|
|||
auditRec.AddEventObjectType("access_control_policy")
|
||||
auditRec.AddEventResultState(np)
|
||||
|
||||
if shouldRedactExpressions(c) {
|
||||
c.App.MaskPolicyExpressions(c.AppContext, np, c.AppContext.Session().UserId)
|
||||
}
|
||||
|
||||
js, err := json.Marshal(np)
|
||||
if err != nil {
|
||||
c.Err = model.NewAppError("createAccessControlPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
|
|
@ -193,6 +211,10 @@ func getAccessControlPolicy(c *Context, w http.ResponseWriter, r *http.Request)
|
|||
return
|
||||
}
|
||||
|
||||
if shouldRedactExpressions(c) {
|
||||
c.App.MaskPolicyExpressions(c.AppContext, policy, c.AppContext.Session().UserId)
|
||||
}
|
||||
|
||||
js, err := json.Marshal(policy)
|
||||
if err != nil {
|
||||
c.Err = model.NewAppError("getAccessControlPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
|
|
@ -268,10 +290,7 @@ func checkExpression(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
||||
if !hasSystemPermission {
|
||||
teamID := checkExpressionRequest.TeamId
|
||||
hasTeamPermission := teamID != "" && model.IsValidId(teamID) &&
|
||||
c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamID, model.PermissionManageTeamAccessRules)
|
||||
if !hasTeamPermission {
|
||||
if !teamAdminCELContextOK(c, channelId, checkExpressionRequest.TeamId) {
|
||||
if channelId == "" {
|
||||
c.SetPermissionError(model.PermissionManageSystem)
|
||||
return
|
||||
|
|
@ -321,8 +340,7 @@ func testExpression(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
||||
hasTeamPermission := !hasSystemPermission && teamID != "" &&
|
||||
c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamID, model.PermissionManageTeamAccessRules)
|
||||
hasTeamPermission := !hasSystemPermission && teamAdminCELContextOK(c, channelId, teamID)
|
||||
|
||||
if !hasSystemPermission && !hasTeamPermission {
|
||||
if channelId == "" {
|
||||
|
|
@ -381,6 +399,189 @@ func testExpression(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
// teamAdminCELContextOK reports whether the session may use the delegated
|
||||
// team-admin shortcut for CEL tooling: valid team_id, ManageTeamAccessRules on
|
||||
// that team, and when a channel_id is supplied it must resolve to a channel in
|
||||
// that same team. Prevents pairing a team the admin manages with an unrelated
|
||||
// channel solely to satisfy the channel branch of auth.
|
||||
func teamAdminCELContextOK(c *Context, channelID, teamID string) bool {
|
||||
if teamID == "" || !model.IsValidId(teamID) {
|
||||
return false
|
||||
}
|
||||
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamID, model.PermissionManageTeamAccessRules) {
|
||||
return false
|
||||
}
|
||||
if channelID == "" {
|
||||
return true
|
||||
}
|
||||
if !model.IsValidId(channelID) {
|
||||
return false
|
||||
}
|
||||
channel, appErr := c.App.GetChannel(c.AppContext, channelID)
|
||||
if appErr != nil {
|
||||
return false
|
||||
}
|
||||
return channel.TeamId == teamID
|
||||
}
|
||||
|
||||
// authorizeSimulatePolicy checks the caller's permission to simulate a
|
||||
// policy and returns whether they have system-level access — used by
|
||||
// the caller to scope SanitizeProfile.
|
||||
//
|
||||
// Authorization order:
|
||||
// - system admin: always.
|
||||
// - team admin: only when teamID is set AND any provided channelID
|
||||
// resolves to a channel in that team. Without this guard a team
|
||||
// admin could simulate a policy for any channel by pairing their
|
||||
// team_id with a foreign channel_id; the cross-team check forces
|
||||
// the auth to fall through to HasPermissionToChannel for any
|
||||
// channel outside the admin's team.
|
||||
// - channel admin: when channelID is set, via HasPermissionToChannel
|
||||
// (which already covers the channel's actual team admins).
|
||||
//
|
||||
// On failure the function sets the appropriate permission error on `c`
|
||||
// and returns ok=false. Callers MUST early-return when ok=false.
|
||||
func authorizeSimulatePolicy(c *Context, channelID, teamID string) (hasSystemPermission bool, ok bool) {
|
||||
hasSystemPermission = c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
||||
if hasSystemPermission {
|
||||
return true, true
|
||||
}
|
||||
|
||||
if teamAdminCELContextOK(c, channelID, teamID) {
|
||||
return false, true
|
||||
}
|
||||
|
||||
if channelID == "" {
|
||||
c.SetPermissionError(model.PermissionManageSystem)
|
||||
return false, false
|
||||
}
|
||||
hasChannelPermission, _ := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelID, model.PermissionManageChannelAccessRules)
|
||||
if !hasChannelPermission {
|
||||
c.SetPermissionError(model.PermissionManageChannelAccessRules)
|
||||
return false, false
|
||||
}
|
||||
return false, true
|
||||
}
|
||||
|
||||
// simulatePolicyForUsers runs the dual-lane PDP simulation against a draft
|
||||
// policy (not persisted) plus any higher-scoped persisted permission
|
||||
// policies, for an explicit set of user IDs (with optional per-user session
|
||||
// attribute overrides). The response carries per-user, per-action
|
||||
// ALLOW/DENY decisions plus blame attribution for any deny — used by the
|
||||
// "Simulate access" picker UX in the System Console and Channel Settings.
|
||||
//
|
||||
// Permission gates:
|
||||
// - System admins: full access.
|
||||
// - Team admins (with PermissionManageTeamAccessRules on the team): when a
|
||||
// team_id is present in the body and any provided channel_id resolves
|
||||
// to a channel in that team.
|
||||
// - Channel admins (with PermissionManageChannelAccessRules on the
|
||||
// channel): when a channel_id is present in the body.
|
||||
//
|
||||
// Non-system admins may only simulate users who belong to the request's
|
||||
// channel (when channel_id is set) or team (team-scoped simulation).
|
||||
// The endpoint requires the PolicySimulation feature flag (which
|
||||
// itself depends on the PermissionPolicies umbrella) and an
|
||||
// Enterprise Advanced license. Returns 501 when ABAC is unavailable.
|
||||
func simulatePolicyForUsers(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
if !c.App.Config().FeatureFlags.IsPolicySimulationEnabled() {
|
||||
c.Err = model.NewAppError("simulatePolicyForUsers", "api.access_control_policy.policy_simulation.feature_disabled", nil, "", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
var params model.PolicySimulationByUsersParams
|
||||
if jsonErr := json.NewDecoder(r.Body).Decode(¶ms); jsonErr != nil {
|
||||
c.SetInvalidParamWithErr("simulation", jsonErr)
|
||||
return
|
||||
}
|
||||
|
||||
if params.Policy == nil {
|
||||
c.SetInvalidParam("policy")
|
||||
return
|
||||
}
|
||||
if len(params.Users) == 0 {
|
||||
c.SetInvalidParam("users")
|
||||
return
|
||||
}
|
||||
if params.ChannelID != "" && !model.IsValidId(params.ChannelID) {
|
||||
c.SetInvalidParam("channel_id")
|
||||
return
|
||||
}
|
||||
if params.TeamID != "" && !model.IsValidId(params.TeamID) {
|
||||
c.SetInvalidParam("team_id")
|
||||
return
|
||||
}
|
||||
switch params.EvaluationScope {
|
||||
case "", model.PolicyEvaluationScopeThisRule, model.PolicyEvaluationScopeAll:
|
||||
default:
|
||||
c.SetInvalidParam("evaluation_scope")
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize the empty string up front to the default
|
||||
if params.EvaluationScope == "" {
|
||||
params.EvaluationScope = model.PolicyEvaluationScopeThisRule
|
||||
}
|
||||
|
||||
hasSystemPermission, ok := authorizeSimulatePolicy(c, params.ChannelID, params.TeamID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Cross-team consistency check: when both IDs are provided, the
|
||||
// channel must actually belong to the named team. authorizeSimulatePolicy
|
||||
// covers this for the team-admin shortcut, but a system admin's auth
|
||||
// short-circuit happens earlier so we re-check here for everyone.
|
||||
// Mismatched IDs would otherwise let downstream user-scope validation
|
||||
// run against the wrong team. We canonicalise params.TeamID from the
|
||||
// channel rather than rejecting outright — the channel ID is the
|
||||
// authoritative scope for a channel-policy simulation.
|
||||
if params.ChannelID != "" && params.TeamID != "" {
|
||||
channel, appErr := c.App.GetChannel(c.AppContext, params.ChannelID)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
if channel.TeamId != params.TeamID {
|
||||
c.SetInvalidParam("team_id")
|
||||
return
|
||||
}
|
||||
params.TeamID = channel.TeamId
|
||||
}
|
||||
|
||||
if !hasSystemPermission {
|
||||
if appErr := c.App.ValidatePolicySimulationUsersInScope(c.AppContext, params.TeamID, params.ChannelID, params.Users); appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
resp, appErr := c.App.SimulateAccessControlPolicyForUsers(c.AppContext, params)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
for i := range resp.Results {
|
||||
c.App.SanitizeProfile(resp.Results[i].User, hasSystemPermission)
|
||||
}
|
||||
|
||||
// Redact protected CPA attribute values for non-system-admin
|
||||
// callers. Targets the user's actual attribute values shown in
|
||||
// the Decision Details panel and per-leaf ActualValue strings.
|
||||
c.App.RedactSimulationAttributesForCaller(c.AppContext, resp, hasSystemPermission)
|
||||
|
||||
js, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
c.Err = model.NewAppError("simulatePolicyForUsers", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := w.Write(js); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func validateExpressionAgainstRequester(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
Expression string `json:"expression"`
|
||||
|
|
@ -407,9 +608,7 @@ func validateExpressionAgainstRequester(c *Context, w http.ResponseWriter, r *ht
|
|||
|
||||
hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
||||
if !hasSystemPermission {
|
||||
hasTeamPermission := teamID != "" &&
|
||||
c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamID, model.PermissionManageTeamAccessRules)
|
||||
if !hasTeamPermission {
|
||||
if !teamAdminCELContextOK(c, channelId, teamID) {
|
||||
if channelId == "" {
|
||||
c.SetPermissionError(model.PermissionManageSystem)
|
||||
return
|
||||
|
|
@ -496,6 +695,12 @@ func searchAccessControlPolicies(c *Context, w http.ResponseWriter, r *http.Requ
|
|||
policies = filtered
|
||||
}
|
||||
|
||||
if shouldRedactExpressions(c) {
|
||||
for _, p := range policies {
|
||||
c.App.MaskPolicyExpressions(c.AppContext, p, c.AppContext.Session().UserId)
|
||||
}
|
||||
}
|
||||
|
||||
result := model.AccessControlPoliciesWithCount{
|
||||
Policies: policies,
|
||||
Total: total,
|
||||
|
|
@ -628,6 +833,12 @@ func setActiveStatus(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
auditRec.Success()
|
||||
|
||||
if shouldRedactExpressions(c) {
|
||||
for _, p := range policies {
|
||||
c.App.MaskPolicyExpressions(c.AppContext, p, c.AppContext.Session().UserId)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(policies); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
|
|
@ -913,9 +1124,7 @@ func getFieldsAutocomplete(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
||||
if !hasSystemPermission {
|
||||
hasTeamPermission := teamID != "" && model.IsValidId(teamID) &&
|
||||
c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamID, model.PermissionManageTeamAccessRules)
|
||||
if !hasTeamPermission {
|
||||
if !teamAdminCELContextOK(c, channelId, teamID) {
|
||||
if channelId == "" {
|
||||
c.SetPermissionError(model.PermissionManageSystem)
|
||||
return
|
||||
|
|
@ -988,10 +1197,7 @@ func convertToVisualAST(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
||||
if !hasSystemPermission {
|
||||
teamID := cel.TeamId
|
||||
hasTeamPermission := teamID != "" && model.IsValidId(teamID) &&
|
||||
c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamID, model.PermissionManageTeamAccessRules)
|
||||
if !hasTeamPermission {
|
||||
if !teamAdminCELContextOK(c, channelId, cel.TeamId) {
|
||||
if channelId == "" {
|
||||
c.SetPermissionError(model.PermissionManageSystem)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ package api4
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
|
|
@ -328,6 +329,135 @@ func TestCreateAccessControlPolicy(t *testing.T) {
|
|||
CheckOKStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("CreateChannelPolicy with permission rules rejected when ChannelPermissionPolicies sub-flag is off", func(t *testing.T) {
|
||||
// Channel-scope policies that ONLY have membership rules
|
||||
// stay available even when the permission-rule sub-flag is
|
||||
// off. As soon as a rule carries a non-membership action
|
||||
// (upload_file_attachment / download_file_attachment) the
|
||||
// API4 gate must reject with 501. Membership-only policies
|
||||
// are exercised by the sibling "CreateAccessControlPolicy
|
||||
// with channel scope permissions" test above; this one
|
||||
// pins the permission-rule branch specifically.
|
||||
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
require.True(t, ok, "SetLicense should return true")
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = true
|
||||
cfg.FeatureFlags.ChannelPermissionPolicies = false
|
||||
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = new(true)
|
||||
})
|
||||
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = false
|
||||
})
|
||||
|
||||
channelPolicy := &model.AccessControlPolicy{
|
||||
ID: model.NewId(),
|
||||
Type: model.AccessControlPolicyTypeChannel,
|
||||
Version: model.AccessControlPolicyVersionV0_4,
|
||||
Revision: 1,
|
||||
Rules: []model.AccessControlPolicyRule{
|
||||
{
|
||||
Name: "Channel members can upload",
|
||||
Role: model.ChannelUserRoleId,
|
||||
Expression: "user.attributes.department == 'engineering'",
|
||||
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, resp, err := th.SystemAdminClient.CreateAccessControlPolicy(context.Background(), channelPolicy)
|
||||
require.Error(t, err)
|
||||
CheckNotImplementedStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("CreateChannelPolicy with permission rules rejected when PermissionPolicies umbrella is off (sub-flag alone is not enough)", func(t *testing.T) {
|
||||
// Dependency-direction guard: ChannelPermissionPolicies on
|
||||
// its own must NOT be enough to bypass the gate. The
|
||||
// IsChannelPermissionPoliciesEnabled helper requires the
|
||||
// PermissionPolicies umbrella too, so a config that turns
|
||||
// the sub-flag on but leaves the umbrella off still gets
|
||||
// a 501. Mirrors the corresponding subtest in
|
||||
// TestSimulatePolicyForUsers.
|
||||
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
require.True(t, ok, "SetLicense should return true")
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = false
|
||||
cfg.FeatureFlags.ChannelPermissionPolicies = true
|
||||
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = new(true)
|
||||
})
|
||||
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.ChannelPermissionPolicies = false
|
||||
})
|
||||
|
||||
channelPolicy := &model.AccessControlPolicy{
|
||||
ID: model.NewId(),
|
||||
Type: model.AccessControlPolicyTypeChannel,
|
||||
Version: model.AccessControlPolicyVersionV0_4,
|
||||
Revision: 1,
|
||||
Rules: []model.AccessControlPolicyRule{
|
||||
{
|
||||
Name: "Channel members can download",
|
||||
Role: model.ChannelUserRoleId,
|
||||
Expression: "user.attributes.department == 'engineering'",
|
||||
Actions: []string{model.AccessControlPolicyActionDownloadFileAttachment},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, resp, err := th.SystemAdminClient.CreateAccessControlPolicy(context.Background(), channelPolicy)
|
||||
require.Error(t, err)
|
||||
CheckNotImplementedStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("CreateChannelPolicy with permission rules accepted when both flags are on", func(t *testing.T) {
|
||||
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
require.True(t, ok, "SetLicense should return true")
|
||||
|
||||
// Use a real private channel for the policy ID; channel-
|
||||
// scope creation runs an eligibility check that fetches the
|
||||
// channel even for system admins. The sibling
|
||||
// "CreateAccessControlPolicy with channel scope permissions"
|
||||
// test uses the same pattern.
|
||||
ch := th.CreatePrivateChannel(t)
|
||||
|
||||
channelPolicy := &model.AccessControlPolicy{
|
||||
ID: ch.Id,
|
||||
Type: model.AccessControlPolicyTypeChannel,
|
||||
Version: model.AccessControlPolicyVersionV0_4,
|
||||
Revision: 1,
|
||||
Rules: []model.AccessControlPolicyRule{
|
||||
{
|
||||
Name: "Channel members can upload",
|
||||
Role: model.ChannelUserRoleId,
|
||||
Expression: "user.attributes.department == 'engineering'",
|
||||
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockAccessControlService := &mocks.AccessControlServiceInterface{}
|
||||
th.App.Srv().Channels().AccessControl = mockAccessControlService
|
||||
// We only care that the gate let the request through to the
|
||||
// PAP; the validation chain past this point is exercised by
|
||||
// other tests, so the mock returns success straight away.
|
||||
mockAccessControlService.On("SavePolicy", mock.AnythingOfType("*request.Context"), mock.AnythingOfType("*model.AccessControlPolicy")).Return(channelPolicy, nil).Times(1)
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = true
|
||||
cfg.FeatureFlags.ChannelPermissionPolicies = true
|
||||
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = new(true)
|
||||
})
|
||||
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = false
|
||||
cfg.FeatureFlags.ChannelPermissionPolicies = false
|
||||
})
|
||||
|
||||
_, resp, err := th.SystemAdminClient.CreateAccessControlPolicy(context.Background(), channelPolicy)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("system admin cannot create a channel-scope policy on a team default channel", func(t *testing.T) {
|
||||
// The api4 handler short-circuits validation for system admins, so the
|
||||
// eligibility guard must live in the app layer. This test rides that
|
||||
|
|
@ -474,15 +604,22 @@ func TestDeleteAccessControlPolicy(t *testing.T) {
|
|||
|
||||
mockAccessControlService := &mocks.AccessControlServiceInterface{}
|
||||
th.App.Srv().Channels().AccessControl = mockAccessControlService
|
||||
// DeleteAccessControlPolicy resolves the policy first to decide
|
||||
// whether to broadcast a channel access control update; return a
|
||||
// parent policy so the channel-update path is not exercised here.
|
||||
parentPolicy := &model.AccessControlPolicy{
|
||||
ID: samplePolicyID,
|
||||
Type: model.AccessControlPolicyTypeParent,
|
||||
Version: model.AccessControlPolicyVersionV0_3,
|
||||
|
||||
// DeleteAccessControlPolicy resolves the policy first to decide whether
|
||||
// to broadcast a channel access-control update after deletion.
|
||||
channelPolicy := &model.AccessControlPolicy{
|
||||
ID: samplePolicyID,
|
||||
Type: model.AccessControlPolicyTypeChannel,
|
||||
Version: model.AccessControlPolicyVersionV0_3,
|
||||
Revision: 1,
|
||||
Rules: []model.AccessControlPolicyRule{
|
||||
{
|
||||
Expression: "user.attributes.team == 'engineering'",
|
||||
Actions: []string{"membership"},
|
||||
},
|
||||
},
|
||||
}
|
||||
mockAccessControlService.On("GetPolicy", mock.AnythingOfType("*request.Context"), samplePolicyID).Return(parentPolicy, nil).Times(1)
|
||||
mockAccessControlService.On("GetPolicy", mock.AnythingOfType("*request.Context"), samplePolicyID).Return(channelPolicy, nil).Times(1)
|
||||
mockAccessControlService.On("DeletePolicy", mock.AnythingOfType("*request.Context"), samplePolicyID).Return(nil).Times(1)
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
|
|
@ -593,6 +730,41 @@ func TestCheckExpression(t *testing.T) {
|
|||
CheckOKStatus(t, resp)
|
||||
require.Empty(t, errors, "expected no errors")
|
||||
})
|
||||
|
||||
t.Run("team admin cannot pair team_id with channel from another team", func(t *testing.T) {
|
||||
mockACS := setupTeamAdminABAC(t, th)
|
||||
mockACS.On("CheckExpression", mock.Anything, mock.Anything).Return([]model.CELExpressionError{}, nil).Maybe()
|
||||
|
||||
teamAdminUser := th.CreateUser(t)
|
||||
makeTeamAdminAndLogin(t, th, teamAdminUser, th.BasicTeam)
|
||||
defer th.LoginBasic(t)
|
||||
|
||||
otherTeam := th.CreateTeam(t)
|
||||
otherChannel, _, err := th.SystemAdminClient.CreateChannel(context.Background(), &model.Channel{
|
||||
TeamId: otherTeam.Id,
|
||||
Type: model.ChannelTypeOpen,
|
||||
Name: "other-" + model.NewId(),
|
||||
DisplayName: "Other team channel",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
body, mErr := json.Marshal(map[string]string{
|
||||
"expression": "true",
|
||||
"teamId": th.BasicTeam.Id,
|
||||
"channelId": otherChannel.Id,
|
||||
})
|
||||
require.NoError(t, mErr)
|
||||
|
||||
// teamAdminCELContextOK rejects the cross-team pairing as
|
||||
// intended, but HasPermissionToChannel then admits via
|
||||
// HasPermissionTo because team_admin carries
|
||||
// manage_channel_access_rules system-wide. Pin the observable
|
||||
// 200 so a future auth tightening fails this loudly.
|
||||
resp, dErr := th.Client.DoAPIPost(context.Background(), "/access_control_policies/cel/check", string(body))
|
||||
require.NoError(t, dErr)
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestTestExpression(t *testing.T) {
|
||||
|
|
@ -1180,38 +1352,17 @@ func TestSearchChannelsForAccessControlPolicy(t *testing.T) {
|
|||
t.Run("team admin body TeamIds forced to authorized team", func(t *testing.T) {
|
||||
setupLicenseAndABAC(t)
|
||||
|
||||
parentPolicy := newSamplePolicy()
|
||||
savedParent, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, parentPolicy)
|
||||
policy := newSamplePolicy()
|
||||
savedPolicy, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, policy)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
_ = th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, savedParent.ID)
|
||||
}()
|
||||
|
||||
// Two teams, each with one private channel. The BasicTeam channel is
|
||||
// linked to the parent policy so it shows up in the search; the
|
||||
// otherTeam channel is unrelated. The override-correctness test then
|
||||
// proves both that the BasicTeam channel IS returned (the search
|
||||
// isn't trivially empty) and that the otherTeam channel is NOT
|
||||
// returned even though the request body asked for it explicitly.
|
||||
basicTeamChannel := th.CreateChannelWithClientAndTeam(t, th.SystemAdminClient, model.ChannelTypePrivate, th.BasicTeam.Id)
|
||||
basicTeamChild := &model.AccessControlPolicy{
|
||||
ID: basicTeamChannel.Id,
|
||||
Type: model.AccessControlPolicyTypeChannel,
|
||||
Version: model.AccessControlPolicyVersionV0_3,
|
||||
Revision: 1,
|
||||
Imports: []string{savedParent.ID},
|
||||
Rules: []model.AccessControlPolicyRule{
|
||||
{Expression: "user.attributes.team == 'engineering'", Actions: []string{"membership"}},
|
||||
},
|
||||
}
|
||||
_, err = th.App.Srv().Store().AccessControlPolicy().Save(th.Context, basicTeamChild)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
_ = th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, basicTeamChannel.Id)
|
||||
_ = th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, savedPolicy.ID)
|
||||
}()
|
||||
|
||||
// Create a second team with a private channel
|
||||
otherTeam := th.CreateTeam(t)
|
||||
otherChannel := th.CreateChannelWithClientAndTeam(t, th.SystemAdminClient, model.ChannelTypePrivate, otherTeam.Id)
|
||||
_ = otherChannel
|
||||
|
||||
th.LinkUserToTeam(t, th.TeamAdminUser, th.BasicTeam)
|
||||
th.UpdateUserToTeamAdmin(t, th.TeamAdminUser, th.BasicTeam)
|
||||
|
|
@ -1221,26 +1372,19 @@ func TestSearchChannelsForAccessControlPolicy(t *testing.T) {
|
|||
|
||||
// Attempt to search with body TeamIds pointing to a different team.
|
||||
// The authZ is against BasicTeam (via team_id query param), but the
|
||||
// body tries to query otherTeam's channels. The handler should force
|
||||
// body tries to query otherTeam's channels. The fix should force
|
||||
// TeamIds to BasicTeam.Id regardless of what the body says.
|
||||
channelsResp, resp, err := th.Client.SearchChannelsForAccessControlPolicyForTeam(
|
||||
context.Background(), savedParent.ID, th.BasicTeam.Id,
|
||||
context.Background(), savedPolicy.ID, th.BasicTeam.Id,
|
||||
model.ChannelSearch{TeamIds: []string{otherTeam.Id}})
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
require.NotNil(t, channelsResp)
|
||||
|
||||
channelsByID := make(map[string]*model.ChannelWithTeamData, len(channelsResp.Channels))
|
||||
for _, ch := range channelsResp.Channels {
|
||||
channelsByID[ch.Id] = ch
|
||||
}
|
||||
require.Contains(t, channelsByID, basicTeamChannel.Id,
|
||||
"BasicTeam channel must surface — proves the search is exercised, not just trivially empty")
|
||||
require.NotContains(t, channelsByID, otherChannel.Id,
|
||||
"otherTeam channel must NOT surface even though body asked for it — proves the team_id query param overrides body TeamIds")
|
||||
// None of the returned channels should belong to the other team
|
||||
for _, ch := range channelsResp.Channels {
|
||||
require.Equal(t, th.BasicTeam.Id, ch.TeamId,
|
||||
"team admin must only see channels from the authorized team, got channel %s from team %s", ch.Id, ch.TeamId)
|
||||
"team admin should only see channels from the authorized team, got channel %s from team %s", ch.Id, ch.TeamId)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -1492,6 +1636,81 @@ func newParentPolicy(teamID string) *model.AccessControlPolicy {
|
|||
}
|
||||
}
|
||||
|
||||
// TestResponseMaskingOnPolicyEndpoints verifies that every API endpoint returning an
|
||||
// AccessControlPolicy redacts the raw CEL expression for callers who cannot see all
|
||||
// values. The risk is a future endpoint forgetting to call MaskPolicyExpressions
|
||||
// before serializing — the masked visual AST would still hide values, but the raw
|
||||
// rule.Expression in the same response would leak them in plain text. We force the
|
||||
// fail-closed branch (unknown property field) so the masking always produces the
|
||||
// "--------" sentinel without requiring a real CPA setup.
|
||||
func TestResponseMaskingOnPolicyEndpoints(t *testing.T) {
|
||||
// SetupConfig sets FFs before route init via SetReadOnlyFF(false). Avoids
|
||||
// os.Setenv which isn't parallel-safe.
|
||||
th := SetupConfig(t, func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.AttributeBasedAccessControl = true
|
||||
cfg.FeatureFlags.AttributeValueMasking = true
|
||||
}).InitBasic(t)
|
||||
|
||||
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
require.True(t, ok, "SetLicense should return true")
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = new(true)
|
||||
})
|
||||
|
||||
const sensitiveExpr = `user.attributes.f_unknown_field == "TF-Zulu"`
|
||||
const expectedMaskedExpr = `user.attributes.f_unknown_field == "--------"`
|
||||
|
||||
// A condition referencing an unknown CPA field forces MaskPolicyExpressions
|
||||
// down the fail-closed branch, which replaces the literal value with the
|
||||
// masked-token sentinel. That gives us a deterministic assertion target
|
||||
// without needing to seed a CPA group + protected field in this test.
|
||||
unknownFieldAST := &model.VisualExpression{
|
||||
Conditions: []model.Condition{
|
||||
{
|
||||
Attribute: "user.attributes.f_unknown_field",
|
||||
Operator: "==",
|
||||
Value: "TF-Zulu",
|
||||
ValueType: model.LiteralValue,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
newPolicy := func(id string) *model.AccessControlPolicy {
|
||||
return &model.AccessControlPolicy{
|
||||
ID: id,
|
||||
Type: model.AccessControlPolicyTypeChannel,
|
||||
Version: model.AccessControlPolicyVersionV0_3,
|
||||
Revision: 1,
|
||||
Rules: []model.AccessControlPolicyRule{
|
||||
{Actions: []string{"membership"}, Expression: sensitiveExpr},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("getAccessControlPolicy response is masked", func(t *testing.T) {
|
||||
// GET is the canonical read path — masking here means the raw CEL in the
|
||||
// policy response cannot leak values the caller couldn't already see in the
|
||||
// visual AST. The create / search / setActive paths share the same
|
||||
// MaskPolicyExpressions call so they're covered by inspection. Unit-testing
|
||||
// them through the HTTP handler is impractical because
|
||||
// validatePolicyExpressionValues rejects unknown-field references before
|
||||
// MaskPolicyExpressions ever runs, and we can't seed a real shared_only
|
||||
// CPA field without plugin context. End-to-end paths are covered by E2E.
|
||||
mockACS := &mocks.AccessControlServiceInterface{}
|
||||
th.App.Srv().Channels().AccessControl = mockACS
|
||||
stored := newPolicy(th.BasicChannel.Id)
|
||||
mockACS.On("GetPolicy", mock.AnythingOfType("*request.Context"), stored.ID).Return(stored, nil)
|
||||
mockACS.On("ExpressionToVisualAST", mock.Anything, mock.Anything).Return(unknownFieldAST, nil).Maybe()
|
||||
|
||||
result, resp, err := th.SystemAdminClient.GetAccessControlPolicy(context.Background(), stored.ID)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
require.NotEmpty(t, result.Rules)
|
||||
require.Equal(t, expectedMaskedExpr, result.Rules[0].Expression,
|
||||
"get response must mask the raw CEL exactly")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateAccessControlPolicyTeamAdmin(t *testing.T) {
|
||||
os.Setenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL", "true")
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
|
@ -2321,3 +2540,210 @@ func TestScopeReconciliationCrossTeam(t *testing.T) {
|
|||
require.Equal(t, th.BasicTeam.Id, reloaded.ScopeID, "scope_id must be preserved when no channels exist")
|
||||
})
|
||||
}
|
||||
|
||||
// TestSimulatePolicyForUsers covers the auth, validation, and feature-flag
|
||||
// gates on POST /access_control_policies/cel/simulate_users. The handler
|
||||
// proxies to the access-control service which we mock here so the test
|
||||
// stays focused on the API surface (auth + payload validation).
|
||||
func TestSimulatePolicyForUsers(t *testing.T) {
|
||||
th := SetupConfig(t, func(cfg *model.Config) { cfg.FeatureFlags.AttributeBasedAccessControl = true }).InitBasic(t)
|
||||
|
||||
t.Run("returns 501 when umbrella PermissionPolicies flag is disabled", func(t *testing.T) {
|
||||
// Set the Enterprise Advanced license up-front so any future
|
||||
// license-level middleware ahead of the handler can't be the
|
||||
// reason for a 501 here. With the license valid, the only
|
||||
// remaining thing that can flip the response is the
|
||||
// FeatureFlag below — which is the contract under test.
|
||||
// Sibling sub-tests (rejects empty users / system admin
|
||||
// reaches the service mock) follow the same pattern of
|
||||
// setting but not clearing the license; the helper is
|
||||
// scoped to this Test* function.
|
||||
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
require.True(t, ok)
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = false
|
||||
cfg.FeatureFlags.PolicySimulation = true // sub-flag alone must not be enough
|
||||
})
|
||||
|
||||
body := mustMarshal(t, model.PolicySimulationByUsersParams{
|
||||
Policy: &model.AccessControlPolicy{ID: model.NewId(), Type: model.AccessControlPolicyTypeChannel},
|
||||
Users: []model.PolicySimulationUserOverride{{UserID: model.NewId()}},
|
||||
})
|
||||
resp, err := th.SystemAdminClient.DoAPIPost(context.Background(), "/access_control_policies/cel/simulate_users", string(body))
|
||||
// `DoAPIPost` surfaces any non-2xx response as an error
|
||||
// carrying the server's AppError text, so we expect an error
|
||||
// here ("Policy simulation feature is not enabled.") and
|
||||
// assert the 501 status code on the response itself —
|
||||
// matching the pattern in sibling sub-tests.
|
||||
require.Error(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("returns 501 when PolicySimulation sub-flag is disabled", func(t *testing.T) {
|
||||
// PermissionPolicies on its own is not enough — the
|
||||
// IsPolicySimulationEnabled helper requires the sub-flag too.
|
||||
// This pins the dependency direction: turning the umbrella on
|
||||
// must NOT silently enable simulation.
|
||||
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
require.True(t, ok)
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = true
|
||||
cfg.FeatureFlags.PolicySimulation = false
|
||||
})
|
||||
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = false
|
||||
})
|
||||
|
||||
body := mustMarshal(t, model.PolicySimulationByUsersParams{
|
||||
Policy: &model.AccessControlPolicy{ID: model.NewId(), Type: model.AccessControlPolicyTypeChannel},
|
||||
Users: []model.PolicySimulationUserOverride{{UserID: model.NewId()}},
|
||||
})
|
||||
resp, err := th.SystemAdminClient.DoAPIPost(context.Background(), "/access_control_policies/cel/simulate_users", string(body))
|
||||
require.Error(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("rejects regular users without channel/team permission", func(t *testing.T) {
|
||||
// Set the Enterprise Advanced license explicitly so this
|
||||
// subtest is self-contained — the deny we assert below comes
|
||||
// from `authorizeSimulatePolicy`'s permission check, and we
|
||||
// want to verify that gate in isolation regardless of the
|
||||
// license state any sibling subtest may have left behind.
|
||||
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
require.True(t, ok)
|
||||
defer th.App.Srv().SetLicense(nil)
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = true
|
||||
cfg.FeatureFlags.PolicySimulation = true
|
||||
})
|
||||
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = false
|
||||
cfg.FeatureFlags.PolicySimulation = false
|
||||
})
|
||||
|
||||
body := mustMarshal(t, model.PolicySimulationByUsersParams{
|
||||
Policy: &model.AccessControlPolicy{ID: model.NewId(), Type: model.AccessControlPolicyTypeChannel},
|
||||
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
|
||||
Users: []model.PolicySimulationUserOverride{{UserID: th.BasicUser.Id}},
|
||||
})
|
||||
resp, err := th.Client.DoAPIPost(context.Background(), "/access_control_policies/cel/simulate_users", string(body))
|
||||
require.Error(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("rejects empty users", func(t *testing.T) {
|
||||
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
require.True(t, ok)
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = true
|
||||
cfg.FeatureFlags.PolicySimulation = true
|
||||
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
|
||||
})
|
||||
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = false
|
||||
cfg.FeatureFlags.PolicySimulation = false
|
||||
})
|
||||
|
||||
mockACS := &mocks.AccessControlServiceInterface{}
|
||||
th.App.Srv().Channels().AccessControl = mockACS
|
||||
|
||||
body := mustMarshal(t, model.PolicySimulationByUsersParams{
|
||||
Policy: &model.AccessControlPolicy{ID: model.NewId(), Type: model.AccessControlPolicyTypeChannel},
|
||||
})
|
||||
resp, err := th.SystemAdminClient.DoAPIPost(context.Background(), "/access_control_policies/cel/simulate_users", string(body))
|
||||
require.Error(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
mockACS.AssertNotCalled(t, "SimulatePolicyForUsers", mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
t.Run("system admin reaches the service mock", func(t *testing.T) {
|
||||
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
require.True(t, ok)
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = true
|
||||
cfg.FeatureFlags.PolicySimulation = true
|
||||
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
|
||||
})
|
||||
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = false
|
||||
cfg.FeatureFlags.PolicySimulation = false
|
||||
})
|
||||
|
||||
mockACS := &mocks.AccessControlServiceInterface{}
|
||||
mockACS.On("SimulatePolicyForUsers", mock.Anything, mock.Anything).Return(
|
||||
&model.PolicySimulationResponse{Results: []model.PolicySimulationUserResult{}, Total: 0},
|
||||
(*model.AppError)(nil),
|
||||
)
|
||||
th.App.Srv().Channels().AccessControl = mockACS
|
||||
|
||||
body := mustMarshal(t, model.PolicySimulationByUsersParams{
|
||||
Policy: &model.AccessControlPolicy{ID: model.NewId(), Type: model.AccessControlPolicyTypeChannel, Version: model.AccessControlPolicyVersionV0_4},
|
||||
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
|
||||
Users: []model.PolicySimulationUserOverride{{UserID: th.BasicUser.Id}},
|
||||
})
|
||||
resp, err := th.SystemAdminClient.DoAPIPost(context.Background(), "/access_control_policies/cel/simulate_users", string(body))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
mockACS.AssertCalled(t, "SimulatePolicyForUsers", mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
t.Run("rejects delegated simulate when user is not in team scope", func(t *testing.T) {
|
||||
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
require.True(t, ok)
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = true
|
||||
cfg.FeatureFlags.PolicySimulation = true
|
||||
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
|
||||
})
|
||||
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = false
|
||||
cfg.FeatureFlags.PolicySimulation = false
|
||||
})
|
||||
|
||||
mockACS := &mocks.AccessControlServiceInterface{}
|
||||
th.App.Srv().Channels().AccessControl = mockACS
|
||||
|
||||
th.AddPermissionToRole(t, model.PermissionManageTeamAccessRules.Id, model.TeamAdminRoleId)
|
||||
teamAdminUser := th.CreateUser(t)
|
||||
makeTeamAdminAndLogin(t, th, teamAdminUser, th.BasicTeam)
|
||||
defer th.LoginBasic(t)
|
||||
|
||||
outsider := th.CreateUser(t)
|
||||
|
||||
body := mustMarshal(t, model.PolicySimulationByUsersParams{
|
||||
Policy: &model.AccessControlPolicy{ID: model.NewId(), Type: model.AccessControlPolicyTypeChannel, Version: model.AccessControlPolicyVersionV0_4},
|
||||
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
|
||||
Users: []model.PolicySimulationUserOverride{{UserID: outsider.Id}},
|
||||
TeamID: th.BasicTeam.Id,
|
||||
})
|
||||
// Capture resp so we can pin the exact status (403 from the
|
||||
// users-out-of-scope check inside ValidatePolicySimulationUsersInScope:
|
||||
// the team admin's session is authorized, but the listed user
|
||||
// isn't a member of the named team, so the delegated path
|
||||
// short-circuits with a Forbidden) rather than any non-2xx
|
||||
// error passing as the cross-team rejection.
|
||||
resp, err := th.Client.DoAPIPost(context.Background(), "/access_control_policies/cel/simulate_users", string(body))
|
||||
require.Error(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
mockACS.AssertNotCalled(t, "SimulatePolicyForUsers", mock.Anything, mock.Anything)
|
||||
})
|
||||
}
|
||||
|
||||
func mustMarshal(t *testing.T, v any) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
require.NoError(t, err)
|
||||
return b
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,8 @@ func (api *API) InitChannel() {
|
|||
|
||||
api.BaseRoutes.ChannelModerations.Handle("", api.APISessionRequired(getChannelModerations)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.ChannelModerations.Handle("/patch", api.APISessionRequired(patchChannelModerations)).Methods(http.MethodPut)
|
||||
|
||||
api.initChannelJoinRequestRoutes()
|
||||
}
|
||||
|
||||
func createChannel(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -144,6 +146,24 @@ func createChannel(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
if channel.Discoverable {
|
||||
if !c.App.Config().FeatureFlags.DiscoverableChannels {
|
||||
c.Err = model.NewAppError("createChannel", "api.channel.discoverable_join_request.feature_disabled.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if channel.Type != model.ChannelTypePrivate {
|
||||
c.Err = model.NewAppError("createChannel", "model.channel.is_valid.discoverable.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// The team-scoped check is the closest analog to "would this user
|
||||
// have permission to manage discoverability after the channel is
|
||||
// created" — channel-scope grants don't exist yet at creation time.
|
||||
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionManagePrivateChannelDiscoverability) {
|
||||
c.SetPermissionError(model.PermissionManagePrivateChannelDiscoverability)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sc, appErr := c.App.CreateChannelWithUser(c.AppContext, channel, c.AppContext.Session().UserId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
|
|
@ -377,12 +397,36 @@ func patchChannel(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
updatingProperties := patch.DisplayName != nil || patch.Name != nil || patch.Header != nil || patch.Purpose != nil || patch.GroupConstrained != nil || patch.DefaultCategoryName != nil
|
||||
updatingAutoTranslation := patch.AutoTranslation != nil
|
||||
updatingManagedCategory := patch.ManagedCategoryName != nil
|
||||
updatingDiscoverable := patch.Discoverable != nil
|
||||
|
||||
if !updatingProperties && !updatingAutoTranslation && patch.BannerInfo == nil && !updatingManagedCategory {
|
||||
if !updatingProperties && !updatingAutoTranslation && patch.BannerInfo == nil && !updatingManagedCategory && !updatingDiscoverable {
|
||||
c.Err = model.NewAppError("patchChannel", "api.channel.patch_update_channel.no_changes.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if updatingDiscoverable {
|
||||
if !c.App.Config().FeatureFlags.DiscoverableChannels {
|
||||
c.Err = model.NewAppError("patchChannel", "api.channel.discoverable_join_request.feature_disabled.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if oldChannel.Type != model.ChannelTypePrivate {
|
||||
c.Err = model.NewAppError("patchChannel", "model.channel.is_valid.discoverable.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if oldChannel.DeleteAt != 0 {
|
||||
c.Err = model.NewAppError("patchChannel", "api.channel.update_channel.deleted.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if oldChannel.IsShared() {
|
||||
c.Err = model.NewAppError("patchChannel", "api.channel.discoverable_join_request.shared.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManagePrivateChannelDiscoverability); !ok {
|
||||
c.SetPermissionError(model.PermissionManagePrivateChannelDiscoverability)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if updatingAutoTranslation && (c.App.AutoTranslation() == nil || !c.App.AutoTranslation().IsFeatureAvailable()) {
|
||||
c.Err = model.NewAppError("patchChannel", "api.channel.patch_update_channel.feature_not_available.app_error", nil, "", http.StatusForbidden)
|
||||
return
|
||||
|
|
@ -806,6 +850,9 @@ func getChannel(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
} else if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !ok {
|
||||
if served := serveDiscoverableNonMember(c, w, channel); served {
|
||||
return
|
||||
}
|
||||
c.SetPermissionError(model.PermissionReadChannel)
|
||||
return
|
||||
}
|
||||
|
|
@ -822,6 +869,80 @@ func getChannel(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
// sanitizeDiscoverableChannel returns a copy of `channel` containing only the
|
||||
// fields safe to expose to a non-member who can see the channel through the
|
||||
// discoverable surface. Cell-level secrets such as Props or per-channel
|
||||
// scheme identifiers are stripped so this view is strictly read-only metadata.
|
||||
func sanitizeDiscoverableChannel(channel *model.Channel) *model.Channel {
|
||||
if channel == nil {
|
||||
return nil
|
||||
}
|
||||
return &model.Channel{
|
||||
Id: channel.Id,
|
||||
TeamId: channel.TeamId,
|
||||
Type: channel.Type,
|
||||
DisplayName: channel.DisplayName,
|
||||
Name: channel.Name,
|
||||
Header: channel.Header,
|
||||
Purpose: channel.Purpose,
|
||||
Discoverable: channel.Discoverable,
|
||||
PolicyEnforced: channel.PolicyEnforced,
|
||||
CreateAt: channel.CreateAt,
|
||||
UpdateAt: channel.UpdateAt,
|
||||
DeleteAt: channel.DeleteAt,
|
||||
}
|
||||
}
|
||||
|
||||
// discoverableNonMemberView returns a sanitized non-member view of `channel`
|
||||
// when the calling user qualifies under the discoverable visibility rules,
|
||||
// or (nil, nil) when the channel must remain hidden — the caller should
|
||||
// emit its own permission-denied response. Errors from the discoverable
|
||||
// lookup are returned for the caller to assign to c.Err. When the feature
|
||||
// flag is off, this returns (nil, nil) and the caller falls through to its
|
||||
// default 403/404 path so the existing read contract is preserved.
|
||||
func discoverableNonMemberView(c *Context, channel *model.Channel) (*model.Channel, *model.AppError) {
|
||||
if !c.App.Config().FeatureFlags.DiscoverableChannels {
|
||||
return nil, nil
|
||||
}
|
||||
user, userErr := c.App.GetUser(c.AppContext.Session().UserId)
|
||||
if userErr != nil {
|
||||
return nil, userErr
|
||||
}
|
||||
allowed, allowedErr := c.App.IsDiscoverableJoinAllowed(c.AppContext, user, channel)
|
||||
if allowedErr != nil {
|
||||
return nil, allowedErr
|
||||
}
|
||||
if !allowed {
|
||||
return nil, nil
|
||||
}
|
||||
return sanitizeDiscoverableChannel(channel), nil
|
||||
}
|
||||
|
||||
// serveDiscoverableNonMember writes the sanitized non-member discoverable
|
||||
// view of `channel` to `w` and returns true when the request was handled
|
||||
// here (either the response was written, or c.Err was set on a lookup
|
||||
// failure). Returns false without touching the response when the caller
|
||||
// should emit its own permission-denied response (the channel is hidden
|
||||
// from this non-member, or the feature flag is off).
|
||||
//
|
||||
// Centralising this here means every read endpoint that previously emitted
|
||||
// 403/404 to a non-member can keep its prior failure shape while opting in
|
||||
// to the discoverable surface with a single `if served { return }` guard.
|
||||
func serveDiscoverableNonMember(c *Context, w http.ResponseWriter, channel *model.Channel) bool {
|
||||
sanitized, err := discoverableNonMemberView(c, channel)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return true
|
||||
}
|
||||
if sanitized == nil {
|
||||
return false
|
||||
}
|
||||
if encErr := json.NewEncoder(w).Encode(sanitized); encErr != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(encErr))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func getChannelUnread(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireChannelId().RequireUserId()
|
||||
if c.Err != nil {
|
||||
|
|
@ -1646,6 +1767,9 @@ func getChannelByName(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
// allows team admins to access private channel
|
||||
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionManageTeam) {
|
||||
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionReadChannel); !ok {
|
||||
if served := serveDiscoverableNonMember(c, w, channel); served {
|
||||
return
|
||||
}
|
||||
c.Err = model.NewAppError("getChannelByName", "app.channel.get_by_name.missing.app_error", nil, "teamId="+channel.TeamId+", "+"name="+channel.Name+"", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
|
@ -1686,6 +1810,9 @@ func getChannelByNameForTeamName(c *Context, w http.ResponseWriter, r *http.Requ
|
|||
} else if !channelOk {
|
||||
// allows team admins to access private channel
|
||||
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionManageTeam) {
|
||||
if served := serveDiscoverableNonMember(c, w, channel); served {
|
||||
return
|
||||
}
|
||||
c.Err = model.NewAppError("getChannelByNameForTeamName", "app.channel.get_by_name.missing.app_error", nil, "teamId="+channel.TeamId+", "+"name="+channel.Name+"", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
|
@ -2252,9 +2379,25 @@ func addChannelMember(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
if channel.Type == model.ChannelTypePrivate {
|
||||
if hasPermission, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionManagePrivateChannelMembers); !hasPermission {
|
||||
// Allow the user to self-add to a discoverable private channel only
|
||||
// through the request flow — the discoverable toggle does not
|
||||
// implicitly grant PermissionManagePrivateChannelMembers, and the
|
||||
// existing addChannelMember API would otherwise let any caller
|
||||
// bypass the queue by issuing a direct POST.
|
||||
c.SetPermissionError(model.PermissionManagePrivateChannelMembers)
|
||||
return
|
||||
}
|
||||
|
||||
// Discoverable + no policy: the request flow is the only path. Even
|
||||
// admins use it to ensure the audit trail. We exempt the case where
|
||||
// the requester is adding someone other than themselves so admin
|
||||
// invites still work.
|
||||
for _, userId := range userIds {
|
||||
if c.App.IsDiscoverableSelfAddBlocked(c.AppContext, channel, c.AppContext.Session().UserId, userId) {
|
||||
c.Err = model.NewAppError("addChannelMember", "api.channel.discoverable_join_request.discoverable_requires_approval.app_error", nil, "channel_id="+channel.Id, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if channel.IsGroupConstrained() {
|
||||
|
|
@ -2488,8 +2631,11 @@ func setChannelMembers(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Reject policy-enforced (ABAC) channels
|
||||
if channel.PolicyEnforced {
|
||||
// Reject channels whose policy controls membership (ABAC). Channels
|
||||
// carrying only a permission policy (e.g. file upload restriction) keep
|
||||
// the bulk-edit endpoint usable — those policies do not gate joins.
|
||||
// App.GetChannel hydrates PolicyActions so this check is reliable.
|
||||
if channel.HasMembershipPolicyAction() {
|
||||
c.Err = model.NewAppError("setChannelMembers", "api.channel.set_members.policy_enforced.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
293
server/channels/api4/channel_join_request.go
Normal file
293
server/channels/api4/channel_join_request.go
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api4
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
)
|
||||
|
||||
// initChannelJoinRequestRoutes registers the discoverable-private-channel
|
||||
// join request endpoints. The route group is split into its own file so the
|
||||
// handlers stay isolated from the rest of api4/channel.go.
|
||||
func (api *API) initChannelJoinRequestRoutes() {
|
||||
if !api.srv.Config().FeatureFlags.DiscoverableChannels {
|
||||
return
|
||||
}
|
||||
|
||||
api.BaseRoutes.Channel.Handle("/join_request", api.APISessionRequired(requestJoinChannel)).Methods(http.MethodPost)
|
||||
api.BaseRoutes.Channel.Handle("/join_request", api.APISessionRequired(getMyChannelJoinRequest)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.Channel.Handle("/join_request", api.APISessionRequired(withdrawMyChannelJoinRequest)).Methods(http.MethodDelete)
|
||||
|
||||
api.BaseRoutes.Channel.Handle("/join_requests", api.APISessionRequired(getChannelJoinRequests)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.Channel.Handle("/join_requests/count", api.APISessionRequired(countPendingChannelJoinRequests)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.Channel.Handle("/join_requests/{request_id:[A-Za-z0-9]+}", api.APISessionRequired(patchChannelJoinRequest)).Methods(http.MethodPatch)
|
||||
|
||||
api.BaseRoutes.User.Handle("/channel_join_requests", api.APISessionRequired(getMyChannelJoinRequests)).Methods(http.MethodGet)
|
||||
}
|
||||
|
||||
// channelJoinRequestBody is the POST body shape for /channels/{id}/join_request.
|
||||
type channelJoinRequestBody struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func requireDiscoverableChannelsEnabled(c *Context, where string) bool {
|
||||
if !c.App.Config().FeatureFlags.DiscoverableChannels {
|
||||
c.Err = model.NewAppError(where, "api.channel.discoverable_join_request.feature_disabled.app_error", nil, "", http.StatusNotFound)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func requestJoinChannel(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireChannelId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
if !requireDiscoverableChannelsEnabled(c, "requestJoinChannel") {
|
||||
return
|
||||
}
|
||||
|
||||
var body channelJoinRequestBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
c.SetInvalidParamWithErr("body", err)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventCreateChannelJoinRequest, model.AuditStatusFail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
|
||||
model.AddEventParameterToAuditRec(auditRec, "user_id", c.AppContext.Session().UserId)
|
||||
|
||||
joined, req, appErr := c.App.RequestJoinChannel(c.AppContext, c.AppContext.Session().UserId, c.Params.ChannelId, body.Message)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
if req != nil {
|
||||
auditRec.AddEventResultState(req)
|
||||
}
|
||||
|
||||
if joined {
|
||||
// Mirror the membership endpoint's "no body, just status" semantics
|
||||
// when the user was added directly via the ABAC fast path.
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{"status": model.ChannelJoinRequestStatusApproved}); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
if err := json.NewEncoder(w).Encode(req); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func getMyChannelJoinRequest(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireChannelId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
if !requireDiscoverableChannelsEnabled(c, "getMyChannelJoinRequest") {
|
||||
return
|
||||
}
|
||||
|
||||
req, appErr := c.App.GetMyChannelJoinRequest(c.AppContext, c.AppContext.Session().UserId, c.Params.ChannelId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
if req == nil {
|
||||
// Mirror REST conventions: not-found instead of an explicit `null`
|
||||
// so clients can distinguish "no pending request" from "service down".
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(req); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func withdrawMyChannelJoinRequest(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireChannelId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
if !requireDiscoverableChannelsEnabled(c, "withdrawMyChannelJoinRequest") {
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventWithdrawChannelJoinRequest, model.AuditStatusFail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
|
||||
model.AddEventParameterToAuditRec(auditRec, "user_id", c.AppContext.Session().UserId)
|
||||
|
||||
req, appErr := c.App.GetMyChannelJoinRequest(c.AppContext, c.AppContext.Session().UserId, c.Params.ChannelId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
if req == nil {
|
||||
c.Err = model.NewAppError("withdrawMyChannelJoinRequest", "app.channel.join_request.not_found.app_error", nil, "channel_id="+c.Params.ChannelId, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
updated, appErr := c.App.WithdrawChannelJoinRequest(c.AppContext, req.Id, c.AppContext.Session().UserId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
auditRec.AddEventResultState(updated)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(updated); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func getChannelJoinRequests(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireChannelId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
if !requireDiscoverableChannelsEnabled(c, "getChannelJoinRequests") {
|
||||
return
|
||||
}
|
||||
|
||||
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManageChannelJoinRequests); !ok {
|
||||
c.SetPermissionError(model.PermissionManageChannelJoinRequests)
|
||||
return
|
||||
}
|
||||
|
||||
opts := model.GetChannelJoinRequestsOpts{
|
||||
Status: r.URL.Query().Get("status"),
|
||||
Page: c.Params.Page,
|
||||
PerPage: c.Params.PerPage,
|
||||
}
|
||||
|
||||
list, appErr := c.App.GetChannelJoinRequests(c.AppContext, c.Params.ChannelId, opts)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(list); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func countPendingChannelJoinRequests(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireChannelId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
if !requireDiscoverableChannelsEnabled(c, "countPendingChannelJoinRequests") {
|
||||
return
|
||||
}
|
||||
|
||||
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManageChannelJoinRequests); !ok {
|
||||
c.SetPermissionError(model.PermissionManageChannelJoinRequests)
|
||||
return
|
||||
}
|
||||
|
||||
count, appErr := c.App.CountPendingChannelJoinRequests(c.AppContext, c.Params.ChannelId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]int64{"count": count}); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func patchChannelJoinRequest(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireChannelId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
if !requireDiscoverableChannelsEnabled(c, "patchChannelJoinRequest") {
|
||||
return
|
||||
}
|
||||
if !model.IsValidId(c.Params.RequestId) {
|
||||
c.SetInvalidURLParam("request_id")
|
||||
return
|
||||
}
|
||||
|
||||
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManageChannelJoinRequests); !ok {
|
||||
c.SetPermissionError(model.PermissionManageChannelJoinRequests)
|
||||
return
|
||||
}
|
||||
|
||||
var patch model.ChannelJoinRequestPatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
|
||||
c.SetInvalidParamWithErr("channel_join_request_patch", err)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventUpdateChannelJoinRequest, model.AuditStatusFail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
|
||||
model.AddEventParameterToAuditRec(auditRec, "request_id", c.Params.RequestId)
|
||||
model.AddEventParameterToAuditRec(auditRec, "status", patch.Status)
|
||||
// Capture only the presence of a denial reason in the audit log; the
|
||||
// free-text contents are intentionally excluded.
|
||||
model.AddEventParameterToAuditRec(auditRec, "has_denial_reason", strconv.FormatBool(patch.DenialReason != nil && *patch.DenialReason != ""))
|
||||
|
||||
updated, appErr := c.App.UpdateChannelJoinRequest(c.AppContext, c.Params.RequestId, c.Params.ChannelId, &patch, c.AppContext.Session().UserId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
auditRec.AddEventResultState(updated)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(updated); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func getMyChannelJoinRequests(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireUserId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
if !requireDiscoverableChannelsEnabled(c, "getMyChannelJoinRequests") {
|
||||
return
|
||||
}
|
||||
|
||||
// Only the calling user can list their own requests; admins should use
|
||||
// the per-channel queue endpoint.
|
||||
if c.Params.UserId != c.AppContext.Session().UserId {
|
||||
c.SetPermissionError(model.PermissionEditOtherUsers)
|
||||
return
|
||||
}
|
||||
|
||||
opts := model.GetChannelJoinRequestsOpts{
|
||||
Status: r.URL.Query().Get("status"),
|
||||
Page: c.Params.Page,
|
||||
PerPage: c.Params.PerPage,
|
||||
}
|
||||
|
||||
list, appErr := c.App.GetMyChannelJoinRequests(c.AppContext, c.AppContext.Session().UserId, opts)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(list); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
154
server/channels/api4/channel_join_request_test.go
Normal file
154
server/channels/api4/channel_join_request_test.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api4
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
)
|
||||
|
||||
// setupDiscoverableTH spins up an api4 fixture with the discoverable channels
|
||||
// feature flag enabled so the new routes are registered.
|
||||
func setupDiscoverableTH(t *testing.T) *TestHelper {
|
||||
t.Helper()
|
||||
return SetupConfig(t, func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.DiscoverableChannels = true
|
||||
}).InitBasic(t)
|
||||
}
|
||||
|
||||
// markDiscoverableViaAdmin patches `channel` to discoverable=true using the
|
||||
// SystemAdminClient so the permission check is satisfied without needing to
|
||||
// rebind the channel-admin role on the test fixture.
|
||||
func markDiscoverableViaAdmin(t *testing.T, th *TestHelper, channel *model.Channel) *model.Channel {
|
||||
t.Helper()
|
||||
on := true
|
||||
patched, _, err := th.SystemAdminClient.PatchChannel(context.Background(), channel.Id, &model.ChannelPatch{Discoverable: &on})
|
||||
require.NoError(t, err)
|
||||
require.True(t, patched.Discoverable)
|
||||
return patched
|
||||
}
|
||||
|
||||
func TestRequestJoinChannelAPI_HappyPath(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := setupDiscoverableTH(t)
|
||||
|
||||
channel := th.CreatePrivateChannel(t)
|
||||
channel = markDiscoverableViaAdmin(t, th, channel)
|
||||
|
||||
other := th.CreateUser(t)
|
||||
th.LinkUserToTeam(t, other, th.BasicTeam)
|
||||
_, _, err := th.Client.Login(context.Background(), other.Email, other.Password)
|
||||
require.NoError(t, err)
|
||||
|
||||
body := []byte(`{"message":"hi"}`)
|
||||
resp, err := th.Client.DoAPIPost(context.Background(), "/channels/"+channel.Id+"/join_request", string(body))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
var req model.ChannelJoinRequest
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&req))
|
||||
assert.Equal(t, model.ChannelJoinRequestStatusPending, req.Status)
|
||||
assert.Equal(t, channel.Id, req.ChannelId)
|
||||
assert.Equal(t, other.Id, req.UserId)
|
||||
}
|
||||
|
||||
func TestRequestJoinChannelAPI_FeatureDisabled(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
channel := th.CreatePrivateChannel(t)
|
||||
body := []byte(`{"message":"hi"}`)
|
||||
resp, err := th.Client.DoAPIPost(context.Background(), "/channels/"+channel.Id+"/join_request", string(body))
|
||||
defer closeBodyOrNil(resp)
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode, "route must be unregistered when feature flag is off")
|
||||
}
|
||||
|
||||
func TestPatchChannelDiscoverable_RejectsNonPrivate(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := setupDiscoverableTH(t)
|
||||
|
||||
publicChannel := th.CreatePublicChannel(t)
|
||||
on := true
|
||||
_, resp, err := th.SystemAdminClient.PatchChannel(context.Background(), publicChannel.Id, &model.ChannelPatch{Discoverable: &on})
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestAddChannelMember_BlocksSelfAddOnDiscoverable(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := setupDiscoverableTH(t)
|
||||
|
||||
channel := th.CreatePrivateChannel(t)
|
||||
channel = markDiscoverableViaAdmin(t, th, channel)
|
||||
|
||||
// Add a user that has manage-private-channel-members on a different
|
||||
// channel but not this one. Use Client (BasicUser2) - they're a team
|
||||
// member but not yet a channel member here.
|
||||
_, _, err := th.Client.Login(context.Background(), th.BasicUser2.Email, th.BasicUser2.Password)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, resp, err := th.Client.AddChannelMember(context.Background(), channel.Id, th.BasicUser2.Id)
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, resp)
|
||||
// Without channel admin permission the underlying permission check
|
||||
// fails first; either way the request flow is what they need to use.
|
||||
assert.True(t, resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized,
|
||||
"got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestGetChannelByName_HiddenForNonQualifyingNonMember(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := setupDiscoverableTH(t)
|
||||
|
||||
// Plain (non-discoverable) private channel: a non-member must still get
|
||||
// 404 — this guards against a regression in the existing read paths.
|
||||
channel := th.CreatePrivateChannel(t)
|
||||
|
||||
_, _, err := th.Client.Login(context.Background(), th.BasicUser2.Email, th.BasicUser2.Password)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, resp, err := th.Client.GetChannelByName(context.Background(), channel.Name, th.BasicTeam.Id, "")
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestGetChannelByName_VisibleForQualifyingNonMemberOnDiscoverable(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := setupDiscoverableTH(t)
|
||||
|
||||
channel := th.CreatePrivateChannel(t)
|
||||
channel = markDiscoverableViaAdmin(t, th, channel)
|
||||
|
||||
_, _, err := th.Client.Login(context.Background(), th.BasicUser2.Email, th.BasicUser2.Password)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, _, err := th.Client.GetChannelByName(context.Background(), channel.Name, th.BasicTeam.Id, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, channel.Id, got.Id)
|
||||
assert.True(t, got.Discoverable)
|
||||
}
|
||||
|
||||
// closeBodyOrNil is a tiny helper so the negative-path tests don't need to
|
||||
// branch on a nil response body before deferring Close.
|
||||
func closeBodyOrNil(resp *http.Response) {
|
||||
if resp == nil || resp.Body == nil {
|
||||
return
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
|
@ -7860,7 +7860,12 @@ func TestSetChannelMembers(t *testing.T) {
|
|||
t.Run("policy-enforced channel rejected", func(t *testing.T) {
|
||||
channel := th.CreatePublicChannel(t)
|
||||
|
||||
// Create an access control policy to make the channel policy-enforced
|
||||
// The gate rejects bulk membership edits only when the channel's
|
||||
// policy actually governs membership. We declare the `membership`
|
||||
// action here so PolicyActions[membership]=true is hydrated on
|
||||
// subsequent reads — a non-membership action (e.g. `view`) would
|
||||
// no longer trigger this path after the Phase 2 migration, which
|
||||
// is intentional and covered by the permission-only test below.
|
||||
policy := &model.AccessControlPolicy{
|
||||
Type: model.AccessControlPolicyTypeChannel,
|
||||
ID: channel.Id,
|
||||
|
|
@ -7868,13 +7873,17 @@ func TestSetChannelMembers(t *testing.T) {
|
|||
Version: model.AccessControlPolicyVersionV0_2,
|
||||
Rules: []model.AccessControlPolicyRule{
|
||||
{
|
||||
Actions: []string{"view"},
|
||||
Actions: []string{model.AccessControlPolicyActionMembership},
|
||||
Expression: "user.attributes.team == \"test\"",
|
||||
},
|
||||
},
|
||||
}
|
||||
_, storeErr := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, policy)
|
||||
require.NoError(t, storeErr)
|
||||
t.Cleanup(func() {
|
||||
_ = th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, channel.Id)
|
||||
})
|
||||
th.App.Srv().Store().Channel().InvalidateChannel(channel.Id)
|
||||
|
||||
_, resp, err := th.SystemAdminClient.SetChannelMembers(ctx, channel.Id, &model.SetChannelMembersRequest{Members: []string{th.BasicUser.Id}}, 0, 0)
|
||||
require.Error(t, err)
|
||||
|
|
@ -8161,6 +8170,54 @@ func TestSetChannelMembers(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
assert.False(t, member.SchemeAdmin, "BasicUser should no longer be admin")
|
||||
})
|
||||
|
||||
t.Run("permission-only policy does NOT reject bulk membership edits (bug fix)", func(t *testing.T) {
|
||||
// The original `policy_enforced` rejection misfired for channels
|
||||
// carrying only a permission policy (e.g. file upload
|
||||
// restriction). After Phase 2 the gate reads
|
||||
// PolicyActions[membership] specifically, so the endpoint must
|
||||
// succeed for these channels.
|
||||
channel := th.CreatePrivateChannel(t)
|
||||
user2 := th.BasicUser2
|
||||
|
||||
_, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, &model.AccessControlPolicy{
|
||||
ID: channel.Id,
|
||||
Type: model.AccessControlPolicyTypeChannel,
|
||||
Active: true,
|
||||
Revision: 1,
|
||||
Version: model.AccessControlPolicyVersionV0_2,
|
||||
Imports: []string{},
|
||||
Rules: []model.AccessControlPolicyRule{
|
||||
{Actions: []string{model.AccessControlPolicyActionUploadFileAttachment}, Expression: "true"},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, channel.Id)
|
||||
})
|
||||
th.App.Srv().Store().Channel().InvalidateChannel(channel.Id)
|
||||
|
||||
// Sanity check: the channel reads as PolicyEnforced=true (any
|
||||
// policy attached) but PolicyActions[membership]=false. Without
|
||||
// this distinction the test below would still pass due to the
|
||||
// rejection path simply being dead code, defeating the purpose
|
||||
// of the regression test.
|
||||
ch, appErr := th.App.GetChannel(th.Context, channel.Id)
|
||||
require.Nil(t, appErr)
|
||||
require.True(t, ch.PolicyEnforced, "channel must report PolicyEnforced=true so we know the bug-prone path is reachable")
|
||||
require.False(t, ch.HasMembershipPolicyAction(), "channel must NOT carry the membership action — that's the bug-fix invariant")
|
||||
|
||||
results, resp, err := th.SystemAdminClient.SetChannelMembers(ctx, channel.Id, &model.SetChannelMembersRequest{
|
||||
Members: []string{th.BasicUser.Id, user2.Id},
|
||||
}, 0, 0)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
var allAdded []string
|
||||
for _, r := range results {
|
||||
allAdded = append(allAdded, r.Added...)
|
||||
}
|
||||
assert.Contains(t, allAdded, user2.Id, "user2 should have been added since the gate must not reject permission-only channels")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetManagedCategories(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ func generateFlaggedPostReport(c *Context, w http.ResponseWriter, r *http.Reques
|
|||
model.AddEventParameterToAuditRec(auditRec, "flaggedPostId", postId)
|
||||
model.AddEventParameterToAuditRec(auditRec, "userId", userId)
|
||||
model.AddEventParameterToAuditRec(auditRec, "comment", actionRequest.Comment)
|
||||
model.AddEventParameterToAuditRec(auditRec, "action", actionRequest.Action)
|
||||
|
||||
post, appErr := c.App.GetSinglePost(c.AppContext, postId, true)
|
||||
if appErr != nil {
|
||||
|
|
@ -65,7 +66,7 @@ func generateFlaggedPostReport(c *Context, w http.ResponseWriter, r *http.Reques
|
|||
return
|
||||
}
|
||||
|
||||
reportPath, appErr := c.App.GenerateFlaggedPostReport(c.AppContext, postId, userId, actionRequest.Comment)
|
||||
reportPath, appErr := c.App.GenerateFlaggedPostReport(c.AppContext, postId, userId, actionRequest.Comment, actionRequest.Action)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@ import (
|
|||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
|
@ -128,6 +130,45 @@ func TestGenerateFlaggedPostReport(t *testing.T) {
|
|||
require.True(t, foundAttachment, "attachment for the flagged post should be present in the report archive")
|
||||
})
|
||||
|
||||
t.Run("Should include reviewer decision from request action", func(t *testing.T) {
|
||||
appErr := setBasicCommonReviewerConfig(th)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
post := th.CreatePost(t)
|
||||
flagPostViaAPI(t, client, post.Id)
|
||||
|
||||
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{
|
||||
Comment: "investigation note",
|
||||
Action: model.ContentFlaggingActionRemove,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.NotEmpty(t, report)
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(report), int64(len(report)))
|
||||
require.NoError(t, err)
|
||||
|
||||
var review model.FlaggedPostReportContentReview
|
||||
var found bool
|
||||
for _, f := range zr.File {
|
||||
if f.Name != "content_review.yaml" {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
require.NoError(t, err)
|
||||
b, err := io.ReadAll(rc)
|
||||
require.NoError(t, err)
|
||||
_ = rc.Close()
|
||||
require.NoError(t, yaml.Unmarshal(b, &review))
|
||||
found = true
|
||||
break
|
||||
}
|
||||
require.True(t, found, "content_review.yaml should be present in the report archive")
|
||||
require.Equal(t, "remove", review.ActorDecision)
|
||||
require.Equal(t, th.BasicUser.Id, review.ActorUserId)
|
||||
require.Equal(t, th.BasicUser.Username, review.ActorUsername)
|
||||
})
|
||||
|
||||
t.Run("Should include edit history entries in the generated report", func(t *testing.T) {
|
||||
appErr := setBasicCommonReviewerConfig(th)
|
||||
require.Nil(t, appErr)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ package api4
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
|
|
@ -31,37 +32,37 @@ func (api *API) InitCustomProfileAttributes() {
|
|||
}
|
||||
|
||||
func listCPAFields(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
if !model.MinimumEnterpriseLicense(c.App.Channels().License()) {
|
||||
c.Err = model.NewAppError("Api4.listCPAFields", "api.custom_profile_attributes.license_error", nil, "", http.StatusForbidden)
|
||||
rctx := app.RequestContextWithCallerID(c.AppContext, sessionCallerID(c))
|
||||
group, appErr := c.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
rctx := app.RequestContextWithCallerID(c.AppContext, c.AppContext.Session().UserId)
|
||||
fields, appErr := c.App.ListCPAFields(rctx)
|
||||
pfs, appErr := c.App.SearchPropertyFields(rctx, group.ID, model.PropertyFieldSearchOpts{
|
||||
GroupID: group.ID,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
PerPage: model.AccessControlGroupFieldLimit + 5,
|
||||
})
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
fields, convErr := model.CPAFieldsFromPropertyFields(pfs)
|
||||
if convErr != nil {
|
||||
c.Err = model.NewAppError("listCPAFields", "app.custom_profile_attributes.property_field_conversion.app_error", nil, "", http.StatusInternalServerError).Wrap(convErr)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(fields); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func createCPAField(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
|
||||
c.SetPermissionError(model.PermissionManageSystem)
|
||||
return
|
||||
}
|
||||
|
||||
if !model.MinimumEnterpriseLicense(c.App.Channels().License()) {
|
||||
c.Err = model.NewAppError("Api4.createCPAField", "api.custom_profile_attributes.license_error", nil, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var pf *model.CPAField
|
||||
err := json.NewDecoder(r.Body).Decode(&pf)
|
||||
if err != nil || pf == nil {
|
||||
if err := json.NewDecoder(r.Body).Decode(&pf); err != nil || pf == nil {
|
||||
c.SetInvalidParamWithErr("property_field", err)
|
||||
return
|
||||
}
|
||||
|
|
@ -72,42 +73,71 @@ func createCPAField(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
defer c.LogAuditRec(auditRec)
|
||||
model.AddEventParameterAuditableToAuditRec(auditRec, "property_field", pf)
|
||||
|
||||
rctx := app.RequestContextWithCallerID(c.AppContext, c.AppContext.Session().UserId)
|
||||
createdField, appErr := c.App.CreateCPAField(rctx, pf)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
auditRec.AddEventResultState(createdField)
|
||||
auditRec.AddEventObjectType("property_field")
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
if err := json.NewEncoder(w).Encode(createdField); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func patchCPAField(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
// CPA fields are system-scoped; only a system administrator may create
|
||||
// them. This mirrors the scope-based permission check the shared generic
|
||||
// handler enforces for system-typed fields.
|
||||
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
|
||||
c.SetPermissionError(model.PermissionManageSystem)
|
||||
return
|
||||
}
|
||||
|
||||
if !model.MinimumEnterpriseLicense(c.App.Channels().License()) {
|
||||
c.Err = model.NewAppError("Api4.patchCPAField", "api.custom_profile_attributes.license_error", nil, "", http.StatusForbidden)
|
||||
// Translate to PropertyField and route through the generic property API.
|
||||
// Server-controlled fields (group, type, target shape, creator) are
|
||||
// stamped here; ID/TargetID/Protected are stripped so a caller can't
|
||||
// inject them. Permissions and timestamps are filled in by lower layers.
|
||||
field := pf.ToPropertyField()
|
||||
group, appErr := c.App.GetPropertyGroup(c.AppContext, model.AccessControlPropertyGroupName)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
field.ID = ""
|
||||
field.GroupID = group.ID
|
||||
field.ObjectType = model.PropertyFieldObjectTypeUser
|
||||
field.TargetType = string(model.PropertyFieldTargetLevelSystem)
|
||||
field.TargetID = ""
|
||||
field.Protected = false
|
||||
field.CreatedBy = c.AppContext.Session().UserId
|
||||
field.UpdatedBy = c.AppContext.Session().UserId
|
||||
|
||||
rctx := app.RequestContextWithCallerID(c.AppContext, sessionCallerID(c))
|
||||
connectionID := r.Header.Get(model.ConnectionId)
|
||||
|
||||
createdField, appErr := c.App.CreatePropertyField(rctx, field, false, connectionID)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
cpaField, convErr := model.NewCPAFieldFromPropertyField(createdField)
|
||||
if convErr != nil {
|
||||
c.Err = model.NewAppError("createCPAField", "app.custom_profile_attributes.property_field_conversion.app_error", nil, "", http.StatusInternalServerError).Wrap(convErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Send CPA-specific websocket event for backwards compatibility
|
||||
message := model.NewWebSocketEvent(model.WebsocketEventCPAFieldCreated, "", "", "", nil, "")
|
||||
message.Add("field", cpaField)
|
||||
c.App.Publish(message)
|
||||
|
||||
auditRec.AddEventObjectType("property_field")
|
||||
auditRec.AddEventResultState(cpaField)
|
||||
auditRec.Success()
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
if err := json.NewEncoder(w).Encode(cpaField); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func patchCPAField(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireFieldId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var patch *model.PropertyFieldPatch
|
||||
err := json.NewDecoder(r.Body).Decode(&patch)
|
||||
if err != nil || patch == nil {
|
||||
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil || patch == nil {
|
||||
c.SetInvalidParamWithErr("property_field_patch", err)
|
||||
return
|
||||
}
|
||||
|
|
@ -115,11 +145,15 @@ func patchCPAField(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
if patch.Name != nil {
|
||||
*patch.Name = strings.TrimSpace(*patch.Name)
|
||||
}
|
||||
// Target fields are server-controlled; prevent the caller from patching them.
|
||||
patch.TargetID = nil
|
||||
patch.TargetType = nil
|
||||
|
||||
if err := patch.IsValid(); err != nil {
|
||||
if appErr, ok := err.(*model.AppError); ok {
|
||||
c.Err = appErr
|
||||
} else {
|
||||
c.Err = model.NewAppError("createCPAField", "api.custom_profile_attributes.invalid_field_patch", nil, "", http.StatusBadRequest)
|
||||
c.Err = model.NewAppError("patchCPAField", "api.custom_profile_attributes.invalid_field_patch", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -128,41 +162,86 @@ func patchCPAField(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
defer c.LogAuditRec(auditRec)
|
||||
model.AddEventParameterAuditableToAuditRec(auditRec, "property_field_patch", patch)
|
||||
|
||||
rctx := app.RequestContextWithCallerID(c.AppContext, c.AppContext.Session().UserId)
|
||||
originalField, appErr := c.App.GetCPAField(rctx, c.Params.FieldId)
|
||||
group, appErr := c.App.GetPropertyGroup(c.AppContext, model.AccessControlPropertyGroupName)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.AddEventPriorState(originalField)
|
||||
rctx := app.RequestContextWithCallerID(c.AppContext, sessionCallerID(c))
|
||||
|
||||
patchedField, appErr := c.App.PatchCPAField(rctx, c.Params.FieldId, patch)
|
||||
existingField, appErr := c.App.GetPropertyField(rctx, group.ID, c.Params.FieldId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
if existingField.ObjectType != model.PropertyFieldObjectTypeUser {
|
||||
c.Err = model.NewAppError("patchCPAField", "api.property_field.object_type_mismatch.app_error", nil, "", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Permission branching (session-bound).
|
||||
isOptionsOnly := isOptionsOnlyPatch(patch)
|
||||
if isOptionsOnly && existingField.Type != model.PropertyFieldTypeSelect && existingField.Type != model.PropertyFieldTypeMultiselect {
|
||||
isOptionsOnly = false
|
||||
}
|
||||
if isOptionsOnly {
|
||||
if !c.App.SessionHasPermissionToManagePropertyFieldOptions(rctx, *c.AppContext.Session(), existingField) {
|
||||
c.Err = model.NewAppError("patchCPAField", "api.property_field.update.no_options_permission.app_error", nil, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if !c.App.SessionHasPermissionToEditPropertyField(rctx, *c.AppContext.Session(), existingField) {
|
||||
c.Err = model.NewAppError("patchCPAField", "api.property_field.update.no_field_permission.app_error", nil, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Capture original state for audit before in-place patch (Attrs is
|
||||
// shallow-copied because Patch mutates it).
|
||||
orig := *existingField
|
||||
if existingField.Attrs != nil {
|
||||
orig.Attrs = make(model.StringInterface, len(existingField.Attrs))
|
||||
maps.Copy(orig.Attrs, existingField.Attrs)
|
||||
}
|
||||
auditRec.AddEventPriorState(&orig)
|
||||
|
||||
existingField.Patch(patch, true)
|
||||
existingField.UpdatedBy = c.AppContext.Session().UserId
|
||||
connectionID := r.Header.Get(model.ConnectionId)
|
||||
|
||||
updatedField, clearedIDs, updateErr := c.App.UpdatePropertyField(rctx, group.ID, existingField, false, connectionID)
|
||||
if updateErr != nil {
|
||||
c.Err = updateErr
|
||||
return
|
||||
}
|
||||
|
||||
cpaField, convErr := model.NewCPAFieldFromPropertyField(updatedField)
|
||||
if convErr != nil {
|
||||
c.Err = model.NewAppError("patchCPAField", "app.custom_profile_attributes.property_field_conversion.app_error", nil, "", http.StatusInternalServerError).Wrap(convErr)
|
||||
return
|
||||
}
|
||||
|
||||
// CPA-specific websocket event (backward compat). delete_values:true tells
|
||||
// pre-PSAv2 webapp clients to clear cached values for this field; PSAv2
|
||||
// clients receive the same signal via WebsocketEventPropertyValuesUpdated
|
||||
// fired by App.UpdatePropertyField.
|
||||
message := model.NewWebSocketEvent(model.WebsocketEventCPAFieldUpdated, "", "", "", nil, "")
|
||||
message.Add("field", cpaField)
|
||||
message.Add("delete_values", len(clearedIDs) > 0)
|
||||
c.App.Publish(message)
|
||||
|
||||
auditRec.Success()
|
||||
auditRec.AddEventResultState(patchedField)
|
||||
auditRec.AddEventResultState(cpaField)
|
||||
auditRec.AddEventObjectType("property_field")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(patchedField); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(cpaField); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func deleteCPAField(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
|
||||
c.SetPermissionError(model.PermissionManageSystem)
|
||||
return
|
||||
}
|
||||
|
||||
if !model.MinimumEnterpriseLicense(c.App.Channels().License()) {
|
||||
c.Err = model.NewAppError("Api4.deleteCPAField", "api.custom_profile_attributes.license_error", nil, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
c.RequireFieldId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
|
|
@ -172,54 +251,185 @@ func deleteCPAField(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
defer c.LogAuditRec(auditRec)
|
||||
model.AddEventParameterToAuditRec(auditRec, "field_id", c.Params.FieldId)
|
||||
|
||||
rctx := app.RequestContextWithCallerID(c.AppContext, c.AppContext.Session().UserId)
|
||||
field, appErr := c.App.GetCPAField(rctx, c.Params.FieldId)
|
||||
group, appErr := c.App.GetPropertyGroup(c.AppContext, model.AccessControlPropertyGroupName)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
auditRec.AddEventPriorState(field)
|
||||
|
||||
if appErr := c.App.DeleteCPAField(rctx, c.Params.FieldId); appErr != nil {
|
||||
rctx := app.RequestContextWithCallerID(c.AppContext, sessionCallerID(c))
|
||||
|
||||
existingField, appErr := c.App.GetPropertyField(rctx, group.ID, c.Params.FieldId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
if existingField.ObjectType != model.PropertyFieldObjectTypeUser {
|
||||
c.Err = model.NewAppError("deleteCPAField", "api.property_field.object_type_mismatch.app_error", nil, "", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if !c.App.SessionHasPermissionToEditPropertyField(rctx, *c.AppContext.Session(), existingField) {
|
||||
c.Err = model.NewAppError("deleteCPAField", "api.property_field.delete.no_permission.app_error", nil, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
connectionID := r.Header.Get(model.ConnectionId)
|
||||
if deleteErr := c.App.DeletePropertyField(rctx, group.ID, c.Params.FieldId, false, connectionID); deleteErr != nil {
|
||||
c.Err = deleteErr
|
||||
return
|
||||
}
|
||||
|
||||
// CPA-specific websocket event (backward compat)
|
||||
message := model.NewWebSocketEvent(model.WebsocketEventCPAFieldDeleted, "", "", "", nil, "")
|
||||
message.Add("field_id", c.Params.FieldId)
|
||||
c.App.Publish(message)
|
||||
|
||||
auditRec.AddEventPriorState(existingField)
|
||||
auditRec.Success()
|
||||
auditRec.AddEventResultState(field)
|
||||
auditRec.AddEventResultState(existingField)
|
||||
auditRec.AddEventObjectType("property_field")
|
||||
|
||||
ReturnStatusOK(w)
|
||||
}
|
||||
|
||||
func getCPAGroup(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
if !model.MinimumEnterpriseLicense(c.App.Channels().License()) {
|
||||
c.Err = model.NewAppError("Api4.getCPAGroup", "api.custom_profile_attributes.license_error", nil, "", http.StatusForbidden)
|
||||
// Every other CPA endpoint enforces MinimumEnterpriseLicense via the
|
||||
// LicenseCheckHook on field/value operations. GetPropertyGroup is not
|
||||
// hooked, so we enforce the same contract here inline.
|
||||
if !model.MinimumEnterpriseLicense(c.App.License()) {
|
||||
c.Err = model.NewAppError("getCPAGroup", "app.property.license_error", nil, "an Enterprise license is required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
groupID, appErr := c.App.CpaGroupID()
|
||||
group, appErr := c.App.GetPropertyGroup(c.AppContext, model.AccessControlPropertyGroupName)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{"id": groupID}); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{"id": group.ID}); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
// cpaPatchValues is the shared implementation for patchCPAValues and
|
||||
// patchCPAValuesForUser. It translates the CPA request format to the generic
|
||||
// property API, performs the same session-bound checks as the generic value
|
||||
// patch handler (target access, batch caps, per-field permission), routes
|
||||
// the upsert through App.UpsertPropertyValues, and emits the CPA-specific
|
||||
// websocket event.
|
||||
func cpaPatchValues(c *Context, w http.ResponseWriter, r *http.Request, userID string, updates map[string]json.RawMessage) {
|
||||
rctx := app.RequestContextWithCallerID(c.AppContext, sessionCallerID(c))
|
||||
group, appErr := c.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
if !hasTargetAccess(c, model.PropertyFieldObjectTypeUser, userID, true) {
|
||||
return
|
||||
}
|
||||
|
||||
// Translate CPA format → generic PropertyValuePatchItem list. Map
|
||||
// iteration is unordered, but FieldID uniqueness is guaranteed by the
|
||||
// JSON object key constraint, so we cannot hit duplicate-FieldID; still,
|
||||
// we keep the same shape as the generic handler for parity.
|
||||
items := make([]model.PropertyValuePatchItem, 0, len(updates))
|
||||
for fieldID, value := range updates {
|
||||
items = append(items, model.PropertyValuePatchItem{
|
||||
FieldID: fieldID,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
c.Err = model.NewAppError("cpaPatchValues", "api.property_value.patch.empty_body.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(items) > maxPropertyValuePatchItems {
|
||||
c.Err = model.NewAppError("cpaPatchValues", "api.property_value.patch.too_many_items.request_error", map[string]any{
|
||||
"Max": maxPropertyValuePatchItems,
|
||||
}, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
fieldIDs := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if !model.IsValidId(item.FieldID) {
|
||||
c.Err = model.NewAppError("cpaPatchValues", "api.property_value.patch.invalid_field_id.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
fieldIDs = append(fieldIDs, item.FieldID)
|
||||
}
|
||||
|
||||
fields, fieldsErr := c.App.GetPropertyFields(rctx, group.ID, fieldIDs)
|
||||
if fieldsErr != nil {
|
||||
c.Err = fieldsErr
|
||||
return
|
||||
}
|
||||
fieldByID := make(map[string]*model.PropertyField, len(fields))
|
||||
for _, f := range fields {
|
||||
fieldByID[f.ID] = f
|
||||
}
|
||||
for _, item := range items {
|
||||
f, ok := fieldByID[item.FieldID]
|
||||
if !ok {
|
||||
c.Err = model.NewAppError("cpaPatchValues", "api.property_value.patch.field_not_found.app_error",
|
||||
map[string]any{"FieldID": item.FieldID}, "", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if f.ObjectType != model.PropertyFieldObjectTypeUser {
|
||||
c.Err = model.NewAppError("cpaPatchValues", "api.property_field.object_type_mismatch.app_error", nil, "", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if !c.App.SessionHasPermissionToSetPropertyFieldValues(rctx, *c.AppContext.Session(), f, userID) {
|
||||
c.Err = model.NewAppError("cpaPatchValues", "api.property_value.patch.no_values_permission.app_error", nil, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
callerID := c.AppContext.Session().UserId
|
||||
values := make([]*model.PropertyValue, len(items))
|
||||
for i, item := range items {
|
||||
values[i] = &model.PropertyValue{
|
||||
TargetID: userID,
|
||||
TargetType: model.PropertyFieldObjectTypeUser,
|
||||
GroupID: group.ID,
|
||||
FieldID: item.FieldID,
|
||||
Value: item.Value,
|
||||
CreatedBy: callerID,
|
||||
UpdatedBy: callerID,
|
||||
}
|
||||
}
|
||||
connectionID := r.Header.Get(model.ConnectionId)
|
||||
|
||||
upserted, upsertErr := c.App.UpsertPropertyValues(rctx, values, model.PropertyFieldObjectTypeUser, userID, connectionID)
|
||||
if upsertErr != nil {
|
||||
c.Err = upsertErr
|
||||
return
|
||||
}
|
||||
|
||||
// Translate response to CPA format: {fieldID: value}
|
||||
results := make(map[string]json.RawMessage, len(upserted))
|
||||
for _, value := range upserted {
|
||||
results[value.FieldID] = value.Value
|
||||
}
|
||||
|
||||
// CPA-specific websocket event (backward compat)
|
||||
message := model.NewWebSocketEvent(model.WebsocketEventCPAValuesUpdated, "", "", "", nil, "")
|
||||
message.Add("user_id", userID)
|
||||
message.Add("values", results)
|
||||
c.App.Publish(message)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(results); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func patchCPAValues(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
if !model.MinimumEnterpriseLicense(c.App.Channels().License()) {
|
||||
c.Err = model.NewAppError("Api4.patchCPAValues", "api.custom_profile_attributes.license_error", nil, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.AppContext.Session().UserId
|
||||
if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), userID) {
|
||||
c.SetPermissionError(model.PermissionEditOtherUsers)
|
||||
return
|
||||
}
|
||||
|
||||
var updates map[string]json.RawMessage
|
||||
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
||||
|
|
@ -231,72 +441,38 @@ func patchCPAValues(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
defer c.LogAuditRec(auditRec)
|
||||
model.AddEventParameterToAuditRec(auditRec, "user_id", userID)
|
||||
|
||||
// if the user is not an admin, we need to check that there are no
|
||||
// admin-managed fields
|
||||
session := *c.AppContext.Session()
|
||||
rctx := app.RequestContextWithCallerID(c.AppContext, session.UserId)
|
||||
|
||||
if !c.App.SessionHasPermissionTo(session, model.PermissionManageSystem) {
|
||||
fields, appErr := c.App.ListCPAFields(rctx)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
// Check if any of the fields being updated are admin-managed
|
||||
for _, field := range fields {
|
||||
if _, isBeingUpdated := updates[field.ID]; isBeingUpdated {
|
||||
if field.IsAdminManaged() {
|
||||
c.Err = model.NewAppError("Api4.patchCPAValues", "app.custom_profile_attributes.property_field_is_managed.app_error", nil, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results := make(map[string]json.RawMessage, len(updates))
|
||||
for fieldID, rawValue := range updates {
|
||||
patchedValue, appErr := c.App.PatchCPAValue(rctx, userID, fieldID, rawValue, false)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
results[fieldID] = patchedValue.Value
|
||||
cpaPatchValues(c, w, r, userID, updates)
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
auditRec.AddEventObjectType("patchCPAValues")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(results); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func listCPAValues(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
if !model.MinimumEnterpriseLicense(c.App.Channels().License()) {
|
||||
c.Err = model.NewAppError("Api4.listCPAValues", "api.custom_profile_attributes.license_error", nil, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
c.RequireUserId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
targetUserID := c.Params.UserId
|
||||
callerUserID := c.AppContext.Session().UserId
|
||||
|
||||
// we check unrestricted sessions to allow local mode requests to go through
|
||||
if !c.AppContext.Session().IsUnrestricted() {
|
||||
canSee, err := c.App.UserCanSeeOtherUser(c.AppContext, callerUserID, targetUserID)
|
||||
if err != nil || !canSee {
|
||||
c.SetPermissionError(model.PermissionViewMembers)
|
||||
return
|
||||
}
|
||||
if !hasTargetAccess(c, model.PropertyFieldObjectTypeUser, c.Params.UserId, false) {
|
||||
return
|
||||
}
|
||||
|
||||
rctx := app.RequestContextWithCallerID(c.AppContext, callerUserID)
|
||||
values, appErr := c.App.ListCPAValues(rctx, targetUserID)
|
||||
rctx := app.RequestContextWithCallerID(c.AppContext, sessionCallerID(c))
|
||||
group, appErr := c.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
values, appErr := c.App.SearchPropertyValues(rctx, group.ID, model.PropertyValueSearchOpts{
|
||||
TargetIDs: []string{c.Params.UserId},
|
||||
TargetType: model.PropertyValueTargetTypeUser,
|
||||
// Single-target search: at most one value per (target, field), so the field cap bounds the page.
|
||||
PerPage: model.AccessControlGroupFieldLimit + 5,
|
||||
})
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
|
|
@ -312,23 +488,12 @@ func listCPAValues(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func patchCPAValuesForUser(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
if !model.MinimumEnterpriseLicense(c.App.Channels().License()) {
|
||||
c.Err = model.NewAppError("Api4.patchCPAValuesForUser", "api.custom_profile_attributes.license_error", nil, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Get userID from URL
|
||||
c.RequireUserId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
userID := c.Params.UserId
|
||||
|
||||
if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), userID) {
|
||||
c.SetPermissionError(model.PermissionEditOtherUsers)
|
||||
return
|
||||
}
|
||||
|
||||
var updates map[string]json.RawMessage
|
||||
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
||||
c.SetInvalidParamWithErr("value", err)
|
||||
|
|
@ -339,47 +504,11 @@ func patchCPAValuesForUser(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
defer c.LogAuditRec(auditRec)
|
||||
model.AddEventParameterToAuditRec(auditRec, "user_id", userID)
|
||||
|
||||
// Check for admin-managed fields
|
||||
session := *c.AppContext.Session()
|
||||
rctx := app.RequestContextWithCallerID(c.AppContext, session.UserId)
|
||||
|
||||
isAdmin := c.App.SessionHasPermissionTo(session, model.PermissionManageSystem)
|
||||
if !isAdmin {
|
||||
fields, appErr := c.App.ListCPAFields(rctx)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
for _, field := range fields {
|
||||
if _, isBeingUpdated := updates[field.ID]; !isBeingUpdated {
|
||||
continue
|
||||
}
|
||||
// Check for admin-managed fields
|
||||
if field.IsAdminManaged() {
|
||||
c.Err = model.NewAppError("Api4.patchCPAValuesForUser",
|
||||
"app.custom_profile_attributes.property_field_is_managed.app_error",
|
||||
nil, "",
|
||||
http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results := make(map[string]json.RawMessage, len(updates))
|
||||
for fieldID, rawValue := range updates {
|
||||
patchedValue, appErr := c.App.PatchCPAValue(rctx, userID, fieldID, rawValue, false)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
results[fieldID] = patchedValue.Value
|
||||
cpaPatchValues(c, w, r, userID, updates)
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
auditRec.AddEventObjectType("patchCPAValues")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(results); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -5,7 +5,8 @@ package api4
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
|
|
@ -20,22 +21,6 @@ func (api *API) InitAction() {
|
|||
api.BaseRoutes.APIRoot.Handle("/actions/dialogs/lookup", api.APISessionRequired(lookupDialog)).Methods(http.MethodPost)
|
||||
}
|
||||
|
||||
// getStringValue safely converts an interface{} value to a string with logging for failures.
|
||||
// It handles nil values gracefully and logs warnings when conversion fails.
|
||||
func getStringValue(val any, fieldName string, logger *mlog.Logger) string {
|
||||
if val == nil {
|
||||
return ""
|
||||
}
|
||||
if str, ok := val.(string); ok {
|
||||
return str
|
||||
}
|
||||
logger.Warn("Failed to convert field to string",
|
||||
mlog.String("field", fieldName),
|
||||
mlog.String("type", fmt.Sprintf("%T", val)),
|
||||
mlog.Any("value", val))
|
||||
return ""
|
||||
}
|
||||
|
||||
func doPostAction(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequirePostId()
|
||||
if c.Err != nil {
|
||||
|
|
@ -43,9 +28,26 @@ func doPostAction(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
var actionRequest model.DoPostActionRequest
|
||||
err := json.NewDecoder(r.Body).Decode(&actionRequest)
|
||||
if err != nil {
|
||||
c.Logger.Warn("Error decoding the action request", mlog.Err(err))
|
||||
dec := json.NewDecoder(r.Body)
|
||||
err := dec.Decode(&actionRequest)
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
// Empty body is allowed for backward-compatibility with older clients.
|
||||
// Any other decode failure means the request cannot be trusted — in
|
||||
// particular, a wrong-type query would otherwise fall through as nil
|
||||
// and silently execute the action without the caller's params.
|
||||
c.SetInvalidParamWithErr("action_request", err)
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
// Reject trailing JSON values after the first object (e.g.
|
||||
// `{"query":{"k":"v"}}{"cookie":"x"}`). json.Decoder.Decode
|
||||
// stops at the first complete value and would otherwise silently
|
||||
// ignore the rest, leaving the caller's intent ambiguous.
|
||||
var trailing any
|
||||
if extraErr := dec.Decode(&trailing); !errors.Is(extraErr, io.EOF) {
|
||||
c.SetInvalidParamWithErr("action_request", extraErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var cookie *model.PostActionCookie
|
||||
|
|
@ -82,7 +84,7 @@ func doPostAction(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
resp := &model.PostActionAPIResponse{Status: "OK"}
|
||||
|
||||
resp.TriggerId, appErr = c.App.DoPostActionWithCookie(c.AppContext, c.Params.PostId, c.Params.ActionId, c.AppContext.Session().UserId,
|
||||
actionRequest.SelectedOption, cookie)
|
||||
actionRequest.SelectedOption, cookie, actionRequest.Query)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
|
|
@ -204,8 +206,8 @@ func lookupDialog(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
mlog.String("user_id", lookup.UserId),
|
||||
mlog.String("channel_id", lookup.ChannelId),
|
||||
mlog.String("team_id", lookup.TeamId),
|
||||
mlog.String("selected_field", getStringValue(lookup.Submission["selected_field"], "selected_field", c.Logger)),
|
||||
mlog.String("query", getStringValue(lookup.Submission["query"], "query", c.Logger)),
|
||||
mlog.Any("selected_field", lookup.Submission["selected_field"]),
|
||||
mlog.Any("query", lookup.Submission["query"]),
|
||||
)
|
||||
|
||||
resp, err := c.App.LookupInteractiveDialog(c.AppContext, lookup)
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@ package api4
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -533,3 +535,189 @@ func TestLookupDialog(t *testing.T) {
|
|||
assert.Empty(t, lookupResp.Items)
|
||||
})
|
||||
}
|
||||
|
||||
// newAttachmentActionPost posts an attachment action pointing at upstreamURL,
|
||||
// attributed to th.BasicUser so th.Client has access to call the action.
|
||||
func newAttachmentActionPost(t *testing.T, th *TestHelper, upstreamURL string) (*model.Post, string) {
|
||||
t.Helper()
|
||||
basicPost := &model.Post{
|
||||
Message: "attachment action post",
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
UserId: th.BasicUser.Id,
|
||||
Props: model.StringInterface{
|
||||
model.PostPropsAttachments: []*model.MessageAttachment{
|
||||
{
|
||||
Text: "hello",
|
||||
Actions: []*model.PostAction{
|
||||
{
|
||||
Type: model.PostActionTypeButton,
|
||||
Name: "click",
|
||||
Integration: &model.PostActionIntegration{
|
||||
URL: upstreamURL,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
created, _, appErr := th.App.CreatePostAsUser(th.Context, basicPost, "", true)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
attachments, ok := created.GetProp(model.PostPropsAttachments).([]*model.MessageAttachment)
|
||||
require.True(t, ok)
|
||||
require.NotEmpty(t, attachments)
|
||||
require.NotEmpty(t, attachments[0].Actions)
|
||||
require.NotEmpty(t, attachments[0].Actions[0].Id)
|
||||
return created, attachments[0].Actions[0].Id
|
||||
}
|
||||
|
||||
func TestDoPostActionQuery_ValidationErrors(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
client := th.Client
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1"
|
||||
})
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
created, actionID := newAttachmentActionPost(t, th, ts.URL)
|
||||
route := "/posts/" + created.Id + "/actions/" + actionID
|
||||
|
||||
t.Run("too many entries returns 400 with expected error id", func(t *testing.T) {
|
||||
ctxMap := make(map[string]string, model.MaxActionQueryEntries+1)
|
||||
for i := range model.MaxActionQueryEntries + 1 {
|
||||
ctxMap[fmt.Sprintf("k%d", i)] = "v"
|
||||
}
|
||||
payload, err := json.Marshal(model.DoPostActionRequest{Query: ctxMap})
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := client.DoAPIPost(context.Background(), route, string(payload))
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, model.BuildResponse(resp))
|
||||
CheckErrorID(t, err, "api.post.do_action.query.app_error")
|
||||
})
|
||||
|
||||
t.Run("oversized key returns 400", func(t *testing.T) {
|
||||
ctxMap := map[string]string{strings.Repeat("k", model.MaxActionQueryKeyLength+1): "v"}
|
||||
payload, err := json.Marshal(model.DoPostActionRequest{Query: ctxMap})
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := client.DoAPIPost(context.Background(), route, string(payload))
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, model.BuildResponse(resp))
|
||||
CheckErrorID(t, err, "api.post.do_action.query.app_error")
|
||||
})
|
||||
|
||||
t.Run("oversized value returns 400", func(t *testing.T) {
|
||||
ctxMap := map[string]string{"k": strings.Repeat("v", model.MaxActionQueryValueLength+1)}
|
||||
payload, err := json.Marshal(model.DoPostActionRequest{Query: ctxMap})
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := client.DoAPIPost(context.Background(), route, string(payload))
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, model.BuildResponse(resp))
|
||||
CheckErrorID(t, err, "api.post.do_action.query.app_error")
|
||||
})
|
||||
|
||||
t.Run("small valid context returns 200", func(t *testing.T) {
|
||||
payload, err := json.Marshal(model.DoPostActionRequest{Query: map[string]string{"tail": "214"}})
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := client.DoAPIPost(context.Background(), route, string(payload))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDoPostActionQuery_OmitempyCompat(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
client := th.Client
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1"
|
||||
})
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
created, actionID := newAttachmentActionPost(t, th, ts.URL)
|
||||
route := "/posts/" + created.Id + "/actions/" + actionID
|
||||
|
||||
// Older clients do not know about query — their request body has no such
|
||||
// key. The omitempty tag should make this equivalent to sending a nil
|
||||
// map, which ValidateActionQuery accepts.
|
||||
payload := `{"selected_option":""}`
|
||||
resp, err := client.DoAPIPost(context.Background(), route, payload)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Completely empty body should also be accepted — same as older clients
|
||||
// calling DoPostActionWithCookie with no selection and no cookie.
|
||||
resp, err = client.DoAPIPost(context.Background(), route, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
// TestDoPostActionMalformedBody verifies non-EOF JSON decode errors now
|
||||
// return 400 instead of silently running the action with an empty request.
|
||||
// A body like `{"query":{"k":1}}` (value is not a string) would otherwise
|
||||
// deserialize to a zero-value Query and skip validation.
|
||||
func TestDoPostActionMalformedBody(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
client := th.Client
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1"
|
||||
})
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
created, actionID := newAttachmentActionPost(t, th, ts.URL)
|
||||
route := "/posts/" + created.Id + "/actions/" + actionID
|
||||
|
||||
t.Run("wrong type for query value returns 400", func(t *testing.T) {
|
||||
// query must be map[string]string; passing an int value triggers a
|
||||
// json UnmarshalTypeError which must not fall through.
|
||||
resp, err := client.DoAPIPost(context.Background(), route, `{"query":{"k":1}}`)
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("syntactically invalid JSON returns 400", func(t *testing.T) {
|
||||
resp, err := client.DoAPIPost(context.Background(), route, `{not json`)
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("trailing JSON values after the first object return 400", func(t *testing.T) {
|
||||
// json.Decoder.Decode stops after the first complete value, so a
|
||||
// body like `{"query":{}}{"cookie":"x"}` would otherwise execute
|
||||
// the action with the first object's intent and silently drop the
|
||||
// rest. The handler explicitly rejects trailing values.
|
||||
resp, err := client.DoAPIPost(context.Background(), route, `{"query":{}}{"cookie":"x"}`)
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -942,7 +942,7 @@ func TestHandlerOutgoingOAuthConnectionUpdate(t *testing.T) {
|
|||
th.AddPermissionToRole(t, model.PermissionManageOutgoingOAuthConnections.Id, model.SystemUserRoleId)
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
body.Write([]byte(`{/}`))
|
||||
body.WriteString(`{/}`)
|
||||
|
||||
req, err := http.NewRequest("PUT", "/", body)
|
||||
if err != nil {
|
||||
|
|
@ -990,7 +990,7 @@ func TestHandlerOutgoingOAuthConnectionUpdate(t *testing.T) {
|
|||
th.AddPermissionToRole(t, model.PermissionManageOutgoingOAuthConnections.Id, model.SystemUserRoleId)
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
body.Write([]byte(`{"Id": "` + model.NewId() + `", "name": "changed name"}`))
|
||||
body.WriteString(`{"Id": "` + model.NewId() + `", "name": "changed name"}`)
|
||||
|
||||
req, err := http.NewRequest("PUT", "/", body)
|
||||
if err != nil {
|
||||
|
|
@ -1133,7 +1133,7 @@ func TestHandlerOutgoingOAuthConnectionHandlerCreate(t *testing.T) {
|
|||
th.AddPermissionToRole(t, model.PermissionManageOutgoingOAuthConnections.Id, model.SystemUserRoleId)
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
body.Write([]byte(`{/}`))
|
||||
body.WriteString(`{/}`)
|
||||
|
||||
req, err := http.NewRequest("POST", "/", body)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -2196,6 +2196,14 @@ func TestUpdatePost(t *testing.T) {
|
|||
CheckOKStatus(t, resp)
|
||||
require.NotNil(t, updatedPost)
|
||||
assert.Equal(t, "Updated message only", updatedPost.Message)
|
||||
|
||||
// Lock in the "unchanged files are preserved" contract — the
|
||||
// caller revoked PermissionEditFileAttachment and re-submitted
|
||||
// the same FileIds, so the response must carry them back. A
|
||||
// silent "files dropped" regression here would still pass the
|
||||
// message assertion above.
|
||||
require.NotNil(t, updatedPost.FileIds)
|
||||
assert.ElementsMatch(t, postWithFiles.FileIds, updatedPost.FileIds)
|
||||
})
|
||||
|
||||
t.Run("should allow changing files when edit_file_attachment permission is present", func(t *testing.T) {
|
||||
|
|
@ -2222,6 +2230,13 @@ func TestUpdatePost(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
require.NotNil(t, updatedPost)
|
||||
|
||||
// Lock in the "files CAN be added when the caller has
|
||||
// PermissionEditFileAttachment" contract — without this
|
||||
// assertion a regression that silently drops the new
|
||||
// attachment would still pass the NotNil check above.
|
||||
require.NotNil(t, updatedPost.FileIds)
|
||||
assert.ElementsMatch(t, updatePost.FileIds, updatedPost.FileIds)
|
||||
})
|
||||
|
||||
t.Run("should be able to add and remove files simultaneously", func(t *testing.T) {
|
||||
|
|
@ -5511,6 +5526,7 @@ func TestGetEditHistoryForPost(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCreatePostNotificationsWithCRT(t *testing.T) {
|
||||
t.Skip("flaky")
|
||||
mainHelper.Parallel(t)
|
||||
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
|
|
|||
|
|
@ -6,12 +6,14 @@ package api4
|
|||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/app"
|
||||
)
|
||||
|
||||
const maxPropertyValuePatchItems = 50
|
||||
|
|
@ -63,43 +65,33 @@ func createPropertyField(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventCreatePropertyField, model.AuditStatusFail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
|
||||
// Set ObjectType and GroupID from URL
|
||||
field.ObjectType = c.Params.ObjectType
|
||||
field.GroupID = group.ID
|
||||
|
||||
// System-object fields attach to the system itself; canonicalize the
|
||||
// target fields so clients can't submit inconsistent combinations.
|
||||
// Permissions are likewise pinned to sysadmin: a system field's
|
||||
// TargetType is "system", which makes member-level scope checks resolve
|
||||
// to "any authenticated user" (see hasPropertyFieldScopeAccess), so
|
||||
// honouring a member-level permission on a system field would expose
|
||||
// the field's definition, options, and values to every logged-in user.
|
||||
if field.ObjectType == model.PropertyFieldObjectTypeSystem {
|
||||
field.TargetType = string(model.PropertyFieldTargetLevelSystem)
|
||||
field.TargetID = ""
|
||||
sysadmin := model.PermissionLevelSysadmin
|
||||
field.PermissionField = &sysadmin
|
||||
field.PermissionValues = &sysadmin
|
||||
field.PermissionOptions = &sysadmin
|
||||
}
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventCreatePropertyField, model.AuditStatusFail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
model.AddEventParameterAuditableToAuditRec(auditRec, "property_field", field)
|
||||
|
||||
rctx := app.RequestContextWithCallerID(c.AppContext, sessionCallerID(c))
|
||||
|
||||
// Reject protected field creation via API
|
||||
if field.Protected {
|
||||
c.Err = model.NewAppError("createPropertyField", "api.property_field.create.protected_via_api.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Template creation is always sysadmin-only, regardless of target_type.
|
||||
// Pre-canonicalize system objects so the scope check below cannot be
|
||||
// bypassed by submitting ObjectType=system with TargetType=channel. The
|
||||
// App layer re-canonicalizes defensively for plugin/internal callers.
|
||||
app.CanonicalizeSystemObjectField(field)
|
||||
|
||||
// Templates are always sysadmin-only, regardless of TargetType.
|
||||
if field.ObjectType == model.PropertyFieldObjectTypeTemplate &&
|
||||
!c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
|
||||
c.SetPermissionError(model.PermissionManageSystem)
|
||||
return
|
||||
}
|
||||
|
||||
// Check scope access for creation based on target_type
|
||||
// Scope-based create permission.
|
||||
switch field.TargetType {
|
||||
case "channel":
|
||||
if field.TargetID == "" {
|
||||
|
|
@ -130,27 +122,15 @@ func createPropertyField(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Trim whitespace from name
|
||||
field.Name = strings.TrimSpace(field.Name)
|
||||
|
||||
// Set permissions based on admin status.
|
||||
// Permissions are not accepted from the request body; they're set by the server.
|
||||
// Templates default to sysadmin since they define the schema linked fields inherit.
|
||||
// System-object fields likewise default to sysadmin since they attach to the
|
||||
// Mattermost instance and only a system administrator should write them.
|
||||
// Default permission levels: pin all three for non-admins, nil-fill for
|
||||
// admins. Stays in API because it is session-bound.
|
||||
isAdmin := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
||||
defaultLevel := model.PermissionLevelMember
|
||||
if field.ObjectType == model.PropertyFieldObjectTypeTemplate ||
|
||||
field.ObjectType == model.PropertyFieldObjectTypeSystem {
|
||||
defaultLevel = model.PermissionLevelSysadmin
|
||||
}
|
||||
defaultLevel := app.DefaultPropertyFieldPermissionLevel(field)
|
||||
if !isAdmin {
|
||||
// Non-admin: force all permissions to the default level
|
||||
field.PermissionField = &defaultLevel
|
||||
field.PermissionValues = &defaultLevel
|
||||
field.PermissionOptions = &defaultLevel
|
||||
} else {
|
||||
// Admin with nil fields: set defaults
|
||||
if field.PermissionField == nil {
|
||||
field.PermissionField = &defaultLevel
|
||||
}
|
||||
|
|
@ -162,17 +142,13 @@ func createPropertyField(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
// Set creator
|
||||
field.CreatedBy = c.AppContext.Session().UserId
|
||||
field.UpdatedBy = c.AppContext.Session().UserId
|
||||
|
||||
model.AddEventParameterAuditableToAuditRec(auditRec, "property_field", field)
|
||||
|
||||
connectionID := r.Header.Get(model.ConnectionId)
|
||||
|
||||
createdField, err := c.App.CreatePropertyField(c.AppContext, field, false, connectionID)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
createdField, appErr := c.App.CreatePropertyField(rctx, field, false, connectionID)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -289,7 +265,6 @@ func patchPropertyField(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
groupID := group.ID
|
||||
|
||||
var patch *model.PropertyFieldPatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil || patch == nil {
|
||||
|
|
@ -301,8 +276,6 @@ func patchPropertyField(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
*patch.Name = strings.TrimSpace(*patch.Name)
|
||||
}
|
||||
|
||||
// target_id and target_type are identity fields that define the
|
||||
// property's scope and cannot be modified via patch
|
||||
patch.TargetID = nil
|
||||
patch.TargetType = nil
|
||||
|
||||
|
|
@ -316,94 +289,65 @@ func patchPropertyField(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Get existing field
|
||||
existingField, err := c.App.GetPropertyField(c.AppContext, groupID, c.Params.FieldId)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventPatchPropertyField, model.AuditStatusFail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
model.AddEventParameterAuditableToAuditRec(auditRec, "property_field_patch", patch)
|
||||
|
||||
rctx := app.RequestContextWithCallerID(c.AppContext, sessionCallerID(c))
|
||||
|
||||
existingField, appErr := c.App.GetPropertyField(rctx, group.ID, c.Params.FieldId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
// FIXME: IsPSAv1 currently includes template fields (ObjectType="template"), but
|
||||
// templates are valid PSAv2 objects and must be patchable. Once the FIXME in
|
||||
// model.PropertyField.IsPSAv1 is resolved, this extra condition can be removed.
|
||||
if existingField.IsPSAv1() && existingField.ObjectType == "" {
|
||||
// PSAv2 routes only operate on PSAv2 fields. Reject legacy fields.
|
||||
if existingField.IsPSAv1() {
|
||||
c.Err = model.NewAppError("patchPropertyField", "api.property_field.patch.legacy_field.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify ObjectType matches
|
||||
// HTTP-routing: a 404 indistinguishable from "no such field" lets us
|
||||
// bucket fields by URL ObjectType without leaking cross-bucket existence.
|
||||
if existingField.ObjectType != c.Params.ObjectType {
|
||||
c.Err = model.NewAppError("patchPropertyField", "api.property_field.object_type_mismatch.app_error", nil, "", http.StatusBadRequest)
|
||||
c.Err = model.NewAppError("patchPropertyField", "api.property_field.object_type_mismatch.app_error", nil, "", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventPatchPropertyField, model.AuditStatusFail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
model.AddEventParameterAuditableToAuditRec(auditRec, "property_field_patch", patch)
|
||||
auditRec.AddEventPriorState(existingField)
|
||||
|
||||
// Reject update of protected field
|
||||
if existingField.Protected {
|
||||
c.Err = model.NewAppError("patchPropertyField", "api.property_field.update.protected_via_api.app_error", nil, "", http.StatusForbidden)
|
||||
return
|
||||
// Permission branching (session-bound): options-only patches use a
|
||||
// narrower permission than full edits.
|
||||
isOptionsOnly := isOptionsOnlyPatch(patch)
|
||||
if isOptionsOnly && existingField.Type != model.PropertyFieldTypeSelect && existingField.Type != model.PropertyFieldTypeMultiselect {
|
||||
isOptionsOnly = false
|
||||
}
|
||||
|
||||
// Linked field restrictions
|
||||
if existingField.LinkedFieldID != nil && *existingField.LinkedFieldID != "" {
|
||||
if patch.Type != nil {
|
||||
c.Err = model.NewAppError("patchPropertyField", "api.property_field.patch.linked_type_change.app_error", nil, "cannot modify type of a linked field", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if patch.Attrs != nil {
|
||||
if _, hasOpts := (*patch.Attrs)[model.PropertyFieldAttributeOptions]; hasOpts {
|
||||
c.Err = model.NewAppError("patchPropertyField", "api.property_field.patch.linked_options_change.app_error", nil, "cannot modify options of a linked field", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
// LinkedFieldID patch validation: only allow unlink (empty string) or same value (no-op)
|
||||
if patch.LinkedFieldID != nil && *patch.LinkedFieldID != "" && *patch.LinkedFieldID != *existingField.LinkedFieldID {
|
||||
c.Err = model.NewAppError("patchPropertyField", "api.property_field.patch.linked_field_change.app_error", nil, "cannot change link target; unlink first then create a new linked field", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Field is not linked — reject attempts to set a new LinkedFieldID
|
||||
if patch.LinkedFieldID != nil && *patch.LinkedFieldID != "" {
|
||||
c.Err = model.NewAppError("patchPropertyField", "api.property_field.patch.cannot_link_existing.app_error", nil, "linked_field_id can only be set at creation time", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Detect if this is an options-only update
|
||||
isOptionsOnlyUpdate := isOptionsOnlyPatch(patch)
|
||||
|
||||
// Options-only permission path only applies to select/multiselect fields.
|
||||
// For other field types, treat options changes as a field update.
|
||||
if isOptionsOnlyUpdate && existingField.Type != model.PropertyFieldTypeSelect && existingField.Type != model.PropertyFieldTypeMultiselect {
|
||||
isOptionsOnlyUpdate = false
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
if isOptionsOnlyUpdate {
|
||||
if !c.App.SessionHasPermissionToManagePropertyFieldOptions(c.AppContext, *c.AppContext.Session(), existingField) {
|
||||
if isOptionsOnly {
|
||||
if !c.App.SessionHasPermissionToManagePropertyFieldOptions(rctx, *c.AppContext.Session(), existingField) {
|
||||
c.Err = model.NewAppError("patchPropertyField", "api.property_field.update.no_options_permission.app_error", nil, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if !c.App.SessionHasPermissionToEditPropertyField(c.AppContext, *c.AppContext.Session(), existingField) {
|
||||
if !c.App.SessionHasPermissionToEditPropertyField(rctx, *c.AppContext.Session(), existingField) {
|
||||
c.Err = model.NewAppError("patchPropertyField", "api.property_field.update.no_field_permission.app_error", nil, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Apply patch
|
||||
// Capture original state for audit before the in-place patch. Attrs is
|
||||
// shallow-copied because Patch mutates it.
|
||||
orig := *existingField
|
||||
if existingField.Attrs != nil {
|
||||
orig.Attrs = make(model.StringInterface, len(existingField.Attrs))
|
||||
maps.Copy(orig.Attrs, existingField.Attrs)
|
||||
}
|
||||
auditRec.AddEventPriorState(&orig)
|
||||
|
||||
existingField.Patch(patch, true)
|
||||
existingField.UpdatedBy = c.AppContext.Session().UserId
|
||||
|
||||
connectionID := r.Header.Get(model.ConnectionId)
|
||||
|
||||
updatedField, err := c.App.UpdatePropertyField(c.AppContext, groupID, existingField, false, connectionID)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
updatedField, _, updateErr := c.App.UpdatePropertyField(rctx, group.ID, existingField, false, connectionID)
|
||||
if updateErr != nil {
|
||||
c.Err = updateErr
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -426,42 +370,34 @@ func deletePropertyField(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
groupID := group.ID
|
||||
|
||||
// Get existing field
|
||||
existingField, err := c.App.GetPropertyField(c.AppContext, groupID, c.Params.FieldId)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
// Verify ObjectType matches
|
||||
if existingField.ObjectType != c.Params.ObjectType {
|
||||
c.Err = model.NewAppError("deletePropertyField", "api.property_field.object_type_mismatch.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventDeletePropertyField, model.AuditStatusFail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
model.AddEventParameterToAuditRec(auditRec, "field_id", c.Params.FieldId)
|
||||
auditRec.AddEventPriorState(existingField)
|
||||
|
||||
// Reject deletion of protected field
|
||||
if existingField.Protected {
|
||||
c.Err = model.NewAppError("deletePropertyField", "api.property_field.delete.protected_via_api.app_error", nil, "", http.StatusForbidden)
|
||||
rctx := app.RequestContextWithCallerID(c.AppContext, sessionCallerID(c))
|
||||
|
||||
existingField, appErr := c.App.GetPropertyField(rctx, group.ID, c.Params.FieldId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
// Check field edit permission
|
||||
if !c.App.SessionHasPermissionToEditPropertyField(c.AppContext, *c.AppContext.Session(), existingField) {
|
||||
if existingField.ObjectType != c.Params.ObjectType {
|
||||
c.Err = model.NewAppError("deletePropertyField", "api.property_field.object_type_mismatch.app_error", nil, "", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if !c.App.SessionHasPermissionToEditPropertyField(rctx, *c.AppContext.Session(), existingField) {
|
||||
c.Err = model.NewAppError("deletePropertyField", "api.property_field.delete.no_permission.app_error", nil, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
connectionID := r.Header.Get(model.ConnectionId)
|
||||
auditRec.AddEventPriorState(existingField)
|
||||
|
||||
if err := c.App.DeletePropertyField(c.AppContext, groupID, c.Params.FieldId, false, connectionID); err != nil {
|
||||
c.Err = err
|
||||
connectionID := r.Header.Get(model.ConnectionId)
|
||||
if deleteErr := c.App.DeletePropertyField(rctx, group.ID, c.Params.FieldId, false, connectionID); deleteErr != nil {
|
||||
c.Err = deleteErr
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -594,12 +530,6 @@ func patchPropertyValuesCore(c *Context, w http.ResponseWriter, r *http.Request,
|
|||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
groupID := group.ID
|
||||
|
||||
// Check target access based on object type
|
||||
if !hasTargetAccess(c, objectType, targetID, true) {
|
||||
return
|
||||
}
|
||||
|
||||
var items []model.PropertyValuePatchItem
|
||||
if err := json.NewDecoder(r.Body).Decode(&items); err != nil {
|
||||
|
|
@ -607,104 +537,91 @@ func patchPropertyValuesCore(c *Context, w http.ResponseWriter, r *http.Request,
|
|||
return
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
c.Err = model.NewAppError("patchPropertyValues", "api.property_value.patch.empty_body.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(items) > maxPropertyValuePatchItems {
|
||||
c.Err = model.NewAppError("patchPropertyValues", "api.property_value.patch.too_many_items.request_error", map[string]any{
|
||||
"Max": maxPropertyValuePatchItems,
|
||||
}, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Collect and validate field IDs
|
||||
idMap := map[string]bool{}
|
||||
fieldIDs := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if !model.IsValidId(item.FieldID) {
|
||||
c.Err = model.NewAppError("patchPropertyValues", "api.property_value.patch.invalid_field_id.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if idMap[item.FieldID] {
|
||||
c.Err = model.NewAppError("patchPropertyValues", "api.property_value.patch.duplicate_field_id.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
idMap[item.FieldID] = true
|
||||
fieldIDs = append(fieldIDs, item.FieldID)
|
||||
}
|
||||
|
||||
// Load all fields and verify they belong to this group.
|
||||
// GetPropertyFields scopes the lookup by groupID, so fields from
|
||||
// a different group won't be found, causing a mismatch error.
|
||||
fields, err := c.App.GetPropertyFields(c.AppContext, groupID, fieldIDs)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
// Each field's ObjectType must match the route's objectType. Without
|
||||
// this, a caller could reference a field of one type via another
|
||||
// type's route (e.g. a system field via the user-values route),
|
||||
// bypassing the route-level access checks and persisting values whose
|
||||
// TargetType disagrees with field.ObjectType. Templates are always
|
||||
// rejected because objectType is never "template" on these routes;
|
||||
// keep a dedicated error for that case so the cause is clear.
|
||||
for _, f := range fields {
|
||||
if f.ObjectType == model.PropertyFieldObjectTypeTemplate {
|
||||
c.Err = model.NewAppError("patchPropertyValues", "api.property_value.template_no_values.app_error", nil, "template fields cannot have values", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if f.ObjectType != objectType {
|
||||
c.Err = model.NewAppError("patchPropertyValues", "api.property_value.field_object_type_mismatch.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Build field map for permission checks
|
||||
fieldMap := make(map[string]*model.PropertyField, len(fields))
|
||||
for _, f := range fields {
|
||||
fieldMap[f.ID] = f
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventPatchPropertyValues, model.AuditStatusFail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
model.AddEventParameterToAuditRec(auditRec, "group_name", c.Params.GroupName)
|
||||
model.AddEventParameterToAuditRec(auditRec, "object_type", objectType)
|
||||
model.AddEventParameterToAuditRec(auditRec, "target_id", targetID)
|
||||
|
||||
// Check values permission on each field (all-or-nothing)
|
||||
rctx := app.RequestContextWithCallerID(c.AppContext, sessionCallerID(c))
|
||||
|
||||
if !hasTargetAccess(c, objectType, targetID, true) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
c.Err = model.NewAppError("patchPropertyValues", "api.property_value.patch.empty_body.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(items) > maxPropertyValuePatchItems {
|
||||
c.Err = model.NewAppError("patchPropertyValues", "api.property_value.patch.too_many_items.request_error", map[string]any{
|
||||
"Max": maxPropertyValuePatchItems,
|
||||
}, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Pre-validate IDs and de-dup so we can bulk-load fields for the
|
||||
// session-bound permission check below. The App layer re-validates these
|
||||
// invariants (defense for plugin/internal callers).
|
||||
seen := map[string]bool{}
|
||||
fieldIDs := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
field := fieldMap[item.FieldID]
|
||||
if !c.App.SessionHasPermissionToSetPropertyFieldValues(c.AppContext, *c.AppContext.Session(), field) {
|
||||
if !model.IsValidId(item.FieldID) {
|
||||
c.Err = model.NewAppError("patchPropertyValues", "api.property_value.patch.invalid_field_id.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if seen[item.FieldID] {
|
||||
c.Err = model.NewAppError("patchPropertyValues", "api.property_value.patch.duplicate_field_id.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
seen[item.FieldID] = true
|
||||
fieldIDs = append(fieldIDs, item.FieldID)
|
||||
}
|
||||
|
||||
fields, fieldsErr := c.App.GetPropertyFields(rctx, group.ID, fieldIDs)
|
||||
if fieldsErr != nil {
|
||||
c.Err = fieldsErr
|
||||
return
|
||||
}
|
||||
fieldByID := make(map[string]*model.PropertyField, len(fields))
|
||||
for _, f := range fields {
|
||||
fieldByID[f.ID] = f
|
||||
}
|
||||
for _, item := range items {
|
||||
f, ok := fieldByID[item.FieldID]
|
||||
if !ok {
|
||||
c.Err = model.NewAppError("patchPropertyValues", "api.property_value.patch.field_not_found.app_error",
|
||||
map[string]any{"FieldID": item.FieldID}, "", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if f.ObjectType != objectType {
|
||||
c.Err = model.NewAppError("patchPropertyValues", "api.property_field.object_type_mismatch.app_error", nil, "", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if !c.App.SessionHasPermissionToSetPropertyFieldValues(rctx, *c.AppContext.Session(), f, targetID) {
|
||||
c.Err = model.NewAppError("patchPropertyValues", "api.property_value.patch.no_values_permission.app_error", nil, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Build PropertyValue objects for upsert
|
||||
userID := c.AppContext.Session().UserId
|
||||
values := make([]*model.PropertyValue, len(items))
|
||||
for i, item := range items {
|
||||
values[i] = &model.PropertyValue{
|
||||
TargetID: targetID,
|
||||
// in PSAv2, values always point to entities of the same
|
||||
// type that their field.ObjectType
|
||||
TargetID: targetID,
|
||||
TargetType: objectType,
|
||||
GroupID: groupID,
|
||||
GroupID: group.ID,
|
||||
FieldID: item.FieldID,
|
||||
Value: item.Value,
|
||||
CreatedBy: userID,
|
||||
UpdatedBy: userID,
|
||||
}
|
||||
}
|
||||
|
||||
connectionID := r.Header.Get(model.ConnectionId)
|
||||
|
||||
upserted, err := c.App.UpsertPropertyValues(c.AppContext, values, objectType, targetID, connectionID)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
upserted, upsertErr := c.App.UpsertPropertyValues(rctx, values, objectType, targetID, connectionID)
|
||||
if upsertErr != nil {
|
||||
c.Err = upsertErr
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -767,11 +684,25 @@ func hasTargetAccess(c *Context, objectType, targetID string, write bool) bool {
|
|||
return false
|
||||
}
|
||||
case model.PropertyFieldObjectTypeUser:
|
||||
// Any authenticated user can read another user's property values.
|
||||
// Only the user themselves or a system admin can write values.
|
||||
if write && targetID != c.AppContext.Session().UserId {
|
||||
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
|
||||
c.Err = model.NewAppError("hasTargetAccess", "api.property_value.target_user.forbidden.app_error", nil, "", http.StatusForbidden)
|
||||
// Self-access and unrestricted sessions (local mode) always pass.
|
||||
if targetID == c.AppContext.Session().UserId || c.AppContext.Session().IsUnrestricted() {
|
||||
return true
|
||||
}
|
||||
if write {
|
||||
// Writing another user's values requires PermissionEditOtherUsers.
|
||||
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionEditOtherUsers) {
|
||||
c.SetPermissionError(model.PermissionEditOtherUsers)
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
// Reading another user's values requires being able to see them.
|
||||
canSee, appErr := c.App.UserCanSeeOtherUser(c.AppContext, c.AppContext.Session().UserId, targetID)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return false
|
||||
}
|
||||
if !canSee {
|
||||
c.SetPermissionError(model.PermissionViewMembers)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -794,6 +725,18 @@ func hasTargetAccess(c *Context, objectType, targetID string, write bool) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
// sessionCallerID returns the caller ID to attach to a request-derived rctx
|
||||
// for property-service hook identification. Local-mode (unrestricted)
|
||||
// sessions have an empty Session.UserId but full admin privileges, so they
|
||||
// are tagged with CallerIDLocalAdmin instead.
|
||||
func sessionCallerID(c *Context) string {
|
||||
session := c.AppContext.Session()
|
||||
if session.IsUnrestricted() {
|
||||
return model.CallerIDLocalAdmin
|
||||
}
|
||||
return session.UserId
|
||||
}
|
||||
|
||||
// isOptionsOnlyPatch checks if the patch only modifies the options attribute.
|
||||
// Returns true if the only change is to attrs.options.
|
||||
func isOptionsOnlyPatch(patch *model.PropertyFieldPatch) bool {
|
||||
|
|
|
|||
|
|
@ -163,6 +163,30 @@ func TestCreatePropertyField(t *testing.T) {
|
|||
require.Equal(t, model.PermissionLevelSysadmin, *createdField.PermissionOptions)
|
||||
})
|
||||
|
||||
t.Run("admin can set permission level=admin on a channel-target field", func(t *testing.T) {
|
||||
adminLevel := model.PermissionLevelAdmin
|
||||
field := &model.PropertyField{
|
||||
Name: model.NewId(),
|
||||
Type: model.PropertyFieldTypeText,
|
||||
TargetType: "channel",
|
||||
TargetID: th.BasicChannel.Id,
|
||||
PermissionField: &adminLevel,
|
||||
PermissionValues: &adminLevel,
|
||||
PermissionOptions: &adminLevel,
|
||||
}
|
||||
|
||||
createdField, resp, err := th.SystemAdminClient.CreatePropertyField(context.Background(), group.Name, "post", field)
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, resp)
|
||||
|
||||
require.NotNil(t, createdField.PermissionField)
|
||||
require.Equal(t, model.PermissionLevelAdmin, *createdField.PermissionField)
|
||||
require.NotNil(t, createdField.PermissionValues)
|
||||
require.Equal(t, model.PermissionLevelAdmin, *createdField.PermissionValues)
|
||||
require.NotNil(t, createdField.PermissionOptions)
|
||||
require.Equal(t, model.PermissionLevelAdmin, *createdField.PermissionOptions)
|
||||
})
|
||||
|
||||
t.Run("invalid group name should fail", func(t *testing.T) {
|
||||
th.LoginBasic(t)
|
||||
|
||||
|
|
@ -901,13 +925,14 @@ func TestPatchPropertyField(t *testing.T) {
|
|||
newName := model.NewId()
|
||||
patch := &model.PropertyFieldPatch{Name: &newName}
|
||||
|
||||
// Try to update with wrong object_type in URL
|
||||
// Try to update with wrong object_type in URL. Expected 404 to match
|
||||
// the shape of a non-existent field.
|
||||
_, resp, err := th.SystemAdminClient.PatchPropertyField(context.Background(), group.Name, "channel", createdField.ID, patch)
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, resp)
|
||||
CheckNotFoundStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("patch with wrong group name should fail", func(t *testing.T) {
|
||||
t.Run("patch with wrong group name should fail 404", func(t *testing.T) {
|
||||
field := &model.PropertyField{
|
||||
Name: model.NewId(),
|
||||
Type: model.PropertyFieldTypeText,
|
||||
|
|
@ -924,11 +949,12 @@ func TestPatchPropertyField(t *testing.T) {
|
|||
newName := model.NewId()
|
||||
patch := &model.PropertyFieldPatch{Name: &newName}
|
||||
|
||||
// Try to patch using the other group's name — field belongs to `group`, not `otherGroup`
|
||||
// Try to patch using the other group's name — field belongs to `group`, not `otherGroup`.
|
||||
// A field not found because of a wrong group must surface as 404, not a generic 500.
|
||||
_, resp, err := th.SystemAdminClient.PatchPropertyField(context.Background(), otherGroup.Name, "post", createdField.ID, patch)
|
||||
require.Error(t, err)
|
||||
// GetPropertyField with the wrong groupID should not find the field
|
||||
require.NotEqual(t, http.StatusOK, resp.StatusCode)
|
||||
CheckNotFoundStatus(t, resp)
|
||||
require.Equal(t, "app.property.not_found.app_error", err.(*model.AppError).Id)
|
||||
})
|
||||
|
||||
t.Run("options-only update should check options permission", func(t *testing.T) {
|
||||
|
|
@ -1435,13 +1461,14 @@ func TestDeletePropertyField(t *testing.T) {
|
|||
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Try to delete with wrong object_type in URL
|
||||
// Try to delete with wrong object_type in URL. Expected 404 to match
|
||||
// the shape of a non-existent field.
|
||||
resp, err := th.SystemAdminClient.DeletePropertyField(context.Background(), group.Name, "channel", createdField.ID)
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, resp)
|
||||
CheckNotFoundStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("delete with wrong group name should fail", func(t *testing.T) {
|
||||
t.Run("delete with wrong group name should fail 404", func(t *testing.T) {
|
||||
field := &model.PropertyField{
|
||||
Name: model.NewId(),
|
||||
Type: model.PropertyFieldTypeText,
|
||||
|
|
@ -1455,12 +1482,13 @@ func TestDeletePropertyField(t *testing.T) {
|
|||
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Try to delete using the other group's name — field belongs to `group`, not `otherGroup`
|
||||
th.LoginBasic(t)
|
||||
resp, err := th.Client.DeletePropertyField(context.Background(), otherGroup.Name, "post", createdField.ID)
|
||||
// Try to delete using the other group's name — field belongs to `group`, not `otherGroup`.
|
||||
// A field not found because of a wrong group must surface as 404, not a generic 500.
|
||||
th.LoginSystemAdmin(t)
|
||||
resp, err := th.SystemAdminClient.DeletePropertyField(context.Background(), otherGroup.Name, "post", createdField.ID)
|
||||
require.Error(t, err)
|
||||
// GetPropertyField with the wrong groupID should not find the field
|
||||
require.NotEqual(t, http.StatusOK, resp.StatusCode)
|
||||
CheckNotFoundStatus(t, resp)
|
||||
require.Equal(t, "app.property.not_found.app_error", err.(*model.AppError).Id)
|
||||
})
|
||||
|
||||
t.Run("user without permission should not be able to delete", func(t *testing.T) {
|
||||
|
|
@ -1824,7 +1852,7 @@ func TestPatchPropertyValues(t *testing.T) {
|
|||
createdNoneField, appErr := th.App.CreatePropertyField(th.Context, noneField, false, "")
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Use a real post as the target so target access checks pass
|
||||
// Use a real post as the target so target access checks pass.
|
||||
targetID := th.BasicPost.Id
|
||||
|
||||
t.Run("unauthenticated request should fail", func(t *testing.T) {
|
||||
|
|
@ -1978,7 +2006,7 @@ func TestPatchPropertyValues(t *testing.T) {
|
|||
}
|
||||
_, resp, patchErr := th.Client.PatchPropertyValues(context.Background(), group.Name, "post", targetID, items)
|
||||
require.Error(t, patchErr)
|
||||
CheckBadRequestStatus(t, resp)
|
||||
CheckNotFoundStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("nonexistent group should fail", func(t *testing.T) {
|
||||
|
|
@ -1992,6 +2020,35 @@ func TestPatchPropertyValues(t *testing.T) {
|
|||
CheckNotFoundStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("field with mismatched object type should fail 404", func(t *testing.T) {
|
||||
// A field in the same group but scoped to a different ObjectType must not
|
||||
// be patchable through the URL of a peer ObjectType; the mismatch collapses
|
||||
// to 404 so callers cannot distinguish "no such field" from "field exists
|
||||
// but in a different object-type bucket".
|
||||
userField := &model.PropertyField{
|
||||
Name: model.NewId(),
|
||||
Type: model.PropertyFieldTypeText,
|
||||
GroupID: group.ID,
|
||||
ObjectType: "user",
|
||||
TargetType: "system",
|
||||
PermissionField: &memberLevel,
|
||||
PermissionValues: &memberLevel,
|
||||
PermissionOptions: &memberLevel,
|
||||
}
|
||||
createdUserField, appErr := th.App.CreatePropertyField(th.Context, userField, false, "")
|
||||
require.Nil(t, appErr)
|
||||
|
||||
th.LoginSystemAdmin(t)
|
||||
|
||||
items := []model.PropertyValuePatchItem{
|
||||
{FieldID: createdUserField.ID, Value: json.RawMessage(`"test"`)},
|
||||
}
|
||||
_, resp, err := th.SystemAdminClient.PatchPropertyValues(context.Background(), group.Name, "post", targetID, items)
|
||||
require.Error(t, err)
|
||||
CheckNotFoundStatus(t, resp)
|
||||
require.Equal(t, "api.property_field.object_type_mismatch.app_error", err.(*model.AppError).Id)
|
||||
})
|
||||
|
||||
t.Run("channel member can set values on channel-scoped field with values permission member", func(t *testing.T) {
|
||||
th.LoginBasic(t)
|
||||
|
||||
|
|
@ -2020,8 +2077,7 @@ func TestPatchPropertyValues(t *testing.T) {
|
|||
require.Equal(t, json.RawMessage(`"channel-value"`), values[0].Value)
|
||||
})
|
||||
|
||||
t.Run("non-member cannot set values on channel-scoped field with values permission member", func(t *testing.T) {
|
||||
// Create a channel that BasicUser is NOT a member of
|
||||
t.Run("non-member cannot set values on post in a channel they don't belong to", func(t *testing.T) {
|
||||
privateChannel, chanErr := th.App.CreateChannel(th.Context, &model.Channel{
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Type: model.ChannelTypePrivate,
|
||||
|
|
@ -2031,26 +2087,14 @@ func TestPatchPropertyValues(t *testing.T) {
|
|||
}, false)
|
||||
require.Nil(t, chanErr)
|
||||
|
||||
channelField := &model.PropertyField{
|
||||
Name: model.NewId(),
|
||||
Type: model.PropertyFieldTypeText,
|
||||
GroupID: group.ID,
|
||||
ObjectType: "post",
|
||||
TargetType: "channel",
|
||||
TargetID: privateChannel.Id,
|
||||
PermissionField: &memberLevel,
|
||||
PermissionValues: &memberLevel,
|
||||
PermissionOptions: &memberLevel,
|
||||
}
|
||||
createdChannelField, fieldErr := th.App.CreatePropertyField(th.Context, channelField, false, "")
|
||||
require.Nil(t, fieldErr)
|
||||
// Create a post in the private channel as SystemAdmin (BasicUser is not a member).
|
||||
privatePost := th.CreatePostWithClient(t, th.SystemAdminClient, privateChannel)
|
||||
|
||||
th.LoginBasic(t)
|
||||
|
||||
items := []model.PropertyValuePatchItem{
|
||||
{FieldID: createdChannelField.ID, Value: json.RawMessage(`"should-fail"`)},
|
||||
{FieldID: createdMemberField.ID, Value: json.RawMessage(`"should-fail"`)},
|
||||
}
|
||||
_, resp, err := th.Client.PatchPropertyValues(context.Background(), group.Name, "post", targetID, items)
|
||||
_, resp, err := th.Client.PatchPropertyValues(context.Background(), group.Name, "post", privatePost.Id, items)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
|
|
@ -2246,6 +2290,23 @@ func TestGetPropertyValuesUserTargetAccess(t *testing.T) {
|
|||
CheckOKStatus(t, resp)
|
||||
require.NotEmpty(t, values)
|
||||
})
|
||||
|
||||
t.Run("non-admin cannot get values of a user they cannot see", func(t *testing.T) {
|
||||
// Strip system-wide view_members so UserCanSeeOtherUser falls back to team/channel membership.
|
||||
th.RemovePermissionFromRole(t, model.PermissionViewMembers.Id, model.SystemUserRoleId)
|
||||
defer th.AddPermissionToRole(t, model.PermissionViewMembers.Id, model.SystemUserRoleId)
|
||||
|
||||
// Drop BasicUser2 from BasicTeam so they no longer share a team with BasicUser.
|
||||
resp, err := th.SystemAdminClient.RemoveTeamMember(context.Background(), th.BasicTeam.Id, th.BasicUser2.Id)
|
||||
CheckOKStatus(t, resp)
|
||||
require.NoError(t, err)
|
||||
|
||||
th.LoginBasic2(t)
|
||||
|
||||
_, resp, err = th.Client.GetPropertyValues(context.Background(), group.Name, "user", th.BasicUser.Id, model.PropertyValueSearch{PerPage: 60})
|
||||
CheckForbiddenStatus(t, resp)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPatchPropertyValuesUserTargetAccess(t *testing.T) {
|
||||
|
|
@ -3353,7 +3414,9 @@ func TestSystemObjectType(t *testing.T) {
|
|||
}
|
||||
_, resp, patchErr := th.SystemAdminClient.PatchSystemPropertyValues(context.Background(), group.Name, items)
|
||||
require.Error(t, patchErr)
|
||||
CheckBadRequestStatus(t, resp)
|
||||
// Mismatch (template field ObjectType != system route's objectType)
|
||||
// collapses to 404 to match the executePatchPropertyField shape.
|
||||
CheckNotFoundStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("system field round-trips a value via the dedicated route", func(t *testing.T) {
|
||||
|
|
@ -3502,10 +3565,11 @@ func TestSystemObjectType(t *testing.T) {
|
|||
{FieldID: systemField.ID, Value: json.RawMessage(`"smuggled"`)},
|
||||
}
|
||||
// Even sysadmin should be rejected — this is a structural check on
|
||||
// the route, not a permission check.
|
||||
// the route, not a permission check. Mismatch collapses to 404 to
|
||||
// match the executePatchPropertyField/executeDeletePropertyField shape.
|
||||
_, resp, patchErr := th.SystemAdminClient.PatchPropertyValues(context.Background(), group.Name, model.PropertyFieldObjectTypeUser, th.SystemAdminUser.Id, items)
|
||||
require.Error(t, patchErr)
|
||||
CheckBadRequestStatus(t, resp)
|
||||
CheckNotFoundStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("system values PATCH route rejects body referencing a non-system field ID", func(t *testing.T) {
|
||||
|
|
@ -3531,6 +3595,6 @@ func TestSystemObjectType(t *testing.T) {
|
|||
}
|
||||
_, resp, patchErr := th.SystemAdminClient.PatchSystemPropertyValues(context.Background(), group.Name, items)
|
||||
require.Error(t, patchErr)
|
||||
CheckBadRequestStatus(t, resp)
|
||||
CheckNotFoundStatus(t, resp)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -274,7 +274,7 @@ func getSharedChannelRemotes(c *Context, w http.ResponseWriter, r *http.Request)
|
|||
if errors.Is(err, model.ErrChannelNotShared) {
|
||||
remoteStatuses = []*model.SharedChannelRemoteStatus{}
|
||||
} else {
|
||||
c.Err = model.NewAppError("getSharedChannelRemotes", "api.command_share.fetch_remote_status.error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
c.Err = model.NewAppError("getSharedChannelRemotes", "api.command_share.fetch_remote_status.error", map[string]any{"Error": err.Error()}, "", http.StatusInternalServerError).Wrap(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ func TestGetSharedChannelRemotes(t *testing.T) {
|
|||
url := fmt.Sprintf("/sharedchannels/%s/remotes", channel1.Id)
|
||||
resp, err := th.Client.DoAPIGet(context.Background(), url, "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result []*model.RemoteClusterInfo
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
|
@ -147,3 +148,46 @@ func TestGetSharedChannelRemotes_ReturnsEmptyForNonSharedChannel(t *testing.T) {
|
|||
require.NotNil(t, result)
|
||||
require.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestGetSharedChannelRemotes_ReturnsEmptyForStaleSharedChannelState(t *testing.T) {
|
||||
th := setupForSharedChannels(t).InitBasic(t)
|
||||
|
||||
channel := th.CreateChannelWithClientAndTeam(t, th.Client, model.ChannelTypeOpen, th.BasicTeam.Id)
|
||||
require.NoError(t, th.App.Srv().Store().Channel().SetShared(channel.Id, true))
|
||||
|
||||
remote, appErr := th.App.AddRemoteCluster(&model.RemoteCluster{
|
||||
Name: "stale-remote",
|
||||
DisplayName: "Stale Remote",
|
||||
SiteURL: "http://stale.example.com",
|
||||
CreatorId: th.BasicUser.Id,
|
||||
Token: model.NewId(),
|
||||
LastPingAt: model.GetMillis(),
|
||||
})
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Simulate stale DB state: the channel/remote rows remain, but the SharedChannels row is missing.
|
||||
_, err := th.App.Srv().Store().SharedChannel().SaveRemote(&model.SharedChannelRemote{
|
||||
ChannelId: channel.Id,
|
||||
RemoteId: remote.RemoteId,
|
||||
CreatorId: th.BasicUser.Id,
|
||||
IsInviteAccepted: true,
|
||||
IsInviteConfirmed: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
hasRemote, err := th.App.Srv().Store().SharedChannel().HasRemote(channel.Id, remote.RemoteId)
|
||||
require.NoError(t, err)
|
||||
require.True(t, hasRemote)
|
||||
|
||||
url := fmt.Sprintf("/sharedchannels/%s/remotes", channel.Id)
|
||||
resp, err := th.Client.DoAPIGet(context.Background(), url, "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result []*model.RemoteClusterInfo
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, result)
|
||||
require.Empty(t, result)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -417,20 +417,47 @@ func TestGetLogs(t *testing.T) {
|
|||
mainHelper.Parallel(t)
|
||||
th := Setup(t)
|
||||
|
||||
testID := model.NewId()
|
||||
expectedMessages := make([]string, 0, 20)
|
||||
for i := range 20 {
|
||||
th.TestLogger.Info(strconv.Itoa(i))
|
||||
message := fmt.Sprintf("getlogs_verify_%s_%d", testID, i)
|
||||
expectedMessages = append(expectedMessages, message)
|
||||
th.TestLogger.Info(message)
|
||||
}
|
||||
|
||||
err := th.TestLogger.Flush()
|
||||
require.NoError(t, err, "failed to flush log")
|
||||
|
||||
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
|
||||
logs, _, err2 := c.GetLogs(context.Background(), 0, 10)
|
||||
require.NoError(t, err2)
|
||||
require.Len(t, logs, 10)
|
||||
var logs []string
|
||||
containsLogMessage := func(logs []string, expected string) bool {
|
||||
for _, logLine := range logs {
|
||||
if strings.Contains(logLine, expected) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
containsExpectedMessages := func(logs []string) bool {
|
||||
for _, expected := range expectedMessages {
|
||||
if !containsLogMessage(logs, expected) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
for i := 10; i < 20; i++ {
|
||||
assert.Containsf(t, logs[i-10], fmt.Sprintf(`"msg":"%d"`, i), "Log line doesn't contain correct message")
|
||||
require.Eventually(t, func() bool {
|
||||
logs, _, err = c.GetLogs(context.Background(), 0, 200)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return containsExpectedMessages(logs)
|
||||
}, 5*time.Second, 25*time.Millisecond)
|
||||
|
||||
for _, expected := range expectedMessages {
|
||||
assert.Truef(t, containsLogMessage(logs, expected), "Log lines don't contain %q", expected)
|
||||
}
|
||||
|
||||
logs, _, err = c.GetLogs(context.Background(), 1, 10)
|
||||
|
|
@ -678,12 +705,12 @@ func TestS3TestConnection(t *testing.T) {
|
|||
config.FileSettings.AmazonS3Bucket = new("Wrong_bucket")
|
||||
resp, err = th.SystemAdminClient.TestS3Connection(context.Background(), &config)
|
||||
CheckInternalErrorStatus(t, resp)
|
||||
CheckErrorID(t, err, "api.file.test_connection_s3_bucket_does_not_exist.app_error")
|
||||
CheckErrorID(t, err, "api.file.test_connection_no_bucket.app_error")
|
||||
|
||||
*config.FileSettings.AmazonS3Bucket = "shouldnotcreatenewbucket"
|
||||
resp, err = th.SystemAdminClient.TestS3Connection(context.Background(), &config)
|
||||
CheckInternalErrorStatus(t, resp)
|
||||
CheckErrorID(t, err, "api.file.test_connection_s3_bucket_does_not_exist.app_error")
|
||||
CheckErrorID(t, err, "api.file.test_connection_no_bucket.app_error")
|
||||
})
|
||||
|
||||
t.Run("with incorrect credentials", func(t *testing.T) {
|
||||
|
|
@ -691,7 +718,7 @@ func TestS3TestConnection(t *testing.T) {
|
|||
*configCopy.FileSettings.AmazonS3AccessKeyId = "invalidaccesskey"
|
||||
resp, err := th.SystemAdminClient.TestS3Connection(context.Background(), &configCopy)
|
||||
CheckInternalErrorStatus(t, resp)
|
||||
CheckErrorID(t, err, "api.file.test_connection_s3_auth.app_error")
|
||||
CheckErrorID(t, err, "api.file.test_connection_auth.app_error")
|
||||
})
|
||||
|
||||
t.Run("empty file settings", func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -1243,12 +1243,24 @@ func TestPatchTeam(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, r)
|
||||
|
||||
// Wait for auto-add to place the group member on the team before toggling group constraint.
|
||||
require.Eventually(t, func() bool {
|
||||
tm, resp, getErr := th.SystemAdminClient.GetTeamMember(context.Background(), team2.Id, groupUser.Id, "")
|
||||
return getErr == nil && resp.StatusCode == http.StatusOK && tm.UserId == groupUser.Id && tm.DeleteAt == 0
|
||||
}, 5*time.Second, 100*time.Millisecond, "timed out waiting for group user to be added to the team")
|
||||
|
||||
patch := &model.TeamPatch{}
|
||||
patch.GroupConstrained = new(true)
|
||||
_, r, err = th.SystemAdminClient.PatchTeam(context.Background(), team2.Id, patch)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, r)
|
||||
|
||||
// PatchTeam kicks off async membership cleanup in a goroutine; wait for it to settle.
|
||||
require.Eventually(t, func() bool {
|
||||
tm, resp, getErr := th.SystemAdminClient.GetTeamMember(context.Background(), team2.Id, groupUser.Id, "")
|
||||
return getErr == nil && resp.StatusCode == http.StatusOK && tm.UserId == groupUser.Id && tm.DeleteAt == 0
|
||||
}, 10*time.Second, 100*time.Millisecond, "timed out waiting for group-constrained membership cleanup to finish")
|
||||
|
||||
patch.GroupConstrained = new(false)
|
||||
_, r, err = th.SystemAdminClient.PatchTeam(context.Background(), team2.Id, patch)
|
||||
require.NoError(t, err)
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ func (api *API) InitUser() {
|
|||
|
||||
api.BaseRoutes.UserByUsername.Handle("", api.APISessionRequired(getUserByUsername)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.UserByEmail.Handle("", api.APISessionRequired(getUserByEmail)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.Users.Handle("/auth_data", api.APISessionRequired(getUserByAuthData)).Methods(http.MethodGet)
|
||||
|
||||
api.BaseRoutes.User.Handle("/sessions", api.APISessionRequired(getSessions)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.User.Handle("/sessions/revoke", api.APISessionRequired(revokeSession)).Methods(http.MethodPost)
|
||||
|
|
@ -461,6 +462,61 @@ func getUserByEmail(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
func getUserByAuthData(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
if !c.IsSystemAdmin() {
|
||||
c.SetPermissionError(model.PermissionManageSystem)
|
||||
return
|
||||
}
|
||||
authData := r.URL.Query().Get("value")
|
||||
if authData == "" {
|
||||
c.SetInvalidParam("value")
|
||||
return
|
||||
}
|
||||
if len(authData) > model.UserAuthDataMaxLength {
|
||||
c.SetInvalidParam("value")
|
||||
return
|
||||
}
|
||||
user, err := c.App.GetUserByAuthData(&authData)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
canSee, err2 := c.App.UserCanSeeOtherUser(c.AppContext, c.AppContext.Session().UserId, user.Id)
|
||||
if err2 != nil {
|
||||
c.Err = err2
|
||||
return
|
||||
}
|
||||
|
||||
if !canSee {
|
||||
c.SetPermissionError(model.PermissionViewMembers)
|
||||
return
|
||||
}
|
||||
|
||||
userTermsOfService, err := c.App.GetUserTermsOfService(user.Id)
|
||||
if err != nil && err.StatusCode != http.StatusNotFound {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
if userTermsOfService != nil {
|
||||
user.TermsOfServiceId = userTermsOfService.TermsOfServiceId
|
||||
user.TermsOfServiceCreateAt = userTermsOfService.CreateAt
|
||||
}
|
||||
|
||||
etag := user.Etag(*c.App.Config().PrivacySettings.ShowFullName, *c.App.Config().PrivacySettings.ShowEmailAddress)
|
||||
|
||||
if c.HandleEtag(etag, "Get User", w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
c.App.SanitizeProfile(user, c.IsSystemAdmin())
|
||||
w.Header().Set(model.HeaderEtagServer, etag)
|
||||
if jerr := json.NewEncoder(w).Encode(user); jerr != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(jerr))
|
||||
}
|
||||
}
|
||||
|
||||
func getDefaultProfileImage(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireUserId()
|
||||
if c.Err != nil {
|
||||
|
|
@ -1899,6 +1955,8 @@ func updatePassword(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
if user.IsSystemAdmin() {
|
||||
canUpdatePassword = c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
||||
} else if user.IsBot {
|
||||
canUpdatePassword = c.App.SessionHasPermissionToManageBot(c.AppContext, *c.AppContext.Session(), c.Params.UserId) == nil
|
||||
} else {
|
||||
canUpdatePassword = c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteUserManagementUsers)
|
||||
}
|
||||
|
|
@ -2471,7 +2529,7 @@ func revokeSession(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
auditRec := c.MakeAuditRecord(model.AuditEventRevokeSession, model.AuditStatusFail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
|
||||
if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) {
|
||||
if !c.App.SessionHasPermissionToUserOrBot(c.AppContext, *c.AppContext.Session(), c.Params.UserId) {
|
||||
c.SetPermissionError(model.PermissionEditOtherUsers)
|
||||
return
|
||||
}
|
||||
|
|
@ -2519,7 +2577,7 @@ func revokeAllSessionsForUser(c *Context, w http.ResponseWriter, r *http.Request
|
|||
defer c.LogAuditRec(auditRec)
|
||||
model.AddEventParameterToAuditRec(auditRec, "user_id", c.Params.UserId)
|
||||
|
||||
if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) {
|
||||
if !c.App.SessionHasPermissionToUserOrBot(c.AppContext, *c.AppContext.Session(), c.Params.UserId) {
|
||||
c.SetPermissionError(model.PermissionEditOtherUsers)
|
||||
return
|
||||
}
|
||||
|
|
@ -2844,6 +2902,10 @@ func createUserAccessToken(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
accessToken.UserId = c.Params.UserId
|
||||
accessToken.Token = ""
|
||||
// TODO: remove once the API officially supports setting expires_at; until
|
||||
// then, strip any client-supplied value so that JSON-decoded requests cannot
|
||||
// set an arbitrary (or zero) expiry through the create-token endpoint.
|
||||
accessToken.ExpiresAt = 0
|
||||
|
||||
token, err := c.App.CreateUserAccessToken(c.AppContext, &accessToken)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ func (api *API) InitUserLocal() {
|
|||
|
||||
api.BaseRoutes.UserByUsername.Handle("", api.APILocal(localGetUserByUsername)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.UserByEmail.Handle("", api.APILocal(localGetUserByEmail)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.Users.Handle("/auth_data", api.APILocal(localGetUserByAuthData)).Methods(http.MethodGet)
|
||||
|
||||
api.BaseRoutes.Users.Handle("/tokens/revoke", api.APILocal(revokeUserAccessToken)).Methods(http.MethodPost)
|
||||
api.BaseRoutes.User.Handle("/tokens", api.APILocal(getUserAccessTokensForUser)).Methods(http.MethodGet)
|
||||
|
|
@ -427,6 +428,46 @@ func localGetUserByEmail(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
func localGetUserByAuthData(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
authData := r.URL.Query().Get("value")
|
||||
if authData == "" {
|
||||
c.SetInvalidParam("value")
|
||||
return
|
||||
}
|
||||
if len(authData) > model.UserAuthDataMaxLength {
|
||||
c.SetInvalidParam("value")
|
||||
return
|
||||
}
|
||||
user, err := c.App.GetUserByAuthData(&authData)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
userTermsOfService, err := c.App.GetUserTermsOfService(user.Id)
|
||||
if err != nil && err.StatusCode != http.StatusNotFound {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
if userTermsOfService != nil {
|
||||
user.TermsOfServiceId = userTermsOfService.TermsOfServiceId
|
||||
user.TermsOfServiceCreateAt = userTermsOfService.CreateAt
|
||||
}
|
||||
|
||||
etag := user.Etag(*c.App.Config().PrivacySettings.ShowFullName, *c.App.Config().PrivacySettings.ShowEmailAddress)
|
||||
|
||||
if c.HandleEtag(etag, "Get User", w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
c.App.SanitizeProfile(user, c.IsSystemAdmin())
|
||||
w.Header().Set(model.HeaderEtagServer, etag)
|
||||
if jerr := json.NewEncoder(w).Encode(user); jerr != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(jerr))
|
||||
}
|
||||
}
|
||||
|
||||
func localGetUploadsForUser(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
uss, appErr := c.App.GetUploadSessionsForUser(c.Params.UserId)
|
||||
if appErr != nil {
|
||||
|
|
|
|||
|
|
@ -1487,6 +1487,169 @@ func TestGetUserByEmail(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestGetUserByAuthData(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t)
|
||||
|
||||
team := th.CreateTeamWithClient(t, th.SystemAdminClient)
|
||||
regularUser := th.CreateUser(t)
|
||||
th.LinkUserToTeam(t, regularUser, team)
|
||||
user := th.CreateUser(t)
|
||||
th.LinkUserToTeam(t, user, team)
|
||||
_, err := th.App.Srv().Store().User().VerifyEmail(user.Id, user.Email)
|
||||
require.NoError(t, err)
|
||||
|
||||
authID := "extid-" + model.NewId()
|
||||
userAuth := &model.UserAuth{
|
||||
AuthData: model.NewPointer(authID),
|
||||
AuthService: model.UserAuthServiceSaml,
|
||||
}
|
||||
_, _, err = th.SystemAdminClient.UpdateUserAuth(context.Background(), user.Id, userAuth)
|
||||
require.NoError(t, err)
|
||||
|
||||
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
|
||||
t.Run("returns user and auth fields for system admin and local", func(t *testing.T) {
|
||||
ruser, resp, getErr := client.GetUserByAuthData(context.Background(), authID, "")
|
||||
require.NoError(t, getErr)
|
||||
require.Equal(t, user.Id, ruser.Id)
|
||||
require.NotNil(t, ruser.AuthData)
|
||||
require.Equal(t, authID, *ruser.AuthData)
|
||||
require.Equal(t, model.UserAuthServiceSaml, ruser.AuthService)
|
||||
ruser, resp, _ = client.GetUserByAuthData(context.Background(), authID, resp.Etag)
|
||||
CheckEtag(t, ruser, resp)
|
||||
})
|
||||
|
||||
t.Run("not found returns not found", func(t *testing.T) {
|
||||
_, resp, notFoundErr := client.GetUserByAuthData(context.Background(), "nope-"+model.NewId(), "")
|
||||
require.Error(t, notFoundErr)
|
||||
CheckNotFoundStatus(t, resp)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("returns accepted terms of service for system admin", func(t *testing.T) {
|
||||
tos, appErr := th.App.CreateTermsOfService("Dummy TOS", user.Id)
|
||||
require.Nil(t, appErr)
|
||||
appErr = th.App.SaveUserTermsOfService(user.Id, tos.Id, true)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
ruser, _, getErr := th.SystemAdminClient.GetUserByAuthData(context.Background(), authID, "")
|
||||
require.NoError(t, getErr)
|
||||
require.Equal(t, tos.Id, ruser.TermsOfServiceId, "Terms of service ID should match")
|
||||
require.NotZero(t, ruser.TermsOfServiceCreateAt, "Terms of service CreateAt should be populated")
|
||||
})
|
||||
|
||||
t.Run("returns user when auth_data is an email-shaped value", func(t *testing.T) {
|
||||
// ResetAuthDataToEmailForUsers sets AuthData = Email for whole batches of
|
||||
// users, so email-shaped auth_data values are common in practice. Verify
|
||||
// the route, Client4 path escaping (`@` -> `%40`), and server-side decoding
|
||||
// all round-trip correctly.
|
||||
emailUser := th.CreateUser(t)
|
||||
th.LinkUserToTeam(t, emailUser, team)
|
||||
emailAuth := "user-" + model.NewId() + "@example.com"
|
||||
_, _, updErr := th.SystemAdminClient.UpdateUserAuth(context.Background(), emailUser.Id, &model.UserAuth{
|
||||
AuthData: model.NewPointer(emailAuth),
|
||||
AuthService: model.UserAuthServiceSaml,
|
||||
})
|
||||
require.NoError(t, updErr)
|
||||
|
||||
ruser, _, getErr := th.SystemAdminClient.GetUserByAuthData(context.Background(), emailAuth, "")
|
||||
require.NoError(t, getErr)
|
||||
require.Equal(t, emailUser.Id, ruser.Id)
|
||||
require.NotNil(t, ruser.AuthData)
|
||||
require.Equal(t, emailAuth, *ruser.AuthData)
|
||||
})
|
||||
|
||||
t.Run("preserves case in auth_data", func(t *testing.T) {
|
||||
// auth_data is opaque and case-sensitive (unlike email, which the email
|
||||
// endpoint lowercases). Non-SAML IdPs commonly issue mixed-case identifiers,
|
||||
// so guard against a regression where the handler normalizes the value.
|
||||
mixedUser := th.CreateUser(t)
|
||||
th.LinkUserToTeam(t, mixedUser, team)
|
||||
mixedAuth := "MixedCase-" + model.NewId() + "@Example.COM"
|
||||
_, _, updErr := th.SystemAdminClient.UpdateUserAuth(context.Background(), mixedUser.Id, &model.UserAuth{
|
||||
AuthData: model.NewPointer(mixedAuth),
|
||||
AuthService: model.UserAuthServiceSaml,
|
||||
})
|
||||
require.NoError(t, updErr)
|
||||
|
||||
ruser, _, getErr := th.SystemAdminClient.GetUserByAuthData(context.Background(), mixedAuth, "")
|
||||
require.NoError(t, getErr)
|
||||
require.Equal(t, mixedUser.Id, ruser.Id)
|
||||
require.NotNil(t, ruser.AuthData)
|
||||
require.Equal(t, mixedAuth, *ruser.AuthData)
|
||||
|
||||
_, resp, lowerErr := th.SystemAdminClient.GetUserByAuthData(context.Background(), strings.ToLower(mixedAuth), "")
|
||||
require.Error(t, lowerErr)
|
||||
CheckNotFoundStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("returns user when auth_data is an LDAP objectGUID hex-escape form", func(t *testing.T) {
|
||||
// AD objectGUID stored under auth_service=ldap uses the LDAP filter
|
||||
// hex-escape form (`\xx` per byte). Backslashes are special in URL paths
|
||||
// (WHATWG rewrites them to `/`), which is why this endpoint uses a query
|
||||
// parameter; this test guards the query-string round-trip for the exact
|
||||
// shape the customer reported.
|
||||
ldapUser := th.CreateUser(t)
|
||||
th.LinkUserToTeam(t, ldapUser, team)
|
||||
ldapAuth := `\61\14\e1\d1\c5\35\18\4a\b6\60\d6\78\50\fd\0d\5d`
|
||||
_, _, updErr := th.SystemAdminClient.UpdateUserAuth(context.Background(), ldapUser.Id, &model.UserAuth{
|
||||
AuthData: model.NewPointer(ldapAuth),
|
||||
AuthService: model.UserAuthServiceLdap,
|
||||
})
|
||||
require.NoError(t, updErr)
|
||||
|
||||
ruser, _, getErr := th.SystemAdminClient.GetUserByAuthData(context.Background(), ldapAuth, "")
|
||||
require.NoError(t, getErr)
|
||||
require.Equal(t, ldapUser.Id, ruser.Id)
|
||||
require.NotNil(t, ruser.AuthData)
|
||||
require.Equal(t, ldapAuth, *ruser.AuthData)
|
||||
})
|
||||
|
||||
t.Run("returns user when auth_data is SAML base64 with reserved chars", func(t *testing.T) {
|
||||
// AD objectGUID stored under auth_service=saml uses standard Base64,
|
||||
// which can contain `+`, `/`, and `=` padding -- all reserved in
|
||||
// application/x-www-form-urlencoded. url.Values.Set escapes them
|
||||
// correctly; this test guards against a future regression where someone
|
||||
// rewrites the client to skip that escaping.
|
||||
samlUser := th.CreateUser(t)
|
||||
th.LinkUserToTeam(t, samlUser, team)
|
||||
// Bytes chosen to produce all three reserved characters in the Base64
|
||||
// output: 0xfb,0xef,0xff,0x00 -> "++//AA==".
|
||||
samlAuth := base64.StdEncoding.EncodeToString([]byte{0xfb, 0xef, 0xff, 0x00})
|
||||
require.Contains(t, samlAuth, "+")
|
||||
require.Contains(t, samlAuth, "/")
|
||||
require.Contains(t, samlAuth, "=")
|
||||
_, _, updErr := th.SystemAdminClient.UpdateUserAuth(context.Background(), samlUser.Id, &model.UserAuth{
|
||||
AuthData: model.NewPointer(samlAuth),
|
||||
AuthService: model.UserAuthServiceSaml,
|
||||
})
|
||||
require.NoError(t, updErr)
|
||||
|
||||
ruser, _, getErr := th.SystemAdminClient.GetUserByAuthData(context.Background(), samlAuth, "")
|
||||
require.NoError(t, getErr)
|
||||
require.Equal(t, samlUser.Id, ruser.Id)
|
||||
require.NotNil(t, ruser.AuthData)
|
||||
require.Equal(t, samlAuth, *ruser.AuthData)
|
||||
})
|
||||
|
||||
t.Run("rejects non-system admin", func(t *testing.T) {
|
||||
// `user` is converted to SAML below and can no longer use password login; use
|
||||
// a separate team member to assert the endpoint requires a system admin.
|
||||
_, _, err = th.Client.Login(context.Background(), regularUser.Email, regularUser.Password)
|
||||
require.NoError(t, err)
|
||||
_, resp, err := th.Client.GetUserByAuthData(context.Background(), authID, "")
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("rejects auth data over max length", func(t *testing.T) {
|
||||
longData := strings.Repeat("x", model.UserAuthDataMaxLength+1)
|
||||
_, resp, err := th.SystemAdminClient.GetUserByAuthData(context.Background(), longData, "")
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, resp)
|
||||
})
|
||||
}
|
||||
|
||||
// This test can flake if two calls to model.NewId can return the same value.
|
||||
// Not much can be done about it.
|
||||
func TestSearchUsers(t *testing.T) {
|
||||
|
|
@ -4349,6 +4512,61 @@ func TestRevokeSessions(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestRevokeSessionBotPermissions(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.ServiceSettings.EnableBotAccountCreation = true
|
||||
})
|
||||
|
||||
bot, botResp, err := th.SystemAdminClient.CreateBot(context.Background(), &model.Bot{
|
||||
Username: GenerateTestUsername(),
|
||||
DisplayName: "Test Bot",
|
||||
Description: "bot for revoke-session permission test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, botResp)
|
||||
defer func() {
|
||||
appErr := th.App.PermanentDeleteBot(th.Context, bot.UserId)
|
||||
assert.Nil(t, appErr)
|
||||
}()
|
||||
|
||||
t.Run("user manager without bot permissions cannot revoke bot session", func(t *testing.T) {
|
||||
th.AddPermissionToRole(t, model.PermissionSysconsoleWriteUserManagementUsers.Id, model.SystemUserRoleId)
|
||||
defer th.RemovePermissionFromRole(t, model.PermissionSysconsoleWriteUserManagementUsers.Id, model.SystemUserRoleId)
|
||||
|
||||
// Seed a real session so the test confirms the permission gate blocks
|
||||
// access even when the target session genuinely exists.
|
||||
botSession, appErr := th.App.CreateSession(th.Context, &model.Session{UserId: bot.UserId})
|
||||
require.Nil(t, appErr)
|
||||
|
||||
th.LoginBasic(t)
|
||||
|
||||
resp, err := th.Client.RevokeSession(context.Background(), bot.UserId, botSession.Id)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("user with bot management permissions can revoke bot session", func(t *testing.T) {
|
||||
th.AddPermissionToRole(t, model.PermissionManageOthersBots.Id, model.SystemUserRoleId)
|
||||
defer th.RemovePermissionFromRole(t, model.PermissionManageOthersBots.Id, model.SystemUserRoleId)
|
||||
|
||||
// Seed a real session for the bot directly via the app layer.
|
||||
botSession, appErr := th.App.CreateSession(th.Context, &model.Session{UserId: bot.UserId})
|
||||
require.Nil(t, appErr)
|
||||
|
||||
th.LoginBasic(t)
|
||||
|
||||
_, err := th.Client.RevokeSession(context.Background(), bot.UserId, botSession.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Confirm the session row is gone.
|
||||
_, appErr = th.App.GetSessionById(th.Context, botSession.Id)
|
||||
require.NotNil(t, appErr, "session should no longer exist after revocation")
|
||||
})
|
||||
}
|
||||
|
||||
func TestRevokeAllSessions(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
|
@ -6743,6 +6961,34 @@ func TestDemoteUserToGuest(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("cannot demote bot account", func(t *testing.T) {
|
||||
th.App.Srv().SetLicense(model.NewTestLicense("guest_accounts"))
|
||||
|
||||
prevBotCreation := *th.App.Config().ServiceSettings.EnableBotAccountCreation
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.ServiceSettings.EnableBotAccountCreation = true
|
||||
})
|
||||
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.ServiceSettings.EnableBotAccountCreation = prevBotCreation
|
||||
})
|
||||
|
||||
createdBot, resp, err := th.SystemAdminClient.CreateBot(context.Background(), &model.Bot{
|
||||
Username: "botdemote" + model.NewId(),
|
||||
DisplayName: "Demote Test Bot",
|
||||
Description: "test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, resp)
|
||||
defer func() {
|
||||
appErr := th.App.PermanentDeleteBot(th.Context, createdBot.UserId)
|
||||
require.Nil(t, appErr)
|
||||
}()
|
||||
|
||||
demoteResp, err := th.SystemAdminClient.DemoteUserToGuest(context.Background(), createdBot.UserId)
|
||||
CheckBadRequestStatus(t, demoteResp)
|
||||
CheckErrorID(t, err, "api.user.demote_user_to_guest.bot_not_allowed.app_error")
|
||||
})
|
||||
|
||||
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
|
||||
_, _, err := c.GetUser(context.Background(), user.Id, "")
|
||||
require.NoError(t, err)
|
||||
|
|
@ -7242,6 +7488,49 @@ func TestUpdatePassword(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestUpdatePasswordBotPermissions(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.ServiceSettings.EnableBotAccountCreation = true
|
||||
})
|
||||
|
||||
bot, botResp, err := th.SystemAdminClient.CreateBot(context.Background(), &model.Bot{
|
||||
Username: GenerateTestUsername(),
|
||||
DisplayName: "Test Bot",
|
||||
Description: "bot for update-password permission test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, botResp)
|
||||
defer func() {
|
||||
appErr := th.App.PermanentDeleteBot(th.Context, bot.UserId)
|
||||
assert.Nil(t, appErr)
|
||||
}()
|
||||
|
||||
t.Run("user manager without bot permissions cannot update bot password", func(t *testing.T) {
|
||||
th.AddPermissionToRole(t, model.PermissionSysconsoleWriteUserManagementUsers.Id, model.SystemUserRoleId)
|
||||
defer th.RemovePermissionFromRole(t, model.PermissionSysconsoleWriteUserManagementUsers.Id, model.SystemUserRoleId)
|
||||
|
||||
th.LoginBasic(t)
|
||||
|
||||
resp, err := th.Client.UpdatePassword(context.Background(), bot.UserId, "", model.NewTestPassword())
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("user with bot management permissions can update bot password", func(t *testing.T) {
|
||||
th.AddPermissionToRole(t, model.PermissionManageOthersBots.Id, model.SystemUserRoleId)
|
||||
defer th.RemovePermissionFromRole(t, model.PermissionManageOthersBots.Id, model.SystemUserRoleId)
|
||||
|
||||
th.LoginBasic(t)
|
||||
|
||||
resp, err := th.Client.UpdatePassword(context.Background(), bot.UserId, "", model.NewTestPassword())
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdatePasswordAudit(t *testing.T) {
|
||||
logFile, err := os.CreateTemp("", "adv.log")
|
||||
require.NoError(t, err)
|
||||
|
|
@ -9660,6 +9949,57 @@ func TestRevokeAllSessionsForUser(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestRevokeAllSessionsForUserBotPermissions(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.ServiceSettings.EnableBotAccountCreation = true
|
||||
})
|
||||
|
||||
bot, botResp, err := th.SystemAdminClient.CreateBot(context.Background(), &model.Bot{
|
||||
Username: GenerateTestUsername(),
|
||||
DisplayName: "Test Bot",
|
||||
Description: "bot for revoke-all-sessions permission test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, botResp)
|
||||
defer func() {
|
||||
appErr := th.App.PermanentDeleteBot(th.Context, bot.UserId)
|
||||
assert.Nil(t, appErr)
|
||||
}()
|
||||
|
||||
t.Run("user manager without bot permissions cannot revoke all sessions for a bot", func(t *testing.T) {
|
||||
th.AddPermissionToRole(t, model.PermissionSysconsoleWriteUserManagementUsers.Id, model.SystemUserRoleId)
|
||||
defer th.RemovePermissionFromRole(t, model.PermissionSysconsoleWriteUserManagementUsers.Id, model.SystemUserRoleId)
|
||||
|
||||
th.LoginBasic(t)
|
||||
|
||||
resp, err := th.Client.RevokeAllSessions(context.Background(), bot.UserId)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("user with bot management permissions can revoke all sessions for a bot", func(t *testing.T) {
|
||||
th.AddPermissionToRole(t, model.PermissionManageOthersBots.Id, model.SystemUserRoleId)
|
||||
defer th.RemovePermissionFromRole(t, model.PermissionManageOthersBots.Id, model.SystemUserRoleId)
|
||||
|
||||
// Seed a real session so RevokeAllSessions is not a no-op.
|
||||
_, appErr := th.App.CreateSession(th.Context, &model.Session{UserId: bot.UserId})
|
||||
require.Nil(t, appErr)
|
||||
|
||||
th.LoginBasic(t)
|
||||
|
||||
_, err := th.Client.RevokeAllSessions(context.Background(), bot.UserId)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Confirm all sessions for the bot are gone.
|
||||
sessions, appErr := th.App.GetSessions(th.Context, bot.UserId)
|
||||
require.Nil(t, appErr)
|
||||
require.Empty(t, sessions, "all bot sessions should be revoked")
|
||||
})
|
||||
}
|
||||
|
||||
func TestResetPasswordFailedAttempts(t *testing.T) {
|
||||
th := SetupEnterprise(t).InitBasic(t)
|
||||
th.SetupLdapConfig()
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue