diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9557847..42a7fb6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,6 +91,112 @@ jobs: - name: ci/build uses: mattermost/actions/plugin-ci/build@0256d363493a1e6b9e361ea788c62a0a73bc00be + # End-to-end suite: boots a real Mattermost via Testcontainers, installs this plugin, and drives + # the space-permission scenarios through the real API with nothing mocked. + # + # It needs a server image carrying the paired core branch's space-permission changes. Core CI + # publishes one per commit as mattermostdevelopment/mattermost-team-edition:<7-char-sha>, so set + # the CORE_IMAGE repository variable to that tag (or to a release tag once those changes ship). + # While CORE_IMAGE is unset this job does not run: no available image could satisfy it, and a + # silent pass would be worse than an absent one. + # + # DO NOT add this job to the repository's required status checks until CORE_IMAGE is set and the + # suite is observed passing. A job skipped by a top-level `if:` reports as "skipped", and branch + # protection counts a skipped required check as satisfied — so listing it early would admit every + # PR as though the suite had run, which is the outcome the condition above exists to avoid. + # For the same reason it is not in delivery/release `needs:` yet: a skipped dependency skips the + # dependent job. Add it to both once CORE_IMAGE is populated, so a permission regression blocks + # publishing. + e2e: + if: ${{ vars.CORE_IMAGE != '' && (github.repository_owner == 'mattermost' || github.event_name != 'schedule') }} + runs-on: ubuntu-latest + needs: build + timeout-minutes: 20 + permissions: + contents: read + env: + CORE_IMAGE: ${{ vars.CORE_IMAGE }} + # Lifted to env because the secrets context is not available in a step-level `if`, and the + # login below has to be skippable: the repository defines no Docker Hub credentials today. + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + steps: + - name: Checkout repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + + # The pin is checked, not just documented: a floating tag (:master, :latest) would silently + # re-point this suite at whatever core published most recently, so an unrelated upstream + # change would surface here as a test failure with no corresponding commit in this repo. + - name: ci/verify-core-image-pin + run: | + if [[ ! "$CORE_IMAGE" =~ :([0-9a-f]{7,40}|v[0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + echo "CORE_IMAGE must be pinned to a commit sha or a release tag; got '$CORE_IMAGE'." >&2 + echo "Core CI publishes one image per commit as :<7-char-sha>." >&2 + exit 1 + fi + image_tag="${CORE_IMAGE##*:}" + + # The shape check above cannot catch the drift that matters. go.mod pins server/public to a + # core commit; CORE_IMAGE is a separate repository variable a human sets. Nothing ties them + # together, so bumping the pin without updating the variable leaves this suite green against + # a core image that predates the very changes under test. + gomod_commit="$(grep -oE 'server/public v[0-9]+\.[0-9]+\.[0-9]+-[0-9]+\.[0-9]+-[0-9a-f]+' go.mod | grep -oE '[0-9a-f]{12}$' || true)" + if [[ -z "$gomod_commit" ]]; then + echo "server/public is pinned to a released version, not a core commit; skipping the sha cross-check." + exit 0 + fi + if [[ ! "$image_tag" =~ ^[0-9a-f]{7,40}$ ]]; then + echo "go.mod pins server/public to unreleased core commit ${gomod_commit}, but CORE_IMAGE is release tag '${image_tag}'." >&2 + echo "A released image cannot carry that commit's changes. Point CORE_IMAGE at the core build for ${gomod_commit}." >&2 + exit 1 + fi + # Core CI tags images with a 7-char sha while go.mod carries 12, so compare on the shorter. + prefix_len=${#image_tag} + if (( ${#gomod_commit} < prefix_len )); then + prefix_len=${#gomod_commit} + fi + if [[ "${image_tag:0:$prefix_len}" != "${gomod_commit:0:$prefix_len}" ]]; then + echo "CORE_IMAGE sha '${image_tag}' does not match the core commit go.mod pins (${gomod_commit})." >&2 + echo "Set CORE_IMAGE to the image core CI published for ${gomod_commit}, or repin go.mod." >&2 + exit 1 + fi + echo "CORE_IMAGE ${image_tag} matches the go.mod core pin ${gomod_commit}." + + - name: ci/setup + uses: mattermost/actions/plugin-ci/setup@0256d363493a1e6b9e361ea788c62a0a73bc00be + with: + go-version-file: go.mod + + # Reuses the bundle the build job already produced rather than rebuilding it here; the + # suite globs dist/ for it. + - name: ci/download-artifact + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: dist + path: dist + + # Authenticated so the core image pull is billed to the org's Docker Hub quota rather than the + # shared anonymous per-IP limit, which GitHub-hosted runners exhaust as a pull failure that + # reads like a test failure. Skipped when no credentials are configured: the pull still works + # anonymously, just against the lower limit, so a missing secret must not fail the suite. + - name: ci/docker-login + if: env.DOCKERHUB_USERNAME != '' + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: ci/e2e + run: go test -tags e2e -count=1 -v ./server/e2e/... + + # The suite boots the server in a container, so a boot or migration failure leaves its + # diagnosis in the container's log rather than in the test output. + - name: ci/e2e-logs + if: failure() + run: docker ps -aq | xargs -r -I{} sh -c 'echo "===== {} ====="; docker logs {} 2>&1 | tail -400' + delivery: if: ${{ github.repository_owner == 'mattermost' && github.event_name != 'schedule' && github.ref_name == 'master' }} runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index b55ebb4..957ed12 100644 --- a/Makefile +++ b/Makefile @@ -362,6 +362,38 @@ ifneq ($(HAS_WEBAPP),) cd webapp && $(NPM) run test; endif +## Runs the official RBAC end-to-end suite (Go + Testcontainers): boots a real Mattermost server +## built from the paired core branch (build/build-core-image.sh), installs the plugin bundle +## into it, and drives the seven Confluence permission scenarios plus their named parity gaps +## through the real HTTP API. Requires Docker. See server/e2e/README.md. +## +## A namespaced CORE_IMAGE (one containing a `/`) names a pullable image, so the local build is +## skipped and Testcontainers fetches it — the path the CI e2e job takes. +## +## The bundle must contain a linux-$(arch) plugin binary for the Docker container to load it — +## `make server` alone only builds for the host OS/arch when MM_SERVICESETTINGS_ENABLEDEVELOPER is +## set (a common local dev convenience), which produces a bundle unusable inside the container. So +## this checks the newest bundle for the linux binary matching the Docker daemon's architecture, +## not just that a bundle file exists, and forces a full cross-compiled `make dist` otherwise. +.PHONY: test-e2e +test-e2e: + @docker_arch=$$(docker info --format '{{.Architecture}}' 2>/dev/null); \ + case "$$docker_arch" in \ + aarch64) goarch=arm64 ;; \ + x86_64) goarch=amd64 ;; \ + *) echo "ERROR: could not determine Docker daemon architecture (got '$$docker_arch'). Is Docker running?" >&2; exit 1 ;; \ + esac; \ + bundle=$$(ls -t dist/$(PLUGIN_ID)-*.tar.gz 2>/dev/null | head -1); \ + if [ -z "$$bundle" ] || ! tar tzf "$$bundle" | grep -q "plugin-linux-$$goarch$$"; then \ + echo "No plugin bundle with a linux-$$goarch binary found — running 'make dist' (forcing an all-architecture build)..."; \ + MM_SERVICESETTINGS_ENABLEDEVELOPER= $(MAKE) dist; \ + fi + @case "$${CORE_IMAGE:-}" in \ + */*) echo "CORE_IMAGE=$$CORE_IMAGE is namespaced — leaving it to Testcontainers to pull; skipping the local core-image build." ;; \ + *) ./build/build-core-image.sh ;; \ + esac + $(GO) test -tags e2e -count=1 -v ./server/e2e/... + ## Creates a coverage report for the server code. .PHONY: coverage coverage: apply webapp/node_modules diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 7112364..9e0599f 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -23,6 +23,10 @@ "id": "api.request_too_large.app_error", "translation": "The request body exceeds the maximum allowed size of {{.MaxBytes}} bytes." }, + { + "id": "api.space.add_member.capabilities_not_allowed.app_error", + "translation": "Capabilities cannot be set when adding a member. Add the member first, then set their capabilities." + }, { "id": "app.optimistic_lock.baseline_required.app_error", "translation": "The {{.Field}} field is required unless force is set." @@ -147,6 +151,10 @@ "id": "app.page.move_to_space.invalid_user_id.app_error", "translation": "Invalid user ID." }, + { + "id": "app.page.move_to_space.subtree_not_owned.app_error", + "translation": "You can only move pages you created. This page, or one of its sub-pages, was created by someone else." + }, { "id": "app.page.not_found.app_error", "translation": "The page could not be found." @@ -395,6 +403,10 @@ "id": "app.space.add_member.failed.app_error", "translation": "Unable to add the member to the space." }, + { + "id": "app.space.auto_join.get_space_failed.app_error", + "translation": "Failed to look up the space." + }, { "id": "app.space.client_not_wired.app_error", "translation": "Unable to complete the request due to a server configuration error." @@ -403,6 +415,10 @@ "id": "app.space.create.add_member_failed.app_error", "translation": "Unable to add the creator to the backing channel for the space." }, + { + "id": "app.space.create.admin_role_failed.app_error", + "translation": "Failed to assign the space admin role to the creator." + }, { "id": "app.space.create.backing_channel_failed.app_error", "translation": "Unable to create the backing channel for the space." @@ -411,6 +427,10 @@ "id": "app.space.create.channel_id_not_allowed.app_error", "translation": "A channel ID must not be supplied when creating a space." }, + { + "id": "app.space.create.forbidden.app_error", + "translation": "You do not have permission to create a space in this team." + }, { "id": "app.space.create.invalid_team_id.app_error", "translation": "Invalid team ID." @@ -419,6 +439,10 @@ "id": "app.space.create.invalid_user_id.app_error", "translation": "Invalid user ID." }, + { + "id": "app.space.create.invalid_view_access.app_error", + "translation": "Invalid view access. Must be either \"open\" or \"private\"." + }, { "id": "app.space.create.nil_input.app_error", "translation": "A space must be supplied." @@ -427,10 +451,34 @@ "id": "app.space.create.not_team_member.app_error", "translation": "You must be a member of the team to create a space in it." }, + { + "id": "app.space.create.scheme_configure_failed.app_error", + "translation": "Failed to configure the space's permission scheme." + }, + { + "id": "app.space.create.scheme_lookup_failed.app_error", + "translation": "Failed to look up the space's permission scheme." + }, { "id": "app.space.create.team_lookup_failed.app_error", "translation": "Unable to verify team membership." }, + { + "id": "app.space.default_capabilities.channel_scheme_missing.app_error", + "translation": "The space has no permission scheme." + }, + { + "id": "app.space.default_capabilities.repoint_failed.app_error", + "translation": "Failed to apply the new default capabilities." + }, + { + "id": "app.space.default_capabilities.scheme_configure_failed.app_error", + "translation": "Failed to configure the space's new permission scheme." + }, + { + "id": "app.space.default_capabilities.scheme_configure_rollback_failed.app_error", + "translation": "Failed to configure the space's new permission scheme, and the space could not be returned to its previous one. Contact your system administrator." + }, { "id": "app.space.delete.invalid_id.app_error", "translation": "Invalid space ID." @@ -439,6 +487,10 @@ "id": "app.space.get.invalid_id.app_error", "translation": "Invalid space ID." }, + { + "id": "app.space.get_for_team.forbidden.app_error", + "translation": "You do not have permission to view spaces in this team." + }, { "id": "app.space.get_for_team.invalid_team_id.app_error", "translation": "Invalid team ID." @@ -455,18 +507,34 @@ "id": "app.space.get_for_team.team_lookup_failed.app_error", "translation": "Unable to verify team membership." }, + { + "id": "app.space.get_members.failed.app_error", + "translation": "Unable to list the members of the space." + }, { "id": "app.space.get_pages.invalid_space_id.app_error", "translation": "Invalid space ID." }, { - "id": "app.space.list_members.failed.app_error", - "translation": "Unable to list the members of the space." + "id": "app.space.lock_timeout.app_error", + "translation": "Another change to this space is in progress. Try again." + }, + { + "id": "app.space.member.admin_count_failed.app_error", + "translation": "Failed to count the space's administrators." + }, + { + "id": "app.space.member.guest_not_assignable.app_error", + "translation": "Capabilities cannot be assigned to a guest." }, { "id": "app.space.member.invalid_user_id.app_error", "translation": "The supplied user ID is not valid." }, + { + "id": "app.space.member.last_admin.app_error", + "translation": "The last administrator of a space cannot be removed or demoted." + }, { "id": "app.space.member.not_team_member.app_error", "translation": "The specified user is not a member of the space's team." @@ -475,10 +543,18 @@ "id": "app.space.member.team_lookup_failed.app_error", "translation": "Unable to verify the user's team membership." }, + { + "id": "app.space.member.update_capabilities_failed.app_error", + "translation": "Failed to update the member's capabilities." + }, { "id": "app.space.member.user_not_found.app_error", "translation": "The specified user could not be found." }, + { + "id": "app.space.preset_scheme_missing.app_error", + "translation": "The built-in space permission schemes are not available on this server." + }, { "id": "app.space.remove_member.failed.app_error", "translation": "Unable to remove the member from the space." @@ -507,6 +583,14 @@ "id": "app.space.update.invalid_id.app_error", "translation": "Invalid space ID." }, + { + "id": "app.space.update.invalid_view_access.app_error", + "translation": "Invalid view access. Must be either \"open\" or \"private\"." + }, + { + "id": "app.space.update.view_access_force.app_error", + "translation": "View access cannot be changed with force set." + }, { "id": "app.store.conflict.app_error", "translation": "The operation conflicts with the current state. Reload and try again." @@ -715,8 +799,24 @@ "id": "model.space.is_valid.update_at.app_error", "translation": "Invalid space update time." }, + { + "id": "model.space.is_valid.view_access.app_error", + "translation": "Invalid view access. Must be either \"open\" or \"private\"." + }, { "id": "model.space.patch.nothing_to_update.app_error", "translation": "The update contains no changes." + }, + { + "id": "model.space_capabilities.admin_not_a_default.app_error", + "translation": "The admin_space capability cannot be a space default." + }, + { + "id": "model.space_capabilities.read_page_not_grantable.app_error", + "translation": "The read_page capability cannot be granted; every space member holds it." + }, + { + "id": "model.space_capabilities.unknown_capability.app_error", + "translation": "Unknown capability: {{.Capability}}." } ] diff --git a/build/build-core-image.sh b/build/build-core-image.sh new file mode 100755 index 0000000..4084b74 --- /dev/null +++ b/build/build-core-image.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# build-core-image.sh — build a Docker image of the docs-core server (the RBAC epic's paired core +# branch) so the Go e2e suite (server/e2e/) can boot a real Mattermost with the plugin installed via +# Testcontainers, without waiting for the branch to merge and ship a published image. +# +# What it does: +# 1. Guards that MM_SERVER_REPO is checked out on DOCS_CORE_BRANCH (never checks out for you). +# 2. Cross-compiles ./cmd/mattermost for the Docker daemon's architecture (so this works +# whether Docker is running amd64 or arm64 containers, independent of the host arch). +# 3. Assembles a minimal build context (binary + i18n/templates/fonts from the core tree, plus +# empty config/client/plugins/data/logs directories) in a temp dir. +# 4. Builds the image and tags it $CORE_IMAGE (default mm-docs-rbac-core:dev). +# +# Usage: +# ./build/build-core-image.sh +# CORE_IMAGE=my-tag:dev ./build/build-core-image.sh +# ./build/build-core-image.sh --skip-build # reuse the existing binaries at $MM_SERVER_REPO/bin/{mattermost,mmctl}-e2e-linux- +# +# Requires: +# - Docker running locally. +# - The core repo checked out on DOCS_CORE_BRANCH (this script refuses to run otherwise — it +# never switches branches for you; see start-docs-core-server.sh for the same guard style). + +set -euo pipefail + +# Configuration is read from the environment with committed defaults: this script must not depend +# on any developer's local, untracked setup. The local bash suites keep their own env.sh, which +# exports these same names, so sourcing it beforehand also works. +# +# MM_SERVER_REPO path to the core repo's server/ directory (required; auto-detected from the +# conventional sibling checkouts when unset) +# DOCS_CORE_BRANCH branch the core repo must be on (default below) +# CORE_IMAGE image tag to build (default below) +DOCS_CORE_BRANCH="${DOCS_CORE_BRANCH:-MM-69269-permissions-rbac-core}" +CORE_IMAGE="${CORE_IMAGE:-mm-docs-rbac-core:dev}" + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +if [[ -z "${MM_SERVER_REPO:-}" ]]; then + for candidate in "$REPO_ROOT/../MM-69269-core/server" "$REPO_ROOT/../mattermost/server"; do + if [[ -d "$candidate" ]]; then + MM_SERVER_REPO="$(cd "$candidate" && pwd)" + break + fi + done +fi +if [[ -z "${MM_SERVER_REPO:-}" || ! -d "$MM_SERVER_REPO" ]]; then + echo "ERROR: cannot locate the core repo. Set MM_SERVER_REPO to its server/ directory, e.g." >&2 + echo " MM_SERVER_REPO=/path/to/mattermost/server $0" >&2 + exit 1 +fi + +SKIP_BUILD=false +for arg in "$@"; do + [[ "$arg" == "--skip-build" ]] && SKIP_BUILD=true +done + +# ── Guard: core repo must be on the docs core-complement branch ───────────────── +# We never check out for you: the core repo holds uncommitted work, and silently switching +# branches could lose or mix it (see git-safety rules). +CURRENT_BRANCH="$(git -C "$MM_SERVER_REPO" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" +if [[ "$CURRENT_BRANCH" != "$DOCS_CORE_BRANCH" ]]; then + echo "ERROR: core repo at $MM_SERVER_REPO is on '$CURRENT_BRANCH', not '$DOCS_CORE_BRANCH'." >&2 + echo " Check it out first: git -C \"$MM_SERVER_REPO\" checkout $DOCS_CORE_BRANCH" >&2 + exit 1 +fi + +# ── Detect the Docker daemon's architecture ───────────────────────────────────── +# Cross-compile for the arch the *daemon* runs containers as, not the host arch — the two can +# differ (e.g. Docker Desktop on Apple Silicon set to emulate amd64). +DOCKER_ARCH="$(docker info --format '{{.Architecture}}' 2>/dev/null || true)" +case "$DOCKER_ARCH" in + aarch64) GOARCH_TARGET="arm64" ;; + x86_64) GOARCH_TARGET="amd64" ;; + *) + echo "ERROR: could not determine Docker daemon architecture (got '$DOCKER_ARCH'). Is Docker running?" >&2 + exit 1 + ;; +esac +echo "Docker daemon architecture: $DOCKER_ARCH -> GOARCH=$GOARCH_TARGET" + +# ── Cross-compile the server + mmctl binaries ──────────────────────────────────── +# mmctl is required in the image too: testcontainers-mattermost-go drives container setup +# (CreateAdmin, CreateTeam, AddUserToTeam, InstallPlugin, SetConfig) entirely via +# `mmctl --local ...` exec'd inside the container, not the HTTP API. +BINARY="$MM_SERVER_REPO/bin/mattermost-e2e-linux-$GOARCH_TARGET" +MMCTL_BINARY="$MM_SERVER_REPO/bin/mmctl-e2e-linux-$GOARCH_TARGET" +if [[ "$SKIP_BUILD" == true && -f "$BINARY" && -f "$MMCTL_BINARY" ]]; then + echo "Skipping build (--skip-build), reusing $BINARY and $MMCTL_BINARY." +else + echo "Cross-compiling docs-core server + mmctl for linux/$GOARCH_TARGET from branch $DOCS_CORE_BRANCH (this can take a minute)..." + ( cd "$MM_SERVER_REPO" && env GOOS=linux GOARCH="$GOARCH_TARGET" CGO_ENABLED=0 \ + go build -o "$BINARY" ./cmd/mattermost ) + ( cd "$MM_SERVER_REPO" && env GOOS=linux GOARCH="$GOARCH_TARGET" CGO_ENABLED=0 \ + go build -o "$MMCTL_BINARY" ./cmd/mmctl ) + echo "Build complete: $BINARY, $MMCTL_BINARY" +fi + +# ── Assemble the build context ─────────────────────────────────────────────────── +BUILD_CTX="$(mktemp -d)" +trap 'rm -rf "$BUILD_CTX"' EXIT + +MM_ROOT="$BUILD_CTX/mattermost" +mkdir -p "$MM_ROOT/bin" "$MM_ROOT/config" "$MM_ROOT/client/plugins" "$MM_ROOT/plugins" "$MM_ROOT/data" "$MM_ROOT/logs" +cp "$BINARY" "$MM_ROOT/bin/mattermost" +cp "$MMCTL_BINARY" "$MM_ROOT/bin/mmctl" +cp -R "$MM_SERVER_REPO/i18n" "$MM_ROOT/i18n" +cp -R "$MM_SERVER_REPO/templates" "$MM_ROOT/templates" +cp -R "$MM_SERVER_REPO/fonts" "$MM_ROOT/fonts" + +cat > "$BUILD_CTX/Dockerfile" <<'DOCKEREOF' +# Pinned by digest: this image is the server the e2e scenarios assert against, so an upstream +# repaint of the noble tag would change behaviour between two builds of identical source. +FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + media-types \ + mailcap \ + tzdata \ + curl \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd -g 2000 mattermost && useradd -u 2000 -g 2000 -m mattermost + +COPY --chown=2000:2000 mattermost /mattermost + +ENV PATH="/mattermost/bin:${PATH}" +ENV MM_SERVICESETTINGS_ENABLELOCALMODE="true" + +USER mattermost +WORKDIR /mattermost + +EXPOSE 8065 +CMD ["mattermost", "server"] +DOCKEREOF + +# ── Build the image ─────────────────────────────────────────────────────────────── +IMAGE_TAG="${CORE_IMAGE:-mm-docs-rbac-core:dev}" +echo "Building Docker image $IMAGE_TAG from $BUILD_CTX ..." +docker build -t "$IMAGE_TAG" "$BUILD_CTX" + +echo "$IMAGE_TAG" diff --git a/go.mod b/go.mod index 0b5ee62..f4ea150 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/gorilla/mux v1.8.1 github.com/jmoiron/sqlx v1.4.0 github.com/lib/pq v1.12.3 - github.com/mattermost/mattermost/server/public v0.4.4-0.20260716203457-5f7f967a7dbf + github.com/mattermost/mattermost/server/public v0.4.4-0.20260804125319-416829fe8ff3 github.com/mattermost/mattermost/server/v8 v8.0.0-20260623200446-ba033eae4704 github.com/mattermost/morph v1.1.0 github.com/mattermost/squirrel v0.5.0 @@ -23,18 +23,38 @@ require ( github.com/wiggin77/merror v1.0.5 ) +require github.com/mattermost/testcontainers-mattermost-go v0.1.0 + require ( + dario.cat/mergo v1.0.0 // indirect filippo.io/edwards25519 v1.2.0 // indirect + github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/beevik/etree v1.6.0 // indirect + github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/containerd v1.7.33 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v27.1.1+incompatible // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a // indirect github.com/fatih/color v1.19.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/francoispqt/gojay v1.2.13 // indirect github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-sql-driver/mysql v1.10.0 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect @@ -44,8 +64,11 @@ require ( github.com/hashicorp/go-plugin v1.8.0 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.7 // indirect github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 // indirect github.com/mattermost/gosaml2 v0.10.0 // indirect github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 // indirect @@ -53,21 +76,43 @@ require ( github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.22 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.5.0 // indirect + github.com/moby/sys/user v0.3.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/morikuni/aec v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/oklog/run v1.2.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect github.com/pborman/uuid v1.2.1 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/russellhaering/goxmldsig v1.6.0 // indirect + github.com/shirou/gopsutil/v3 v3.23.12 // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/stretchr/objx v0.5.3 // indirect + github.com/testcontainers/testcontainers-go v0.35.0 // indirect + github.com/testcontainers/testcontainers-go/modules/postgres v0.35.0 // indirect github.com/tinylib/msgp v1.6.4 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/wiggin77/srslog v1.0.1 // indirect github.com/yuin/goldmark v1.8.2 // indirect + github.com/yusufpapurcu/wmi v1.2.3 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect golang.org/x/crypto v0.53.0 // indirect golang.org/x/mod v0.36.0 // indirect golang.org/x/net v0.56.0 // indirect @@ -85,3 +130,8 @@ require ( modernc.org/memory v1.11.0 // indirect modernc.org/sqlite v1.50.1 // indirect ) + +// server/public is pinned to the head of the paired core PR (mattermost/mattermost#37685), which +// carries the scheme/role plugin-API additions this plugin calls. Repin to a released version once +// that PR merges. The go.sum entries for it were recorded directly from the module, since the +// checksum database has no record of a commit that is not yet on a released branch. diff --git a/go.sum b/go.sum index 5b7f020..dbf2d04 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT cloud.google.com/go v0.31.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.37.0/go.mod h1:TS1dMSSfndXH133OKGwekG838Om/cQT0BUHV3HcBgoo= +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dmitri.shuralyov.com/app/changes v0.0.0-20180602232624-0a106ad413e3/go.mod h1:Yl+fi1br7+Rr3LqpNJf1/uxUdtRUV+Tnj0o93V2B9MU= dmitri.shuralyov.com/html/belt v0.0.0-20180602232347-f7d459c86be0/go.mod h1:JLBrvjyP0v+ecvNYvCpyZgu5/xkfAUhi6wJj28eUfSU= dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4= @@ -10,9 +12,15 @@ filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4 filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= github.com/beevik/etree v1.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE= @@ -22,15 +30,35 @@ github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBT github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/containerd/containerd v1.7.33 h1:iAkYGC/ifR/V+0eR4iXWHNGYUF0DF2PmGV5iz4Irj5M= +github.com/containerd/containerd v1.7.33/go.mod h1:gSbSCVjPCdkfJCjyrzz7aRC+xFlqVbatNpfHfVCYGUM= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY= +github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= @@ -39,6 +67,8 @@ github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a/go. github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= @@ -48,16 +78,21 @@ github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aev github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk= github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= @@ -68,6 +103,9 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= @@ -87,7 +125,10 @@ github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWS github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/grpc-gateway v1.5.0 h1:WcmKMm43DR7RdtlkEXQJyo5ws8iTp98CyhCCbOHMvNI= github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -102,6 +143,14 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.4 h1:Xp2aQS8uXButQdnCMWNmvx6UysWQQC+u1EoizjguY+8= +github.com/jackc/pgx/v5 v5.5.4/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= @@ -112,7 +161,10 @@ github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbd github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= @@ -130,7 +182,11 @@ github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6Fm github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 h1:Khvh6waxG1cHc4Cz5ef9n3XVCxRWpAKUtqg9PJl5+y8= github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404/go.mod h1:RyS7FDNQlzF1PsjbJWHRI35exqaKGSO9qD4iv8QjE34= @@ -140,14 +196,16 @@ github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 h1:Y1Tu/swM31pVwwb github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956/go.mod h1:SRl30Lb7/QoYyohYeVBuqYvvmXSZJxZgiV3Zf6VbxjI= github.com/mattermost/logr/v2 v2.0.22 h1:npFkXlkAWR9J8payh8ftPcCZvLbHSI125mAM5/r/lP4= github.com/mattermost/logr/v2 v2.0.22/go.mod h1:0sUKpO+XNMZApeumaid7PYaUZPBIydfuWZ0dqixXo+s= -github.com/mattermost/mattermost/server/public v0.4.4-0.20260716203457-5f7f967a7dbf h1:Elv/2xLhHNauIlgA3EajAQYRzpF2OfCF77UTCmQdhX4= -github.com/mattermost/mattermost/server/public v0.4.4-0.20260716203457-5f7f967a7dbf/go.mod h1:rHFKFSnyNmyk1qieL00Fv+YuLb093Q7y8VTwBu/43ic= +github.com/mattermost/mattermost/server/public v0.4.4-0.20260804125319-416829fe8ff3 h1:9iwJaAj1UtabljjpAqz4VNb3W3TxjGNAlxRLSuUEjtc= +github.com/mattermost/mattermost/server/public v0.4.4-0.20260804125319-416829fe8ff3/go.mod h1:rHFKFSnyNmyk1qieL00Fv+YuLb093Q7y8VTwBu/43ic= github.com/mattermost/mattermost/server/v8 v8.0.0-20260623200446-ba033eae4704 h1:vEw+u4m6mUrjHgpvDLXfCkuYcpUk8Q5CorgPRsqjW74= github.com/mattermost/mattermost/server/v8 v8.0.0-20260623200446-ba033eae4704/go.mod h1:RBaqawSPsPB76XA4hfrIRFvLS0QWF1qJWHr2+w34+6s= github.com/mattermost/morph v1.1.0 h1:Q9vrJbeM3s2jfweGheq12EFIzdNp9a/6IovcbvOQ6Cw= github.com/mattermost/morph v1.1.0/go.mod h1:gD+EaqX2UMyyuzmF4PFh4r33XneQ8Nzi+0E8nXjMa3A= github.com/mattermost/squirrel v0.5.0 h1:81QPS0aA+inQbpA7Pzmv6O9sWwB6VaBh/VYw3oJf8ZY= github.com/mattermost/squirrel v0.5.0/go.mod h1:NPPtk+CdpWre4GxMGoOpzEVFVc0ZoEFyJBZGCtn9nSU= +github.com/mattermost/testcontainers-mattermost-go v0.1.0 h1:oWNqnU2mua2PYMSornWjjiFQUZqPNeODSQSsWjFcuMw= +github.com/mattermost/testcontainers-mattermost-go v0.1.0/go.mod h1:XXKu9w+dsAavOjujftqSV5L0+GkybOSpWlmkNicUpEQ= github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= @@ -162,15 +220,35 @@ github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxU github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mdelapenya/tlscert v0.1.0 h1:YTpF579PYUX475eOL+6zyEO3ngLTOUWck78NBuJVXaM= +github.com/mdelapenya/tlscert v0.1.0/go.mod h1:wrbyM/DwbFCeCeqdPX/8c6hNOqQgbf0rUDErE1uD+64= github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= +github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= +github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= +github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E= github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= @@ -186,6 +264,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= @@ -194,13 +274,19 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russellhaering/goxmldsig v1.2.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= github.com/russellhaering/goxmldsig v1.6.0 h1:8fdWXEPh2k/NZNQBPFNoVfS3JmzS4ZprY/sAOpKQLks= github.com/russellhaering/goxmldsig v1.6.0/go.mod h1:TrnaquDcYxWXfJrOjeMBTX4mLBeYAqaHEyUeWPxZlBM= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4= +github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY= github.com/shurcooL/events v0.0.0-20181021180414-410e4ca65f48/go.mod h1:5u70Mqkb5O5cxEA8nxTsgrgLehJeAw6Oc4Ab1c/P1HM= github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0= @@ -228,16 +314,29 @@ github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC4 github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= +github.com/testcontainers/testcontainers-go v0.35.0 h1:uADsZpTKFAtp8SLK+hMwSaa+X+JiERHtd4sQAFmXeMo= +github.com/testcontainers/testcontainers-go v0.35.0/go.mod h1:oEVBj5zrfJTrgjwONs1SsRbnBtH9OKl+IGl3UMcr2B4= +github.com/testcontainers/testcontainers-go/modules/postgres v0.35.0 h1:eEGx9kYzZb2cNhRbBrNOCL/YPOM7+RMJiy3bB+ie0/I= +github.com/testcontainers/testcontainers-go/modules/postgres v0.35.0/go.mod h1:hfH71Mia/WWLBgMD2YctYcMlfsbnT0hflweL1dy8Q4s= github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/viant/assertly v0.4.8/go.mod h1:aGifi++jvCrUaklKEKT0BU95igDNaqkvz+49uaYMPRU= github.com/viant/toolbox v0.24.0/go.mod h1:OxMCG57V0PXuIP2HNQrtJf2CjqdmbrOx5EkMILuUhzM= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= @@ -248,13 +347,23 @@ github.com/wiggin77/merror v1.0.5 h1:P+lzicsn4vPMycAf2mFf7Zk6G9eco5N+jB1qJ2XW3ME github.com/wiggin77/merror v1.0.5/go.mod h1:H2ETSu7/bPE0Ymf4bEwdUoo73OOEkdClnoRisfw0Nm0= github.com/wiggin77/srslog v1.0.1 h1:gA2XjSMy3DrRdX9UqLuDtuVAAshb8bE1NhX1YK0Qe+8= github.com/wiggin77/srslog v1.0.1/go.mod h1:fehkyYDq1QfuYn60TDPu9YdY2bB85VUW2mvN1WynEls= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= +github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= @@ -263,17 +372,23 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= +go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE= golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+MfF/py+xQ/tYqIWW03b70/CG9Rw= golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/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/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -284,6 +399,10 @@ golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190313220215-9f648a60d977/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +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-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= @@ -296,6 +415,9 @@ golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/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-20190911185100-cd5d95a43a6e/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.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -303,30 +425,50 @@ golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20181029174526-d69651ed3497/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-20190316082340-a2f829d7f35f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/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-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181030000716-a0a13e073c7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +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/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= @@ -341,6 +483,9 @@ google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoA google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20181202183823-bd91e49a0898/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg= google.golang.org/genproto v0.0.0-20190306203927-b5d61aea6440/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20231211222908-989df2bf70f3 h1:1hfbdAfFbkmpg41000wDVqr7jUpK/Yo+LPnIxxGzmkg= +google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4= +google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo= google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= @@ -367,6 +512,8 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/server/api.go b/server/api.go index c128f36..93da2b7 100644 --- a/server/api.go +++ b/server/api.go @@ -22,9 +22,14 @@ import ( // /api/v1 prefix (full root: /plugins/com.mattermost.docs/api/v1/). // // Authorization: every route requires an authenticated user via MattermostAuthorizationRequired. -// All space- and page-scoped handlers additionally gate on backing-channel membership via -// CheckSpaceMembership. Per-page role ACLs (author vs. editor within a space) are not yet -// implemented. +// Every space- and page-scoped handler additionally gates on the capability-based RBAC model: +// requireSpaceRead/requireSpacePagePerm for reads, requirePageWrite/requireDeleteOwnOrAnyFrom for +// page writes (with the open-space auto-join pre-step), requireSpaceManage for membership +// management and general space-field updates, and requireSpaceAdmin/requireSpaceDelete for the +// space-wide exposure-policy and delete/restore operations. A helper taking a space id resolves +// the record and then gates it; one taking a *model.Space gates a record the caller already +// holds, and a From suffix means the caller supplies the ReadResolution too. See +// server/app/permissions.go for the gate implementations. func (p *Plugin) initRouter() *mux.Router { router := mux.NewRouter() router.Use(p.MattermostAuthorizationRequired) @@ -42,9 +47,16 @@ func (p *Plugin) initRouter() *mux.Router { api.HandleFunc("/spaces/{space_id}/restore", p.handleRestoreSpace).Methods(http.MethodPatch) // Space membership. - api.HandleFunc("/spaces/{space_id}/members", p.handleListSpaceMembers).Methods(http.MethodGet) + api.HandleFunc("/spaces/{space_id}/members", p.handleGetSpaceMembers).Methods(http.MethodGet) api.HandleFunc("/spaces/{space_id}/members", p.handleAddSpaceMember).Methods(http.MethodPost) api.HandleFunc("/spaces/{space_id}/members/{user_id}", p.handleRemoveSpaceMember).Methods(http.MethodDelete) + // PUT, not PATCH: these two replace the capability set outright — the body carries the full set + // the target should end up with, and a token omitted from it is revoked. There is no + // add-one/remove-one form. PATCH on this API means "nil fields leave unchanged" (see + // PATCH /spaces/{space_id}), so a caller generalizing from that would read an omitted token as + // "leave it alone" and silently revoke it instead. + api.HandleFunc("/spaces/{space_id}/members/{user_id}/capabilities", p.handleSetSpaceMemberCapabilities).Methods(http.MethodPut) + api.HandleFunc("/spaces/{space_id}/default-capabilities", p.handleSetSpaceDefaultCapabilities).Methods(http.MethodPut) // Page collection. api.HandleFunc("/spaces/{space_id}/pages", p.handleGetSpacePages).Methods(http.MethodGet) @@ -95,19 +107,207 @@ func (p *Plugin) MattermostAuthorizationRequired(next http.Handler) http.Handler }) } -// requireSpaceMembership calls CheckSpaceMembership and writes the error response when access is -// denied. Returns the fetched space and true on success so callers can reuse the already-loaded -// record instead of re-fetching it. includeDeleted must be true for restore -// operations where the space is soft-deleted at lookup time. -func (p *Plugin) requireSpaceMembership(w http.ResponseWriter, spaceID, userID string, includeDeleted bool) (*model.Space, bool) { - space, appErr := p.service.CheckSpaceMembership(spaceID, userID, includeDeleted) +// getSpaceForGate fetches spaceID (with soft-deleted rows included when includeDeleted is +// true) and maps a not-found lookup to the shared existence-hiding 403, so no enforcement helper +// ever needs to special-case a missing space differently from a denied one. Writes the error +// response and returns ok=false on failure. +func (p *Plugin) getSpaceForGate(w http.ResponseWriter, spaceID string, includeDeleted bool) (*model.Space, bool) { + var space *model.Space + var appErr *mmmodel.AppError + if includeDeleted { + space, appErr = p.service.GetSpaceWithDeleted(spaceID) + } else { + space, appErr = p.service.GetSpace(spaceID) + } if appErr != nil { + if appErr.StatusCode == http.StatusNotFound { + p.writeAppError(w, app.ExistenceHidingForbidden("getSpaceForGate").Wrap(appErr)) + return nil, false + } p.writeAppError(w, appErr) return nil, false } return space, true } +// requireSpaceGate fetches spaceID and applies gate to it, writing the error response and +// returning ok=false on either a failed fetch or a denied gate. Every space-scoped route gate +// below is this shape — only the enforcement helper and its operation label differ. +func (p *Plugin) requireSpaceGate(w http.ResponseWriter, spaceID string, includeDeleted bool, gate func(space *model.Space) *mmmodel.AppError) (*model.Space, bool) { + space, ok := p.getSpaceForGate(w, spaceID, includeDeleted) + if !ok { + return nil, false + } + if appErr := gate(space); appErr != nil { + p.writeAppError(w, appErr) + return nil, false + } + return space, true +} + +// requireSpaceRead gates a route on the space read resolver (member, or the non-member open-space +// team fall-through) — the gate for every page read. It reports how the read was admitted so a +// caller that goes on to evaluate a further permission on the same space can pass the resolution +// down instead of re-deriving the team membership behind it. +func (p *Plugin) requireSpaceRead(w http.ResponseWriter, spaceID, userID string) (*model.Space, app.ReadResolution, bool) { + space, ok := p.getSpaceForGate(w, spaceID, false) + if !ok { + return nil, app.ReadDenied, false + } + resolution, ok := p.requireSpaceReadFrom(w, "api.space.read", space, userID) + if !ok { + return nil, app.ReadDenied, false + } + return space, resolution, true +} + +// requireSpacePagePerm gates a route on a single page-scoped permission (read-only; write +// permissions go through requirePageWrite so the auto-join pre-step can run first). +func (p *Plugin) requireSpacePagePerm(w http.ResponseWriter, spaceID, userID string, perm *mmmodel.Permission) (*model.Space, bool) { + return p.requireSpaceGate(w, spaceID, false, func(space *model.Space) *mmmodel.AppError { + return p.service.RequireSpacePagePermission("api.space.page", space, userID, perm) + }) +} + +// requireSpaceManage gates a route on manage-tier authority: sysadmin, channel admin_space, or +// (once the read resolver has already admitted the caller) team manage_space. +func (p *Plugin) requireSpaceManage(w http.ResponseWriter, spaceID, userID string) (*model.Space, bool) { + return p.requireSpaceGate(w, spaceID, false, func(space *model.Space) *mmmodel.AppError { + return p.service.RequireSpaceAdminOrTeamPerm("api.space.manage", space, userID, mmmodel.PermissionManageSpace) + }) +} + +// requireSpaceAdmin gates a route on Service.RequireSpaceAdminOrSysadmin — the space-wide +// exposure-policy knobs (ViewAccess, default capabilities). +func (p *Plugin) requireSpaceAdmin(w http.ResponseWriter, spaceID, userID string) (*model.Space, bool) { + return p.requireSpaceGate(w, spaceID, false, func(space *model.Space) *mmmodel.AppError { + return p.service.RequireSpaceAdminOrSysadmin("api.space.admin", space, userID) + }) +} + +// requireSpaceDelete gates space delete/restore: sysadmin, channel admin_space, or (once the +// read resolver has already admitted the caller) team delete_space. includeDeleted must be true +// for restore, where the space is soft-deleted at lookup time; the read resolver and the delete +// gate then evaluate against that soft-deleted record. +func (p *Plugin) requireSpaceDelete(w http.ResponseWriter, spaceID, userID string, includeDeleted bool) (*model.Space, bool) { + return p.requireSpaceGate(w, spaceID, includeDeleted, func(space *model.Space) *mmmodel.AppError { + return p.service.RequireSpaceAdminOrTeamPerm("api.space.delete", space, userID, mmmodel.PermissionDeleteSpace) + }) +} + +// requirePageWrite resolves the read gate first — a caller cannot be granted write authority over a +// space it cannot read — then runs the auto-join pre-step when that read was admitted only via the +// non-member open-space fall-through, then re-resolves perm as a (possibly just-joined) member. +// Writes the error response and returns false on any denial. +// +// joined reports whether the pre-step created a membership; a handler whose write then fails must +// pass it to the service's UndoAutoJoin. +func (p *Plugin) requirePageWrite(w http.ResponseWriter, space *model.Space, userID string, perm *mmmodel.Permission) (joined, ok bool) { + resolution, readOK := p.requireSpaceReadFrom(w, "requirePageWrite", space, userID) + if !readOK { + return false, false + } + return p.requirePageWriteFrom(w, space, userID, perm, resolution) +} + +// requirePageWriteFrom is requirePageWrite for a caller that has already resolved the read gate for the +// same space and user, so that resolution is not derived a second time. +func (p *Plugin) requirePageWriteFrom(w http.ResponseWriter, space *model.Space, userID string, perm *mmmodel.Permission, resolution app.ReadResolution) (joined, ok bool) { + joined, appErr := p.service.AutoJoinIfDefaultGranted(space, userID, resolution, perm, nil) + if appErr != nil { + p.writeAppError(w, appErr) + return false, false + } + if permErr := p.service.RequireSpacePagePermissionFrom("api.page.write", space, userID, perm, resolution); permErr != nil { + // The gate itself rejected the caller, so the pre-step's membership is undone here rather + // than left for the handler, which never sees this path. + p.service.UndoAutoJoin(joined, space, userID) + p.writeAppError(w, permErr) + return false, false + } + return joined, true +} + +// requireDeleteOwnOrAnyFrom gates a delete-class page operation: delete_page (any), or delete_own_page +// when ownerID == userID. Both are grantable as space defaults, so the auto-join pre-step is +// attempted against each in turn — delete_page first, unconditionally, then delete_own_page gated on +// ownership. Attempting only the own-scoped one would deny a non-member the delete a space +// defaulting to delete_page entitles them to. resolution is the read gate the caller has already +// resolved for the same space and user, so it is not re-derived here. +func (p *Plugin) requireDeleteOwnOrAnyFrom(w http.ResponseWriter, space *model.Space, userID, ownerID string, resolution app.ReadResolution) (joined, ok bool) { + joined, appErr := p.service.AutoJoinIfDefaultGranted(space, userID, resolution, mmmodel.PermissionDeletePage, nil) + if appErr != nil { + p.writeAppError(w, appErr) + return false, false + } + if !joined { + joined, appErr = p.service.AutoJoinIfDefaultGranted(space, userID, resolution, mmmodel.PermissionDeleteOwnPage, func() (bool, error) { return ownerID == userID, nil }) + if appErr != nil { + p.writeAppError(w, appErr) + return false, false + } + } + if _, gateOK := p.requireOwnOrAnyFrom(w, "requireDeleteOwnOrAnyFrom", space, userID, + "api.page.delete", mmmodel.PermissionDeletePage, + "api.page.delete_own", mmmodel.PermissionDeleteOwnPage, ownerID == userID, resolution); !gateOK { + p.service.UndoAutoJoin(joined, space, userID) + return false, false + } + return joined, true +} + +// requireDraftWrite gates a draft mutation on create-or-edit authority over the space, resolving +// the read gate first like every other write. It returns the space so the caller can pass the +// backing channel on to the service. +// joined reports whether the auto-join pre-step created a membership; a handler whose draft write +// then fails must pass it to the service's UndoAutoJoin. +func (p *Plugin) requireDraftWrite(w http.ResponseWriter, spaceID, userID string) (space *model.Space, resolution app.ReadResolution, joined, ok bool) { + space, resolution, readOK := p.requireSpaceRead(w, spaceID, userID) + if !readOK { + return nil, app.ReadDenied, false, false + } + joined, appErr := p.service.RequireSpaceDraftWrite("api.page_draft.write", space, userID, resolution) + if appErr != nil { + p.service.UndoAutoJoin(joined, space, userID) + p.writeAppError(w, appErr) + return nil, app.ReadDenied, false, false + } + return space, resolution, joined, true +} + +// requireOwnOrAnyFrom resolves a two-tier own/any permission pair against an already-resolved +// read, mapping a denial to the shared existence-hiding 403 under where. It reports whether the +// caller qualified only via ownPerm, which a caller that must push ownership enforcement further +// down needs. Writes the error response and returns ok=false on failure. +func (p *Plugin) requireOwnOrAnyFrom(w http.ResponseWriter, where string, space *model.Space, userID, anyWhere string, anyPerm *mmmodel.Permission, ownWhere string, ownPerm *mmmodel.Permission, ownerMatches bool, resolution app.ReadResolution) (ownOnly, ok bool) { + ownOnly, allowed, permErr := p.service.ResolveSpacePageOwnOrAny(space, userID, anyWhere, anyPerm, ownWhere, ownPerm, ownerMatches, resolution) + if permErr != nil { + p.writeAppError(w, permErr) + return false, false + } + if !allowed { + p.writeAppError(w, app.ExistenceHidingForbidden(where)) + return false, false + } + return ownOnly, true +} + +// requireSpaceReadFrom resolves the read gate that precedes every page read and page-write gate, +// mapping a denied read to the shared existence-hiding 403. Writes the error response and returns +// ok=false on failure. +func (p *Plugin) requireSpaceReadFrom(w http.ResponseWriter, where string, space *model.Space, userID string) (app.ReadResolution, bool) { + resolution, resErr := p.service.ResolveSpaceRead(where, space, userID) + if resErr != nil { + p.writeAppError(w, resErr) + return app.ReadDenied, false + } + if resolution == app.ReadDenied { + p.writeAppError(w, app.ExistenceHidingForbidden(where)) + return app.ReadDenied, false + } + return resolution, true +} + // EnableDocsRequired is a middleware that rejects all API requests with 501 Not Implemented when // the EnableDocs feature flag is off. func (p *Plugin) EnableDocsRequired(next http.Handler) http.Handler { diff --git a/server/api_handler_test.go b/server/api_handler_test.go index fc0d569..814aa70 100644 --- a/server/api_handler_test.go +++ b/server/api_handler_test.go @@ -5,9 +5,9 @@ // router) over an isolated Postgres schema and drive handlers via httptest. CreateSpace needs a // pluginapi client, so that path uses a plugintest.API mock; the rest seed via the store directly. // -// Every space- and page-scoped handler enforces backing-channel membership (CheckSpaceMembership), -// returning 403 Forbidden for non-members. Per-page role ACLs (author vs. editor) are not yet -// implemented and are deferred to a follow-up. +// Every space- and page-scoped handler enforces the capability-based RBAC gates in +// server/app/permissions.go, returning 403 Forbidden for a caller the read resolver denies. +// Per-page role ACLs (author vs. editor) are not yet implemented and are deferred to a follow-up. package main import ( @@ -17,6 +17,7 @@ import ( "io" "net/http" "net/http/httptest" + "slices" "strings" "testing" @@ -51,21 +52,35 @@ func openTestPlugin(t *testing.T, mockAPI *plugintest.API) *apiTestHarness { var client *pluginapi.Client if mockAPI == nil { - // Minimal stub: satisfies the EnableDocsRequired middleware and grants any user membership - // to any channel/team so space-scoped tests pass membership checks without a real server. - // Channel side-effects (archive/restore) are no-ops so store-only tests don't need to - // set up specific channel expectations. mockAPI = newEnabledMockAPI() - mockAPI.On("GetChannelMember", mock.Anything, mock.Anything).Return(&mmmodel.ChannelMember{}, nil).Maybe() - mockAPI.On("GetTeamMember", mock.Anything, mock.Anything).Return(&mmmodel.TeamMember{}, nil).Maybe() - mockAPI.On("DeleteChannel", mock.Anything).Return(nil).Maybe() - mockAPI.On("RestoreChannel", mock.Anything).Return(nil).Maybe() - mockAPI.On("GetChannelMembers", mock.Anything, mock.AnythingOfType("int"), mock.AnythingOfType("int")).Return(mmmodel.ChannelMembers{}, nil).Maybe() - mockAPI.On("GetChannelOfType", mock.Anything, mock.Anything).Return((*mmmodel.Channel)(nil), nil).Maybe() } + // Minimal defaults: satisfy the EnableDocsRequired middleware and isActiveTeamMember/read + // checks so space-scoped tests pass membership checks without a real server. Channel + // side-effects (archive/restore) are no-ops so store-only tests don't need to set up specific + // channel expectations. .Maybe() and registered last, so any test-specific stub registered + // before openTestPlugin is called takes precedence. + mockAPI.On("GetChannelMember", mock.Anything, mock.Anything).Return(&mmmodel.ChannelMember{}, nil).Maybe() + mockAPI.On("GetTeamMember", mock.Anything, mock.Anything).Return(&mmmodel.TeamMember{}, nil).Maybe() + mockAPI.On("DeleteChannel", mock.Anything).Return(nil).Maybe() + mockAPI.On("RestoreChannel", mock.Anything).Return(nil).Maybe() + mockAPI.On("GetChannelMembers", mock.Anything, mock.AnythingOfType("int"), mock.AnythingOfType("int")).Return(mmmodel.ChannelMembers{}, nil).Maybe() + mockAPI.On("GetChannelStats", mock.Anything).Return(&mmmodel.ChannelStats{}, nil).Maybe() // writeAppError logs 500-class failures (message plus four key/value pairs) regardless of - // which mock a test supplies, so stub it universally. + // which mock a test supplies, so stub it universally. The two-pair shape covers the + // custom-scheme retire failures. + mockAPI.On("LogError", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return().Maybe() mockAPI.On("LogError", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return().Maybe() + testutil.StubDefaultSpacePermissions(mockAPI) + testutil.StubPresetSchemes(mockAPI) + // Resolves any channel to the contribute preset, and serves the channel read/write that + // UpdateSpace's best-effort metadata sync (syncSpaceChannelMetadata) performs. Registered last + // because mock.Mock matches in registration order: a test seeding a channel at a specific + // (non-contribute) scheme, or asserting a repoint, registers its own stub before calling + // openTestPlugin (testutil.MustSeedChannelScheme / stubSpaceSchemeRepoint) so it wins. + testutil.StubDefaultChannelScheme(mockAPI) + // CreateSpace assigns the creator SchemeAdmin via the scheme's resolved role-name string; no + // test asserts the exact roles argument, so a wildcard catch-all covers every create. + mockAPI.On("UpdateChannelMemberRoles", mock.Anything, mock.Anything, mock.Anything).Return(&mmmodel.ChannelMember{}, nil).Maybe() client = pluginapi.NewClient(mockAPI, nil) t.Cleanup(func() { mockAPI.AssertExpectations(t) }) @@ -87,6 +102,32 @@ func newEnabledMockAPI() *plugintest.API { return mockAPI } +// grantSpaceManage, grantSpaceAdmin, and grantSpaceDelete register an elevated-permission +// expectation for userID that MUST be added to mockAPI before it is passed to openTestPlugin: +// openTestPlugin registers testutil.StubDefaultSpacePermissions' default-deny catch-alls for +// manage_space/admin_space/delete_space, and mock.Mock matches expectations in registration +// order, so a stub added after openTestPlugin runs would never be reached. +func grantSpaceManage(mockAPI *plugintest.API, userID string) { + mockAPI.On("HasPermissionToTeam", userID, mock.Anything, mmmodel.PermissionManageSpace).Return(true) +} + +func grantSpaceDelete(mockAPI *plugintest.API, userID string) { + mockAPI.On("HasPermissionToTeam", userID, mock.Anything, mmmodel.PermissionDeleteSpace).Return(true) +} + +// grantSpaceAdmin registers a channel-level admin_space grant for userID on channelID — the +// exposure-policy gate (RequireSpaceAdminOrSysadmin), stricter than grantSpaceManage's team +// manage_space grant, which that gate deliberately does not accept. +func grantSpaceAdmin(mockAPI *plugintest.API, channelID, userID string) { + mockAPI.On("HasPermissionToChannel", userID, channelID, mmmodel.PermissionAdminSpace).Return(true) +} + +// grantSysadmin registers the system-wide manage_system override for userID, satisfying every +// space gate unconditionally. +func grantSysadmin(mockAPI *plugintest.API, userID string) { + mockAPI.On("HasPermissionTo", userID, mmmodel.PermissionManageSystem).Return(true) +} + // do issues a request through the plugin router. An empty userID omits the auth header. func (h *apiTestHarness) do(t *testing.T, method, path, userID string, body any) *httptest.ResponseRecorder { t.Helper() @@ -105,12 +146,15 @@ func (h *apiTestHarness) do(t *testing.T, method, path, userID string, body any) return rec } -func seedSpace(t *testing.T, s *store.Store, channelID string) *model.Space { +func seedSpace(t *testing.T, s *store.Store, db *sql.DB, channelID string) *model.Space { t.Helper() - return seedSpaceInTeam(t, s, channelID, mmmodel.NewId()) + return seedSpaceInTeam(t, s, db, channelID, mmmodel.NewId()) } -func seedSpaceInTeam(t *testing.T, s *store.Store, channelID, teamID string) *model.Space { +// seedSpaceInTeam creates a space via the store directly. Its channel resolves to the contribute +// preset through the generic catch-all (testutil.StubDefaultChannelScheme, wired in +// openTestPlugin), so no per-channel scheme stub is registered here. +func seedSpaceInTeam(t *testing.T, s *store.Store, db *sql.DB, channelID, teamID string) *model.Space { t.Helper() return testutil.MustCreateSpace(t, s, channelID, teamID) } @@ -146,10 +190,13 @@ func TestHandler_CreateSpace(t *testing.T) { mockAPI := newEnabledMockAPI() mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). Return(&mmmodel.TeamMember{}, nil) + backingChannelID := mmmodel.NewId() mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). - Return(&mmmodel.Channel{Id: mmmodel.NewId(), Type: mmmodel.ChannelTypeSpace}, nil) + Return(&mmmodel.Channel{Id: backingChannelID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). Return(&mmmodel.ChannelMember{}, nil) + mockAPI.On("UpdateChannelMemberRoles", backingChannelID, mock.Anything, mock.Anything). + Return(&mmmodel.ChannelMember{}, nil) h := openTestPlugin(t, mockAPI) teamID := mmmodel.NewId() @@ -158,10 +205,17 @@ func TestHandler_CreateSpace(t *testing.T) { }) require.Equal(t, http.StatusCreated, rec.Code) - var created model.Space + var created model.SpaceWithAccess require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created)) require.NotEmpty(t, created.Id) require.Equal(t, "My Space", created.Title) + // Create establishes the space's access state, so the response carries it and the caller needs + // no follow-up read: the seeded contribute default, and the creator's own admin set. + contribute, ok := model.DefaultCapabilitiesForSchemeName(mmmodel.SchemeNameSpaceContribute) + require.True(t, ok) + require.Equal(t, contribute, created.DefaultCapabilities) + require.Equal(t, model.AdminEffectiveCapabilities(), created.Capabilities) + require.Contains(t, created.Capabilities, model.CapabilityAdminSpace) } // TestHandler_CreateSpace_IgnoresServerOwnedFields ensures the create handler does not trust @@ -171,10 +225,13 @@ func TestHandler_CreateSpace_IgnoresServerOwnedFields(t *testing.T) { mockAPI := newEnabledMockAPI() mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). Return(&mmmodel.TeamMember{}, nil) + backingChannelID := mmmodel.NewId() mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). - Return(&mmmodel.Channel{Id: mmmodel.NewId(), Type: mmmodel.ChannelTypeSpace}, nil) + Return(&mmmodel.Channel{Id: backingChannelID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). Return(&mmmodel.ChannelMember{}, nil) + mockAPI.On("UpdateChannelMemberRoles", backingChannelID, mock.Anything, mock.Anything). + Return(&mmmodel.ChannelMember{}, nil) mockAPI.On("GetChannelMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). Return(&mmmodel.ChannelMember{}, nil) h := openTestPlugin(t, mockAPI) @@ -207,7 +264,7 @@ func TestHandler_SpaceAndPageRoundTrip(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) t.Run("get space", func(t *testing.T) { rec := h.do(t, http.MethodGet, "/api/v1/spaces/"+space.Id, user, nil) @@ -237,14 +294,17 @@ func TestHandler_SpaceAndPageRoundTrip(t *testing.T) { t.Run("get page in wrong space is 404", func(t *testing.T) { page := seedPage(t, h.store, space.Id, channelID, "") - otherSpace := seedSpace(t, h.store, mmmodel.NewId()) + otherSpace := seedSpace(t, h.store, h.db, mmmodel.NewId()) rec := h.do(t, http.MethodGet, "/api/v1/spaces/"+otherSpace.Id+"/pages/"+page.Id, user, nil) require.Equal(t, http.StatusNotFound, rec.Code) }) t.Run("move then delete page", func(t *testing.T) { parent := seedPage(t, h.store, space.Id, channelID, "") - page := seedPage(t, h.store, space.Id, channelID, "") + // Owned by the acting user: a contribute-default member holds only delete_own_page, not + // delete_page (any) — deleting a page owned by someone else needs that separate grant, + // exercised separately. + page := testutil.MustCreatePage(t, h.store, space.Id, channelID, user, "") rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/pages/"+page.Id+"/move", user, map[string]any{ "parent_id": parent.Id, @@ -263,12 +323,16 @@ func TestHandler_SpaceAndPageRoundTrip(t *testing.T) { }) } -// TestHandler_UpdateSpace patches a space's mutable fields. +// TestHandler_UpdateSpace patches a space's mutable fields. Updating a space is manage-gated: the +// acting user needs an elevated grant, not bare membership. func TestHandler_UpdateSpace(t *testing.T) { - h := openTestPlugin(t, nil) - space := seedSpace(t, h.store, mmmodel.NewId()) + mockAPI := newEnabledMockAPI() + adminID := mmmodel.NewId() + grantSpaceManage(mockAPI, adminID) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, mmmodel.NewId()) - rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id, mmmodel.NewId(), map[string]any{ + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id, adminID, map[string]any{ "title": "Renamed", "description": "New description", "expected_update_at": space.UpdateAt, @@ -280,19 +344,35 @@ func TestHandler_UpdateSpace(t *testing.T) { require.Equal(t, "New description", updated.Description) // A stale baseline now conflicts (the optimistic lock is client-supplied). - rec = h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id, mmmodel.NewId(), map[string]any{ + rec = h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id, adminID, map[string]any{ "title": "Stale", "expected_update_at": space.UpdateAt, }) require.Equal(t, http.StatusConflict, rec.Code) } -// TestHandler_DeleteSpace deletes a space; a follow-up read returns 403 (CheckSpaceMembership -// masks deleted-space existence to prevent probing via the status code). -func TestHandler_DeleteSpace(t *testing.T) { +// TestHandler_UpdateSpace_NonManageMemberForbidden verifies an ordinary contribute-default member +// (no manage_space/admin_space grant) cannot update a space's mutable fields. +func TestHandler_UpdateSpace_NonManageMemberForbidden(t *testing.T) { h := openTestPlugin(t, nil) + space := seedSpace(t, h.store, h.db, mmmodel.NewId()) + + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id, mmmodel.NewId(), map[string]any{ + "title": "Renamed", + "expected_update_at": space.UpdateAt, + }) + require.Equal(t, http.StatusForbidden, rec.Code) +} + +// TestHandler_DeleteSpace deletes a space; a follow-up read returns 403 (the gate masks +// deleted-space existence to prevent probing via the status code). Delete/restore requires team +// delete_space or channel admin_space. +func TestHandler_DeleteSpace(t *testing.T) { + mockAPI := newEnabledMockAPI() user := mmmodel.NewId() - space := seedSpace(t, h.store, mmmodel.NewId()) + grantSpaceDelete(mockAPI, user) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, mmmodel.NewId()) rec := h.do(t, http.MethodDelete, "/api/v1/spaces/"+space.Id, user, nil) require.Equal(t, http.StatusOK, rec.Code) @@ -304,9 +384,11 @@ func TestHandler_DeleteSpace(t *testing.T) { // TestHandler_RestoreSpace deletes then restores a space; a second restore of an already-live // space is rejected as a 409 (conflict — the space is already live). func TestHandler_RestoreSpace(t *testing.T) { - h := openTestPlugin(t, nil) + mockAPI := newEnabledMockAPI() user := mmmodel.NewId() - space := seedSpace(t, h.store, mmmodel.NewId()) + grantSpaceDelete(mockAPI, user) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, mmmodel.NewId()) rec := h.do(t, http.MethodDelete, "/api/v1/spaces/"+space.Id, user, nil) require.Equal(t, http.StatusOK, rec.Code) @@ -331,7 +413,7 @@ func TestHandler_RestoreSpace(t *testing.T) { func TestHandler_GetSpacePages(t *testing.T) { h := openTestPlugin(t, nil) channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) seedPage(t, h.store, space.Id, channelID, "") seedPage(t, h.store, space.Id, channelID, "") @@ -351,7 +433,7 @@ func TestHandler_PageCollectionsReturnMetadataOnly(t *testing.T) { h := openTestPlugin(t, nil) userID := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) parentInput := testutil.NewPage(space.Id, channelID, userID, "") parentInput.Title = "Parent" @@ -406,23 +488,27 @@ func TestHandler_PageCollectionsReturnMetadataOnly(t *testing.T) { require.Contains(t, detail, "props") } -// TestHandler_ListSpaceMembers lists a space's members through the backing channel. -func TestHandler_ListSpaceMembers(t *testing.T) { +// TestHandler_GetSpaceMembers lists a space's members through the backing channel. The members +// list is manage-gated — deliberately stricter than MM channels, since the projection +// carries the per-member capability matrix. +func TestHandler_GetSpaceMembers(t *testing.T) { channelID := mmmodel.NewId() memberUserID := mmmodel.NewId() + adminID := mmmodel.NewId() mockAPI := newEnabledMockAPI() + grantSpaceManage(mockAPI, adminID) mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). Return(&mmmodel.TeamMember{}, nil) mockAPI.On("GetChannelMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). - Return(&mmmodel.ChannelMember{}, nil) + Return(&mmmodel.ChannelMember{}, nil).Maybe() mockAPI.On("GetChannelMembers", channelID, mock.AnythingOfType("int"), mock.AnythingOfType("int")). Return(mmmodel.ChannelMembers{{ChannelId: channelID, UserId: memberUserID}}, nil) h := openTestPlugin(t, mockAPI) - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) - rec := h.do(t, http.MethodGet, "/api/v1/spaces/"+space.Id+"/members", mmmodel.NewId(), nil) + rec := h.do(t, http.MethodGet, "/api/v1/spaces/"+space.Id+"/members", adminID, nil) require.Equal(t, http.StatusOK, rec.Code) var resp paginatedResponse[*model.SpaceMember] @@ -432,19 +518,21 @@ func TestHandler_ListSpaceMembers(t *testing.T) { require.False(t, resp.HasMore) } -// TestHandler_ListSpaceMembers_HasMore verifies the probe row: when the requested page comes back +// TestHandler_GetSpaceMembers_HasMore verifies the probe row: when the requested page comes back // full and another member exists on the next page, has_more is true and the page is trimmed to // per_page entries. -func TestHandler_ListSpaceMembers_HasMore(t *testing.T) { +func TestHandler_GetSpaceMembers_HasMore(t *testing.T) { channelID := mmmodel.NewId() firstMember := mmmodel.NewId() secondMember := mmmodel.NewId() + adminID := mmmodel.NewId() mockAPI := newEnabledMockAPI() + grantSpaceManage(mockAPI, adminID) mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). Return(&mmmodel.TeamMember{}, nil) mockAPI.On("GetChannelMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). - Return(&mmmodel.ChannelMember{}, nil) + Return(&mmmodel.ChannelMember{}, nil).Maybe() // Page 0 at size 1 comes back full, so the handler probes the next page's first slot // (page-indexed API: index (page+1)*perPage at size 1). mockAPI.On("GetChannelMembers", channelID, 0, 1). @@ -453,9 +541,9 @@ func TestHandler_ListSpaceMembers_HasMore(t *testing.T) { Return(mmmodel.ChannelMembers{{ChannelId: channelID, UserId: secondMember}}, nil) h := openTestPlugin(t, mockAPI) - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) - rec := h.do(t, http.MethodGet, "/api/v1/spaces/"+space.Id+"/members?per_page=1", mmmodel.NewId(), nil) + rec := h.do(t, http.MethodGet, "/api/v1/spaces/"+space.Id+"/members?per_page=1", adminID, nil) require.Equal(t, http.StatusOK, rec.Code) var resp paginatedResponse[*model.SpaceMember] @@ -465,23 +553,26 @@ func TestHandler_ListSpaceMembers_HasMore(t *testing.T) { require.True(t, resp.HasMore) } -// TestHandler_AddSpaceMember adds a member to a space; any current space member may do so. +// TestHandler_AddSpaceMember adds a member to a space; the caller needs requireSpaceManage +// authority. func TestHandler_AddSpaceMember(t *testing.T) { channelID := mmmodel.NewId() targetUserID := mmmodel.NewId() + adminID := mmmodel.NewId() mockAPI := newEnabledMockAPI() + grantSpaceManage(mockAPI, adminID) mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). Return(&mmmodel.TeamMember{}, nil) mockAPI.On("GetChannelMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). - Return(&mmmodel.ChannelMember{}, nil) + Return(&mmmodel.ChannelMember{}, nil).Maybe() mockAPI.On("AddChannelMember", channelID, targetUserID). Return(&mmmodel.ChannelMember{ChannelId: channelID, UserId: targetUserID}, nil) h := openTestPlugin(t, mockAPI) - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) - rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+space.Id+"/members", mmmodel.NewId(), map[string]any{ + rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+space.Id+"/members", adminID, map[string]any{ "user_id": targetUserID, }) require.Equal(t, http.StatusCreated, rec.Code) @@ -492,12 +583,31 @@ func TestHandler_AddSpaceMember(t *testing.T) { mockAPI.AssertCalled(t, "AddChannelMember", channelID, targetUserID) } -// TestHandler_RemoveSpaceMember removes a member from a space. +// TestHandler_AddSpaceMember_NonManageMemberForbidden verifies an ordinary member without a +// manage grant cannot add another member. +func TestHandler_AddSpaceMember_NonManageMemberForbidden(t *testing.T) { + channelID := mmmodel.NewId() + targetUserID := mmmodel.NewId() + + h := openTestPlugin(t, nil) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+space.Id+"/members", mmmodel.NewId(), map[string]any{ + "user_id": targetUserID, + }) + require.Equal(t, http.StatusForbidden, rec.Code) +} + +// TestHandler_RemoveSpaceMember removes a member from a space; removing a non-self target +// requires requireSpaceManage — self-removal stays membership-gated, covered by +// TestHandler_RemoveSpaceMember_Self. func TestHandler_RemoveSpaceMember(t *testing.T) { channelID := mmmodel.NewId() targetUserID := mmmodel.NewId() + adminID := mmmodel.NewId() mockAPI := newEnabledMockAPI() + grantSpaceManage(mockAPI, adminID) mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). Return(&mmmodel.TeamMember{}, nil) mockAPI.On("GetChannelMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). @@ -509,13 +619,49 @@ func TestHandler_RemoveSpaceMember(t *testing.T) { mockAPI.On("DeleteChannelMember", channelID, targetUserID).Return(nil) h := openTestPlugin(t, mockAPI) - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) - rec := h.do(t, http.MethodDelete, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID, mmmodel.NewId(), nil) + rec := h.do(t, http.MethodDelete, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID, adminID, nil) require.Equal(t, http.StatusOK, rec.Code) mockAPI.AssertCalled(t, "DeleteChannelMember", channelID, targetUserID) } +// TestHandler_RemoveSpaceMember_NonManageMemberForbidden verifies an ordinary member without a +// manage grant cannot remove another member. +func TestHandler_RemoveSpaceMember_NonManageMemberForbidden(t *testing.T) { + channelID := mmmodel.NewId() + targetUserID := mmmodel.NewId() + + h := openTestPlugin(t, nil) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodDelete, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID, mmmodel.NewId(), nil) + require.Equal(t, http.StatusForbidden, rec.Code) +} + +// TestHandler_RemoveSpaceMember_Self verifies self-removal stays membership-gated: any member may +// leave the space they belong to, no manage grant required. +func TestHandler_RemoveSpaceMember_Self(t *testing.T) { + channelID := mmmodel.NewId() + selfID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.TeamMember{}, nil) + mockAPI.On("GetChannelMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.ChannelMember{}, nil) + mockAPI.On("GetChannelMembers", channelID, 0, app.PerPageMaximum). + Return(mmmodel.ChannelMembers{{ChannelId: channelID, UserId: selfID}, {ChannelId: channelID, UserId: mmmodel.NewId()}}, nil) + mockAPI.On("DeleteChannelMember", channelID, selfID).Return(nil) + h := openTestPlugin(t, mockAPI) + + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodDelete, "/api/v1/spaces/"+space.Id+"/members/"+selfID, selfID, nil) + require.Equal(t, http.StatusOK, rec.Code) + mockAPI.AssertCalled(t, "DeleteChannelMember", channelID, selfID) +} + // TestHandler_GetTeamSpaces lists the spaces on a team visible to the caller. func TestHandler_GetTeamSpaces(t *testing.T) { channelID := mmmodel.NewId() @@ -526,7 +672,7 @@ func TestHandler_GetTeamSpaces(t *testing.T) { mockAPI.On("GetTeamMember", teamID, caller).Return(&mmmodel.TeamMember{}, nil) h := openTestPlugin(t, mockAPI) - seedSpaceInTeam(t, h.store, channelID, teamID) + seedSpaceInTeam(t, h.store, h.db, channelID, teamID) testutil.MustAddChannelMember(t, h.db, channelID, caller) rec := h.do(t, http.MethodGet, "/api/v1/teams/"+teamID+"/spaces", caller, nil) @@ -541,7 +687,7 @@ func TestHandler_UpdatePage(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) page := seedPage(t, h.store, space.Id, channelID, "") // SearchText is derived from the body server-side, so only the body is supplied; a @@ -582,7 +728,7 @@ func TestHandler_UpdatePage_BaselineRequired(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) page := seedPage(t, h.store, space.Id, channelID, "") rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/pages/"+page.Id, user, map[string]any{ @@ -594,7 +740,7 @@ func TestHandler_UpdatePage_BaselineRequired(t *testing.T) { require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &appErr)) require.Equal(t, "app.optimistic_lock.baseline_required.app_error", appErr.Id) - // force substitutes for the baseline (last-write-wins). + // force substitutes for the baseline, so the update applies over the stored row. rec = h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/pages/"+page.Id, user, map[string]any{ "body": `{"type":"doc","content":[]}`, "search_text": "text", @@ -609,7 +755,7 @@ func TestHandler_UpdatePage_Props(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) page := seedPage(t, h.store, space.Id, channelID, "") body := map[string]any{ @@ -629,8 +775,10 @@ func TestHandler_RestorePage(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) - page := seedPage(t, h.store, space.Id, channelID, "") + space := seedSpace(t, h.store, h.db, channelID) + // Owned by the acting user: a contribute-default member holds only delete_own_page, not + // delete_page (any). + page := testutil.MustCreatePage(t, h.store, space.Id, channelID, user, "") rec := h.do(t, http.MethodDelete, "/api/v1/spaces/"+space.Id+"/pages/"+page.Id, user, nil) require.Equal(t, http.StatusOK, rec.Code) @@ -651,7 +799,7 @@ func TestHandler_GetPageChildren(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) parent := seedPage(t, h.store, space.Id, channelID, "") childA := seedPage(t, h.store, space.Id, channelID, parent.Id) childB := seedPage(t, h.store, space.Id, channelID, parent.Id) @@ -673,8 +821,8 @@ func TestHandler_GetPageChildren_WrongSpaceIs404(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) - otherSpace := seedSpace(t, h.store, mmmodel.NewId()) + space := seedSpace(t, h.store, h.db, channelID) + otherSpace := seedSpace(t, h.store, h.db, mmmodel.NewId()) parent := seedPage(t, h.store, space.Id, channelID, "") seedPage(t, h.store, space.Id, channelID, parent.Id) @@ -687,10 +835,12 @@ func TestHandler_MovePageToSpace(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelA := mmmodel.NewId() - spaceA := seedSpace(t, h.store, channelA) - page := seedPage(t, h.store, spaceA.Id, channelA, "") + spaceA := seedSpace(t, h.store, h.db, channelA) + // Owned by the acting user: move-to-space's source-side own-path requires ownership of the + // entire moved subtree, and a contribute-default member holds only delete_own_page. + page := testutil.MustCreatePage(t, h.store, spaceA.Id, channelA, user, "") - spaceB, err := h.store.CreateSpace(&model.Space{ChannelId: mmmodel.NewId(), TeamId: spaceA.TeamId, CreatorId: user, Title: "B"}) + spaceB, err := h.store.CreateSpace(&model.Space{ChannelId: mmmodel.NewId(), TeamId: spaceA.TeamId, CreatorId: user, Title: "B", ViewAccess: model.ViewAccessOpen}) require.NoError(t, err) rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+spaceA.Id+"/pages/"+page.Id+"/move-to-space", user, map[string]any{ @@ -703,13 +853,44 @@ func TestHandler_MovePageToSpace(t *testing.T) { require.Equal(t, spaceB.Id, moved.SpaceId) } +// TestHandler_MovePageToSpace_OwnPathRejectsUnownedDescendant drives the own-path subtree-ownership +// rule through the real HTTP route. The default harness grants delete_own_page but not delete_page, +// so the caller is admitted by the own permission, and the move must then require ownership of every +// live page in the moved subtree rather than of the reparented root alone. A descendant owned by +// another user rejects the move. +// +// The route is exercised end to end instead of calling MovePageToSpace with a hand-supplied owner +// id, so the own-versus-any resolution is itself under test: an admission reported as the any-page +// path carries no owner requirement into the store, and this move would then succeed. +func TestHandler_MovePageToSpace_OwnPathRejectsUnownedDescendant(t *testing.T) { + h := openTestPlugin(t, nil) + user := mmmodel.NewId() + other := mmmodel.NewId() + channelA := mmmodel.NewId() + spaceA := seedSpace(t, h.store, h.db, channelA) + root := testutil.MustCreatePage(t, h.store, spaceA.Id, channelA, user, "") + testutil.MustCreatePage(t, h.store, spaceA.Id, channelA, other, root.Id) + + spaceB, err := h.store.CreateSpace(&model.Space{ChannelId: mmmodel.NewId(), TeamId: spaceA.TeamId, CreatorId: user, Title: "B", ViewAccess: model.ViewAccessOpen}) + require.NoError(t, err) + + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+spaceA.Id+"/pages/"+root.Id+"/move-to-space", user, map[string]any{ + "target_space_id": spaceB.Id, + "expected_update_at": root.UpdateAt, + }) + require.Equal(t, http.StatusForbidden, rec.Code) + var appErr mmmodel.AppError + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &appErr)) + require.Equal(t, "app.page.move_to_space.subtree_not_owned.app_error", appErr.Id) +} + // TestHandler_MovePageToSpace_MissingTargetSpaceId verifies that omitting target_space_id returns // the handler-specific 400 before any app-layer call is made. func TestHandler_MovePageToSpace_MissingTargetSpaceId(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelA := mmmodel.NewId() - spaceA := seedSpace(t, h.store, channelA) + spaceA := seedSpace(t, h.store, h.db, channelA) page := seedPage(t, h.store, spaceA.Id, channelA, "") rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+spaceA.Id+"/pages/"+page.Id+"/move-to-space", user, map[string]any{ @@ -727,7 +908,7 @@ func TestHandler_MovePageToSpace_InvalidTargetSpaceId(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelA := mmmodel.NewId() - spaceA := seedSpace(t, h.store, channelA) + spaceA := seedSpace(t, h.store, h.db, channelA) page := seedPage(t, h.store, spaceA.Id, channelA, "") rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+spaceA.Id+"/pages/"+page.Id+"/move-to-space", user, map[string]any{ @@ -746,11 +927,11 @@ func TestHandler_MovePageToSpace_CrossTeamRejected(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelA := mmmodel.NewId() - spaceA := seedSpace(t, h.store, channelA) + spaceA := seedSpace(t, h.store, h.db, channelA) page := seedPage(t, h.store, spaceA.Id, channelA, "") // A second space in a different team (seedSpace randomizes the team id per call). - spaceB := seedSpace(t, h.store, mmmodel.NewId()) + spaceB := seedSpace(t, h.store, h.db, mmmodel.NewId()) rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+spaceA.Id+"/pages/"+page.Id+"/move-to-space", user, map[string]any{ "target_space_id": spaceB.Id, @@ -769,11 +950,13 @@ func TestHandler_MovePageToSpace_DepthExceeded(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelA := mmmodel.NewId() - spaceA := seedSpace(t, h.store, channelA) - page := seedPage(t, h.store, spaceA.Id, channelA, "") + spaceA := seedSpace(t, h.store, h.db, channelA) + // Owned by the acting user: move-to-space's source-side own-path requires ownership of the + // entire moved subtree, and a contribute-default member holds only delete_own_page. + page := testutil.MustCreatePage(t, h.store, spaceA.Id, channelA, user, "") channelB := mmmodel.NewId() - spaceB, err := h.store.CreateSpace(&model.Space{ChannelId: channelB, TeamId: spaceA.TeamId, CreatorId: user, Title: "B"}) + spaceB, err := h.store.CreateSpace(&model.Space{ChannelId: channelB, TeamId: spaceA.TeamId, CreatorId: user, Title: "B", ViewAccess: model.ViewAccessOpen}) require.NoError(t, err) parentID := "" @@ -799,8 +982,10 @@ func TestHandler_MovePageToSpace_RejectsCycle(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelA := mmmodel.NewId() - spaceA := seedSpace(t, h.store, channelA) - root := seedPage(t, h.store, spaceA.Id, channelA, "") + spaceA := seedSpace(t, h.store, h.db, channelA) + // Owned by the acting user: a contribute-default member holds only delete_own_page, so the + // same-space move requires ownership of the reparented page before the cycle check is reached. + root := testutil.MustCreatePage(t, h.store, spaceA.Id, channelA, user, "") child := seedPage(t, h.store, spaceA.Id, channelA, root.Id) rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+spaceA.Id+"/pages/"+root.Id+"/move-to-space", user, map[string]any{ @@ -820,11 +1005,11 @@ func TestHandler_MovePageToSpace_ParentInWrongSpace(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelA := mmmodel.NewId() - spaceA := seedSpace(t, h.store, channelA) + spaceA := seedSpace(t, h.store, h.db, channelA) page := seedPage(t, h.store, spaceA.Id, channelA, "") parentInA := seedPage(t, h.store, spaceA.Id, channelA, "") - spaceB, err := h.store.CreateSpace(&model.Space{ChannelId: mmmodel.NewId(), TeamId: spaceA.TeamId, CreatorId: user, Title: "B"}) + spaceB, err := h.store.CreateSpace(&model.Space{ChannelId: mmmodel.NewId(), TeamId: spaceA.TeamId, CreatorId: user, Title: "B", ViewAccess: model.ViewAccessOpen}) require.NoError(t, err) rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+spaceA.Id+"/pages/"+page.Id+"/move-to-space", user, map[string]any{ @@ -844,7 +1029,7 @@ func TestHandler_MovePage_MaxDepthExceeded(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) parentID := "" for range model.MaxPageDepth { @@ -869,7 +1054,7 @@ func TestHandler_MovePage_CircularReference(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) t.Run("self as parent", func(t *testing.T) { page := seedPage(t, h.store, space.Id, channelID, "") @@ -904,11 +1089,11 @@ func TestHandler_MovePage_ParentInDifferentSpace(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelA := mmmodel.NewId() - spaceA := seedSpace(t, h.store, channelA) + spaceA := seedSpace(t, h.store, h.db, channelA) page := seedPage(t, h.store, spaceA.Id, channelA, "") channelB := mmmodel.NewId() - spaceB := seedSpace(t, h.store, channelB) + spaceB := seedSpace(t, h.store, h.db, channelB) parentInB := seedPage(t, h.store, spaceB.Id, channelB, "") rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+spaceA.Id+"/pages/"+page.Id+"/move", user, map[string]any{ @@ -926,7 +1111,7 @@ func TestHandler_DuplicatePage(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) page := seedPage(t, h.store, space.Id, channelID, "") rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+space.Id+"/pages/"+page.Id+"/duplicate", user, nil) @@ -941,7 +1126,7 @@ func TestHandler_DuplicatePage_WithChildren(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) parent := seedPage(t, h.store, space.Id, channelID, "") seedPage(t, h.store, space.Id, channelID, parent.Id) @@ -959,10 +1144,10 @@ func TestHandler_DuplicatePage_CrossSpace(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelA := mmmodel.NewId() - spaceA := seedSpace(t, h.store, channelA) + spaceA := seedSpace(t, h.store, h.db, channelA) page := seedPage(t, h.store, spaceA.Id, channelA, "") - spaceB, err := h.store.CreateSpace(&model.Space{ChannelId: mmmodel.NewId(), TeamId: spaceA.TeamId, CreatorId: user, Title: "B"}) + spaceB, err := h.store.CreateSpace(&model.Space{ChannelId: mmmodel.NewId(), TeamId: spaceA.TeamId, CreatorId: user, Title: "B", ViewAccess: model.ViewAccessOpen}) require.NoError(t, err) rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+spaceA.Id+"/pages/"+page.Id+"/duplicate", user, map[string]any{ @@ -980,7 +1165,7 @@ func TestHandler_DuplicatePage_InvalidTargetSpaceId(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelA := mmmodel.NewId() - spaceA := seedSpace(t, h.store, channelA) + spaceA := seedSpace(t, h.store, h.db, channelA) page := seedPage(t, h.store, spaceA.Id, channelA, "") rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+spaceA.Id+"/pages/"+page.Id+"/duplicate", user, map[string]any{ @@ -997,7 +1182,7 @@ func TestHandler_DuplicatePage_WithParent(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) parent := seedPage(t, h.store, space.Id, channelID, "") page := seedPage(t, h.store, space.Id, channelID, "") rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+space.Id+"/pages/"+page.Id+"/duplicate", user, map[string]any{ @@ -1014,9 +1199,9 @@ func TestHandler_DuplicatePage_WrongSpaceIs404(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) page := seedPage(t, h.store, space.Id, channelID, "") - otherSpace := seedSpace(t, h.store, mmmodel.NewId()) + otherSpace := seedSpace(t, h.store, h.db, mmmodel.NewId()) rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+otherSpace.Id+"/pages/"+page.Id+"/duplicate", user, nil) require.Equal(t, http.StatusNotFound, rec.Code) @@ -1027,9 +1212,9 @@ func TestHandler_UpdatePage_WrongSpaceIs404(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) page := seedPage(t, h.store, space.Id, channelID, "") - otherSpace := seedSpace(t, h.store, mmmodel.NewId()) + otherSpace := seedSpace(t, h.store, h.db, mmmodel.NewId()) rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+otherSpace.Id+"/pages/"+page.Id, user, map[string]any{ "title": "New Title", @@ -1043,9 +1228,9 @@ func TestHandler_DeletePage_WrongSpaceIs404(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) page := seedPage(t, h.store, space.Id, channelID, "") - otherSpace := seedSpace(t, h.store, mmmodel.NewId()) + otherSpace := seedSpace(t, h.store, h.db, mmmodel.NewId()) rec := h.do(t, http.MethodDelete, "/api/v1/spaces/"+otherSpace.Id+"/pages/"+page.Id, user, nil) require.Equal(t, http.StatusNotFound, rec.Code) @@ -1056,9 +1241,9 @@ func TestHandler_RestorePage_WrongSpaceIs404(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) page := seedPage(t, h.store, space.Id, channelID, "") - otherSpace := seedSpace(t, h.store, mmmodel.NewId()) + otherSpace := seedSpace(t, h.store, h.db, mmmodel.NewId()) // Soft-delete the page so restore has something to act on. _, delErr := h.store.DeletePage(page.Id, space.Id, user) @@ -1069,22 +1254,29 @@ func TestHandler_RestorePage_WrongSpaceIs404(t *testing.T) { } // TestHandler_SpaceMembershipRequired verifies that all space- and page-scoped handlers -// reject callers who are not members of the space's backing channel with 403 Forbidden. +// reject callers who are not members of a private space's backing channel with 403 Forbidden. +// Private, not the default open fixture: an open space deliberately allows non-member reads +// and auto-joins non-member default-granted writes, so this membership-required +// assertion only holds on a private space. func TestHandler_SpaceMembershipRequired(t *testing.T) { mockAPI := newEnabledMockAPI() - h := openTestPlugin(t, mockAPI) - channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) - page := seedPage(t, h.store, space.Id, channelID, "") stranger := mmmodel.NewId() - // The stranger passes the team gate (an active member row) so the channel-membership - // rejection below is what each route exercises. + // The stranger passes the team gate (an active member row) so the lack of the read_page + // channel grant below is what each route exercises. Registered before openTestPlugin: mock + // matching is by registration order, so a stub added after openTestPlugin would be shadowed + // by StubDefaultSpacePermissions' permissive read_page-for-anyone catch-all. mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), stranger). Return(&mmmodel.TeamMember{}, nil) - mockAPI.On("GetChannelMember", channelID, stranger). - Return(nil, &mmmodel.AppError{StatusCode: http.StatusNotFound}) + mockAPI.On("HasPermissionToChannel", stranger, channelID, mmmodel.PermissionReadPage). + Return(false) + + h := openTestPlugin(t, mockAPI) + + space, err := h.store.CreateSpace(&model.Space{ChannelId: channelID, TeamId: mmmodel.NewId(), CreatorId: mmmodel.NewId(), Title: "Private Space", ViewAccess: model.ViewAccessPrivate}) + require.NoError(t, err) + page := seedPage(t, h.store, space.Id, channelID, "") cases := []struct { method string @@ -1122,8 +1314,11 @@ func TestHandler_SpaceMembershipRequired(t *testing.T) { } } -// TestHandler_TeamSpacesPrivacy verifies that GET /teams/{team_id}/spaces filters out spaces -// whose backing channel the caller does not belong to. +// TestHandler_TeamSpacesPrivacy verifies that GET /teams/{team_id}/spaces filters out private +// spaces whose backing channel the caller does not belong to. An open space the caller doesn't +// belong to is deliberately still listed (the list-for-team predicate's open-space branch — +// members' spaces union open spaces the caller can read), so the hidden fixture here must be +// private, not merely non-member, for the exclusion to hold. func TestHandler_TeamSpacesPrivacy(t *testing.T) { mockAPI := newEnabledMockAPI() h := openTestPlugin(t, mockAPI) @@ -1131,8 +1326,9 @@ func TestHandler_TeamSpacesPrivacy(t *testing.T) { teamID := mmmodel.NewId() visibleChannelID := mmmodel.NewId() hiddenChannelID := mmmodel.NewId() - visible := seedSpaceInTeam(t, h.store, visibleChannelID, teamID) - _ = seedSpaceInTeam(t, h.store, hiddenChannelID, teamID) + visible := seedSpaceInTeam(t, h.store, h.db, visibleChannelID, teamID) + _, err := h.store.CreateSpace(&model.Space{ChannelId: hiddenChannelID, TeamId: teamID, CreatorId: mmmodel.NewId(), Title: "Hidden", ViewAccess: model.ViewAccessPrivate}) + require.NoError(t, err) caller := mmmodel.NewId() mockAPI.On("GetTeamMember", teamID, caller). @@ -1161,9 +1357,13 @@ func TestHandler_TeamSpacesHiddenPageBoundary(t *testing.T) { hidden2ChannelID := mmmodel.NewId() caller := mmmodel.NewId() - visible := seedSpaceInTeam(t, h.store, visibleChannelID, teamID) - _ = seedSpaceInTeam(t, h.store, hidden1ChannelID, teamID) - _ = seedSpaceInTeam(t, h.store, hidden2ChannelID, teamID) + visible := seedSpaceInTeam(t, h.store, h.db, visibleChannelID, teamID) + // Private, not merely non-member: an open space the caller can't reach as a member is still + // listed via the list-for-team predicate's open-space branch. + _, err := h.store.CreateSpace(&model.Space{ChannelId: hidden1ChannelID, TeamId: teamID, CreatorId: mmmodel.NewId(), Title: "Hidden 1", ViewAccess: model.ViewAccessPrivate}) + require.NoError(t, err) + _, err = h.store.CreateSpace(&model.Space{ChannelId: hidden2ChannelID, TeamId: teamID, CreatorId: mmmodel.NewId(), Title: "Hidden 2", ViewAccess: model.ViewAccessPrivate}) + require.NoError(t, err) mockAPI.On("GetTeamMember", teamID, caller). Return(&mmmodel.TeamMember{TeamId: teamID, UserId: caller}, nil) @@ -1192,7 +1392,7 @@ func TestHandler_TeamSpacesPagination(t *testing.T) { caller := mmmodel.NewId() for range 3 { channelID := mmmodel.NewId() - _ = seedSpaceInTeam(t, h.store, channelID, teamID) + _ = seedSpaceInTeam(t, h.store, h.db, channelID, teamID) testutil.MustAddChannelMember(t, h.db, channelID, caller) } @@ -1212,15 +1412,17 @@ func TestHandler_TeamSpacesPagination(t *testing.T) { // denied with 403 (the team-membership gate on the listing route). func TestHandler_TeamSpacesNotTeamMember(t *testing.T) { mockAPI := newEnabledMockAPI() - h := openTestPlugin(t, mockAPI) - teamID := mmmodel.NewId() - _ = seedSpaceInTeam(t, h.store, mmmodel.NewId(), teamID) caller := mmmodel.NewId() + // Registered before openTestPlugin: mock matching is by registration order, so a stub added + // after openTestPlugin would be shadowed by its permissive default GetTeamMember catch-all. mockAPI.On("GetTeamMember", teamID, caller). Return(nil, &mmmodel.AppError{StatusCode: http.StatusNotFound}) + h := openTestPlugin(t, mockAPI) + _ = seedSpaceInTeam(t, h.store, h.db, mmmodel.NewId(), teamID) + rec := h.do(t, http.MethodGet, "/api/v1/teams/"+teamID+"/spaces", caller, nil) require.Equal(t, http.StatusForbidden, rec.Code) } @@ -1229,15 +1431,17 @@ func TestHandler_TeamSpacesNotTeamMember(t *testing.T) { // team-membership lookup propagates as 500. func TestHandler_TeamSpacesTeamLookupError(t *testing.T) { mockAPI := newEnabledMockAPI() - h := openTestPlugin(t, mockAPI) - teamID := mmmodel.NewId() - _ = seedSpaceInTeam(t, h.store, mmmodel.NewId(), teamID) caller := mmmodel.NewId() + // Registered before openTestPlugin: mock matching is by registration order, so a stub added + // after openTestPlugin would be shadowed by its permissive default GetTeamMember catch-all. mockAPI.On("GetTeamMember", teamID, caller). Return(nil, &mmmodel.AppError{StatusCode: http.StatusInternalServerError}) + h := openTestPlugin(t, mockAPI) + _ = seedSpaceInTeam(t, h.store, h.db, mmmodel.NewId(), teamID) + rec := h.do(t, http.MethodGet, "/api/v1/teams/"+teamID+"/spaces", caller, nil) require.Equal(t, http.StatusInternalServerError, rec.Code) } @@ -1245,7 +1449,7 @@ func TestHandler_TeamSpacesTeamLookupError(t *testing.T) { // TestHandler_InvalidJSON returns 400 on a malformed request body. func TestHandler_InvalidJSON(t *testing.T) { h := openTestPlugin(t, nil) - space := seedSpace(t, h.store, mmmodel.NewId()) + space := seedSpace(t, h.store, h.db, mmmodel.NewId()) req := httptest.NewRequest(http.MethodPost, "/api/v1/spaces/"+space.Id+"/pages", bytes.NewReader([]byte("{not json"))) req.Header.Set("Mattermost-User-ID", mmmodel.NewId()) @@ -1261,7 +1465,7 @@ func TestHandler_RequestTooLarge(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) page := seedPage(t, h.store, space.Id, channelID, "") // A well-formed JSON document whose "content" field alone exceeds maxPageBodyBytes, so the @@ -1319,7 +1523,7 @@ func TestHandler_MovePage_InvalidParentID(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) page := seedPage(t, h.store, space.Id, channelID, "") bad := "not-a-valid-id" @@ -1339,7 +1543,7 @@ func TestHandler_CreatePage_InvalidParentID(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) bad := "not-a-valid-id" rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+space.Id+"/pages", user, map[string]any{ @@ -1358,7 +1562,7 @@ func TestHandler_DuplicatePage_InvalidParentID(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) page := seedPage(t, h.store, space.Id, channelID, "") bad := "not-a-valid-id" @@ -1377,10 +1581,10 @@ func TestHandler_MovePageToSpace_InvalidParentID(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelA := mmmodel.NewId() - spaceA := seedSpace(t, h.store, channelA) + spaceA := seedSpace(t, h.store, h.db, channelA) page := seedPage(t, h.store, spaceA.Id, channelA, "") - spaceB, err := h.store.CreateSpace(&model.Space{ChannelId: mmmodel.NewId(), TeamId: spaceA.TeamId, CreatorId: user, Title: "B"}) + spaceB, err := h.store.CreateSpace(&model.Space{ChannelId: mmmodel.NewId(), TeamId: spaceA.TeamId, CreatorId: user, Title: "B", ViewAccess: model.ViewAccessOpen}) require.NoError(t, err) bad := "not-a-valid-id" @@ -1400,7 +1604,7 @@ func TestHandler_GetSpace(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) rec := h.do(t, http.MethodGet, "/api/v1/spaces/"+space.Id, user, nil) require.Equal(t, http.StatusOK, rec.Code) @@ -1417,7 +1621,7 @@ func TestHandler_GetPage(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) page := seedPage(t, h.store, space.Id, channelID, "") rec := h.do(t, http.MethodGet, "/api/v1/spaces/"+space.Id+"/pages/"+page.Id, user, nil) @@ -1436,7 +1640,7 @@ func TestHandler_CreatePage(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+space.Id+"/pages", user, map[string]any{ "title": "Brand New Page", @@ -1462,8 +1666,10 @@ func TestHandler_DeletePage(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) - page := seedPage(t, h.store, space.Id, channelID, "") + space := seedSpace(t, h.store, h.db, channelID) + // Owned by the acting user: a contribute-default member holds only delete_own_page, not + // delete_page (any). + page := testutil.MustCreatePage(t, h.store, space.Id, channelID, user, "") rec := h.do(t, http.MethodDelete, "/api/v1/spaces/"+space.Id+"/pages/"+page.Id, user, nil) require.Equal(t, http.StatusOK, rec.Code) @@ -1472,13 +1678,67 @@ func TestHandler_DeletePage(t *testing.T) { require.Equal(t, http.StatusNotFound, rec.Code) } +// TestHandler_DeletePage_OtherOwnerForbidden verifies the negative half of the own/any pair: a +// contribute-default member holds only delete_own_page, so a delete request against a page owned +// by someone else must be forbidden rather than falling through to the delete_page (any) grant it +// does not hold. +func TestHandler_DeletePage_OtherOwnerForbidden(t *testing.T) { + h := openTestPlugin(t, nil) + user := mmmodel.NewId() + channelID := mmmodel.NewId() + space := seedSpace(t, h.store, h.db, channelID) + // Owned by someone else, so ownerMatches is false and the delete_own_page grant cannot apply. + page := testutil.MustCreatePage(t, h.store, space.Id, channelID, mmmodel.NewId(), "") + + rec := h.do(t, http.MethodDelete, "/api/v1/spaces/"+space.Id+"/pages/"+page.Id, user, nil) + require.Equal(t, http.StatusForbidden, rec.Code) + + rec = h.do(t, http.MethodGet, "/api/v1/spaces/"+space.Id+"/pages/"+page.Id, user, nil) + require.Equal(t, http.StatusOK, rec.Code, "the page must still exist since the delete was denied") +} + +// TestHandler_DeletePage_NonMemberAutoJoinsViaDeletePageDefault verifies requireDeleteOwnOrAnyFrom +// attempts the auto-join pre-step against delete_page (any) first, unconditionally: a non-member +// of an open space whose default capability set grants delete_page is auto-joined and can delete a +// page it does not own, rather than being denied because only the narrower delete_own_page +// attempt was tried. +func TestHandler_DeletePage_NonMemberAutoJoinsViaDeletePageDefault(t *testing.T) { + mockAPI := newEnabledMockAPI() + stranger := mmmodel.NewId() + channelID := mmmodel.NewId() + + // The stranger is not yet a member: read_page is withheld so the read gate admits them only + // through the open-space fall-through, which is what the auto-join pre-step keys on. delete_page + // reflects what their post-join role would grant, standing in for the space's default + // capability set — the same way TestAutoJoin_JoinsWhenDefaultGrants' RolesGrantPermission stub + // does. Registered before openTestPlugin: mock.Mock matches expectations in registration order, + // and StubDefaultSpacePermissions' catch-all would otherwise grant read_page first. + mockAPI.On("HasPermissionToChannel", stranger, channelID, mmmodel.PermissionReadPage).Return(false) + mockAPI.On("HasPermissionToChannel", stranger, channelID, mmmodel.PermissionDeletePage).Return(true) + mockAPI.On("RolesGrantPermission", mock.Anything, mmmodel.PermissionDeletePage.Id).Return(true) + // Not yet a member: the join path runs only when the membership probe misses. + mockAPI.On("GetChannelMember", channelID, stranger). + Return((*mmmodel.ChannelMember)(nil), &mmmodel.AppError{StatusCode: http.StatusNotFound}) + mockAPI.On("AddChannelMember", channelID, stranger). + Return(&mmmodel.ChannelMember{ChannelId: channelID, UserId: stranger}, nil) + + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + // Owned by someone else: only the delete_page (any) grant, not delete_own_page, can admit this. + page := testutil.MustCreatePage(t, h.store, space.Id, channelID, mmmodel.NewId(), "") + + rec := h.do(t, http.MethodDelete, "/api/v1/spaces/"+space.Id+"/pages/"+page.Id, stranger, nil) + require.Equal(t, http.StatusOK, rec.Code) + mockAPI.AssertCalled(t, "AddChannelMember", channelID, stranger) +} + // TestHandler_GetSpacePagesHasMoreBoundary pins the has_more transition exactly at the page-size // boundary: a window equal to the result count reports has_more=false; one smaller reports true. func TestHandler_GetSpacePagesHasMoreBoundary(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) for range 3 { seedPage(t, h.store, space.Id, channelID, "") } @@ -1504,7 +1764,7 @@ func TestHandler_GetPageChildrenHasMoreBoundary(t *testing.T) { h := openTestPlugin(t, nil) user := mmmodel.NewId() channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) parent := seedPage(t, h.store, space.Id, channelID, "") for range 3 { seedPage(t, h.store, space.Id, channelID, parent.Id) @@ -1537,11 +1797,11 @@ func TestHandler_CreatePage_PublishesCreatedEvent(t *testing.T) { mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). Return(&mmmodel.TeamMember{}, nil) mockAPI.On("GetChannelMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). - Return(&mmmodel.ChannelMember{}, nil) + Return(&mmmodel.ChannelMember{}, nil).Maybe() mockAPI.On("PublishWebSocketEvent", "page_created", mock.Anything, mock.Anything).Return().Once() h := openTestPlugin(t, mockAPI) - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+space.Id+"/pages", mmmodel.NewId(), map[string]any{ "title": "Evented Page", }) @@ -1553,3 +1813,892 @@ func TestHandler_CreatePage_PublishesCreatedEvent(t *testing.T) { map[string]any{"page_id": created.Id, "space_id": space.Id, "parent_id": created.ParentId}, &mmmodel.WebsocketBroadcast{ChannelId: channelID}) } + +// TestHandler_CreateSpace_RequiresCreateSpacePermission verifies that team membership alone does +// not authorize creating a space in it: a caller without team create_space (and not sysadmin) is +// rejected before any backing-channel side effect. TestHandler_CreateSpace (default-granted via +// StubDefaultSpacePermissions) covers the allowed path. +func TestHandler_CreateSpace_RequiresCreateSpacePermission(t *testing.T) { + teamID := mmmodel.NewId() + callerID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + mockAPI.On("GetTeamMember", teamID, callerID).Return(&mmmodel.TeamMember{}, nil) + // Registered before openTestPlugin so it takes precedence over StubDefaultSpacePermissions' + // permissive create_space catch-all (mock.Mock matches in registration order). + mockAPI.On("HasPermissionToTeam", callerID, teamID, mmmodel.PermissionCreateSpace).Return(false) + h := openTestPlugin(t, mockAPI) + + rec := h.do(t, http.MethodPost, "/api/v1/teams/"+teamID+"/spaces", callerID, map[string]any{ + "title": "Nope", + }) + require.Equal(t, http.StatusForbidden, rec.Code) + mockAPI.AssertNotCalled(t, "CreateChannel") +} + +// TestHandler_AddSpaceMember_GuestProjectsReadOnly verifies a guest added to a space is projected +// through the same reverse-role logic as GetSpaceMembers/SetSpaceMemberCapabilities: is_guest +// true and capabilities read_page-only, never the space default. +func TestHandler_AddSpaceMember_GuestProjectsReadOnly(t *testing.T) { + channelID := mmmodel.NewId() + targetUserID := mmmodel.NewId() + adminID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceManage(mockAPI, adminID) + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.TeamMember{}, nil) + mockAPI.On("GetChannelMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.ChannelMember{}, nil).Maybe() + mockAPI.On("AddChannelMember", channelID, targetUserID). + Return(&mmmodel.ChannelMember{ChannelId: channelID, UserId: targetUserID, SchemeGuest: true}, nil) + h := openTestPlugin(t, mockAPI) + + // The contribute-preset default (seeded by seedSpace) must not leak into a guest's projection. + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+space.Id+"/members", adminID, map[string]any{ + "user_id": targetUserID, + }) + require.Equal(t, http.StatusCreated, rec.Code) + + var member model.SpaceMember + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &member)) + require.Equal(t, targetUserID, member.UserId) + require.True(t, member.IsGuest) + require.False(t, member.IsAdmin) + require.Equal(t, []string{model.CapabilityReadPage}, member.Capabilities) + require.Empty(t, member.GrantedCapabilities) +} + +// TestHandler_RemoveSpaceMember_NonSelfMissingTargetIsNotFound verifies that a manage-gated +// caller removing a non-member target always gets the plain 404 — even on a private space, where +// self-removal would get the existence-hiding 403 instead. A manage-gated caller has already +// proven manage authority over the space, so there is nothing left to existence-hide behind. +func TestHandler_RemoveSpaceMember_NonSelfMissingTargetIsNotFound(t *testing.T) { + channelID := mmmodel.NewId() + targetUserID := mmmodel.NewId() + adminID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceManage(mockAPI, adminID) + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.TeamMember{}, nil) + mockAPI.On("GetChannelMember", channelID, targetUserID). + Return((*mmmodel.ChannelMember)(nil), &mmmodel.AppError{StatusCode: http.StatusNotFound}) + h := openTestPlugin(t, mockAPI) + + space, err := h.store.CreateSpace(&model.Space{ChannelId: channelID, TeamId: mmmodel.NewId(), CreatorId: mmmodel.NewId(), Title: "Private Space", ViewAccess: model.ViewAccessPrivate}) + require.NoError(t, err) + + rec := h.do(t, http.MethodDelete, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID, adminID, nil) + require.Equal(t, http.StatusNotFound, rec.Code) +} + +// TestHandler_SetSpaceMemberCapabilities_Forbidden verifies a plain member without a manage grant +// cannot change another member's capabilities. +func TestHandler_SetSpaceMemberCapabilities_Forbidden(t *testing.T) { + channelID := mmmodel.NewId() + targetUserID := mmmodel.NewId() + + h := openTestPlugin(t, nil) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", mmmodel.NewId(), map[string]any{ + "granted_capabilities": []string{"create_page"}, + }) + require.Equal(t, http.StatusForbidden, rec.Code) +} + +// TestHandler_SetSpaceMemberCapabilities_GuestTargetRejected verifies a guest target is rejected +// with 400: guests stay read-only via the scheme's guest role and are never grant-assignable. +func TestHandler_SetSpaceMemberCapabilities_GuestTargetRejected(t *testing.T) { + channelID := mmmodel.NewId() + targetUserID := mmmodel.NewId() + adminID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceManage(mockAPI, adminID) + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.TeamMember{}, nil) + mockAPI.On("GetChannelMember", channelID, targetUserID). + Return(&mmmodel.ChannelMember{ChannelId: channelID, UserId: targetUserID, SchemeGuest: true}, nil) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ + "granted_capabilities": []string{"create_page"}, + }) + require.Equal(t, http.StatusBadRequest, rec.Code) + var appErr mmmodel.AppError + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &appErr)) + require.Equal(t, "app.space.member.guest_not_assignable.app_error", appErr.Id) +} + +// TestHandler_SetSpaceMemberCapabilities_InvalidCapability verifies the granted-capability +// vocabulary is enforced: read_page (the non-grantable baseline) and an unknown token are both +// rejected with 400, before any target lookup. +func TestHandler_SetSpaceMemberCapabilities_InvalidCapability(t *testing.T) { + channelID := mmmodel.NewId() + targetUserID := mmmodel.NewId() + adminID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceManage(mockAPI, adminID) + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.TeamMember{}, nil) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + cases := []struct { + name string + capabilities []string + }{ + {"read_page is not grantable", []string{"read_page"}}, + {"unknown token is rejected", []string{"not_a_real_capability"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ + "granted_capabilities": tc.capabilities, + }) + require.Equal(t, http.StatusBadRequest, rec.Code) + }) + } +} + +// TestHandler_SetSpaceMemberCapabilities_Grant covers the happy path: a manage-granted caller +// grants create_page to a plain member on a read-only-default space, and the response reflects +// both the granted set and the resulting effective capabilities (read_page plus the new grant). +func TestHandler_SetSpaceMemberCapabilities_Grant(t *testing.T) { + channelID := mmmodel.NewId() + teamID := mmmodel.NewId() + targetUserID := mmmodel.NewId() + adminID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceManage(mockAPI, adminID) + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.TeamMember{}, nil) + // The pre-update target: a plain member, no grant yet. This is the state the guest and + // last-admin guards read before the write. + mockAPI.On("GetChannelMember", channelID, targetUserID). + Return(&mmmodel.ChannelMember{ChannelId: channelID, UserId: targetUserID}, nil) + // The post-update target, echoing the roles just written — core returns the updated member from + // this call, and the response is projected from it. Registered before openTestPlugin so it wins + // over that helper's wildcard catch-all, which matching order would otherwise resolve first. + mockAPI.On("UpdateChannelMemberRoles", channelID, targetUserID, mock.Anything). + Return(func(chID, uID, newRoles string) (*mmmodel.ChannelMember, *mmmodel.AppError) { + return &mmmodel.ChannelMember{ChannelId: chID, UserId: uID, ExplicitRoles: newRoles}, nil + }) + // Registered before openTestPlugin: the read-only preset must win over + // StubDefaultChannelScheme's contribute-preset catch-all, and mock matching is by + // registration order. + testutil.MustSeedChannelScheme(t, mockAPI, channelID, mmmodel.SchemeNameSpaceReadOnly) + h := openTestPlugin(t, mockAPI) + + space, err := h.store.CreateSpace(&model.Space{ChannelId: channelID, TeamId: teamID, CreatorId: mmmodel.NewId(), Title: "RO Space", ViewAccess: model.ViewAccessOpen}) + require.NoError(t, err) + + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ + "granted_capabilities": []string{"create_page"}, + }) + require.Equal(t, http.StatusOK, rec.Code) + + var member model.SpaceMember + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &member)) + require.Equal(t, []string{"create_page"}, member.GrantedCapabilities) + require.ElementsMatch(t, []string{"read_page", "create_page"}, member.Capabilities) + require.False(t, member.IsAdmin) +} + +// TestHandler_SetSpaceMemberCapabilities_OmissionRevokesAdminForbidden verifies that a manage-only +// caller (no channel admin_space grant) cannot demote a current SchemeAdmin target by omitting +// admin_space from the requested set: the escalation guard fires on the current-holder side, not +// only when admin_space is newly requested. +func TestHandler_SetSpaceMemberCapabilities_OmissionRevokesAdminForbidden(t *testing.T) { + channelID := mmmodel.NewId() + targetUserID := mmmodel.NewId() + adminID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceManage(mockAPI, adminID) + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.TeamMember{}, nil) + mockAPI.On("GetChannelMember", channelID, targetUserID). + Return(&mmmodel.ChannelMember{ChannelId: channelID, UserId: targetUserID, SchemeAdmin: true}, nil) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ + "granted_capabilities": []string{}, + }) + require.Equal(t, http.StatusForbidden, rec.Code) +} + +// TestHandler_SetSpaceMemberCapabilities_SelfTargetForbidden verifies a manage-only caller (no +// channel admin_space grant) cannot change their own capabilities: self-targeting always requires +// the stricter admin gate, regardless of what is requested. +func TestHandler_SetSpaceMemberCapabilities_SelfTargetForbidden(t *testing.T) { + channelID := mmmodel.NewId() + callerID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceManage(mockAPI, callerID) + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.TeamMember{}, nil) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/members/"+callerID+"/capabilities", callerID, map[string]any{ + "granted_capabilities": []string{"create_page"}, + }) + require.Equal(t, http.StatusForbidden, rec.Code) +} + +// TestHandler_SetSpaceMemberCapabilities_LastAdminConflict verifies removing admin_space from the +// space's sole authorized admin is rejected with 409, even for an admin-capable caller. +func TestHandler_SetSpaceMemberCapabilities_LastAdminConflict(t *testing.T) { + channelID := mmmodel.NewId() + targetUserID := mmmodel.NewId() + adminID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceAdmin(mockAPI, channelID, adminID) + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.TeamMember{}, nil) + mockAPI.On("GetChannelMember", channelID, targetUserID). + Return(&mmmodel.ChannelMember{ChannelId: channelID, UserId: targetUserID, SchemeAdmin: true}, nil) + // The last-admin guard scans the member list: only the sole target holds SchemeAdmin. + mockAPI.On("GetChannelMembers", channelID, 0, app.PerPageMaximum). + Return(mmmodel.ChannelMembers{{ChannelId: channelID, UserId: targetUserID, SchemeAdmin: true}}, nil) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ + "granted_capabilities": []string{}, + }) + require.Equal(t, http.StatusConflict, rec.Code) + // A membership 409 carries the shared conflict envelope like every other 409; current_page is + // nil because the conflict is not about a page. + var conflict struct { + Error *mmmodel.AppError `json:"error"` + CurrentPage *model.Page `json:"current_page"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &conflict)) + require.NotNil(t, conflict.Error) + require.Equal(t, "app.space.member.last_admin.app_error", conflict.Error.Id) + require.Nil(t, conflict.CurrentPage) +} + +// TestHandler_SetSpaceMemberCapabilities_OtherAdminAllowsDemote verifies the positive side of the +// last-admin guard: demoting an admin succeeds once another reachable admin remains. +// +// The member list deliberately places a non-admin ahead of the surviving admin. The guard's scan +// stops only once it has answered both of its questions, so a scan that stopped at the first +// reachable member would never see the admin behind it and would reject this demote as removing +// the last one. +func TestHandler_SetSpaceMemberCapabilities_OtherAdminAllowsDemote(t *testing.T) { + channelID := mmmodel.NewId() + targetUserID := mmmodel.NewId() + plainMemberID := mmmodel.NewId() + otherAdminID := mmmodel.NewId() + adminID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceAdmin(mockAPI, channelID, adminID) + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.TeamMember{}, nil) + mockAPI.On("GetChannelMember", channelID, targetUserID). + Return(&mmmodel.ChannelMember{ChannelId: channelID, UserId: targetUserID, SchemeAdmin: true}, nil) + mockAPI.On("GetChannelMembers", channelID, 0, app.PerPageMaximum). + Return(mmmodel.ChannelMembers{ + {ChannelId: channelID, UserId: targetUserID, SchemeAdmin: true}, + {ChannelId: channelID, UserId: plainMemberID}, + {ChannelId: channelID, UserId: otherAdminID, SchemeAdmin: true}, + }, nil) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ + "granted_capabilities": []string{}, + }) + require.Equal(t, http.StatusOK, rec.Code) + + var member model.SpaceMember + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &member)) + require.False(t, member.IsAdmin) +} + +// TestHandler_SetSpaceMemberCapabilities_EmptyDoesNotDemoteBelowDefault verifies the additive-only +// contract: granting the empty set to a plain member on a contribute-default space clears their +// per-member grant but never demotes their effective capabilities below the space default. +func TestHandler_SetSpaceMemberCapabilities_EmptyDoesNotDemoteBelowDefault(t *testing.T) { + channelID := mmmodel.NewId() + targetUserID := mmmodel.NewId() + adminID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceManage(mockAPI, adminID) + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.TeamMember{}, nil) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ + "granted_capabilities": []string{}, + }) + require.Equal(t, http.StatusOK, rec.Code) + + var member model.SpaceMember + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &member)) + require.Empty(t, member.GrantedCapabilities) + require.ElementsMatch(t, + []string{model.CapabilityReadPage, model.CapabilityCommentPage, model.CapabilityCreatePage, model.CapabilityEditPage, model.CapabilityDeleteOwnPage}, + member.Capabilities) +} + +// TestHandler_SetSpaceMemberCapabilities_PublishesEvent pins space_member_capabilities_updated: +// space/user payload, delivered both to the target user directly and to the backing channel. +func TestHandler_SetSpaceMemberCapabilities_PublishesEvent(t *testing.T) { + channelID := mmmodel.NewId() + targetUserID := mmmodel.NewId() + adminID := mmmodel.NewId() + + // Built by hand instead of via newEnabledMockAPI: its wildcard PublishWebSocketEvent stub + // would swallow the pinned expectation below. + mockAPI := &plugintest.API{} + mockAPI.On("GetConfig").Return(&mmmodel.Config{FeatureFlags: &mmmodel.FeatureFlags{EnableDocs: true}}).Maybe() + grantSpaceManage(mockAPI, adminID) + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.TeamMember{}, nil) + mockAPI.On("GetChannelMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.ChannelMember{}, nil).Maybe() + mockAPI.On("PublishWebSocketEvent", "space_member_capabilities_updated", mock.Anything, mock.Anything).Return() + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/members/"+targetUserID+"/capabilities", adminID, map[string]any{ + "granted_capabilities": []string{"create_page"}, + }) + require.Equal(t, http.StatusOK, rec.Code) + + payload := map[string]any{"space_id": space.Id, "user_id": targetUserID} + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "space_member_capabilities_updated", + payload, &mmmodel.WebsocketBroadcast{UserId: targetUserID}) + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "space_member_capabilities_updated", + payload, &mmmodel.WebsocketBroadcast{ChannelId: channelID}) +} + +// stubSpaceSchemeRepoint makes a SetSpaceDefaultCapabilities repoint against channelID observable: +// the returned shared *model.Channel is the one GetChannelOfType hands out, so the repoint's +// in-place SchemeId write is visible to the caller and to every subsequent scheme-resolving read. +// The channel starts pointed at the contribute preset scheme, mirroring seedSpace's default. +// +// Must be registered before openTestPlugin, whose testutil.StubDefaultChannelScheme catch-all would +// otherwise shadow it for this channelID — the same registration-order rule as +// grantSpaceManage/grantSpaceAdmin. +func stubSpaceSchemeRepoint(t *testing.T, mockAPI *plugintest.API, channelID string) *mmmodel.Channel { + t.Helper() + return testutil.MustSeedChannelScheme(t, mockAPI, channelID, mmmodel.SchemeNameSpaceContribute) +} + +// stubSpaceCustomSchemeCreate wires the mock calls the custom-scheme path needs for a non-preset +// default-capability set: CreateScheme returning a scheme whose Name carries the custom prefix, +// plus GetRoleByName and PatchRole for each of its three generated roles. The new scheme's role +// names are registered with testutil so a channel repointed at it resolves them through +// GetSchemeRolesForChannel, the way core does. +// +// Returns the scheme id CreateScheme resolves to, so the caller can assert against it (e.g. the +// channel's post-repoint SchemeId, or a later DeleteScheme call retiring it). +func stubSpaceCustomSchemeCreate(t *testing.T, mockAPI *plugintest.API) string { + t.Helper() + testutil.StubPooledSchemeMiss(mockAPI) + customSchemeID := mmmodel.NewId() + userRole, adminRole, guestRole := "custom_scheme_user_role", "custom_scheme_admin_role", "custom_scheme_guest_role" + customScheme := &mmmodel.Scheme{ + Id: customSchemeID, + Name: model.SharedSchemeNameForCapabilities([]string{"create_page"}), + Scope: mmmodel.SchemeScopeChannel, + DefaultChannelUserRole: userRole, + DefaultChannelAdminRole: adminRole, + DefaultChannelGuestRole: guestRole, + } + mockAPI.On("CreateScheme", mock.AnythingOfType("*model.Scheme")).Return(customScheme, nil) + testutil.RegisterSchemeRoles(customSchemeID, guestRole, userRole, adminRole) + // The generated roles start empty, the way core's CreateScheme leaves them before the plugin + // patches in the exact sets. StubRole hands back one shared *Role per name so StubPatchRole's + // mutation is visible to a later getRolePermissionsByName read of the same role. + testutil.StubRole(mockAPI, userRole, nil) + testutil.StubRole(mockAPI, adminRole, nil) + testutil.StubRole(mockAPI, guestRole, nil) + testutil.StubPatchRole(mockAPI) + return customSchemeID +} + +// assertCustomSchemeRolePermissions pins which permission set each generated role received, so a +// regression that sent the space-admin set to the guest or user role fails instead of passing on a +// single catch-all PatchRole expectation. capabilities is the requested default set. +func assertCustomSchemeRolePermissions(t *testing.T, mockAPI *plugintest.API, capabilities []string) { + t.Helper() + expected := map[string][]string{ + "custom_scheme_user_role": append([]string{model.CapabilityReadPage}, capabilities...), + "custom_scheme_admin_role": mmmodel.PermissionIDs(mmmodel.SpaceAdminRolePermissions), + "custom_scheme_guest_role": {model.CapabilityReadPage}, + } + for roleName, perms := range expected { + mockAPI.AssertCalled(t, "PatchRole", + mock.MatchedBy(func(roleID string) bool { + patchedName, ok := testutil.StubbedRoleName(roleID) + return ok && patchedName == roleName + }), + mock.MatchedBy(func(p *mmmodel.RolePatch) bool { + return p != nil && p.Permissions != nil && slices.Equal( + model.NormalizeCapabilitySet(*p.Permissions), model.NormalizeCapabilitySet(perms)) + })) + } +} + +// TestHandler_SetSpaceDefaultCapabilities_Forbidden verifies a manage-only caller (team +// manage_space, no channel admin_space) cannot change a space's default capability set: the +// exposure-policy gate is stricter than ordinary manage. +func TestHandler_SetSpaceDefaultCapabilities_Forbidden(t *testing.T) { + mockAPI := newEnabledMockAPI() + userID := mmmodel.NewId() + // A manage-only grant, never exercised by this gate: RequireSpaceAdminOrSysadmin has no + // team-manage_space branch, so this stub is optional, not asserted. + mockAPI.On("HasPermissionToTeam", userID, mock.Anything, mmmodel.PermissionManageSpace).Return(true).Maybe() + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, mmmodel.NewId()) + + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ + "default_capabilities": []string{"create_page"}, + }) + require.Equal(t, http.StatusForbidden, rec.Code) +} + +// TestHandler_SetSpaceDefaultCapabilities_Allowed covers the two callers admitted by the +// exposure-policy gate: a channel admin_space grant, and the system manage_system override. +func TestHandler_SetSpaceDefaultCapabilities_Allowed(t *testing.T) { + t.Run("channel admin_space grant", func(t *testing.T) { + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceAdmin(mockAPI, channelID, userID) + stubSpaceSchemeRepoint(t, mockAPI, channelID) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ + "default_capabilities": []string{"comment_page"}, + }) + require.Equal(t, http.StatusOK, rec.Code) + }) + + t.Run("sysadmin override", func(t *testing.T) { + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSysadmin(mockAPI, userID) + stubSpaceSchemeRepoint(t, mockAPI, channelID) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ + "default_capabilities": []string{"comment_page"}, + }) + require.Equal(t, http.StatusOK, rec.Code) + }) +} + +// TestHandler_SetSpaceDefaultCapabilities_InvalidCapability verifies the default-capability +// vocabulary is enforced: read_page (implicit baseline), admin_space (member-grant-only, never a +// space default), and an unknown token are all rejected with 400. +func TestHandler_SetSpaceDefaultCapabilities_InvalidCapability(t *testing.T) { + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceAdmin(mockAPI, channelID, userID) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + cases := []struct { + name string + capabilities []string + }{ + {"read_page is implicit, not settable", []string{"read_page"}}, + {"admin_space is member-grant-only", []string{"admin_space"}}, + {"unknown token is rejected", []string{"not_a_real_capability"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ + "default_capabilities": tc.capabilities, + }) + require.Equal(t, http.StatusBadRequest, rec.Code) + }) + } +} + +// TestHandler_SetSpaceDefaultCapabilities_CreatesPooledScheme verifies a non-preset default +// capability set repoints the backing channel at a scheme from the shared pool, minted on first +// use under the name that capability set resolves to, and that the response echoes exactly the +// requested set. +func TestHandler_SetSpaceDefaultCapabilities_CreatesPooledScheme(t *testing.T) { + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceAdmin(mockAPI, channelID, userID) + channel := stubSpaceSchemeRepoint(t, mockAPI, channelID) + customSchemeID := stubSpaceCustomSchemeCreate(t, mockAPI) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ + "default_capabilities": []string{"create_page"}, + }) + require.Equal(t, http.StatusOK, rec.Code) + + var updated model.SpaceWithAccess + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &updated)) + require.Equal(t, []string{"create_page"}, updated.DefaultCapabilities) + + // The pool key is a pure function of the capability set, so the minted scheme's name is exactly + // the one any other space requesting this set would resolve to. + mockAPI.AssertCalled(t, "CreateScheme", mock.MatchedBy(func(s *mmmodel.Scheme) bool { + return s.Name == model.SharedSchemeNameForCapabilities([]string{"create_page"}) + })) + require.NotNil(t, channel.SchemeId) + require.Equal(t, customSchemeID, *channel.SchemeId, "the channel must be repointed at the pooled scheme") + assertCustomSchemeRolePermissions(t, mockAPI, []string{"create_page"}) + // Exactly the three generated roles (user/admin/guest) are patched — no role skipped, none + // double-patched. assertCustomSchemeRolePermissions pins the per-role content; this pins the count. + mockAPI.AssertNumberOfCalls(t, "PatchRole", 3) +} + +// TestHandler_SetSpaceDefaultCapabilities_ReusesPooledScheme pins the property the shared pool +// exists for: a capability set resolves to one scheme forever. Switching a space to a non-preset +// set mints a pooled scheme, switching away to a preset leaves it in place, and switching back +// resolves to that same scheme rather than minting a second one carrying identical permissions. +func TestHandler_SetSpaceDefaultCapabilities_ReusesPooledScheme(t *testing.T) { + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceAdmin(mockAPI, channelID, userID) + channel := stubSpaceSchemeRepoint(t, mockAPI, channelID) + testutil.StubSchemePool(mockAPI) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + setDefaults := func(capabilities []string) { + t.Helper() + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ + "default_capabilities": capabilities, + }) + require.Equal(t, http.StatusOK, rec.Code) + } + + setDefaults([]string{"create_page"}) + require.NotNil(t, channel.SchemeId) + pooledSchemeID := *channel.SchemeId + require.NotEqual(t, testutil.PresetSchemeID(mmmodel.SchemeNameSpaceContribute), pooledSchemeID) + + setDefaults([]string{"comment_page", "create_page", "edit_page", "delete_own_page"}) + require.NotNil(t, channel.SchemeId) + require.Equal(t, testutil.PresetSchemeID(mmmodel.SchemeNameSpaceContribute), *channel.SchemeId, + "a set matching a preset must repoint at the shared preset scheme") + + setDefaults([]string{"create_page"}) + require.NotNil(t, channel.SchemeId) + require.Equal(t, pooledSchemeID, *channel.SchemeId, + "returning to a capability set must resolve to the scheme already pooled for it") + + // One scheme minted across all three switches, and none deleted: a pooled scheme is shared, so + // no space owns it and no repoint retires it. + mockAPI.AssertNumberOfCalls(t, "CreateScheme", 1) + mockAPI.AssertNotCalled(t, "DeleteScheme", mock.Anything) +} + +// TestHandler_SetSpaceDefaultCapabilities_ResubmitCurrentSetIsNoOp verifies that resubmitting the +// space's current default capability set (deduplicated and reordered) is a no-op: the backing +// channel's SchemeId does not change. +func TestHandler_SetSpaceDefaultCapabilities_ResubmitCurrentSetIsNoOp(t *testing.T) { + channelID := mmmodel.NewId() + userID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceAdmin(mockAPI, channelID, userID) + // stubSpaceSchemeRepoint's channel already starts pointed at the contribute preset scheme, + // the same default seedSpace/MustCreateSpaceWithScheme use, so the requested set — which + // normalizes to that same preset — already matches the live scheme with no further setup. + channel := stubSpaceSchemeRepoint(t, mockAPI, channelID) + contributeID := testutil.PresetSchemeID(mmmodel.SchemeNameSpaceContribute) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPut, "/api/v1/spaces/"+space.Id+"/default-capabilities", userID, map[string]any{ + "default_capabilities": []string{"edit_page", "comment_page", "comment_page", "create_page", "delete_own_page"}, + }) + require.Equal(t, http.StatusOK, rec.Code) + + mockAPI.AssertNotCalled(t, "UpdateChannel", mock.Anything) + require.NotNil(t, channel.SchemeId) + require.Equal(t, contributeID, *channel.SchemeId, "resubmitting the current set must not repoint the channel") +} + +// TestHandler_UpdateSpace_ViewAccessRequiresAdmin verifies that a manage-only caller (no channel +// admin_space grant) cannot change ViewAccess, and that a rejected ViewAccess change fails the +// whole request — the title in the same patch is not applied either. +func TestHandler_UpdateSpace_ViewAccessRequiresAdmin(t *testing.T) { + mockAPI := newEnabledMockAPI() + userID := mmmodel.NewId() + grantSpaceManage(mockAPI, userID) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, mmmodel.NewId()) + require.Equal(t, model.ViewAccessOpen, space.ViewAccess) + + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id, userID, map[string]any{ + "view_access": "private", + "title": "x", + "expected_update_at": space.UpdateAt, + }) + require.Equal(t, http.StatusForbidden, rec.Code) + + rec = h.do(t, http.MethodGet, "/api/v1/spaces/"+space.Id, userID, nil) + require.Equal(t, http.StatusOK, rec.Code) + var got model.Space + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Equal(t, "Test Space", got.Title, "the whole request must fail together; title must not be applied") + require.Equal(t, model.ViewAccessOpen, got.ViewAccess) +} + +// TestHandler_UpdateSpace_ViewAccessForceRejected verifies force=true is rejected on a ViewAccess +// change: the exposure-policy escalation check must always run, never be skipped by force. +func TestHandler_UpdateSpace_ViewAccessForceRejected(t *testing.T) { + mockAPI := newEnabledMockAPI() + userID := mmmodel.NewId() + grantSpaceManage(mockAPI, userID) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, mmmodel.NewId()) + + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id, userID, map[string]any{ + "view_access": "open", + "force": true, + }) + require.Equal(t, http.StatusBadRequest, rec.Code) + var appErr mmmodel.AppError + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &appErr)) + require.Equal(t, "app.space.update.view_access_force.app_error", appErr.Id) +} + +// TestHandler_UpdateSpace_ViewAccessAdminSucceedsMemberRetained verifies an admin-capable caller +// can flip a space from open to private, and that an existing member can still read the space +// afterwards (privatizing does not shed members). +func TestHandler_UpdateSpace_ViewAccessAdminSucceedsMemberRetained(t *testing.T) { + channelID := mmmodel.NewId() + adminID := mmmodel.NewId() + memberID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceAdmin(mockAPI, channelID, adminID) + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&mmmodel.TeamMember{}, nil) + mockAPI.On("AddChannelMember", channelID, memberID). + Return(&mmmodel.ChannelMember{ChannelId: channelID, UserId: memberID}, nil) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+space.Id+"/members", adminID, map[string]any{"user_id": memberID}) + require.Equal(t, http.StatusCreated, rec.Code) + + rec = h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id, adminID, map[string]any{ + "view_access": "private", + "expected_update_at": space.UpdateAt, + }) + require.Equal(t, http.StatusOK, rec.Code) + var updated model.Space + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &updated)) + require.Equal(t, model.ViewAccessPrivate, updated.ViewAccess) + + rec = h.do(t, http.MethodGet, "/api/v1/spaces/"+space.Id, memberID, nil) + require.Equal(t, http.StatusOK, rec.Code) +} + +// TestHandler_UpdateSpace_ViewAccessInvalidValue verifies an admin-capable caller supplying a +// nonsensical view_access value is rejected with 400. +func TestHandler_UpdateSpace_ViewAccessInvalidValue(t *testing.T) { + channelID := mmmodel.NewId() + adminID := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + grantSpaceAdmin(mockAPI, channelID, adminID) + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + + rec := h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id, adminID, map[string]any{ + "view_access": "bogus", + "expected_update_at": space.UpdateAt, + }) + require.Equal(t, http.StatusBadRequest, rec.Code) +} + +// TestHandler_OpenSpaceReadFallthrough_VisibleWithReadPublicChannel verifies a non-member of an +// open space with team read_public_channel sees it both in the team space listing and via the +// single-space read, the latter carrying only the read_page capability (never a hypothetical +// post-join grant). +func TestHandler_OpenSpaceReadFallthrough_VisibleWithReadPublicChannel(t *testing.T) { + teamID := mmmodel.NewId() + channelID := mmmodel.NewId() + caller := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + // Registered before openTestPlugin: mock matching is by registration order, so this specific + // stub (simulating "not a member") takes precedence over StubDefaultSpacePermissions' + // permissive read_page-for-anyone catch-all. + mockAPI.On("HasPermissionToChannel", caller, channelID, mmmodel.PermissionReadPage).Return(false) + mockAPI.On("GetTeamMember", teamID, caller).Return(&mmmodel.TeamMember{}, nil) + h := openTestPlugin(t, mockAPI) + + space := seedSpaceInTeam(t, h.store, h.db, channelID, teamID) + require.Equal(t, model.ViewAccessOpen, space.ViewAccess) + + rec := h.do(t, http.MethodGet, "/api/v1/teams/"+teamID+"/spaces", caller, nil) + require.Equal(t, http.StatusOK, rec.Code) + var list paginatedResponse[*model.Space] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &list)) + require.Len(t, list.Items, 1) + require.Equal(t, space.Id, list.Items[0].Id) + + rec = h.do(t, http.MethodGet, "/api/v1/spaces/"+space.Id, caller, nil) + require.Equal(t, http.StatusOK, rec.Code) + var wrapper model.SpaceWithAccess + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &wrapper)) + require.Equal(t, []string{model.CapabilityReadPage}, wrapper.Capabilities) +} + +// TestHandler_OpenSpaceReadFallthrough_HiddenWithoutReadPublicChannel verifies the complementary +// case: the same non-member caller, but without team read_public_channel, sees the open space +// neither in the team listing nor via the single-space read (403). +func TestHandler_OpenSpaceReadFallthrough_HiddenWithoutReadPublicChannel(t *testing.T) { + teamID := mmmodel.NewId() + channelID := mmmodel.NewId() + caller := mmmodel.NewId() + + mockAPI := newEnabledMockAPI() + // Registered before openTestPlugin: same registration-order rule as above. + mockAPI.On("HasPermissionToChannel", caller, channelID, mmmodel.PermissionReadPage).Return(false) + mockAPI.On("HasPermissionToTeam", caller, teamID, mmmodel.PermissionReadPublicChannel).Return(false) + mockAPI.On("GetTeamMember", teamID, caller).Return(&mmmodel.TeamMember{}, nil) + h := openTestPlugin(t, mockAPI) + + space := seedSpaceInTeam(t, h.store, h.db, channelID, teamID) + + rec := h.do(t, http.MethodGet, "/api/v1/teams/"+teamID+"/spaces", caller, nil) + require.Equal(t, http.StatusOK, rec.Code) + var list paginatedResponse[*model.Space] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &list)) + require.Empty(t, list.Items) + + rec = h.do(t, http.MethodGet, "/api/v1/spaces/"+space.Id, caller, nil) + require.Equal(t, http.StatusForbidden, rec.Code) +} + +// TestHandler_DraftWritesRequireContributeCapability verifies the draft-write gate: a member who +// can read the space but holds neither create_page nor edit_page may still read their own drafts +// and the presence snapshot, but cannot autosave, reserve a new page id, or publish. A draft is a +// pending page, so holding one at all requires the authority to contribute pages here. +func TestHandler_DraftWritesRequireContributeCapability(t *testing.T) { + mockAPI := newEnabledMockAPI() + channelID := mmmodel.NewId() + reader := mmmodel.NewId() + + // Registered before openTestPlugin so these deny-stubs win over StubDefaultSpacePermissions' + // permissive catch-alls (mock matching is by registration order). + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), reader).Return(&mmmodel.TeamMember{}, nil) + mockAPI.On("HasPermissionToChannel", reader, channelID, mmmodel.PermissionReadPage).Return(true) + mockAPI.On("HasPermissionToChannel", reader, channelID, mmmodel.PermissionCreatePage).Return(false) + mockAPI.On("HasPermissionToChannel", reader, channelID, mmmodel.PermissionEditPage).Return(false) + + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + page := seedPage(t, h.store, space.Id, channelID, "") + testutil.MustAddChannelMember(t, h.db, channelID, reader) + + // Seeded through the store, not the API: the publish gate must be what rejects the request, + // and without an existing draft publish would 404 on its idempotency guard before reaching it. + _, _, err := h.store.UpsertDraft(&model.Draft{ + UserId: reader, SpaceId: space.Id, PageId: page.Id, Title: "Pending", BaseEditAt: page.EditAt, + }, nil, nil, nil) + require.NoError(t, err) + + denied := []struct { + method string + path string + body any + }{ + {http.MethodPost, "/api/v1/spaces/" + space.Id + "/drafts", map[string]any{"title": "D"}}, + {http.MethodPatch, "/api/v1/spaces/" + space.Id + "/pages/" + page.Id + "/draft", map[string]any{"title": "D"}}, + {http.MethodPost, "/api/v1/spaces/" + space.Id + "/pages/" + page.Id + "/draft/publish", nil}, + } + for _, tc := range denied { + rec := h.do(t, tc.method, tc.path, reader, tc.body) + require.Equal(t, http.StatusForbidden, rec.Code, "expected 403 for %s %s", tc.method, tc.path) + } + + // Reads stay open to a plain reader: the draft routes are own-scoped in the store, so reading + // carries no exposure beyond the space read the caller already holds. + allowed := []string{ + "/api/v1/spaces/" + space.Id + "/drafts", + "/api/v1/spaces/" + space.Id + "/pages/" + page.Id + "/active-editors", + } + for _, path := range allowed { + rec := h.do(t, http.MethodGet, path, reader, nil) + require.Equal(t, http.StatusOK, rec.Code, "expected 200 for GET %s", path) + } +} + +// TestHandler_PublishGatesOnTargetKind verifies that publish resolves its permission from the +// target the draft lands on, not from the route: a member holding create_page but not edit_page +// may publish a brand-new page, and is refused when the same call would overwrite a page that is +// already live. The distinction is derived inside PublishPageDraft, where the target row is +// classified. +func TestHandler_PublishGatesOnTargetKind(t *testing.T) { + mockAPI := newEnabledMockAPI() + channelID := mmmodel.NewId() + author := mmmodel.NewId() + + mockAPI.On("GetTeamMember", mock.AnythingOfType("string"), author).Return(&mmmodel.TeamMember{}, nil) + mockAPI.On("HasPermissionToChannel", author, channelID, mmmodel.PermissionReadPage).Return(true) + mockAPI.On("HasPermissionToChannel", author, channelID, mmmodel.PermissionCreatePage).Return(true) + mockAPI.On("HasPermissionToChannel", author, channelID, mmmodel.PermissionEditPage).Return(false) + + h := openTestPlugin(t, mockAPI) + space := seedSpace(t, h.store, h.db, channelID) + testutil.MustAddChannelMember(t, h.db, channelID, author) + + // New-page path: reserve an id, autosave into it, publish. create_page alone carries this. + rec := h.do(t, http.MethodPost, "/api/v1/spaces/"+space.Id+"/drafts", author, map[string]any{"title": "New"}) + require.Equal(t, http.StatusCreated, rec.Code) + var reserved model.Draft + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &reserved)) + + rec = h.do(t, http.MethodPost, "/api/v1/spaces/"+space.Id+"/pages/"+reserved.PageId+"/draft/publish", author, nil) + require.Equal(t, http.StatusCreated, rec.Code, "create_page must carry a new-page publish") + var published model.Page + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &published)) + + // Edit path: the same author now has a draft over the page they just published. Publishing it + // updates live content, which needs edit_page. base_edit_at baselines the draft against the + // page just published, so the publish below fails on authority rather than on a stale baseline. + rec = h.do(t, http.MethodPatch, "/api/v1/spaces/"+space.Id+"/pages/"+reserved.PageId+"/draft", author, + map[string]any{"title": "Revised", "base_edit_at": published.EditAt}) + require.Equal(t, http.StatusOK, rec.Code, "the draft write itself only needs the create-or-edit pair") + + rec = h.do(t, http.MethodPost, "/api/v1/spaces/"+space.Id+"/pages/"+reserved.PageId+"/draft/publish", author, nil) + require.Equal(t, http.StatusForbidden, rec.Code, "publishing over a live page must require edit_page") +} diff --git a/server/api_page.go b/server/api_page.go index 0328908..d5384d5 100644 --- a/server/api_page.go +++ b/server/api_page.go @@ -9,6 +9,7 @@ import ( "github.com/gorilla/mux" mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost-plugin-docs/server/app" "github.com/mattermost/mattermost-plugin-docs/server/model" ) @@ -22,25 +23,30 @@ const maxPageBodyBytes = 8 << 20 // 8 MiB // IDs, booleans, and timestamps — no content fields. const maxPageStructBodyBytes = 4 * 1024 // 4 KiB -// resolveTargetSpace validates a target_space_id carried in the request body and checks the -// caller's membership. Unlike the {space_id} in the URL path — which every handler checks up front -// via requireSpaceMembership — a body-supplied ID only surfaces after decoding, so it is checked -// inline here. It writes the error response and returns ok=false on failure. Callers pass the -// invalid-ID rejection as a pre-built AppError with a string-literal ID so the i18n extraction -// tool can discover the message key. -func (p *Plugin) resolveTargetSpace(w http.ResponseWriter, invalidIDErr *mmmodel.AppError, targetSpaceID, userID string) (*model.Space, bool) { +// requireTargetSpaceRead validates a target_space_id carried in the request body and resolves the +// caller's read access to it. Unlike the {space_id} in the URL path — which every handler checks +// up front — a body-supplied ID only surfaces after decoding, so it is checked inline here. It +// writes the error response and returns ok=false on failure. Callers pass the invalid-ID +// rejection as a pre-built AppError with a string-literal ID so the i18n extraction tool can +// discover the message key. +func (p *Plugin) requireTargetSpaceRead(w http.ResponseWriter, invalidIDErr *mmmodel.AppError, targetSpaceID, userID string) (*model.Space, app.ReadResolution, bool) { if !mmmodel.IsValidId(targetSpaceID) { p.writeAppError(w, invalidIDErr) - return nil, false + return nil, app.ReadDenied, false } - return p.requireSpaceMembership(w, targetSpaceID, userID, false) + return p.requireSpaceRead(w, targetSpaceID, userID) } // handleCreatePage handles POST /api/v1/spaces/{space_id}/pages. func (p *Plugin) handleCreatePage(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) userID := userIDFromRequest(r) - if _, ok := p.requireSpaceMembership(w, vars["space_id"], userID, false); !ok { + space, ok := p.getSpaceForGate(w, vars["space_id"], false) + if !ok { + return + } + joined, ok := p.requirePageWrite(w, space, userID, mmmodel.PermissionCreatePage) + if !ok { return } @@ -51,10 +57,12 @@ func (p *Plugin) handleCreatePage(w http.ResponseWriter, r *http.Request) { Body string `json:"body,omitempty"` } if !p.decodeJSONBody(w, r, maxPageBodyBytes, &req, "handleCreatePage", false) { + p.service.UndoAutoJoin(joined, space, userID) return } page, appErr := p.service.CreatePage(vars["space_id"], mmmodel.SafeDereference(req.ParentId), req.Title, req.Body, userID) if appErr != nil { + p.service.UndoAutoJoin(joined, space, userID) p.writeAppError(w, appErr) return } @@ -65,7 +73,7 @@ func (p *Plugin) handleCreatePage(w http.ResponseWriter, r *http.Request) { func (p *Plugin) handleGetPage(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) userID := userIDFromRequest(r) - if _, ok := p.requireSpaceMembership(w, vars["space_id"], userID, false); !ok { + if _, ok := p.requireSpacePagePerm(w, vars["space_id"], userID, mmmodel.PermissionReadPage); !ok { return } page, appErr := p.service.GetPageInSpace("handleGetPage", vars["page_id"], vars["space_id"], false) @@ -81,7 +89,12 @@ func (p *Plugin) handleGetPage(w http.ResponseWriter, r *http.Request) { func (p *Plugin) handleUpdatePage(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) userID := userIDFromRequest(r) - if _, ok := p.requireSpaceMembership(w, vars["space_id"], userID, false); !ok { + space, ok := p.getSpaceForGate(w, vars["space_id"], false) + if !ok { + return + } + joined, ok := p.requirePageWrite(w, space, userID, mmmodel.PermissionEditPage) + if !ok { return } @@ -94,12 +107,14 @@ func (p *Plugin) handleUpdatePage(w http.ResponseWriter, r *http.Request) { Force bool `json:"force"` } if !p.decodeJSONBody(w, r, maxPageBodyBytes, &req, "handleUpdatePage", false) { + p.service.UndoAutoJoin(joined, space, userID) return } patch := &model.PagePatch{Title: req.Title, Body: req.Body, Props: req.Props} updated, appErr := p.service.UpdatePage(vars["page_id"], vars["space_id"], patch, req.BaseEditAt, req.Force, userID) if appErr != nil { + p.service.UndoAutoJoin(joined, space, userID) if updated != nil { p.writeConflictWithPage(w, appErr, updated) return @@ -110,29 +125,53 @@ func (p *Plugin) handleUpdatePage(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, updated) } -// handleDeletePage handles DELETE /api/v1/spaces/{space_id}/pages/{page_id}. +// handleDeletePage handles DELETE /api/v1/spaces/{space_id}/pages/{page_id}. Own/any: delete_page +// (any), or delete_own_page when the caller owns the page. func (p *Plugin) handleDeletePage(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) userID := userIDFromRequest(r) - if _, ok := p.requireSpaceMembership(w, vars["space_id"], userID, false); !ok { + space, read, ok := p.requireSpaceRead(w, vars["space_id"], userID) + if !ok { + return + } + page, appErr := p.service.GetPageInSpace("handleDeletePage", vars["page_id"], vars["space_id"], false) + if appErr != nil { + p.writeAppError(w, appErr) + return + } + joined, ok := p.requireDeleteOwnOrAnyFrom(w, space, userID, page.UserId, read) + if !ok { return } if appErr := p.service.DeletePage(vars["page_id"], vars["space_id"], userID); appErr != nil { + p.service.UndoAutoJoin(joined, space, userID) p.writeAppError(w, appErr) return } writeStatusOK(w) } -// handleRestorePage handles PATCH /api/v1/spaces/{space_id}/pages/{page_id}/restore. +// handleRestorePage handles PATCH /api/v1/spaces/{space_id}/pages/{page_id}/restore. The page is +// soft-deleted at gate time, so the owner comparison resolves it via the include-deleted getter. func (p *Plugin) handleRestorePage(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) userID := userIDFromRequest(r) - if _, ok := p.requireSpaceMembership(w, vars["space_id"], userID, false); !ok { + space, read, ok := p.requireSpaceRead(w, vars["space_id"], userID) + if !ok { + return + } + page, appErr := p.service.GetPageInSpace("handleRestorePage", vars["page_id"], vars["space_id"], true) + if appErr != nil { + p.writeAppError(w, appErr) + return + } + joined, ok := p.requireDeleteOwnOrAnyFrom(w, space, userID, page.UserId, read) + if !ok { return } restored, appErr := p.service.RestorePage(vars["page_id"], vars["space_id"], userID) if appErr != nil { + p.service.UndoAutoJoin(joined, space, userID) p.writeAppError(w, appErr) return } @@ -143,11 +182,16 @@ func (p *Plugin) handleRestorePage(w http.ResponseWriter, r *http.Request) { // (parent_id nil leaves the parent unchanged; "" moves to the space root) and positions it at // sibling_index within the destination sibling group (clamped to the group's bounds rather than // rejected if out of range). The optimistic-lock baseline (expected_update_at) is required unless -// force is set. +// force is set. Gated on edit_page. func (p *Plugin) handleMovePage(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) userID := userIDFromRequest(r) - if _, ok := p.requireSpaceMembership(w, vars["space_id"], userID, false); !ok { + space, ok := p.getSpaceForGate(w, vars["space_id"], false) + if !ok { + return + } + joined, ok := p.requirePageWrite(w, space, userID, mmmodel.PermissionEditPage) + if !ok { return } @@ -158,10 +202,12 @@ func (p *Plugin) handleMovePage(w http.ResponseWriter, r *http.Request) { Force bool `json:"force,omitempty"` } if !p.decodeJSONBody(w, r, maxPageStructBodyBytes, &req, "handleMovePage", false) { + p.service.UndoAutoJoin(joined, space, userID) return } moved, appErr := p.service.MovePage(vars["page_id"], vars["space_id"], req.ParentId, req.SiblingIndex, req.ExpectedUpdateAt, req.Force, userID) if appErr != nil { + p.service.UndoAutoJoin(joined, space, userID) p.writeAppError(w, appErr) return } @@ -171,11 +217,12 @@ func (p *Plugin) handleMovePage(w http.ResponseWriter, r *http.Request) { // handleDuplicatePage handles POST /api/v1/spaces/{space_id}/pages/{page_id}/duplicate. An empty (or // all-default) body duplicates the page in place: same space, same parent, single page. // include_children copies the whole subtree; target_space_id/parent_id redirect the copy -// elsewhere. +// elsewhere. Gated on source read_page plus target create_page (target defaults to the source +// space, so an in-place duplicate is still create-gated). func (p *Plugin) handleDuplicatePage(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) userID := userIDFromRequest(r) - sourceSpace, ok := p.requireSpaceMembership(w, vars["space_id"], userID, false) + sourceSpace, sourceRead, ok := p.requireSpaceRead(w, vars["space_id"], userID) if !ok { return } @@ -188,17 +235,24 @@ func (p *Plugin) handleDuplicatePage(w http.ResponseWriter, r *http.Request) { if !p.decodeJSONBody(w, r, maxPageStructBodyBytes, &req, "handleDuplicatePage", true) { return } - // A nil targetSpace means "duplicate into the source space"; the fetched records are passed - // through so the service never re-reads them. - var targetSpace *model.Space + // An omitted or same-space target duplicates into the source space; the fetched records and the + // read each was admitted by are passed through so neither is resolved twice. + targetSpace, targetRead := sourceSpace, sourceRead if req.TargetSpaceId != "" && req.TargetSpaceId != vars["space_id"] { - if targetSpace, ok = p.resolveTargetSpace(w, mmmodel.NewAppError("handleDuplicatePage", "api.page.duplicate.invalid_target_space_id.app_error", nil, "", http.StatusBadRequest), req.TargetSpaceId, userID); !ok { + var targetOK bool + targetSpace, targetRead, targetOK = p.requireTargetSpaceRead(w, mmmodel.NewAppError("handleDuplicatePage", "api.page.duplicate.invalid_target_space_id.app_error", nil, "", http.StatusBadRequest), req.TargetSpaceId, userID) + if !targetOK { return } } + joined, ok := p.requirePageWriteFrom(w, targetSpace, userID, mmmodel.PermissionCreatePage, targetRead) + if !ok { + return + } duplicated, appErr := p.service.DuplicatePage(vars["page_id"], sourceSpace, userID, req.IncludeChildren, targetSpace, req.ParentId) if appErr != nil { + p.service.UndoAutoJoin(joined, targetSpace, userID) p.writeAppError(w, appErr) return } @@ -210,7 +264,7 @@ func (p *Plugin) handleDuplicatePage(w http.ResponseWriter, r *http.Request) { func (p *Plugin) handleGetPageChildren(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) userID := userIDFromRequest(r) - if _, ok := p.requireSpaceMembership(w, vars["space_id"], userID, false); !ok { + if _, ok := p.requireSpacePagePerm(w, vars["space_id"], userID, mmmodel.PermissionReadPage); !ok { return } page, perPage := pageParam(r), perPageParam(r) @@ -225,11 +279,12 @@ func (p *Plugin) handleGetPageChildren(w http.ResponseWriter, r *http.Request) { // handleMovePageToSpace handles PATCH /api/v1/spaces/{space_id}/pages/{page_id}/move-to-space, // moving the page and its subtree to target_space_id (parent_id optional; "" = target root). // The optimistic-lock baseline (expected_update_at, the moved root's last-seen UpdateAt) is -// required unless force is set. +// required unless force is set. Gated on source read_page, a remove-class delete permission over +// the moved subtree, and target create_page. func (p *Plugin) handleMovePageToSpace(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) userID := userIDFromRequest(r) - sourceSpace, ok := p.requireSpaceMembership(w, vars["space_id"], userID, false) + sourceSpace, sourceRead, ok := p.requireSpaceRead(w, vars["space_id"], userID) if !ok { return } @@ -249,15 +304,42 @@ func (p *Plugin) handleMovePageToSpace(w http.ResponseWriter, r *http.Request) { } // The fetched records are passed through so the service never re-reads them; a same-space // move reuses the membership gate's record instead of resolving the same space twice. - targetSpace := sourceSpace + targetSpace, targetRead := sourceSpace, sourceRead if req.TargetSpaceId != vars["space_id"] { - if targetSpace, ok = p.resolveTargetSpace(w, mmmodel.NewAppError("handleMovePageToSpace", "api.page.move_to_space.invalid_target_space_id.app_error", nil, "", http.StatusBadRequest), req.TargetSpaceId, userID); !ok { + var targetOK bool + targetSpace, targetRead, targetOK = p.requireTargetSpaceRead(w, mmmodel.NewAppError("handleMovePageToSpace", "api.page.move_to_space.invalid_target_space_id.app_error", nil, "", http.StatusBadRequest), req.TargetSpaceId, userID) + if !targetOK { return } } + // Source-side remove-class gate: delete_page (any) if held, else delete_own_page, which + // requires the caller own every page in the moved subtree (passed down as requiredOwnerID). + // No source auto-join — + // only the target side allows a non-member write. Resolved before the target gate below, which + // can join the caller to the target space: a caller denied here must not be left holding a + // membership the rejected request created. + ownOnly, ok := p.requireOwnOrAnyFrom(w, "handleMovePageToSpace", sourceSpace, userID, + "api.page.move_to_space", mmmodel.PermissionDeletePage, + "api.page.move_to_space.own", mmmodel.PermissionDeleteOwnPage, true, sourceRead) + if !ok { + return + } + requiredOwnerID := "" + if ownOnly { + requiredOwnerID = userID + } + + joined, writeOK := p.requirePageWriteFrom(w, targetSpace, userID, mmmodel.PermissionCreatePage, targetRead) + if !writeOK { + return + } - moved, appErr := p.service.MovePageToSpace(vars["page_id"], sourceSpace, targetSpace, req.ParentId, req.ExpectedUpdateAt, req.Force, userID) + // The subtree-ownership rule is enforced inside the move, so a caller admitted by the target + // gate can still be rejected here — including with a 403. Undoing the pre-step's membership + // extends the source-side ordering above to the rejections the gates cannot see. + moved, appErr := p.service.MovePageToSpace(vars["page_id"], sourceSpace, targetSpace, req.ParentId, req.ExpectedUpdateAt, req.Force, userID, requiredOwnerID) if appErr != nil { + p.service.UndoAutoJoin(joined, targetSpace, userID) p.writeAppError(w, appErr) return } diff --git a/server/api_page_drafts.go b/server/api_page_drafts.go index 38441e0..af509ad 100644 --- a/server/api_page_drafts.go +++ b/server/api_page_drafts.go @@ -43,7 +43,7 @@ func (p *Plugin) handleUpdatePageDraft(w http.ResponseWriter, r *http.Request) { pageID := vars["page_id"] userID := userIDFromRequest(r) - space, ok := p.requireSpaceMembership(w, spaceID, userID, false) + space, _, joined, ok := p.requireDraftWrite(w, spaceID, userID) if !ok { return } @@ -57,6 +57,7 @@ func (p *Plugin) handleUpdatePageDraft(w http.ResponseWriter, r *http.Request) { BaseEditAt *int64 `json:"base_edit_at"` } if !p.decodeJSONBody(w, r, maxDraftBodyBytes, &req, "handleUpdatePageDraft", false) { + p.service.UndoAutoJoin(joined, space, userID) return } @@ -83,6 +84,7 @@ func (p *Plugin) handleUpdatePageDraft(w http.ResponseWriter, r *http.Request) { // req.Props nil → preserve; non-nil → replace the whole map (an empty map clears all keys). saved, appErr := p.service.UpdatePageDraft(draft, req.ParentId, req.FileIds, req.Props, space.ChannelId) if appErr != nil { + p.service.UndoAutoJoin(joined, space, userID) p.writeAppError(w, appErr) return } @@ -97,7 +99,7 @@ func (p *Plugin) handleGetPageDraft(w http.ResponseWriter, r *http.Request) { pageID := vars["page_id"] userID := userIDFromRequest(r) - if _, ok := p.requireSpaceMembership(w, spaceID, userID, false); !ok { + if _, _, ok := p.requireSpaceRead(w, spaceID, userID); !ok { return } @@ -117,7 +119,7 @@ func (p *Plugin) handleDeletePageDraft(w http.ResponseWriter, r *http.Request) { pageID := vars["page_id"] userID := userIDFromRequest(r) - space, ok := p.requireSpaceMembership(w, spaceID, userID, false) + space, _, ok := p.requireSpaceRead(w, spaceID, userID) if !ok { return } @@ -143,7 +145,14 @@ func (p *Plugin) handleCreateSpaceDraft(w http.ResponseWriter, r *http.Request) spaceID := vars["space_id"] userID := userIDFromRequest(r) - if _, ok := p.requireSpaceMembership(w, spaceID, userID, false); !ok { + // Reserving a page id is the first act of page creation, so this gates on create_page rather + // than the looser draft-write pair. + space, resolution, ok := p.requireSpaceRead(w, spaceID, userID) + if !ok { + return + } + joined, writeOK := p.requirePageWriteFrom(w, space, userID, mmmodel.PermissionCreatePage, resolution) + if !writeOK { return } @@ -152,11 +161,13 @@ func (p *Plugin) handleCreateSpaceDraft(w http.ResponseWriter, r *http.Request) ParentId string `json:"parent_id"` } if !p.decodeJSONBody(w, r, maxPageStructBodyBytes, &req, "handleCreateSpaceDraft", false) { + p.service.UndoAutoJoin(joined, space, userID) return } saved, appErr := p.service.CreateSpaceDraft(userID, spaceID, req.Title, req.ParentId) if appErr != nil { + p.service.UndoAutoJoin(joined, space, userID) p.writeAppError(w, appErr) return } @@ -181,7 +192,8 @@ func (p *Plugin) handlePublishPageDraft(w http.ResponseWriter, r *http.Request) pageID := vars["page_id"] userID := userIDFromRequest(r) - if _, ok := p.requireSpaceMembership(w, spaceID, userID, false); !ok { + space, resolution, ok := p.requireSpaceRead(w, spaceID, userID) + if !ok { return } @@ -195,7 +207,7 @@ func (p *Plugin) handlePublishPageDraft(w http.ResponseWriter, r *http.Request) return } - page, wasCreated, appErr := p.service.PublishPageDraft(userID, spaceID, pageID, req.Force) + page, wasCreated, appErr := p.service.PublishPageDraft(space, userID, pageID, req.Force, resolution) if appErr != nil { if page != nil { p.writeConflictWithPage(w, appErr, page) @@ -219,7 +231,7 @@ func (p *Plugin) handleGetPageDraftsForSpace(w http.ResponseWriter, r *http.Requ spaceID := vars["space_id"] userID := userIDFromRequest(r) - if _, ok := p.requireSpaceMembership(w, spaceID, userID, false); !ok { + if _, _, ok := p.requireSpaceRead(w, spaceID, userID); !ok { return } diff --git a/server/api_page_drafts_test.go b/server/api_page_drafts_test.go index ccc765a..afa553d 100644 --- a/server/api_page_drafts_test.go +++ b/server/api_page_drafts_test.go @@ -26,7 +26,7 @@ import ( func TestHandler_DraftLifecycle(t *testing.T) { h := openTestPlugin(t, nil) channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() base := "/api/v1/spaces/" + space.Id @@ -61,7 +61,7 @@ func TestHandler_DraftLifecycle(t *testing.T) { // endpoint, and it omits draft bodies so the listing does not ship whole documents. func TestHandler_ListSpaceDrafts(t *testing.T) { h := openTestPlugin(t, nil) - space := seedSpace(t, h.store, mmmodel.NewId()) + space := seedSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() base := "/api/v1/spaces/" + space.Id @@ -96,7 +96,7 @@ func TestHandler_ListSpaceDrafts(t *testing.T) { func TestHandler_PublishMalformedBodyReturns400(t *testing.T) { h := openTestPlugin(t, nil) channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() base := "/api/v1/spaces/" + space.Id @@ -121,7 +121,7 @@ func TestHandler_PublishMalformedBodyReturns400(t *testing.T) { // that has no existing draft must return 404 rather than silently creating one. func TestHandler_UpdatePageDraftRequiresExistingDraft(t *testing.T) { h := openTestPlugin(t, nil) - space := seedSpace(t, h.store, mmmodel.NewId()) + space := seedSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() // No draft has been created for this page id — the PATCH must be rejected. @@ -136,7 +136,7 @@ func TestHandler_UpdatePageDraftRequiresExistingDraft(t *testing.T) { // root), while omitting parent_id entirely must leave the parent unchanged. func TestHandler_UpdatePageDraftClearsParentWhenParentIdIsEmptyString(t *testing.T) { h := openTestPlugin(t, nil) - space := seedSpace(t, h.store, mmmodel.NewId()) + space := seedSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() base := "/api/v1/spaces/" + space.Id @@ -181,7 +181,7 @@ func TestHandler_UpdatePageDraftClearsParentWhenParentIdIsEmptyString(t *testing func TestHandler_UpdatePageDraftCreatesForExistingPage(t *testing.T) { h := openTestPlugin(t, nil) channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() base := "/api/v1/spaces/" + space.Id @@ -241,7 +241,7 @@ func TestHandler_UpdatePageDraftCreatesForExistingPage(t *testing.T) { func TestHandler_PublishConflict409(t *testing.T) { h := openTestPlugin(t, nil) channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) userA := mmmodel.NewId() userB := mmmodel.NewId() base := "/api/v1/spaces/" + space.Id @@ -298,7 +298,7 @@ func TestHandler_PublishConflict409(t *testing.T) { // afterwards, and 404 when no draft exists. func TestHandler_DeletePageDraft(t *testing.T) { h := openTestPlugin(t, nil) - space := seedSpace(t, h.store, mmmodel.NewId()) + space := seedSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() base := "/api/v1/spaces/" + space.Id @@ -326,8 +326,8 @@ func TestHandler_DeletePageDraft(t *testing.T) { // leak: a page in one space must not be reachable through another space's active-editors route. func TestHandler_ActiveEditorsWrongSpaceReturns404(t *testing.T) { h := openTestPlugin(t, nil) - spaceA := seedSpace(t, h.store, mmmodel.NewId()) - spaceB := seedSpace(t, h.store, mmmodel.NewId()) + spaceA := seedSpace(t, h.store, h.db, mmmodel.NewId()) + spaceB := seedSpace(t, h.store, h.db, mmmodel.NewId()) pageInB := seedPage(t, h.store, spaceB.Id, spaceB.ChannelId, "") userID := mmmodel.NewId() @@ -342,7 +342,7 @@ func TestHandler_ActiveEditorsWrongSpaceReturns404(t *testing.T) { // an empty list when no draft is open, and the editor's user ID when they have an active draft. func TestHandler_ActiveEditorsResponseBody(t *testing.T) { h := openTestPlugin(t, nil) - space := seedSpace(t, h.store, mmmodel.NewId()) + space := seedSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() base := "/api/v1/spaces/" + space.Id @@ -392,7 +392,7 @@ func TestHandler_ActiveEditorsResponseBody(t *testing.T) { // as absent, an empty object clears all keys, and a populated object replaces the whole map. func TestHandler_UpdatePageDraftPropsPointerIntent(t *testing.T) { h := openTestPlugin(t, nil) - space := seedSpace(t, h.store, mmmodel.NewId()) + space := seedSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() base := "/api/v1/spaces/" + space.Id @@ -462,7 +462,7 @@ func TestHandler_UpdatePageDraftPropsPointerIntent(t *testing.T) { func TestHandler_UpdatePageDraftPropsBaselineNoLongerHonored(t *testing.T) { h := openTestPlugin(t, nil) channelID := mmmodel.NewId() - space := seedSpace(t, h.store, channelID) + space := seedSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() base := "/api/v1/spaces/" + space.Id @@ -491,7 +491,7 @@ func TestHandler_UpdatePageDraftPropsBaselineNoLongerHonored(t *testing.T) { // router: a stale-baseline publish that 409s without it succeeds when the request carries force. func TestHandler_PublishForceOverHTTP(t *testing.T) { h := openTestPlugin(t, nil) - space := seedSpace(t, h.store, mmmodel.NewId()) + space := seedSpace(t, h.store, h.db, mmmodel.NewId()) userA := mmmodel.NewId() userB := mmmodel.NewId() base := "/api/v1/spaces/" + space.Id diff --git a/server/api_page_presence.go b/server/api_page_presence.go index a04c050..bd46466 100644 --- a/server/api_page_presence.go +++ b/server/api_page_presence.go @@ -18,7 +18,7 @@ func (p *Plugin) handleGetPageActiveEditors(w http.ResponseWriter, r *http.Reque pageID := vars["page_id"] userID := userIDFromRequest(r) - if _, ok := p.requireSpaceMembership(w, spaceID, userID, false); !ok { + if _, _, ok := p.requireSpaceRead(w, spaceID, userID); !ok { return } diff --git a/server/api_space.go b/server/api_space.go index 9d284a2..ecea1f4 100644 --- a/server/api_space.go +++ b/server/api_space.go @@ -14,6 +14,10 @@ import ( const maxSpaceBodyBytes = 1 << 20 // 1 MiB +// maxCapabilitiesBodyBytes caps the capability-set request bodies, which carry only capability +// tokens from a small fixed vocabulary — no content fields. +const maxCapabilitiesBodyBytes = 4 * 1024 // 4 KiB + // handleGetTeamSpaces handles GET /api/v1/teams/{team_id}/spaces. func (p *Plugin) handleGetTeamSpaces(w http.ResponseWriter, r *http.Request) { userID := userIDFromRequest(r) @@ -34,9 +38,11 @@ func (p *Plugin) handleCreateSpace(w http.ResponseWriter, r *http.Request) { teamID := mux.Vars(r)["team_id"] var req struct { - Title string `json:"title"` - Description string `json:"description,omitempty"` - Icon string `json:"icon,omitempty"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Icon string `json:"icon,omitempty"` + DefaultCapabilities *[]string `json:"default_capabilities,omitempty"` + ViewAccess *model.ViewAccess `json:"view_access,omitempty"` } if !p.decodeJSONBody(w, r, maxSpaceBodyBytes, &req, "handleCreateSpace", false) { return @@ -51,7 +57,7 @@ func (p *Plugin) handleCreateSpace(w http.ResponseWriter, r *http.Request) { Description: req.Description, Icon: req.Icon, } - created, appErr := p.service.CreateSpace(space, userID) + created, appErr := p.service.CreateSpace(space, userID, req.DefaultCapabilities, req.ViewAccess) if appErr != nil { p.writeAppError(w, appErr) return @@ -59,25 +65,34 @@ func (p *Plugin) handleCreateSpace(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, created) } -// handleGetSpace handles GET /api/v1/spaces/{space_id}. +// handleGetSpace handles GET /api/v1/spaces/{space_id}, returning the SpaceWithAccess wrapper +// carrying the space's default capability set and the caller's own effective capabilities. func (p *Plugin) handleGetSpace(w http.ResponseWriter, r *http.Request) { userID := userIDFromRequest(r) spaceID := mux.Vars(r)["space_id"] - space, ok := p.requireSpaceMembership(w, spaceID, userID, false) + // BuildSpaceWithAccess resolves the read gate itself and returns the same existence-hiding 403 + // on a denial, so it is the gate here rather than a second resolution behind requireSpaceRead. + space, ok := p.getSpaceForGate(w, spaceID, false) if !ok { return } - writeJSON(w, http.StatusOK, space) + wrapper, appErr := p.service.BuildSpaceWithAccess(space, userID) + if appErr != nil { + p.writeAppError(w, appErr) + return + } + writeJSON(w, http.StatusOK, wrapper) } // handleUpdateSpace handles PATCH /api/v1/spaces/{space_id}. Only the supplied mutable fields -// (title, description, icon, props) are applied onto the existing space; a supplied empty string -// clears the field. The optimistic-lock baseline (expected_update_at) is required unless force -// is set. +// (title, description, icon, props, view_access) are applied onto the existing space; a supplied +// empty string clears a string field. The optimistic-lock baseline (expected_update_at) is +// required unless force is set. requireSpaceManage is the route floor; a ViewAccess change +// requires the stricter admin gate, enforced inside UpdateSpace against the live row. func (p *Plugin) handleUpdateSpace(w http.ResponseWriter, r *http.Request) { userID := userIDFromRequest(r) spaceID := mux.Vars(r)["space_id"] - space, ok := p.requireSpaceMembership(w, spaceID, userID, false) + space, ok := p.requireSpaceManage(w, spaceID, userID) if !ok { return } @@ -87,26 +102,36 @@ func (p *Plugin) handleUpdateSpace(w http.ResponseWriter, r *http.Request) { Description *string `json:"description"` Icon *string `json:"icon"` Props *mmmodel.StringInterface `json:"props"` + ViewAccess *model.ViewAccess `json:"view_access"` ExpectedUpdateAt *int64 `json:"expected_update_at"` Force bool `json:"force"` } if !p.decodeJSONBody(w, r, maxSpaceBodyBytes, &req, "handleUpdateSpace", false) { return } - patch := &model.SpacePatch{Title: req.Title, Description: req.Description, Icon: req.Icon, Props: req.Props} - updated, appErr := p.service.UpdateSpace(space, patch, req.ExpectedUpdateAt, req.Force) + patch := &model.SpacePatch{Title: req.Title, Description: req.Description, Icon: req.Icon, Props: req.Props, ViewAccess: req.ViewAccess} + updated, appErr := p.service.UpdateSpace(space, patch, req.ExpectedUpdateAt, req.Force, userID) if appErr != nil { p.writeAppError(w, appErr) return } - writeJSON(w, http.StatusOK, updated) + // Answered with the same SpaceWithAccess wrapper the create and single-read routes return. + // Returning a bare space here would flatten to a body a client cannot tell apart from the + // wrapper, so refreshing a cached record from this response would silently drop the capability + // fields the other two routes supplied. + wrapper, wrapErr := p.service.BuildSpaceWithAccess(updated, userID) + if wrapErr != nil { + p.writeAppError(w, wrapErr) + return + } + writeJSON(w, http.StatusOK, wrapper) } // handleDeleteSpace handles DELETE /api/v1/spaces/{space_id}. func (p *Plugin) handleDeleteSpace(w http.ResponseWriter, r *http.Request) { userID := userIDFromRequest(r) spaceID := mux.Vars(r)["space_id"] - space, ok := p.requireSpaceMembership(w, spaceID, userID, false) + space, ok := p.requireSpaceDelete(w, spaceID, userID, false) if !ok { return } @@ -122,7 +147,7 @@ func (p *Plugin) handleDeleteSpace(w http.ResponseWriter, r *http.Request) { func (p *Plugin) handleRestoreSpace(w http.ResponseWriter, r *http.Request) { userID := userIDFromRequest(r) spaceID := mux.Vars(r)["space_id"] - if _, ok := p.requireSpaceMembership(w, spaceID, userID, true); !ok { + if _, ok := p.requireSpaceDelete(w, spaceID, userID, true); !ok { return } restored, appErr := p.service.RestoreSpace(spaceID) @@ -137,7 +162,7 @@ func (p *Plugin) handleRestoreSpace(w http.ResponseWriter, r *http.Request) { func (p *Plugin) handleGetSpacePages(w http.ResponseWriter, r *http.Request) { userID := userIDFromRequest(r) spaceID := mux.Vars(r)["space_id"] - space, ok := p.requireSpaceMembership(w, spaceID, userID, false) + space, ok := p.requireSpacePagePerm(w, spaceID, userID, mmmodel.PermissionReadPage) if !ok { return } @@ -150,16 +175,16 @@ func (p *Plugin) handleGetSpacePages(w http.ResponseWriter, r *http.Request) { writePaginatedJSON(w, pages, page, perPage, hasMore) } -// handleListSpaceMembers handles GET /api/v1/spaces/{space_id}/members. -func (p *Plugin) handleListSpaceMembers(w http.ResponseWriter, r *http.Request) { +// handleGetSpaceMembers handles GET /api/v1/spaces/{space_id}/members. +func (p *Plugin) handleGetSpaceMembers(w http.ResponseWriter, r *http.Request) { userID := userIDFromRequest(r) spaceID := mux.Vars(r)["space_id"] - space, ok := p.requireSpaceMembership(w, spaceID, userID, false) + space, ok := p.requireSpaceManage(w, spaceID, userID) if !ok { return } page, perPage := pageParam(r), perPageParam(r) - members, hasMore, appErr := p.service.ListSpaceMembers(space, page, perPage) + members, hasMore, appErr := p.service.GetSpaceMembers(space, page, perPage) if appErr != nil { p.writeAppError(w, appErr) return @@ -167,21 +192,29 @@ func (p *Plugin) handleListSpaceMembers(w http.ResponseWriter, r *http.Request) writePaginatedJSON(w, members, page, perPage, hasMore) } -// handleAddSpaceMember handles POST /api/v1/spaces/{space_id}/members. Any current space member -// may add another user to the space. +// handleAddSpaceMember handles POST /api/v1/spaces/{space_id}/members. Adds the target at the +// space default only; granted_capabilities/capabilities in the body are rejected (400) rather +// than silently dropped, since a caller believing they restricted a new member's capabilities +// would otherwise be misled. func (p *Plugin) handleAddSpaceMember(w http.ResponseWriter, r *http.Request) { userID := userIDFromRequest(r) spaceID := mux.Vars(r)["space_id"] - space, ok := p.requireSpaceMembership(w, spaceID, userID, false) + space, ok := p.requireSpaceManage(w, spaceID, userID) if !ok { return } var req struct { - UserID string `json:"user_id"` + UserID string `json:"user_id"` + GrantedCapabilities *[]string `json:"granted_capabilities"` + Capabilities *[]string `json:"capabilities"` } if !p.decodeJSONBody(w, r, maxSpaceBodyBytes, &req, "handleAddSpaceMember", false) { return } + if req.GrantedCapabilities != nil || req.Capabilities != nil { + p.writeAppError(w, mmmodel.NewAppError("handleAddSpaceMember", "api.space.add_member.capabilities_not_allowed.app_error", nil, "", http.StatusBadRequest)) + return + } member, appErr := p.service.AddSpaceMember(space, req.UserID) if appErr != nil { p.writeAppError(w, appErr) @@ -190,18 +223,72 @@ func (p *Plugin) handleAddSpaceMember(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, member) } -// handleRemoveSpaceMember handles DELETE /api/v1/spaces/{space_id}/members/{user_id}. Any current -// space member may remove another user from the space, except the last remaining member (409). +// handleSetSpaceMemberCapabilities handles PUT /api/v1/spaces/{space_id}/members/{user_id}/capabilities. +func (p *Plugin) handleSetSpaceMemberCapabilities(w http.ResponseWriter, r *http.Request) { + userID := userIDFromRequest(r) + vars := mux.Vars(r) + spaceID := vars["space_id"] + targetUserID := vars["user_id"] + space, ok := p.requireSpaceManage(w, spaceID, userID) + if !ok { + return + } + var req struct { + GrantedCapabilities []string `json:"granted_capabilities"` + } + if !p.decodeJSONBody(w, r, maxCapabilitiesBodyBytes, &req, "handleSetSpaceMemberCapabilities", false) { + return + } + member, appErr := p.service.SetSpaceMemberCapabilities(space, targetUserID, req.GrantedCapabilities, userID) + if appErr != nil { + p.writeAppError(w, appErr) + return + } + writeJSON(w, http.StatusOK, member) +} + +// handleSetSpaceDefaultCapabilities handles PUT /api/v1/spaces/{space_id}/default-capabilities. +func (p *Plugin) handleSetSpaceDefaultCapabilities(w http.ResponseWriter, r *http.Request) { + userID := userIDFromRequest(r) + spaceID := mux.Vars(r)["space_id"] + space, ok := p.requireSpaceAdmin(w, spaceID, userID) + if !ok { + return + } + var req struct { + DefaultCapabilities []string `json:"default_capabilities"` + } + if !p.decodeJSONBody(w, r, maxCapabilitiesBodyBytes, &req, "handleSetSpaceDefaultCapabilities", false) { + return + } + updated, appErr := p.service.SetSpaceDefaultCapabilities(space, req.DefaultCapabilities, userID) + if appErr != nil { + p.writeAppError(w, appErr) + return + } + writeJSON(w, http.StatusOK, updated) +} + +// handleRemoveSpaceMember handles DELETE /api/v1/spaces/{space_id}/members/{user_id}. Self-removal +// is gated on the read resolver alone (any member may leave); removing another user requires +// requireSpaceManage, with the escalation/last-admin guards enforced inside the service. func (p *Plugin) handleRemoveSpaceMember(w http.ResponseWriter, r *http.Request) { userID := userIDFromRequest(r) vars := mux.Vars(r) spaceID := vars["space_id"] targetUserID := vars["user_id"] - space, ok := p.requireSpaceMembership(w, spaceID, userID, false) + + var space *model.Space + var ok bool + if targetUserID == userID { + space, _, ok = p.requireSpaceRead(w, spaceID, userID) + } else { + space, ok = p.requireSpaceManage(w, spaceID, userID) + } if !ok { return } - if appErr := p.service.RemoveSpaceMember(space, targetUserID); appErr != nil { + if appErr := p.service.RemoveSpaceMember(space, targetUserID, userID); appErr != nil { p.writeAppError(w, appErr) return } diff --git a/server/app/page_draft.go b/server/app/page_draft.go index c963d3b..e8d52ab 100644 --- a/server/app/page_draft.go +++ b/server/app/page_draft.go @@ -289,13 +289,14 @@ func (s *Service) GetPageDraftsForSpace(userID, spaceID string, page, perPage in // - wasCreated=false → an existing page was updated, or a concurrent create was adopted (return 200) // - appErr is a 409 edit conflict → page is the current server page (or nil if the re-read failed), // so the caller can surface a diff without a follow-up read; on every other error page is nil. -func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) (*model.Page, bool, *mmmodel.AppError) { +func (s *Service) PublishPageDraft(space *model.Space, userID, pageID string, force bool, admittedVia ReadResolution) (publishedPage *model.Page, wasCreated bool, appErr *mmmodel.AppError) { if !mmmodel.IsValidId(userID) { return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.invalid_user_id.app_error", nil, "", http.StatusBadRequest) } - if !mmmodel.IsValidId(spaceID) { + if space == nil || !mmmodel.IsValidId(space.Id) { return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.invalid_space_id.app_error", nil, "", http.StatusBadRequest) } + spaceID := space.Id if !mmmodel.IsValidId(pageID) { return nil, false, mmmodel.NewAppError("PublishPageDraft", "app.page_draft.publish.invalid_page_id.app_error", nil, "", http.StatusBadRequest) } @@ -317,6 +318,24 @@ func (s *Service) PublishPageDraft(userID, spaceID, pageID string, force bool) ( return nil, false, targetErr } + // Authorization runs here rather than in the handler because it depends on the classification + // above: publishing a new page needs create_page, publishing over a live one needs edit_page. + // The draft write that produced this content was gated only on the looser create-or-edit pair, + // so this check must precede the writes below. + joined, permErr := s.RequireSpacePublish("PublishPageDraft", space, userID, admittedVia, isNewPage) + if permErr != nil { + s.UndoAutoJoin(joined, space, userID) + return nil, false, permErr + } + // The writes below can still reject the publish — an unpublished parent, a failed validation, a + // concurrent autosave — so the membership the pre-step created is undone on every one of those + // paths rather than only on the gate's own denial. + defer func() { + if appErr != nil { + s.UndoAutoJoin(joined, space, userID) + } + }() + // 3. Parent guard (new-page path only): a new page's parent must be a published live page; a // draft-only or non-live parent returns 409. The edit path never reparents, so a stale ParentId // carried on the draft must not block a content-only edit-publish. diff --git a/server/app/page_draft_test.go b/server/app/page_draft_test.go index d8db064..c035303 100644 --- a/server/app/page_draft_test.go +++ b/server/app/page_draft_test.go @@ -27,16 +27,16 @@ func docWith(text string) string { // publishNewPage creates a new-page draft, autosaves the given body, and publishes it, returning // the live page. It asserts the reserved draft id is preserved through publish. -func publishNewPage(t *testing.T, h *testHarness, spaceID, userID, title, bodyText string) *model.Page { +func publishNewPage(t *testing.T, h *testHarness, space *model.Space, userID, title, bodyText string) *model.Page { t.Helper() - draft, appErr := h.svc.CreateSpaceDraft(userID, spaceID, title, "") + draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, title, "") require.Nil(t, appErr) reservedID := draft.PageId - _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: spaceID, PageId: reservedID, Title: title, Body: docWith(bodyText)}, nil, nil, nil, "") + _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: reservedID, Title: title, Body: docWith(bodyText)}, nil, nil, nil, "") require.Nil(t, appErr) - page, wasCreated, appErr := h.svc.PublishPageDraft(userID, spaceID, reservedID, false) + page, wasCreated, appErr := h.svc.PublishPageDraft(space, userID, reservedID, false, app.ReadViaMember) require.Nil(t, appErr) require.True(t, wasCreated, "publishing a brand-new page must report wasCreated=true") require.Equal(t, reservedID, page.Id, "publish must preserve the reserved draft id") @@ -46,7 +46,7 @@ func publishNewPage(t *testing.T, h *testHarness, spaceID, userID, title, bodyTe func TestUpdatePageDraftPreservesBodyOnTitleOnlyAutosave(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Doc", "") @@ -65,10 +65,10 @@ func TestUpdatePageDraftPreservesBodyOnTitleOnlyAutosave(t *testing.T) { func TestPublishEmptyDraftBodyDoesNotWipePage(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() - page := publishNewPage(t, h, space.Id, userID, "Important", "ORIGINAL") + page := publishNewPage(t, h, space, userID, "Important", "ORIGINAL") // Start an edit session whose first (and only) autosave carries the title but no body, exactly // the heartbeat case that previously wiped the page on publish. @@ -78,7 +78,7 @@ func TestPublishEmptyDraftBodyDoesNotWipePage(t *testing.T) { }, nil, nil, nil, "") require.Nil(t, appErr) - republished, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, false) + republished, _, appErr := h.svc.PublishPageDraft(space, userID, page.Id, false, app.ReadViaMember) require.Nil(t, appErr) require.Contains(t, republished.Body, "ORIGINAL", "publishing a title-only edit must preserve the page body") } @@ -90,10 +90,10 @@ func TestPublishEmptyDraftBodyDoesNotWipePage(t *testing.T) { func TestPublishNoOpDraftDiscardsAndReturnsExistingPage(t *testing.T) { mockAPI := &plugintest.API{} h := openTestServiceWithAPI(t, mockAPI) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() - page := publishNewPage(t, h, space.Id, userID, "Doc", "original") + page := publishNewPage(t, h, space, userID, "Doc", "original") // Start an edit session whose only autosave carries the baseline — no Title, no Body. _, appErr := h.svc.UpdatePageDraft(&model.Draft{ @@ -102,7 +102,7 @@ func TestPublishNoOpDraftDiscardsAndReturnsExistingPage(t *testing.T) { }, nil, nil, nil, "") require.Nil(t, appErr) - result, wasCreated, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, false) + result, wasCreated, appErr := h.svc.PublishPageDraft(space, userID, page.Id, false, app.ReadViaMember) require.Nil(t, appErr) require.False(t, wasCreated, "a no-content publish must not report a creation") require.Equal(t, page.Title, result.Title, "the page's title must be unchanged") @@ -118,10 +118,10 @@ func TestPublishNoOpDraftDiscardsAndReturnsExistingPage(t *testing.T) { func TestPublishRejectsMissingBaselineOnEdit(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() - page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + page := publishNewPage(t, h, space, userID, "Doc", "v1") // UpsertDraft guards the first edit-draft save: attempting to create a draft for an existing // page without a base_edit_at baseline is rejected at the store layer, so the client must send @@ -137,7 +137,7 @@ func TestPublishRejectsMissingBaselineOnEdit(t *testing.T) { }, nil, nil, nil, "") require.Nil(t, appErr) - forced, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, true) + forced, _, appErr := h.svc.PublishPageDraft(space, userID, page.Id, true, app.ReadViaMember) require.Nil(t, appErr) require.Contains(t, forced.Body, "v2") } @@ -152,7 +152,7 @@ func TestPublishRejectsMissingBaselineOnEdit(t *testing.T) { func TestPublishRejectsNoBaselineOnExistingPageEdit(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() page := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -166,13 +166,13 @@ func TestPublishRejectsNoBaselineOnExistingPageEdit(t *testing.T) { require.NoError(t, err) // Without a baseline, a non-force publish of an edit must be rejected. - _, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, false) + _, _, appErr := h.svc.PublishPageDraft(space, userID, page.Id, false, app.ReadViaMember) require.NotNil(t, appErr) require.Equal(t, http.StatusBadRequest, appErr.StatusCode) require.Equal(t, "app.page_draft.publish.baseline_required.app_error", appErr.Id) // force=true bypasses the missing-baseline guard and publishes the edit. - forced, wasCreated, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, true) + forced, wasCreated, appErr := h.svc.PublishPageDraft(space, userID, page.Id, true, app.ReadViaMember) require.Nil(t, appErr) require.False(t, wasCreated) require.Equal(t, "Edited", forced.Title) @@ -181,10 +181,10 @@ func TestPublishRejectsNoBaselineOnExistingPageEdit(t *testing.T) { func TestPublishStaleBaselineConflicts(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() - page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + page := publishNewPage(t, h, space, userID, "Doc", "v1") staleEditAt := page.EditAt // Start an edit session with a draft baselined at the current EditAt. The draft persists (it is @@ -204,7 +204,7 @@ func TestPublishStaleBaselineConflicts(t *testing.T) { // Publishing the draft against the now-stale baseline must 409, and return the current server // page (the concurrent edit's content + advanced baseline) so the caller can diff without a // follow-up read. - current, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, false) + current, _, appErr := h.svc.PublishPageDraft(space, userID, page.Id, false, app.ReadViaMember) require.NotNil(t, appErr) require.Equal(t, http.StatusConflict, appErr.StatusCode) require.NotNil(t, current, "edit conflict must return the current server page") @@ -217,10 +217,10 @@ func TestPublishStaleBaselineConflicts(t *testing.T) { // path in PublishPageDraft guards only the concurrent-delete race, which this flow cannot produce. func TestPublishAfterPageDeleteReturns404(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() - page := publishNewPage(t, h, space.Id, userID, "Doomed", "x") + page := publishNewPage(t, h, space, userID, "Doomed", "x") _, appErr := h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doomed", Body: docWith("y"), BaseEditAt: page.EditAt, @@ -228,15 +228,15 @@ func TestPublishAfterPageDeleteReturns404(t *testing.T) { require.Nil(t, appErr) requireStoreDeletePage(t, h.store, page.Id, space.Id, userID) - _, _, appErr = h.svc.PublishPageDraft(userID, space.Id, page.Id, false) + _, _, appErr = h.svc.PublishPageDraft(space, userID, page.Id, false, app.ReadViaMember) require.NotNil(t, appErr) require.Equal(t, http.StatusNotFound, appErr.StatusCode) } func TestDeletePageDraftRejectsWrongSpace(t *testing.T) { h := openTestService(t) - spaceA := mustCreateSpace(t, h.store, mmmodel.NewId()) - spaceB := mustCreateSpace(t, h.store, mmmodel.NewId()) + spaceA := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) + spaceB := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() draft, appErr := h.svc.CreateSpaceDraft(userID, spaceA.Id, "In A", "") @@ -259,7 +259,7 @@ func TestDeletePageDraftRejectsWrongSpace(t *testing.T) { // draft gone / 409 conflict). Either way the page ends up live and the draft consumed. func TestPublishPageDraftConcurrentPublishesConverge(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Doc", "") @@ -278,7 +278,7 @@ func TestPublishPageDraftConcurrentPublishesConverge(t *testing.T) { for i := range results { wg.Go(func() { <-start - p, created, pubErr := h.svc.PublishPageDraft(userID, space.Id, draft.PageId, false) + p, created, pubErr := h.svc.PublishPageDraft(space, userID, draft.PageId, false, app.ReadViaMember) results[i] = result{page: p, created: created, appErr: pubErr} }) } @@ -316,7 +316,7 @@ func TestPublishPageDraftConcurrentPublishesConverge(t *testing.T) { // row at all (as opposed to a page living in another space, covered below). func TestGetPageActiveEditorsUnknownPageReturns404(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) _, appErr := h.svc.GetPageActiveEditors(mmmodel.NewId(), space.Id) require.NotNil(t, appErr) @@ -325,11 +325,11 @@ func TestGetPageActiveEditorsUnknownPageReturns404(t *testing.T) { func TestGetPageActiveEditorsRejectsWrongSpace(t *testing.T) { h := openTestService(t) - spaceA := mustCreateSpace(t, h.store, mmmodel.NewId()) - spaceB := mustCreateSpace(t, h.store, mmmodel.NewId()) + spaceA := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) + spaceB := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() - page := publishNewPage(t, h, spaceA.Id, userID, "Doc", "x") + page := publishNewPage(t, h, spaceA, userID, "Doc", "x") // The page lives in space A; querying its editors through space B must not resolve it. _, appErr := h.svc.GetPageActiveEditors(page.Id, spaceB.Id) @@ -344,10 +344,10 @@ func TestGetPageActiveEditorsRejectsWrongSpace(t *testing.T) { func TestActiveEditorsSurfacesHeartbeat(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() - page := publishNewPage(t, h, space.Id, userID, "Doc", "x") + page := publishNewPage(t, h, space, userID, "Doc", "x") // An autosave is the heartbeat; the editor must then appear as active. _, appErr := h.svc.UpdatePageDraft(&model.Draft{ @@ -365,19 +365,19 @@ func TestActiveEditorsSurfacesHeartbeat(t *testing.T) { func TestPublishSetsLastModifiedBy(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() - page := publishNewPage(t, h, space.Id, userID, "Doc", "x") + page := publishNewPage(t, h, space, userID, "Doc", "x") require.Equal(t, userID, page.LastModifiedBy, "a page published via draft must record its author as last modifier") } func TestPublishForceOverridesStaleBaseline(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() - page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + page := publishNewPage(t, h, space, userID, "Doc", "v1") staleEditAt := page.EditAt // Start an edit session with a draft baselined at the current EditAt; the draft persists. @@ -393,7 +393,7 @@ func TestPublishForceOverridesStaleBaseline(t *testing.T) { require.Nil(t, appErr) // force=true must override the stale-baseline CAS and win with the draft's content. - forced, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, true) + forced, _, appErr := h.svc.PublishPageDraft(space, userID, page.Id, true, app.ReadViaMember) require.Nil(t, appErr) require.Contains(t, forced.Body, "v3", "force must override a stale baseline") require.Greater(t, forced.EditAt, concurrentPage.EditAt, "a force-publish must still advance the page's EditAt") @@ -401,10 +401,10 @@ func TestPublishForceOverridesStaleBaseline(t *testing.T) { func TestPublishForceDoesNotRevertUntouchedField(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() - page := publishNewPage(t, h, space.Id, userID, "Original title", "original body") + page := publishNewPage(t, h, space, userID, "Original title", "original body") baseEditAt := page.EditAt // A title-only edit: the draft carries a new title but no body, baselined at the current EditAt. @@ -421,7 +421,7 @@ func TestPublishForceDoesNotRevertUntouchedField(t *testing.T) { // Force-publish the title-only draft: force overrides the stale baseline, but only the title may // be applied. The concurrent body edit must survive rather than be reverted to the pre-lock value. - forced, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, true) + forced, _, appErr := h.svc.PublishPageDraft(space, userID, page.Id, true, app.ReadViaMember) require.Nil(t, appErr) require.Equal(t, "New title", forced.Title, "the draft's title must be applied") require.Contains(t, forced.Body, "concurrent body", "a field the draft never set must not be reverted") @@ -429,10 +429,10 @@ func TestPublishForceDoesNotRevertUntouchedField(t *testing.T) { func TestUpdatePageDraftRejectsStaleBaselineAfterPublish(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() - page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + page := publishNewPage(t, h, space, userID, "Doc", "v1") baseEditAt := page.EditAt // Edit and publish: the draft is consumed and the page's EditAt advances. @@ -441,7 +441,7 @@ func TestUpdatePageDraftRejectsStaleBaselineAfterPublish(t *testing.T) { BaseEditAt: baseEditAt, }, nil, nil, nil, "") require.Nil(t, appErr) - _, _, appErr = h.svc.PublishPageDraft(userID, space.Id, page.Id, false) + _, _, appErr = h.svc.PublishPageDraft(space, userID, page.Id, false, app.ReadViaMember) require.Nil(t, appErr) // A late autosave carrying the pre-publish baseline must be rejected, not resurrect a phantom @@ -461,7 +461,7 @@ func TestUpdatePageDraftRejectsStaleBaselineAfterPublish(t *testing.T) { func TestUpdatePageDraftPreservesPropsOnOmit(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Doc", "") @@ -495,7 +495,7 @@ func TestUpdatePageDraftPreservesPropsOnOmit(t *testing.T) { // by Draft.IsValid's size guard in the store, surfaced through the app layer. func TestUpdatePageDraftRejectsOversizedBody(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Doc", "") @@ -516,7 +516,7 @@ func TestUpdatePageDraftRejectsOversizedBody(t *testing.T) { // carries no props preserves them. func TestPublishCarriesDraftProps(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() // New-page publish adopts the draft's props. @@ -528,7 +528,7 @@ func TestPublishCarriesDraftProps(t *testing.T) { }, nil, nil, &mmmodel.StringInterface{"color": "blue"}, "") require.Nil(t, appErr) - page, wasCreated, appErr := h.svc.PublishPageDraft(userID, space.Id, pageID, false) + page, wasCreated, appErr := h.svc.PublishPageDraft(space, userID, pageID, false, app.ReadViaMember) require.Nil(t, appErr) require.True(t, wasCreated) require.Equal(t, "blue", page.Props["color"], "a new page must adopt the draft's props on publish") @@ -538,7 +538,7 @@ func TestPublishCarriesDraftProps(t *testing.T) { UserId: userID, SpaceId: space.Id, PageId: pageID, Title: "Doc", BaseEditAt: page.EditAt, }, nil, nil, &mmmodel.StringInterface{"color": "red"}, "") require.Nil(t, appErr) - edited, _, appErr := h.svc.PublishPageDraft(userID, space.Id, pageID, false) + edited, _, appErr := h.svc.PublishPageDraft(space, userID, pageID, false, app.ReadViaMember) require.Nil(t, appErr) require.Equal(t, "red", edited.Props["color"], "an edit with non-empty draft props must replace the page's props") @@ -547,7 +547,7 @@ func TestPublishCarriesDraftProps(t *testing.T) { UserId: userID, SpaceId: space.Id, PageId: pageID, Body: docWith("v2"), BaseEditAt: edited.EditAt, }, nil, nil, nil, "") require.Nil(t, appErr) - preserved, _, appErr := h.svc.PublishPageDraft(userID, space.Id, pageID, false) + preserved, _, appErr := h.svc.PublishPageDraft(space, userID, pageID, false, app.ReadViaMember) require.Nil(t, appErr) require.Equal(t, "red", preserved.Props["color"], "an edit carrying no props must preserve the page's props") require.Contains(t, preserved.Body, "v2") @@ -555,8 +555,8 @@ func TestPublishCarriesDraftProps(t *testing.T) { func TestPublishRejectsForeignSpacePage(t *testing.T) { h := openTestService(t) - spaceA := mustCreateSpace(t, h.store, mmmodel.NewId()) - spaceB := mustCreateSpace(t, h.store, mmmodel.NewId()) + spaceA := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) + spaceB := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userA, userB := mmmodel.NewId(), mmmodel.NewId() // userA reserves a page id in space A. @@ -573,14 +573,14 @@ func TestPublishRejectsForeignSpacePage(t *testing.T) { // userA autosaves content and publishes, so the page becomes live in space A. _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userA, SpaceId: spaceA.Id, PageId: pageID, Title: "A doc", Body: docWith("a content")}, nil, nil, nil, "") require.Nil(t, appErr) - pageA, wasCreated, appErr := h.svc.PublishPageDraft(userA, spaceA.Id, pageID, false) + pageA, wasCreated, appErr := h.svc.PublishPageDraft(spaceA, userA, pageID, false, app.ReadViaMember) require.Nil(t, appErr) require.True(t, wasCreated) // userB has no draft for pageID (the cross-space reservation was rejected above), so publishing // from space B must fail with 404 (draft not found). force cannot bypass this. for _, force := range []bool{false, true} { - _, _, appErr = h.svc.PublishPageDraft(userB, spaceB.Id, pageID, force) + _, _, appErr = h.svc.PublishPageDraft(spaceB, userB, pageID, force, app.ReadViaMember) require.NotNil(t, appErr, "cross-space publish (force=%v) must fail", force) require.Equal(t, http.StatusNotFound, appErr.StatusCode, "cross-space publish (force=%v) must be rejected", force) @@ -595,7 +595,7 @@ func TestPublishRejectsForeignSpacePage(t *testing.T) { func TestUpdatePageDraftPreservesTitleOnBodyOnlyAutosave(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Keep Title", "") @@ -613,7 +613,7 @@ func TestUpdatePageDraftPreservesTitleOnBodyOnlyAutosave(t *testing.T) { func TestUpdatePageDraftSanitizesBody(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Doc", "") @@ -633,10 +633,10 @@ func TestUpdatePageDraftSanitizesBody(t *testing.T) { // publish a content-only edit, because the edit path never reparents. func TestPublishEditIgnoresStaleParentGuard(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() - parent := publishNewPage(t, h, space.Id, userID, "Parent", "p") + parent := publishNewPage(t, h, space, userID, "Parent", "p") // Publish a child under the (live) parent. childDraft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Child", parent.Id) @@ -644,7 +644,7 @@ func TestPublishEditIgnoresStaleParentGuard(t *testing.T) { childID := childDraft.PageId _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: childID, Title: "Child", Body: docWith("c1")}, nil, nil, nil, "") require.Nil(t, appErr) - child, _, appErr := h.svc.PublishPageDraft(userID, space.Id, childID, false) + child, _, appErr := h.svc.PublishPageDraft(space, userID, childID, false, app.ReadViaMember) require.Nil(t, appErr) // Start an edit session whose draft still carries the (currently live) parent id. @@ -659,7 +659,7 @@ func TestPublishEditIgnoresStaleParentGuard(t *testing.T) { requireStoreDeletePage(t, h.store, parent.Id, space.Id, userID) // A content-only edit-publish must still succeed: the edit path does not reparent. - republished, wasCreated, appErr := h.svc.PublishPageDraft(userID, space.Id, childID, false) + republished, wasCreated, appErr := h.svc.PublishPageDraft(space, userID, childID, false, app.ReadViaMember) require.Nil(t, appErr, "edit-publish must not be blocked by a stale parent id") require.False(t, wasCreated) require.Contains(t, republished.Body, "c2") @@ -667,7 +667,7 @@ func TestPublishEditIgnoresStaleParentGuard(t *testing.T) { func TestUpdatePageDraftRejectsInvalidPageID(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() _, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: "not-a-valid-id", Title: "x"}, nil, nil, nil, "") @@ -677,7 +677,7 @@ func TestUpdatePageDraftRejectsInvalidPageID(t *testing.T) { func TestCreateSpaceDraftRejectsForeignDraftParent(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userA, userB := mmmodel.NewId(), mmmodel.NewId() parent, appErr := h.svc.CreateSpaceDraft(userA, space.Id, "Parent", "") @@ -691,14 +691,14 @@ func TestCreateSpaceDraftRejectsForeignDraftParent(t *testing.T) { // userA can, and the child then publishes only after the parent does. child, appErr := h.svc.CreateSpaceDraft(userA, space.Id, "Child", parent.PageId) require.Nil(t, appErr) - _, _, appErr = h.svc.PublishPageDraft(userA, space.Id, child.PageId, false) + _, _, appErr = h.svc.PublishPageDraft(space, userA, child.PageId, false, app.ReadViaMember) require.NotNil(t, appErr) require.Equal(t, http.StatusConflict, appErr.StatusCode, "child cannot publish under an unpublished parent") } func TestUpdatePageDraftRejectsInvalidUserID(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) _, appErr := h.svc.UpdatePageDraft(&model.Draft{UserId: "bad-id", SpaceId: space.Id, PageId: mmmodel.NewId()}, nil, nil, nil, "") require.NotNil(t, appErr) @@ -715,7 +715,7 @@ func TestUpdatePageDraftRejectsInvalidSpaceID(t *testing.T) { func TestCreateSpaceDraftRejectsEmptyTitle(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) _, appErr := h.svc.CreateSpaceDraft(mmmodel.NewId(), space.Id, "", "") require.NotNil(t, appErr) @@ -724,7 +724,7 @@ func TestCreateSpaceDraftRejectsEmptyTitle(t *testing.T) { func TestCreateSpaceDraftRejectsTitleOverCap(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) longTitle := strings.Repeat("x", model.PageTitleMaxRunes+1) _, appErr := h.svc.CreateSpaceDraft(mmmodel.NewId(), space.Id, longTitle, "") @@ -734,7 +734,7 @@ func TestCreateSpaceDraftRejectsTitleOverCap(t *testing.T) { func TestCreateSpaceDraftRejectsMalformedParentID(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) _, appErr := h.svc.CreateSpaceDraft(mmmodel.NewId(), space.Id, "Doc", "not-a-valid-id") require.NotNil(t, appErr) @@ -746,7 +746,7 @@ func TestCreateSpaceDraftRejectsMalformedParentID(t *testing.T) { // the draft has already been consumed by publish, rather than recreating a phantom draft. func TestUpdatePageDraftRejectsResurrectionAfterNewPagePublish(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Doc", "") @@ -755,7 +755,7 @@ func TestUpdatePageDraftRejectsResurrectionAfterNewPagePublish(t *testing.T) { _, appErr = h.svc.UpdatePageDraft(&model.Draft{UserId: userID, SpaceId: space.Id, PageId: pageID, Body: docWith("v1")}, nil, nil, nil, "") require.Nil(t, appErr) - _, _, appErr = h.svc.PublishPageDraft(userID, space.Id, pageID, false) + _, _, appErr = h.svc.PublishPageDraft(space, userID, pageID, false, app.ReadViaMember) require.Nil(t, appErr) // Late autosave with no baseline: the draft is gone, so this must be rejected. @@ -775,7 +775,7 @@ func TestUpdatePageDraftRejectsResurrectionAfterNewPagePublish(t *testing.T) { // parent to a page that already (transitively) points back to the draft must be rejected. func TestUpdatePageDraftRejectsDraftParentCycle(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() draftA, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "A", "") @@ -799,7 +799,7 @@ func TestUpdatePageDraftRejectsDraftParentCycle(t *testing.T) { func TestUpdatePageDraftRejectsDraftHierarchyTooDeep(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() // Build a chain of model.MaxPageDepth+1 drafts. The first draft is the root @@ -833,7 +833,7 @@ func TestUpdatePageDraftRejectsDraftHierarchyTooDeep(t *testing.T) { func TestDeletePageDraftReparentsChildren(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() // Create a parent draft P and a child draft C that points to P. @@ -865,8 +865,8 @@ func TestDeletePageDraftReparentsChildren(t *testing.T) { func TestGetPageDraftCrossSpaceReturns404(t *testing.T) { h := openTestService(t) - spaceA := mustCreateSpace(t, h.store, mmmodel.NewId()) - spaceB := mustCreateSpace(t, h.store, mmmodel.NewId()) + spaceA := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) + spaceB := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() draft, appErr := h.svc.CreateSpaceDraft(userID, spaceA.Id, "Doc", "") @@ -880,7 +880,7 @@ func TestGetPageDraftCrossSpaceReturns404(t *testing.T) { func TestDeletePageDraftRejectsInvalidUserID(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) appErr := h.svc.DeletePageDraft("bad-id", space.Id, mmmodel.NewId(), "") require.NotNil(t, appErr) @@ -889,7 +889,7 @@ func TestDeletePageDraftRejectsInvalidUserID(t *testing.T) { func TestDeletePageDraftRejectsInvalidPageID(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) appErr := h.svc.DeletePageDraft(mmmodel.NewId(), space.Id, "bad-id", "") require.NotNil(t, appErr) @@ -898,12 +898,12 @@ func TestDeletePageDraftRejectsInvalidPageID(t *testing.T) { func TestPublishNewPageRejectsCrossSpaceParent(t *testing.T) { h := openTestService(t) - spaceA := mustCreateSpace(t, h.store, mmmodel.NewId()) - spaceB := mustCreateSpace(t, h.store, mmmodel.NewId()) + spaceA := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) + spaceB := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() // Publish a live parent in spaceA. - parentInA := publishNewPage(t, h, spaceA.Id, userID, "Parent", "p") + parentInA := publishNewPage(t, h, spaceA, userID, "Parent", "p") // The store rejects a draft whose ParentId points at a page in another space. // To simulate the race (parent was in the same space when editing started, then moved), @@ -920,7 +920,7 @@ func TestPublishNewPageRejectsCrossSpaceParent(t *testing.T) { // Publishing must reject: the parent lives in a different space than the draft's space. The guard // collapses "cross-space" and "not a live page" into a single parent_unpublished 409 so the // response can't be used to probe page ids in spaces the caller cannot read. - _, _, appErr := h.svc.PublishPageDraft(userID, spaceB.Id, pageID, false) + _, _, appErr := h.svc.PublishPageDraft(spaceB, userID, pageID, false, app.ReadViaMember) require.NotNil(t, appErr) require.Equal(t, http.StatusConflict, appErr.StatusCode, "cross-space parent must be rejected: %v", appErr) require.Equal(t, "app.page_draft.publish.parent_unpublished.app_error", appErr.Id) @@ -928,7 +928,7 @@ func TestPublishNewPageRejectsCrossSpaceParent(t *testing.T) { func TestGetPageActiveEditorsRejectsInvalidPageID(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) _, appErr := h.svc.GetPageActiveEditors("not-valid", space.Id) require.NotNil(t, appErr) @@ -937,7 +937,7 @@ func TestGetPageActiveEditorsRejectsInvalidPageID(t *testing.T) { func TestCreateSpaceDraftEnforcesQuota(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() // Fill the user's quota by inserting rows directly; calling CreateSpaceDraft 100 times @@ -966,7 +966,7 @@ func TestCreateSpaceDraftEnforcesQuota(t *testing.T) { // the service layer, independent of HTTP routing. func TestGetPageDraftRejectsInvalidIDs(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID, pageID := mmmodel.NewId(), mmmodel.NewId() cases := []struct { @@ -990,7 +990,7 @@ func TestGetPageDraftRejectsInvalidIDs(t *testing.T) { // validation, which handler tests reach only through valid routes. func TestGetPageDraftsForSpaceRejectsInvalidIDs(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) _, _, appErr := h.svc.GetPageDraftsForSpace("not-valid", space.Id, 0, 10) require.NotNil(t, appErr) @@ -1015,10 +1015,10 @@ func TestGetPageActiveEditorsRejectsInvalidSpaceID(t *testing.T) { // draft left unset. func TestPublishForceAppliesDraftFieldOverConcurrentEdit(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() - page := publishNewPage(t, h, space.Id, userID, "Original title", "original body") + page := publishNewPage(t, h, space, userID, "Original title", "original body") baseEditAt := page.EditAt // A title-only edit draft baselined at the current EditAt. @@ -1035,7 +1035,7 @@ func TestPublishForceAppliesDraftFieldOverConcurrentEdit(t *testing.T) { &model.PagePatch{Title: &concurrentTitle, Body: &concurrentBody}, new(baseEditAt), false, userID) require.Nil(t, appErr) - forced, _, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, true) + forced, _, appErr := h.svc.PublishPageDraft(space, userID, page.Id, true, app.ReadViaMember) require.Nil(t, appErr) require.Equal(t, "Draft title", forced.Title, "the field the draft carries must win under force") require.Contains(t, forced.Body, "concurrent body", "a field the draft never set must keep the concurrent value") @@ -1046,7 +1046,7 @@ func TestPublishForceAppliesDraftFieldOverConcurrentEdit(t *testing.T) { // is the only bound on the write path. func TestUpdatePageDraftRejectsOversizedFileIds(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() // 12 valid ids serialize to ~349 runes, over the DraftFileIdsMaxRunes=300 cap. @@ -1066,7 +1066,7 @@ func TestUpdatePageDraftRejectsOversizedFileIds(t *testing.T) { // and this guard is the only bound on the written value. func TestUpdatePageDraftRejectsOversizedProps(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() props := mmmodel.StringInterface{"k": strings.Repeat("x", model.PagePropsMaxBytes)} @@ -1080,7 +1080,7 @@ func TestUpdatePageDraftRejectsOversizedProps(t *testing.T) { // intent, including the empty entry an empty-slice clear must not admit. func TestUpdatePageDraftRejectsInvalidFileId(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) userID := mmmodel.NewId() for name, entry := range map[string]string{"malformed id": "not-a-valid-id", "empty entry": ""} { diff --git a/server/app/page_duplicate_test.go b/server/app/page_duplicate_test.go index ffc81f6..d93afeb 100644 --- a/server/app/page_duplicate_test.go +++ b/server/app/page_duplicate_test.go @@ -25,7 +25,7 @@ import ( func TestServiceDuplicatePage(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() parent := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -54,7 +54,7 @@ func TestServiceDuplicatePage(t *testing.T) { func TestServiceDuplicatePage_CopiesSearchText(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() source, appErr := h.svc.CreatePage(space.Id, "", "Searchable", "body text", userID) @@ -73,7 +73,7 @@ func TestServiceDuplicatePage_CopiesSearchText(t *testing.T) { func TestServiceDuplicatePage_CopiesProps(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() source, appErr := h.svc.CreatePage(space.Id, "", "Has Props", "body text", userID) @@ -104,7 +104,7 @@ func TestServiceDuplicatePage_CopiesProps(t *testing.T) { func TestServiceDuplicatePage_TruncatesLongTitle(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() longTitle := strings.Repeat("x", model.PageTitleMaxRunes) @@ -146,7 +146,7 @@ func TestServiceDuplicatePage_NilSpace(t *testing.T) { func TestServiceDuplicatePage_InvalidUserID(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) source := mustCreatePage(t, h.store, space.Id, channelID, mmmodel.NewId(), "") _, appErr := h.svc.DuplicatePage(source.Id, space, "not-an-id", false, nil, nil) @@ -161,7 +161,7 @@ func TestServiceDuplicatePage_InvalidUserID(t *testing.T) { func TestServiceDuplicatePage_IncludeChildren(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() source := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -198,7 +198,7 @@ func TestServiceDuplicatePage_IncludeChildren(t *testing.T) { func TestServiceDuplicatePage_MaxDepthExceeded(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() // A chain of MaxPageDepth pages: the deepest has MaxPageDepth-1 ancestors, so a single page @@ -223,7 +223,7 @@ func TestServiceDuplicatePage_MaxDepthExceeded(t *testing.T) { func TestServiceDuplicatePage_TargetParentNotFound(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() source := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -241,7 +241,7 @@ func TestServiceDuplicatePage_TargetParentNotFound(t *testing.T) { func TestServiceDuplicatePage_TargetParentIsSourceItself(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() source := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -260,10 +260,10 @@ func TestServiceDuplicatePage_TargetParentInWrongSpace(t *testing.T) { userID := mmmodel.NewId() teamID := mmmodel.NewId() - sourceSpace := seedSpaceForTeam(t, h.store, mmmodel.NewId(), teamID) + sourceSpace := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), teamID) source := mustCreatePage(t, h.store, sourceSpace.Id, sourceSpace.ChannelId, userID, "") - targetSpace := seedSpaceForTeam(t, h.store, mmmodel.NewId(), teamID) + targetSpace := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), teamID) // parentInSource lives in sourceSpace, not targetSpace — an invalid destination parent for a // copy landing in targetSpace. parentInSource := mustCreatePage(t, h.store, sourceSpace.Id, sourceSpace.ChannelId, userID, "") @@ -281,10 +281,10 @@ func TestServiceDuplicatePage_TargetSpaceAndParent(t *testing.T) { userID := mmmodel.NewId() teamID := mmmodel.NewId() - sourceSpace := seedSpaceForTeam(t, h.store, mmmodel.NewId(), teamID) + sourceSpace := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), teamID) source := mustCreatePage(t, h.store, sourceSpace.Id, sourceSpace.ChannelId, userID, "") - targetSpace := seedSpaceForTeam(t, h.store, mmmodel.NewId(), teamID) + targetSpace := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), teamID) targetParent := mustCreatePage(t, h.store, targetSpace.Id, targetSpace.ChannelId, userID, "") dup, appErr := h.svc.DuplicatePage(source.Id, sourceSpace, userID, false, targetSpace, &targetParent.Id) @@ -302,11 +302,11 @@ func TestServiceDuplicatePage_CrossSpaceDefaultsToRoot(t *testing.T) { userID := mmmodel.NewId() teamID := mmmodel.NewId() - sourceSpace := seedSpaceForTeam(t, h.store, mmmodel.NewId(), teamID) + sourceSpace := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), teamID) sourceParent := mustCreatePage(t, h.store, sourceSpace.Id, sourceSpace.ChannelId, userID, "") source := mustCreatePage(t, h.store, sourceSpace.Id, sourceSpace.ChannelId, userID, sourceParent.Id) - targetSpace := seedSpaceForTeam(t, h.store, mmmodel.NewId(), teamID) + targetSpace := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), teamID) dup, appErr := h.svc.DuplicatePage(source.Id, sourceSpace, userID, false, targetSpace, nil) require.Nil(t, appErr) @@ -320,10 +320,10 @@ func TestServiceDuplicatePage_CrossTeamRejected(t *testing.T) { h := openTestService(t) userID := mmmodel.NewId() - sourceSpace := mustCreateSpace(t, h.store, mmmodel.NewId()) + sourceSpace := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) source := mustCreatePage(t, h.store, sourceSpace.Id, sourceSpace.ChannelId, userID, "") - targetSpace := mustCreateSpace(t, h.store, mmmodel.NewId()) + targetSpace := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) _, appErr := h.svc.DuplicatePage(source.Id, sourceSpace, userID, false, targetSpace, nil) require.NotNil(t, appErr) @@ -336,7 +336,7 @@ func TestServiceDuplicatePage_CrossTeamRejected(t *testing.T) { func TestServiceDuplicatePage_IncludeChildren_MaxDepthExceeded(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() // A chain of MaxPageDepth-1 pages: the deepest has MaxPageDepth-2 ancestors, so @@ -364,7 +364,7 @@ func TestServiceDuplicatePage_IncludeChildren_MaxDepthExceeded(t *testing.T) { func TestServiceDuplicatePage_InvalidParentID(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() source := mustCreatePage(t, h.store, space.Id, channelID, userID, "") diff --git a/server/app/page_hierarchy.go b/server/app/page_hierarchy.go index 1314c06..1e02754 100644 --- a/server/app/page_hierarchy.go +++ b/server/app/page_hierarchy.go @@ -156,15 +156,18 @@ func (s *Service) reparentWithinSpace(where, pageID, spaceID string, newParentID } // MovePageToSpace moves a page and its subtree to another space in the same team. parentPageID -// nil/"" places it at the target root. Rejects cross-team moves and wrong-space parents here; -// destination-inside-subtree cycles and depth-cap breaches are enforced authoritatively by -// store.MovePageToSpace and surface through storeAppError's shared message keys. +// nil/"" places it at the target root. Cross-team moves, wrong-space parents, +// destination-inside-subtree cycles, and depth-cap breaches are all rejected. // A nil expectedUpdateAt without force is rejected: the mutation must supply a baseline. // sourceSpace and targetSpace are the caller's already-fetched records (from its membership // gates), so no re-read happens here. userID is the acting user and must be a valid ID: the store // scopes the target-space draft quota to the drafts it owns. It does not change the page's -// LastModifiedBy. Per-page restrictions and redirects are not handled yet. -func (s *Service) MovePageToSpace(pageID string, sourceSpace, targetSpace *model.Space, parentPageID *string, expectedUpdateAt *int64, force bool, userID string) (*model.Page, *mmmodel.AppError) { +// LastModifiedBy. requiredOwnerID, when non-empty, requires every live page in the moved subtree +// to be owned by it — the gate resolves this to userID on the delete_own_page-only path and "" on +// the delete_page (any) path — and the move is rejected as a whole if any page has a different +// owner. A same-space request requires only the reparented root to be owned, since no other page +// leaves the space. Per-page restrictions and redirects are not handled yet. +func (s *Service) MovePageToSpace(pageID string, sourceSpace, targetSpace *model.Space, parentPageID *string, expectedUpdateAt *int64, force bool, userID, requiredOwnerID string) (*model.Page, *mmmodel.AppError) { if !mmmodel.IsValidId(pageID) { return nil, mmmodel.NewAppError("MovePageToSpace", "app.page.move_to_space.invalid_id.app_error", nil, "", http.StatusBadRequest) } @@ -212,6 +215,21 @@ func (s *Service) MovePageToSpace(pageID string, sourceSpace, targetSpace *model // requestedParent is passed explicitly because nil means "target root" here but "leave // unchanged" to MovePage. if sourceSpace.Id == targetSpace.Id { + // The subtree keeps its space, so the reparented root is the only page that moves: when + // requiredOwnerID is set, it is the only page whose owner has to match. store.MovePage has + // no ownership parameter, so the check runs here rather than inside the store transaction. + // Reading the owner outside that transaction is still safe: a page's UserId is set at + // creation and never updated, so no concurrent write can change the answer between this + // read and the move. + if requiredOwnerID != "" { + page, pageErr := s.GetPageInSpace("MovePageToSpace", pageID, sourceSpace.Id, false) + if pageErr != nil { + return nil, pageErr + } + if page.UserId != requiredOwnerID { + return nil, mmmodel.NewAppError("MovePageToSpace", "app.page.move_to_space.subtree_not_owned.app_error", nil, "", http.StatusForbidden) + } + } if requestedParent != "" && requestedParent != curParentID { if destErr := s.validateDestinationParent("MovePageToSpace", pageID, requestedParent, sourceSpace.Id); destErr != nil { return nil, destErr @@ -231,7 +249,7 @@ func (s *Service) MovePageToSpace(pageID string, sourceSpace, targetSpace *model s.log.Debug("Moving page to space", "page_id", pageID, "source_space_id", sourceSpace.Id, "target_space_id", targetSpace.Id, "user_id", userID) - moved, priorParentID, storeErr := s.store.MovePageToSpace(pageID, sourceSpace.Id, targetSpace.Id, userID, parentPageID, mmmodel.SafeDereference(expectedUpdateAt), force, model.MaxPageDepth) + moved, priorParentID, storeErr := s.store.MovePageToSpace(pageID, sourceSpace.Id, targetSpace.Id, userID, parentPageID, mmmodel.SafeDereference(expectedUpdateAt), force, model.MaxPageDepth, requiredOwnerID) if storeErr != nil { return nil, storeAppError("MovePageToSpace", storeErr) } diff --git a/server/app/page_move_test.go b/server/app/page_move_test.go index 7cadf96..f98cf3e 100644 --- a/server/app/page_move_test.go +++ b/server/app/page_move_test.go @@ -34,7 +34,7 @@ func buildPageChain(t *testing.T, s *store.Store, spaceID, channelID, userID str func TestServiceMovePage_Reparent(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() parent := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -54,7 +54,7 @@ func TestServiceMovePage_Reparent(t *testing.T) { func TestServiceMovePage_SameParentNoOp(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() parent := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -72,7 +72,7 @@ func TestServiceMovePage_SameParentNoOp(t *testing.T) { func TestServiceMovePage_ToRootLevel(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() parent := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -121,11 +121,11 @@ func TestServiceMovePage_ErrorPaths(t *testing.T) { userID := mmmodel.NewId() channelA := mmmodel.NewId() - spaceA := mustCreateSpace(t, h.store, channelA) + spaceA := mustCreateSpace(t, h.store, h.db, channelA) page := mustCreatePage(t, h.store, spaceA.Id, channelA, userID, "") channelB := mmmodel.NewId() - spaceB := mustCreateSpace(t, h.store, channelB) + spaceB := mustCreateSpace(t, h.store, h.db, channelB) parentInB := mustCreatePage(t, h.store, spaceB.Id, channelB, userID, "") newParent := parentInB.Id @@ -138,7 +138,7 @@ func TestServiceMovePage_ErrorPaths(t *testing.T) { t.Run("non-existent parent", func(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) page := mustCreatePage(t, h.store, space.Id, channelID, mmmodel.NewId(), "") ghost := mmmodel.NewId() @@ -151,7 +151,7 @@ func TestServiceMovePage_ErrorPaths(t *testing.T) { t.Run("self as parent is a circular reference", func(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) page := mustCreatePage(t, h.store, space.Id, channelID, mmmodel.NewId(), "") self := page.Id @@ -164,7 +164,7 @@ func TestServiceMovePage_ErrorPaths(t *testing.T) { t.Run("moving under own descendant is a circular reference", func(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() root := mustCreatePage(t, h.store, space.Id, channelID, userID, "") child := mustCreatePage(t, h.store, space.Id, channelID, userID, root.Id) @@ -180,7 +180,7 @@ func TestServiceMovePage_ErrorPaths(t *testing.T) { t.Run("new depth would exceed MaxPageDepth", func(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() // A chain of MaxPageDepth pages: the deepest has MaxPageDepth-1 ancestors, so a leaf @@ -199,7 +199,7 @@ func TestServiceMovePage_ErrorPaths(t *testing.T) { t.Run("subtree would push past MaxPageDepth", func(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() // Anchor at depth MaxPageDepth-1 (MaxPageDepth-2 ancestors): a moved page lands at @@ -225,7 +225,7 @@ func TestServiceMovePage_ErrorPaths(t *testing.T) { func TestServiceMovePage_StaleBaselineConflicts(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() parent := mustCreatePage(t, h.store, space.Id, channelID, userID, "") diff --git a/server/app/page_move_to_space_test.go b/server/app/page_move_to_space_test.go index a58c6cb..4a482c5 100644 --- a/server/app/page_move_to_space_test.go +++ b/server/app/page_move_to_space_test.go @@ -4,6 +4,8 @@ package app_test import ( + "database/sql" + "net/http" "testing" "github.com/stretchr/testify/mock" @@ -18,7 +20,9 @@ import ( ) // seedSpaceForTeam creates a space with a caller-chosen team id (mustCreateSpace randomizes it). -func seedSpaceForTeam(t *testing.T, s *store.Store, channelID, teamID string) *model.Space { +// No test in this package resolves a scheme for a store-direct-created space, so no per-channel +// scheme stub is registered here. +func seedSpaceForTeam(t *testing.T, s *store.Store, db *sql.DB, channelID, teamID string) *model.Space { t.Helper() return testutil.MustCreateSpace(t, s, channelID, teamID) } @@ -43,14 +47,14 @@ func TestServiceMovePageToSpace(t *testing.T) { user := mmmodel.NewId() chA := mmmodel.NewId() - spaceA := seedSpaceForTeam(t, h.store, chA, teamID) + spaceA := seedSpaceForTeam(t, h.store, h.db, chA, teamID) chB := mmmodel.NewId() - spaceB := seedSpaceForTeam(t, h.store, chB, teamID) + spaceB := seedSpaceForTeam(t, h.store, h.db, chB, teamID) root := mustCreatePage(t, h.store, spaceA.Id, chA, user, "") child := mustCreatePage(t, h.store, spaceA.Id, chA, user, root.Id) - moved, appErr := h.svc.MovePageToSpace(root.Id, spaceA, spaceB, nil, new(root.UpdateAt), false, mmmodel.NewId()) + moved, appErr := h.svc.MovePageToSpace(root.Id, spaceA, spaceB, nil, new(root.UpdateAt), false, mmmodel.NewId(), "") require.Nil(t, appErr) require.Equal(t, spaceB.Id, moved.SpaceId) require.Equal(t, chB, moved.ChannelId) @@ -81,15 +85,15 @@ func TestServiceMovePageToSpace_ReturnsMovedPage(t *testing.T) { user := mmmodel.NewId() chA := mmmodel.NewId() - spaceA := seedSpaceForTeam(t, h.store, chA, teamID) + spaceA := seedSpaceForTeam(t, h.store, h.db, chA, teamID) chB := mmmodel.NewId() - spaceB := seedSpaceForTeam(t, h.store, chB, teamID) + spaceB := seedSpaceForTeam(t, h.store, h.db, chB, teamID) target := mustCreatePage(t, h.store, spaceB.Id, chB, user, "") page := mustCreatePage(t, h.store, spaceA.Id, chA, user, "") parentID := target.Id - moved, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, &parentID, new(page.UpdateAt), false, mmmodel.NewId()) + moved, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, &parentID, new(page.UpdateAt), false, mmmodel.NewId(), "") require.Nil(t, appErr) require.Equal(t, spaceB.Id, moved.SpaceId) require.Equal(t, chB, moved.ChannelId) @@ -109,12 +113,12 @@ func TestServiceMovePageToSpace_RejectsCrossTeam(t *testing.T) { user := mmmodel.NewId() chA := mmmodel.NewId() - spaceA := seedSpaceForTeam(t, h.store, chA, mmmodel.NewId()) + spaceA := seedSpaceForTeam(t, h.store, h.db, chA, mmmodel.NewId()) chB := mmmodel.NewId() - spaceB := seedSpaceForTeam(t, h.store, chB, mmmodel.NewId()) + spaceB := seedSpaceForTeam(t, h.store, h.db, chB, mmmodel.NewId()) page := mustCreatePage(t, h.store, spaceA.Id, chA, user, "") - _, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, nil, new(int64(0)), true, mmmodel.NewId()) + _, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, nil, new(int64(0)), true, mmmodel.NewId(), "") require.NotNil(t, appErr) require.Equal(t, 400, appErr.StatusCode) require.Equal(t, "app.page.move_to_space.cross_team.app_error", appErr.Id) @@ -126,21 +130,21 @@ func TestServiceMovePageToSpace_InvalidIDs(t *testing.T) { someSpace := &model.Space{Id: mmmodel.NewId()} t.Run("invalid pageID", func(t *testing.T) { - _, appErr := h.svc.MovePageToSpace("not-an-id", someSpace, someSpace, nil, new(int64(0)), false, mmmodel.NewId()) + _, appErr := h.svc.MovePageToSpace("not-an-id", someSpace, someSpace, nil, new(int64(0)), false, mmmodel.NewId(), "") require.NotNil(t, appErr) require.Equal(t, 400, appErr.StatusCode) require.Equal(t, "app.page.move_to_space.invalid_id.app_error", appErr.Id) }) t.Run("nil source space", func(t *testing.T) { - _, appErr := h.svc.MovePageToSpace(mmmodel.NewId(), nil, someSpace, nil, new(int64(0)), false, mmmodel.NewId()) + _, appErr := h.svc.MovePageToSpace(mmmodel.NewId(), nil, someSpace, nil, new(int64(0)), false, mmmodel.NewId(), "") require.NotNil(t, appErr) require.Equal(t, 400, appErr.StatusCode) require.Equal(t, "app.page.move_to_space.invalid_source_space.app_error", appErr.Id) }) t.Run("nil target space", func(t *testing.T) { - _, appErr := h.svc.MovePageToSpace(mmmodel.NewId(), someSpace, nil, nil, new(int64(0)), false, mmmodel.NewId()) + _, appErr := h.svc.MovePageToSpace(mmmodel.NewId(), someSpace, nil, nil, new(int64(0)), false, mmmodel.NewId(), "") require.NotNil(t, appErr) require.Equal(t, 400, appErr.StatusCode) require.Equal(t, "app.page.move_to_space.invalid_target_space.app_error", appErr.Id) @@ -155,15 +159,15 @@ func TestServiceMovePageToSpace_RejectsParentInWrongSpace(t *testing.T) { user := mmmodel.NewId() chA := mmmodel.NewId() - spaceA := seedSpaceForTeam(t, h.store, chA, teamID) + spaceA := seedSpaceForTeam(t, h.store, h.db, chA, teamID) chB := mmmodel.NewId() - spaceB := seedSpaceForTeam(t, h.store, chB, teamID) + spaceB := seedSpaceForTeam(t, h.store, h.db, chB, teamID) page := mustCreatePage(t, h.store, spaceA.Id, chA, user, "") parentInA := mustCreatePage(t, h.store, spaceA.Id, chA, user, "") parentID := parentInA.Id - _, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, &parentID, new(int64(0)), true, mmmodel.NewId()) + _, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, &parentID, new(int64(0)), true, mmmodel.NewId(), "") require.NotNil(t, appErr) require.Equal(t, 400, appErr.StatusCode) require.Equal(t, "app.page.invalid_parent.app_error", appErr.Id) @@ -178,14 +182,14 @@ func TestServiceMovePageToSpace_RejectsMissingParent(t *testing.T) { user := mmmodel.NewId() chA := mmmodel.NewId() - spaceA := seedSpaceForTeam(t, h.store, chA, teamID) + spaceA := seedSpaceForTeam(t, h.store, h.db, chA, teamID) chB := mmmodel.NewId() - spaceB := seedSpaceForTeam(t, h.store, chB, teamID) + spaceB := seedSpaceForTeam(t, h.store, h.db, chB, teamID) page := mustCreatePage(t, h.store, spaceA.Id, chA, user, "") ghost := mmmodel.NewId() - _, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, &ghost, new(int64(0)), true, mmmodel.NewId()) + _, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, &ghost, new(int64(0)), true, mmmodel.NewId(), "") require.NotNil(t, appErr) require.Equal(t, 400, appErr.StatusCode) require.Equal(t, "app.page.invalid_parent.app_error", appErr.Id) @@ -199,9 +203,9 @@ func TestServiceMovePageToSpace_RejectsDepthExceeded(t *testing.T) { user := mmmodel.NewId() chA := mmmodel.NewId() - spaceA := seedSpaceForTeam(t, h.store, chA, teamID) + spaceA := seedSpaceForTeam(t, h.store, h.db, chA, teamID) chB := mmmodel.NewId() - spaceB := seedSpaceForTeam(t, h.store, chB, teamID) + spaceB := seedSpaceForTeam(t, h.store, h.db, chB, teamID) // Build a chain in spaceB down to MaxPageDepth; a child under the deepest node would breach it. parentID := "" @@ -211,7 +215,7 @@ func TestServiceMovePageToSpace_RejectsDepthExceeded(t *testing.T) { } page := mustCreatePage(t, h.store, spaceA.Id, chA, user, "") - _, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, &parentID, new(int64(0)), true, mmmodel.NewId()) + _, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, &parentID, new(int64(0)), true, mmmodel.NewId(), "") require.NotNil(t, appErr) require.Equal(t, 400, appErr.StatusCode) require.Equal(t, "app.page.max_depth_exceeded.app_error", appErr.Id) @@ -225,9 +229,9 @@ func TestServiceMovePageToSpace_RewritesSnapshots(t *testing.T) { user := mmmodel.NewId() chA := mmmodel.NewId() - spaceA := seedSpaceForTeam(t, h.store, chA, teamID) + spaceA := seedSpaceForTeam(t, h.store, h.db, chA, teamID) chB := mmmodel.NewId() - spaceB := seedSpaceForTeam(t, h.store, chB, teamID) + spaceB := seedSpaceForTeam(t, h.store, h.db, chB, teamID) root := mustCreatePage(t, h.store, spaceA.Id, chA, user, "") @@ -238,7 +242,7 @@ func TestServiceMovePageToSpace_RewritesSnapshots(t *testing.T) { snap.Id, root.Id, mmmodel.GetMillis()) require.NoError(t, rawErr) - _, appErr := h.svc.MovePageToSpace(root.Id, spaceA, spaceB, nil, new(int64(0)), true, mmmodel.NewId()) + _, appErr := h.svc.MovePageToSpace(root.Id, spaceA, spaceB, nil, new(int64(0)), true, mmmodel.NewId(), "") require.Nil(t, appErr) var spaceID, channelID string @@ -255,13 +259,13 @@ func TestServiceMovePageToSpace_RejectsCycle(t *testing.T) { user := mmmodel.NewId() chA := mmmodel.NewId() - spaceA := seedSpaceForTeam(t, h.store, chA, teamID) + spaceA := seedSpaceForTeam(t, h.store, h.db, chA, teamID) root := mustCreatePage(t, h.store, spaceA.Id, chA, user, "") child := mustCreatePage(t, h.store, spaceA.Id, chA, user, root.Id) childID := child.Id - _, appErr := h.svc.MovePageToSpace(root.Id, spaceA, spaceA, &childID, new(int64(0)), true, mmmodel.NewId()) + _, appErr := h.svc.MovePageToSpace(root.Id, spaceA, spaceA, &childID, new(int64(0)), true, mmmodel.NewId(), "") require.NotNil(t, appErr) require.Equal(t, 400, appErr.StatusCode) require.Equal(t, "app.page.circular_reference.app_error", appErr.Id) @@ -276,19 +280,19 @@ func TestServiceMovePageToSpace_StaleBaselineConflicts(t *testing.T) { user := mmmodel.NewId() chA := mmmodel.NewId() - spaceA := seedSpaceForTeam(t, h.store, chA, teamID) + spaceA := seedSpaceForTeam(t, h.store, h.db, chA, teamID) chB := mmmodel.NewId() - spaceB := seedSpaceForTeam(t, h.store, chB, teamID) + spaceB := seedSpaceForTeam(t, h.store, h.db, chB, teamID) page := mustCreatePage(t, h.store, spaceA.Id, chA, user, "") // A cross-space move falls through to the store CAS (the no-op short-circuit only applies when // the page is already in the target space under the requested parent). - _, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, nil, new(page.UpdateAt-1), false, mmmodel.NewId()) + _, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, nil, new(page.UpdateAt-1), false, mmmodel.NewId(), "") require.NotNil(t, appErr) require.Equal(t, 409, appErr.StatusCode) - moved, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, nil, new(page.UpdateAt-1), true, mmmodel.NewId()) + moved, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, nil, new(page.UpdateAt-1), true, mmmodel.NewId(), "") require.Nil(t, appErr) require.Equal(t, spaceB.Id, moved.SpaceId) } @@ -303,22 +307,22 @@ func TestServiceMovePageToSpace_NoOpEnforcesBaseline(t *testing.T) { user := mmmodel.NewId() chA := mmmodel.NewId() - spaceA := seedSpaceForTeam(t, h.store, chA, teamID) + spaceA := seedSpaceForTeam(t, h.store, h.db, chA, teamID) // A freshly created root page in spaceA: targeting spaceA with parentPageID nil is the genuine // no-op (already in the target space, already at the root). page := mustCreatePage(t, h.store, spaceA.Id, chA, user, "") - _, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceA, nil, new(page.UpdateAt-1), false, mmmodel.NewId()) + _, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceA, nil, new(page.UpdateAt-1), false, mmmodel.NewId(), "") require.NotNil(t, appErr) require.Equal(t, 409, appErr.StatusCode) - same, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceA, nil, new(page.UpdateAt), false, mmmodel.NewId()) + same, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceA, nil, new(page.UpdateAt), false, mmmodel.NewId(), "") require.Nil(t, appErr) require.Equal(t, page.Id, same.Id) require.Equal(t, spaceA.Id, same.SpaceId) - _, appErr = h.svc.MovePageToSpace(page.Id, spaceA, spaceA, nil, new(page.UpdateAt-1), true, mmmodel.NewId()) + _, appErr = h.svc.MovePageToSpace(page.Id, spaceA, spaceA, nil, new(page.UpdateAt-1), true, mmmodel.NewId(), "") require.Nil(t, appErr) } @@ -332,13 +336,13 @@ func TestServiceMovePageToSpace_SameSpaceReparent(t *testing.T) { teamID := mmmodel.NewId() user := mmmodel.NewId() ch := mmmodel.NewId() - space := seedSpaceForTeam(t, h.store, ch, teamID) + space := seedSpaceForTeam(t, h.store, h.db, ch, teamID) newParent := mustCreatePage(t, h.store, space.Id, ch, user, "") page := mustCreatePage(t, h.store, space.Id, ch, user, "") parentID := newParent.Id - moved, appErr := h.svc.MovePageToSpace(page.Id, space, space, &parentID, new(page.UpdateAt), false, user) + moved, appErr := h.svc.MovePageToSpace(page.Id, space, space, &parentID, new(page.UpdateAt), false, user, "") require.Nil(t, appErr) require.Equal(t, newParent.Id, moved.ParentId) require.Equal(t, space.Id, moved.SpaceId) @@ -355,6 +359,38 @@ func TestServiceMovePageToSpace_SameSpaceReparent(t *testing.T) { mockAPI.AssertNotCalled(t, "PublishWebSocketEvent", "page_moved_to_space", mock.Anything, mock.Anything) } +// TestServiceMovePageToSpace_SameSpaceRequiredOwnerID verifies the own-scoped gate holds on the +// same-space path too: a caller resolved to delete_own_page can reparent a page it owns, but not +// one owned by someone else — the branch delegates to the in-space move, which carries no +// ownership check of its own. +func TestServiceMovePageToSpace_SameSpaceRequiredOwnerID(t *testing.T) { + h := openTestService(t) + teamID := mmmodel.NewId() + owner := mmmodel.NewId() + ch := mmmodel.NewId() + space := seedSpaceForTeam(t, h.store, h.db, ch, teamID) + + newParent := mustCreatePage(t, h.store, space.Id, ch, owner, "") + parentID := newParent.Id + + foreign := mustCreatePage(t, h.store, space.Id, ch, mmmodel.NewId(), "") + _, appErr := h.svc.MovePageToSpace(foreign.Id, space, space, &parentID, new(foreign.UpdateAt), false, owner, owner) + require.NotNil(t, appErr) + // An authorization denial, so it carries 403 like every other own/any denial in the feature — + // not the 400 a malformed request would get. + require.Equal(t, http.StatusForbidden, appErr.StatusCode) + require.Equal(t, "app.page.move_to_space.subtree_not_owned.app_error", appErr.Id) + + stillRoot, getErr := h.svc.GetPage(foreign.Id) + require.Nil(t, getErr) + require.Empty(t, stillRoot.ParentId) + + own := mustCreatePage(t, h.store, space.Id, ch, owner, "") + moved, appErr := h.svc.MovePageToSpace(own.Id, space, space, &parentID, new(own.UpdateAt), false, owner, owner) + require.Nil(t, appErr) + require.Equal(t, newParent.Id, moved.ParentId) +} + // TestServiceMovePageToSpace_NoOpRejectsStaleSource verifies a stale sourceSpaceID is rejected as // not-found even when the requested target space and parent already match the page's current // location: after a concurrent move landed the page in targetSpaceID, a caller still addressing @@ -365,20 +401,20 @@ func TestServiceMovePageToSpace_NoOpRejectsStaleSource(t *testing.T) { user := mmmodel.NewId() chA := mmmodel.NewId() - spaceA := seedSpaceForTeam(t, h.store, chA, teamID) + spaceA := seedSpaceForTeam(t, h.store, h.db, chA, teamID) chB := mmmodel.NewId() - spaceB := seedSpaceForTeam(t, h.store, chB, teamID) + spaceB := seedSpaceForTeam(t, h.store, h.db, chB, teamID) page := mustCreatePage(t, h.store, spaceA.Id, chA, user, "") // Concurrently relocate the page to spaceB, out from under the caller's stale spaceA route. - _, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, nil, new(page.UpdateAt), false, mmmodel.NewId()) + _, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, nil, new(page.UpdateAt), false, mmmodel.NewId(), "") require.Nil(t, appErr) // The stale request still names spaceA as the source and spaceB as the target; the page is // already in spaceB at the root, which matches the no-op's SpaceId/ParentId check, but not its // sourceSpaceID. - _, appErr = h.svc.MovePageToSpace(page.Id, spaceA, spaceB, nil, new(page.UpdateAt), false, mmmodel.NewId()) + _, appErr = h.svc.MovePageToSpace(page.Id, spaceA, spaceB, nil, new(page.UpdateAt), false, mmmodel.NewId(), "") require.NotNil(t, appErr) require.Equal(t, 404, appErr.StatusCode) } diff --git a/server/app/page_reorder_test.go b/server/app/page_reorder_test.go index e735e3d..e4ecec5 100644 --- a/server/app/page_reorder_test.go +++ b/server/app/page_reorder_test.go @@ -30,7 +30,7 @@ func childOrder(t *testing.T, h *testHarness, parentID, spaceID string) []string func TestServiceMovePage_PositionalReorder(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() parent := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -68,7 +68,7 @@ func TestServiceMovePage_PositionalReorder(t *testing.T) { func TestServiceMovePage_ReparentAtIndex(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() source := mustCreatePage(t, h.store, space.Id, channelID, userID, "") diff --git a/server/app/permissions.go b/server/app/permissions.go new file mode 100644 index 0000000..cd85e1a --- /dev/null +++ b/server/app/permissions.go @@ -0,0 +1,483 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "errors" + "fmt" + "net/http" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/pluginapi" + + "github.com/mattermost/mattermost-plugin-docs/server/model" + "github.com/mattermost/mattermost-plugin-docs/server/store" +) + +// ReadResolution reports how a read was admitted, so callers (notably the auto-join pre-step) +// can tell a real membership/sysadmin read from the non-member open-space fall-through. +type ReadResolution int + +const ( + ReadDenied ReadResolution = iota + ReadViaSysadmin + ReadViaMember + ReadViaOpenFallthrough +) + +// String names the resolution, so a log field or a failed test assertion reports how the read was +// admitted rather than the underlying integer. +func (r ReadResolution) String() string { + switch r { + case ReadDenied: + return "denied" + case ReadViaSysadmin: + return "sysadmin" + case ReadViaMember: + return "member" + case ReadViaOpenFallthrough: + return "open_fallthrough" + default: + return fmt.Sprintf("ReadResolution(%d)", int(r)) + } +} + +// existenceHidingForbidden is the shared 403 every enforcement helper returns on a lookup miss +// or a denied check, so a caller cannot distinguish "doesn't exist", "not a member", and "no +// longer a member" by status code or message. +func existenceHidingForbidden(where string) *mmmodel.AppError { + return mmmodel.NewAppError(where, "app.space.access.forbidden.app_error", nil, "", http.StatusForbidden) +} + +// ExistenceHidingForbidden is existenceHidingForbidden for the API layer, whose own gate helpers +// have to deny in the same indistinguishable terms. Exported so that message stays defined in one +// place: a second literal spelling of it elsewhere could drift and make the denials tellable apart. +func ExistenceHidingForbidden(where string) *mmmodel.AppError { + return existenceHidingForbidden(where) +} + +// isComplianceEnabled reports whether ComplianceSettings.Enable is set and true. When the setting +// cannot be read at all — no client, or no config — it reports true, so the fall-through this +// guards is suppressed rather than admitted: an undeterminable setting must not widen access. +func (s *Service) isComplianceEnabled() bool { + if s.client == nil { + return true + } + cfg := s.client.Configuration.GetConfig() + if cfg == nil { + return true + } + return mmmodel.SafeDereference(cfg.ComplianceSettings.Enable) +} + +// hasOpenTeamFallthrough reports whether userID holds the non-member team read_public_channel +// fall-through into teamID's open spaces. Suppressed under compliance mode. member is userID's +// already-resolved membership in teamID; pass nil when the caller does not hold one and this +// resolves the team row itself. +func (s *Service) hasOpenTeamFallthrough(member *mmmodel.TeamMember, userID, teamID string) bool { + if s.isComplianceEnabled() { + return false + } + return s.teamPermGranted(member, userID, teamID, mmmodel.PermissionReadPublicChannel) +} + +// readResolutionFrom evaluates the read gate against space for userID, given the caller's +// already-resolved sysadmin status and team membership. A nil member means the caller is not an +// active member of the space's team. +func (s *Service) readResolutionFrom(sysadmin bool, member *mmmodel.TeamMember, space *model.Space, userID string) ReadResolution { + if sysadmin { + return ReadViaSysadmin + } + if member != nil && s.client.User.HasPermissionToChannel(userID, space.ChannelId, mmmodel.PermissionReadPage) { + return ReadViaMember + } + if space.ViewAccess == model.ViewAccessOpen && member != nil && s.hasOpenTeamFallthrough(member, userID, space.TeamId) { + return ReadViaOpenFallthrough + } + return ReadDenied +} + +// ResolveSpaceRead resolves the read gate for space against userID, reporting how the read was +// admitted so callers can gate auto-join to the fall-through case only. where identifies the +// calling operation for the 500 an activeTeamMember lookup failure surfaces as. On that +// failure the returned resolution is ReadDenied but the error is non-nil, so callers must check +// the error first — treating the resolution alone as authoritative would misreport an outage as +// "not authorized". +func (s *Service) ResolveSpaceRead(where string, space *model.Space, userID string) (ReadResolution, *mmmodel.AppError) { + member, sysadmin, appErr := s.requireActiveMemberGate(where, space, userID) + if appErr != nil { + return ReadDenied, appErr + } + return s.readResolutionFrom(sysadmin, member, space, userID), nil +} + +// requireActiveMemberGate performs the four checks that precede the permission-specific branches +// of RequireSpacePagePermission, RequireSpaceAdminOrTeamPerm, and RequireSpaceAdminOrSysadmin +// (RequireSpacePagePermissionFrom skips them, reusing a ReadResolution its caller already +// resolved): client wiring, existence-hiding on a nil space, the sysadmin override, and +// active-team-membership resolution with its 500 on a genuine lookup failure. A non-nil appErr +// must be returned by the caller immediately. Otherwise, when sysadmin is true the caller may +// return nil immediately; when it is false the caller continues with member to evaluate its own +// permission-specific branches. A nil member means the caller is not an active team member; the +// membership itself is returned rather than a bool so a caller needing a team permission resolves +// it from these roles instead of re-reading the row. +func (s *Service) requireActiveMemberGate(where string, space *model.Space, userID string) (member *mmmodel.TeamMember, sysadmin bool, appErr *mmmodel.AppError) { + if appErr = s.requireClient(where, "space_id", spaceIDOrEmpty(space), "user_id", userID); appErr != nil { + return nil, false, appErr + } + // A malformed user id is a caller fault, not a denial: it reports as a 400 so it stays + // distinguishable from the existence-hiding 403 every genuine denial returns. + if !mmmodel.IsValidId(userID) { + return nil, false, mmmodel.NewAppError(where, "app.space.access.invalid_user_id.app_error", nil, "", http.StatusBadRequest) + } + if space == nil { + return nil, false, existenceHidingForbidden(where) + } + if s.client.User.HasPermissionTo(userID, mmmodel.PermissionManageSystem) { + return nil, true, nil + } + member, err := s.activeTeamMember(space.TeamId, userID) + if err != nil { + return nil, false, mmmodel.NewAppError(where, "app.space.access.team_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return member, false, nil +} + +// RequireSpacePagePermission gates a page-scoped operation on perm: sysadmin override, then +// backing-channel membership (plus active team membership) via HasPermissionToChannel, then — +// for a read permission on an open space only — the non-member fall-through. Any other case +// (including a nil space, mirroring a lookup miss upstream) yields the shared existence-hiding +// 403. +func (s *Service) RequireSpacePagePermission(where string, space *model.Space, userID string, perm *mmmodel.Permission) *mmmodel.AppError { + member, sysadmin, appErr := s.requireActiveMemberGate(where, space, userID) + if appErr != nil { + return appErr + } + if sysadmin { + return nil + } + return s.evaluatePagePermission(where, space, userID, perm, member != nil, member) +} + +// evaluatePagePermission grants perm to an active member holding it on the backing channel, or — +// for a read permission on an open space only — to an active team member via the non-member +// fall-through. Any other case yields the shared existence-hiding 403. active is the caller's +// already-resolved team-membership status; member is the membership behind it when the caller +// holds one, and nil when active was established without reading the row. +func (s *Service) evaluatePagePermission(where string, space *model.Space, userID string, perm *mmmodel.Permission, active bool, member *mmmodel.TeamMember) *mmmodel.AppError { + if active && s.client.User.HasPermissionToChannel(userID, space.ChannelId, perm) { + return nil + } + if perm.Id == mmmodel.PermissionReadPage.Id && space.ViewAccess == model.ViewAccessOpen && active && + s.hasOpenTeamFallthrough(member, userID, space.TeamId) { + return nil + } + return existenceHidingForbidden(where) +} + +// RequireSpacePagePermissionFrom is RequireSpacePagePermission for a caller that has already +// resolved the read gate for the same space and user via ResolveSpaceRead, so the team-membership +// lookup behind that resolution is not repeated. admittedVia must be a non-denied resolution; +// every such resolution other than ReadViaSysadmin already established active team membership. +// +// That team-active status is carried forward, not re-verified: a revocation landing between the +// admitting read and this call is not caught until the next call. The channel-scoped check below +// is always re-resolved, because the auto-join pre-step may have changed channel membership in +// between. +func (s *Service) RequireSpacePagePermissionFrom(where string, space *model.Space, userID string, perm *mmmodel.Permission, admittedVia ReadResolution) *mmmodel.AppError { + if admittedVia == ReadDenied { + return existenceHidingForbidden(where) + } + if admittedVia == ReadViaSysadmin { + return nil + } + if appErr := s.requireClient(where, "space_id", spaceIDOrEmpty(space), "user_id", userID); appErr != nil { + return appErr + } + if space == nil { + return existenceHidingForbidden(where) + } + return s.evaluatePagePermission(where, space, userID, perm, true, nil) +} + +// ResolveSpacePageOwnOrAny decides a page operation that is granted by either of two permissions: +// a broad one covering any page, or a narrower own-page one that applies when the caller owns the +// target. delete_page with delete_own_page is the pair; move-to-space's remove-class gate is the +// other. The caller is admitted by anyPerm alone, or by ownPerm when ownerMatches. Each of the two +// attempts has to tell a denial apart from a failed lookup, which is why the sequence lives here +// instead of being repeated per handler. +// +// ownOnly reports that ownPerm was what admitted the caller. It matters where the operation +// reaches further than the one page whose owner was checked here: MovePageToSpace relocates an +// entire subtree, so an own-admitted caller must additionally be shown to own every page in it. +// +// admitted=false with a nil appErr means neither permission grants the operation. The caller +// writes its own denial, so the 403 carries the caller's operation label rather than this one's. +// A non-nil appErr is a failure of the check itself, not a denial: reporting it as a denial would +// present a backend outage to the user as "not authorized". +func (s *Service) ResolveSpacePageOwnOrAny(space *model.Space, userID, anyWhere string, anyPerm *mmmodel.Permission, ownWhere string, ownPerm *mmmodel.Permission, ownerMatches bool, admittedVia ReadResolution) (ownOnly, admitted bool, appErr *mmmodel.AppError) { + anyErr := s.RequireSpacePagePermissionFrom(anyWhere, space, userID, anyPerm, admittedVia) + if anyErr == nil { + return false, true, nil + } + if anyErr.StatusCode != http.StatusForbidden { + return false, false, anyErr + } + if !ownerMatches { + return false, false, nil + } + ownErr := s.RequireSpacePagePermissionFrom(ownWhere, space, userID, ownPerm, admittedVia) + if ownErr == nil { + return true, true, nil + } + if ownErr.StatusCode != http.StatusForbidden { + return false, false, ownErr + } + return false, false, nil +} + +// RequireSpaceAdminOrTeamPerm gates an elevated space operation: sysadmin, channel admin_space +// (plus active team membership), or teamPerm on the space's team — the latter only once the read +// resolver has already admitted the caller, so a team-wide grant authorizes acting only on spaces +// the caller can already read. Callers pass the operation's own team permission: manage_space for +// the manage tier, delete_space for delete/restore. +func (s *Service) RequireSpaceAdminOrTeamPerm(where string, space *model.Space, userID string, teamPerm *mmmodel.Permission) *mmmodel.AppError { + member, sysadmin, appErr := s.requireActiveMemberGate(where, space, userID) + if appErr != nil { + return appErr + } + if sysadmin { + return nil + } + if member != nil && s.client.User.HasPermissionToChannel(userID, space.ChannelId, mmmodel.PermissionAdminSpace) { + return nil + } + if s.readResolutionFrom(false, member, space, userID) != ReadDenied && + s.teamPermGranted(member, userID, space.TeamId, teamPerm) { + return nil + } + return existenceHidingForbidden(where) +} + +// RequireSpaceAdminOrSysadmin gates the space-wide exposure-policy knobs (ViewAccess, default +// capabilities) and admin-affecting member changes: sysadmin, or channel admin_space plus active +// team membership. No team-manage_space branch — those knobs are stricter than ordinary manage. +func (s *Service) RequireSpaceAdminOrSysadmin(where string, space *model.Space, userID string) *mmmodel.AppError { + member, sysadmin, appErr := s.requireActiveMemberGate(where, space, userID) + if appErr != nil { + return appErr + } + if sysadmin { + return nil + } + if member != nil && s.client.User.HasPermissionToChannel(userID, space.ChannelId, mmmodel.PermissionAdminSpace) { + return nil + } + return existenceHidingForbidden(where) +} + +// requirePageWriteFrom runs the auto-join pre-step for perm and then gates on it, the shared tail +// of every page-write authorization on an already-resolved read. joined reports whether the +// pre-step created a membership, so a caller whose guarded write then fails can undo it. +func (s *Service) requirePageWriteFrom(where string, space *model.Space, userID string, perm *mmmodel.Permission, admittedVia ReadResolution) (joined bool, appErr *mmmodel.AppError) { + joined, appErr = s.AutoJoinIfDefaultGranted(space, userID, admittedVia, perm, nil) + if appErr != nil { + return false, appErr + } + return joined, s.RequireSpacePagePermissionFrom(where, space, userID, perm, admittedVia) +} + +// RequireSpaceDraftWrite gates a draft mutation on the caller holding either page-creation or +// page-edit authority in the space. A draft is a pending page private to its author, so this +// establishes only that the caller may contribute pages here at all; the exact permission the +// content needs is enforced at publish (RequireSpacePublish), the point where the draft becomes +// state other users can see. +func (s *Service) RequireSpaceDraftWrite(where string, space *model.Space, userID string, admittedVia ReadResolution) (joined bool, appErr *mmmodel.AppError) { + createJoined, createErr := s.requirePageWriteFrom(where, space, userID, mmmodel.PermissionCreatePage, admittedVia) + if createErr == nil { + return createJoined, nil + } + // Only a denial falls through to the second attempt: a failure of the check itself must not be + // retried into a 403, which would present a backend outage to the user as "not authorized". + // Mirrors ResolveSpacePageOwnOrAny's handling of the same two-attempt shape. + if createErr.StatusCode != http.StatusForbidden { + return createJoined, createErr + } + // Either attempt can have joined, so the two results are combined: the caller must be able to + // undo a membership the create_page attempt created even when the edit_page attempt is the one + // that admitted it. + editJoined, editErr := s.requirePageWriteFrom(where, space, userID, mmmodel.PermissionEditPage, admittedVia) + return createJoined || editJoined, editErr +} + +// RequireSpacePublish gates publishing a draft on the permission its target actually needs: +// create_page when the draft becomes a new page, edit_page when it updates a live one. isNewPage +// comes from the publish path's own classification of the target row (a fresh draft versus one +// updating a live page). +// +// joined reports whether the auto-join pre-step created a membership; a caller whose publish then +// fails must pass it to UndoAutoJoin. +func (s *Service) RequireSpacePublish(where string, space *model.Space, userID string, admittedVia ReadResolution, isNewPage bool) (joined bool, appErr *mmmodel.AppError) { + perm := mmmodel.PermissionEditPage + if isNewPage { + perm = mmmodel.PermissionCreatePage + } + return s.requirePageWriteFrom(where, space, userID, perm, admittedVia) +} + +// DefaultRolesGrantPermission reports whether space's current default capability set (the scheme's +// generated user role) grants perm to a plain member — the auto-join admission test. Channel +// without a scheme (ErrNotFound) reports false, not an error. +func (s *Service) DefaultRolesGrantPermission(space *model.Space, perm *mmmodel.Permission) (bool, error) { + // Checked before the lookup below, which reaches through the client itself. + if s.client == nil || space == nil { + return false, nil + } + roles, err := s.getSchemeRolesForChannel(space.ChannelId) + if err != nil { + if store.IsErrNotFound(err) { + return false, nil + } + return false, err + } + return s.client.User.RolesGrantPermission([]string{roles.UserRoleName}, perm.Id), nil +} + +// spaceIDOrEmpty is a nil-safe accessor used only for log context. +func spaceIDOrEmpty(space *model.Space) string { + if space == nil { + return "" + } + return space.Id +} + +// AutoJoinIfDefaultGranted is the auto-join pre-step: when a non-member's write was admitted only via +// the open-space read fall-through (admittedVia == ReadViaOpenFallthrough) and the space's +// current default capability set grants perm to a plain member, it joins userID to the backing +// channel (idempotent, and published as a membership-added event) so the subsequent write-gate +// re-check passes as a member. The +// open-read admission is re-validated before joining: a concurrent open->private flip between the +// admitting read and this pre-step aborts the join. +// ownerCheck, when non-nil, must additionally hold (used for delete_own_page, where the caller +// must already own the target page before being joined). Returns whether a join happened. +func (s *Service) AutoJoinIfDefaultGranted(space *model.Space, userID string, admittedVia ReadResolution, perm *mmmodel.Permission, ownerCheck func() (bool, error)) (bool, *mmmodel.AppError) { + if admittedVia != ReadViaOpenFallthrough { + return false, nil + } + if space == nil { + return false, existenceHidingForbidden("AutoJoinIfDefaultGranted") + } + if appErr := s.requireClient("AutoJoinIfDefaultGranted", "space_id", space.Id, "user_id", userID); appErr != nil { + return false, appErr + } + + // A set of defaults that cannot grant perm can never admit this write, so the answer is settled + // before taking the space-keyed lock, which holds a dedicated connection and serializes every + // membership and scheme mutation on the space. Only a clean negative short-circuits: a failed + // lookup falls through, leaving the in-lock check below authoritative. + if granted, grantErr := s.DefaultRolesGrantPermission(space, perm); grantErr == nil && !granted { + return false, nil + } + + joined := false + var joinedUserID, joinedChannelID string + lockErr := s.store.WithSpaceMembershipLock(space.Id, func() error { + fresh, getErr := s.store.GetSpace(space.Id, false) + if getErr != nil { + if store.IsErrNotFound(getErr) { + return nil // Space vanished or was deleted concurrently: no join. + } + return mmmodel.NewAppError("AutoJoinIfDefaultGranted", "app.space.auto_join.get_space_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(getErr) + } + resolution, resErr := s.ResolveSpaceRead("AutoJoinIfDefaultGranted", fresh, userID) + if resErr != nil { + return resErr + } + if resolution != ReadViaOpenFallthrough { + return nil // Re-validation failed: e.g. the space flipped private concurrently. + } + granted, grantErr := s.DefaultRolesGrantPermission(fresh, perm) + if grantErr != nil { + return grantErr + } + if !granted { + return nil + } + if ownerCheck != nil { + owns, ownErr := ownerCheck() + if ownErr != nil { + return ownErr + } + if !owns { + return nil + } + } + _, memErr := s.client.Channel.GetMember(fresh.ChannelId, userID) + if memErr == nil { + return nil // Already a member. + } + // Only an absence means "not a member yet". A failed lookup must not fall through to the add + // below: core returns the existing membership unchanged for an add of a current member, so + // this would report a join that never happened, and the undo paired with it would then + // remove a membership the caller already held. + if !errors.Is(memErr, pluginapi.ErrNotFound) { + return mmmodel.NewAppError("AutoJoinIfDefaultGranted", "app.space.access.channel_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(memErr) + } + member, addErr := s.client.Channel.AddMember(fresh.ChannelId, userID) + if addErr != nil { + // A vanished user is a caller-state condition, not a server fault; AddSpaceMember + // classifies the same failure the same way. + if errors.Is(addErr, pluginapi.ErrNotFound) { + return mmmodel.NewAppError("AutoJoinIfDefaultGranted", "app.space.member.user_not_found.app_error", nil, "", http.StatusNotFound).Wrap(addErr) + } + return addErr + } + joined = true + joinedUserID, joinedChannelID = member.UserId, fresh.ChannelId + return nil + }) + if lockErr != nil { + return false, membershipLockAppError("AutoJoinIfDefaultGranted", lockErr) + } + // Published after the lock is released, since the membership lock holds a dedicated connection + // for its whole duration. + if joined { + payload := map[string]any{"space_id": space.Id, "user_id": joinedUserID} + s.publishToChannels(wsEventSpaceMemberAdded, payload, joinedChannelID) + // Also delivered directly, matching AddSpaceMember: the channel-scoped broadcast may not + // resolve the just-joined member, who has no other signal that the auto-join happened. + s.publishToUser(wsEventSpaceMemberAdded, payload, joinedUserID) + } + return joined, nil +} + +// UndoAutoJoin removes a membership AutoJoinIfDefaultGranted created for a write that then failed, +// so a rejected request leaves no membership behind. Callers pass the joined result of the gate +// that admitted them; when it is false this does nothing. +// +// The pre-step runs ahead of the guarded write, so without this a request rejected after the gate — +// a cycle or depth breach on a move, a stale optimistic-lock baseline, or the subtree-ownership 403 — +// would still leave the caller a member of a space it never successfully wrote to. +// +// Removal is best-effort and reported only in the log: the caller is already returning the write's +// own error, and replacing it with a cleanup failure would hide why the request was rejected. A +// leftover membership grants no more than the space defaults already granted the caller. +func (s *Service) UndoAutoJoin(joined bool, space *model.Space, userID string) { + if !joined || space == nil || s.client == nil { + return + } + lockErr := s.store.WithSpaceMembershipLock(space.Id, func() error { + return s.client.Channel.DeleteMember(space.ChannelId, userID) + }) + if lockErr != nil { + s.log.Error("failed to remove the membership an auto-join created for a rejected write; the user remains a member of the space", + "space_id", space.Id, "user_id", userID, "err", lockErr) + return + } + payload := map[string]any{"space_id": space.Id, "user_id": userID} + s.publishToChannels(wsEventSpaceMemberRemoved, payload, space.ChannelId) + // The user has already left the backing channel, so the channel-scoped broadcast above never + // reaches them; send the event to their own connections directly. + s.publishToUser(wsEventSpaceMemberRemoved, payload, userID) +} diff --git a/server/app/permissions_test.go b/server/app/permissions_test.go new file mode 100644 index 0000000..1e013c0 --- /dev/null +++ b/server/app/permissions_test.go @@ -0,0 +1,523 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app_test + +import ( + "errors" + "net/http" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/plugin/plugintest" + + "github.com/mattermost/mattermost-plugin-docs/server/app" + "github.com/mattermost/mattermost-plugin-docs/server/internal/testutil" + "github.com/mattermost/mattermost-plugin-docs/server/model" +) + +// stubNonMember denies userID every backing-channel page permission, so the read resolver admits +// them through the open-space team fall-through rather than as a member — the only admission +// auto-join acts on. Must run before the harness constructor: mock.Mock matches expectations in +// registration order, and StubDefaultSpacePermissions' catch-all otherwise grants read_page first. +func stubNonMember(mockAPI *plugintest.API, userID string) { + for _, p := range []*mmmodel.Permission{ + mmmodel.PermissionReadPage, mmmodel.PermissionCreatePage, mmmodel.PermissionCommentPage, + mmmodel.PermissionEditPage, mmmodel.PermissionDeleteOwnPage, + } { + mockAPI.On("HasPermissionToChannel", userID, mock.Anything, p).Return(false).Maybe() + } +} + +// autoJoinHarness seeds one open space whose backing channel resolves to the contribute preset, +// and returns the harness plus that space. The caller stubs whichever of RolesGrantPermission / +// GetChannelMember / AddChannelMember its scenario reaches. +func autoJoinHarness(t *testing.T, mockAPI *plugintest.API, viewAccess model.ViewAccess) (*testHarness, *model.Space) { + t.Helper() + h := openTestServiceWithAPI(t, mockAPI) + channelID := mmmodel.NewId() + testutil.MustSeedChannelScheme(t, mockAPI, channelID, mmmodel.SchemeNameSpaceContribute) + space := testutil.MustCreateSpace(t, h.store, channelID, mmmodel.NewId()) + if space.ViewAccess != viewAccess { + updated, err := h.store.UpdateSpace(space.Id, &model.SpacePatch{ViewAccess: &viewAccess}, space.UpdateAt, false) + require.NoError(t, err) + space = updated + } + return h, space +} + +// TestAutoJoin_NotFallthroughIsNoOp covers the guard that makes auto-join apply to exactly one +// admission path: a caller admitted as a member (or as sysadmin, or denied outright) must never be +// joined, since joining is only ever a consequence of the open-space non-member fall-through. +func TestAutoJoin_NotFallthroughIsNoOp(t *testing.T) { + for _, resolution := range []app.ReadResolution{app.ReadDenied, app.ReadViaSysadmin, app.ReadViaMember} { + mockAPI := &plugintest.API{} + h, space := autoJoinHarness(t, mockAPI, model.ViewAccessOpen) + + joined, appErr := h.svc.AutoJoinIfDefaultGranted(space, mmmodel.NewId(), resolution, mmmodel.PermissionCreatePage, nil) + require.Nil(t, appErr) + require.False(t, joined, "resolution %v must not auto-join", resolution) + mockAPI.AssertNotCalled(t, "AddChannelMember", mock.Anything, mock.Anything) + } +} + +// TestAutoJoin_JoinsWhenDefaultGrants covers the success path of the auto-join pre-step — the step +// that runs ahead of a write gate and can add the caller to the backing channel so the gate then +// passes. A non-member admitted via the open-space fall-through, whose space default grants the +// permission, is added to the channel and the membership-added event is published. +func TestAutoJoin_JoinsWhenDefaultGrants(t *testing.T) { + mockAPI := &plugintest.API{} + userID := mmmodel.NewId() + stubNonMember(mockAPI, userID) + h, space := autoJoinHarness(t, mockAPI, model.ViewAccessOpen) + + mockAPI.On("RolesGrantPermission", mock.Anything, mmmodel.PermissionCreatePage.Id).Return(true) + // Not yet a member: the join path runs only when the membership probe misses. + mockAPI.On("GetChannelMember", space.ChannelId, userID).Return((*mmmodel.ChannelMember)(nil), &mmmodel.AppError{StatusCode: 404}) + mockAPI.On("AddChannelMember", space.ChannelId, userID). + Return(&mmmodel.ChannelMember{ChannelId: space.ChannelId, UserId: userID}, nil) + + joined, appErr := h.svc.AutoJoinIfDefaultGranted(space, userID, app.ReadViaOpenFallthrough, mmmodel.PermissionCreatePage, nil) + require.Nil(t, appErr) + require.True(t, joined) + mockAPI.AssertCalled(t, "AddChannelMember", space.ChannelId, userID) +} + +// TestAutoJoin_DefaultDoesNotGrant covers the admission test: the fall-through alone never joins. +// A space whose default capability set withholds the permission leaves the caller a non-member, so +// the write gate that follows denies them rather than silently granting access by joining first. +func TestAutoJoin_DefaultDoesNotGrant(t *testing.T) { + mockAPI := &plugintest.API{} + userID := mmmodel.NewId() + stubNonMember(mockAPI, userID) + h, space := autoJoinHarness(t, mockAPI, model.ViewAccessOpen) + + mockAPI.On("RolesGrantPermission", mock.Anything, mmmodel.PermissionCreatePage.Id).Return(false) + + joined, appErr := h.svc.AutoJoinIfDefaultGranted(space, userID, app.ReadViaOpenFallthrough, mmmodel.PermissionCreatePage, nil) + require.Nil(t, appErr) + require.False(t, joined) + mockAPI.AssertNotCalled(t, "AddChannelMember", mock.Anything, mock.Anything) +} + +// TestAutoJoin_PrivateFlipAbortsJoin is the concurrency guard: the admitting read happened against +// an open space, but the space is private by the time the pre-step re-reads it under the lock. The +// stale admission must not survive that flip — re-validation, not the caller's resolution, decides. +func TestAutoJoin_PrivateFlipAbortsJoin(t *testing.T) { + mockAPI := &plugintest.API{} + userID := mmmodel.NewId() + stubNonMember(mockAPI, userID) + h, space := autoJoinHarness(t, mockAPI, model.ViewAccessOpen) + + // Granted, so the re-validation below is the only thing that can abort the join — the pre-step's + // own admission test must not be what makes this pass. + mockAPI.On("RolesGrantPermission", mock.Anything, mmmodel.PermissionCreatePage.Id).Return(true) + + // The caller still holds the open-space record it was admitted against; the stored row has + // since flipped private, which is what the pre-step re-reads. + stale := *space + private := model.ViewAccessPrivate + _, err := h.store.UpdateSpace(space.Id, &model.SpacePatch{ViewAccess: &private}, space.UpdateAt, false) + require.NoError(t, err) + + joined, appErr := h.svc.AutoJoinIfDefaultGranted(&stale, userID, app.ReadViaOpenFallthrough, mmmodel.PermissionCreatePage, nil) + require.Nil(t, appErr) + require.False(t, joined, "a space that flipped private must not auto-join a stale open-read admission") + mockAPI.AssertNotCalled(t, "AddChannelMember", mock.Anything, mock.Anything) +} + +// TestAutoJoin_DeletedSpaceIsNoOp covers the space-vanished branch: a space deleted between the +// admitting read and the pre-step yields no join and no error, rather than a 500 on a race the +// caller cannot act on. +func TestAutoJoin_DeletedSpaceIsNoOp(t *testing.T) { + mockAPI := &plugintest.API{} + userID := mmmodel.NewId() + stubNonMember(mockAPI, userID) + h, space := autoJoinHarness(t, mockAPI, model.ViewAccessOpen) + + // Granted, so the deleted-space branch below is the only thing that can prevent the join. + mockAPI.On("RolesGrantPermission", mock.Anything, mmmodel.PermissionCreatePage.Id).Return(true) + + require.NoError(t, h.store.DeleteSpace(space.Id)) + + joined, appErr := h.svc.AutoJoinIfDefaultGranted(space, userID, app.ReadViaOpenFallthrough, mmmodel.PermissionCreatePage, nil) + require.Nil(t, appErr) + require.False(t, joined) + mockAPI.AssertNotCalled(t, "AddChannelMember", mock.Anything, mock.Anything) +} + +// TestAutoJoin_OwnerCheckGatesJoin covers the delete_own_page contract: the ownerCheck must hold +// before the join, so a caller who does not own the target page is never made a member on the +// strength of an own-page permission that cannot apply to it. +func TestAutoJoin_OwnerCheckGatesJoin(t *testing.T) { + mockAPI := &plugintest.API{} + userID := mmmodel.NewId() + stubNonMember(mockAPI, userID) + h, space := autoJoinHarness(t, mockAPI, model.ViewAccessOpen) + + mockAPI.On("RolesGrantPermission", mock.Anything, mmmodel.PermissionDeleteOwnPage.Id).Return(true) + + joined, appErr := h.svc.AutoJoinIfDefaultGranted(space, userID, app.ReadViaOpenFallthrough, + mmmodel.PermissionDeleteOwnPage, func() (bool, error) { return false, nil }) + require.Nil(t, appErr) + require.False(t, joined) + mockAPI.AssertNotCalled(t, "AddChannelMember", mock.Anything, mock.Anything) +} + +// TestAutoJoin_OwnerCheckFailurePropagates distinguishes a failed ownership lookup from a negative +// one: the first is an outage and must surface as an error, not as a silent no-join the caller +// would report to the user as a denial. +func TestAutoJoin_OwnerCheckFailurePropagates(t *testing.T) { + mockAPI := &plugintest.API{} + userID := mmmodel.NewId() + stubNonMember(mockAPI, userID) + h, space := autoJoinHarness(t, mockAPI, model.ViewAccessOpen) + + mockAPI.On("RolesGrantPermission", mock.Anything, mmmodel.PermissionDeleteOwnPage.Id).Return(true) + + joined, appErr := h.svc.AutoJoinIfDefaultGranted(space, userID, app.ReadViaOpenFallthrough, + mmmodel.PermissionDeleteOwnPage, func() (bool, error) { return false, errors.New("lookup failed") }) + require.NotNil(t, appErr) + require.False(t, joined) + mockAPI.AssertNotCalled(t, "AddChannelMember", mock.Anything, mock.Anything) +} + +// TestAutoJoin_AlreadyMemberIsNoOp covers idempotency: an existing member is not re-added, so a +// repeated write by an already-joined caller does not churn membership or republish the event. +func TestAutoJoin_AlreadyMemberIsNoOp(t *testing.T) { + mockAPI := &plugintest.API{} + userID := mmmodel.NewId() + stubNonMember(mockAPI, userID) + h, space := autoJoinHarness(t, mockAPI, model.ViewAccessOpen) + + mockAPI.On("RolesGrantPermission", mock.Anything, mmmodel.PermissionCreatePage.Id).Return(true) + mockAPI.On("GetChannelMember", space.ChannelId, userID). + Return(&mmmodel.ChannelMember{ChannelId: space.ChannelId, UserId: userID}, nil) + + joined, appErr := h.svc.AutoJoinIfDefaultGranted(space, userID, app.ReadViaOpenFallthrough, mmmodel.PermissionCreatePage, nil) + require.Nil(t, appErr) + require.False(t, joined) + mockAPI.AssertNotCalled(t, "AddChannelMember", mock.Anything, mock.Anything) +} + +// TestRequireSpaceDraftWrite_LookupFailureIsNotADenial pins the error discipline of the two-attempt +// draft gate: a failure of the create_page check must surface as itself, not be retried into the +// edit_page attempt and reported as a 403 — a backend outage must never read as "not authorized". +func TestRequireSpaceDraftWrite_LookupFailureIsNotADenial(t *testing.T) { + mockAPI := &plugintest.API{} + userID := mmmodel.NewId() + stubNonMember(mockAPI, userID) + // The scheme lookup behind the auto-join pre-step fails outright. That is a failure of the + // check, not a denial, so it must not be swallowed and retried as the edit_page attempt. + channelID := mmmodel.NewId() + mockAPI.On("GetChannelOfType", channelID, mmmodel.ChannelTypeSpace). + Return((*mmmodel.Channel)(nil), &mmmodel.AppError{StatusCode: 500, Id: "channel.lookup.failed"}) + h := openTestServiceWithAPI(t, mockAPI) + space := testutil.MustCreateSpace(t, h.store, channelID, mmmodel.NewId()) + + _, appErr := h.svc.RequireSpaceDraftWrite("test", space, userID, app.ReadViaOpenFallthrough) + + require.NotNil(t, appErr) + require.NotEqual(t, http.StatusForbidden, appErr.StatusCode, + "a failed lookup must not be reported as an authorization denial") + // One attempt, not two: the create_page failure short-circuits instead of falling through to + // the edit_page retry. Each attempt runs the pre-step, which resolves team membership once. + mockAPI.AssertNumberOfCalls(t, "GetTeamMember", 1) +} + +// TestResolveSpacePageOwnOrAny_CheckFailureIsNotADenial pins the same error discipline on the +// own/any gate that TestRequireSpaceDraftWrite_LookupFailureIsNotADenial pins on the draft gate: +// a failure of either attempt surfaces as itself. +// +// The distinction the gate draws is between "neither permission grants this" — reported as +// admitted=false with no error, which the caller turns into its own 403 — and "the check could not +// be carried out", which must reach the caller as the failure it is. +// +// Both ownerMatches values are covered because an owner match is what opens the second attempt, +// and the failure must not be converted into a grant by it. Only the any-attempt's guard is +// exercised either way: both attempts resolve through the same client, so an any-attempt that +// reached a verdict at all guarantees the own-attempt can only deny or grant. +func TestResolveSpacePageOwnOrAny_CheckFailureIsNotADenial(t *testing.T) { + for name, ownerMatches := range map[string]bool{ + "owner does not match": false, + "owner matches": true, + } { + t.Run(name, func(t *testing.T) { + h := openTestService(t) + // An unwired client makes both permission attempts fail as a 500 rather than deny. + h.svc = app.New(h.store, nil, nil) + space := testutil.MustCreateSpace(t, h.store, mmmodel.NewId(), mmmodel.NewId()) + + ownOnly, admitted, appErr := h.svc.ResolveSpacePageOwnOrAny( + space, mmmodel.NewId(), + "any", mmmodel.PermissionDeletePage, + "own", mmmodel.PermissionDeleteOwnPage, + ownerMatches, app.ReadViaMember) + + require.NotNil(t, appErr, "a failed check must not be reported as an ordinary denial") + require.Equal(t, http.StatusInternalServerError, appErr.StatusCode) + require.False(t, admitted) + require.False(t, ownOnly) + }) + } +} + +// TestRequireSpacePagePermissionFrom_FallthroughAdmitsReadOnly is the escalation guard on the +// open-space fall-through: it exists to admit reads, and must never admit a write. A non-member +// whose space default withholds the write permission — so the auto-join pre-step does not join +// them — reaches the gate still holding only the fall-through, and every write permission has to +// be refused there. Without the read-permission condition on that branch the fall-through would +// grant create/edit/delete outright, since the caller already holds the team read_public_channel +// the branch keys on. +func TestRequireSpacePagePermissionFrom_FallthroughAdmitsReadOnly(t *testing.T) { + for _, perm := range []*mmmodel.Permission{ + mmmodel.PermissionCreatePage, mmmodel.PermissionEditPage, + mmmodel.PermissionDeletePage, mmmodel.PermissionDeleteOwnPage, + } { + t.Run(perm.Id, func(t *testing.T) { + mockAPI := &plugintest.API{} + userID := mmmodel.NewId() + stubNonMember(mockAPI, userID) + mockAPI.On("HasPermissionToChannel", userID, mock.Anything, mmmodel.PermissionDeletePage).Return(false).Maybe() + h, space := autoJoinHarness(t, mockAPI, model.ViewAccessOpen) + + appErr := h.svc.RequireSpacePagePermissionFrom("test", space, userID, perm, app.ReadViaOpenFallthrough) + + require.NotNil(t, appErr, "%s must not be admitted by the open-space read fall-through", perm.Id) + require.Equal(t, http.StatusForbidden, appErr.StatusCode) + }) + } +} + +// TestRequireSpacePagePermissionFrom_FallthroughAdmitsRead is the positive half of the pair above: +// the very same non-member and resolution do get read_page, so the refusals there come from the +// permission being a write, not from the fall-through being closed altogether. +func TestRequireSpacePagePermissionFrom_FallthroughAdmitsRead(t *testing.T) { + mockAPI := &plugintest.API{} + userID := mmmodel.NewId() + stubNonMember(mockAPI, userID) + h, space := autoJoinHarness(t, mockAPI, model.ViewAccessOpen) + + appErr := h.svc.RequireSpacePagePermissionFrom("test", space, userID, mmmodel.PermissionReadPage, app.ReadViaOpenFallthrough) + + require.Nil(t, appErr) +} + +// TestRequireSpacePagePermission_FormerTeamMemberDenied verifies the same team-membership guard +// evaluatePagePermission applies as readResolutionFrom (see +// TestResolveSpaceRead_FormerTeamMemberDenied): a user who left the space's team is denied even +// though their backing-channel ChannelMember row still exists, because active is false and the +// `active &&` conjunct on the channel-permission check short-circuits before it is ever consulted. +func TestRequireSpacePagePermission_FormerTeamMemberDenied(t *testing.T) { + mockAPI := &plugintest.API{} + teamID := mmmodel.NewId() + userID := mmmodel.NewId() + // Registered before the harness, whose GetTeamMember catch-all returns an active membership: + // mock.Mock matches expectations in registration order. + mockAPI.On("GetTeamMember", teamID, userID). + Return(&mmmodel.TeamMember{TeamId: teamID, UserId: userID, DeleteAt: 1}, nil) + h := openTestServiceWithAPI(t, mockAPI) + + space := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), teamID) + + appErr := h.svc.RequireSpacePagePermission("test", space, userID, mmmodel.PermissionReadPage) + require.NotNil(t, appErr) + require.Equal(t, http.StatusForbidden, appErr.StatusCode) + // The team gate blocks before any channel-scoped permission is consulted, so the lingering + // ChannelMember row is irrelevant. + mockAPI.AssertNotCalled(t, "HasPermissionToChannel", mock.Anything, mock.Anything, mock.Anything) +} + +// TestResolveSpaceRead_ComplianceModeSuppressesOpenFallthrough verifies that the open-space +// non-member read fall-through is suppressed under ComplianceSettings.Enable, even though the +// caller holds the team read_public_channel grant hasOpenTeamFallthrough otherwise admits on. +func TestResolveSpaceRead_ComplianceModeSuppressesOpenFallthrough(t *testing.T) { + mockAPI := &plugintest.API{} + strangerID := mmmodel.NewId() + // Registered before the harness so it takes precedence over StubDefaultSpacePermissions' + // permissive catch-all: mock.Mock matches expectations in registration order. + mockAPI.On("HasPermissionToChannel", strangerID, mock.Anything, mmmodel.PermissionReadPage).Return(false) + mockAPI.On("GetConfig"). + Return(&mmmodel.Config{ComplianceSettings: mmmodel.ComplianceSettings{Enable: mmmodel.NewPointer(true)}}).Once() + h := openTestServiceWithAPI(t, mockAPI) + + // The default space fixture is open, and the harness stubs GetTeamMember to an active + // membership, so the stranger clears the team gate and reaches the open-space fall-through. + space := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), mmmodel.NewId()) + + resolution, appErr := h.svc.ResolveSpaceRead("test", space, strangerID) + require.Nil(t, appErr) + require.Equal(t, app.ReadDenied, resolution, + "compliance mode must suppress the open-space non-member fall-through even though the caller holds read_public_channel") +} + +// TestResolveSpaceRead_UnreadableConfigSuppressesOpenFallthrough covers the fail-closed direction of +// the compliance check: a config that cannot be read at all suppresses the open-space non-member +// fall-through rather than admitting it. Reading an absent config as "compliance off" would widen +// access on exactly the signal that says the access policy is unknown. +func TestResolveSpaceRead_UnreadableConfigSuppressesOpenFallthrough(t *testing.T) { + mockAPI := &plugintest.API{} + strangerID := mmmodel.NewId() + // Both registered before the harness, whose catch-alls would otherwise answer first: mock.Mock + // matches expectations in registration order. + mockAPI.On("HasPermissionToChannel", strangerID, mock.Anything, mmmodel.PermissionReadPage).Return(false) + mockAPI.On("GetConfig").Return((*mmmodel.Config)(nil)).Once() + h := openTestServiceWithAPI(t, mockAPI) + + // The default space fixture is open, and the harness stubs GetTeamMember to an active + // membership, so the stranger clears the team gate and reaches the open-space fall-through. + space := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), mmmodel.NewId()) + + resolution, appErr := h.svc.ResolveSpaceRead("test", space, strangerID) + require.Nil(t, appErr) + require.Equal(t, app.ReadDenied, resolution, + "an unreadable config must suppress the open-space non-member fall-through, not admit it") +} + +// teamManagerHarness seeds a space with the given view access whose acting user holds team +// manage_space but is not a member of the backing channel — exactly the caller +// RequireSpaceAdminOrTeamPerm's read-gate conjunct exists to constrain. +func teamManagerHarness(t *testing.T, viewAccess model.ViewAccess) (*testHarness, *model.Space, string) { + t.Helper() + mockAPI := &plugintest.API{} + userID := mmmodel.NewId() + teamID := mmmodel.NewId() + // Both registered before the harness, whose catch-alls would otherwise answer first: + // mock.Mock matches expectations in registration order. + stubNonMember(mockAPI, userID) + mockAPI.On("HasPermissionToChannel", userID, mock.Anything, mmmodel.PermissionAdminSpace).Return(false).Maybe() + mockAPI.On("HasPermissionToTeam", userID, teamID, mmmodel.PermissionManageSpace).Return(true).Maybe() + h := openTestServiceWithAPI(t, mockAPI) + + space := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), teamID) + if space.ViewAccess != viewAccess { + updated, err := h.store.UpdateSpace(space.Id, &model.SpacePatch{ViewAccess: &viewAccess}, space.UpdateAt, false) + require.NoError(t, err) + space = updated + } + return h, space, userID +} + +// TestRequireSpaceAdminOrTeamPerm_TeamPermNeedsReadOnPrivateSpace pins the read-gate conjunct on the +// team-permission branch: a team-wide manage_space grant authorizes managing only spaces the caller +// can already read. Here the caller holds manage_space on the team but is not a backing-channel +// member of a private space, so the read resolver denies them and the team grant must not admit +// them anyway. Without the conjunct, any team manage_space holder could patch or delete every +// private space in the team, including ones they cannot open. +func TestRequireSpaceAdminOrTeamPerm_TeamPermNeedsReadOnPrivateSpace(t *testing.T) { + h, space, userID := teamManagerHarness(t, model.ViewAccessPrivate) + + appErr := h.svc.RequireSpaceAdminOrTeamPerm("test", space, userID, mmmodel.PermissionManageSpace) + require.NotNil(t, appErr, "team manage_space must not admit a caller who cannot read the space") + require.Equal(t, http.StatusForbidden, appErr.StatusCode) + require.Equal(t, "app.space.access.forbidden.app_error", appErr.Id, + "the denial must be the shared existence-hiding 403, not a distinguishable one") +} + +// TestRequireSpaceAdminOrTeamPerm_TeamPermAdmitsOnOpenSpace is the positive half of the pair above: +// the same caller, the same team grant, on an open space the read resolver admits them to via the +// non-member fall-through. Together the two pin the conjunct to the read gate specifically — this +// case failing would mean the gate rejects callers it should serve, rather than the conjunct being +// absent. +func TestRequireSpaceAdminOrTeamPerm_TeamPermAdmitsOnOpenSpace(t *testing.T) { + h, space, userID := teamManagerHarness(t, model.ViewAccessOpen) + + appErr := h.svc.RequireSpaceAdminOrTeamPerm("test", space, userID, mmmodel.PermissionManageSpace) + require.Nil(t, appErr, "team manage_space must admit a caller the read gate already admits") +} + +// formerAdminHarness seeds an open space whose acting user left the team but whose backing-channel +// state still grants them everything the elevated gates check: admin_space on the channel and +// manage_space on the team. Every grant the gates consult is stubbed true, so a denial can only come +// from the team-active conjunct — which is what these tests exist to pin. +func formerAdminHarness(t *testing.T) (*testHarness, *model.Space, string, *plugintest.API) { + t.Helper() + mockAPI := &plugintest.API{} + userID := mmmodel.NewId() + teamID := mmmodel.NewId() + // All registered before the harness, whose catch-alls would otherwise answer first: mock.Mock + // matches expectations in registration order. + mockAPI.On("GetTeamMember", teamID, userID). + Return(&mmmodel.TeamMember{TeamId: teamID, UserId: userID, DeleteAt: 1}, nil) + mockAPI.On("HasPermissionToChannel", userID, mock.Anything, mmmodel.PermissionAdminSpace).Return(true).Maybe() + mockAPI.On("HasPermissionToTeam", userID, teamID, mmmodel.PermissionManageSpace).Return(true).Maybe() + h := openTestServiceWithAPI(t, mockAPI) + + space := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), teamID) + return h, space, userID, mockAPI +} + +// TestRequireSpaceAdminOrTeamPerm_FormerTeamMemberDenied pins the team-active conjunct on the +// channel-admin branch, the counterpart to TestRequireSpacePagePermission_FormerTeamMemberDenied on +// the page gate. Leaving a team does not remove the user's backing-channel rows, so a former member +// can still hold admin_space there. Without the conjunct that stale grant would keep admitting them +// to the manage tier — patch and delete on a space in a team they no longer belong to. +func TestRequireSpaceAdminOrTeamPerm_FormerTeamMemberDenied(t *testing.T) { + h, space, userID, mockAPI := formerAdminHarness(t) + + appErr := h.svc.RequireSpaceAdminOrTeamPerm("test", space, userID, mmmodel.PermissionManageSpace) + require.NotNil(t, appErr, "a former team member must not reach the manage tier through a stale channel admin_space grant") + require.Equal(t, http.StatusForbidden, appErr.StatusCode) + require.Equal(t, "app.space.access.forbidden.app_error", appErr.Id, + "the denial must be the shared existence-hiding 403, not a distinguishable one") + // The team gate blocks before either elevated branch is consulted, so neither the lingering + // channel grant nor the team manage_space grant is ever reached. + mockAPI.AssertNotCalled(t, "HasPermissionToChannel", mock.Anything, mock.Anything, mock.Anything) +} + +// TestRequireSpaceAdminOrSysadmin_FormerTeamMemberDenied is the same guard on the stricter gate: +// the space-wide exposure knobs (ViewAccess, default capabilities) and admin-affecting member +// changes. This gate has no team-permission branch at all, so its channel-admin branch is the only +// non-sysadmin way in — making the team-active conjunct the whole of its membership requirement. +func TestRequireSpaceAdminOrSysadmin_FormerTeamMemberDenied(t *testing.T) { + h, space, userID, mockAPI := formerAdminHarness(t) + + appErr := h.svc.RequireSpaceAdminOrSysadmin("test", space, userID) + require.NotNil(t, appErr, "a former team member must not reach the admin tier through a stale channel admin_space grant") + require.Equal(t, http.StatusForbidden, appErr.StatusCode) + require.Equal(t, "app.space.access.forbidden.app_error", appErr.Id, + "the denial must be the shared existence-hiding 403, not a distinguishable one") + mockAPI.AssertNotCalled(t, "HasPermissionToChannel", mock.Anything, mock.Anything, mock.Anything) +} + +// TestResolveSpaceRead_GuestDeniedOpenFallthrough covers the guest exclusion from the open-space +// non-member read. Core's team_guest holds read_space but not read_public_channel, so a guest who +// is not a backing-channel member has no path into an open space they were never added to — +// unlike a plain team member, whom the fall-through admits. +func TestResolveSpaceRead_GuestDeniedOpenFallthrough(t *testing.T) { + mockAPI := &plugintest.API{} + guestID := mmmodel.NewId() + // Both registered before the harness: StubDefaultSpacePermissions grants read_page on the + // channel and read_public_channel on the team to any user, and mock.Mock matches expectations + // in registration order. + stubNonMember(mockAPI, guestID) + testutil.StubGuestTeamDefaults(mockAPI, guestID) + h := openTestServiceWithAPI(t, mockAPI) + + // The default fixture is open and the harness stubs an active team membership, so the guest + // clears the team gate and reaches the fall-through — where team_guest's missing + // read_public_channel is the only thing left to deny them. + space := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), mmmodel.NewId()) + + resolution, appErr := h.svc.ResolveSpaceRead("test", space, guestID) + require.Nil(t, appErr) + require.Equal(t, app.ReadDenied, resolution, + "a guest lacking team read_public_channel must not be admitted to an open space by the fall-through") +} + +// TestResolveSpaceRead_InvalidUserIDIsBadRequest keeps a malformed user id reporting as a caller +// fault. Collapsing it into the existence-hiding 403 every genuine denial returns would make a +// plumbing bug indistinguishable from an ordinary authorization failure. +func TestResolveSpaceRead_InvalidUserIDIsBadRequest(t *testing.T) { + mockAPI := &plugintest.API{} + h, space := autoJoinHarness(t, mockAPI, model.ViewAccessOpen) + + resolution, appErr := h.svc.ResolveSpaceRead("test", space, "") + + require.NotNil(t, appErr) + require.Equal(t, http.StatusBadRequest, appErr.StatusCode) + require.Equal(t, app.ReadDenied, resolution) +} diff --git a/server/app/scheme.go b/server/app/scheme.go new file mode 100644 index 0000000..5dfba49 --- /dev/null +++ b/server/app/scheme.go @@ -0,0 +1,224 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "errors" + "slices" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/pluginapi" + + "github.com/mattermost/mattermost-plugin-docs/server/model" + "github.com/mattermost/mattermost-plugin-docs/server/store" +) + +// permissionSetsEqual reports whether two permission sets hold the same ids, disregarding order. +// The sets being compared are built differently — a generated user role leads with the baseline +// read followed by the sorted capabilities, the admin role follows core's declaration order, and a +// stored role comes back in whatever order it was last written. +func permissionSetsEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + sortedA, sortedB := slices.Clone(a), slices.Clone(b) + slices.Sort(sortedA) + slices.Sort(sortedB) + return slices.Equal(sortedA, sortedB) +} + +// A non-preset default-capability set resolves to a scheme in a shared pool keyed by that set, so +// the number of schemes is bounded by the capability vocabulary rather than by the number of +// spaces: every space configured the same way points at one scheme. A pooled scheme is never +// deleted — nothing owns it — so there is no retirement, no reference counting, and no residue +// from an interrupted create. +// +// Core accepts a scheme name as proof of space scope only for the reserved preset names +// (model.SchemeNameForDefaultCapabilities recognizes them); a pooled scheme proves its scope by +// having a space backing channel point at it instead. + +// schemeRoles is the generated channel-scheme role names governing one backing channel's scheme. +// Space capability grants reference these generated names, not the literal +// channel_user/channel_admin roles: on a scheme-backed channel, core rejects the literal. +type schemeRoles struct { + UserRoleName string + AdminRoleName string + GuestRoleName string +} + +// getSchemeRolesForChannel resolves the generated role names of the scheme governing channelID's +// backing channel. Returns store.ErrNotFound when the channel does not exist or carries no scheme, +// so callers translate it with storeAppError/IsErrNotFound. +func (s *Service) getSchemeRolesForChannel(channelID string) (*schemeRoles, error) { + if channelID == "" { + return nil, &store.ErrInvalidInput{Entity: "Channel", Field: "id", Value: channelID} + } + + channel, err := s.client.Channel.GetChannelOfType(channelID, mmmodel.ChannelTypeSpace) + if err != nil { + if errors.Is(err, pluginapi.ErrNotFound) { + return nil, &store.ErrNotFound{EntityName: "ChannelScheme", ID: channelID} + } + return nil, err + } + return s.schemeRolesFromChannel(channelID, channel) +} + +// schemeRolesFromChannel is getSchemeRolesForChannel for a caller that already holds the backing +// channel. channelID identifies the channel in the returned not-found errors independently of what +// the channel object carries. +func (s *Service) schemeRolesFromChannel(channelID string, channel *mmmodel.Channel) (*schemeRoles, error) { + // The scheme reference is checked here rather than inferred from the resolved role names: core's + // RolesForChannel falls back to the team scheme's channel roles for a channel carrying no scheme + // of its own, and a space that lost its scheme must report not-found instead of silently + // resolving to team roles that grant no page capabilities. + if channel == nil || channel.SchemeId == nil || *channel.SchemeId == "" { + return nil, &store.ErrNotFound{EntityName: "ChannelScheme", ID: channelID} + } + + guestRole, userRole, adminRole, err := s.client.Scheme.GetRolesForChannel(channelID) + if err != nil { + if errors.Is(err, pluginapi.ErrNotFound) { + return nil, &store.ErrNotFound{EntityName: "ChannelScheme", ID: channelID} + } + return nil, err + } + + return &schemeRoles{ + UserRoleName: userRole, + AdminRoleName: adminRole, + GuestRoleName: guestRole, + }, nil +} + +// getSchemeIDByName returns the id of the scheme with the given name. +func (s *Service) getSchemeIDByName(name string) (string, error) { + scheme, err := s.client.Scheme.GetByName(name) + if err != nil { + if errors.Is(err, pluginapi.ErrNotFound) { + return "", &store.ErrNotFound{EntityName: "Scheme", ID: name} + } + return "", err + } + return scheme.Id, nil +} + +// getRolePermissionsByName returns the permission ids granted by the named role. +func (s *Service) getRolePermissionsByName(roleName string) ([]string, error) { + role, err := s.client.Role.GetByName(roleName) + if err != nil { + if errors.Is(err, pluginapi.ErrNotFound) { + return nil, &store.ErrNotFound{EntityName: "Role", ID: roleName} + } + return nil, err + } + return role.Permissions, nil +} + +// getOrCreateSharedScheme resolves the pooled channel scheme expressing capabilities, creating it +// on first use. The name is a pure function of the capability set (see +// model.SharedSchemeNameForCapabilities), so every space configured that way resolves to the same +// scheme: two schemes carrying one capability set would be indistinguishable in behaviour, since +// nothing reads a scheme id or a generated role name for meaning. +// +// The returned roles start on core's default channel baseline when the scheme is new, and the +// caller gives them their permission sets through configureSharedScheme once a backing channel +// points at the scheme. That configure runs on every resolution, not only on creation: a scheme +// created by a racing caller may still be mid-configuration when this one finds it, and rewriting +// an already-correct permission set is idempotent. +func (s *Service) getOrCreateSharedScheme(capabilities []string) (string, *schemeRoles, error) { + name := model.SharedSchemeNameForCapabilities(capabilities) + if scheme, err := s.client.Scheme.GetByName(name); err == nil { + // Logged because the configure that follows rewrites roles every space already on this + // scheme resolves against, not just this caller's space. The write is idempotent while the + // capability-to-permission mapping is correct, so a mapping regression is otherwise + // indistinguishable from ordinary traffic until its effects show up across those spaces. + s.log.Debug("resolved an existing pooled space scheme; its role write applies to every space sharing it", + "scheme_id", scheme.Id, "scheme_name", name) + return scheme.Id, rolesFromScheme(scheme), nil + } else if !errors.Is(err, pluginapi.ErrNotFound) { + return "", nil, err + } + + scheme, err := s.client.Scheme.Create(&mmmodel.Scheme{ + Name: name, + DisplayName: model.SharedSchemeDisplayNameForCapabilities(capabilities), + Scope: mmmodel.SchemeScopeChannel, + }) + if err != nil { + // The name is unique, so a concurrent first use of the same capability set loses this + // create and adopts the winner's scheme rather than failing the caller. + if existing, getErr := s.client.Scheme.GetByName(name); getErr == nil { + return existing.Id, rolesFromScheme(existing), nil + } + return "", nil, err + } + return scheme.Id, rolesFromScheme(scheme), nil +} + +// rolesFromScheme names the three roles core generated for scheme. +func rolesFromScheme(scheme *mmmodel.Scheme) *schemeRoles { + return &schemeRoles{ + UserRoleName: scheme.DefaultChannelUserRole, + AdminRoleName: scheme.DefaultChannelAdminRole, + GuestRoleName: scheme.DefaultChannelGuestRole, + } +} + +// configureSharedScheme writes the permission sets of the three roles generated for a pooled +// scheme, so members of a space pointing at it hold exactly capabilities plus the baseline read. +// roles are the names getOrCreateSharedScheme returned, so the writes land on the resolved scheme +// rather than on whatever a channel currently points at. +// +// It must run only once a space backing channel already points at that scheme: core allows a role +// write carrying space permissions for the roles of a reserved preset scheme (see the pool comment +// above), or for a scheme a space backing channel already references, and it does not accept a +// caller-chosen scheme name as proof. +// Idempotent, so re-running it against an already-configured pooled scheme is a no-op in effect. +func (s *Service) configureSharedScheme(roles *schemeRoles, capabilities []string) error { + capabilities = model.NormalizeCapabilitySet(capabilities) + roleSets := []struct { + name string + perms []string + }{ + {roles.UserRoleName, append([]string{model.CapabilityReadPage}, capabilities...)}, + {roles.AdminRoleName, mmmodel.PermissionIDs(mmmodel.SpaceAdminRolePermissions)}, + {roles.GuestRoleName, []string{model.CapabilityReadPage}}, + } + for _, rs := range roleSets { + if err := s.setRolePermissions(rs.name, rs.perms); err != nil { + return err + } + } + return nil +} + +// setRolePermissions replaces the named role's permission set with permissions. Returns +// store.ErrNotFound for a missing role, matching getRolePermissionsByName's translation of the same +// lookup so both surface the same status through storeAppError. +func (s *Service) setRolePermissions(roleName string, permissions []string) error { + role, err := s.client.Role.GetByName(roleName) + if err != nil { + if errors.Is(err, pluginapi.ErrNotFound) { + return &store.ErrNotFound{EntityName: "Role", ID: roleName} + } + return err + } + // configureSharedScheme runs on every resolution, not only when the scheme is created, so that a + // scheme a racing caller left mid-configuration still converges. Once the stored set matches, + // rewriting it would invalidate the role in core's cache on every node, for every space sharing + // this pooled scheme, so a matching set is left as it stands. + if permissionSetsEqual(role.Permissions, permissions) { + return nil + } + // Patched by id rather than by handing back the role just read: core re-reads the stored role + // so its scope guard judges a SchemeId the caller cannot influence. + if _, err = s.client.Role.Patch(role.Id, &mmmodel.RolePatch{Permissions: &permissions}); err != nil { + if errors.Is(err, pluginapi.ErrNotFound) { + return &store.ErrNotFound{EntityName: "Role", ID: roleName} + } + return err + } + return nil +} diff --git a/server/app/scheme_test.go b/server/app/scheme_test.go new file mode 100644 index 0000000..06702a4 --- /dev/null +++ b/server/app/scheme_test.go @@ -0,0 +1,82 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/plugin/plugintest" + "github.com/mattermost/mattermost/server/public/pluginapi" + + "github.com/mattermost/mattermost-plugin-docs/server/internal/testutil" + "github.com/mattermost/mattermost-plugin-docs/server/model" + "github.com/mattermost/mattermost-plugin-docs/server/store" +) + +// TestGetOrCreateSharedScheme_ConcurrentCreateAdoptsWinner covers the race guard: the pooled +// scheme's name is a pure function of the capability set, so two callers racing the same +// capability set collide on create. The loser must adopt the winner's scheme instead of failing +// the caller outright. +func TestGetOrCreateSharedScheme_ConcurrentCreateAdoptsWinner(t *testing.T) { + s, _ := testutil.OpenTestStore(t) + mockAPI := &plugintest.API{} + capabilities := []string{"create_page"} + name := model.SharedSchemeNameForCapabilities(capabilities) + + winner := &mmmodel.Scheme{ + Id: mmmodel.NewId(), + Name: name, + DefaultChannelUserRole: "winner_user_role", + DefaultChannelAdminRole: "winner_admin_role", + DefaultChannelGuestRole: "winner_guest_role", + } + + // The first lookup misses: this caller has not seen the scheme yet. + mockAPI.On("GetSchemeByName", name). + Return((*mmmodel.Scheme)(nil), &mmmodel.AppError{Id: "app.scheme.get.app_error", StatusCode: 404}).Once() + // This caller's own create loses the race. + mockAPI.On("CreateScheme", mock.AnythingOfType("*model.Scheme")). + Return((*mmmodel.Scheme)(nil), &mmmodel.AppError{Id: "app.scheme.create.app_error", StatusCode: 500}).Once() + // The second lookup, taken after the failed create, finds the concurrent winner. + mockAPI.On("GetSchemeByName", name).Return(winner, nil).Once() + + client := pluginapi.NewClient(mockAPI, nil) + svc := New(s, nil, client) + + schemeID, roles, err := svc.getOrCreateSharedScheme(capabilities) + require.NoError(t, err, "a create lost to a concurrent winner must not fail the caller") + require.Equal(t, winner.Id, schemeID) + require.NotNil(t, roles) + require.Equal(t, "winner_user_role", roles.UserRoleName) + require.Equal(t, "winner_admin_role", roles.AdminRoleName) + require.Equal(t, "winner_guest_role", roles.GuestRoleName) + mockAPI.AssertExpectations(t) +} + +// TestSchemeRolesFromChannel_MissingSchemeVariants covers both shapes of "no scheme" the code +// treats as distinct cases: a nil SchemeId and a non-nil SchemeId pointing at an empty string. +// Either must resolve to not-found rather than falling through to a scheme lookup. +func TestSchemeRolesFromChannel_MissingSchemeVariants(t *testing.T) { + s, _ := testutil.OpenTestStore(t) + svc := New(s, nil, nil) + + t.Run("nil SchemeId", func(t *testing.T) { + channel := &mmmodel.Channel{Id: mmmodel.NewId(), Type: mmmodel.ChannelTypeSpace} + _, err := svc.schemeRolesFromChannel(channel.Id, channel) + require.Error(t, err) + require.True(t, store.IsErrNotFound(err)) + }) + + t.Run("empty string SchemeId", func(t *testing.T) { + empty := "" + channel := &mmmodel.Channel{Id: mmmodel.NewId(), Type: mmmodel.ChannelTypeSpace, SchemeId: &empty} + _, err := svc.schemeRolesFromChannel(channel.Id, channel) + require.Error(t, err) + require.True(t, store.IsErrNotFound(err)) + }) +} diff --git a/server/app/service.go b/server/app/service.go index 6091b95..f2d91f4 100644 --- a/server/app/service.go +++ b/server/app/service.go @@ -124,6 +124,39 @@ func requireBaseline(where, field string, baseline *int64, force bool) *mmmodel. return nil } +// membershipLockAppError maps a WithSpaceMembershipLock failure to an *AppError: an AppError the +// locked closure returned is surfaced as-is, and the store's own errors — notably the retryable +// ErrConflict a lock-acquisition timeout yields — go through storeAppError so they keep their +// conventional status codes rather than collapsing to a 500. +func membershipLockAppError(where string, lockErr error) *mmmodel.AppError { + var appErr *mmmodel.AppError + if errors.As(lockErr, &appErr) { + return appErr + } + return storeAppError(where, lockErr) +} + +// schemeAppError maps a scheme-resolution failure to an *AppError. Resolution reaches core through +// the pluginapi client rather than the plugin's own store, so the error may already be an +// *AppError carrying the status core chose — a license denial for a custom scheme, or the refusal +// the scheme API returns until core finishes the asynchronous permissions migration it runs at +// startup, during which no scheme can be resolved or created. Those are surfaced +// unchanged; storeAppError would collapse both to a 500 because neither is a store sentinel. +// +// A missing preset scheme is separated out because storeAppError renders every not-found with the +// shared key ordinary row lookups use, which would report an unseeded server as though the caller +// had asked for a space that does not exist. +func schemeAppError(where string, err error) *mmmodel.AppError { + if errors.Is(err, errPresetSchemeMissing) { + return mmmodel.NewAppError(where, "app.space.preset_scheme_missing.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + var appErr *mmmodel.AppError + if errors.As(err, &appErr) { + return appErr + } + return storeAppError(where, err) +} + // storeAppError maps a store sentinel error to an *AppError with the conventional status code // and a shared message key (app.store.*); the where argument identifies the calling operation for logs. // This is the default for translating store errors; hand-roll an inline NewAppError only when a @@ -137,6 +170,11 @@ func storeAppError(where string, err error) *mmmodel.AppError { return mmmodel.NewAppError(where, "app.page.circular_reference.app_error", nil, "", http.StatusBadRequest).Wrap(err) case store.IsErrInvalidInput(err): return invalidInputAppError(where, err) + case store.IsErrLockTimeout(err): + // Distinct from the default CAS/unique-constraint conflict below: this is a + // WithSpaceMembershipLock acquisition timeout (another mutation on the same space is + // still in flight), not a stale-baseline write. + return mmmodel.NewAppError(where, "app.space.lock_timeout.app_error", nil, "", http.StatusConflict).Wrap(err) case store.IsErrConflict(err): return mmmodel.NewAppError(where, "app.store.conflict.app_error", nil, "", http.StatusConflict).Wrap(err) case store.IsErrLimitExceeded(err): @@ -177,6 +215,10 @@ func invalidInputAppError(where string, err error) *mmmodel.AppError { return mmmodel.NewAppError(where, "app.page_draft.update.parent_cycle.app_error", nil, "", http.StatusBadRequest).Wrap(err) case store.ReasonDraftTooDeep: return mmmodel.NewAppError(where, "app.page_draft.update.parent_too_deep.app_error", nil, "", http.StatusBadRequest).Wrap(err) + case store.ReasonSubtreeNotOwned: + // An authorization denial, not a malformed request: the caller holds only + // delete_own_page and the moved subtree contains a page they do not own. + return mmmodel.NewAppError(where, "app.page.move_to_space.subtree_not_owned.app_error", nil, "", http.StatusForbidden).Wrap(err) } return mmmodel.NewAppError(where, invErr.Reason, nil, "", http.StatusBadRequest).Wrap(err) } diff --git a/server/app/service_test.go b/server/app/service_test.go index 977ed02..cb7f8f0 100644 --- a/server/app/service_test.go +++ b/server/app/service_test.go @@ -13,10 +13,12 @@ import ( "strings" "testing" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" mmmodel "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin/plugintest" + "github.com/mattermost/mattermost/server/public/pluginapi" "github.com/mattermost/mattermost-plugin-docs/server/app" "github.com/mattermost/mattermost-plugin-docs/server/internal/testutil" @@ -51,18 +53,34 @@ func openTestService(t *testing.T) *testHarness { s, db := testutil.OpenTestStore(t) - // nil pluginapi client: these tests seed data directly through the store and - // never exercise the client, so no mock is needed. - svc := app.New(s, nil, nil) + // A permissive client rather than nil: most tests here seed data through the store and never + // touch it, but the ones that publish a draft go through PublishPageDraft's own permission + // gate, which needs a wired client. The stub grants the ordinary contribute-member set, so a + // test asserting publish behaviour is not also asserting authorization. + mockAPI := &plugintest.API{} + t.Cleanup(func() { mockAPI.AssertExpectations(t) }) + testutil.StubDefaultSpacePermissions(mockAPI) + mockAPI.On("GetChannelMember", mock.Anything, mock.Anything).Return(&mmmodel.ChannelMember{}, nil).Maybe() + mockAPI.On("GetTeamMember", mock.Anything, mock.Anything).Return(&mmmodel.TeamMember{}, nil).Maybe() + // With a client wired, the WS publishes and channel side-effects these paths perform are no + // longer no-ops. Tests that assert on specific events register their own expectations against + // their own mock. + mockAPI.On("PublishWebSocketEvent", mock.Anything, mock.Anything, mock.Anything).Return().Maybe() + mockAPI.On("GetChannelMembers", mock.Anything, mock.AnythingOfType("int"), mock.AnythingOfType("int")). + Return(mmmodel.ChannelMembers{}, nil).Maybe() + mockAPI.On("DeleteChannel", mock.Anything).Return(nil).Maybe() + mockAPI.On("RestoreChannel", mock.Anything).Return(nil).Maybe() + mockAPI.On("GetChannelOfType", mock.Anything, mock.Anything).Return((*mmmodel.Channel)(nil), nil).Maybe() + svc := app.New(s, nil, pluginapi.NewClient(mockAPI, nil)) return &testHarness{svc: svc, store: s, db: db} } // helpers to create test data directly on the store (bypassing business logic where the // higher-level Create methods need channel/space state that may not exist). -func mustCreateSpace(t *testing.T, s *store.Store, channelID string) *model.Space { +func mustCreateSpace(t *testing.T, s *store.Store, db *sql.DB, channelID string) *model.Space { t.Helper() - return seedSpaceForTeam(t, s, channelID, mmmodel.NewId()) + return seedSpaceForTeam(t, s, db, channelID, mmmodel.NewId()) } func mustCreatePage(t *testing.T, s *store.Store, spaceID, channelID, userID, parentID string) *model.Page { @@ -75,7 +93,7 @@ func TestServiceGetSpace(t *testing.T) { t.Run("found", func(t *testing.T) { channelID := mmmodel.NewId() - saved := mustCreateSpace(t, h.store, channelID) + saved := mustCreateSpace(t, h.store, h.db, channelID) got, err := h.svc.GetSpace(saved.Id) require.Nil(t, err) @@ -97,13 +115,13 @@ func TestServiceCreatePageParentDifferentSpace(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) // Create a real second space (different channel) and seed the rogue parent into it. // Using a real space avoids a foreign-key violation (Pages.SpaceId references Spaces) // and produces a page whose SpaceId is genuinely different from `space.Id`. otherChannelID := mmmodel.NewId() - otherSpace := mustCreateSpace(t, h.store, otherChannelID) + otherSpace := mustCreateSpace(t, h.store, h.db, otherChannelID) rogueParent := mustCreatePage(t, h.store, otherSpace.Id, otherChannelID, userID, "") _, err := h.svc.CreatePage(space.Id, rogueParent.Id, "Child", "", userID) @@ -133,7 +151,7 @@ func TestServiceDeleteSpace(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - saved := mustCreateSpace(t, h.store, channelID) + saved := mustCreateSpace(t, h.store, h.db, channelID) require.Nil(t, h.svc.DeleteSpace(saved)) @@ -147,7 +165,7 @@ func TestServiceGetPage(t *testing.T) { t.Run("found", func(t *testing.T) { channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) created := mustCreatePage(t, h.store, space.Id, channelID, mmmodel.NewId(), "") got, err := h.svc.GetPage(created.Id) @@ -166,7 +184,7 @@ func TestServiceUpdatePage(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() created := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -194,7 +212,7 @@ func TestServiceUpdatePageSearchTextWithoutBody(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() created := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -213,7 +231,7 @@ func TestServiceUpdatePageBodyDerivesSearchText(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() created := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -230,7 +248,7 @@ func TestServiceUpdatePageSearchTextIgnoredOnBodyClear(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() created := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -251,7 +269,7 @@ func TestServiceUpdatePageClearSearchTextAlone(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() created := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -269,7 +287,7 @@ func TestServiceUpdatePageForce(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() created := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -310,7 +328,7 @@ func TestServiceUpdatePageInvalidUserID(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) created := mustCreatePage(t, h.store, space.Id, channelID, mmmodel.NewId(), "") t.Run("UpdatePage rejects malformed userID", func(t *testing.T) { @@ -324,7 +342,7 @@ func TestServiceGetPageChildren(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() parent := mustCreatePage(t, h.store, space.Id, channelID, userID, "") child := mustCreatePage(t, h.store, space.Id, channelID, userID, parent.Id) @@ -339,7 +357,7 @@ func TestServiceGetSpacePages(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() for range 3 { mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -355,7 +373,7 @@ func TestServiceGetSpacePages(t *testing.T) { // new space must not leak the old space's pages. require.Nil(t, h.svc.DeleteSpace(space)) - space2 := mustCreateSpace(t, h.store, channelID) + space2 := mustCreateSpace(t, h.store, h.db, channelID) mustCreatePage(t, h.store, space2.Id, channelID, userID, "") pages2, _, err := h.svc.GetSpacePages(space2, 0, 0) @@ -383,7 +401,7 @@ func TestServiceGetTeamSpaces(t *testing.T) { teamID := mmmodel.NewId() userID := mmmodel.NewId() for range 2 { - sp := &model.Space{ChannelId: mmmodel.NewId(), TeamId: teamID, CreatorId: mmmodel.NewId(), Title: "Test Space"} + sp := &model.Space{ChannelId: mmmodel.NewId(), TeamId: teamID, CreatorId: mmmodel.NewId(), Title: "Test Space", ViewAccess: model.ViewAccessOpen} _, err := h.store.CreateSpace(sp) require.NoError(t, err) testutil.MustAddChannelMember(t, h.db, sp.ChannelId, userID) @@ -409,7 +427,7 @@ func TestServiceCreatePageDerivesChannelFromSpace(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() created, err := h.svc.CreatePage(space.Id, "", "My Page", "", userID) @@ -422,7 +440,7 @@ func TestServiceCreatePageDerivesChannelFromSpace(t *testing.T) { func TestServiceCreatePage(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() t.Run("rejects invalid space id", func(t *testing.T) { @@ -460,7 +478,7 @@ func TestServiceCreatePage(t *testing.T) { t.Run("rejects parent in a different space", func(t *testing.T) { otherChannelID := mmmodel.NewId() - otherSpace := mustCreateSpace(t, h.store, otherChannelID) + otherSpace := mustCreateSpace(t, h.store, h.db, otherChannelID) parent := mustCreatePage(t, h.store, otherSpace.Id, otherChannelID, userID, "") _, err := h.svc.CreatePage(space.Id, parent.Id, "Title", "", userID) require.NotNil(t, err) @@ -470,7 +488,7 @@ func TestServiceCreatePage(t *testing.T) { t.Run("rejects exceeding max depth", func(t *testing.T) { // Own channel/space so the deep chain does not pollute the shared fixture. depthChannelID := mmmodel.NewId() - depthSpace := mustCreateSpace(t, h.store, depthChannelID) + depthSpace := mustCreateSpace(t, h.store, h.db, depthChannelID) // Build a full-depth chain (root at depth 1 up to MaxPageDepth); the next // child would be at depth MaxPageDepth+1 and must be rejected. parentID := "" @@ -518,7 +536,7 @@ func TestServiceUpdatePageNothingToUpdate(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) created := mustCreatePage(t, h.store, space.Id, channelID, mmmodel.NewId(), "") _, err := h.svc.UpdatePage(created.Id, created.SpaceId, &model.PagePatch{}, new(created.EditAt), false, mmmodel.NewId()) @@ -533,7 +551,7 @@ func TestServiceUpdatePageOversizedBody(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) created := mustCreatePage(t, h.store, space.Id, channelID, mmmodel.NewId(), "") oversized := strings.Repeat("x", model.PageBodyMaxBytes+1) @@ -549,7 +567,7 @@ func TestServiceUpdatePageOversizedSearchTextIgnored(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) created := mustCreatePage(t, h.store, space.Id, channelID, mmmodel.NewId(), "") oversized := strings.Repeat("x", model.PageSearchTextMaxBytes+1) @@ -564,7 +582,7 @@ func TestServiceCreatePageOversizedBody(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) oversized := strings.Repeat("x", model.PageBodyMaxBytes+1) _, err := h.svc.CreatePage(space.Id, "", "Title", oversized, mmmodel.NewId()) @@ -579,7 +597,7 @@ func TestServiceUpdatePageCanSetEmptyBody(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() created := mustCreatePage(t, h.store, space.Id, channelID, userID, "") @@ -602,7 +620,7 @@ func TestServiceCreatePageSearchTextDerivedFromBody(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) created, err := h.svc.CreatePage(space.Id, "", "Title", "", mmmodel.NewId()) require.Nil(t, err) @@ -615,7 +633,7 @@ func TestServiceGetPageWithDeleted(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) actorID := mmmodel.NewId() created := mustCreatePage(t, h.store, space.Id, channelID, actorID, "") requireStoreDeletePage(t, h.store, created.Id, created.SpaceId, actorID) @@ -657,7 +675,7 @@ func TestServiceDeletePage(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() t.Run("deletes a page so the live get returns 404", func(t *testing.T) { @@ -700,7 +718,7 @@ func TestServiceRestorePage(t *testing.T) { h := openTestService(t) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) userID := mmmodel.NewId() t.Run("restores a soft-deleted page", func(t *testing.T) { @@ -765,7 +783,7 @@ func TestServiceRestoreSpace(t *testing.T) { h := openTestService(t) t.Run("restores a soft-deleted space", func(t *testing.T) { - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) require.Nil(t, h.svc.DeleteSpace(space)) got, err := h.svc.RestoreSpace(space.Id) @@ -774,7 +792,7 @@ func TestServiceRestoreSpace(t *testing.T) { }) t.Run("a non-deleted space returns 409", func(t *testing.T) { - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) _, err := h.svc.RestoreSpace(space.Id) require.NotNil(t, err) require.Equal(t, http.StatusConflict, err.StatusCode) @@ -790,12 +808,12 @@ func TestServiceRestoreSpace(t *testing.T) { t.Run("restoring over a channel a new live space owns returns 409", func(t *testing.T) { channelID := mmmodel.NewId() - original := mustCreateSpace(t, h.store, channelID) + original := mustCreateSpace(t, h.store, h.db, channelID) require.Nil(t, h.svc.DeleteSpace(original)) // A new live space now owns the backing channel; restoring the original would breach // the partial unique index uq_docs_space_channel_id. - mustCreateSpace(t, h.store, channelID) + mustCreateSpace(t, h.store, h.db, channelID) _, err := h.svc.RestoreSpace(original.Id) require.NotNil(t, err) diff --git a/server/app/space.go b/server/app/space.go index a2335d0..3431449 100644 --- a/server/app/space.go +++ b/server/app/space.go @@ -5,16 +5,21 @@ package app import ( "errors" + "fmt" "net/http" + "slices" "unicode/utf8" mmmodel "github.com/mattermost/mattermost/server/public/model" - "github.com/mattermost/mattermost/server/public/pluginapi" "github.com/mattermost/mattermost-plugin-docs/server/model" "github.com/mattermost/mattermost-plugin-docs/server/store" ) +// errPresetSchemeMissing tags a preset-scheme lookup that found nothing, which means core has not +// seeded the space schemes on this server. +var errPresetSchemeMissing = errors.New("preset space scheme is not seeded") + // validateSpaceMutableFields enforces the Description/Icon size caps shared by CreateSpace and // UpdateSpace. where identifies the calling operation for logs; the message keys are shared // across callers. @@ -28,85 +33,6 @@ func validateSpaceMutableFields(where, description, icon string) *mmmodel.AppErr return nil } -// requireClient rejects the operation when the pluginapi client is not wired, which every -// membership-gated space operation depends on. where identifies the calling operation for the -// log line and the returned AppError; kv are its extra log context pairs. -func (s *Service) requireClient(where string, kv ...any) *mmmodel.AppError { - if s.client != nil { - return nil - } - s.log.Warn("pluginapi client not wired; denying access", append([]any{"operation", where}, kv...)...) - return mmmodel.NewAppError(where, "app.space.client_not_wired.app_error", nil, "", http.StatusInternalServerError) -} - -// isActiveTeamMember reports whether userID currently belongs to teamID. Core keeps removed -// team members as rows with DeleteAt set — and GetMember returns such a row without error — so -// a missing row and a soft-deleted row both read as "not a member". Space access must check -// this, not just backing-channel membership: leaving a team does not remove a user from the -// team's space channels, so channel membership alone would let a former team member keep using -// known space and page IDs. -func (s *Service) isActiveTeamMember(teamID, userID string) (bool, error) { - member, err := s.client.Team.GetMember(teamID, userID) - if err != nil { - if errors.Is(err, pluginapi.ErrNotFound) { - return false, nil - } - return false, err - } - return member.DeleteAt == 0, nil -} - -// forEachChannelMember visits every member of channelID page by page. Iteration ends early -// when visit returns stop=true or an error; the error is returned as-is. -func (s *Service) forEachChannelMember(channelID string, visit func(cm *mmmodel.ChannelMember) (stop bool, err error)) error { - for page := 0; ; page++ { - members, err := s.client.Channel.ListMembers(channelID, page, PerPageMaximum) - if err != nil { - return err - } - for _, cm := range members { - stop, visitErr := visit(cm) - if visitErr != nil { - return visitErr - } - if stop { - return nil - } - } - if len(members) < PerPageMaximum { - return nil - } - } -} - -// hasOtherAuthorizedMember reports whether space has at least one backing-channel member other -// than excludeUserID who can still reach the space — for a team space, one who is still an -// active member of the team. Former team members keep their channel-member rows after leaving -// the team, so counting raw rows would let the last reachable member be removed and leave the -// space stranded behind members who all fail the team half of the access gate. -func (s *Service) hasOtherAuthorizedMember(space *model.Space, excludeUserID string) (bool, error) { - found := false - err := s.forEachChannelMember(space.ChannelId, func(cm *mmmodel.ChannelMember) (bool, error) { - if cm.UserId == excludeUserID { - return false, nil - } - if space.TeamId == "" { - found = true - return true, nil - } - active, activeErr := s.isActiveTeamMember(space.TeamId, cm.UserId) - if activeErr != nil { - return false, activeErr - } - found = active - return found, nil - }) - if err != nil { - return false, err - } - return found, nil -} - // archiveOrphanChannel archives a backing channel when a later step in space creation fails, // to avoid an orphaned channel. reason describes the step that failed; cause is its error. func (s *Service) archiveOrphanChannel(channelID, reason string, cause error) { @@ -120,16 +46,48 @@ func (s *Service) archiveOrphanChannel(channelID, reason string, cause error) { } } +// resolveSpaceScheme picks the backing-channel scheme that gives a space's plain members the +// requested capabilities. A set matching one of the seeded presets resolves to that preset's +// scheme; any other set resolves to a scheme in the shared pool keyed by the set itself, created +// on first use and thereafter shared by every space configured that way. +// +// pooledRoles is non-nil for the pooled case and names the roles the caller must write through +// configureSharedScheme once a backing channel points at the scheme — which is what lets core +// admit those writes. A preset's roles are seeded already and are never rewritten, so the preset +// case returns nil. Nothing here is owned by one space, so no failure path deletes a scheme. +func (s *Service) resolveSpaceScheme(capabilities []string) (schemeID string, pooledRoles *schemeRoles, err error) { + // Normalize before the permission set is persisted: the validators are dedup-tolerant, so + // without this a request repeating one allowlisted token would write that repetition verbatim + // into the generated role's Permissions column. + capabilities = model.NormalizeCapabilitySet(capabilities) + if presetName, ok := model.SchemeNameForDefaultCapabilities(capabilities); ok { + id, getErr := s.getSchemeIDByName(presetName) + if getErr != nil { + // Core seeds the presets; the plugin only reads them. A miss therefore means the server + // is unseeded, not that the caller named something that does not exist, so it is tagged + // to keep it out of the shared not-found translation. + if store.IsErrNotFound(getErr) { + return "", nil, fmt.Errorf("%w: %s", errPresetSchemeMissing, presetName) + } + return "", nil, getErr + } + return id, nil, nil + } + return s.getOrCreateSharedScheme(capabilities) +} + // CreateSpace creates a ChannelTypeSpace ("S") backing channel via pluginapi, saves the -// space row pointing at it, and adds the creator as a member. space.ChannelId must be empty — -// it is set from the created channel. If the row save fails, the backing channel is archived -// to avoid an orphan. +// space row pointing at it, and adds the creator as a member with SchemeAdmin. space.ChannelId +// must be empty — it is set from the created channel. defaultCapabilities nil defaults to the +// contribute preset; viewAccess nil defaults to open. If any step after the backing channel's +// creation fails, the backing channel is archived to avoid an orphan. Any scheme resolved along the +// way is left alone: presets and pooled schemes are shared, so none is this space's to remove. // // The channel create and the row save are separate systems with no shared transaction: a crash // between them leaves a real channel with no space row and no persisted marker to key a retry // off, so that window is cleaned up only by the best-effort compensating archive below (or an // operator, if that also fails). -func (s *Service) CreateSpace(space *model.Space, userID string) (*model.Space, *mmmodel.AppError) { +func (s *Service) CreateSpace(space *model.Space, userID string, defaultCapabilities *[]string, viewAccess *model.ViewAccess) (*model.SpaceWithAccess, *mmmodel.AppError) { if space == nil { return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.nil_input.app_error", nil, "", http.StatusBadRequest) } @@ -161,19 +119,48 @@ func (s *Service) CreateSpace(space *model.Space, userID string) (*model.Space, // Reject a creator who isn't an active member of the target team before standing up a backing // channel there — otherwise any authenticated user could create a real, visible channel in any // team by supplying its id. - active, memberErr := s.isActiveTeamMember(space.TeamId, userID) + member, memberErr := s.activeTeamMember(space.TeamId, userID) if memberErr != nil { // A transient/backend failure must not be misreported as "not a team member". return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.team_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(memberErr) } - if !active { + if member == nil { return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.not_team_member.app_error", nil, "", http.StatusForbidden) } + // Team membership alone does not authorize creating a space in it: the caller must also hold + // create_space on the team (or be sysadmin). Unlike the read/manage/delete gates, no space + // exists yet here, so there is nothing to existence-hide behind — a plain 403 is correct. + if !s.client.User.HasPermissionTo(userID, mmmodel.PermissionManageSystem) && + !s.teamPermGranted(member, userID, space.TeamId, mmmodel.PermissionCreateSpace) { + return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.forbidden.app_error", nil, "", http.StatusForbidden) + } // Sanitize before it's used as the channel Header below — Space.PreSave sanitizes it again on // the store.CreateSpace path, but that happens after the channel is already created. space.Description = mmmodel.SanitizeUnicode(space.Description) space.CreatorId = userID + va := model.ViewAccessOpen + if viewAccess != nil { + va = *viewAccess + } + if va != model.ViewAccessOpen && va != model.ViewAccessPrivate { + return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.invalid_view_access.app_error", nil, "", http.StatusBadRequest) + } + space.ViewAccess = va + + capabilities, _ := model.DefaultCapabilitiesForSchemeName(mmmodel.SchemeNameSpaceContribute) + if defaultCapabilities != nil { + capabilities = *defaultCapabilities + } + if capErr := model.ValidateDefaultCapabilities(capabilities); capErr != nil { + return nil, capErr + } + + schemeID, pooledRoles, schemeErr := s.resolveSpaceScheme(capabilities) + if schemeErr != nil { + return nil, schemeAppError("CreateSpace", schemeErr) + } + s.log.Debug("Creating space", "team_id", space.TeamId, "user_id", userID) backingChannel := &mmmodel.Channel{ @@ -181,6 +168,7 @@ func (s *Service) CreateSpace(space *model.Space, userID string) (*model.Space, Type: mmmodel.ChannelTypeSpace, Name: "space-" + mmmodel.NewId()[:20], CreatorId: userID, + SchemeId: &schemeID, } applySpaceFieldsToChannel(backingChannel, space) if err := s.client.Channel.Create(backingChannel); err != nil { @@ -193,6 +181,16 @@ func (s *Service) CreateSpace(space *model.Space, userID string) (*model.Space, return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.backing_channel_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } + // Only now that the backing channel points at the scheme will core admit role writes carrying + // space permissions, so a freshly created pooled scheme gets its exact permission sets here + // rather than at create time. + if pooledRoles != nil { + if cfgErr := s.configureSharedScheme(pooledRoles, capabilities); cfgErr != nil { + s.archiveOrphanChannel(backingChannel.Id, "pooled scheme role configuration failed", cfgErr) + return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.scheme_configure_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(cfgErr) + } + } + if _, addErr := s.client.Channel.AddMember(backingChannel.Id, userID); addErr != nil { // A space whose creator is not a member of its backing channel is a dead-end once per-space // membership gating lands (unreachable to everyone, creator included), so fail the create @@ -201,6 +199,20 @@ func (s *Service) CreateSpace(space *model.Space, userID string) (*model.Space, return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.add_member_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(addErr) } + // The creator is added as SchemeAdmin — both the scheme's resolved generated role names, never + // literals: on a scheme-backed channel core rejects the literal channel_user/channel_admin + // tokens. The base user-role token is required, not optional (core resets all scheme flags and + // rejects a string that leaves SchemeUser unset). + resolvedRoles, rolesErr := s.getSchemeRolesForChannel(backingChannel.Id) + if rolesErr != nil { + s.archiveOrphanChannel(backingChannel.Id, "scheme role lookup failed", rolesErr) + return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.scheme_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(rolesErr) + } + if _, roleErr := s.client.Channel.UpdateChannelMemberRoles(backingChannel.Id, userID, resolvedRoles.UserRoleName+" "+resolvedRoles.AdminRoleName); roleErr != nil { + s.archiveOrphanChannel(backingChannel.Id, "creator admin role assignment failed", roleErr) + return nil, mmmodel.NewAppError("CreateSpace", "app.space.create.admin_role_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(roleErr) + } + space.ChannelId = backingChannel.Id saved, err := s.store.CreateSpace(space) @@ -211,7 +223,17 @@ func (s *Service) CreateSpace(space *model.Space, userID string) (*model.Space, s.publishToChannels(wsEventSpaceCreated, map[string]any{"space_id": saved.Id}, saved.ChannelId) - return saved, nil + // Both halves of the access state are already settled here, so the wrapper is projected from + // what this call established rather than re-resolved: defaultCapabilities is the set just + // applied to the scheme, and the creator was assigned SchemeAdmin above — a step whose failure + // aborts the create, so reaching this point means the creator holds the full admin set. + wrapper := &model.SpaceWithAccess{ + Space: *saved, + DefaultCapabilities: model.NormalizeCapabilitySet(capabilities), + Capabilities: model.AdminEffectiveCapabilities(), + } + wrapper.EnsureCapabilities() + return wrapper, nil } // GetSpace returns the live space with the given ID. @@ -238,191 +260,240 @@ func (s *Service) GetSpaceWithDeleted(spaceID string) (*model.Space, *mmmodel.Ap return space, nil } -// CheckSpaceMembership verifies that userID is a member of the space's backing channel — and, -// when the space belongs to a team, still an active member of that team — and returns the -// fetched space on success so callers can avoid a redundant read. The team check exists because -// leaving a team does not remove a user from the team's space channels (core's team-leave sweep -// covers only regular message channels), so channel membership alone would let a former team -// member keep using known space and page IDs. When includeDeleted is true the space row is -// fetched regardless of its DeleteAt state, which is required for operations that run against a -// soft-deleted space (e.g. restore). Non-members, former team members, and non-existent spaces -// all yield the same 403 to prevent callers from probing space existence via the error code. A -// missing or malformed userID is rejected, never treated as a trusted caller; callers that -// legitimately act without a user must read the space directly instead. -func (s *Service) CheckSpaceMembership(spaceID, userID string, includeDeleted bool) (*model.Space, *mmmodel.AppError) { - if appErr := s.requireClient("CheckSpaceMembership", "space_id", spaceID, "user_id", userID); appErr != nil { - return nil, appErr - } - if !mmmodel.IsValidId(userID) { - return nil, mmmodel.NewAppError("CheckSpaceMembership", "app.space.access.invalid_user_id.app_error", nil, "", http.StatusBadRequest) - } - var space *model.Space - var getErr *mmmodel.AppError - if includeDeleted { - space, getErr = s.GetSpaceWithDeleted(spaceID) - } else { - space, getErr = s.GetSpace(spaceID) - } - if getErr != nil { - if getErr.StatusCode == http.StatusNotFound { - return nil, mmmodel.NewAppError("CheckSpaceMembership", "app.space.access.forbidden.app_error", nil, "", http.StatusForbidden).Wrap(getErr) - } - return nil, getErr - } - if space.TeamId != "" { - active, teamErr := s.isActiveTeamMember(space.TeamId, userID) - if teamErr != nil { - return nil, mmmodel.NewAppError("CheckSpaceMembership", "app.space.access.team_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(teamErr) - } - if !active { - return nil, mmmodel.NewAppError("CheckSpaceMembership", "app.space.access.forbidden.app_error", nil, "", http.StatusForbidden) - } - } - if _, err := s.client.Channel.GetMember(space.ChannelId, userID); err != nil { - if errors.Is(err, pluginapi.ErrNotFound) { - return nil, mmmodel.NewAppError("CheckSpaceMembership", "app.space.access.forbidden.app_error", nil, "", http.StatusForbidden).Wrap(err) - } - return nil, mmmodel.NewAppError("CheckSpaceMembership", "app.space.access.channel_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) +// spaceDefaultCapabilities returns space's current default capability set in wire form +// (read_page-free): the generated user role's stored permission set projected onto the capability +// vocabulary. The projection covers presets and pooled schemes alike, since a preset's generated +// user role carries exactly that preset's capabilities. +func (s *Service) spaceDefaultCapabilities(space *model.Space) ([]string, error) { + roles, err := s.getSchemeRolesForChannel(space.ChannelId) + if err != nil { + return nil, err } - return space, nil + return s.defaultCapabilitiesForRoles(roles) } -// ListSpaceMembers returns one page of space's members plus whether more members exist beyond -// it. page/perPage are normalized like every other paginated method (page and perPage both -// clamped). The pluginapi member listing is page-indexed rather than offset-based, so when the -// requested page comes back full a one-row probe at the next page's first slot decides has-more. -// space is the caller's already-fetched record (from its membership gate), so no re-read here. -func (s *Service) ListSpaceMembers(space *model.Space, page, perPage int) ([]*model.SpaceMember, bool, *mmmodel.AppError) { - if space == nil { - return nil, false, mmmodel.NewAppError("ListSpaceMembers", "app.space.get.invalid_id.app_error", nil, "", http.StatusBadRequest) - } - if appErr := s.requireClient("ListSpaceMembers", "space_id", space.Id); appErr != nil { - return nil, false, appErr +// spaceDefaultCapabilitiesFromChannel is spaceDefaultCapabilities for a caller that already holds +// the backing channel. +func (s *Service) spaceDefaultCapabilitiesFromChannel(channelID string, channel *mmmodel.Channel) ([]string, error) { + roles, err := s.schemeRolesFromChannel(channelID, channel) + if err != nil { + return nil, err } - page = ClampPage(page) - perPage = ClampPerPage(perPage) - channelMembers, err := s.client.Channel.ListMembers(space.ChannelId, page, perPage) + return s.defaultCapabilitiesForRoles(roles) +} + +// defaultCapabilitiesForRoles is spaceDefaultCapabilities for a caller that already holds the +// backing channel's scheme roles. +func (s *Service) defaultCapabilitiesForRoles(roles *schemeRoles) ([]string, error) { + perms, err := s.getRolePermissionsByName(roles.UserRoleName) if err != nil { - return nil, false, mmmodel.NewAppError("ListSpaceMembers", "app.space.list_members.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - } - members := make([]*model.SpaceMember, 0, len(channelMembers)) - for _, cm := range channelMembers { - members = append(members, &model.SpaceMember{UserId: cm.UserId}) - } - hasMore := false - if len(channelMembers) == perPage { - // A page of size 1 holds exactly one element, so its page index equals that element's - // offset: requesting page (page+1)*perPage at size 1 fetches precisely the first member - // beyond the current window. - probe, probeErr := s.client.Channel.ListMembers(space.ChannelId, (page+1)*perPage, 1) - if probeErr != nil { - return nil, false, mmmodel.NewAppError("ListSpaceMembers", "app.space.list_members.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(probeErr) - } - hasMore = len(probe) > 0 + return nil, err } - return members, hasMore, nil + return model.DefaultCapabilitiesFromPermissions(perms), nil +} + +// BuildSpaceWithAccess resolves the GET /spaces/{id} response wrapper: the space's default +// capability set plus the caller's own truthful, current effective capabilities — never a +// hypothetical post-join grant. A denied read yields the shared existence-hiding 403. +func (s *Service) BuildSpaceWithAccess(space *model.Space, userID string) (*model.SpaceWithAccess, *mmmodel.AppError) { + return s.buildSpaceWithAccess(space, userID, nil) } -// AddSpaceMember adds a user to space's backing channel. Any current space member may manage -// members (flat model; no per-space admin role yet). space is the caller's already-fetched -// record (from its membership gate), so no re-read here. -func (s *Service) AddSpaceMember(space *model.Space, userID string) (*model.SpaceMember, *mmmodel.AppError) { +// buildSpaceWithAccess is BuildSpaceWithAccess for a caller that already knows the space's default +// capability set. knownDefaults, when non-nil, is used instead of reading the scheme's roles back: +// a caller that just wrote those roles must not re-read them, since the read may be served by a +// lagging replica still carrying the pre-write set and would report the caller's own committed +// change as not having taken effect. The caller's effective capabilities are derived from the same +// value, so both fields describe one consistent state. +func (s *Service) buildSpaceWithAccess(space *model.Space, userID string, knownDefaults []string) (*model.SpaceWithAccess, *mmmodel.AppError) { if space == nil { - return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.get.invalid_id.app_error", nil, "", http.StatusBadRequest) - } - if !mmmodel.IsValidId(userID) { - return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.member.invalid_user_id.app_error", nil, "", http.StatusBadRequest) + return nil, mmmodel.NewAppError("BuildSpaceWithAccess", "app.space.get.invalid_id.app_error", nil, "", http.StatusBadRequest) } - if appErr := s.requireClient("AddSpaceMember", "space_id", space.Id, "user_id", userID); appErr != nil { + if appErr := s.requireClient("BuildSpaceWithAccess", "space_id", space.Id, "user_id", userID); appErr != nil { return nil, appErr } - // Reject a target who is not an active member of the space's team before touching the - // backing channel. Core's channel-member add enforces the same integrity check but surfaces - // it as an opaque failure; checking here keeps the status code honest and guarantees every - // space member can pass the team half of the access gate — which the last-member guard in - // RemoveSpaceMember relies on when deciding who can still reach the space. - if space.TeamId != "" { - active, memberErr := s.isActiveTeamMember(space.TeamId, userID) - if memberErr != nil { - return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.member.team_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(memberErr) - } - if !active { - return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.member.not_team_member.app_error", nil, "", http.StatusForbidden) + // The read gate resolves first: a denied caller must get the existence-hiding 403 rather than + // whatever the default-capability lookup below would surface for a space it cannot see. + resolution, resErr := s.ResolveSpaceRead("BuildSpaceWithAccess", space, userID) + if resErr != nil { + return nil, resErr + } + if resolution == ReadDenied { + return nil, existenceHidingForbidden("BuildSpaceWithAccess") + } + defaultCapabilities := knownDefaults + if defaultCapabilities == nil { + var err error + defaultCapabilities, err = s.spaceDefaultCapabilities(space) + if err != nil { + return nil, storeAppError("BuildSpaceWithAccess", err) } } - member, err := s.client.Channel.AddMember(space.ChannelId, userID) - if err != nil { - // A missing target user is the caller's mistake, not a server fault. - if errors.Is(err, pluginapi.ErrNotFound) { - return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.member.user_not_found.app_error", nil, "", http.StatusNotFound).Wrap(err) + + var capabilities []string + switch resolution { + case ReadViaSysadmin: + capabilities = model.AdminEffectiveCapabilities() + case ReadViaMember: + member, memErr := s.client.Channel.GetMember(space.ChannelId, userID) + if memErr != nil { + return nil, mmmodel.NewAppError("BuildSpaceWithAccess", "app.space.access.channel_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(memErr) } - return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.add_member.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + capabilities = model.CapabilitiesFromMember(member.ExplicitRoles, member.SchemeAdmin, member.SchemeGuest, defaultCapabilities).Effective + case ReadViaOpenFallthrough: + capabilities = []string{model.CapabilityReadPage} + default: + return nil, existenceHidingForbidden("BuildSpaceWithAccess") } - s.publishToChannels(wsEventSpaceMemberAdded, map[string]any{"space_id": space.Id, "user_id": member.UserId}, space.ChannelId) - return &model.SpaceMember{UserId: member.UserId}, nil + + wrapper := &model.SpaceWithAccess{Space: *space, DefaultCapabilities: defaultCapabilities, Capabilities: capabilities} + wrapper.EnsureCapabilities() + return wrapper, nil } -// RemoveSpaceMember removes a user from space's backing channel. The last member who can still -// reach the space cannot be removed: membership is the only gate on every space and page route, -// so a space with no reachable member — and every page in it — would be permanently unreachable -// through the plugin API (there is no admin bypass, and adding a member back requires the caller -// to already be one). Reachable means passing the full access gate, so for a team space a member -// who has since left the team does not count — see hasOtherAuthorizedMember. space is the -// caller's already-fetched record (from its membership gate), so no re-read here. -func (s *Service) RemoveSpaceMember(space *model.Space, userID string) *mmmodel.AppError { +// SetSpaceDefaultCapabilities changes space's default capability set: a set matching a seeded +// preset repoints the backing channel at that preset's scheme; any other set repoints it at the +// pooled scheme for that capability set, created on first use and shared by every space configured +// the same way. The superseded scheme is left in place — no scheme belongs to a single space, so +// there is nothing to retire. The repoint goes through pluginapi Channel.Update (not a store-direct write) so +// core's member-cache invalidation runs and the new scheme takes effect on the next permission +// check, rather than when the cache expires. +func (s *Service) SetSpaceDefaultCapabilities(space *model.Space, capabilities []string, actingUserID string) (*model.SpaceWithAccess, *mmmodel.AppError) { if space == nil { - return mmmodel.NewAppError("RemoveSpaceMember", "app.space.get.invalid_id.app_error", nil, "", http.StatusBadRequest) + return nil, mmmodel.NewAppError("SetSpaceDefaultCapabilities", "app.space.get.invalid_id.app_error", nil, "", http.StatusBadRequest) } - if !mmmodel.IsValidId(userID) { - return mmmodel.NewAppError("RemoveSpaceMember", "app.space.member.invalid_user_id.app_error", nil, "", http.StatusBadRequest) + if appErr := model.ValidateDefaultCapabilities(capabilities); appErr != nil { + return nil, appErr } - if appErr := s.requireClient("RemoveSpaceMember", "space_id", space.Id, "user_id", userID); appErr != nil { - return appErr + if appErr := s.requireClient("SetSpaceDefaultCapabilities", "space_id", space.Id); appErr != nil { + return nil, appErr } - // The member-list read and the removal below are separate calls, so on their own two - // concurrent removals of a two-member space's remaining members could each pass the - // last-member guard and leave the space memberless — unreachable through the plugin API. - // The space-scoped advisory lock serializes the guard and the removal as one unit. + + // Whether the scheme was actually repointed. Both no-op branches below leave it false, so a + // caller resubmitting the set the space already carries produces no space_updated broadcast. + var repointed bool + // The normalized set the space carries once this returns, carried out of the closure so the + // response is projected from it rather than re-read from the roles just written. + var requested []string lockErr := s.store.WithSpaceMembershipLock(space.Id, func() error { - // Removing a non-member alongside a sole reachable member falls through the guard: the - // DeleteMember call below reports that failure. - hasOther, guardErr := s.hasOtherAuthorizedMember(space, userID) - if guardErr != nil { - return mmmodel.NewAppError("RemoveSpaceMember", "app.space.remove_member.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(guardErr) + channel, chanErr := s.client.Channel.GetChannelOfType(space.ChannelId, mmmodel.ChannelTypeSpace) + if chanErr != nil { + return mmmodel.NewAppError("SetSpaceDefaultCapabilities", "app.space.access.channel_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(chanErr) } - if !hasOther { - return mmmodel.NewAppError("RemoveSpaceMember", "app.space.remove_member.last_member.app_error", nil, "", http.StatusConflict) + if channel == nil || channel.SchemeId == nil { + return mmmodel.NewAppError("SetSpaceDefaultCapabilities", "app.space.default_capabilities.channel_scheme_missing.app_error", nil, "", http.StatusInternalServerError) + } + currentSchemeID := *channel.SchemeId + // Project the superseded scheme's capability set from the channel already in hand, before + // the repoint below rewrites which scheme the channel points at. A failure here only costs + // the no-op shortcut, so it is carried as a value rather than failing the whole operation. + liveCapabilities, liveCapabilitiesErr := s.spaceDefaultCapabilitiesFromChannel(space.ChannelId, channel) + + requested = model.NormalizeCapabilitySet(capabilities) + _, requestedIsPreset := model.SchemeNameForDefaultCapabilities(requested) + + // An unchanged non-preset set is settled here, on the projected capabilities, so the no-op + // costs no scheme resolution at all. A preset request is left to the id comparison below, + // which settles it by scheme identity — the projection cannot, because a pooled scheme + // whose roles were never configured projects to the same empty set as the read-only preset, + // and shortcutting there would strand the space on that unconfigured scheme with no way to + // move off it. + if !requestedIsPreset && liveCapabilitiesErr == nil && slices.Equal(liveCapabilities, requested) { + return nil + } + + targetSchemeID, pooledRoles, schemeErr := s.resolveSpaceScheme(requested) + if schemeErr != nil { + return schemeAppError("SetSpaceDefaultCapabilities", schemeErr) } - if err := s.client.Channel.DeleteMember(space.ChannelId, userID); err != nil { - // A target user who isn't a member (or doesn't exist) is the caller's mistake, - // not a server fault. - if errors.Is(err, pluginapi.ErrNotFound) { - return mmmodel.NewAppError("RemoveSpaceMember", "app.space.member.user_not_found.app_error", nil, "", http.StatusNotFound).Wrap(err) + if targetSchemeID == currentSchemeID { + // The channel already points at the target scheme, so there is no repoint to perform. + // The roles behind that scheme are not necessarily written, though: an earlier run + // interrupted between the repoint below and its configure leaves the space resolving + // against core's default channel baseline, and every later submission of the same set + // would take this branch and never reach that configure. Running it here is what lets + // resubmitting the intended set recover such a space; it is a no-op in effect once the + // permission sets already match. + if pooledRoles != nil { + if cfgErr := s.configureSharedScheme(pooledRoles, requested); cfgErr != nil { + return schemeAppError("SetSpaceDefaultCapabilities", cfgErr) + } + } + return nil + } + + channel.SchemeId = &targetSchemeID + if updErr := s.client.Channel.Update(channel); updErr != nil { + return mmmodel.NewAppError("SetSpaceDefaultCapabilities", "app.space.default_capabilities.repoint_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(updErr) + } + repointed = true + + // The repoint above is what lets core admit role writes carrying space permissions, so a + // pooled scheme's roles are written only now. A failure leaves the space on a scheme whose + // roles may still hold core's default channel baseline, so the repoint is undone. + // + // Between the repoint and the configure below, a concurrent page-permission check in this + // space resolves against the new scheme's roles, which on the pool's first use still hold + // that baseline, so a member can be denied a page write they hold. The membership lock does + // not close this: it serializes other membership and scheme mutations, not ordinary + // permission checks, which read core's roles directly. The ordering is forced — core admits + // a role write carrying space permissions only once a channel already references the scheme + // — so the window is accepted rather than closed. It is bounded by the role writes below, + // self-heals, and does not recur once the pooled scheme is configured. + if pooledRoles != nil { + if cfgErr := s.configureSharedScheme(pooledRoles, requested); cfgErr != nil { + channel.SchemeId = ¤tSchemeID + if rollbackErr := s.client.Channel.Update(channel); rollbackErr != nil { + s.log.Error("failed to restore the previous space scheme after a failed scheme configuration; the space is left on a scheme whose roles may be unconfigured", "channel_id", space.ChannelId, "scheme_id", targetSchemeID, "previous_scheme_id", currentSchemeID, "err", rollbackErr) + // Distinct from the plain configure failure below: the space is left on the new + // scheme with roles that may be unconfigured, which an operator has to resolve. + // Reporting both as one id makes that state indistinguishable from the recovered + // case in monitoring. + return mmmodel.NewAppError("SetSpaceDefaultCapabilities", "app.space.default_capabilities.scheme_configure_rollback_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(cfgErr) + } + repointed = false + return mmmodel.NewAppError("SetSpaceDefaultCapabilities", "app.space.default_capabilities.scheme_configure_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(cfgErr) } - return mmmodel.NewAppError("RemoveSpaceMember", "app.space.remove_member.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } + + // The superseded scheme is left in place: presets and pooled schemes alike are shared by + // every space expressing that capability set, so none is this space's to delete. return nil }) if lockErr != nil { - var appErr *mmmodel.AppError - if errors.As(lockErr, &appErr) { - return appErr + return nil, membershipLockAppError("SetSpaceDefaultCapabilities", lockErr) + } + + // The response is projected from the set written under the lock, not read back from the roles + // this call just wrote: those reads go through core, which serves them from a replica that on a + // lagging one still carries the pre-update roles and would report the caller's own committed + // change as not having taken effect. Matches SetSpaceMemberCapabilities, which projects from the + // member its role update returned for the same reason. + fresh, getErr := s.GetSpace(space.Id) + if getErr != nil { + // The scheme repoint already committed, so re-reporting this as a failure would misreport + // success as an error; project the response from the requested set and the pre-update + // space instead, still firing the WS event. + s.log.Warn("SetSpaceDefaultCapabilities: post-commit re-read failed; responding from the requested set", "space_id", space.Id, "err", getErr) + if repointed { + s.publishToChannels(wsEventSpaceUpdated, map[string]any{"space_id": space.Id}, space.ChannelId) } - // The store's own errors — notably the retryable ErrConflict a lock-acquisition timeout - // yields — keep their conventional status codes rather than collapsing to a 500. - return storeAppError("RemoveSpaceMember", lockErr) - } - payload := map[string]any{"space_id": space.Id, "user_id": userID} - s.publishToChannels(wsEventSpaceMemberRemoved, payload, space.ChannelId) - // The removed user has already left the backing channel, so the channel-scoped broadcast - // above never reaches them; send the event to their own connections directly. - s.publishToUser(wsEventSpaceMemberRemoved, payload, userID) - return nil + return s.buildSpaceWithAccess(space, actingUserID, requested) + } + if repointed { + s.publishToChannels(wsEventSpaceUpdated, map[string]any{"space_id": fresh.Id}, fresh.ChannelId) + } + return s.buildSpaceWithAccess(fresh, actingUserID, requested) } -// GetSpacesForTeam returns one page of a team's live spaces, plus whether more exist beyond -// it. userID is verified to be a team member and the result is filtered to spaces whose -// backing channel the caller belongs to, matching the membership gate on single-space reads. +// GetSpaceMembers, AddSpaceMember, SetSpaceMemberCapabilities, and RemoveSpaceMember live in +// space_members.go alongside the escalation and last-admin guards. + +// GetSpacesForTeam returns one page of a team's live spaces, plus whether more exist beyond it. +// userID must be an active team member holding team read_space (the list-entry gate; every +// team_user holds it by default). The result is the union of spaces the caller is a +// backing-channel member of and open spaces the caller can reach on the same terms single-space +// read admits them: the caller holds team read_public_channel, and compliance mode is off. func (s *Service) GetSpacesForTeam(teamID, userID string, page, perPage int) ([]*model.Space, bool, *mmmodel.AppError) { if !mmmodel.IsValidId(teamID) { return nil, false, mmmodel.NewAppError("GetSpacesForTeam", "app.space.get_for_team.invalid_team_id.app_error", nil, "", http.StatusBadRequest) @@ -434,14 +505,20 @@ func (s *Service) GetSpacesForTeam(teamID, userID string, page, perPage int) ([] if appErr := s.requireClient("GetSpacesForTeam", "team_id", teamID, "user_id", userID); appErr != nil { return nil, false, appErr } - active, memberErr := s.isActiveTeamMember(teamID, userID) + // The membership resolved here answers all three questions below. + member, memberErr := s.activeTeamMember(teamID, userID) if memberErr != nil { return nil, false, mmmodel.NewAppError("GetSpacesForTeam", "app.space.get_for_team.team_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(memberErr) } - if !active { + if member == nil { return nil, false, mmmodel.NewAppError("GetSpacesForTeam", "app.space.get_for_team.not_team_member.app_error", nil, "", http.StatusForbidden) } - spaces, err := s.store.GetSpacesForTeam(teamID, userID, offset, limit) + if !s.client.User.HasPermissionTo(userID, mmmodel.PermissionManageSystem) && + !s.teamPermGranted(member, userID, teamID, mmmodel.PermissionReadSpace) { + return nil, false, mmmodel.NewAppError("GetSpacesForTeam", "app.space.get_for_team.forbidden.app_error", nil, "", http.StatusForbidden) + } + callerHasOpenFallthrough := s.hasOpenTeamFallthrough(member, userID, teamID) + spaces, err := s.store.GetSpacesForTeam(teamID, userID, callerHasOpenFallthrough, offset, limit) if err != nil { return nil, false, storeAppError("GetSpacesForTeam", err) } @@ -464,6 +541,9 @@ func normalizeAndValidateSpacePatch(where string, patch *model.SpacePatch) *mmmo } patch.Title = &normalized } + if patch.ViewAccess != nil && *patch.ViewAccess != model.ViewAccessOpen && *patch.ViewAccess != model.ViewAccessPrivate { + return mmmodel.NewAppError(where, "app.space.update.invalid_view_access.app_error", nil, "", http.StatusBadRequest) + } description, icon := "", "" if patch.Description != nil { description = *patch.Description @@ -477,12 +557,19 @@ func normalizeAndValidateSpacePatch(where string, patch *model.SpacePatch) *mmmo // UpdateSpace applies the non-nil fields of patch onto the space and saves it. A non-nil // field (including an empty string) overwrites the current value, so a field can be cleared. // Optimistic-locked on expectedUpdateAt: the caller passes the UpdateAt it last read, and a stale -// baseline yields a conflict unless force overrides it with last-write-wins; a nil -// expectedUpdateAt without force is rejected. The store merges the patch into the current row, -// so a forced update overwrites only the fields the patch supplies — concurrent changes to -// other fields survive. space is the caller's already-fetched record (from its +// baseline yields a conflict unless force overrides the check and applies the update anyway; a +// nil expectedUpdateAt without force is rejected. The store merges the patch into the current +// row, so a forced update overwrites only the fields the patch supplies — concurrent changes to +// other fields are preserved. space is the caller's already-fetched record (from its // membership gate); only its Id is used here. -func (s *Service) UpdateSpace(space *model.Space, patch *model.SpacePatch, expectedUpdateAt *int64, force bool) (*model.Space, *mmmodel.AppError) { +// +// A patch that changes ViewAccess requires RequireSpaceAdminOrSysadmin against the live row and is +// rejected when force=true. actingUserID is used only for that escalation check. +// +// Any patch on a space with a backing channel runs under the space's membership advisory lock (the +// same lock AutoJoinIfDefaultGranted and the last-admin/last-member guards use), because it also +// drives the channel-metadata sync below, which must not race a concurrent membership change. +func (s *Service) UpdateSpace(space *model.Space, patch *model.SpacePatch, expectedUpdateAt *int64, force bool, actingUserID string) (*model.Space, *mmmodel.AppError) { if space == nil { return nil, mmmodel.NewAppError("UpdateSpace", "app.space.update.invalid_id.app_error", nil, "", http.StatusBadRequest) } @@ -492,24 +579,99 @@ func (s *Service) UpdateSpace(space *model.Space, patch *model.SpacePatch, expec if appErr := normalizeAndValidateSpacePatch("UpdateSpace", patch); appErr != nil { return nil, appErr } + if patch.ViewAccess != nil && force { + return nil, mmmodel.NewAppError("UpdateSpace", "app.space.update.view_access_force.app_error", nil, "", http.StatusBadRequest) + } s.log.Debug("Updating space", "space_id", space.Id) - updated, err := s.store.UpdateSpace(space.Id, patch, mmmodel.SafeDereference(expectedUpdateAt), force) - if err != nil { - return nil, storeAppError("UpdateSpace", err) - } - if updated.ChannelId != "" && s.client != nil { - if chanErr := s.syncSpaceChannelMetadata(updated.Id); chanErr != nil { - // Deliberately not returned: the space row (the source of truth) committed, so failing - // the request would misreport a successful update, and retrying it would 409 on the - // now-stale baseline. The next successful UpdateSpace re-syncs the channel. Logged at - // Error so the resulting name/header divergence is visible to operators. - s.log.Error("UpdateSpace: failed to sync backing channel metadata; display name/header stale until the next update", "channel_id", updated.ChannelId, "space_id", updated.Id, "err", chanErr) + // A private->private no-op (the patch re-asserts the current value) must not attach + // member_count: only a genuine open->private transition sheds no members but exposes an + // admin to a count they should act on. Compare against the live row's prior value, not + // just the patch value. Carried out of apply so the count lookup and the broadcast it feeds + // run after the lock is released. + var flippedToPrivate bool + apply := func() (*model.Space, *mmmodel.AppError) { + var live *model.Space + if patch.ViewAccess != nil { + gotLive, liveErr := s.store.GetSpace(space.Id, false) + if liveErr != nil { + return nil, storeAppError("UpdateSpace", liveErr) + } + live = gotLive + if *patch.ViewAccess != live.ViewAccess { + if appErr := s.RequireSpaceAdminOrSysadmin("UpdateSpace", live, actingUserID); appErr != nil { + return nil, appErr + } + } } + updated, err := s.store.UpdateSpace(space.Id, patch, mmmodel.SafeDereference(expectedUpdateAt), force) + if err != nil { + return nil, storeAppError("UpdateSpace", err) + } + flippedToPrivate = patch.ViewAccess != nil && *patch.ViewAccess == model.ViewAccessPrivate && + live != nil && live.ViewAccess == model.ViewAccessOpen + // Stays under the lock: it projects the row this call just wrote onto the backing channel, + // so two concurrent updates must not interleave their syncs and leave the channel carrying + // the older title. + if updated.ChannelId != "" && s.client != nil { + if chanErr := s.syncSpaceChannelMetadata(updated.Id); chanErr != nil { + // Deliberately not returned: the space row (the source of truth) committed, so + // failing the request would misreport a successful update, and retrying it would + // 409 on the now-stale baseline. The next successful UpdateSpace re-syncs the + // channel. Logged at Error so the resulting name/header divergence is visible to + // operators. + s.log.Error("UpdateSpace: failed to sync backing channel metadata; display name/header stale until the next update", "channel_id", updated.ChannelId, "space_id", updated.Id, "err", chanErr) + } + } + return updated, nil + } + + // publish reports the update once the space-keyed lock is no longer held. The lock holds a + // dedicated connection for the whole closure, so the member-count lookup and the broadcast — + // neither of which the update's atomicity depends on — are kept out of it, matching every other + // membership-lock caller in this package. + publish := func(updated *model.Space) { + payload := map[string]any{"space_id": updated.Id} + if flippedToPrivate && s.client != nil { + // Privatizing does not shed members: every existing member (including anyone + // auto-joined earlier) stays. Surface the current count so the admin is prompted to + // prune via RemoveSpaceMember. + stats, statsErr := s.client.Channel.GetChannelStats(updated.ChannelId) + if statsErr != nil { + // The update itself committed, so this only affects the prompt: the event ships + // without member_count rather than failing a successful write. + s.log.Warn("UpdateSpace: member-count lookup failed; the view_access event omits member_count", "channel_id", updated.ChannelId, "space_id", updated.Id, "err", statsErr) + } else { + payload["member_count"] = stats.MemberCount + s.log.Debug("space view_access flipped to private", "space_id", updated.Id, "member_count", stats.MemberCount) + } + } + s.publishToChannels(wsEventSpaceUpdated, payload, updated.ChannelId) + } + + if space.ChannelId == "" { + updated, appErr := apply() + if appErr != nil { + return nil, appErr + } + publish(updated) + return updated, nil + } + var result *model.Space + lockErr := s.store.WithSpaceMembershipLock(space.Id, func() error { + r, appErr := apply() + if appErr != nil { + return appErr + } + result = r + return nil + }) + if lockErr != nil { + return nil, membershipLockAppError("UpdateSpace", lockErr) } - s.publishToChannels(wsEventSpaceUpdated, map[string]any{"space_id": updated.Id}, updated.ChannelId) - return updated, nil + publish(result) + return result, nil } // syncSpaceChannelMetadata projects the space's current Title and Description onto its backing diff --git a/server/app/space_access.go b/server/app/space_access.go new file mode 100644 index 0000000..4e57050 --- /dev/null +++ b/server/app/space_access.go @@ -0,0 +1,133 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "errors" + "net/http" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/pluginapi" + + "github.com/mattermost/mattermost-plugin-docs/server/model" +) + +// The low-level access primitives the space subsystem shares: client wiring, team-membership and +// team-permission resolution, and backing-channel member iteration. They belong to no single +// caller — space lifecycle (space.go), authorization (permissions.go), and membership +// (space_members.go) each build on them — so they live here rather than in whichever file happened +// to need them first. + +// requireClient rejects the operation when the pluginapi client is not wired, which every +// membership-gated space operation depends on. where identifies the calling operation for the +// log line and the returned AppError; kv are its extra log context pairs. +func (s *Service) requireClient(where string, kv ...any) *mmmodel.AppError { + if s.client != nil { + return nil + } + s.log.Error("pluginapi client not wired; denying access", append([]any{"operation", where}, kv...)...) + return mmmodel.NewAppError(where, "app.space.client_not_wired.app_error", nil, "", http.StatusInternalServerError) +} + +// activeTeamMember returns userID's membership in teamID, or nil when there is none. Core keeps +// removed team members as rows with DeleteAt set — and GetMember returns such a row without error +// — so a missing row and a soft-deleted row both read as "not a member". Space access must check +// this, not just backing-channel membership: leaving a team does not remove a user from the +// team's space channels, so channel membership alone would let a former team member keep using +// known space and page IDs. +// +// The membership carries the team roles, so a caller that needs both "is a member" and "holds a +// team permission" answers both from this one row via teamPermGranted. +func (s *Service) activeTeamMember(teamID, userID string) (*mmmodel.TeamMember, error) { + member, err := s.client.Team.GetMember(teamID, userID) + if err != nil { + if errors.Is(err, pluginapi.ErrNotFound) { + return nil, nil + } + return nil, err + } + if member.DeleteAt != 0 { + return nil, nil + } + return member, nil +} + +// isActiveTeamMember reports whether userID currently belongs to teamID, for callers that need +// only the answer and not the roles. +func (s *Service) isActiveTeamMember(teamID, userID string) (bool, error) { + member, err := s.activeTeamMember(teamID, userID) + return member != nil, err +} + +// teamPermGranted reports whether userID holds perm on teamID, answering from member's roles when +// that already settles it. member is a membership the caller has resolved, or nil. +// +// The roles on an already-resolved membership answer the granting case directly. A negative is not +// conclusive — core also honours a system-role grant the team roles cannot express — so it defers +// to core rather than denying, which keeps the outcome identical to calling HasPermissionToTeam +// directly. +func (s *Service) teamPermGranted(member *mmmodel.TeamMember, userID, teamID string, perm *mmmodel.Permission) bool { + if member != nil && s.client.User.RolesGrantPermission(member.GetRoles(), perm.Id) { + return true + } + return s.client.User.HasPermissionToTeam(userID, teamID, perm) +} + +// forEachChannelMember visits every member of channelID page by page. Iteration ends early +// when visit returns stop=true or an error; the error is returned as-is. +func (s *Service) forEachChannelMember(channelID string, visit func(cm *mmmodel.ChannelMember) (stop bool, err error)) error { + for page := 0; ; page++ { + members, err := s.client.Channel.ListMembers(channelID, page, PerPageMaximum) + if err != nil { + return err + } + for _, cm := range members { + stop, visitErr := visit(cm) + if visitErr != nil { + return visitErr + } + if stop { + return nil + } + } + if len(members) < PerPageMaximum { + return nil + } + } +} + +// otherAuthorizedMembers answers both reachability questions the membership guards ask — is there +// another member who can still reach the space, and is one of them an admin — in a single walk, +// disregarding excludeUserID (the member being demoted or removed). Former team members keep their +// channel-member rows after leaving the team, so counting raw rows would let the last reachable +// member be removed and leave the space stranded behind members who all fail the team half of the +// access gate. Iteration stops once both answers are known, and a row that cannot change either +// answer is skipped before its team lookup. +func (s *Service) otherAuthorizedMembers(space *model.Space, excludeUserID string) (anyMember, anyAdmin bool, err error) { + err = s.forEachChannelMember(space.ChannelId, func(cm *mmmodel.ChannelMember) (bool, error) { + if cm.UserId == excludeUserID { + return false, nil + } + // Once a reachable member is known, only an admin row can still teach us anything. + if anyMember && !cm.SchemeAdmin { + return false, nil + } + active, activeErr := s.isActiveTeamMember(space.TeamId, cm.UserId) + if activeErr != nil { + return false, activeErr + } + if !active { + return false, nil + } + anyMember = true + if cm.SchemeAdmin { + anyAdmin = true + } + return anyMember && anyAdmin, nil + }) + if err != nil { + return false, false, err + } + return anyMember, anyAdmin, nil +} diff --git a/server/app/space_members.go b/server/app/space_members.go new file mode 100644 index 0000000..cb388d7 --- /dev/null +++ b/server/app/space_members.go @@ -0,0 +1,343 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "errors" + "net/http" + "slices" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/pluginapi" + + "github.com/mattermost/mattermost-plugin-docs/server/model" +) + +// GetSpaceMembers returns one page of space's members plus whether more members exist beyond +// it, each projected to their effective/granted capabilities. page/perPage are normalized +// like every other paginated method (page and perPage both clamped). The pluginapi member +// listing is page-indexed rather than offset-based, so when the requested page comes back full a +// one-row probe at the next page's first slot decides has-more. space is the caller's +// already-fetched record, from its manage gate. +func (s *Service) GetSpaceMembers(space *model.Space, page, perPage int) ([]*model.SpaceMember, bool, *mmmodel.AppError) { + if space == nil { + return nil, false, mmmodel.NewAppError("GetSpaceMembers", "app.space.get.invalid_id.app_error", nil, "", http.StatusBadRequest) + } + if appErr := s.requireClient("GetSpaceMembers", "space_id", space.Id); appErr != nil { + return nil, false, appErr + } + defaultCapabilities, err := s.spaceDefaultCapabilities(space) + if err != nil { + return nil, false, storeAppError("GetSpaceMembers", err) + } + page = ClampPage(page) + perPage = ClampPerPage(perPage) + channelMembers, err := s.client.Channel.ListMembers(space.ChannelId, page, perPage) + if err != nil { + return nil, false, mmmodel.NewAppError("GetSpaceMembers", "app.space.get_members.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + members := make([]*model.SpaceMember, 0, len(channelMembers)) + for _, cm := range channelMembers { + members = append(members, toSpaceMember(cm, defaultCapabilities)) + } + hasMore := false + if len(channelMembers) == perPage { + // A page of size 1 holds exactly one element, so its page index equals that element's + // offset: requesting page (page+1)*perPage at size 1 fetches precisely the first member + // beyond the current window. + probe, probeErr := s.client.Channel.ListMembers(space.ChannelId, (page+1)*perPage, 1) + if probeErr != nil { + return nil, false, mmmodel.NewAppError("GetSpaceMembers", "app.space.get_members.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(probeErr) + } + hasMore = len(probe) > 0 + } + return members, hasMore, nil +} + +// toSpaceMember builds the wire representation of a channel member's capability state. +func toSpaceMember(cm *mmmodel.ChannelMember, defaultCapabilities []string) *model.SpaceMember { + mc := model.CapabilitiesFromMember(cm.ExplicitRoles, cm.SchemeAdmin, cm.SchemeGuest, defaultCapabilities) + member := &model.SpaceMember{ + UserId: cm.UserId, + Capabilities: mc.Effective, + GrantedCapabilities: mc.Granted, + IsAdmin: mc.IsAdmin, + IsGuest: mc.IsGuest, + } + member.EnsureCapabilities() + return member +} + +// AddSpaceMember adds a user to space's backing channel at the space default (SchemeUser, no +// per-member grants). space is the caller's already-fetched record, from its manage gate. +func (s *Service) AddSpaceMember(space *model.Space, userID string) (*model.SpaceMember, *mmmodel.AppError) { + if space == nil { + return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.get.invalid_id.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(userID) { + return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.member.invalid_user_id.app_error", nil, "", http.StatusBadRequest) + } + if appErr := s.requireClient("AddSpaceMember", "space_id", space.Id, "user_id", userID); appErr != nil { + return nil, appErr + } + // Reject a target who is not an active member of the space's team before touching the + // backing channel. Core's channel-member add enforces the same integrity check but surfaces + // it as an opaque failure; checking here keeps the status code honest and guarantees every + // space member can pass the team half of the access gate — which the last-authorized-member + // guard in RemoveSpaceMember relies on when deciding who can still reach the space. + active, memberErr := s.isActiveTeamMember(space.TeamId, userID) + if memberErr != nil { + return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.member.team_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(memberErr) + } + if !active { + return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.member.not_team_member.app_error", nil, "", http.StatusForbidden) + } + defaultCapabilities, err := s.spaceDefaultCapabilities(space) + if err != nil { + return nil, storeAppError("AddSpaceMember", err) + } + member, err := s.client.Channel.AddMember(space.ChannelId, userID) + if err != nil { + // A missing target user is the caller's mistake, not a server fault. + if errors.Is(err, pluginapi.ErrNotFound) { + return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.member.user_not_found.app_error", nil, "", http.StatusNotFound).Wrap(err) + } + return nil, mmmodel.NewAppError("AddSpaceMember", "app.space.add_member.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + payload := map[string]any{"space_id": space.Id, "user_id": member.UserId} + s.publishToChannels(wsEventSpaceMemberAdded, payload, space.ChannelId) + // Also delivered directly: the channel-scoped broadcast may not resolve a member added moments + // earlier, and the new member has no other signal that they now have the space. + s.publishToUser(wsEventSpaceMemberAdded, payload, member.UserId) + return toSpaceMember(member, defaultCapabilities), nil +} + +// requireNotLastAdmin rejects an operation that would leave space without an admin who can still +// reach it, disregarding excludeUserID (the member being demoted or removed). Callers run it inside +// the space-keyed membership lock, alongside the mutation it guards. where attributes both the +// lookup failure and the rejection to the calling operation. +func (s *Service) requireNotLastAdmin(where string, space *model.Space, excludeUserID string) *mmmodel.AppError { + _, otherAdmin, err := s.otherAuthorizedMembers(space, excludeUserID) + if err != nil { + return mmmodel.NewAppError(where, "app.space.member.admin_count_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + if !otherAdmin { + return mmmodel.NewAppError(where, "app.space.member.last_admin.app_error", nil, "", http.StatusConflict) + } + return nil +} + +// SetSpaceMemberCapabilities replaces targetUserID's per-member granted capability set. Callers +// must already hold manage-tier authority over the space (RequireSpaceAdminOrTeamPerm with +// manage_space); this method +// additionally enforces the self/admin escalation guard and the last-admin invariant. Guest +// members are rejected: they stay read-only via the scheme's guest role. actingUserID is the +// caller, used only to decide the self-escalation guard. +func (s *Service) SetSpaceMemberCapabilities(space *model.Space, targetUserID string, capabilities []string, actingUserID string) (*model.SpaceMember, *mmmodel.AppError) { + if space == nil { + return nil, mmmodel.NewAppError("SetSpaceMemberCapabilities", "app.space.get.invalid_id.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(targetUserID) { + return nil, mmmodel.NewAppError("SetSpaceMemberCapabilities", "app.space.member.invalid_user_id.app_error", nil, "", http.StatusBadRequest) + } + if appErr := model.ValidateGrantedCapabilities(capabilities); appErr != nil { + return nil, appErr + } + if appErr := s.requireClient("SetSpaceMemberCapabilities", "space_id", space.Id, "user_id", targetUserID); appErr != nil { + return nil, appErr + } + + requestedAdmin := slices.Contains(capabilities, model.CapabilityAdminSpace) + selfTargeted := targetUserID == actingUserID + + // The scheme-role read, the target's current admin status, the escalation-guard decision, and + // the admin count all read state that a concurrent SetSpaceDefaultCapabilities (repoints the + // channel's scheme) or SetSpaceMemberCapabilities/RemoveSpaceMember (the last-admin invariant) + // call could change: a stale read taken before the lock lets a concurrent default-capabilities + // repoint write a superseded scheme's role name, or a concurrent promotion/demotion flip admin + // status, mid-operation — so every one of them runs inside the space-keyed + // advisory lock, alongside the mutation itself. + var appErr *mmmodel.AppError + var newRoles string + var newSchemeAdmin bool + var resolvedRoles *schemeRoles + var updatedMember *mmmodel.ChannelMember + lockErr := s.store.WithSpaceMembershipLock(space.Id, func() error { + var rolesErr error + resolvedRoles, rolesErr = s.getSchemeRolesForChannel(space.ChannelId) + if rolesErr != nil { + appErr = storeAppError("SetSpaceMemberCapabilities", rolesErr) + return appErr + } + newRoles, newSchemeAdmin = model.RolesForCapabilities(capabilities, resolvedRoles.UserRoleName) + + target, memErr := s.client.Channel.GetMember(space.ChannelId, targetUserID) + if memErr != nil { + if errors.Is(memErr, pluginapi.ErrNotFound) { + appErr = mmmodel.NewAppError("SetSpaceMemberCapabilities", "app.space.member.user_not_found.app_error", nil, "", http.StatusNotFound).Wrap(memErr) + return appErr + } + appErr = mmmodel.NewAppError("SetSpaceMemberCapabilities", "app.space.access.channel_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(memErr) + return appErr + } + if target.SchemeGuest { + appErr = mmmodel.NewAppError("SetSpaceMemberCapabilities", "app.space.member.guest_not_assignable.app_error", nil, "", http.StatusBadRequest) + return appErr + } + + adminAffected := target.SchemeAdmin || requestedAdmin + if adminAffected || selfTargeted { + if e := s.RequireSpaceAdminOrSysadmin("SetSpaceMemberCapabilities", space, actingUserID); e != nil { + appErr = e + return e + } + } + + if target.SchemeAdmin && !newSchemeAdmin { + if e := s.requireNotLastAdmin("SetSpaceMemberCapabilities", space, targetUserID); e != nil { + appErr = e + return e + } + } + + roles := newRoles + if newSchemeAdmin { + roles = roles + " " + resolvedRoles.AdminRoleName + } + member, updErr := s.client.Channel.UpdateChannelMemberRoles(space.ChannelId, targetUserID, roles) + if updErr != nil { + appErr = mmmodel.NewAppError("SetSpaceMemberCapabilities", "app.space.member.update_capabilities_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(updErr) + return appErr + } + updatedMember = member + return nil + }) + if lockErr != nil { + if appErr != nil { + return nil, appErr + } + return nil, storeAppError("SetSpaceMemberCapabilities", lockErr) + } + + // The scheme roles read under the lock still describe this channel: only + // SetSpaceDefaultCapabilities repoints a channel's scheme, and it serializes behind the same + // space-keyed lock, so the default capability set is projected from them rather than re-read. + defaultCapabilities, defErr := s.defaultCapabilitiesForRoles(resolvedRoles) + if defErr != nil { + return nil, storeAppError("SetSpaceMemberCapabilities", defErr) + } + // The response is projected from the member the role update returned. Re-reading it would go to + // a replica, which on a lagging one still carries the pre-update roles and would report the + // caller's own committed change as not having taken effect. + result := toSpaceMember(updatedMember, defaultCapabilities) + + payload := map[string]any{"space_id": space.Id, "user_id": targetUserID} + // Delivered both ways deliberately: the channel-scoped broadcast covers observers, and the + // direct publish guarantees the target learns its own capabilities changed even if the + // channel-scoped resolution misses them on a space ("S") channel. + s.publishToUser(wsEventSpaceMemberCapabilitiesUpdated, payload, targetUserID) + s.publishToChannels(wsEventSpaceMemberCapabilitiesUpdated, payload, space.ChannelId) + return result, nil +} + +// RemoveSpaceMember removes a user from space's backing channel. Precedence: target existence +// resolves before the last-member/last-admin guards; an admin-target removal is additionally +// escalation-guarded (RequireSpaceAdminOrSysadmin) and last-admin-guarded, both under the same +// space-scoped advisory lock as SetSpaceMemberCapabilities' admin-revoke path. space is the +// caller's already-fetched record, from its gate. +func (s *Service) RemoveSpaceMember(space *model.Space, userID, actingUserID string) *mmmodel.AppError { + if space == nil { + return mmmodel.NewAppError("RemoveSpaceMember", "app.space.get.invalid_id.app_error", nil, "", http.StatusBadRequest) + } + if !mmmodel.IsValidId(userID) { + return mmmodel.NewAppError("RemoveSpaceMember", "app.space.member.invalid_user_id.app_error", nil, "", http.StatusBadRequest) + } + if appErr := s.requireClient("RemoveSpaceMember", "space_id", space.Id, "user_id", userID); appErr != nil { + return appErr + } + + // The target lookup, its admin status, the escalation-guard decision, and the admin count all + // read state a concurrent SetSpaceMemberCapabilities/RemoveSpaceMember call could change + // (the last-admin invariant) — a stale admin-status read taken before the lock lets a + // concurrent promotion flip it mid-operation — so every one of them runs inside + // the space-keyed advisory lock, alongside the mutation itself. This applies to self-removal + // too, since the last-admin invariant covers the sole admin's self-leave. + var appErr *mmmodel.AppError + lockErr := s.store.WithSpaceMembershipLock(space.Id, func() error { + target, memErr := s.client.Channel.GetMember(space.ChannelId, userID) + if memErr != nil { + if !errors.Is(memErr, pluginapi.ErrNotFound) { + appErr = mmmodel.NewAppError("RemoveSpaceMember", "app.space.access.channel_lookup_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(memErr) + return appErr + } + // Non-member target. Removing someone else means the caller already cleared the manage + // gate, so the space is not hidden from them and it is a plain 404, matching + // SetSpaceMemberCapabilities. + // + // Self-removal splits on ViewAccess. On a private space the caller is a non-member the + // read gate would have denied, so the existence-hiding 403 is what they must see. On an + // open space that gate admitted them by design and they can already read the space, so + // there is no existence left to hide and reporting the absent membership as 404 is both + // accurate and what the caller can act on. + if userID != actingUserID || space.ViewAccess == model.ViewAccessOpen { + appErr = mmmodel.NewAppError("RemoveSpaceMember", "app.space.member.user_not_found.app_error", nil, "", http.StatusNotFound).Wrap(memErr) + } else { + appErr = existenceHidingForbidden("RemoveSpaceMember") + } + return appErr + } + + // Resolved before the scan below so an unauthorized caller is rejected without running it. + if target.SchemeAdmin { + if e := s.RequireSpaceAdminOrSysadmin("RemoveSpaceMember", space, actingUserID); e != nil { + appErr = e + return e + } + } + // The last-admin and last-member invariants are answered from one walk: removing an admin + // needs both, and the admin set is a subset of the reachable set. + hasOther, hasOtherAdmin, guardErr := s.otherAuthorizedMembers(space, userID) + if guardErr != nil { + // Attributed to whichever invariant the caller is actually being held to, so the failure + // of the shared walk reports the same id each guard reported when it walked alone. + if target.SchemeAdmin { + appErr = mmmodel.NewAppError("RemoveSpaceMember", "app.space.member.admin_count_failed.app_error", nil, "", http.StatusInternalServerError).Wrap(guardErr) + } else { + appErr = mmmodel.NewAppError("RemoveSpaceMember", "app.space.remove_member.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(guardErr) + } + return appErr + } + if target.SchemeAdmin && !hasOtherAdmin { + appErr = mmmodel.NewAppError("RemoveSpaceMember", "app.space.member.last_admin.app_error", nil, "", http.StatusConflict) + return appErr + } + if !hasOther { + appErr = mmmodel.NewAppError("RemoveSpaceMember", "app.space.remove_member.last_member.app_error", nil, "", http.StatusConflict) + return appErr + } + if err := s.client.Channel.DeleteMember(space.ChannelId, userID); err != nil { + if errors.Is(err, pluginapi.ErrNotFound) { + appErr = mmmodel.NewAppError("RemoveSpaceMember", "app.space.member.user_not_found.app_error", nil, "", http.StatusNotFound).Wrap(err) + return appErr + } + appErr = mmmodel.NewAppError("RemoveSpaceMember", "app.space.remove_member.failed.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return appErr + } + return nil + }) + if lockErr != nil { + if appErr != nil { + return appErr + } + // The store's own errors — notably the retryable ErrConflict a lock-acquisition timeout + // yields — keep their conventional status codes rather than collapsing to a 500. + return storeAppError("RemoveSpaceMember", lockErr) + } + payload := map[string]any{"space_id": space.Id, "user_id": userID} + s.publishToChannels(wsEventSpaceMemberRemoved, payload, space.ChannelId) + // The removed user has already left the backing channel, so the channel-scoped broadcast + // above never reaches them; send the event to their own connections directly. + s.publishToUser(wsEventSpaceMemberRemoved, payload, userID) + return nil +} diff --git a/server/app/space_test.go b/server/app/space_test.go index c2f7304..a00f559 100644 --- a/server/app/space_test.go +++ b/server/app/space_test.go @@ -30,6 +30,8 @@ import ( func openTestServiceWithAPI(t *testing.T, mockAPI *plugintest.API) *testHarness { t.Helper() h := openTestService(t) + testutil.StubDefaultSpacePermissions(mockAPI) + testutil.StubPresetSchemes(mockAPI) mockAPI.On("GetConfig").Return(&mmmodel.Config{}).Maybe() // Mutations publish best-effort WS events through the client; tests that assert event // content override this with exact-argument expectations. @@ -50,15 +52,19 @@ func openTestServiceWithAPI(t *testing.T, mockAPI *plugintest.API) *testHarness Return(&mmmodel.TeamMember{}, nil).Maybe() // plugintest flattens a log call's variadic pairs into the mock's argument list, so a stub only // matches calls with exactly that many arguments. Cover each shape the service emits: LogWarn - // with message plus two key/value pairs (CheckSpaceMembership/GetSpacesForTeam client-not-wired + // with message plus two key/value pairs (ResolveSpaceRead/GetSpacesForTeam client-not-wired // denials), LogWarn with message plus three pairs (DeleteSpace, restoreSpaceChannel), and // LogError with message plus four (archiveOrphanChannel). mockAPI.On("LogWarn", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return().Maybe() mockAPI.On("LogWarn", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return().Maybe() - // LogError shapes: message plus three pairs (UpdateSpace's channel-metadata sync failure) - // and message plus four pairs (archiveOrphanChannel). + // LogError shapes: message plus two pairs (the custom-scheme retire failures), plus three + // pairs (UpdateSpace's channel-metadata sync failure) and plus four (archiveOrphanChannel). + mockAPI.On("LogError", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return().Maybe() mockAPI.On("LogError", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return().Maybe() mockAPI.On("LogError", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return().Maybe() + // CreateSpace assigns the creator SchemeAdmin via the scheme's resolved role-name string; no + // test asserts the exact roles argument, so a wildcard catch-all covers every create. + mockAPI.On("UpdateChannelMemberRoles", mock.Anything, mock.Anything, mock.Anything).Return(&mmmodel.ChannelMember{}, nil).Maybe() client := pluginapi.NewClient(mockAPI, nil) h.svc = app.New(h.store, &client.Log, client) return h @@ -74,6 +80,7 @@ func TestServiceCreateSpace_BackingChannel(t *testing.T) { userID := mmmodel.NewId() backingChannelID := mmmodel.NewId() + testutil.MustSeedChannelScheme(t, mockAPI, backingChannelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.MatchedBy(func(ch *mmmodel.Channel) bool { return ch.Type == mmmodel.ChannelTypeSpace && ch.TeamId == teamID })).Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) @@ -81,7 +88,7 @@ func TestServiceCreateSpace_BackingChannel(t *testing.T) { space := &model.Space{TeamId: teamID, Title: "Test Space"} - saved, appErr := h.svc.CreateSpace(space, userID) + saved, appErr := h.svc.CreateSpace(space, userID, nil, nil) require.Nil(t, appErr) require.Equal(t, backingChannelID, saved.ChannelId) require.Equal(t, userID, saved.CreatorId) @@ -93,6 +100,48 @@ func TestServiceCreateSpace_BackingChannel(t *testing.T) { mockAPI.AssertExpectations(t) } +// TestServiceCreateSpace_PresetSchemeMissing verifies an unseeded server is reported as a server +// fault rather than as a missing space. Core seeds the preset schemes; a space create that cannot +// find one says nothing about what the caller asked for, so it must not share the not-found key +// every ordinary row lookup returns. +func TestServiceCreateSpace_PresetSchemeMissing(t *testing.T) { + mockAPI := &plugintest.API{} + // Registered ahead of the harness, whose StubPresetSchemes would otherwise match first. + mockAPI.On("GetSchemeByName", mmmodel.SchemeNameSpaceContribute). + Return((*mmmodel.Scheme)(nil), &mmmodel.AppError{StatusCode: http.StatusNotFound}) + h := openTestServiceWithAPI(t, mockAPI) + + _, appErr := h.svc.CreateSpace(&model.Space{TeamId: mmmodel.NewId(), Title: "Unseeded"}, mmmodel.NewId(), nil, nil) + + require.NotNil(t, appErr) + require.Equal(t, http.StatusInternalServerError, appErr.StatusCode) + require.Equal(t, "app.space.preset_scheme_missing.app_error", appErr.Id) + mockAPI.AssertNotCalled(t, "CreateChannel") +} + +// TestServiceCreateSpace_SchemeDenialKeepsStatus verifies a refusal core issues against the scheme +// API — a license gate, or the permissions migration still running — reaches the caller with the +// status core chose. Reporting it as a 500 would hide a condition the operator can act on. +func TestServiceCreateSpace_SchemeDenialKeepsStatus(t *testing.T) { + mockAPI := &plugintest.API{} + mockAPI.On("GetSchemeByName", mock.AnythingOfType("string")). + Return((*mmmodel.Scheme)(nil), &mmmodel.AppError{StatusCode: http.StatusNotFound}) + // The status and id core's own gate returns: minting a custom scheme without the licence for it + // is reported as not-implemented, not as a permission denial. + mockAPI.On("CreateScheme", mock.AnythingOfType("*model.Scheme")). + Return((*mmmodel.Scheme)(nil), &mmmodel.AppError{StatusCode: http.StatusNotImplemented, Id: "api.scheme.create_scheme.license.error"}) + h := openTestServiceWithAPI(t, mockAPI) + + // A single capability matches no preset, so the create resolves through the shared pool. + capabilities := []string{model.CapabilityCreatePage} + _, appErr := h.svc.CreateSpace(&model.Space{TeamId: mmmodel.NewId(), Title: "Unlicensed"}, mmmodel.NewId(), &capabilities, nil) + + require.NotNil(t, appErr) + require.Equal(t, http.StatusNotImplemented, appErr.StatusCode) + require.Equal(t, "api.scheme.create_scheme.license.error", appErr.Id) + mockAPI.AssertNotCalled(t, "CreateChannel") +} + // TestServiceCreateSpace_ChannelIdRejected verifies a caller-supplied ChannelId is rejected // before any backing-channel side effect. func TestServiceCreateSpace_ChannelIdRejected(t *testing.T) { @@ -101,7 +150,7 @@ func TestServiceCreateSpace_ChannelIdRejected(t *testing.T) { space := &model.Space{ChannelId: mmmodel.NewId(), TeamId: mmmodel.NewId(), Title: "Bad Space"} - _, appErr := h.svc.CreateSpace(space, mmmodel.NewId()) + _, appErr := h.svc.CreateSpace(space, mmmodel.NewId(), nil, nil) require.NotNil(t, appErr) require.Equal(t, 400, appErr.StatusCode) mockAPI.AssertNotCalled(t, "CreateChannel") @@ -117,7 +166,7 @@ func TestServiceCreateSpace_ChannelCreationFails(t *testing.T) { mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(nil, &mmmodel.AppError{Message: "boom", StatusCode: http.StatusInternalServerError}) - _, appErr := h.svc.CreateSpace(&model.Space{TeamId: mmmodel.NewId(), Title: "Doomed"}, mmmodel.NewId()) + _, appErr := h.svc.CreateSpace(&model.Space{TeamId: mmmodel.NewId(), Title: "Doomed"}, mmmodel.NewId(), nil, nil) require.NotNil(t, appErr) require.Equal(t, "app.space.create.backing_channel_failed.app_error", appErr.Id) mockAPI.AssertNotCalled(t, "AddChannelMember") @@ -133,6 +182,8 @@ func TestServiceCreateSpace_ReplicaConfiguredSucceeds(t *testing.T) { // expectations cannot be overridden, so this test wires its mocks from scratch. mockAPI := &plugintest.API{} h := openTestService(t) + testutil.StubDefaultSpacePermissions(mockAPI) + testutil.StubPresetSchemes(mockAPI) mockAPI.On("GetConfig"). Return(&mmmodel.Config{SqlSettings: mmmodel.SqlSettings{DataSourceReplicas: []string{"replica"}}}).Maybe() mockAPI.On("LogDebug", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return().Maybe() @@ -142,18 +193,21 @@ func TestServiceCreateSpace_ReplicaConfiguredSucceeds(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() + testutil.MustSeedChannelScheme(t, mockAPI, channelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: channelID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", channelID, userID).Return(&mmmodel.ChannelMember{}, nil) + mockAPI.On("UpdateChannelMemberRoles", channelID, userID, mock.Anything).Return(&mmmodel.ChannelMember{}, nil) client := pluginapi.NewClient(mockAPI, nil) h.svc = app.New(h.store, &client.Log, client) - saved, appErr := h.svc.CreateSpace(&model.Space{TeamId: mmmodel.NewId(), Title: "Replicated"}, userID) + saved, appErr := h.svc.CreateSpace(&model.Space{TeamId: mmmodel.NewId(), Title: "Replicated"}, userID, nil, nil) require.Nil(t, appErr) require.Equal(t, channelID, saved.ChannelId) mockAPI.AssertNotCalled(t, "GetChannel", channelID) mockAPI.AssertNotCalled(t, "DeleteChannel", channelID) + mockAPI.AssertExpectations(t) } // TestServiceCreateSpace_InvalidInput verifies the up-front validations reject before any @@ -169,7 +223,7 @@ func TestServiceCreateSpace_InvalidInput(t *testing.T) { mockAPI := &plugintest.API{} h := openTestServiceWithAPI(t, mockAPI) - _, appErr := h.svc.CreateSpace(space, mmmodel.NewId()) + _, appErr := h.svc.CreateSpace(space, mmmodel.NewId(), nil, nil) require.NotNil(t, appErr) require.Equal(t, 400, appErr.StatusCode) mockAPI.AssertNotCalled(t, "CreateChannel") @@ -180,7 +234,7 @@ func TestServiceCreateSpace_InvalidInput(t *testing.T) { mockAPI := &plugintest.API{} h := openTestServiceWithAPI(t, mockAPI) - _, appErr := h.svc.CreateSpace(nil, mmmodel.NewId()) + _, appErr := h.svc.CreateSpace(nil, mmmodel.NewId(), nil, nil) require.NotNil(t, appErr) require.Equal(t, 400, appErr.StatusCode) mockAPI.AssertNotCalled(t, "CreateChannel") @@ -202,7 +256,7 @@ func TestServiceCreateSpace_NotTeamMember(t *testing.T) { mockAPI.On("GetTeamMember", teamID, userID). Return(nil, &mmmodel.AppError{Message: "not a member", StatusCode: http.StatusNotFound}) - _, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Not Allowed"}, userID) + _, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Not Allowed"}, userID, nil, nil) require.NotNil(t, appErr) require.Equal(t, http.StatusForbidden, appErr.StatusCode) require.Equal(t, "app.space.create.not_team_member.app_error", appErr.Id) @@ -224,38 +278,35 @@ func TestServiceCreateSpace_FormerTeamMemberBlocked(t *testing.T) { mockAPI.On("GetTeamMember", teamID, userID). Return(&mmmodel.TeamMember{TeamId: teamID, UserId: userID, DeleteAt: 1}, nil) - _, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Not Allowed"}, userID) + _, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Not Allowed"}, userID, nil, nil) require.NotNil(t, appErr) require.Equal(t, http.StatusForbidden, appErr.StatusCode) require.Equal(t, "app.space.create.not_team_member.app_error", appErr.Id) mockAPI.AssertNotCalled(t, "CreateChannel") } -// TestCheckSpaceMembership_FormerTeamMemberBlocked verifies that access to a team's space ends -// with team membership: leaving a team does not remove the user from the space's backing -// channel, so a former team member still holds a ChannelMember row — the team gate (which must -// read DeleteAt, since core returns removed memberships without error) is what blocks them. -func TestCheckSpaceMembership_FormerTeamMemberBlocked(t *testing.T) { - h := openTestService(t) +// TestResolveSpaceRead_FormerTeamMemberDenied verifies that access to a team's space ends with +// team membership: leaving a team does not remove the user from the space's backing channel, so a +// former team member still holds a ChannelMember row — the team gate (which must read DeleteAt, +// since core returns removed memberships without error) is what blocks them. +func TestResolveSpaceRead_FormerTeamMemberDenied(t *testing.T) { mockAPI := &plugintest.API{} - mockAPI.On("GetConfig").Return(&mmmodel.Config{}).Maybe() - client := pluginapi.NewClient(mockAPI, nil) - h.svc = app.New(h.store, &client.Log, client) - teamID := mmmodel.NewId() userID := mmmodel.NewId() - space := seedSpaceForTeam(t, h.store, mmmodel.NewId(), teamID) - + // Registered before the harness, whose GetTeamMember catch-all returns an active membership: + // mock.Mock matches expectations in registration order. mockAPI.On("GetTeamMember", teamID, userID). Return(&mmmodel.TeamMember{TeamId: teamID, UserId: userID, DeleteAt: 1}, nil) + h := openTestServiceWithAPI(t, mockAPI) - _, appErr := h.svc.CheckSpaceMembership(space.Id, userID, false) - require.NotNil(t, appErr) - require.Equal(t, http.StatusForbidden, appErr.StatusCode) - require.Equal(t, "app.space.access.forbidden.app_error", appErr.Id) - // The team gate blocks before the channel membership is ever consulted, so the lingering + space := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), teamID) + + resolution, appErr := h.svc.ResolveSpaceRead("test", space, userID) + require.Nil(t, appErr) + require.Equal(t, app.ReadDenied, resolution) + // The team gate blocks before any channel-scoped permission is consulted, so the lingering // ChannelMember row is irrelevant. - mockAPI.AssertNotCalled(t, "GetChannelMember", mock.Anything, mock.Anything) + mockAPI.AssertNotCalled(t, "HasPermissionToChannel", mock.Anything, mock.Anything, mock.Anything) } // TestServiceDeleteSpace_ArchivesBackingChannel verifies DeleteSpace archives the space's @@ -268,16 +319,17 @@ func TestServiceDeleteSpace_ArchivesBackingChannel(t *testing.T) { userID := mmmodel.NewId() backingChannelID := mmmodel.NewId() + testutil.MustSeedChannelScheme(t, mockAPI, backingChannelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", backingChannelID, userID).Return(&mmmodel.ChannelMember{}, nil) mockAPI.On("DeleteChannel", backingChannelID).Return(nil) mockAPI.On("GetChannelMembers", backingChannelID, 0, app.PerPageMaximum).Return(mmmodel.ChannelMembers{}, nil) - space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Doomed"}, userID) + space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Doomed"}, userID, nil, nil) require.Nil(t, appErr) - require.Nil(t, h.svc.DeleteSpace(space)) + require.Nil(t, h.svc.DeleteSpace(&space.Space)) mockAPI.AssertCalled(t, "DeleteChannel", backingChannelID) // The space is soft-deleted: a live read no longer finds it. @@ -297,6 +349,7 @@ func TestServiceDeleteSpace_ArchiveFailureTolerated(t *testing.T) { userID := mmmodel.NewId() backingChannelID := mmmodel.NewId() + testutil.MustSeedChannelScheme(t, mockAPI, backingChannelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", backingChannelID, userID).Return(&mmmodel.ChannelMember{}, nil) @@ -305,10 +358,10 @@ func TestServiceDeleteSpace_ArchiveFailureTolerated(t *testing.T) { mockAPI.On("GetChannelMembers", backingChannelID, 0, app.PerPageMaximum).Return(mmmodel.ChannelMembers{}, nil) mockAPI.On("LogWarn", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return().Maybe() - space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Doomed"}, userID) + space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Doomed"}, userID, nil, nil) require.Nil(t, appErr) - require.Nil(t, h.svc.DeleteSpace(space), "DeleteSpace must succeed even though the channel archive fails") + require.Nil(t, h.svc.DeleteSpace(&space.Space), "DeleteSpace must succeed even though the channel archive fails") mockAPI.AssertCalled(t, "DeleteChannel", backingChannelID) // The space is soft-deleted regardless of the channel-archive failure. @@ -330,6 +383,12 @@ func TestServiceRestoreSpace_ChannelRestoreFailurePropagates(t *testing.T) { userID := mmmodel.NewId() backingChannelID := mmmodel.NewId() + channel := testutil.MustSeedChannelScheme(t, mockAPI, backingChannelID, mmmodel.SchemeNameSpaceContribute) + // The channel is still archived, so the un-archive genuinely failed and must propagate. + // Set on the shared channel MustSeedChannelScheme's GetChannelOfType stub returns, rather than + // a second competing stub: DeleteAt is irrelevant to CreateSpace's own scheme-resolution read + // of the same channel, so it can be set from the start. + channel.DeleteAt = 100 mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", backingChannelID, userID).Return(&mmmodel.ChannelMember{}, nil) @@ -337,13 +396,10 @@ func TestServiceRestoreSpace_ChannelRestoreFailurePropagates(t *testing.T) { mockAPI.On("GetChannelMembers", backingChannelID, 0, app.PerPageMaximum).Return(mmmodel.ChannelMembers{}, nil) mockAPI.On("RestoreChannel", backingChannelID). Return(&mmmodel.AppError{Message: "boom", StatusCode: http.StatusInternalServerError}) - // The channel is still archived, so the un-archive genuinely failed and must propagate. - mockAPI.On("GetChannelOfType", backingChannelID, mmmodel.ChannelTypeSpace). - Return(&mmmodel.Channel{Id: backingChannelID, Type: mmmodel.ChannelTypeSpace, DeleteAt: 100}, nil) - space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Round Trip"}, userID) + space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Round Trip"}, userID, nil, nil) require.Nil(t, appErr) - require.Nil(t, h.svc.DeleteSpace(space)) + require.Nil(t, h.svc.DeleteSpace(&space.Space)) _, appErr = h.svc.RestoreSpace(space.Id) require.NotNil(t, appErr, "RestoreSpace must fail when the channel un-archive fails") @@ -369,6 +425,11 @@ func TestServiceRestoreSpace_RetriesStuckChannelRestore(t *testing.T) { userID := mmmodel.NewId() backingChannelID := mmmodel.NewId() + channel := testutil.MustSeedChannelScheme(t, mockAPI, backingChannelID, mmmodel.SchemeNameSpaceContribute) + // The channel stays archived throughout, so both the failure check and the retry see it as + // genuinely needing an un-archive. Set on the shared channel MustSeedChannelScheme's + // GetChannelOfType stub returns, rather than a second competing stub. + channel.DeleteAt = 100 mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", backingChannelID, userID).Return(&mmmodel.ChannelMember{}, nil) @@ -378,14 +439,10 @@ func TestServiceRestoreSpace_RetriesStuckChannelRestore(t *testing.T) { mockAPI.On("RestoreChannel", backingChannelID). Return(&mmmodel.AppError{Message: "boom", StatusCode: http.StatusInternalServerError}).Once() mockAPI.On("RestoreChannel", backingChannelID).Return(nil).Once() - // The channel stays archived throughout, so both the failure check and the retry see it as - // genuinely needing an un-archive. - mockAPI.On("GetChannelOfType", backingChannelID, mmmodel.ChannelTypeSpace). - Return(&mmmodel.Channel{Id: backingChannelID, Type: mmmodel.ChannelTypeSpace, DeleteAt: 100}, nil) - space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Stuck Restore"}, userID) + space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Stuck Restore"}, userID, nil, nil) require.Nil(t, appErr) - require.Nil(t, h.svc.DeleteSpace(space)) + require.Nil(t, h.svc.DeleteSpace(&space.Space)) _, appErr = h.svc.RestoreSpace(space.Id) require.NotNil(t, appErr, "the first RestoreSpace must fail on the channel un-archive") @@ -412,6 +469,9 @@ func TestServiceRestoreSpace_ChannelNeverArchived(t *testing.T) { userID := mmmodel.NewId() backingChannelID := mmmodel.NewId() + // MustSeedChannelScheme's shared channel defaults to DeleteAt 0 (live), matching this test's + // "the channel stays live" intent — no separate GetChannelOfType stub needed. + testutil.MustSeedChannelScheme(t, mockAPI, backingChannelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", backingChannelID, userID).Return(&mmmodel.ChannelMember{}, nil) @@ -422,12 +482,10 @@ func TestServiceRestoreSpace_ChannelNeverArchived(t *testing.T) { // Core rejects un-archiving a channel that was never archived. mockAPI.On("RestoreChannel", backingChannelID). Return(&mmmodel.AppError{Message: "channel is not archived", StatusCode: http.StatusBadRequest}) - mockAPI.On("GetChannelOfType", backingChannelID, mmmodel.ChannelTypeSpace). - Return(&mmmodel.Channel{Id: backingChannelID, Type: mmmodel.ChannelTypeSpace, DeleteAt: 0}, nil) - space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Never Archived"}, userID) + space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Never Archived"}, userID, nil, nil) require.Nil(t, appErr) - require.Nil(t, h.svc.DeleteSpace(space)) + require.Nil(t, h.svc.DeleteSpace(&space.Space)) restored, appErr := h.svc.RestoreSpace(space.Id) require.Nil(t, appErr, "RestoreSpace must succeed when the backing channel was never archived") @@ -450,8 +508,9 @@ func TestServiceCreateSpace_CompensatingDelete(t *testing.T) { // Pre-seed a live space that already owns collisionChannelID, so the second insert // trips the unique channel-id constraint and the row save fails. - mustCreateSpace(t, h.store, collisionChannelID) + mustCreateSpace(t, h.store, h.db, collisionChannelID) + testutil.MustSeedChannelScheme(t, mockAPI, collisionChannelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: collisionChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", collisionChannelID, userID).Return(&mmmodel.ChannelMember{}, nil) @@ -459,7 +518,7 @@ func TestServiceCreateSpace_CompensatingDelete(t *testing.T) { space := &model.Space{TeamId: teamID, Title: "Doomed Space"} - _, appErr := h.svc.CreateSpace(space, userID) + _, appErr := h.svc.CreateSpace(space, userID, nil, nil) require.NotNil(t, appErr) require.Equal(t, 409, appErr.StatusCode) mockAPI.AssertCalled(t, "DeleteChannel", collisionChannelID) @@ -478,8 +537,9 @@ func TestServiceCreateSpace_CompensatingDeleteAlsoFails(t *testing.T) { // Pre-seed a live space that already owns collisionChannelID, so the second insert // trips the unique channel-id constraint and the row save fails. - mustCreateSpace(t, h.store, collisionChannelID) + mustCreateSpace(t, h.store, h.db, collisionChannelID) + testutil.MustSeedChannelScheme(t, mockAPI, collisionChannelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: collisionChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", collisionChannelID, userID).Return(&mmmodel.ChannelMember{}, nil) @@ -488,7 +548,7 @@ func TestServiceCreateSpace_CompensatingDeleteAlsoFails(t *testing.T) { space := &model.Space{TeamId: teamID, Title: "Doomed Space"} - _, appErr := h.svc.CreateSpace(space, userID) + _, appErr := h.svc.CreateSpace(space, userID, nil, nil) require.NotNil(t, appErr) require.Equal(t, 409, appErr.StatusCode, "the original row-save conflict must surface even though the compensating archive also failed") mockAPI.AssertCalled(t, "DeleteChannel", collisionChannelID) @@ -504,13 +564,14 @@ func TestServiceCreateSpace_AddMemberFailedCompensates(t *testing.T) { userID := mmmodel.NewId() backingChannelID := mmmodel.NewId() + testutil.MustSeedChannelScheme(t, mockAPI, backingChannelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", backingChannelID, userID). Return(nil, &mmmodel.AppError{Message: "boom", StatusCode: http.StatusInternalServerError}) mockAPI.On("DeleteChannel", backingChannelID).Return(nil) - _, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Doomed"}, userID) + _, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Doomed"}, userID, nil, nil) require.NotNil(t, appErr) require.Equal(t, "app.space.create.add_member_failed.app_error", appErr.Id) // The orphan channel is removed; no space row is persisted for the team. @@ -524,6 +585,336 @@ func TestServiceCreateSpace_AddMemberFailedCompensates(t *testing.T) { require.Empty(t, spaces) } +// stubCustomSchemeCreate wires the mock calls a non-preset default-capability set needs: a +// CreateScheme returning a scheme with three generated roles, those roles registered so a channel +// repointed at the scheme resolves them, and a PatchRole that applies the plugin's writes. Returns +// the new scheme's id. +func stubCustomSchemeCreate(t *testing.T, mockAPI *plugintest.API) string { + t.Helper() + testutil.StubPooledSchemeMiss(mockAPI) + schemeID := mmmodel.NewId() + userRole, adminRole, guestRole := "custom_user_role", "custom_admin_role", "custom_guest_role" + mockAPI.On("CreateScheme", mock.AnythingOfType("*model.Scheme")).Return(&mmmodel.Scheme{ + Id: schemeID, + Name: model.SharedSchemeNamePrefix + mmmodel.NewId(), + Scope: mmmodel.SchemeScopeChannel, + DefaultChannelUserRole: userRole, + DefaultChannelAdminRole: adminRole, + DefaultChannelGuestRole: guestRole, + }, nil) + testutil.RegisterSchemeRoles(schemeID, guestRole, userRole, adminRole) + testutil.StubRole(mockAPI, userRole, nil) + testutil.StubRole(mockAPI, adminRole, nil) + testutil.StubRole(mockAPI, guestRole, nil) + testutil.StubPatchRole(mockAPI) + return schemeID +} + +// TestServiceCreateSpace_PooledSchemeSurvivesAbandon covers the compensating path a non-preset +// default-capability set takes when a later create step fails: the doomed backing channel is +// archived, but the pooled scheme it pointed at is left alone. The pool is keyed by the capability +// set, so that scheme is not this space's to delete — another space may already be resolving to it, +// and deleting it would strip their members' capabilities. +func TestServiceCreateSpace_PooledSchemeSurvivesAbandon(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + + teamID := mmmodel.NewId() + userID := mmmodel.NewId() + backingChannelID := mmmodel.NewId() + + channel := testutil.MustSeedChannelScheme(t, mockAPI, backingChannelID, mmmodel.SchemeNameSpaceContribute) + pooledSchemeID := stubCustomSchemeCreate(t, mockAPI) + // CreateChannel attaches the pooled scheme, which is what lets core admit the role writes. + mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). + Run(func(args mock.Arguments) { + created, ok := args.Get(0).(*mmmodel.Channel) + require.True(t, ok) + require.NotNil(t, created.SchemeId) + channel.SchemeId = created.SchemeId + }). + Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) + mockAPI.On("AddChannelMember", backingChannelID, userID). + Return(nil, &mmmodel.AppError{Message: "boom", StatusCode: http.StatusInternalServerError}) + mockAPI.On("DeleteChannel", backingChannelID).Return(nil) + + _, appErr := h.svc.CreateSpace( + &model.Space{TeamId: teamID, Title: "Doomed Pooled"}, userID, &[]string{"create_page"}, nil) + require.NotNil(t, appErr) + require.Equal(t, "app.space.create.add_member_failed.app_error", appErr.Id) + + mockAPI.AssertCalled(t, "DeleteChannel", backingChannelID) + mockAPI.AssertNotCalled(t, "DeleteScheme", mock.Anything) + require.NotEmpty(t, pooledSchemeID) +} + +// TestServiceCreateSpace_PresetSchemeSurvivesAbandon is the negative half of the case above: a +// preset scheme is shared by every space using it, so a failed create must archive the channel +// without ever deleting the scheme. +func TestServiceCreateSpace_PresetSchemeSurvivesAbandon(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + + teamID := mmmodel.NewId() + userID := mmmodel.NewId() + backingChannelID := mmmodel.NewId() + + testutil.MustSeedChannelScheme(t, mockAPI, backingChannelID, mmmodel.SchemeNameSpaceContribute) + mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). + Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) + mockAPI.On("AddChannelMember", backingChannelID, userID). + Return(nil, &mmmodel.AppError{Message: "boom", StatusCode: http.StatusInternalServerError}) + mockAPI.On("DeleteChannel", backingChannelID).Return(nil) + + _, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Doomed Preset"}, userID, nil, nil) + require.NotNil(t, appErr) + mockAPI.AssertCalled(t, "DeleteChannel", backingChannelID) + mockAPI.AssertNotCalled(t, "DeleteScheme", mock.Anything) +} + +// TestServiceCreateSpace_CustomSchemeConfiguredAfterChannelAttach verifies the ordering core +// requires: the role writes carrying space permissions land only after the backing channel already +// points at the scheme, since a caller-chosen scheme name is not accepted as proof of space scope. +func TestServiceCreateSpace_CustomSchemeConfiguredAfterChannelAttach(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + + teamID := mmmodel.NewId() + userID := mmmodel.NewId() + backingChannelID := mmmodel.NewId() + + channel := testutil.MustSeedChannelScheme(t, mockAPI, backingChannelID, mmmodel.SchemeNameSpaceContribute) + + // Registered before stubCustomSchemeCreate's own catch-all PatchRole so this one matches first + // (mock.Mock matches in registration order) and can record the permission set per role. + channelAttached := false + patched := map[string][]string{} + mockAPI.On("PatchRole", mock.AnythingOfType("string"), mock.AnythingOfType("*model.RolePatch")). + Run(func(args mock.Arguments) { + require.True(t, channelAttached, "roles must be patched only after the channel attaches the scheme") + roleID, ok := args.Get(0).(string) + require.True(t, ok) + roleName, ok := testutil.StubbedRoleName(roleID) + require.True(t, ok, "PatchRole called with an unregistered role id %q", roleID) + patch, ok := args.Get(1).(*mmmodel.RolePatch) + require.True(t, ok) + require.NotNil(t, patch.Permissions) + patched[roleName] = *patch.Permissions + }). + Return(&mmmodel.Role{}, nil) + + customSchemeID := stubCustomSchemeCreate(t, mockAPI) + mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). + Run(func(args mock.Arguments) { + created, ok := args.Get(0).(*mmmodel.Channel) + require.True(t, ok) + require.NotNil(t, created.SchemeId) + require.Equal(t, customSchemeID, *created.SchemeId) + channel.SchemeId = created.SchemeId + channelAttached = true + }). + Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) + mockAPI.On("AddChannelMember", backingChannelID, userID).Return(&mmmodel.ChannelMember{}, nil) + + space, appErr := h.svc.CreateSpace( + &model.Space{TeamId: teamID, Title: "Custom Caps"}, userID, &[]string{"create_page"}, nil) + require.Nil(t, appErr) + require.NotNil(t, space) + + // Each generated role gets its own set: the user role the requested capabilities plus the + // baseline read, the guest role read alone, and the admin role the full space-admin set. + require.ElementsMatch(t, []string{"read_page", "create_page"}, patched["custom_user_role"]) + require.ElementsMatch(t, []string{"read_page"}, patched["custom_guest_role"]) + require.ElementsMatch(t, mmmodel.PermissionIDs(mmmodel.SpaceAdminRolePermissions), patched["custom_admin_role"]) +} + +// stubCustomSchemeCreateFailingPatch is stubCustomSchemeCreate with a PatchRole that always fails, +// so the configure step a freshly created custom scheme needs cannot complete. Returns the new +// scheme's id. +func stubCustomSchemeCreateFailingPatch(t *testing.T, mockAPI *plugintest.API) string { + t.Helper() + testutil.StubPooledSchemeMiss(mockAPI) + schemeID := mmmodel.NewId() + userRole, adminRole, guestRole := "unconfigurable_user_role", "unconfigurable_admin_role", "unconfigurable_guest_role" + mockAPI.On("CreateScheme", mock.AnythingOfType("*model.Scheme")).Return(&mmmodel.Scheme{ + Id: schemeID, + Name: model.SharedSchemeNamePrefix + mmmodel.NewId(), + Scope: mmmodel.SchemeScopeChannel, + DefaultChannelUserRole: userRole, + DefaultChannelAdminRole: adminRole, + DefaultChannelGuestRole: guestRole, + }, nil) + testutil.RegisterSchemeRoles(schemeID, guestRole, userRole, adminRole) + testutil.StubRole(mockAPI, userRole, nil) + testutil.StubRole(mockAPI, adminRole, nil) + testutil.StubRole(mockAPI, guestRole, nil) + mockAPI.On("PatchRole", mock.AnythingOfType("string"), mock.AnythingOfType("*model.RolePatch")). + Return(nil, &mmmodel.AppError{Message: "boom", StatusCode: http.StatusInternalServerError}) + return schemeID +} + +// TestServiceCreateSpace_PooledSchemeConfigureFailureAbandons covers the CreateSpace branch that +// runs when the role writes fail after the backing channel already carries the pooled scheme: the +// create fails and the channel is archived, while the pooled scheme stays — it is shared, so a +// later space resolving to the same capability set reconfigures it. The creator is never added, +// since configuration precedes that. +func TestServiceCreateSpace_PooledSchemeConfigureFailureAbandons(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + + teamID := mmmodel.NewId() + userID := mmmodel.NewId() + backingChannelID := mmmodel.NewId() + + channel := testutil.MustSeedChannelScheme(t, mockAPI, backingChannelID, mmmodel.SchemeNameSpaceContribute) + customSchemeID := stubCustomSchemeCreateFailingPatch(t, mockAPI) + mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). + Run(func(args mock.Arguments) { + created, ok := args.Get(0).(*mmmodel.Channel) + require.True(t, ok) + require.NotNil(t, created.SchemeId) + channel.SchemeId = created.SchemeId + }). + Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) + mockAPI.On("DeleteChannel", backingChannelID).Return(nil) + + _, appErr := h.svc.CreateSpace( + &model.Space{TeamId: teamID, Title: "Unconfigurable Pooled"}, userID, &[]string{"create_page"}, nil) + require.NotNil(t, appErr) + require.Equal(t, "app.space.create.scheme_configure_failed.app_error", appErr.Id) + + mockAPI.AssertCalled(t, "DeleteChannel", backingChannelID) + mockAPI.AssertNotCalled(t, "DeleteScheme", mock.Anything) + require.NotEmpty(t, customSchemeID) + mockAPI.AssertNotCalled(t, "AddChannelMember", mock.Anything, mock.Anything) + + spaces, err := h.store.GetSpacesForTeam(teamID, userID, false, 0, 10) + require.NoError(t, err) + require.Empty(t, spaces) +} + +// TestServiceSetSpaceDefaultCapabilities_ConfigureFailureRollsBack covers the repoint-then-configure +// failure branch: a space whose newly pooled scheme cannot be configured must be put back on its +// previous scheme rather than left on one whose roles may still carry core's default channel +// baseline. The pooled scheme itself is never deleted — it is shared, not this space's to retire. +func TestServiceSetSpaceDefaultCapabilities_ConfigureFailureRollsBack(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + + channelID := mmmodel.NewId() + channel := testutil.MustSeedChannelScheme(t, mockAPI, channelID, mmmodel.SchemeNameSpaceContribute) + pooledSchemeID := stubCustomSchemeCreateFailingPatch(t, mockAPI) + + space := mustCreateSpace(t, h.store, h.db, channelID) + _, appErr := h.svc.SetSpaceDefaultCapabilities(space, []string{"create_page"}, mmmodel.NewId()) + + require.NotNil(t, appErr) + require.Equal(t, "app.space.default_capabilities.scheme_configure_failed.app_error", appErr.Id) + require.NotNil(t, channel.SchemeId) + require.Equal(t, testutil.PresetSchemeID(mmmodel.SchemeNameSpaceContribute), *channel.SchemeId, + "the channel must be repointed back at the scheme it started on") + require.NotEqual(t, pooledSchemeID, *channel.SchemeId) + mockAPI.AssertNotCalled(t, "DeleteScheme", mock.Anything) +} + +// TestServiceSetSpaceDefaultCapabilities_SameSchemeStillConfiguresRoles covers the recovery path for +// a space already pointing at the scheme its requested set resolves to, whose roles were never +// written — the state an earlier run interrupted between the channel repoint and the role write +// leaves behind. There is no repoint to perform, so the operation could return having done nothing, +// and every later submission of the intended set would do the same, leaving the space permanently on +// core's default channel baseline with no way to move off it. Resubmitting must write the roles. +func TestServiceSetSpaceDefaultCapabilities_SameSchemeStillConfiguresRoles(t *testing.T) { + mockAPI := &plugintest.API{} + sysadminID := mmmodel.NewId() + // Registered before the harness so the response's own-capability projection resolves via + // sysadmin, sidestepping the ReadViaMember channel-member lookup this test does not stub. + // mock.Mock matches expectations in registration order. + mockAPI.On("HasPermissionTo", sysadminID, mmmodel.PermissionManageSystem).Return(true) + h := openTestServiceWithAPI(t, mockAPI) + + capabilities := []string{model.CapabilityCreatePage} + pooledName := model.SharedSchemeNameForCapabilities(capabilities) + schemeID := mmmodel.NewId() + userRole, adminRole, guestRole := pooledName+"_user", pooledName+"_admin", pooledName+"_guest" + mockAPI.On("GetSchemeByName", pooledName).Return(&mmmodel.Scheme{ + Id: schemeID, + Name: pooledName, + Scope: mmmodel.SchemeScopeChannel, + DefaultChannelUserRole: userRole, + DefaultChannelAdminRole: adminRole, + DefaultChannelGuestRole: guestRole, + }, nil) + testutil.RegisterSchemeRoles(schemeID, guestRole, userRole, adminRole) + // Empty permission sets stand in for core's default channel baseline: the roles core generated + // alongside the scheme, never given the grants configureSharedScheme writes. + unconfiguredUserRole := testutil.StubRole(mockAPI, userRole, nil) + testutil.StubRole(mockAPI, adminRole, nil) + testutil.StubRole(mockAPI, guestRole, nil) + testutil.StubPatchRole(mockAPI) + + // The channel already points at that scheme, so the repoint branch is skipped entirely. + channelID := mmmodel.NewId() + testutil.StubChannelScheme(mockAPI, channelID, &mmmodel.Channel{ + Id: channelID, Type: mmmodel.ChannelTypeSpace, SchemeId: &schemeID, + }) + space := mustCreateSpace(t, h.store, h.db, channelID) + + updated, appErr := h.svc.SetSpaceDefaultCapabilities(space, capabilities, sysadminID) + + require.Nil(t, appErr) + require.Equal(t, capabilities, updated.DefaultCapabilities) + require.ElementsMatch(t, []string{model.CapabilityReadPage, model.CapabilityCreatePage}, + unconfiguredUserRole.Permissions, + "resubmitting the set a space already resolves to must write the roles it was left without") +} + +// TestServiceSetSpaceDefaultCapabilities_ResponseReflectsRequestedNotStaleReadback covers the +// projection guard: the response is built from the capability set written under the lock, not +// from a fresh read of the roles that write just committed. GetRoleByName here always answers with +// a role frozen on a pre-update permission set, standing in for a lagging-replica read of the +// caller's own committed change; the response must still report the requested set. +func TestServiceSetSpaceDefaultCapabilities_ResponseReflectsRequestedNotStaleReadback(t *testing.T) { + mockAPI := &plugintest.API{} + sysadminID := mmmodel.NewId() + // Registered before the harness so the response's own-capability projection resolves via + // sysadmin (AdminEffectiveCapabilities), sidestepping the ReadViaMember channel-member lookup + // this test does not otherwise stub. mock.Mock matches expectations in registration order. + mockAPI.On("HasPermissionTo", sysadminID, mmmodel.PermissionManageSystem).Return(true) + h := openTestServiceWithAPI(t, mockAPI) + + channelID := mmmodel.NewId() + testutil.MustSeedChannelScheme(t, mockAPI, channelID, mmmodel.SchemeNameSpaceContribute) + + testutil.StubPooledSchemeMiss(mockAPI) + schemeID := mmmodel.NewId() + userRole, adminRole, guestRole := "stale_readback_user_role", "stale_readback_admin_role", "stale_readback_guest_role" + mockAPI.On("CreateScheme", mock.AnythingOfType("*model.Scheme")).Return(&mmmodel.Scheme{ + Id: schemeID, + Name: model.SharedSchemeNamePrefix + mmmodel.NewId(), + Scope: mmmodel.SchemeScopeChannel, + DefaultChannelUserRole: userRole, + DefaultChannelAdminRole: adminRole, + DefaultChannelGuestRole: guestRole, + }, nil) + testutil.RegisterSchemeRoles(schemeID, guestRole, userRole, adminRole) + // Frozen on a permission set that does not match the request below, and never updated by the + // PatchRole stub: a caller that re-read this role after the write would see the wrong set. + mockAPI.On("GetRoleByName", userRole). + Return(&mmmodel.Role{Id: mmmodel.NewId(), Name: userRole, Permissions: []string{model.CapabilityCommentPage}}, nil) + mockAPI.On("GetRoleByName", adminRole).Return(&mmmodel.Role{Id: mmmodel.NewId(), Name: adminRole}, nil) + mockAPI.On("GetRoleByName", guestRole).Return(&mmmodel.Role{Id: mmmodel.NewId(), Name: guestRole}, nil) + mockAPI.On("PatchRole", mock.AnythingOfType("string"), mock.AnythingOfType("*model.RolePatch")). + Return(&mmmodel.Role{}, nil) + + space := mustCreateSpace(t, h.store, h.db, channelID) + + updated, appErr := h.svc.SetSpaceDefaultCapabilities(space, []string{"create_page"}, sysadminID) + require.Nil(t, appErr) + require.NotNil(t, updated) + require.ElementsMatch(t, []string{"create_page"}, updated.DefaultCapabilities, + "the response must report the requested set, not a stale role read-back") +} + // TestServiceRestoreSpace_UnarchivesBackingChannel verifies a create→delete→restore round trip // un-archives the backing channel and brings the space back live. func TestServiceRestoreSpace_UnarchivesBackingChannel(t *testing.T) { @@ -534,6 +925,7 @@ func TestServiceRestoreSpace_UnarchivesBackingChannel(t *testing.T) { userID := mmmodel.NewId() backingChannelID := mmmodel.NewId() + testutil.MustSeedChannelScheme(t, mockAPI, backingChannelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: backingChannelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", backingChannelID, userID).Return(&mmmodel.ChannelMember{}, nil) @@ -541,9 +933,9 @@ func TestServiceRestoreSpace_UnarchivesBackingChannel(t *testing.T) { mockAPI.On("GetChannelMembers", backingChannelID, 0, app.PerPageMaximum).Return(mmmodel.ChannelMembers{}, nil) mockAPI.On("RestoreChannel", backingChannelID).Return(nil) - space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Round Trip"}, userID) + space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Round Trip"}, userID, nil, nil) require.Nil(t, appErr) - require.Nil(t, h.svc.DeleteSpace(space)) + require.Nil(t, h.svc.DeleteSpace(&space.Space)) got, appErr := h.svc.RestoreSpace(space.Id) require.Nil(t, appErr) @@ -556,9 +948,9 @@ func TestServiceRestoreSpace_UnarchivesBackingChannel(t *testing.T) { // optimistic-lock baseline. func TestServiceUpdateSpace(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) - patched, appErr := h.svc.UpdateSpace(space, &model.SpacePatch{Title: mmmodel.NewPointer("New Title"), Description: mmmodel.NewPointer("New Desc")}, new(space.UpdateAt), false) + patched, appErr := h.svc.UpdateSpace(space, &model.SpacePatch{Title: mmmodel.NewPointer("New Title"), Description: mmmodel.NewPointer("New Desc")}, new(space.UpdateAt), false, "") require.Nil(t, appErr) require.Equal(t, "New Title", patched.Title) require.Equal(t, "New Desc", patched.Description) @@ -566,50 +958,50 @@ func TestServiceUpdateSpace(t *testing.T) { // A patch with only Icon leaves the previously-set Title/Description intact. The caller // passes its latest fetched record, mirroring the handler flow (membership gate re-fetches // the space on every request). - patched2, appErr := h.svc.UpdateSpace(patched, &model.SpacePatch{Icon: mmmodel.NewPointer("icon-data")}, new(patched.UpdateAt), false) + patched2, appErr := h.svc.UpdateSpace(patched, &model.SpacePatch{Icon: mmmodel.NewPointer("icon-data")}, new(patched.UpdateAt), false, "") require.Nil(t, appErr) require.Equal(t, "New Title", patched2.Title, "unspecified fields are preserved") require.Equal(t, "New Desc", patched2.Description) require.Equal(t, "icon-data", patched2.Icon) // An explicit empty string clears a field (a nil field would leave it unchanged). - patched3, appErr := h.svc.UpdateSpace(patched2, &model.SpacePatch{Description: mmmodel.NewPointer("")}, new(patched2.UpdateAt), false) + patched3, appErr := h.svc.UpdateSpace(patched2, &model.SpacePatch{Description: mmmodel.NewPointer("")}, new(patched2.UpdateAt), false, "") require.Nil(t, appErr) require.Equal(t, "", patched3.Description, "an explicit empty string clears the field") require.Equal(t, "New Title", patched3.Title) // A stale baseline is rejected as a conflict unless force is set. - _, appErr = h.svc.UpdateSpace(patched3, &model.SpacePatch{Title: mmmodel.NewPointer("Stale")}, new(space.UpdateAt), false) + _, appErr = h.svc.UpdateSpace(patched3, &model.SpacePatch{Title: mmmodel.NewPointer("Stale")}, new(space.UpdateAt), false, "") require.NotNil(t, appErr) require.Equal(t, http.StatusConflict, appErr.StatusCode) - forced, appErr := h.svc.UpdateSpace(patched3, &model.SpacePatch{Title: mmmodel.NewPointer("Forced")}, new(space.UpdateAt), true) + forced, appErr := h.svc.UpdateSpace(patched3, &model.SpacePatch{Title: mmmodel.NewPointer("Forced")}, new(space.UpdateAt), true, "") require.Nil(t, appErr) require.Equal(t, "Forced", forced.Title) // A whitespace-only title is rejected. - _, appErr = h.svc.UpdateSpace(space, &model.SpacePatch{Title: mmmodel.NewPointer(" ")}, new(int64(0)), true) + _, appErr = h.svc.UpdateSpace(space, &model.SpacePatch{Title: mmmodel.NewPointer(" ")}, new(int64(0)), true, "") require.NotNil(t, appErr) require.Equal(t, http.StatusBadRequest, appErr.StatusCode) require.Equal(t, "app.shared.title_required.app_error", appErr.Id) // A title that exceeds SpaceTitleMaxRunes is rejected. longTitle := strings.Repeat("x", model.SpaceTitleMaxRunes+1) - _, appErr = h.svc.UpdateSpace(space, &model.SpacePatch{Title: mmmodel.NewPointer(longTitle)}, new(int64(0)), true) + _, appErr = h.svc.UpdateSpace(space, &model.SpacePatch{Title: mmmodel.NewPointer(longTitle)}, new(int64(0)), true, "") require.NotNil(t, appErr) require.Equal(t, http.StatusBadRequest, appErr.StatusCode) require.Equal(t, "app.shared.title_too_long.app_error", appErr.Id) // A description that exceeds SpaceDescriptionMaxRunes is rejected with the documented error ID. longDesc := strings.Repeat("x", model.SpaceDescriptionMaxRunes+1) - _, appErr = h.svc.UpdateSpace(space, &model.SpacePatch{Description: mmmodel.NewPointer(longDesc)}, new(int64(0)), true) + _, appErr = h.svc.UpdateSpace(space, &model.SpacePatch{Description: mmmodel.NewPointer(longDesc)}, new(int64(0)), true, "") require.NotNil(t, appErr) require.Equal(t, http.StatusBadRequest, appErr.StatusCode) require.Equal(t, "app.shared.description_too_long.app_error", appErr.Id) // An icon that exceeds SpaceIconMaxBytes is rejected with the documented error ID. largeIcon := strings.Repeat("i", model.SpaceIconMaxBytes+1) - _, appErr = h.svc.UpdateSpace(space, &model.SpacePatch{Icon: mmmodel.NewPointer(largeIcon)}, new(int64(0)), true) + _, appErr = h.svc.UpdateSpace(space, &model.SpacePatch{Icon: mmmodel.NewPointer(largeIcon)}, new(int64(0)), true, "") require.NotNil(t, appErr) require.Equal(t, http.StatusBadRequest, appErr.StatusCode) require.Equal(t, "app.shared.icon_too_large.app_error", appErr.Id) @@ -619,9 +1011,9 @@ func TestServiceUpdateSpace(t *testing.T) { // than silently bumping UpdateAt (mirroring PagePatch's nothing-to-update guard). func TestServiceUpdateSpace_NoChangesRejected(t *testing.T) { h := openTestService(t) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) - _, appErr := h.svc.UpdateSpace(space, &model.SpacePatch{}, new(space.UpdateAt), false) + _, appErr := h.svc.UpdateSpace(space, &model.SpacePatch{}, new(space.UpdateAt), false, "") require.NotNil(t, appErr) require.Equal(t, http.StatusBadRequest, appErr.StatusCode) require.Equal(t, "model.space.patch.nothing_to_update.app_error", appErr.Id) @@ -642,15 +1034,16 @@ func TestGetSpaceWithDeleted(t *testing.T) { userID := mmmodel.NewId() channelID := mmmodel.NewId() + testutil.MustSeedChannelScheme(t, mockAPI, channelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: channelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", channelID, userID).Return(&mmmodel.ChannelMember{}, nil) mockAPI.On("DeleteChannel", channelID).Return(nil) mockAPI.On("GetChannelMembers", channelID, 0, app.PerPageMaximum).Return(mmmodel.ChannelMembers{}, nil) - space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Test"}, userID) + space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Test"}, userID, nil, nil) require.Nil(t, appErr) - require.Nil(t, h.svc.DeleteSpace(space)) + require.Nil(t, h.svc.DeleteSpace(&space.Space)) // GetSpace excludes deleted rows. _, appErr = h.svc.GetSpace(space.Id) @@ -664,136 +1057,41 @@ func TestGetSpaceWithDeleted(t *testing.T) { require.NotZero(t, got.DeleteAt) } -// TestCheckSpaceMembership_MemberAllowed verifies that a user who is a member of the -// space's backing channel is allowed through. -func TestCheckSpaceMembership_MemberAllowed(t *testing.T) { +// TestResolveSpaceRead_MemberAdmitted verifies that a user who holds read_page on the space's +// backing channel is admitted as a member rather than via the open-space fall-through. +func TestResolveSpaceRead_MemberAdmitted(t *testing.T) { mockAPI := &plugintest.API{} h := openTestServiceWithAPI(t, mockAPI) + // The harness stubs GetTeamMember to an active membership and read_page to true. teamID := mmmodel.NewId() userID := mmmodel.NewId() - channelID := mmmodel.NewId() - - mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). - Return(&mmmodel.Channel{Id: channelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) - mockAPI.On("AddChannelMember", channelID, userID).Return(&mmmodel.ChannelMember{}, nil) + space := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), teamID) - space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Test"}, userID) - require.Nil(t, appErr) - - mockAPI.On("GetChannelMember", space.ChannelId, userID).Return(&mmmodel.ChannelMember{}, nil) - - _, appErr = h.svc.CheckSpaceMembership(space.Id, userID, false) + resolution, appErr := h.svc.ResolveSpaceRead("test", space, userID) require.Nil(t, appErr) + require.Equal(t, app.ReadViaMember, resolution) } -// TestCheckSpaceMembership_NonMemberBlocked verifies that a user who is not a member of the -// space's backing channel receives a 403 Forbidden. -func TestCheckSpaceMembership_NonMemberBlocked(t *testing.T) { +// TestResolveSpaceRead_NonMemberDeniedOnPrivateSpace verifies that an active team member who holds +// no channel-scoped read_page is denied on a private space — the open-space fall-through is the +// only non-member admission, and it does not apply here. +func TestResolveSpaceRead_NonMemberDeniedOnPrivateSpace(t *testing.T) { mockAPI := &plugintest.API{} - h := openTestServiceWithAPI(t, mockAPI) - - teamID := mmmodel.NewId() - userID := mmmodel.NewId() - channelID := mmmodel.NewId() - - mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). - Return(&mmmodel.Channel{Id: channelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) - mockAPI.On("AddChannelMember", channelID, userID).Return(&mmmodel.ChannelMember{}, nil) - - space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Test"}, userID) - require.Nil(t, appErr) - strangerID := mmmodel.NewId() - mockAPI.On("GetChannelMember", space.ChannelId, strangerID). - Return(nil, &mmmodel.AppError{StatusCode: http.StatusNotFound}) - - _, appErr = h.svc.CheckSpaceMembership(space.Id, strangerID, false) - require.NotNil(t, appErr) - require.Equal(t, http.StatusForbidden, appErr.StatusCode) - require.Equal(t, "app.space.access.forbidden.app_error", appErr.Id) -} - -// TestCheckSpaceMembership_EmptyUserIDRejected verifies that an empty userID is rejected rather -// than treated as a trusted system caller, so a caller that loses its user id fails closed. -func TestCheckSpaceMembership_EmptyUserIDRejected(t *testing.T) { - mockAPI := &plugintest.API{} - h := openTestServiceWithAPI(t, mockAPI) - - teamID := mmmodel.NewId() - userID := mmmodel.NewId() - channelID := mmmodel.NewId() - - mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). - Return(&mmmodel.Channel{Id: channelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) - mockAPI.On("AddChannelMember", channelID, userID).Return(&mmmodel.ChannelMember{}, nil) - - space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Test"}, userID) - require.Nil(t, appErr) - - returnedSpace, appErr := h.svc.CheckSpaceMembership(space.Id, "", false) - require.Nil(t, returnedSpace) - require.NotNil(t, appErr) - require.Equal(t, http.StatusBadRequest, appErr.StatusCode) - require.Equal(t, "app.space.access.invalid_user_id.app_error", appErr.Id) -} - -// TestCheckSpaceMembership_IncludeDeleted verifies that includeDeleted=true reaches a -// soft-deleted space for the membership check, while includeDeleted=false returns 404. -func TestCheckSpaceMembership_IncludeDeleted(t *testing.T) { - mockAPI := &plugintest.API{} + // Registered before the harness so it takes precedence over StubDefaultSpacePermissions' + // permissive catch-all: mock.Mock matches expectations in registration order. + mockAPI.On("HasPermissionToChannel", strangerID, mock.Anything, mmmodel.PermissionReadPage).Return(false) h := openTestServiceWithAPI(t, mockAPI) - teamID := mmmodel.NewId() - userID := mmmodel.NewId() - channelID := mmmodel.NewId() - - mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). - Return(&mmmodel.Channel{Id: channelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) - mockAPI.On("AddChannelMember", channelID, userID).Return(&mmmodel.ChannelMember{}, nil) - mockAPI.On("DeleteChannel", channelID).Return(nil) - mockAPI.On("GetChannelMembers", channelID, 0, app.PerPageMaximum).Return(mmmodel.ChannelMembers{}, nil) - - space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Test"}, userID) - require.Nil(t, appErr) - require.Nil(t, h.svc.DeleteSpace(space)) - - // includeDeleted=false → GetSpace returns 404, which CheckSpaceMembership converts to 403 - // to prevent existence probing by non-members. - _, appErr = h.svc.CheckSpaceMembership(space.Id, userID, false) - require.NotNil(t, appErr) - require.Equal(t, http.StatusForbidden, appErr.StatusCode) - - // includeDeleted=true → GetSpaceWithDeleted finds the space; membership check proceeds. - mockAPI.On("GetChannelMember", space.ChannelId, userID).Return(&mmmodel.ChannelMember{}, nil) - _, appErr = h.svc.CheckSpaceMembership(space.Id, userID, true) - require.Nil(t, appErr) -} - -// TestCheckSpaceMembership_ChannelLookupFailed verifies that a non-404 error from -// GetChannelMember propagates as a 500 with the channel_lookup_failed error key. -func TestCheckSpaceMembership_ChannelLookupFailed(t *testing.T) { - mockAPI := &plugintest.API{} - h := openTestServiceWithAPI(t, mockAPI) + // The harness stubs GetTeamMember to an active membership, so the stranger clears the team + // gate and is denied purely on the channel-scoped check. + space := seedSpaceForTeam(t, h.store, h.db, mmmodel.NewId(), mmmodel.NewId()) + space.ViewAccess = model.ViewAccessPrivate - teamID := mmmodel.NewId() - userID := mmmodel.NewId() - channelID := mmmodel.NewId() - - mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). - Return(&mmmodel.Channel{Id: channelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) - mockAPI.On("AddChannelMember", channelID, userID).Return(&mmmodel.ChannelMember{}, nil) - - space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Test"}, userID) + resolution, appErr := h.svc.ResolveSpaceRead("test", space, strangerID) require.Nil(t, appErr) - - mockAPI.On("GetChannelMember", space.ChannelId, userID). - Return(nil, &mmmodel.AppError{Id: "store.sql_channel.get_member.missing.app_error", StatusCode: http.StatusInternalServerError}) - - _, appErr = h.svc.CheckSpaceMembership(space.Id, userID, false) - require.NotNil(t, appErr) - require.Equal(t, http.StatusInternalServerError, appErr.StatusCode) - require.Equal(t, "app.space.access.channel_lookup_failed.app_error", appErr.Id) + require.Equal(t, app.ReadDenied, resolution) } // createSpaceForMemberTests stands up a space with a mocked backing channel so the member-management @@ -804,18 +1102,19 @@ func createSpaceForMemberTests(t *testing.T, h *testHarness, mockAPI *plugintest creatorID := mmmodel.NewId() channelID := mmmodel.NewId() + testutil.MustSeedChannelScheme(t, mockAPI, channelID, mmmodel.SchemeNameSpaceContribute) mockAPI.On("CreateChannel", mock.AnythingOfType("*model.Channel")). Return(&mmmodel.Channel{Id: channelID, TeamId: teamID, Type: mmmodel.ChannelTypeSpace}, nil) mockAPI.On("AddChannelMember", channelID, creatorID).Return(&mmmodel.ChannelMember{}, nil) - space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Test"}, creatorID) + space, appErr := h.svc.CreateSpace(&model.Space{TeamId: teamID, Title: "Test"}, creatorID, nil, nil) require.Nil(t, appErr) - return space, creatorID + return &space.Space, creatorID } -// TestServiceListSpaceMembers_ListFails verifies that a failed member listing on the backing -// channel propagates as a 500 with the list_members error key. -func TestServiceListSpaceMembers_ListFails(t *testing.T) { +// TestServiceGetSpaceMembers_ListFails verifies that a failed member listing on the backing +// channel propagates as a 500 with the get_members error key. +func TestServiceGetSpaceMembers_ListFails(t *testing.T) { mockAPI := &plugintest.API{} h := openTestServiceWithAPI(t, mockAPI) space, _ := createSpaceForMemberTests(t, h, mockAPI) @@ -823,10 +1122,30 @@ func TestServiceListSpaceMembers_ListFails(t *testing.T) { mockAPI.On("GetChannelMembers", space.ChannelId, 0, 60). Return(nil, &mmmodel.AppError{Id: "app.channel.get_members.app_error", StatusCode: http.StatusInternalServerError}) - _, _, appErr := h.svc.ListSpaceMembers(space, 0, 60) + _, _, appErr := h.svc.GetSpaceMembers(space, 0, 60) require.NotNil(t, appErr) require.Equal(t, http.StatusInternalServerError, appErr.StatusCode) - require.Equal(t, "app.space.list_members.failed.app_error", appErr.Id) + require.Equal(t, "app.space.get_members.failed.app_error", appErr.Id) +} + +// TestServiceDefaultRolesGrantPermission_ChannelWithoutScheme covers schemeRolesFromChannel's +// fail-closed branch: a backing channel carrying no scheme (a space that lost its scheme) resolves +// to not-found, which DefaultRolesGrantPermission maps to "not granted" — never a silent +// fall-through to the team scheme's channel roles, and never a wrong-role grant. The lookup must +// short-circuit before reaching GetSchemeRolesForChannel. +func TestServiceDefaultRolesGrantPermission_ChannelWithoutScheme(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + + channelID := mmmodel.NewId() + // The mock never sets a SchemeId, standing in for a space whose backing channel carries none. + mockAPI.On("GetChannelOfType", channelID, mmmodel.ChannelTypeSpace). + Return(&mmmodel.Channel{Id: channelID, Type: mmmodel.ChannelTypeSpace}, nil) + + granted, err := h.svc.DefaultRolesGrantPermission(&model.Space{ChannelId: channelID}, mmmodel.PermissionCreatePage) + require.NoError(t, err) + require.False(t, granted) + mockAPI.AssertNotCalled(t, "GetSchemeRolesForChannel", mock.Anything) } // TestServiceAddSpaceMember_AddFails verifies that a failed channel-member add propagates as a 500 @@ -854,6 +1173,8 @@ func TestServiceRemoveSpaceMember_RemoveFails(t *testing.T) { space, _ := createSpaceForMemberTests(t, h, mockAPI) targetID := mmmodel.NewId() + // The target-existence resolve runs before the last-member/last-admin guards. + mockAPI.On("GetChannelMember", space.ChannelId, targetID).Return(&mmmodel.ChannelMember{ChannelId: space.ChannelId, UserId: targetID}, nil) // The last-member guard scans the member list before removing; report another (active, // via the default GetTeamMember stub) member so the removal proceeds to the failing // DeleteChannelMember call. @@ -862,12 +1183,56 @@ func TestServiceRemoveSpaceMember_RemoveFails(t *testing.T) { mockAPI.On("DeleteChannelMember", space.ChannelId, targetID). Return(&mmmodel.AppError{Id: "app.channel.remove_member.app_error", StatusCode: http.StatusInternalServerError}) - appErr := h.svc.RemoveSpaceMember(space, targetID) + appErr := h.svc.RemoveSpaceMember(space, targetID, "") require.NotNil(t, appErr) require.Equal(t, http.StatusInternalServerError, appErr.StatusCode) require.Equal(t, "app.space.remove_member.failed.app_error", appErr.Id) } +// TestServiceRemoveSpaceMember_SelfNonMemberOnOpenSpaceIs404 covers a non-member's self-removal +// from an open space. The read gate admits non-members to an open space by design, so the caller +// can already see it exists and there is nothing left to hide: the absent membership reports as a +// plain 404 rather than the existence-hiding 403, which would misreport a no-op as an +// authorization failure. +func TestServiceRemoveSpaceMember_SelfNonMemberOnOpenSpaceIs404(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + space, _ := createSpaceForMemberTests(t, h, mockAPI) + require.Equal(t, model.ViewAccessOpen, space.ViewAccess, "fixture must be open for this case") + + selfID := mmmodel.NewId() + mockAPI.On("GetChannelMember", space.ChannelId, selfID). + Return((*mmmodel.ChannelMember)(nil), &mmmodel.AppError{StatusCode: http.StatusNotFound}) + + appErr := h.svc.RemoveSpaceMember(space, selfID, selfID) + require.NotNil(t, appErr) + require.Equal(t, http.StatusNotFound, appErr.StatusCode) + require.Equal(t, "app.space.member.user_not_found.app_error", appErr.Id) +} + +// TestServiceRemoveSpaceMember_SelfNonMemberOnPrivateSpaceIs403 is the other half of the split: on +// a private space the same caller is a non-member the read gate denies, so reporting the absent +// membership as 404 would confirm the space exists to someone who cannot read it. They get the +// shared existence-hiding 403 instead. +func TestServiceRemoveSpaceMember_SelfNonMemberOnPrivateSpaceIs403(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + space, _ := createSpaceForMemberTests(t, h, mockAPI) + + private := model.ViewAccessPrivate + updated, err := h.store.UpdateSpace(space.Id, &model.SpacePatch{ViewAccess: &private}, space.UpdateAt, false) + require.NoError(t, err) + + selfID := mmmodel.NewId() + mockAPI.On("GetChannelMember", updated.ChannelId, selfID). + Return((*mmmodel.ChannelMember)(nil), &mmmodel.AppError{StatusCode: http.StatusNotFound}) + + appErr := h.svc.RemoveSpaceMember(updated, selfID, selfID) + require.NotNil(t, appErr) + require.Equal(t, http.StatusForbidden, appErr.StatusCode) + require.Equal(t, "app.space.access.forbidden.app_error", appErr.Id) +} + // TestServiceRemoveSpaceMember_LastMemberRejected verifies the sole remaining member cannot be // removed: membership is the only gate on every space and page route, so a memberless space // would be permanently unreachable through the plugin API. @@ -877,10 +1242,11 @@ func TestServiceRemoveSpaceMember_LastMemberRejected(t *testing.T) { space, _ := createSpaceForMemberTests(t, h, mockAPI) soleID := mmmodel.NewId() + mockAPI.On("GetChannelMember", space.ChannelId, soleID).Return(&mmmodel.ChannelMember{ChannelId: space.ChannelId, UserId: soleID}, nil) mockAPI.On("GetChannelMembers", space.ChannelId, 0, app.PerPageMaximum). Return(mmmodel.ChannelMembers{{ChannelId: space.ChannelId, UserId: soleID}}, nil) - appErr := h.svc.RemoveSpaceMember(space, soleID) + appErr := h.svc.RemoveSpaceMember(space, soleID, "") require.NotNil(t, appErr) require.Equal(t, http.StatusConflict, appErr.StatusCode) require.Equal(t, "app.space.remove_member.last_member.app_error", appErr.Id) @@ -903,10 +1269,11 @@ func TestServiceRemoveSpaceMember_LastActiveMemberRejected(t *testing.T) { h := openTestServiceWithAPI(t, mockAPI) space, _ := createSpaceForMemberTests(t, h, mockAPI) + mockAPI.On("GetChannelMember", space.ChannelId, activeID).Return(&mmmodel.ChannelMember{ChannelId: space.ChannelId, UserId: activeID}, nil) mockAPI.On("GetChannelMembers", space.ChannelId, 0, app.PerPageMaximum). Return(mmmodel.ChannelMembers{{ChannelId: space.ChannelId, UserId: activeID}, {ChannelId: space.ChannelId, UserId: formerID}}, nil) - appErr := h.svc.RemoveSpaceMember(space, activeID) + appErr := h.svc.RemoveSpaceMember(space, activeID, "") require.NotNil(t, appErr) require.Equal(t, http.StatusConflict, appErr.StatusCode) require.Equal(t, "app.space.remove_member.last_member.app_error", appErr.Id) @@ -922,11 +1289,12 @@ func TestServiceRemoveSpaceMember_FormerTeamMemberRemovable(t *testing.T) { activeID := mmmodel.NewId() formerID := mmmodel.NewId() + mockAPI.On("GetChannelMember", space.ChannelId, formerID).Return(&mmmodel.ChannelMember{ChannelId: space.ChannelId, UserId: formerID}, nil) mockAPI.On("GetChannelMembers", space.ChannelId, 0, app.PerPageMaximum). Return(mmmodel.ChannelMembers{{ChannelId: space.ChannelId, UserId: activeID}, {ChannelId: space.ChannelId, UserId: formerID}}, nil) mockAPI.On("DeleteChannelMember", space.ChannelId, formerID).Return(nil) - appErr := h.svc.RemoveSpaceMember(space, formerID) + appErr := h.svc.RemoveSpaceMember(space, formerID, "") require.Nil(t, appErr) mockAPI.AssertCalled(t, "DeleteChannelMember", space.ChannelId, formerID) } @@ -960,7 +1328,7 @@ func TestServiceMovePage_PublishesMovedEvent(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) parent := mustCreatePage(t, h.store, space.Id, channelID, userID, "") page := mustCreatePage(t, h.store, space.Id, channelID, userID, "") diff --git a/server/app/ws_events.go b/server/app/ws_events.go index 62b0f8a..12634c4 100644 --- a/server/app/ws_events.go +++ b/server/app/ws_events.go @@ -11,15 +11,21 @@ import ( // can refresh the affected space list and page tree without a full reload. The platform prepends // "custom__" to each name on the wire, so the names carry no redundant plugin prefix. // -// Every event is scoped to the space's backing channel, matching the visibility boundary the REST -// API enforces: reads are membership-gated (CheckSpaceMembership) and the team space list is -// filtered to the caller's channel memberships, so a broader broadcast would leak space existence -// and activity to users who cannot read the space. For the same reason a cross-space move -// publishes a separate payload to each side — the source channel learns only the source space's -// half (source space and old parent), the target channel only the target's half — rather than one -// payload naming both spaces. A member removal is additionally sent to the -// removed user directly, who has already left the channel when the channel-scoped broadcast fires -// and would otherwise never learn of it. space_deleted is likewise delivered to each member +// Every event is scoped to the space's backing channel. Reads are not purely +// membership-gated (an open space also allows non-member reads via the team read_public_channel +// fall-through, and the team space list includes open spaces the caller hasn't joined), but WS +// delivery deliberately stays channel-scoped: a non-member reader of an open space receives no +// live updates until a write auto-joins them. This is narrower than the read set — an accepted +// UX consequence, not a leak, since the same caller can always re-fetch. For the same reason a +// cross-space move publishes a separate payload to each side — the source channel learns only the +// source space's half (source space and old parent), the target channel only the target's half — +// rather than one payload naming both spaces. A member removal is additionally sent to the removed +// user directly, who has already left the channel when the channel-scoped broadcast fires and +// would otherwise never learn of it. space_member_added and space_member_capabilities_updated are +// sent to the affected user directly for the mirror-image reason: the channel-scoped broadcast +// resolves recipients on a space ("S") channel, which may not yet include a member added moments +// earlier, and that user has no other signal that their access changed. +// space_deleted is likewise delivered to each member // directly, from a snapshot taken before the backing channel is archived — channel-scoped // delivery resolves recipients from live channels only, so a broadcast to the just-archived // channel would reach nobody. A space delete or restore cascades to every live page in @@ -40,12 +46,13 @@ const ( // follow-up fetch. wsEventPagePresenceUpdated = "page_presence_updated" - wsEventSpaceCreated = "space_created" - wsEventSpaceUpdated = "space_updated" - wsEventSpaceDeleted = "space_deleted" - wsEventSpaceRestored = "space_restored" - wsEventSpaceMemberAdded = "space_member_added" - wsEventSpaceMemberRemoved = "space_member_removed" + wsEventSpaceCreated = "space_created" + wsEventSpaceUpdated = "space_updated" + wsEventSpaceDeleted = "space_deleted" + wsEventSpaceRestored = "space_restored" + wsEventSpaceMemberAdded = "space_member_added" + wsEventSpaceMemberRemoved = "space_member_removed" + wsEventSpaceMemberCapabilitiesUpdated = "space_member_capabilities_updated" ) // publishToChannels publishes a WebSocket event broadcast to each non-empty, distinct channel ID. diff --git a/server/app/ws_events_test.go b/server/app/ws_events_test.go index 262a4b9..e497247 100644 --- a/server/app/ws_events_test.go +++ b/server/app/ws_events_test.go @@ -29,7 +29,7 @@ func TestServiceUpdatePage_PublishesUpdatedEvent(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) page := mustCreatePage(t, h.store, space.Id, channelID, userID, "") title := "Renamed" @@ -49,7 +49,7 @@ func TestServiceDeletePage_PublishesDeletedEvent(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) page := mustCreatePage(t, h.store, space.Id, channelID, userID, "") require.Nil(t, h.svc.DeletePage(page.Id, space.Id, userID)) @@ -67,7 +67,7 @@ func TestServiceDeletePage_FailurePublishesNothing(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) page := mustCreatePage(t, h.store, space.Id, channelID, userID, "") appErr := h.svc.DeletePage(page.Id, mmmodel.NewId(), userID) @@ -85,7 +85,7 @@ func TestServiceRestorePage_PublishesRestoredEvent(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) page := mustCreatePage(t, h.store, space.Id, channelID, userID, "") require.Nil(t, h.svc.DeletePage(page.Id, space.Id, userID)) @@ -105,7 +105,7 @@ func TestServiceDuplicatePage_PublishesDuplicatedEvent(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) page := mustCreatePage(t, h.store, space.Id, channelID, userID, "") copyRoot, appErr := h.svc.DuplicatePage(page.Id, space, userID, false, nil, nil) @@ -129,12 +129,12 @@ func TestServiceMovePageToSpace_PublishesEventToBothChannels(t *testing.T) { teamID := mmmodel.NewId() userID := mmmodel.NewId() chA := mmmodel.NewId() - spaceA := seedSpaceForTeam(t, h.store, chA, teamID) + spaceA := seedSpaceForTeam(t, h.store, h.db, chA, teamID) chB := mmmodel.NewId() - spaceB := seedSpaceForTeam(t, h.store, chB, teamID) + spaceB := seedSpaceForTeam(t, h.store, h.db, chB, teamID) page := mustCreatePage(t, h.store, spaceA.Id, chA, userID, "") - moved, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, nil, new(page.UpdateAt), false, userID) + moved, appErr := h.svc.MovePageToSpace(page.Id, spaceA, spaceB, nil, new(page.UpdateAt), false, userID, "") require.Nil(t, appErr) mockAPI.AssertCalled(t, "PublishWebSocketEvent", "page_moved_to_space", @@ -164,10 +164,10 @@ func TestServiceMovePageToSpace_NoOpPublishesNothing(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) page := mustCreatePage(t, h.store, space.Id, channelID, userID, "") - same, appErr := h.svc.MovePageToSpace(page.Id, space, space, nil, new(page.UpdateAt), false, userID) + same, appErr := h.svc.MovePageToSpace(page.Id, space, space, nil, new(page.UpdateAt), false, userID, "") require.Nil(t, appErr) require.Equal(t, page.Id, same.Id) @@ -184,10 +184,10 @@ func TestServiceUpdatePageDraft_PublishesPresenceEvent(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) // Use a published page so UpdatePageDraft takes the channel-broadcast path. - page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + page := publishNewPage(t, h, space, userID, "Doc", "v1") _, appErr := h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", @@ -218,8 +218,8 @@ func TestServiceUpdatePageDraft_PresenceBroadcastThrottled(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) - page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + space := mustCreateSpace(t, h.store, h.db, channelID) + page := publishNewPage(t, h, space, userID, "Doc", "v1") presenceBroadcasts := func() int { n := 0 @@ -254,9 +254,9 @@ func TestServicePublishPageDraft_PublishesCreatedEvent(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) - page := publishNewPage(t, h, space.Id, userID, "Doc", "hello") + page := publishNewPage(t, h, space, userID, "Doc", "hello") mockAPI.AssertCalled(t, "PublishWebSocketEvent", "page_created", map[string]any{"page_id": page.Id, "space_id": space.Id, "parent_id": page.ParentId}, @@ -284,8 +284,8 @@ func TestServicePublishPageDraft_PublishesUpdatedEvent(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) - page := publishNewPage(t, h, space.Id, userID, "Doc", "original") + space := mustCreateSpace(t, h.store, h.db, channelID) + page := publishNewPage(t, h, space, userID, "Doc", "original") // Start an edit session against the live page's baseline, then publish it. _, appErr := h.svc.UpdatePageDraft(&model.Draft{ @@ -294,7 +294,7 @@ func TestServicePublishPageDraft_PublishesUpdatedEvent(t *testing.T) { }, nil, nil, nil, "") require.Nil(t, appErr) - republished, wasCreated, appErr := h.svc.PublishPageDraft(userID, space.Id, page.Id, false) + republished, wasCreated, appErr := h.svc.PublishPageDraft(space, userID, page.Id, false, app.ReadViaMember) require.Nil(t, appErr) require.False(t, wasCreated, "publishing an edit to an existing page must report wasCreated=false") @@ -325,10 +325,10 @@ func TestServiceDeletePageDraft_PublishesPresenceEvent(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) // Use a published page so the edit-draft delete takes the channel-broadcast path. - page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + page := publishNewPage(t, h, space, userID, "Doc", "v1") _, appErr := h.svc.UpdatePageDraft(&model.Draft{ UserId: userID, SpaceId: space.Id, PageId: page.Id, Title: "Doc", @@ -360,7 +360,7 @@ func TestServiceUpdatePageDraft_NewPageDraftPublishesToUserOnly(t *testing.T) { channelID := mmmodel.NewId() userID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) // Create a new-page draft — no published page row exists yet. draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Unpublished", "") @@ -397,7 +397,7 @@ func TestServiceDeletePageDraft_NewPageDraftClearsSelfPresenceOnly(t *testing.T) channelID := mmmodel.NewId() userID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) // Create a new-page draft — no published page row exists yet. draft, appErr := h.svc.CreateSpaceDraft(userID, space.Id, "Unpublished", "") @@ -436,8 +436,8 @@ func TestServiceUpdatePageDraft_PresenceRateLimitSuppressesSecondBroadcast(t *te channelID := mmmodel.NewId() userID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) - page := publishNewPage(t, h, space.Id, userID, "Doc", "v1") + space := mustCreateSpace(t, h.store, h.db, channelID) + page := publishNewPage(t, h, space, userID, "Doc", "v1") // Reset call log so only the two autosaves below are counted. mockAPI.Calls = nil @@ -488,13 +488,13 @@ func TestServiceUpdateSpace_PublishesUpdatedEvent(t *testing.T) { mockAPI := &plugintest.API{} h := openTestServiceWithAPI(t, mockAPI) - space := mustCreateSpace(t, h.store, mmmodel.NewId()) + space := mustCreateSpace(t, h.store, h.db, mmmodel.NewId()) // The post-update channel-metadata sync looks the backing channel up; a nil channel makes it // a no-op without needing channel-update expectations. mockAPI.On("GetChannelOfType", mock.Anything, mock.Anything).Return((*mmmodel.Channel)(nil), nil) title := "Renamed Space" - updated, appErr := h.svc.UpdateSpace(space, &model.SpacePatch{Title: &title}, new(space.UpdateAt), false) + updated, appErr := h.svc.UpdateSpace(space, &model.SpacePatch{Title: &title}, new(space.UpdateAt), false, "") require.Nil(t, appErr) mockAPI.AssertCalled(t, "PublishWebSocketEvent", "space_updated", @@ -512,7 +512,7 @@ func TestServiceDeleteSpace_PublishesDeletedEvent(t *testing.T) { h := openTestServiceWithAPI(t, mockAPI) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) memberA := mmmodel.NewId() memberB := mmmodel.NewId() mockAPI.On("GetChannelMembers", channelID, 0, app.PerPageMaximum). @@ -543,7 +543,7 @@ func TestServiceDeleteSpace_SnapshotFailureFallsBackToChannelBroadcast(t *testin h := openTestServiceWithAPI(t, mockAPI) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) mockAPI.On("GetChannelMembers", channelID, 0, app.PerPageMaximum). Return(nil, &mmmodel.AppError{Message: "boom", StatusCode: 500}) mockAPI.On("DeleteChannel", channelID).Return(nil) @@ -578,7 +578,7 @@ func TestServiceRestoreSpace_PublishesRestoredEvent(t *testing.T) { h := openTestServiceWithAPI(t, mockAPI) channelID := mmmodel.NewId() - space := mustCreateSpace(t, h.store, channelID) + space := mustCreateSpace(t, h.store, h.db, channelID) mockAPI.On("GetChannelMembers", channelID, 0, app.PerPageMaximum).Return(mmmodel.ChannelMembers{}, nil) mockAPI.On("DeleteChannel", channelID).Return(nil) mockAPI.On("RestoreChannel", channelID).Return(nil) @@ -592,9 +592,10 @@ func TestServiceRestoreSpace_PublishesRestoredEvent(t *testing.T) { &mmmodel.WebsocketBroadcast{ChannelId: restored.ChannelId}) } -// TestServiceAddSpaceMember_PublishesMemberAddedEvent pins space_member_added on add: -// space/user payload, broadcast scoped to the backing channel (matching the membership-gated -// reads, like every other space and page event). +// TestServiceAddSpaceMember_PublishesMemberAddedEvent pins space_member_added on add: its +// space/user payload and its two delivery targets — the channel-scoped broadcast for observers +// (matching the membership-gated reads, like every other space and page event), and the direct +// publish that reaches the added user, whom the channel-scoped resolution may not yet include. func TestServiceAddSpaceMember_PublishesMemberAddedEvent(t *testing.T) { mockAPI := &plugintest.API{} h := openTestServiceWithAPI(t, mockAPI) @@ -607,9 +608,11 @@ func TestServiceAddSpaceMember_PublishesMemberAddedEvent(t *testing.T) { member, appErr := h.svc.AddSpaceMember(space, newUserID) require.Nil(t, appErr) - mockAPI.AssertCalled(t, "PublishWebSocketEvent", "space_member_added", - map[string]any{"space_id": space.Id, "user_id": member.UserId}, + payload := map[string]any{"space_id": space.Id, "user_id": member.UserId} + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "space_member_added", payload, &mmmodel.WebsocketBroadcast{ChannelId: space.ChannelId}) + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "space_member_added", payload, + &mmmodel.WebsocketBroadcast{UserId: member.UserId}) } // TestServiceRemoveSpaceMember_PublishesMemberRemovedEvent pins space_member_removed on remove: @@ -621,6 +624,7 @@ func TestServiceRemoveSpaceMember_PublishesMemberRemovedEvent(t *testing.T) { space, creatorID := createSpaceForMemberTests(t, h, mockAPI) targetID := mmmodel.NewId() + mockAPI.On("GetChannelMember", space.ChannelId, targetID).Return(&mmmodel.ChannelMember{ChannelId: space.ChannelId, UserId: targetID}, nil) mockAPI.On("GetChannelMembers", space.ChannelId, 0, app.PerPageMaximum). Return(mmmodel.ChannelMembers{ {ChannelId: space.ChannelId, UserId: creatorID}, @@ -628,7 +632,7 @@ func TestServiceRemoveSpaceMember_PublishesMemberRemovedEvent(t *testing.T) { }, nil) mockAPI.On("DeleteChannelMember", space.ChannelId, targetID).Return(nil) - require.Nil(t, h.svc.RemoveSpaceMember(space, targetID)) + require.Nil(t, h.svc.RemoveSpaceMember(space, targetID, "")) mockAPI.AssertCalled(t, "PublishWebSocketEvent", "space_member_removed", map[string]any{"space_id": space.Id, "user_id": targetID}, @@ -637,3 +641,30 @@ func TestServiceRemoveSpaceMember_PublishesMemberRemovedEvent(t *testing.T) { map[string]any{"space_id": space.Id, "user_id": targetID}, &mmmodel.WebsocketBroadcast{UserId: targetID}) } + +// TestServiceSetSpaceMemberCapabilities_PublishesToChannelAndUser pins +// space_member_capabilities_updated's two delivery targets. The direct publish is what guarantees +// the target learns its own capabilities changed, so a regression to channel-only delivery would +// leave exactly the affected user uninformed. +func TestServiceSetSpaceMemberCapabilities_PublishesToChannelAndUser(t *testing.T) { + mockAPI := &plugintest.API{} + h := openTestServiceWithAPI(t, mockAPI) + space, actingUserID := createSpaceForMemberTests(t, h, mockAPI) + targetUserID := mmmodel.NewId() + + // A non-admin capability granted to another non-admin member reaches neither the + // admin-affecting nor the self-targeted arm of the escalation guard, so no space-admin + // permission is read here. + mockAPI.On("GetChannelMember", space.ChannelId, targetUserID). + Return(&mmmodel.ChannelMember{ChannelId: space.ChannelId, UserId: targetUserID}, nil) + + _, appErr := h.svc.SetSpaceMemberCapabilities(space, targetUserID, + []string{model.CapabilityEditPage}, actingUserID) + require.Nil(t, appErr) + + payload := map[string]any{"space_id": space.Id, "user_id": targetUserID} + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "space_member_capabilities_updated", payload, + &mmmodel.WebsocketBroadcast{UserId: targetUserID}) + mockAPI.AssertCalled(t, "PublishWebSocketEvent", "space_member_capabilities_updated", payload, + &mmmodel.WebsocketBroadcast{ChannelId: space.ChannelId}) +} diff --git a/server/e2e/README.md b/server/e2e/README.md new file mode 100644 index 0000000..1535ddd --- /dev/null +++ b/server/e2e/README.md @@ -0,0 +1,56 @@ +# Docs plugin — RBAC end-to-end suite + +This is the **official** end-to-end suite for the docs plugin's space-permission RBAC model +(MM-69269). It is Go + [Testcontainers](https://golang.testcontainers.org/), and boots a real +Mattermost server with the plugin installed — no mocks. + +It covers the seven canonical Confluence permission scenarios plus their named parity gaps, and is +the authoritative behavioral spec for them. + +## Why a locally built core image + +The core changes this epic depends on (the `Space` channel type, the atomic per-page capability +roles, the seeded preset schemes) live on an **unmerged** core branch, so no published Mattermost +image carries them yet. `build/build-core-image.sh` cross-compiles `./cmd/mattermost` from that +branch and packages it into a minimal Docker image (`$CORE_IMAGE`, default `mm-docs-rbac-core:dev`). + +Building locally is only needed for a branch that has not been pushed. Core's own CI publishes a +server image for every commit as `mattermostdevelopment/mattermost-team-edition:<7-char-sha>`, so +once the paired core change is up as a PR, `CORE_IMAGE` can point at that tag and no local image +build is required — that is exactly how the `e2e` job in `.github/workflows/ci.yml` runs, reading +the tag from the `CORE_IMAGE` repository variable. Once those core changes merge and ship in a +release, point `CORE_IMAGE` at the released image instead and this suite runs unchanged. + +A bare tag (no `/`) is treated as locally built: if it is absent the suite fails immediately and +names the build script, rather than letting Testcontainers fail mid-boot. A namespaced tag is +assumed pullable and is left to Testcontainers to fetch. + +## Running it + +Requires Docker. + +```sh +make test-e2e +``` + +This builds the core image (`build/build-core-image.sh`), ensures the plugin bundle exists +(`make dist` if missing), then runs: + +```sh +go test -tags e2e -count=1 -v ./server/e2e/... +``` + +The container boots once for the whole suite (container startup is slow) and is torn down after +all tests finish. Every scenario creates its own space and it is deleted at the end of the run. + +## Known environment gap + +Scenario 6 (read-only guest reviewer) drives the real `POST /users/{id}/demote` core endpoint. +That endpoint requires an Enterprise license (`api.team.demote_user_to_guest.license.error`), and +the core image built by `build/build-core-image.sh` has none. In that case the subtest skips +without asserting the guest-specific behavior, so the run reports it as SKIP rather than as a pass +that covered nothing. It does not fail the suite. This is the one place the repo's no-skip rule is +waived, and the waiver is marked `//nolint:forbidigo` at the call: the rule exists to stop a +missing prerequisite from going unreported, which a silent early return does more thoroughly than +a skip. Supplying a real EE license to the container (extend `startEnv` in `container_test.go` +with `mmcontainer.WithLicense`) would let this subtest run to completion. diff --git a/server/e2e/container_test.go b/server/e2e/container_test.go new file mode 100644 index 0000000..1a09060 --- /dev/null +++ b/server/e2e/container_test.go @@ -0,0 +1,213 @@ +//go:build e2e + +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Package e2e is the official end-to-end suite for the docs plugin's space-permission RBAC model +// (see README.md in this directory). It boots a real Mattermost server (built from the paired core +// branch that carries the space-permission changes) via Testcontainers, installs the plugin into +// it, and drives the seven Confluence permission scenarios plus their named parity gaps through the +// real HTTP API — no mocks. Build with -tags e2e (see `make test-e2e`); it is excluded from +// `go test ./...` and CI's default run by the build tag. +package e2e + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + mmcontainer "github.com/mattermost/testcontainers-mattermost-go" +) + +// pluginID is the Docs plugin's manifest id (see plugin.json at the repo root). +const pluginID = "com.mattermost.docs" + +// defaultCoreImage matches the CORE_IMAGE default in build/build-core-image.sh — the image it +// produces from the paired core branch until those changes merge and ship in a published release +// image, at which point CORE_IMAGE can point at that instead. Override with the CORE_IMAGE env var. +const defaultCoreImage = "mm-docs-rbac-core:dev" + +// testEnv holds the single Mattermost+plugin container shared by every scenario in this package. +type testEnv struct { + container *mmcontainer.MattermostContainer + baseURL string + adminClient *mmmodel.Client4 +} + +var ( + sharedEnv *testEnv + sharedEnvErr error + sharedEnvOnce sync.Once +) + +// TestMain boots the shared container once for the whole package and tears it down after every +// test has run — container startup is slow (a full Mattermost + Postgres + plugin install), so it +// must not happen per scenario. +func TestMain(m *testing.M) { + code := m.Run() + + if sharedEnv != nil && sharedEnv.container != nil { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + if err := sharedEnv.container.Terminate(ctx); err != nil { + fmt.Printf("failed to terminate shared e2e container: %v\n", err) + } + cancel() + } + + os.Exit(code) +} + +// getEnv returns the shared test environment, starting the container on first use. +func getEnv(t *testing.T) *testEnv { + t.Helper() + sharedEnvOnce.Do(func() { + sharedEnv, sharedEnvErr = startEnv() + }) + if sharedEnvErr != nil { + t.Fatalf("failed to start shared Mattermost+plugin container: %v", sharedEnvErr) + } + return sharedEnv +} + +// startEnv resolves the plugin bundle and the core image, then boots the container. +func startEnv() (*testEnv, error) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + bundlePath, err := resolveBundlePath() + if err != nil { + return nil, err + } + + imageTag := os.Getenv("CORE_IMAGE") + if imageTag == "" { + imageTag = defaultCoreImage + } + if imgErr := checkImageExists(imageTag); imgErr != nil { + return nil, imgErr + } + + container, err := mmcontainer.RunContainer(ctx, + withImage(imageTag), + mmcontainer.WithPlugin(bundlePath, pluginID, nil), + mmcontainer.WithEnv("MM_FEATUREFLAGS_ENABLEDOCS", "true"), + ) + if err != nil { + return nil, fmt.Errorf("failed to start Mattermost container: %w", err) + } + + // Teardown runs on its own context: the likeliest failure below is ctx's own deadline expiring, + // and Terminate cannot clean up on an already-expired context — leaving a Mattermost+Postgres + // pair running on the CI agent or dev machine. + terminate := func() { + termCtx, termCancel := context.WithTimeout(context.Background(), 60*time.Second) + defer termCancel() + _ = container.Terminate(termCtx) + } + + baseURL, err := container.URL(ctx) + if err != nil { + terminate() + return nil, fmt.Errorf("failed to resolve container URL: %w", err) + } + + adminClient, err := container.GetAdminClient(ctx) + if err != nil { + terminate() + return nil, fmt.Errorf("failed to get admin client: %w", err) + } + + // Core's advanced-permissions phase-2 migration (which seeds the scheme/role state the space + // admin-role assignment in CreateSpace depends on) runs on an async scheduler job, not + // synchronously at boot — mmcontainer's wait strategy only waits for the "Server is listening" + // log line, well before that job's first run. Without this, the very first CreateSpace call + // fails with app.space.create.admin_role_failed.app_error wrapping + // app.schemes.is_phase_2_migration_completed.not_completed.app_error. + if err := waitForPhase2Migration(ctx, adminClient); err != nil { + terminate() + return nil, err + } + + return &testEnv{container: container, baseURL: baseURL, adminClient: adminClient}, nil +} + +// waitForPhase2Migration polls a Phase2-migration-gated endpoint until it stops reporting +// "not completed", or the deadline passes. +func waitForPhase2Migration(ctx context.Context, adminClient *mmmodel.Client4) error { + deadline := time.Now().Add(2 * time.Minute) + var lastErr error + for time.Now().Before(deadline) { + _, _, err := adminClient.GetSchemes(ctx, "", 0, 1) + if err == nil { + return nil + } + lastErr = err + select { + case <-ctx.Done(): + return fmt.Errorf("waiting for advanced-permissions phase-2 migration: %w (last error: %v)", ctx.Err(), lastErr) + case <-time.After(2 * time.Second): + } + } + return fmt.Errorf("advanced-permissions phase-2 migration did not complete within 2 minutes: %w", lastErr) +} + +// resolveBundlePath globs dist/ (relative to this package's directory) for the built plugin +// bundle, failing with a clear pointer to `make dist` when it is absent. Several bundles can sit +// there (a version bump, or a stale host-only bundle from `make server`), so the one with the +// newest modification time is selected — the same one `make test-e2e` inspects before deciding +// to rebuild. +func resolveBundlePath() (string, error) { + matches, err := filepath.Glob("../../dist/" + pluginID + "-*.tar.gz") + if err != nil { + return "", fmt.Errorf("failed to glob plugin bundle: %w", err) + } + if len(matches) == 0 { + return "", fmt.Errorf("no plugin bundle found at dist/%s-*.tar.gz — run `make dist` first", pluginID) + } + newest, newestMod := "", time.Time{} + for _, match := range matches { + info, statErr := os.Stat(match) + if statErr != nil { + continue + } + if newest == "" || info.ModTime().After(newestMod) { + newest, newestMod = match, info.ModTime() + } + } + if newest == "" { + return "", fmt.Errorf("no readable plugin bundle at dist/%s-*.tar.gz", pluginID) + } + return newest, nil +} + +// checkImageExists fails clearly (naming the build script) rather than letting Testcontainers +// surface an opaque "no such image" error mid-boot. +// +// A locally built image has a bare tag and can only come from the build script, so its absence is +// a hard error. A namespaced tag (registry/repo:tag) is one CI publishes per core commit, so +// Testcontainers can pull it and a local miss is expected rather than a failure. +func checkImageExists(imageTag string) error { + cmd := exec.Command("docker", "image", "inspect", imageTag) // #nosec -- imageTag is test config (CORE_IMAGE env var), not untrusted input + if err := cmd.Run(); err == nil { + return nil + } + if strings.Contains(imageTag, "/") { + return nil + } + return fmt.Errorf("core image %q not found — build it with ./build/build-core-image.sh (CORE_IMAGE=%s)", imageTag, imageTag) +} + +// withImage overrides the image RunContainer would otherwise use (a stock Mattermost release, +// which does not carry this epic's unmerged core changes) with the locally built core image. +func withImage(img string) mmcontainer.MattermostCustomizeRequestOption { + return func(req *mmcontainer.MattermostContainerRequest) { + req.Image = img + } +} diff --git a/server/e2e/helpers_test.go b/server/e2e/helpers_test.go new file mode 100644 index 0000000..cd8ac44 --- /dev/null +++ b/server/e2e/helpers_test.go @@ -0,0 +1,179 @@ +//go:build e2e + +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package e2e + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "testing" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// pluginAPIBase is the Docs plugin's own HTTP route prefix. It is served directly off the server +// root (/plugins//api/v1/...), not under Client4's /api/v4 prefix, so none of +// Client4's exported Do* helpers (which all prepend APIURL) can address it directly. +const pluginAPIBase = "/plugins/" + pluginID + "/api/v1" + +const actorPassword = "E2e-actor-pass1!" // #nosec G101 -- test-only fixture password, not a credential + +// doPluginRequest issues an HTTP request against the Docs plugin's routes and decodes a non-empty +// JSON response body into out (when non-nil). It wraps Client4's lowest-level exported primitives +// (URL, AuthToken, AuthType, HTTPClient) directly — the same primitives Client4's own +// DoAPIRequestWithHeaders uses internally, minus the APIURL prefix that does not apply here. The +// raw response body is always returned too, so a caller can assert on it directly (e.g. comparing +// two error bodies byte-for-byte). +func doPluginRequest(ctx context.Context, client *mmmodel.Client4, method, path string, body, out any) (int, []byte, error) { + var reader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return 0, nil, err + } + reader = bytes.NewReader(b) + } + + req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(client.URL, "/")+pluginAPIBase+path, reader) + if err != nil { + return 0, nil, err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if client.AuthToken != "" { + req.Header.Set("Authorization", client.AuthType+" "+client.AuthToken) + } + + resp, err := client.HTTPClient.Do(req) + if err != nil { + return 0, nil, err + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return resp.StatusCode, nil, err + } + if out != nil && len(respBody) > 0 { + if err := json.Unmarshal(respBody, out); err != nil { + return resp.StatusCode, respBody, fmt.Errorf("failed to decode response body %q: %w", respBody, err) + } + } + return resp.StatusCode, respBody, nil +} + +// actor is a logged-in team member driving requests against the plugin. +type actor struct { + id string + client *mmmodel.Client4 +} + +// createActor creates a real user, adds it to teamID, and logs it in. +func createActor(t *testing.T, ctx context.Context, env *testEnv, teamID, username string) actor { + t.Helper() + + user := &mmmodel.User{ + Username: username, + Email: username + "@example.com", + Password: actorPassword, + } + created, _, err := env.adminClient.CreateUser(ctx, user) + require.NoError(t, err, "failed to create actor %s", username) + + _, _, err = env.adminClient.AddTeamMember(ctx, teamID, created.Id) + require.NoError(t, err, "failed to add actor %s to team", username) + + client := mmmodel.NewAPIv4Client(env.baseURL) + _, _, err = client.Login(ctx, username, actorPassword) + require.NoError(t, err, "failed to log in actor %s", username) + + return actor{id: created.Id, client: client} +} + +// addSpaceMember adds userID as a plain (default-only) space member via the space admin actor, +// failing loudly if the add did not round-trip with the expected 201. +func addSpaceMember(t *testing.T, ctx context.Context, admin actor, spaceID, userID string) { + t.Helper() + status, body, err := doPluginRequest(ctx, admin.client, http.MethodPost, "/spaces/"+spaceID+"/members", + map[string]string{"user_id": userID}, nil) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "add member %s to space %s: %s", userID, spaceID, body) +} + +// spaceMembersResponse is the paginated member-list shape handleGetSpaceMembers writes (see +// paginatedResponse in server/api.go) — redeclared here since that type is unexported. +type spaceMembersResponse struct { + Items []*spaceMemberJSON `json:"items"` +} + +// spaceMemberJSON is a minimal decode target for the member-list response; only user_id is needed +// here. +type spaceMemberJSON struct { + UserId string `json:"user_id"` +} + +// spaceHasMember reports whether userID currently appears in spaceID's member list, as resolved +// by the space admin actor. +func spaceHasMember(t *testing.T, ctx context.Context, admin actor, spaceID, userID string) bool { + t.Helper() + var resp spaceMembersResponse + status, body, err := doPluginRequest(ctx, admin.client, http.MethodGet, "/spaces/"+spaceID+"/members", nil, &resp) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "list members of space %s: %s", spaceID, body) + for _, m := range resp.Items { + if m.UserId == userID { + return true + } + } + return false +} + +// deleteSpace deletes spaceID via the space admin actor. Called at the end of TestScenarios via a +// shared t.Cleanup over every space id the scenarios accumulated in spacesToClean. +// deleteSpace removes a space during teardown. A failure is reported and marks the test failed, but +// does not abort the calling goroutine: teardown deletes a list of spaces, and require's FailNow +// would skip every space after the first failure, leaving the rest behind. +func deleteSpace(t *testing.T, ctx context.Context, admin actor, spaceID string) { + t.Helper() + status, body, err := doPluginRequest(ctx, admin.client, http.MethodDelete, "/spaces/"+spaceID, nil, nil) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, http.StatusOK, status, "cleanup: delete space %s: %s", spaceID, body) +} + +// pageDocBody is a minimal valid page body (see model.Page.IsValid — only size-limited, not +// schema-validated). +const pageDocBody = `{"type":"doc","content":[]}` + +// createPageReq builds a minimal valid page-create body. +func createPageReq(title string) map[string]any { + return map[string]any{"title": title, "body": pageDocBody} +} + +// editPageReq builds a minimal valid page-update body; body and search_text must both be present +// together (see PagePatch.IsValid's cross-check). +func editPageReq(baseEditAt int64, searchText string) map[string]any { + return map[string]any{"base_edit_at": baseEditAt, "body": pageDocBody, "search_text": searchText} +} + +// appErrorID extracts the AppError id from a plugin JSON error body, for asserting on the specific +// error rather than just the status code. +func appErrorID(body []byte) string { + var appErr *mmmodel.AppError + if !errors.As(mmmodel.AppErrorFromJSON(bytes.NewReader(body)), &appErr) { + return "" + } + return appErr.Id +} diff --git a/server/e2e/scenarios_test.go b/server/e2e/scenarios_test.go new file mode 100644 index 0000000..0101953 --- /dev/null +++ b/server/e2e/scenarios_test.go @@ -0,0 +1,615 @@ +//go:build e2e + +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package e2e + +import ( + "context" + "errors" + "fmt" + "net/http" + "slices" + "strings" + "testing" + "time" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/require" + + pluginmodel "github.com/mattermost/mattermost-plugin-docs/server/model" +) + +// TestScenarios runs the seven canonical Confluence space-permission scenarios plus the named +// parity gaps against a real Mattermost server (built from the paired core branch) with the +// plugin installed, via Testcontainers. This suite is the authoritative behavioral spec for those +// scenarios. +func TestScenarios(t *testing.T) { + env := getEnv(t) + ctx := context.Background() + + teamName := fmt.Sprintf("e2e-scenario-%d", time.Now().UnixNano()) + team, _, err := env.adminClient.CreateTeam(ctx, &mmmodel.Team{ + Name: teamName, + DisplayName: "E2E Scenario Smoke", + Type: mmmodel.TeamOpen, + }) + require.NoError(t, err, "failed to create scenario team") + + ts := time.Now().UnixNano() + spaceAdmin := createActor(t, ctx, env, team.Id, fmt.Sprintf("scn-admin-%d", ts)) + contrib := createActor(t, ctx, env, team.Id, fmt.Sprintf("scn-contrib-%d", ts)) + member := createActor(t, ctx, env, team.Id, fmt.Sprintf("scn-member-%d", ts)) + outsider := createActor(t, ctx, env, team.Id, fmt.Sprintf("scn-outsider-%d", ts)) + guestCandidate := createActor(t, ctx, env, team.Id, fmt.Sprintf("scn-guest-%d", ts)) + + // spacesToClean accumulates every space id created below; deleted once at the very end (after + // the gap subtests, which continue off scenario 1's space) rather than per-subtest, since a + // t.Cleanup registered on a subtest's own *testing.T fires when that subtest finishes — too + // early for a space a later gap subtest still needs. + var spacesToClean []string + t.Cleanup(func() { + for _, id := range spacesToClean { + deleteSpace(t, ctx, spaceAdmin, id) + } + }) + + var s1ID string // captured for the gap subtests, which continue off scenario 1's space. + + t.Run("scenario1_open_wiki", func(t *testing.T) { + var space pluginmodel.Space + status, body, err := doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/teams/"+team.Id+"/spaces", + map[string]string{"title": "Scenario Open Wiki"}, &space) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "createSpace failed: %s", body) + require.Regexp(t, "^[a-z0-9]{26}$", space.Id) + require.Equal(t, pluginmodel.ViewAccessOpen, space.ViewAccess, "expected create-time default view_access=open") + s1ID = space.Id + spacesToClean = append(spacesToClean, s1ID) + + addSpaceMember(t, ctx, spaceAdmin, s1ID, member.id) + + status, body, err = doPluginRequest(ctx, member.client, http.MethodGet, "/spaces/"+s1ID, nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "existing member read: %s", body) + + var page pluginmodel.Page + status, body, err = doPluginRequest(ctx, member.client, http.MethodPost, "/spaces/"+s1ID+"/pages", + createPageReq("Open Wiki Page"), &page) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "member create page failed: %s", body) + require.Regexp(t, "^[a-z0-9]{26}$", page.Id) + + status, body, err = doPluginRequest(ctx, member.client, http.MethodPatch, "/spaces/"+s1ID+"/pages/"+page.Id, + editPageReq(page.EditAt, "edited by an existing member"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "existing member edit: %s", body) + + // Headline assertion: a team member who is not yet a space member is auto-joined by their + // first default-granted write. + status, body, err = doPluginRequest(ctx, outsider.client, http.MethodPost, "/spaces/"+s1ID+"/pages", + createPageReq("Drive-by page"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "space non-member create: %s", body) + require.True(t, spaceHasMember(t, ctx, spaceAdmin, s1ID, outsider.id), + "space non-member's drive-by write did not auto-join them (real GetChannelMember/AddChannelMember round-trip)") + }) + + t.Run("scenario2_knowledge_base", func(t *testing.T) { + var space pluginmodel.Space + status, body, err := doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/teams/"+team.Id+"/spaces", + map[string]string{"title": "Scenario Knowledge Base"}, &space) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "createSpace failed: %s", body) + spacesToClean = append(spacesToClean, space.Id) + + addSpaceMember(t, ctx, spaceAdmin, space.Id, member.id) + addSpaceMember(t, ctx, spaceAdmin, space.Id, contrib.id) + + var roResp pluginmodel.SpaceWithAccess + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPut, "/spaces/"+space.Id+"/default-capabilities", + map[string][]string{"default_capabilities": {}}, &roResp) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "setting read-only default failed: %s", body) + require.Empty(t, roResp.DefaultCapabilities) + + var seed pluginmodel.Page + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("KB Seed Page"), &seed) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "admin seed page failed: %s", body) + + status, _, err = doPluginRequest(ctx, member.client, http.MethodGet, "/spaces/"+space.Id, nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "MEMBER read") + + status, body, err = doPluginRequest(ctx, member.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("Unauthorized"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "ungranted MEMBER create: %s", body) + + status, body, err = doPluginRequest(ctx, member.client, http.MethodPatch, "/spaces/"+space.Id+"/pages/"+seed.Id, + editPageReq(seed.EditAt, "nope"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "ungranted MEMBER edit: %s", body) + + // The real grant surface: the space admin assigns create_page + edit_page to CONTRIB. + var grantResp pluginmodel.SpaceMember + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPut, "/spaces/"+space.Id+"/members/"+contrib.id+"/capabilities", + map[string][]string{"granted_capabilities": {pluginmodel.CapabilityCreatePage, pluginmodel.CapabilityEditPage}}, &grantResp) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "granting CONTRIB capabilities: %s", body) + require.ElementsMatch(t, []string{pluginmodel.CapabilityCreatePage, pluginmodel.CapabilityEditPage}, grantResp.GrantedCapabilities, + "grant round-tripped as %v", grantResp.GrantedCapabilities) + + status, body, err = doPluginRequest(ctx, contrib.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("Contributed"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "granted CONTRIB create: %s", body) + + status, body, err = doPluginRequest(ctx, contrib.client, http.MethodPatch, "/spaces/"+space.Id+"/pages/"+seed.Id, + editPageReq(seed.EditAt, "edited by contributor"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "granted CONTRIB edit: %s", body) + + status, body, err = doPluginRequest(ctx, member.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("Still unauthorized"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "MEMBER still expected 403 after CONTRIB's grant: %s", body) + }) + + t.Run("scenario3_private_team_space", func(t *testing.T) { + var space pluginmodel.Space + status, body, err := doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/teams/"+team.Id+"/spaces", + map[string]any{"title": "Scenario Private Team Space", "view_access": pluginmodel.ViewAccessPrivate}, &space) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "createSpace failed: %s", body) + spacesToClean = append(spacesToClean, space.Id) + + addSpaceMember(t, ctx, spaceAdmin, space.Id, member.id) + // OUTSIDER is deliberately NOT added — it is the non-member control for this scenario. + + status, _, err = doPluginRequest(ctx, member.client, http.MethodGet, "/spaces/"+space.Id, nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "member read") + + var strangerBody map[string]any + status, strangerRaw, err := doPluginRequest(ctx, outsider.client, http.MethodGet, "/spaces/"+space.Id, nil, &strangerBody) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "stranger metadata read: %s", strangerRaw) + + status, _, err = doPluginRequest(ctx, outsider.client, http.MethodGet, "/spaces/"+space.Id+"/pages", nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "stranger page-list read") + + // The identical 403 for a syntactically valid but nonexistent space id — no existence oracle. + nonexistentID := reverseString(space.Id) + var nonexistentBody map[string]any + status, nonexistentRaw, err := doPluginRequest(ctx, outsider.client, http.MethodGet, "/spaces/"+nonexistentID, nil, &nonexistentBody) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "nonexistent-space read: %s", nonexistentRaw) + require.Equal(t, strangerBody, nonexistentBody, + "a real private space's 403 body differs from a nonexistent id's 403 body (existence oracle)") + }) + + t.Run("scenario4_announcement_space", func(t *testing.T) { + var space pluginmodel.Space + status, body, err := doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/teams/"+team.Id+"/spaces", + map[string]any{"title": "Scenario Announcement Space", "default_capabilities": []string{pluginmodel.CapabilityCommentPage}}, &space) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "createSpace failed: %s", body) + spacesToClean = append(spacesToClean, space.Id) + + // The create response is the bare Space (no default_capabilities field); GET the space to + // confirm the comment default actually landed. + var withAccess pluginmodel.SpaceWithAccess + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodGet, "/spaces/"+space.Id, nil, &withAccess) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "get space: %s", body) + require.ElementsMatch(t, []string{pluginmodel.CapabilityCommentPage}, withAccess.DefaultCapabilities, + "comment default not set at create: %s", body) + + addSpaceMember(t, ctx, spaceAdmin, space.Id, member.id) + addSpaceMember(t, ctx, spaceAdmin, space.Id, contrib.id) + + status, _, err = doPluginRequest(ctx, member.client, http.MethodGet, "/spaces/"+space.Id, nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "plain member read") + + status, body, err = doPluginRequest(ctx, member.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("Unauthorized announcement"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "plain member create (comment default grants no create_page): %s", body) + + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPut, "/spaces/"+space.Id+"/members/"+contrib.id+"/capabilities", + map[string][]string{"granted_capabilities": {pluginmodel.CapabilityCreatePage}}, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "granting CONTRIB create_page: %s", body) + + status, body, err = doPluginRequest(ctx, contrib.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("Announcement"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "granted author create: %s", body) + + t.Log("scenario4: the comment halves (a plain member commenting, a granted commenter succeeding) are not asserted — the comments epic has not landed its routes yet, so there is nothing to drive them through.") + }) + + t.Run("scenario5_mixed_matrix", func(t *testing.T) { + var space pluginmodel.Space + status, body, err := doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/teams/"+team.Id+"/spaces", + map[string]any{"title": "Scenario Mixed Matrix", "default_capabilities": []string{}}, &space) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "createSpace failed: %s", body) + spacesToClean = append(spacesToClean, space.Id) + + addSpaceMember(t, ctx, spaceAdmin, space.Id, contrib.id) + addSpaceMember(t, ctx, spaceAdmin, space.Id, member.id) + + var seed pluginmodel.Page + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("Matrix Seed Page"), &seed) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "admin seed page failed: %s", body) + + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPut, "/spaces/"+space.Id+"/members/"+contrib.id+"/capabilities", + map[string][]string{"granted_capabilities": {pluginmodel.CapabilityCreatePage}}, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "granting A create_page: %s", body) + + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPut, "/spaces/"+space.Id+"/members/"+member.id+"/capabilities", + map[string][]string{"granted_capabilities": {pluginmodel.CapabilityEditPage}}, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "granting B edit_page: %s", body) + + status, body, err = doPluginRequest(ctx, contrib.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("By A"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "A (create_page-only) create: %s", body) + + status, body, err = doPluginRequest(ctx, contrib.client, http.MethodPatch, "/spaces/"+space.Id+"/pages/"+seed.Id, + editPageReq(seed.EditAt, "a"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "A (create_page-only) edit: %s", body) + + status, body, err = doPluginRequest(ctx, member.client, http.MethodPatch, "/spaces/"+space.Id+"/pages/"+seed.Id, + editPageReq(seed.EditAt, "b"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "B (edit_page-only) edit: %s", body) + + status, body, err = doPluginRequest(ctx, member.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("By B"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "B (edit_page-only) create: %s", body) + }) + + t.Run("scenario6_readonly_guest_reviewer", func(t *testing.T) { + var space pluginmodel.Space + status, body, err := doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/teams/"+team.Id+"/spaces", + map[string]string{"title": "Scenario Guest Reviewer"}, &space) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "createSpace failed: %s", body) + spacesToClean = append(spacesToClean, space.Id) + + addSpaceMember(t, ctx, spaceAdmin, space.Id, guestCandidate.id) + + cfg, _, err := env.adminClient.GetConfig(ctx) + require.NoError(t, err, "failed to read server config") + originalGuestEnable := cfg.GuestAccountsSettings.Enable != nil && *cfg.GuestAccountsSettings.Enable + + demoteResp, demoteErr := env.adminClient.DemoteUserToGuest(ctx, guestCandidate.id) + if demoteErr != nil { + var appErr *mmmodel.AppError + if !errors.As(demoteErr, &appErr) { + t.Fatalf("scenario6: demote failed with a non-AppError: %v", demoteErr) + } + if strings.Contains(appErr.Id, "license") { + // Skipped rather than returned: a bare return reports this subtest as a pass + // having asserted nothing about the guest flow, which is indistinguishable from + // real coverage. The no-skip rule exists to stop a missing prerequisite from + // being papered over, and an unreported gap papers it over more thoroughly than + // a skip does. + //nolint:forbidigo // a visible gap beats a silent pass; see the note above + t.Skipf("scenario6: guest reviewer flow (real DemoteUserToGuest) needs an Enterprise license — %s: %s. "+ + "The core image built by build/build-core-image.sh has none, and guest accounts require one. "+ + "See README.md.", appErr.Id, appErr.Error()) + } + if !strings.Contains(appErr.Id, "disabled") { + t.Fatalf("scenario6: demote failed unexpectedly: %s (%s)", appErr.Id, appErr.Error()) + } + + t.Logf("scenario6: guest accounts disabled (%s) — enabling GuestAccountsSettings.Enable for this run", appErr.Id) + _, _, patchErr := env.adminClient.PatchConfig(ctx, &mmmodel.Config{GuestAccountsSettings: mmmodel.GuestAccountsSettings{Enable: mmmodel.NewPointer(true)}}) + require.NoError(t, patchErr, "enabling GuestAccountsSettings failed") + t.Cleanup(func() { + _, _, restoreErr := env.adminClient.PatchConfig(ctx, &mmmodel.Config{GuestAccountsSettings: mmmodel.GuestAccountsSettings{Enable: mmmodel.NewPointer(originalGuestEnable)}}) + if restoreErr != nil { + t.Logf("scenario6: failed to restore GuestAccountsSettings.Enable=%v: %v", originalGuestEnable, restoreErr) + } + }) + + demoteResp, demoteErr = env.adminClient.DemoteUserToGuest(ctx, guestCandidate.id) + require.NoError(t, demoteErr, "demote still failed after enabling guest accounts") + } + require.Equal(t, http.StatusOK, demoteResp.StatusCode, "demote expected 200") + + status, body, err = doPluginRequest(ctx, guestCandidate.client, http.MethodGet, "/spaces/"+space.Id, nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "guest read: %s", body) + + status, body, err = doPluginRequest(ctx, guestCandidate.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("Not allowed"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "guest create: %s", body) + + var seed pluginmodel.Page + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("Guest Seed Page"), &seed) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "admin seed page failed: %s", body) + + status, body, err = doPluginRequest(ctx, guestCandidate.client, http.MethodPatch, "/spaces/"+space.Id+"/pages/"+seed.Id, + editPageReq(seed.EditAt, "guest edit"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "guest update: %s", body) + + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPut, "/spaces/"+space.Id+"/members/"+guestCandidate.id+"/capabilities", + map[string][]string{"granted_capabilities": {pluginmodel.CapabilityCreatePage}}, nil) + require.NoError(t, err) + require.Equal(t, http.StatusBadRequest, status, "granting a guest capabilities: %s", body) + require.Equal(t, "app.space.member.guest_not_assignable.app_error", appErrorID(body)) + }) + + var s7ID string + t.Run("scenario7_delegated_space_admin", func(t *testing.T) { + var space pluginmodel.Space + status, body, err := doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/teams/"+team.Id+"/spaces", + map[string]string{"title": "Scenario Delegated Admin"}, &space) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "createSpace failed: %s", body) + s7ID = space.Id + spacesToClean = append(spacesToClean, s7ID) + + addSpaceMember(t, ctx, spaceAdmin, s7ID, member.id) // the plain-member control actor for this scenario + updateAt := space.UpdateAt + + status, _, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodGet, "/spaces/"+s7ID+"/members", nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "admin list members") + + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/spaces/"+s7ID+"/members", + map[string]string{"user_id": contrib.id}, nil) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "admin add member: %s", body) + + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPut, "/spaces/"+s7ID+"/members/"+contrib.id+"/capabilities", + map[string][]string{"granted_capabilities": {pluginmodel.CapabilityEditPage}}, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "admin set member capabilities: %s", body) + + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPut, "/spaces/"+s7ID+"/default-capabilities", + map[string][]string{"default_capabilities": {pluginmodel.CapabilityCommentPage}}, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "admin set default capabilities: %s", body) + + var patched pluginmodel.Space + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPatch, "/spaces/"+s7ID, + map[string]any{"view_access": pluginmodel.ViewAccessPrivate, "expected_update_at": updateAt}, &patched) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "admin ViewAccess flip failed: %s", body) + require.Equal(t, pluginmodel.ViewAccessPrivate, patched.ViewAccess) + updateAt = patched.UpdateAt + + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodDelete, "/spaces/"+s7ID, nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "admin delete: %s", body) + + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPatch, "/spaces/"+s7ID+"/restore", nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "admin restore: %s", body) + + // A plain member (no elevation) is denied on each of the same routes. + status, _, err = doPluginRequest(ctx, member.client, http.MethodGet, "/spaces/"+s7ID+"/members", nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "control list members") + + status, _, err = doPluginRequest(ctx, member.client, http.MethodPost, "/spaces/"+s7ID+"/members", + map[string]string{"user_id": outsider.id}, nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "control add member") + + status, _, err = doPluginRequest(ctx, member.client, http.MethodPut, "/spaces/"+s7ID+"/members/"+contrib.id+"/capabilities", + map[string][]string{"granted_capabilities": {pluginmodel.CapabilityEditPage}}, nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "control set member capabilities") + + status, _, err = doPluginRequest(ctx, member.client, http.MethodPut, "/spaces/"+s7ID+"/default-capabilities", + map[string][]string{"default_capabilities": {pluginmodel.CapabilityCommentPage}}, nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "control set default capabilities") + + status, _, err = doPluginRequest(ctx, member.client, http.MethodPatch, "/spaces/"+s7ID, + map[string]any{"view_access": pluginmodel.ViewAccessOpen, "expected_update_at": updateAt}, nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "control ViewAccess flip") + + status, _, err = doPluginRequest(ctx, member.client, http.MethodDelete, "/spaces/"+s7ID, nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "control delete") + }) + + // scenario8 exercises the POOLED scheme path — a default-capability set matching no preset — + // which the plugin provisions through core's CreateScheme + PatchRole plugin API. It asserts the + // whole write→read chain end-to-end against real core: the custom set round-trips through GET + // (proving PatchRole set exactly those role permissions and they project back), a plain member + // is enforced against it, and switching back to a preset repoints the channel, leaving the + // superseded scheme in place for any other space using it. + t.Run("scenario8_custom_capability_scheme", func(t *testing.T) { + customCaps := []string{pluginmodel.CapabilityCreatePage, pluginmodel.CapabilityEditPage} + + var space pluginmodel.Space + status, body, err := doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/teams/"+team.Id+"/spaces", + map[string]any{"title": "Scenario Custom Capability Scheme", "default_capabilities": customCaps}, &space) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "createSpace with custom default caps failed: %s", body) + spacesToClean = append(spacesToClean, space.Id) + + // The custom set must round-trip: create resolved a pooled docs_space_default_* scheme whose + // user role PatchRole set to exactly {read_page}+customCaps, and GET projects that back. + var withAccess pluginmodel.SpaceWithAccess + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodGet, "/spaces/"+space.Id, nil, &withAccess) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "get space: %s", body) + require.ElementsMatch(t, customCaps, withAccess.DefaultCapabilities, + "custom default caps did not round-trip (CreateScheme/PatchRole path): %s", body) + + addSpaceMember(t, ctx, spaceAdmin, space.Id, member.id) + + // A plain member holds exactly the custom default: create and edit, but not delete-own. + var page pluginmodel.Page + status, body, err = doPluginRequest(ctx, member.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("Member custom-create"), &page) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "granted custom create_page: %s", body) + + status, body, err = doPluginRequest(ctx, member.client, http.MethodPatch, "/spaces/"+space.Id+"/pages/"+page.Id, + editPageReq(page.EditAt, "member custom-edit"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "granted custom edit_page: %s", body) + + status, body, err = doPluginRequest(ctx, member.client, http.MethodDelete, "/spaces/"+space.Id+"/pages/"+page.Id, nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "custom default excludes delete_own_page: %s", body) + + // Switching to the read-only preset repoints the backing channel at that preset's scheme; + // the pooled scheme it leaves behind stays for the next space to request the same set. The + // member loses create. + var roResp pluginmodel.SpaceWithAccess + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPut, "/spaces/"+space.Id+"/default-capabilities", + map[string][]string{"default_capabilities": {}}, &roResp) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "switch custom→preset failed: %s", body) + require.Empty(t, roResp.DefaultCapabilities, "default caps not cleared to read-only: %s", body) + + status, body, err = doPluginRequest(ctx, member.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("Should be forbidden now"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "member create after switch to read-only: %s", body) + }) + + t.Run("gap_open_space_removal_not_durable", func(t *testing.T) { + // Continues from scenario 1's open space, where OUTSIDER is already a member via auto-join. + require.NotEmpty(t, s1ID, "scenario1 must have run first") + require.True(t, spaceHasMember(t, ctx, spaceAdmin, s1ID, outsider.id), + "expected OUTSIDER to already be a member of %s from scenario1", s1ID) + + status, body, err := doPluginRequest(ctx, spaceAdmin.client, http.MethodDelete, "/spaces/"+s1ID+"/members/"+outsider.id, nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "removing OUTSIDER: %s", body) + require.False(t, spaceHasMember(t, ctx, spaceAdmin, s1ID, outsider.id), + "OUTSIDER still listed as a member immediately after removal") + + status, body, err = doPluginRequest(ctx, outsider.client, http.MethodPost, "/spaces/"+s1ID+"/pages", + createPageReq("Regained access"), nil) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "OUTSIDER's post-removal write: %s", body) + require.True(t, spaceHasMember(t, ctx, spaceAdmin, s1ID, outsider.id), + "OUTSIDER's default-granted write did not re-join them — removal should NOT be durable on an open space") + }) + + // delete_page (delete-any) is grantable to a plain member without making them a space admin, + // matching Confluence, where Delete is assignable independently of Admin. The grant reaches a + // page the grantee does not own — that is the whole difference from delete_own_page. + t.Run("delete_any_grantable_to_non_admin", func(t *testing.T) { + var space pluginmodel.Space + status, body, err := doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/teams/"+team.Id+"/spaces", + map[string]string{"title": "Scenario Delete Any"}, &space) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "createSpace failed: %s", body) + spacesToClean = append(spacesToClean, space.Id) + + addSpaceMember(t, ctx, spaceAdmin, space.Id, contrib.id) + + var seed pluginmodel.Page + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("Admin-owned Page"), &seed) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "admin seed page failed: %s", body) + + // Before the grant: the contribute default carries delete_own_page only, so a page the + // contributor does not own is out of reach. + status, body, err = doPluginRequest(ctx, contrib.client, http.MethodDelete, "/spaces/"+space.Id+"/pages/"+seed.Id, nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, status, "delete of an unowned page before the grant: %s", body) + + var granted pluginmodel.SpaceMember + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPut, "/spaces/"+space.Id+"/members/"+contrib.id+"/capabilities", + map[string][]string{"granted_capabilities": {pluginmodel.CapabilityDeletePage}}, &granted) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "granting delete_page: %s", body) + require.Contains(t, granted.GrantedCapabilities, pluginmodel.CapabilityDeletePage, + "delete_page did not round-trip through ExplicitRoles: %s", body) + require.False(t, granted.IsAdmin, "granting delete_page must not make the member a space admin") + + status, body, err = doPluginRequest(ctx, contrib.client, http.MethodDelete, "/spaces/"+space.Id+"/pages/"+seed.Id, nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "delete of an unowned page after the grant: %s", body) + }) + + // The same capability as a space default rather than a per-member grant, so every member holds + // delete-any. delete_page is in no preset, so this drives the pooled-scheme path end to end: + // the scheme is minted, the backing channel is attached to it, and only then does the role + // patch carrying delete_page become admissible to core. + t.Run("delete_any_as_space_default", func(t *testing.T) { + defaultCaps := []string{pluginmodel.CapabilityCreatePage, pluginmodel.CapabilityDeletePage} + + var space pluginmodel.Space + status, body, err := doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/teams/"+team.Id+"/spaces", + map[string]any{"title": "Scenario Delete Any Default", "default_capabilities": defaultCaps}, &space) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "createSpace with a delete_page default failed: %s", body) + spacesToClean = append(spacesToClean, space.Id) + + var withAccess pluginmodel.SpaceWithAccess + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodGet, "/spaces/"+space.Id, nil, &withAccess) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "get space: %s", body) + require.ElementsMatch(t, defaultCaps, withAccess.DefaultCapabilities, + "delete_page did not round-trip as a space default: %s", body) + + addSpaceMember(t, ctx, spaceAdmin, space.Id, member.id) + + var seed pluginmodel.Page + status, body, err = doPluginRequest(ctx, spaceAdmin.client, http.MethodPost, "/spaces/"+space.Id+"/pages", + createPageReq("Admin-owned Page"), &seed) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, "admin seed page failed: %s", body) + + status, body, err = doPluginRequest(ctx, member.client, http.MethodDelete, "/spaces/"+space.Id+"/pages/"+seed.Id, nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status, "plain member delete of an unowned page: %s", body) + }) + + t.Run("gap_anonymous_access_denied_on_open_space", func(t *testing.T) { + require.NotEmpty(t, s1ID, "scenario1 must have run first") + anon := mmmodel.NewAPIv4Client(env.baseURL) + status, body, err := doPluginRequest(ctx, anon, http.MethodGet, "/spaces/"+s1ID, nil, nil) + require.NoError(t, err) + require.Equal(t, http.StatusUnauthorized, status, + "an unauthenticated read on an open space: %s", body) + }) + + // The two remaining named parity gaps have no API surface to probe: per-group capability + // grants beyond member/admin (a synced group's GroupSyncable carries only a binary + // SchemeAdmin, so an arbitrary per-group capability set has no route to request it), and the + // export_space split (Export is mapped onto admin_space in this epic; no separate export + // capability exists to grant, deny, or probe). +} + +// reverseString reverses s — used to derive a syntactically valid but nonexistent id from a real +// one. +func reverseString(s string) string { + r := []rune(s) + slices.Reverse(r) + return string(r) +} diff --git a/server/internal/testutil/fixtures.go b/server/internal/testutil/fixtures.go index 4b1142c..791ca30 100644 --- a/server/internal/testutil/fixtures.go +++ b/server/internal/testutil/fixtures.go @@ -59,10 +59,11 @@ func MustAddChannelMember(t *testing.T, db *sql.DB, channelID, userID string) { // creator. func NewSpace(channelID, teamID string) *model.Space { return &model.Space{ - ChannelId: channelID, - TeamId: teamID, - CreatorId: mmmodel.NewId(), - Title: "Test Space", + ChannelId: channelID, + TeamId: teamID, + CreatorId: mmmodel.NewId(), + Title: "Test Space", + ViewAccess: model.ViewAccessOpen, } } diff --git a/server/internal/testutil/permstub.go b/server/internal/testutil/permstub.go new file mode 100644 index 0000000..60561cf --- /dev/null +++ b/server/internal/testutil/permstub.go @@ -0,0 +1,73 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package testutil + +import ( + "github.com/stretchr/testify/mock" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/plugin/plugintest" +) + +// StubDefaultSpacePermissions registers .Maybe() stubs on mockAPI granting every user the +// "ordinary contribute-preset space member" channel permissions plus the team-baseline +// permissions every active team member holds by default (read_space, create_space, +// read_public_channel) — mirroring production's default create posture (open + contribute) so +// tests that assume plain backing-channel membership is sufficient need no per-test permission +// wiring. admin_space/delete_page (channel) and manage_space/delete_space (team) and the system +// manage_system override default to false. +// +// A test exercising an elevation-gated route (manage/admin/sysadmin) registers its own specific +// HasPermissionTo*/HasPermissionToTeam/HasPermissionToChannel expectation for the acting user +// BEFORE calling the harness constructor that invokes this — mock.Mock matches expectations in +// registration order, so the more specific, earlier-registered stub takes precedence over these +// catch-alls. +// +// The team grants here are team_user's. A test whose actor is a guest must register +// StubGuestTeamDefaults for that user first, since core's team_guest holds neither create_space nor +// read_public_channel and the catch-all below would otherwise admit it to both. +func StubDefaultSpacePermissions(mockAPI *plugintest.API) { + for _, p := range []*mmmodel.Permission{ + mmmodel.PermissionReadPage, mmmodel.PermissionCreatePage, mmmodel.PermissionCommentPage, + mmmodel.PermissionEditPage, mmmodel.PermissionDeleteOwnPage, + } { + mockAPI.On("HasPermissionToChannel", mock.Anything, mock.Anything, p).Return(true).Maybe() + } + for _, p := range []*mmmodel.Permission{mmmodel.PermissionDeletePage, mmmodel.PermissionAdminSpace} { + mockAPI.On("HasPermissionToChannel", mock.Anything, mock.Anything, p).Return(false).Maybe() + } + for _, p := range []*mmmodel.Permission{ + mmmodel.PermissionReadSpace, mmmodel.PermissionCreateSpace, mmmodel.PermissionReadPublicChannel, + } { + mockAPI.On("HasPermissionToTeam", mock.Anything, mock.Anything, p).Return(true).Maybe() + } + for _, p := range []*mmmodel.Permission{mmmodel.PermissionManageSpace, mmmodel.PermissionDeleteSpace} { + mockAPI.On("HasPermissionToTeam", mock.Anything, mock.Anything, p).Return(false).Maybe() + } + mockAPI.On("HasPermissionTo", mock.Anything, mmmodel.PermissionManageSystem).Return(false).Maybe() + // teamPermGranted consults the resolved team roles before falling back to HasPermissionToTeam. + // The stubbed team memberships carry no roles, so this arm decides nothing and the + // HasPermissionToTeam expectations above remain the ones that express a test's team grants. + // + // Scoped to the team permissions rather than registered as a catch-all: RolesGrantPermission is + // also how DefaultRolesGrantPermission resolves a space's *page* defaults, and a catch-all here + // would match those first and shadow the per-test expectations that drive the auto-join paths. + for _, p := range []*mmmodel.Permission{ + mmmodel.PermissionReadSpace, mmmodel.PermissionCreateSpace, mmmodel.PermissionReadPublicChannel, + mmmodel.PermissionManageSpace, mmmodel.PermissionDeleteSpace, + } { + mockAPI.On("RolesGrantPermission", mock.Anything, p.Id).Return(false).Maybe() + } +} + +// StubGuestTeamDefaults narrows guestUserID's team grants to what core's team_guest actually holds: +// read_space, but neither create_space nor read_public_channel. Register it BEFORE +// StubDefaultSpacePermissions, whose catch-alls match any user and would otherwise grant a guest +// the read_public_channel that admits the open-space non-member read fall-through. +func StubGuestTeamDefaults(mockAPI *plugintest.API, guestUserID string) { + for _, p := range []*mmmodel.Permission{mmmodel.PermissionCreateSpace, mmmodel.PermissionReadPublicChannel} { + mockAPI.On("HasPermissionToTeam", guestUserID, mock.Anything, p).Return(false).Maybe() + } + mockAPI.On("HasPermissionToTeam", guestUserID, mock.Anything, mmmodel.PermissionReadSpace).Return(true).Maybe() +} diff --git a/server/internal/testutil/schemestub.go b/server/internal/testutil/schemestub.go new file mode 100644 index 0000000..ebc797a --- /dev/null +++ b/server/internal/testutil/schemestub.go @@ -0,0 +1,386 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package testutil + +import ( + "net/http" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/plugin/plugintest" + + "github.com/mattermost/mattermost-plugin-docs/server/model" +) + +// presetSchemeFixture is the fixed identity and role/permission shape stubbed for one of the three +// seeded preset space schemes (see mmmodel.SpaceSchemeNames) — mirroring the paired core branch's +// core seeding migration, which creates these same three schemes with generated per-scheme roles +// in production. +type presetSchemeFixture struct { + schemeID string + userRole string + adminRole string + guestRole string + userPerms []*mmmodel.Permission + adminPerms []*mmmodel.Permission + guestPerms []*mmmodel.Permission +} + +// presetSchemeFixtures holds one fixture per preset name, keyed by the scheme's Name. Scheme ids +// are fixed 26-char valid model ids (mmmodel.IsValidId) so MustSeedChannelScheme and +// StubPresetSchemes agree on which id a preset resolves to. +var presetSchemeFixtures = map[string]presetSchemeFixture{ + mmmodel.SchemeNameSpaceContribute: { + schemeID: "contributescheme0000000000", + userRole: "space_contribute_user_role", + adminRole: "space_contribute_admin_role", + guestRole: "space_contribute_guest_role", + userPerms: mmmodel.SpaceDefaultContributePermissions, + adminPerms: mmmodel.SpaceAdminRolePermissions, + guestPerms: []*mmmodel.Permission{mmmodel.PermissionReadPage}, + }, + mmmodel.SchemeNameSpaceComment: { + schemeID: "commentscheme0000000000000", + userRole: "space_comment_user_role", + adminRole: "space_comment_admin_role", + guestRole: "space_comment_guest_role", + userPerms: mmmodel.SpaceDefaultCommentPermissions, + adminPerms: mmmodel.SpaceAdminRolePermissions, + guestPerms: []*mmmodel.Permission{mmmodel.PermissionReadPage}, + }, + mmmodel.SchemeNameSpaceReadOnly: { + schemeID: "readonlyscheme000000000000", + userRole: "space_readonly_user_role", + adminRole: "space_readonly_admin_role", + guestRole: "space_readonly_guest_role", + userPerms: mmmodel.SpaceDefaultReadOnlyPermissions, + adminPerms: mmmodel.SpaceAdminRolePermissions, + guestPerms: []*mmmodel.Permission{mmmodel.PermissionReadPage}, + }, +} + +// schemeRoleNames is the generated guest/user/admin role names one scheme id resolves to. +type schemeRoleNames struct { + Guest string + User string + Admin string +} + +// schemeRoleRegistry maps a scheme id to its generated role names, so a stubbed channel resolves +// whichever scheme it currently points at. It is keyed by globally unique scheme ids — the three +// fixed preset ids plus whatever a test registers for a custom scheme — so entries never collide +// across tests. Guarded because a test binary may run packages in parallel. +var schemeRoleRegistry = struct { + sync.RWMutex + byScheme map[string]schemeRoleNames +}{byScheme: presetSchemeRoleNames()} + +func presetSchemeRoleNames() map[string]schemeRoleNames { + out := make(map[string]schemeRoleNames, len(presetSchemeFixtures)) + for _, fx := range presetSchemeFixtures { + out[fx.schemeID] = schemeRoleNames{Guest: fx.guestRole, User: fx.userRole, Admin: fx.adminRole} + } + return out +} + +// RegisterSchemeRoles records the generated role names schemeID resolves to, so a channel later +// repointed at it resolves that scheme's roles through GetSchemeRolesForChannel. The three presets +// are registered already; a test creating a custom scheme registers its own. +func RegisterSchemeRoles(schemeID, guestRole, userRole, adminRole string) { + schemeRoleRegistry.Lock() + defer schemeRoleRegistry.Unlock() + schemeRoleRegistry.byScheme[schemeID] = schemeRoleNames{Guest: guestRole, User: userRole, Admin: adminRole} +} + +func rolesForScheme(schemeID string) (schemeRoleNames, bool) { + schemeRoleRegistry.RLock() + defer schemeRoleRegistry.RUnlock() + roles, ok := schemeRoleRegistry.byScheme[schemeID] + return roles, ok +} + +// PresetSchemeID returns the fixed scheme id StubPresetSchemes stubbed for the named preset (see +// mmmodel.SpaceSchemeNames) — exported for callers that need to seed a *model.Channel pointing at a +// specific preset scheme directly, rather than through MustSeedChannelScheme. +func PresetSchemeID(name string) string { + return presetSchemeFixtures[name].schemeID +} + +// StubPresetSchemes registers .Maybe() stubs on mockAPI resolving each of the three preset space +// schemes by name, plus their three generated roles by name — the shape +// getSchemeIDByName/getRolePermissionsByName resolve through the plugin API in production. Call +// this once per mockAPI, alongside StubDefaultSpacePermissions. +func StubPresetSchemes(mockAPI *plugintest.API) { + for name, fx := range presetSchemeFixtures { + scheme := &mmmodel.Scheme{ + Id: fx.schemeID, + Name: name, + Scope: mmmodel.SchemeScopeChannel, + DefaultChannelUserRole: fx.userRole, + DefaultChannelAdminRole: fx.adminRole, + DefaultChannelGuestRole: fx.guestRole, + } + mockAPI.On("GetSchemeByName", name).Return(scheme, nil).Maybe() + StubRole(mockAPI, fx.userRole, mmmodel.PermissionIDs(fx.userPerms)) + StubRole(mockAPI, fx.adminRole, mmmodel.PermissionIDs(fx.adminPerms)) + StubRole(mockAPI, fx.guestRole, mmmodel.PermissionIDs(fx.guestPerms)) + } +} + +// roleRegistry maps a stubbed role's id to the shared *Role object, so a PatchRole stub keyed by +// id (the shape core's PatchRole takes) reaches the same object a GetRoleByName stub hands out. +// Keyed by generated ids, so entries never collide across tests. Guarded because a test binary may +// run packages in parallel. +var roleRegistry = struct { + sync.RWMutex + byID map[string]*mmmodel.Role +}{byID: map[string]*mmmodel.Role{}} + +func roleByID(roleID string) (*mmmodel.Role, bool) { + roleRegistry.RLock() + defer roleRegistry.RUnlock() + role, ok := roleRegistry.byID[roleID] + return role, ok +} + +// registerRoleByID records role under its id so a PatchRole stub, which core keys by id, reaches +// the same object a GetRoleByName stub handed out. Ids are generated per role, so unlike role names +// they never collide across tests. +func registerRoleByID(role *mmmodel.Role) { + roleRegistry.Lock() + defer roleRegistry.Unlock() + roleRegistry.byID[role.Id] = role +} + +// StubbedRoleName resolves a stubbed role's id back to its name, so a test asserting on PatchRole — +// which core takes by id — can still express its expectation in role names. +func StubbedRoleName(roleID string) (string, bool) { + role, ok := roleByID(roleID) + if !ok { + return "", false + } + return role.Name, true +} + +func isPooledSchemeName(name string) bool { + return strings.HasPrefix(name, model.SharedSchemeNamePrefix) +} + +// StubPooledSchemeMiss answers the shared-pool lookup with not-found for any pooled scheme name, so +// a test exercising a non-preset default-capability set reaches the create path. Register it after +// StubPresetSchemes, whose per-name stubs must keep matching first, and before any stub that +// answers a specific pooled name. Use StubSchemePool instead when the test needs the pool to +// actually accumulate. +func StubPooledSchemeMiss(mockAPI *plugintest.API) { + mockAPI.On("GetSchemeByName", mock.MatchedBy(isPooledSchemeName)). + Return(nil, &mmmodel.AppError{Id: "app.scheme.get.app_error", StatusCode: http.StatusNotFound}).Maybe() +} + +// StubSchemePool simulates the shared default-capability pool with state: a pooled scheme name +// resolves to not-found until CreateScheme mints it and to that same scheme afterwards. This is +// what lets a test assert the property the pool exists for — that two spaces configured alike, or +// one space returning to a set it used before, resolve to a single scheme instead of minting a +// second identical one. +// +// The generated role names are derived from the scheme name so they can be answered by a standing +// stub; registering new expectations from inside a running mock call would race the mock's own lock. +func StubSchemePool(mockAPI *plugintest.API) { + var mu sync.Mutex + byName := map[string]*mmmodel.Scheme{} + + mockAPI.On("GetSchemeByName", mock.MatchedBy(isPooledSchemeName)). + Return(func(name string) (*mmmodel.Scheme, *mmmodel.AppError) { + mu.Lock() + defer mu.Unlock() + if scheme, ok := byName[name]; ok { + return scheme, nil + } + return nil, &mmmodel.AppError{Id: "app.scheme.get.app_error", StatusCode: http.StatusNotFound} + }).Maybe() + + mockAPI.On("CreateScheme", mock.MatchedBy(func(scheme *mmmodel.Scheme) bool { + return scheme != nil && isPooledSchemeName(scheme.Name) + })).Return(func(in *mmmodel.Scheme) (*mmmodel.Scheme, *mmmodel.AppError) { + mu.Lock() + defer mu.Unlock() + if existing, ok := byName[in.Name]; ok { + return existing, nil + } + scheme := &mmmodel.Scheme{ + Id: mmmodel.NewId(), + Name: in.Name, + DisplayName: in.DisplayName, + Scope: mmmodel.SchemeScopeChannel, + DefaultChannelUserRole: in.Name + "_user", + DefaultChannelAdminRole: in.Name + "_admin", + DefaultChannelGuestRole: in.Name + "_guest", + } + byName[in.Name] = scheme + RegisterSchemeRoles(scheme.Id, scheme.DefaultChannelGuestRole, scheme.DefaultChannelUserRole, scheme.DefaultChannelAdminRole) + return scheme, nil + }).Maybe() + + stubPooledRoles(mockAPI) + StubPatchRole(mockAPI) +} + +// stubPooledRoles answers GetRoleByName for any role generated for a pooled scheme, minting the +// shared *Role on first read so a later PatchRole mutation is visible to a subsequent read. +// +// The name-keyed map is per-call rather than package-level: a pooled scheme's name is a digest of +// its capability set, so two tests asking for the same set generate the same role names, and a +// shared map would hand the second test the first one's already-patched role. +func stubPooledRoles(mockAPI *plugintest.API) { + var mu sync.Mutex + byName := map[string]*mmmodel.Role{} + + mockAPI.On("GetRoleByName", mock.MatchedBy(isPooledSchemeName)). + Return(func(roleName string) (*mmmodel.Role, *mmmodel.AppError) { + mu.Lock() + defer mu.Unlock() + if role, ok := byName[roleName]; ok { + return role, nil + } + role := &mmmodel.Role{Id: mmmodel.NewId(), Name: roleName} + byName[roleName] = role + registerRoleByID(role) + return role, nil + }).Maybe() +} + +// StubRole registers a GetRoleByName stub returning one shared *Role carrying permissions, so a +// PatchRole stub that mutates it (see StubPatchRole) is visible to a later read of the same role — +// the mock does not track state on its own. The role is given an id and registered under it, since +// production patches by id. Returns the shared Role. +func StubRole(mockAPI *plugintest.API, roleName string, permissions []string) *mmmodel.Role { + role := &mmmodel.Role{Id: mmmodel.NewId(), Name: roleName, Permissions: permissions} + roleRegistry.Lock() + roleRegistry.byID[role.Id] = role + roleRegistry.Unlock() + mockAPI.On("GetRoleByName", roleName).Return(role, nil).Maybe() + return role +} + +// StubPatchRole registers a PatchRole stub applying the patch's permission set to the shared *Role +// the matching GetRoleByName stub returns, so the write is observable to a later read. Roles are +// looked up by id, mirroring core's own PatchRole, which re-reads the stored role rather than +// trusting one supplied by the caller. +func StubPatchRole(mockAPI *plugintest.API) { + mockAPI.On("PatchRole", mock.AnythingOfType("string"), mock.AnythingOfType("*model.RolePatch")). + Return(func(roleID string, patch *mmmodel.RolePatch) (*mmmodel.Role, *mmmodel.AppError) { + role, ok := roleByID(roleID) + if !ok { + return nil, &mmmodel.AppError{Id: "app.role.get.app_error", StatusCode: http.StatusNotFound} + } + if patch != nil && patch.Permissions != nil { + role.Permissions = *patch.Permissions + } + return role, nil + }).Maybe() +} + +// MustSeedChannelScheme registers mock stubs so channelID resolves to the named seeded space scheme +// preset: GetChannelOfType returns a channel pointing at the preset's id, and +// GetSchemeRolesForChannel resolves whichever scheme that channel currently points at — so a test +// that repoints the channel sees the new scheme's roles without re-stubbing, the way core resolves +// them. Production sets this up through the real backing channel's SchemeId when +// CreateSpace/SetSpaceDefaultCapabilities points a space at a scheme. +// +// Every matching call returns the same shared *Channel, since a space's scheme-resolving paths may +// call GetChannelOfType more than once per test; a test that also needs to simulate the channel's +// archived/live state (e.g. RestoreSpace's backingChannelArchived check) mutates the returned +// Channel's DeleteAt directly rather than registering a second, competing stub. Marked .Maybe() so +// a test whose flow never reaches a scheme lookup does not fail AssertExpectations on an unused +// stub. Returns the shared Channel. +func MustSeedChannelScheme(t *testing.T, mockAPI *plugintest.API, channelID, schemeName string) *mmmodel.Channel { + t.Helper() + require.NotNil(t, mockAPI, "MustSeedChannelScheme needs a mock to register the channel's scheme stubs on") + fx, ok := presetSchemeFixtures[schemeName] + require.True(t, ok, "unknown preset space scheme name %q", schemeName) + + schemeID := fx.schemeID + channel := &mmmodel.Channel{Id: channelID, Type: mmmodel.ChannelTypeSpace, SchemeId: &schemeID} + StubChannelScheme(mockAPI, channelID, channel) + return channel +} + +// StubChannelScheme wires the three calls that resolve one channel's scheme: the channel read, the +// scheme-role resolution that follows the channel's current SchemeId, and the metadata-sync write. +// +// UpdateChannel must return the SAME shared object GetChannelOfType hands out, because pluginapi's +// Channel.Update copies the returned channel back over the passed one — returning a fresh channel +// would wipe SchemeId and 404 the next scheme-resolving read. +func StubChannelScheme(mockAPI *plugintest.API, channelID string, channel *mmmodel.Channel) { + mockAPI.On("GetChannelOfType", channelID, mmmodel.ChannelTypeSpace).Return(channel, nil).Maybe() + mockAPI.On("UpdateChannel", channel).Return(channel, nil).Maybe() + mockAPI.On("GetSchemeRolesForChannel", channelID). + Return(func(string) (string, string, string, *mmmodel.AppError) { + return resolveChannelRoles(channel) + }).Maybe() +} + +// resolveChannelRoles mirrors core's GetSchemeRolesForChannel for a stubbed channel: the roles of +// whichever scheme the channel currently points at. A channel with no scheme resolves to empty role +// names, matching core. A channel pointing at a scheme no test registered resolves to not-found, so +// a test relying on an unregistered scheme fails visibly rather than reading empty role names as a +// successful lookup. +func resolveChannelRoles(channel *mmmodel.Channel) (string, string, string, *mmmodel.AppError) { + if channel == nil || channel.SchemeId == nil || *channel.SchemeId == "" { + return "", "", "", nil + } + roles, ok := rolesForScheme(*channel.SchemeId) + if !ok { + // Built as a literal, like the other stand-ins for this core error above: the id belongs to + // core, and constructing it through NewAppError would enter the plugin's own translation + // file as an untranslated string it never emits. + return "", "", "", &mmmodel.AppError{Id: "app.scheme.get.app_error", StatusCode: http.StatusNotFound} + } + return roles.Guest, roles.User, roles.Admin, nil +} + +// StubDefaultChannelScheme registers .Maybe() catch-all stubs resolving any channel not given its +// own specific stub (MustSeedChannelScheme, or a test's own registration) to the contribute preset +// scheme, live (DeleteAt 0) — the same default CreateSpace/MustCreateSpaceWithScheme use for a +// space's backing channel. Each channel id gets its OWN channel object carrying that id, so two +// spaces in one test never alias each other's scheme or metadata. +// +// Register this last: mock.Mock matches expectations in registration order, so a channel-specific +// stub must be registered before this catch-all to take precedence. +func StubDefaultChannelScheme(mockAPI *plugintest.API) { + var mu sync.Mutex + byID := map[string]*mmmodel.Channel{} + defaultChannel := func(channelID string) *mmmodel.Channel { + mu.Lock() + defer mu.Unlock() + if ch, ok := byID[channelID]; ok { + return ch + } + schemeID := PresetSchemeID(mmmodel.SchemeNameSpaceContribute) + ch := &mmmodel.Channel{Id: channelID, Type: mmmodel.ChannelTypeSpace, SchemeId: &schemeID} + byID[channelID] = ch + return ch + } + + mockAPI.On("GetChannelOfType", mock.AnythingOfType("string"), mock.Anything). + Return(func(channelID string, _ mmmodel.ChannelType) (*mmmodel.Channel, *mmmodel.AppError) { + return defaultChannel(channelID), nil + }).Maybe() + mockAPI.On("GetSchemeRolesForChannel", mock.AnythingOfType("string")). + Return(func(channelID string) (string, string, string, *mmmodel.AppError) { + return resolveChannelRoles(defaultChannel(channelID)) + }).Maybe() + // A backing-channel metadata sync (any UpdateSpace that writes the channel, e.g. a view_access + // flip) calls Channel.Update, and pluginapi's Update copies the returned channel back over the + // passed one. Return the same object that was passed so the copy-back is a no-op and the + // channel's SchemeId survives the sync. + mockAPI.On("UpdateChannel", mock.AnythingOfType("*model.Channel")). + Return(func(channel *mmmodel.Channel) (*mmmodel.Channel, *mmmodel.AppError) { + return channel, nil + }).Maybe() +} diff --git a/server/model/space.go b/server/model/space.go index ad3029d..5ceadf5 100644 --- a/server/model/space.go +++ b/server/model/space.go @@ -19,8 +19,20 @@ const ( // SpacePropsMaxBytes caps the serialized size of the opaque Props map. SpacePropsMaxBytes = 64 * 1024 + + // ViewAccessOpen makes a space readable by any active member of its team, not just backing- + // channel members (see the app-layer read resolver). ViewAccessPrivate restricts reads to + // backing-channel members. There is no third value; PreSave deliberately does not default + // ViewAccess — the caller (app layer) always decides it explicitly. + ViewAccessOpen ViewAccess = "open" + ViewAccessPrivate ViewAccess = "private" ) +// ViewAccess is a space's non-member read policy, one of ViewAccessOpen/ViewAccessPrivate. The +// defined type keeps the closed vocabulary visible at every call site, mirroring core's ChannelType; +// it marshals as a plain JSON string, so the wire format is a bare "open"/"private". +type ViewAccess string + // Space is stored in the DOCS_Space table. Each space owns a backing MM channel (ChannelId). // A soft-deleted space (DeleteAt>0) retains its pages; pages share the same DeleteAt via a // cascade and can be restored with the space. @@ -33,10 +45,48 @@ type Space struct { Description string `json:"description,omitempty"` Icon string `json:"icon,omitempty"` Props mmmodel.StringInterface `json:"props"` - CreateAt int64 `json:"create_at"` - UpdateAt int64 `json:"update_at"` - DeleteAt int64 `json:"delete_at"` - SortOrder int64 `json:"sort_order"` + // ViewAccess is one of ViewAccessOpen/ViewAccessPrivate. It gates non-member reads only — + // a member's read is always settled by membership. + ViewAccess ViewAccess `json:"view_access"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + DeleteAt int64 `json:"delete_at"` + SortOrder int64 `json:"sort_order"` +} + +// SpaceWithAccess carries the caller-relevant access state alongside the plain Space fields. The +// anonymous Space embed keeps the JSON flat, mirroring core's ChannelMemberWithTeamData pattern. +// DefaultCapabilities is the space's default capability set, read_page-free (the implicit +// baseline). Capabilities is the caller's own effective set and does include read_page, since an +// effective set states what the caller may actually do rather than what was granted on top of the +// baseline. Both are non-nil-on-empty. +// +// An endpoint returns this wrapper when it establishes access state (CreateSpace), changes it +// (SetSpaceDefaultCapabilities), reads a space directly (GET /spaces/{id}), or patches one +// (PATCH /spaces/{id}): a patch may alter view_access, which moves who may read the space, and +// answering with the wrapper keeps a client refreshing its cached entry from dropping the +// capability fields. The endpoints that return a bare Space are the restore route, which alters +// neither field, and the team listing, which omits them because resolving a capability set per +// space would cost a scheme-and-role lookup per row. +// +// Because the embed is flat, a bare Space and this wrapper are indistinguishable to a client that +// types them alike: a client caching a space must merge a bare-Space response into its cached +// entry rather than replace it, or it will drop the capability fields an earlier read supplied. +type SpaceWithAccess struct { + Space + DefaultCapabilities []string `json:"default_capabilities"` + Capabilities []string `json:"capabilities"` +} + +// EnsureCapabilities normalizes DefaultCapabilities and Capabilities to non-nil slices so they +// marshal as JSON [] rather than null, mirroring the Space.GetProps discipline. +func (w *SpaceWithAccess) EnsureCapabilities() { + if w.DefaultCapabilities == nil { + w.DefaultCapabilities = []string{} + } + if w.Capabilities == nil { + w.Capabilities = []string{} + } } // SpacePatch carries a partial update to a space's mutable fields. A nil field is left unchanged; a @@ -46,20 +96,41 @@ type SpacePatch struct { Description *string `json:"description"` Icon *string `json:"icon"` Props *mmmodel.StringInterface `json:"props"` + ViewAccess *ViewAccess `json:"view_access"` } // SpaceMember is the API-facing view of a user's membership in a space. Membership is backed by -// the space's channel, but the channel-membership mechanics (channel id, roles, notify props) -// stay internal — only the user is exposed, mirroring how Space hides its ChannelId. +// the space's channel; this type projects the caller's effective membership state — capabilities, +// admin/guest standing — while the raw channel mechanics (channel id, generated scheme-role names, +// ExplicitRoles string, notify props) stay internal. type SpaceMember struct { UserId string `json:"user_id"` + // Capabilities is the member's effective capability set (space default union granted), + // including the read_page baseline. + Capabilities []string `json:"capabilities"` + // GrantedCapabilities is the member's per-member granted set beyond the space default, + // read_page-free since the baseline is never independently granted. + GrantedCapabilities []string `json:"granted_capabilities"` + IsAdmin bool `json:"is_admin"` + IsGuest bool `json:"is_guest"` +} + +// EnsureCapabilities normalizes Capabilities and GrantedCapabilities to non-nil slices so they +// marshal as JSON [] rather than null, mirroring the Space.GetProps discipline. +func (m *SpaceMember) EnsureCapabilities() { + if m.Capabilities == nil { + m.Capabilities = []string{} + } + if m.GrantedCapabilities == nil { + m.GrantedCapabilities = []string{} + } } // IsValid rejects a nil patch and an all-nil-fields patch — both no-ops that would otherwise bump // UpdateAt and consume the optimistic-lock baseline without a real change. Enforced here, not just // in the service, so callers that bypass the service still uphold it — mirroring PagePatch.IsValid. func (p *SpacePatch) IsValid() *mmmodel.AppError { - if p == nil || (p.Title == nil && p.Description == nil && p.Icon == nil && p.Props == nil) { + if p == nil || (p.Title == nil && p.Description == nil && p.Icon == nil && p.Props == nil && p.ViewAccess == nil) { return mmmodel.NewAppError("SpacePatch.IsValid", "model.space.patch.nothing_to_update.app_error", nil, "", http.StatusBadRequest) } return nil @@ -84,6 +155,9 @@ func (s *Space) Patch(patch *SpacePatch) { if patch.Props != nil { s.Props = maps.Clone(*patch.Props) } + if patch.ViewAccess != nil { + s.ViewAccess = *patch.ViewAccess + } } // PreSave sanitizes Space and defaults its Id-independent fields before insert. @@ -134,6 +208,7 @@ func (s *Space) Auditable() map[string]any { "description": s.Description, "icon": s.Icon, "props": s.GetProps(), + "view_access": s.ViewAccess, "create_at": s.CreateAt, "update_at": s.UpdateAt, "delete_at": s.DeleteAt, @@ -187,6 +262,10 @@ func (s *Space) IsValid() *mmmodel.AppError { return err } + if s.ViewAccess != ViewAccessOpen && s.ViewAccess != ViewAccessPrivate { + return mmmodel.NewAppError("Space.IsValid", "model.space.is_valid.view_access.app_error", nil, "id="+s.Id, http.StatusBadRequest) + } + return nil } diff --git a/server/model/space_capabilities.go b/server/model/space_capabilities.go new file mode 100644 index 0000000..705bd86 --- /dev/null +++ b/server/model/space_capabilities.go @@ -0,0 +1,306 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "crypto/sha256" + "encoding/hex" + "maps" + "net/http" + "slices" + "strings" + + mmmodel "github.com/mattermost/mattermost/server/public/model" +) + +// The capability vocabulary is the core page-permission id strings themselves, so the API speaks +// the same tokens core enforces — no invented level names. Consumed only as symbols (never a +// re-declared permission list), keeping the plugin single-sourced against core (see the drift +// defence discussion on the round-trip tests in space_capabilities_test.go). +var ( + // CapabilityReadPage is the baseline capability, always present in an effective set, and never + // independently grantable (see ValidateGrantedCapabilities/ValidateDefaultCapabilities). + CapabilityReadPage = mmmodel.PermissionReadPage.Id + CapabilityCreatePage = mmmodel.PermissionCreatePage.Id + CapabilityCommentPage = mmmodel.PermissionCommentPage.Id + CapabilityEditPage = mmmodel.PermissionEditPage.Id + CapabilityDeleteOwnPage = mmmodel.PermissionDeleteOwnPage.Id + // CapabilityDeletePage is delete-any: it covers pages the holder does not own, and so + // subsumes CapabilityDeleteOwnPage. Grantable independently of the admin capability, matching + // Confluence, where Delete is assignable without Admin. + CapabilityDeletePage = mmmodel.PermissionDeletePage.Id + // CapabilityAdminSpace is the admin capability: a member-grant target (toggles SchemeAdmin), + // but never a valid space-default capability. + CapabilityAdminSpace = mmmodel.PermissionAdminSpace.Id +) + +// grantableMemberCapabilities is the wire vocabulary a caller may explicitly grant to a member: +// the five atomic per-page capabilities plus the admin capability. +var grantableMemberCapabilities = map[string]bool{ + CapabilityCreatePage: true, + CapabilityCommentPage: true, + CapabilityEditPage: true, + CapabilityDeleteOwnPage: true, + CapabilityDeletePage: true, + CapabilityAdminSpace: true, +} + +// grantableDefaultCapabilities is the wire vocabulary a space-default capability set may hold: +// the five atomic per-page capabilities. admin_space is member-grant-only, never a space default. +var grantableDefaultCapabilities = map[string]bool{ + CapabilityCreatePage: true, + CapabilityCommentPage: true, + CapabilityEditPage: true, + CapabilityDeleteOwnPage: true, + CapabilityDeletePage: true, +} + +// capabilityAtomicRole maps each non-admin grantable capability to the core atomic capability +// role that carries it in ExplicitRoles. +var capabilityAtomicRole = map[string]string{ + CapabilityCreatePage: mmmodel.SpacePageCreatorRoleId, + CapabilityCommentPage: mmmodel.SpacePageCommenterRoleId, + CapabilityEditPage: mmmodel.SpacePageEditorRoleId, + CapabilityDeleteOwnPage: mmmodel.SpacePageDeleterOwnRoleId, + CapabilityDeletePage: mmmodel.SpacePageDeleterRoleId, +} + +// atomicRoleCapability is the reverse of capabilityAtomicRole, used to parse a stored +// ExplicitRoles string back into the granted capability set. Derived from capabilityAtomicRole so +// the two cannot drift. +var atomicRoleCapability = func() map[string]string { + m := make(map[string]string, len(capabilityAtomicRole)) + for capability, roleName := range capabilityAtomicRole { + m[roleName] = capability + } + return m +}() + +// stripReadPage projects a core permission slice onto its wire id strings with the implicit +// read_page baseline removed, so a canonical core permission set can be single-sourced into the +// read_page-free wire vocabulary without drift. +func stripReadPage(permissions []*mmmodel.Permission) []string { + return slices.DeleteFunc(mmmodel.PermissionIDs(permissions), func(id string) bool { + return id == CapabilityReadPage + }) +} + +// spaceAdminEffectiveCapabilities is the full capability set a SchemeAdmin member effectively +// holds, single-sourced from core's canonical admin permission slice (SpaceAdminRolePermissions, +// which already includes read_page). Stored already normalized, like presetCapabilitySets below, +// so the accessor copies without re-deriving the canonical form on every call. +var spaceAdminEffectiveCapabilities = NormalizeCapabilitySet(mmmodel.PermissionIDs(mmmodel.SpaceAdminRolePermissions)) + +// presetCapabilitySets are the three seeded default-capability presets in wire form (read_page- +// free — the baseline is implicit and never listed), single-sourced from core's canonical +// permission slices. Stored already normalized so the lookups below compare and copy without +// re-deriving the canonical form on every call. +var presetCapabilitySets = map[string][]string{ + mmmodel.SchemeNameSpaceContribute: NormalizeCapabilitySet(stripReadPage(mmmodel.SpaceDefaultContributePermissions)), + mmmodel.SchemeNameSpaceComment: NormalizeCapabilitySet(stripReadPage(mmmodel.SpaceDefaultCommentPermissions)), + mmmodel.SchemeNameSpaceReadOnly: NormalizeCapabilitySet(stripReadPage(mmmodel.SpaceDefaultReadOnlyPermissions)), +} + +// validateCapabilities validates capabilities against allowed, rejecting read_page as the +// non-grantable baseline, plus admin_space when rejectAdmin is set. An unknown token is rejected. +// Dedup-tolerant. where attributes the rejection to the calling validator. +func validateCapabilities(where string, capabilities []string, allowed map[string]bool, rejectAdmin bool) *mmmodel.AppError { + for _, c := range capabilities { + if c == CapabilityReadPage { + return mmmodel.NewAppError(where, "model.space_capabilities.read_page_not_grantable.app_error", nil, "", http.StatusBadRequest) + } + if rejectAdmin && c == CapabilityAdminSpace { + return mmmodel.NewAppError(where, "model.space_capabilities.admin_not_a_default.app_error", nil, "", http.StatusBadRequest) + } + if !allowed[c] { + return mmmodel.NewAppError(where, "model.space_capabilities.unknown_capability.app_error", map[string]any{"Capability": c}, "", http.StatusBadRequest) + } + } + return nil +} + +// ValidateGrantedCapabilities validates a per-member granted-capability request: each token must +// be one of the grantable member capabilities. +func ValidateGrantedCapabilities(capabilities []string) *mmmodel.AppError { + return validateCapabilities("ValidateGrantedCapabilities", capabilities, grantableMemberCapabilities, false) +} + +// ValidateDefaultCapabilities validates a space-default capability set: same rule as +// ValidateGrantedCapabilities, plus admin_space is also rejected — a space default is never +// admin-granting. +func ValidateDefaultCapabilities(capabilities []string) *mmmodel.AppError { + return validateCapabilities("ValidateDefaultCapabilities", capabilities, grantableDefaultCapabilities, true) +} + +// RolesForCapabilities maps the requested non-admin capabilities to their atomic role names for +// ExplicitRoles, and reports whether admin_space was requested (schemeAdmin). The base +// schemeUserRole token is always emitted first — core rejects a member update that leaves the base +// scheme role unset. Pure: schemeUserRole (the scheme's generated user-role name) comes from the +// caller, resolved via a store lookup elsewhere. +func RolesForCapabilities(capabilities []string, schemeUserRole string) (explicitRoles string, schemeAdmin bool) { + roles := []string{schemeUserRole} + for _, c := range NormalizeCapabilitySet(capabilities) { + if c == CapabilityAdminSpace { + schemeAdmin = true + continue + } + if roleName, ok := capabilityAtomicRole[c]; ok { + roles = append(roles, roleName) + } + } + return strings.Join(roles, " "), schemeAdmin +} + +// MemberCapabilities is the reverse projection of a member's stored role state onto the capability +// vocabulary: Effective is the member's effective capability set, Granted is the per-member +// granted set beyond the space default. +type MemberCapabilities struct { + Effective []string + Granted []string + IsAdmin bool + IsGuest bool +} + +// CapabilitiesFromMember reverse-projects a member's raw role state onto the capability vocabulary. +// explicitRoles is the raw space-delimited ChannelMember.ExplicitRoles string; any token that is +// not an atomic capability role is ignored (harmless if the base scheme token is passed too). +// defaultCapabilities is the space's default capability set (wire form, read_page-free). +// +// A SchemeGuest member's effective set is read_page union Granted only — never the space default, +// since a guest resolves through the read-only DefaultChannelGuestRole, not the scheme's user-role +// default. A SchemeAdmin member's effective set additionally includes the full canonical admin +// capability set, since SchemeAdmin resolves through DefaultChannelAdminRole regardless of what is +// (or isn't) recorded in ExplicitRoles/the space default. Pure: the model never touches the store. +func CapabilitiesFromMember(explicitRoles string, schemeAdmin, schemeGuest bool, defaultCapabilities []string) MemberCapabilities { + granted := make(map[string]bool) + for token := range strings.FieldsSeq(explicitRoles) { + if c, ok := atomicRoleCapability[token]; ok { + granted[c] = true + } + } + if schemeAdmin { + granted[CapabilityAdminSpace] = true + } + grantedList := NormalizeCapabilitySet(slices.Collect(maps.Keys(granted))) + + effective := map[string]bool{CapabilityReadPage: true} + if !schemeGuest { + if schemeAdmin { + for _, c := range spaceAdminEffectiveCapabilities { + effective[c] = true + } + } + for _, c := range defaultCapabilities { + effective[c] = true + } + } + for _, c := range grantedList { + effective[c] = true + } + + return MemberCapabilities{ + Effective: NormalizeCapabilitySet(slices.Collect(maps.Keys(effective))), + Granted: grantedList, + IsAdmin: schemeAdmin, + IsGuest: schemeGuest, + } +} + +// AdminEffectiveCapabilities returns the full capability set a SchemeAdmin effectively holds, wire +// form, non-nil. +func AdminEffectiveCapabilities() []string { + // Cloned, not returned directly: the set is package state a caller must not be able to mutate + // through the returned slice. + return slices.Clone(spaceAdminEffectiveCapabilities) +} + +// DefaultCapabilitiesFromPermissions projects a pooled scheme's stored user-role permission set +// (raw core permission ids) onto the wire default-capability vocabulary: read_page (the implicit +// baseline) and any non-default-capability permission are stripped; only the grantable default +// capability tokens survive. +func DefaultCapabilitiesFromPermissions(permissions []string) []string { + out := make([]string, 0, len(permissions)) + for _, p := range permissions { + if grantableDefaultCapabilities[p] { + out = append(out, p) + } + } + return NormalizeCapabilitySet(out) +} + +// DefaultCapabilitiesForSchemeName returns the wire-form default capability set for one of the +// three seeded preset scheme names, or false if name is not a preset. +func DefaultCapabilitiesForSchemeName(name string) ([]string, bool) { + capabilities, ok := presetCapabilitySets[name] + if !ok { + return nil, false + } + // Cloned, not returned directly: the presets are package state a caller must not be able to + // mutate through the returned slice. + return slices.Clone(capabilities), true +} + +// SharedSchemeNamePrefix labels every scheme in the shared default-capability pool. The suffix is +// derived from the capability set, so one scheme serves every space configured that way. Exported +// so a caller matching pooled scheme names resolves the prefix from here rather than restating it. +const SharedSchemeNamePrefix = "docs_space_default_" + +// sharedSchemeDisplayNamePrefix opens the operator-facing name of a pooled scheme, which then lists +// the capability set the scheme grants. +const sharedSchemeDisplayNamePrefix = "Space defaults: " + +// sharedSchemeNameDigestLength is how much of the digest the pool scheme name carries. Together +// with SharedSchemeNamePrefix it fits core's 64-character limit with room to spare, and 64 bits is +// far more than a vocabulary of a few tokens can collide within. +const sharedSchemeNameDigestLength = 16 + +// SharedSchemeNameForCapabilities returns the pool scheme name expressing capabilities: a +// deterministic function of the capability set, so two spaces configured the same way resolve to +// one shared scheme rather than each owning an identical private copy. The suffix is a digest +// rather than the tokens themselves, which keeps the name inside core's 64-character +// [a-z0-9_] limit and — unlike a positional encoding — leaves existing names meaning what they +// always meant when the capability vocabulary grows. +func SharedSchemeNameForCapabilities(capabilities []string) string { + sum := sha256.Sum256([]byte(strings.Join(NormalizeCapabilitySet(capabilities), " "))) + return SharedSchemeNamePrefix + hex.EncodeToString(sum[:])[:sharedSchemeNameDigestLength] +} + +// SharedSchemeDisplayNameForCapabilities returns the operator-facing name of the pooled scheme for +// capabilities, listing the set so the digest in the scheme name is legible in the System Console. +// Truncated to core's DisplayName limit, which the current vocabulary is far short of. +func SharedSchemeDisplayNameForCapabilities(capabilities []string) string { + name := sharedSchemeDisplayNamePrefix + strings.Join(NormalizeCapabilitySet(capabilities), ", ") + if len(name) > mmmodel.SchemeDisplayNameMaxLength { + name = name[:mmmodel.SchemeDisplayNameMaxLength] + } + return name +} + +// SchemeNameForDefaultCapabilities returns the seeded preset scheme name matching capabilities, or false +// if capabilities does not match any preset. Recognition is set equality — order-insensitive, deduplicated +// — never a raw array comparison. +func SchemeNameForDefaultCapabilities(capabilities []string) (string, bool) { + normalized := NormalizeCapabilitySet(capabilities) + for name, preset := range presetCapabilitySets { + if slices.Equal(preset, normalized) { + return name, true + } + } + return "", false +} + +// NormalizeCapabilitySet dedupes and sorts capabilities into a deterministic, non-nil capability slice. +func NormalizeCapabilitySet(capabilities []string) []string { + seen := make(map[string]bool, len(capabilities)) + out := make([]string, 0, len(capabilities)) + for _, c := range capabilities { + if c == "" || seen[c] { + continue + } + seen[c] = true + out = append(out, c) + } + slices.Sort(out) + return out +} diff --git a/server/model/space_capabilities_test.go b/server/model/space_capabilities_test.go new file mode 100644 index 0000000..b0fcf77 --- /dev/null +++ b/server/model/space_capabilities_test.go @@ -0,0 +1,185 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model_test + +import ( + "slices" + "strings" + "testing" + + mmmodel "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-plugin-docs/server/model" +) + +// grantableCapabilities is the full grant vocabulary (the five atomic per-page capabilities plus +// admin_space), used to enumerate every subset for the round-trip test below. +var grantableCapabilities = []string{ + model.CapabilityCreatePage, + model.CapabilityCommentPage, + model.CapabilityEditPage, + model.CapabilityDeleteOwnPage, + model.CapabilityDeletePage, + model.CapabilityAdminSpace, +} + +// TestRolesForCapabilities_CapabilitiesFromMember_RoundTrip verifies that every subset of the +// grantable capabilities is unchanged after RolesForCapabilities -> (core persistence, which +// drops the base scheme-user token and keeps only the atomic role names) -> CapabilitiesFromMember. +func TestRolesForCapabilities_CapabilitiesFromMember_RoundTrip(t *testing.T) { + const schemeUserRole = "generated_scheme_user_role" + + for mask := range 1 << len(grantableCapabilities) { + var capabilities []string + for i, c := range grantableCapabilities { + if mask&(1< 0 && slices.Contains(capabilities, model.CapabilityAdminSpace), mc.IsAdmin) + } +} + +// TestCapabilitiesFromMember_Guest verifies that a SchemeGuest member's effective capabilities are +// read_page union their granted set only — the space default never contributes, since a guest +// resolves through the read-only guest role rather than the scheme's user-role default. +func TestCapabilitiesFromMember_Guest(t *testing.T) { + t.Run("grant-free guest is read-only", func(t *testing.T) { + mc := model.CapabilitiesFromMember("", false, true, []string{model.CapabilityCreatePage, model.CapabilityCommentPage, model.CapabilityEditPage, model.CapabilityDeleteOwnPage}) + require.Equal(t, []string{model.CapabilityReadPage}, mc.Effective) + require.Empty(t, mc.Granted) + require.True(t, mc.IsGuest) + require.False(t, mc.IsAdmin) + }) + + t.Run("surviving grants project without the default", func(t *testing.T) { + explicitRoles := mmmodel.SpacePageCreatorRoleId + " " + mmmodel.SpacePageEditorRoleId + // A contribute default that must NOT leak into a guest's effective set. + defaultCapabilities := []string{model.CapabilityCommentPage, model.CapabilityCreatePage, model.CapabilityEditPage, model.CapabilityDeleteOwnPage} + + mc := model.CapabilitiesFromMember(explicitRoles, false, true, defaultCapabilities) + + require.Equal(t, model.NormalizeCapabilitySet([]string{model.CapabilityCreatePage, model.CapabilityEditPage}), mc.Granted) + require.Equal(t, model.NormalizeCapabilitySet([]string{model.CapabilityReadPage, model.CapabilityCreatePage, model.CapabilityEditPage}), mc.Effective) + require.NotContains(t, mc.Effective, model.CapabilityCommentPage) + require.NotContains(t, mc.Effective, model.CapabilityDeleteOwnPage) + require.True(t, mc.IsGuest) + }) +} + +// TestPresetRoundTrip verifies the default-preset <-> scheme-name maps invert each other for the +// three seeded presets, and that preset recognition is order-insensitive and dedup-tolerant. +func TestPresetRoundTrip(t *testing.T) { + presetNames := []string{ + mmmodel.SchemeNameSpaceContribute, + mmmodel.SchemeNameSpaceComment, + mmmodel.SchemeNameSpaceReadOnly, + } + + for _, name := range presetNames { + t.Run(name, func(t *testing.T) { + capabilities, ok := model.DefaultCapabilitiesForSchemeName(name) + require.True(t, ok) + + recognizedName, ok := model.SchemeNameForDefaultCapabilities(capabilities) + require.True(t, ok) + require.Equal(t, name, recognizedName) + + roundTripped, ok := model.DefaultCapabilitiesForSchemeName(recognizedName) + require.True(t, ok) + require.Equal(t, capabilities, roundTripped) + }) + } + + t.Run("recognition is order-insensitive and dedup-tolerant", func(t *testing.T) { + contribute, ok := model.DefaultCapabilitiesForSchemeName(mmmodel.SchemeNameSpaceContribute) + require.True(t, ok) + require.NotEmpty(t, contribute) + + // Reverse the set and duplicate every entry: still recognized as contribute. + shuffled := make([]string, 0, len(contribute)*2) + for i := len(contribute) - 1; i >= 0; i-- { + shuffled = append(shuffled, contribute[i], contribute[i]) + } + + name, ok := model.SchemeNameForDefaultCapabilities(shuffled) + require.True(t, ok) + require.Equal(t, mmmodel.SchemeNameSpaceContribute, name) + }) + + t.Run("a non-preset set is not recognized", func(t *testing.T) { + _, ok := model.SchemeNameForDefaultCapabilities([]string{model.CapabilityCreatePage}) + require.False(t, ok) + }) +} + +// TestValidateGrantedCapabilities verifies the per-member grant validator: read_page is rejected +// as the non-grantable baseline, admin_space is accepted, and an unknown token is rejected. +func TestValidateGrantedCapabilities(t *testing.T) { + t.Run("read_page rejected", func(t *testing.T) { + aerr := model.ValidateGrantedCapabilities([]string{model.CapabilityReadPage}) + require.NotNil(t, aerr) + require.Equal(t, "model.space_capabilities.read_page_not_grantable.app_error", aerr.Id) + }) + + t.Run("admin_space accepted", func(t *testing.T) { + require.Nil(t, model.ValidateGrantedCapabilities([]string{model.CapabilityAdminSpace})) + }) + + t.Run("unknown token rejected", func(t *testing.T) { + aerr := model.ValidateGrantedCapabilities([]string{"not_a_real_capability"}) + require.NotNil(t, aerr) + require.Equal(t, "model.space_capabilities.unknown_capability.app_error", aerr.Id) + }) + + t.Run("every non-admin grantable capability accepted", func(t *testing.T) { + require.Nil(t, model.ValidateGrantedCapabilities([]string{ + model.CapabilityCreatePage, model.CapabilityCommentPage, model.CapabilityEditPage, + model.CapabilityDeleteOwnPage, model.CapabilityDeletePage, + })) + }) +} + +// TestValidateDefaultCapabilities verifies the space-default validator: read_page and admin_space +// are both rejected (the baseline is implicit; admin is never a default), and an unknown token is +// rejected. +func TestValidateDefaultCapabilities(t *testing.T) { + t.Run("read_page rejected", func(t *testing.T) { + aerr := model.ValidateDefaultCapabilities([]string{model.CapabilityReadPage}) + require.NotNil(t, aerr) + require.Equal(t, "model.space_capabilities.read_page_not_grantable.app_error", aerr.Id) + }) + + t.Run("admin_space rejected", func(t *testing.T) { + aerr := model.ValidateDefaultCapabilities([]string{model.CapabilityAdminSpace}) + require.NotNil(t, aerr) + require.Equal(t, "model.space_capabilities.admin_not_a_default.app_error", aerr.Id) + }) + + t.Run("unknown token rejected", func(t *testing.T) { + aerr := model.ValidateDefaultCapabilities([]string{"not_a_real_capability"}) + require.NotNil(t, aerr) + require.Equal(t, "model.space_capabilities.unknown_capability.app_error", aerr.Id) + }) + + t.Run("every non-admin grantable capability accepted", func(t *testing.T) { + require.Nil(t, model.ValidateDefaultCapabilities([]string{ + model.CapabilityCreatePage, model.CapabilityCommentPage, model.CapabilityEditPage, + model.CapabilityDeleteOwnPage, model.CapabilityDeletePage, + })) + }) +} diff --git a/server/model/space_test.go b/server/model/space_test.go index 01002e7..55ebfac 100644 --- a/server/model/space_test.go +++ b/server/model/space_test.go @@ -15,10 +15,11 @@ import ( func validSpace() *model.Space { s := &model.Space{ - ChannelId: mmmodel.NewId(), - TeamId: mmmodel.NewId(), - CreatorId: mmmodel.NewId(), - Title: "Title", + ChannelId: mmmodel.NewId(), + TeamId: mmmodel.NewId(), + CreatorId: mmmodel.NewId(), + Title: "Title", + ViewAccess: model.ViewAccessOpen, } s.PreSave() return s diff --git a/server/store/migrations/000006_add_viewaccess_to_spaces.down.sql b/server/store/migrations/000006_add_viewaccess_to_spaces.down.sql new file mode 100644 index 0000000..00fcd64 --- /dev/null +++ b/server/store/migrations/000006_add_viewaccess_to_spaces.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE DOCS_Space DROP CONSTRAINT IF EXISTS chk_docs_space_view_access; +ALTER TABLE DOCS_Space DROP COLUMN IF EXISTS ViewAccess; diff --git a/server/store/migrations/000006_add_viewaccess_to_spaces.up.sql b/server/store/migrations/000006_add_viewaccess_to_spaces.up.sql new file mode 100644 index 0000000..0124e25 --- /dev/null +++ b/server/store/migrations/000006_add_viewaccess_to_spaces.up.sql @@ -0,0 +1,9 @@ +ALTER TABLE DOCS_Space ADD COLUMN IF NOT EXISTS ViewAccess VARCHAR(16) NOT NULL DEFAULT 'private'; + +-- The DDL default is 'private' (fail-closed), so a row written without ViewAccess lands on the more +-- restrictive access level. +-- +-- Postgres has no ADD CONSTRAINT IF NOT EXISTS, so the constraint is dropped first. That keeps this +-- file re-runnable as a whole, matching the guarded ADD COLUMN above. +ALTER TABLE DOCS_Space DROP CONSTRAINT IF EXISTS chk_docs_space_view_access; +ALTER TABLE DOCS_Space ADD CONSTRAINT chk_docs_space_view_access CHECK (ViewAccess IN ('open', 'private')); diff --git a/server/store/page_move.go b/server/store/page_move.go index 73099c4..eeac89c 100644 --- a/server/store/page_move.go +++ b/server/store/page_move.go @@ -247,7 +247,11 @@ func (s *Store) reindexSiblingGroup(tx *sqlx.Tx, channelID, parentID, movedPageI // cycle-safety are all re-validated under lock, so the move is safe regardless of concurrent // operations between the caller's pre-checks and this call. Cross-owner resources // (page-comment Posts, FileInfo) are not re-homed here. -func (s *Store) MovePageToSpace(pageID, sourceSpaceID, targetSpaceID, moverUserID string, parentPageID *string, expectedUpdateAt int64, force bool, maxDepth int) (_ *model.Page, priorParentID string, err error) { +// requiredOwnerID, when non-empty, requires every live page in the moved subtree to be owned by +// that user, failing the move with ErrInvalidInput otherwise; empty skips the check. Checked +// against the transaction's own locked subtree read, alongside the other re-validations above, so +// a concurrent reparent cannot graft another user's page into the subtree undetected. +func (s *Store) MovePageToSpace(pageID, sourceSpaceID, targetSpaceID, moverUserID string, parentPageID *string, expectedUpdateAt int64, force bool, maxDepth int, requiredOwnerID string) (_ *model.Page, priorParentID string, err error) { if pageID == "" { return nil, "", &ErrInvalidInput{Entity: "Page", Field: "Id", Value: pageID} } @@ -322,13 +326,22 @@ func (s *Store) MovePageToSpace(pageID, sourceSpaceID, targetSpaceID, moverUserI newParentID = *parentPageID } - // Collect the live subtree ids (the root is included — every node is re-homed) along with the - // subtree's max relative depth, reused by the depth-cap check below instead of a second walk. - ids, subtreeMax, e := s.collectLiveSubtreeIDs(tx, pageID) + // Collect the live subtree ids (the root is included — every node is re-homed) along with each + // row's UserId and the subtree's max relative depth, reused by the depth-cap check below + // instead of a second walk. + ids, owners, subtreeMax, e := s.collectLiveSubtreeIDs(tx, pageID) if e != nil { return nil, "", e } + if requiredOwnerID != "" { + for _, id := range ids { + if owners[id] != requiredOwnerID { + return nil, "", &ErrInvalidInput{Entity: "Page", Field: "subtree_owner", Reason: ReasonSubtreeNotOwned} + } + } + } + if newParentID != "" { // Cycle guard, checked under the held locks against the just-collected subtree: the // destination parent may not be the moved page or one of its descendants. Checked before @@ -392,39 +405,57 @@ func (s *Store) MovePageToSpace(pageID, sourceSpaceID, targetSpaceID, moverUserI return &moved, page.ParentId, nil } -// collectLiveSubtreeIDs returns the ids of pageID's whole live subtree (the root included) plus -// the subtree's max relative depth (levels below the root, 0 for a leaf — the same value -// pageSubtreeMaxDepth computes, read here at no extra cost), run within tx so it observes locked, -// uncommitted state. It errors — rather than silently truncating — when the subtree exceeds -// MaxPageDescendantsLimit or MaxPageHierarchyDepth (see pageSubtreeCTE for the recursion/cap -// mechanics). -func (s *Store) collectLiveSubtreeIDs(tx *sqlx.Tx, pageID string) ([]string, int, error) { - subtreeCTE := pageSubtreeCTE + fmt.Sprintf(` - SELECT Id, depth FROM page_subtree ORDER BY depth, Id LIMIT %d`, MaxPageDescendantsLimit+2) +// pageSubtreeWithOwnerCTE mirrors pageSubtreeCTE (page_hierarchy.go) but also carries each +// subtree row's UserId, so collectLiveSubtreeIDs can resolve subtree-wide ownership in the same +// walk. Kept as a local variant here, rather than widening the shared CTE, so pageSubtreeCTE's +// other caller (pageSubtreeMaxDepth) is unaffected. +var pageSubtreeWithOwnerCTE = fmt.Sprintf(` + WITH RECURSIVE page_subtree AS ( + SELECT Id, UserId, 0 AS depth, ARRAY[Id]::text[] AS path + FROM DOCS_Page WHERE Id = $1 AND DeleteAt = 0 AND OriginalId = '' + UNION ALL + SELECT p.Id, p.UserId, ps.depth + 1, ps.path || p.Id + FROM DOCS_Page p + INNER JOIN page_subtree ps ON p.ParentId = ps.Id + WHERE p.DeleteAt = 0 AND p.OriginalId = '' AND ps.depth <= %d + AND NOT (p.Id = ANY(ps.path)) + )`, MaxPageHierarchyDepth) + +// collectLiveSubtreeIDs returns the ids of pageID's whole live subtree (the root included), each +// id's owning UserId, and the subtree's max relative depth (levels below the root, 0 for a leaf — +// the same value pageSubtreeMaxDepth computes, read here at no extra cost), run within tx so it +// observes locked, uncommitted state. It errors — rather than silently truncating — when the +// subtree exceeds MaxPageDescendantsLimit or MaxPageHierarchyDepth. +func (s *Store) collectLiveSubtreeIDs(tx *sqlx.Tx, pageID string) ([]string, map[string]string, int, error) { + subtreeCTE := pageSubtreeWithOwnerCTE + fmt.Sprintf(` + SELECT Id, UserId, depth FROM page_subtree ORDER BY depth, Id LIMIT %d`, MaxPageDescendantsLimit+2) var subtreeRows []struct { - ID string - Depth int + ID string + UserId string + Depth int } if e := s.selectAll(tx, &subtreeRows, subtreeCTE, pageID); e != nil { - return nil, 0, errors.Wrap(e, "failed to collect page subtree") + return nil, nil, 0, errors.Wrap(e, "failed to collect page subtree") } if len(subtreeRows) == 0 { - return nil, 0, &ErrNotFound{EntityName: "Page", ID: pageID} + return nil, nil, 0, &ErrNotFound{EntityName: "Page", ID: pageID} } // Exclude the root from the descendant-count cap (subtreeRows always holds at least the root). if len(subtreeRows)-1 > MaxPageDescendantsLimit { - return nil, 0, &ErrLimitExceeded{Resource: "Page subtree for page_id=" + pageID + " (size)", Limit: MaxPageDescendantsLimit} + return nil, nil, 0, &ErrLimitExceeded{Resource: "Page subtree for page_id=" + pageID + " (size)", Limit: MaxPageDescendantsLimit} } ids := make([]string, 0, len(subtreeRows)) + owners := make(map[string]string, len(subtreeRows)) maxRelDepth := 0 for _, row := range subtreeRows { if row.Depth > MaxPageHierarchyDepth { - return nil, 0, &ErrLimitExceeded{Resource: "Page subtree for page_id=" + pageID + " (depth)", Limit: MaxPageHierarchyDepth} + return nil, nil, 0, &ErrLimitExceeded{Resource: "Page subtree for page_id=" + pageID + " (depth)", Limit: MaxPageHierarchyDepth} } maxRelDepth = max(maxRelDepth, row.Depth) ids = append(ids, row.ID) + owners[row.ID] = row.UserId } - return ids, maxRelDepth, nil + return ids, owners, maxRelDepth, nil } // rewriteSubtreeSpace re-homes the given page IDs onto diff --git a/server/store/page_move_test.go b/server/store/page_move_test.go index c33d300..2956ea2 100644 --- a/server/store/page_move_test.go +++ b/server/store/page_move_test.go @@ -121,7 +121,7 @@ func TestMovePageToSpace_Store(t *testing.T) { spaceB, err := s.CreateSpace(newSpace(chB)) require.NoError(t, err) - movedRoot, _, err := s.MovePageToSpace(root.Id, spaceA.Id, spaceB.Id, mmmodel.NewId(), nil, root.UpdateAt, false, store.MaxPageHierarchyDepth) + movedRoot, _, err := s.MovePageToSpace(root.Id, spaceA.Id, spaceB.Id, mmmodel.NewId(), nil, root.UpdateAt, false, store.MaxPageHierarchyDepth, "") require.NoError(t, err) require.Equal(t, spaceB.Id, movedRoot.SpaceId, "returned page reflects the committed move") require.Equal(t, chB, movedRoot.ChannelId) @@ -171,7 +171,7 @@ func TestMovePageToSpace_Store(t *testing.T) { require.NoError(t, err) require.Len(t, sourceBefore, 2, "both drafts are readable in the source space before the move") - _, _, err = s.MovePageToSpace(page.Id, spaceA.Id, spaceB.Id, user, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth) + _, _, err = s.MovePageToSpace(page.Id, spaceA.Id, spaceB.Id, user, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth, "") require.NoError(t, err) movedDraft, err := s.GetDraft(user, page.Id) @@ -210,7 +210,7 @@ func TestMovePageToSpace_Store(t *testing.T) { _, _, err = s.UpsertDraft(otherDraft, nil, nil, nil) require.NoError(t, err) - _, _, err = s.MovePageToSpace(page.Id, spaceA.Id, spaceB.Id, mover, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth) + _, _, err = s.MovePageToSpace(page.Id, spaceA.Id, spaceB.Id, mover, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth, "") require.NoError(t, err) moved, err := s.GetDraft(other, page.Id) @@ -250,7 +250,7 @@ func TestMovePageToSpace_Store(t *testing.T) { require.NoError(t, err) } - _, _, err = s.MovePageToSpace(page.Id, spaceA.Id, spaceB.Id, mover, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth) + _, _, err = s.MovePageToSpace(page.Id, spaceA.Id, spaceB.Id, mover, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth, "") require.Error(t, err) require.True(t, store.IsErrLimitExceeded(err), "expected ErrLimitExceeded, got %T: %v", err, err) @@ -286,7 +286,7 @@ func TestMovePageToSpace_Store(t *testing.T) { require.NoError(t, err) require.Equal(t, []string{user}, editorsBefore, "the user is an active editor in the source space before the move") - _, _, err = s.MovePageToSpace(page.Id, spaceA.Id, spaceB.Id, user, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth) + _, _, err = s.MovePageToSpace(page.Id, spaceA.Id, spaceB.Id, user, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth, "") require.NoError(t, err) movedDraft, err := s.GetDraft(user, page.Id) @@ -300,7 +300,7 @@ func TestMovePageToSpace_Store(t *testing.T) { t.Run("empty pageID returns invalid-input", func(t *testing.T) { s := openTestDB(t) - _, _, err := s.MovePageToSpace("", mmmodel.NewId(), mmmodel.NewId(), mmmodel.NewId(), nil, 0, false, store.MaxPageHierarchyDepth) + _, _, err := s.MovePageToSpace("", mmmodel.NewId(), mmmodel.NewId(), mmmodel.NewId(), nil, 0, false, store.MaxPageHierarchyDepth, "") require.Error(t, err) var inv *store.ErrInvalidInput require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) @@ -308,7 +308,7 @@ func TestMovePageToSpace_Store(t *testing.T) { t.Run("empty sourceSpaceID returns invalid-input", func(t *testing.T) { s := openTestDB(t) - _, _, err := s.MovePageToSpace(mmmodel.NewId(), "", mmmodel.NewId(), mmmodel.NewId(), nil, 0, false, store.MaxPageHierarchyDepth) + _, _, err := s.MovePageToSpace(mmmodel.NewId(), "", mmmodel.NewId(), mmmodel.NewId(), nil, 0, false, store.MaxPageHierarchyDepth, "") require.Error(t, err) var inv *store.ErrInvalidInput require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) @@ -316,7 +316,7 @@ func TestMovePageToSpace_Store(t *testing.T) { t.Run("empty targetSpaceID returns invalid-input", func(t *testing.T) { s := openTestDB(t) - _, _, err := s.MovePageToSpace(mmmodel.NewId(), mmmodel.NewId(), "", mmmodel.NewId(), nil, 0, false, store.MaxPageHierarchyDepth) + _, _, err := s.MovePageToSpace(mmmodel.NewId(), mmmodel.NewId(), "", mmmodel.NewId(), nil, 0, false, store.MaxPageHierarchyDepth, "") require.Error(t, err) var inv *store.ErrInvalidInput require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) @@ -328,7 +328,7 @@ func TestMovePageToSpace_Store(t *testing.T) { spaceB, err := s.CreateSpace(newSpace(chB)) require.NoError(t, err) - _, _, err = s.MovePageToSpace(mmmodel.NewId(), mmmodel.NewId(), spaceB.Id, mmmodel.NewId(), nil, 0, false, store.MaxPageHierarchyDepth) + _, _, err = s.MovePageToSpace(mmmodel.NewId(), mmmodel.NewId(), spaceB.Id, mmmodel.NewId(), nil, 0, false, store.MaxPageHierarchyDepth, "") require.Error(t, err) require.True(t, store.IsErrNotFound(err), "expected ErrNotFound, got %T: %v", err, err) }) @@ -529,7 +529,7 @@ func TestPageMutations_ScopedToSpace(t *testing.T) { }) t.Run("move-to-space with wrong source space is not found", func(t *testing.T) { - _, _, mErr := s.MovePageToSpace(page.Id, spaceB.Id, page.SpaceId, user, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth) + _, _, mErr := s.MovePageToSpace(page.Id, spaceB.Id, page.SpaceId, user, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth, "") require.True(t, store.IsErrNotFound(mErr)) }) @@ -566,6 +566,82 @@ func TestPageMutations_ScopedToSpace(t *testing.T) { }) } +// TestMovePageToSpace_RequiredOwnerID covers requiredOwnerID: a mechanical in-transaction +// ownership check the app layer supplies only when it resolved the caller to the own-scoped +// delete_own_page gate. A subtree with any live page owned by someone else is rejected; a subtree +// wholly owned by requiredOwnerID succeeds; an empty requiredOwnerID ignores ownership entirely. +func TestMovePageToSpace_RequiredOwnerID(t *testing.T) { + t.Run("descendant owned by a different user fails with subtree_not_owned", func(t *testing.T) { + s := openTestDB(t) + chA := mmmodel.NewId() + spaceA, err := s.CreateSpace(newSpace(chA)) + require.NoError(t, err) + owner := mmmodel.NewId() + root, err := s.CreatePage(newPage(spaceA.Id, chA, owner, ""), testDefaultMaxDepth) + require.NoError(t, err) + _, err = s.CreatePage(newPage(spaceA.Id, chA, mmmodel.NewId(), root.Id), testDefaultMaxDepth) + require.NoError(t, err) + + chB := mmmodel.NewId() + spaceB, err := s.CreateSpace(newSpace(chB)) + require.NoError(t, err) + + _, _, err = s.MovePageToSpace(root.Id, spaceA.Id, spaceB.Id, owner, nil, root.UpdateAt, false, store.MaxPageHierarchyDepth, owner) + require.Error(t, err) + var inv *store.ErrInvalidInput + require.True(t, errors.As(err, &inv), "expected ErrInvalidInput, got %T: %v", err, err) + require.Equal(t, store.ReasonSubtreeNotOwned, inv.Reason) + + // The rejected move must leave the subtree in place. + gotRoot, gErr := s.GetPage(root.Id, false) + require.NoError(t, gErr) + require.Equal(t, spaceA.Id, gotRoot.SpaceId) + }) + + t.Run("subtree wholly owned by requiredOwnerID succeeds", func(t *testing.T) { + s := openTestDB(t) + chA := mmmodel.NewId() + spaceA, err := s.CreateSpace(newSpace(chA)) + require.NoError(t, err) + owner := mmmodel.NewId() + root, err := s.CreatePage(newPage(spaceA.Id, chA, owner, ""), testDefaultMaxDepth) + require.NoError(t, err) + child, err := s.CreatePage(newPage(spaceA.Id, chA, owner, root.Id), testDefaultMaxDepth) + require.NoError(t, err) + + chB := mmmodel.NewId() + spaceB, err := s.CreateSpace(newSpace(chB)) + require.NoError(t, err) + + moved, _, err := s.MovePageToSpace(root.Id, spaceA.Id, spaceB.Id, owner, nil, root.UpdateAt, false, store.MaxPageHierarchyDepth, owner) + require.NoError(t, err) + require.Equal(t, spaceB.Id, moved.SpaceId) + + gotChild, gErr := s.GetPage(child.Id, false) + require.NoError(t, gErr) + require.Equal(t, spaceB.Id, gotChild.SpaceId, "the owned child follows the subtree") + }) + + t.Run("empty requiredOwnerID ignores ownership", func(t *testing.T) { + s := openTestDB(t) + chA := mmmodel.NewId() + spaceA, err := s.CreateSpace(newSpace(chA)) + require.NoError(t, err) + root, err := s.CreatePage(newPage(spaceA.Id, chA, mmmodel.NewId(), ""), testDefaultMaxDepth) + require.NoError(t, err) + _, err = s.CreatePage(newPage(spaceA.Id, chA, mmmodel.NewId(), root.Id), testDefaultMaxDepth) + require.NoError(t, err) + + chB := mmmodel.NewId() + spaceB, err := s.CreateSpace(newSpace(chB)) + require.NoError(t, err) + + moved, _, err := s.MovePageToSpace(root.Id, spaceA.Id, spaceB.Id, mmmodel.NewId(), nil, root.UpdateAt, false, store.MaxPageHierarchyDepth, "") + require.NoError(t, err) + require.Equal(t, spaceB.Id, moved.SpaceId) + }) +} + // TestMovePageToSpace_ConcurrentAutosaveInvariants exercises the space-row FOR UPDATE // serialization that guards a cross-space move against a simultaneous autosave on a draft of the // moving page. MovePageToSpace and UpsertDraft both take lockLiveSpace on the source space, so the @@ -587,7 +663,6 @@ func TestMovePageToSpace_ConcurrentAutosaveInvariants(t *testing.T) { chB := mmmodel.NewId() spaceB, err := s.CreateSpace(newSpace(chB)) require.NoError(t, err) - user := mmmodel.NewId() page, err := s.CreatePage(newPage(spaceA.Id, chA, user, ""), testDefaultMaxDepth) require.NoError(t, err) @@ -609,7 +684,7 @@ func TestMovePageToSpace_ConcurrentAutosaveInvariants(t *testing.T) { go func() { defer wg.Done() <-start - _, _, _ = s.MovePageToSpace(page.Id, spaceA.Id, spaceB.Id, user, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth) + _, _, _ = s.MovePageToSpace(page.Id, spaceA.Id, spaceB.Id, user, nil, page.UpdateAt, false, store.MaxPageHierarchyDepth, "") }() go func() { defer wg.Done() diff --git a/server/store/page_store.go b/server/store/page_store.go index b47b92a..90072e6 100644 --- a/server/store/page_store.go +++ b/server/store/page_store.go @@ -167,7 +167,7 @@ func (s *Store) GetPage(pageID string, includeDeleted bool) (*model.Page, error) // When force is false it compare-and-swaps on EditAt, returning ErrConflict if a concurrent // writer has advanced it. When force is true the CAS is skipped, but the patch is still merged // into the current row, so fields the patch leaves untouched keep any concurrent edit rather -// than being clobbered by a stale snapshot. lastModifiedBy records the editor. +// than being overwritten by a stale snapshot. lastModifiedBy records the editor. func (s *Store) UpdatePage(pageID, spaceID string, patch *model.PagePatch, baseEditAt int64, force bool, lastModifiedBy string) (_ *model.Page, err error) { if pageID == "" { return nil, &ErrInvalidInput{Entity: "Page", Field: "Id", Value: pageID} diff --git a/server/store/space_store.go b/server/store/space_store.go index 421bf7e..2d4735f 100644 --- a/server/store/space_store.go +++ b/server/store/space_store.go @@ -19,7 +19,7 @@ import ( ) var spaceSelectColumns = []string{ - "Id", "ChannelId", "TeamId", "CreatorId", "Title", "Description", "Icon", "Props", + "Id", "ChannelId", "TeamId", "CreatorId", "Title", "Description", "Icon", "Props", "ViewAccess", "CreateAt", "UpdateAt", "DeleteAt", "SortOrder", } @@ -43,7 +43,7 @@ func (s *Store) CreateSpace(space *model.Space) (*model.Space, error) { builder := s.getQueryBuilder(). Insert("DOCS_Space"). Columns(spaceSelectColumns...). - Values(space.Id, space.ChannelId, space.TeamId, space.CreatorId, space.Title, space.Description, space.Icon, space.GetProps(), + Values(space.Id, space.ChannelId, space.TeamId, space.CreatorId, space.Title, space.Description, space.Icon, space.GetProps(), space.ViewAccess, space.CreateAt, space.UpdateAt, space.DeleteAt, space.SortOrder) if _, err := s.execBuilder(s.db, builder); err != nil { @@ -81,13 +81,16 @@ func (s *Store) GetSpace(spaceID string, includeDeleted bool) (*model.Space, err return &space, nil } -// GetSpacesForTeam returns live spaces for the given team visible to userID, ordered by -// SortOrder ascending with CreateAt then Id as stable tie-breakers. Visibility is membership -// of the space's backing channel, resolved by a read-only join against core's ChannelMembers -// table: space ("S") channels are excluded from the generic channel-listing plugin APIs, so -// the caller cannot supply its visible-channel set. There is deliberately no unfiltered -// variant, so a listing can never bypass the membership filter. limit must be > 0. -func (s *Store) GetSpacesForTeam(teamID, userID string, offset, limit int) ([]*model.Space, error) { +// GetSpacesForTeam returns one page of the given team's live spaces visible to userID, ordered by +// SortOrder ascending with CreateAt then Id as stable tie-breakers. A space is visible when the +// caller is a member of its backing channel (a read-only EXISTS against core's ChannelMembers +// table: space ("S") channels are excluded from the generic channel-listing plugin APIs, so the +// caller cannot supply its visible-channel set), or when the space is ViewAccess='open' and +// callerHasOpenFallthrough is true — a single app-layer-computed boolean carrying the caller's +// team-active/read_public_channel/compliance-mode conjunct (see the app-layer read resolver); +// the store never evaluates permissions itself. There is deliberately no unfiltered variant, so a +// listing can never bypass this predicate. limit must be > 0. +func (s *Store) GetSpacesForTeam(teamID, userID string, callerHasOpenFallthrough bool, offset, limit int) ([]*model.Space, error) { if teamID == "" { return nil, &ErrInvalidInput{Entity: "Space", Field: "teamID", Value: teamID} } @@ -98,11 +101,16 @@ func (s *Store) GetSpacesForTeam(teamID, userID string, offset, limit int) ([]*m return nil, err } + visible := sq.Or{sq.Expr("EXISTS (SELECT 1 FROM ChannelMembers cm WHERE cm.ChannelId = sp.ChannelId AND cm.UserId = ?)", userID)} + if callerHasOpenFallthrough { + visible = append(visible, sq.Eq{"sp.ViewAccess": model.ViewAccessOpen}) + } + builder := s.getQueryBuilder(). Select(columnsWithAlias("sp", spaceSelectColumns)...). From("DOCS_Space sp"). - Join("ChannelMembers cm ON cm.ChannelId = sp.ChannelId"). - Where(sq.Eq{"sp.TeamId": teamID, "sp.DeleteAt": 0, "cm.UserId": userID}). + Where(sq.Eq{"sp.TeamId": teamID, "sp.DeleteAt": 0}). + Where(visible). OrderBy("sp.SortOrder ASC", "sp.CreateAt DESC", "sp.Id ASC") builder = applyLimitOffset(builder, offset, limit) @@ -115,10 +123,10 @@ func (s *Store) GetSpacesForTeam(teamID, userID string, offset, limit int) ([]*m // UpdateSpace applies patch to a live space's mutable fields (Title, Description, Icon, // Props). The patch is merged into the row read under lock, so fields the patch leaves nil -// keep any concurrent writer's value rather than being clobbered by the caller's stale -// snapshot. expectedUpdateAt is the optimistic-lock baseline (first-one-wins): a mismatch -// returns ErrConflict. Passing force skips the check (last-write-wins), but the merge into -// the locked row still applies. +// keep any concurrent writer's value rather than being overwritten by the caller's stale +// snapshot. expectedUpdateAt is the optimistic-lock baseline: a mismatch returns ErrConflict, +// so the first update to commit is the one kept. Passing force skips that check and applies the +// caller's update over whatever is stored, but the merge into the locked row still applies. func (s *Store) UpdateSpace(spaceID string, patch *model.SpacePatch, expectedUpdateAt int64, force bool) (_ *model.Space, err error) { if spaceID == "" { return nil, &ErrInvalidInput{Entity: "Space", Field: "Id", Value: spaceID} @@ -130,10 +138,11 @@ func (s *Store) UpdateSpace(spaceID string, patch *model.SpacePatch, expectedUpd return nil, &ErrInvalidInput{Entity: "Space", Field: "Patch", Value: validErr.Error(), Reason: validErr.Id} } - tx, err := s.db.Beginx() + tx, cancel, err := s.beginBoundedTx() if err != nil { return nil, errors.Wrap(err, "begin_transaction") } + defer cancel() defer s.finalizeTransaction(tx, &err) var existing model.Space @@ -167,6 +176,7 @@ func (s *Store) UpdateSpace(spaceID string, patch *model.SpacePatch, expectedUpd Set("Description", existing.Description). Set("Icon", existing.Icon). Set("Props", existing.GetProps()). + Set("ViewAccess", existing.ViewAccess). Set("UpdateAt", existing.UpdateAt). Where(sq.Eq{"Id": existing.Id, "DeleteAt": 0}) @@ -355,13 +365,17 @@ const ( // WithSpaceMembershipLock runs fn while holding spaceID's membership advisory lock, serializing // membership mutations for one space across processes. Guards that span multiple non-database // calls — read the member list, then mutate it — are atomic with respect to each other only -// under this lock. The lock is session-scoped on a dedicated pooled connection, with no open -// transaction: fn does no database work of its own, so wrapping it in a transaction would only -// expose the guard to idle-in-transaction timeouts. The connection is still intentionally held -// for fn's whole duration — cross-process serialization of a read-modify-write that spans -// non-database calls has no cheaper primitive — so fn must stay short. fn's error is returned -// unchanged. When the lock cannot be acquired within spaceMembershipLockAcquireTimeout, -// ErrConflict is returned so the caller can surface a retryable conflict. +// under this lock. The lock is session-scoped on a dedicated pooled connection with no open +// transaction of its own. fn may perform store work of its own — reads and its own transactions +// — so each in-flight caller holds two pooled connections for the critical section: this +// lock's session connection plus any connection fn's own transaction acquires. Any transaction fn +// opens must therefore bound its connection acquisition (see beginBoundedTx), or a saturated pool +// can leave every lock holder waiting on a connection no other holder will release. fn must also +// stay short: the lock connection is held for fn's whole duration, and cross-process +// serialization of a read-modify-write that spans non-database calls has no cheaper primitive. +// fn's error is returned unchanged. When the lock cannot be acquired within +// spaceMembershipLockAcquireTimeout, ErrConflict is returned so the caller can surface a +// retryable conflict. func (s *Store) WithSpaceMembershipLock(spaceID string, fn func() error) error { return s.withSpaceMembershipLock(spaceID, spaceMembershipLockAcquireTimeout, fn) } @@ -400,7 +414,7 @@ func (s *Store) withSpaceMembershipLock(spaceID string, acquireTimeout time.Dura } } if ctx.Err() != nil { - return &ErrConflict{Resource: "Space membership lock space_id=" + spaceID} + return &ErrConflict{Resource: "Space membership lock space_id=" + spaceID, Reason: ReasonLockTimeout} } return errors.Wrap(lockErr, "failed to acquire space membership advisory lock") } @@ -409,7 +423,7 @@ func (s *Store) withSpaceMembershipLock(spaceID string, acquireTimeout time.Dura } select { case <-ctx.Done(): - return &ErrConflict{Resource: "Space membership lock space_id=" + spaceID} + return &ErrConflict{Resource: "Space membership lock space_id=" + spaceID, Reason: ReasonLockTimeout} case <-time.After(spaceMembershipLockRetryInterval): } } diff --git a/server/store/store.go b/server/store/store.go index 7d009f5..05cbd15 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -168,6 +168,26 @@ func (s *Store) finalizeTransaction(tx *sqlx.Tx, perr *error) { } } +// beginBoundedTx starts a transaction bounded by defaultQueryTimeout, and must be used by any +// transaction that can run while its caller already holds WithSpaceMembershipLock's dedicated +// connection. Such a caller needs a second pooled connection while holding one, so an unbounded +// acquisition can wait forever on a saturated pool while itself holding a connection that pool +// needs in order to drain. +// +// The timeout spans the whole transaction, not just connection acquisition: the context is handed +// to BeginTxx, so database/sql rolls the transaction back if it expires mid-flight. Callers must +// therefore finish inside defaultQueryTimeout, not merely start inside it. The returned cancel must +// be deferred, and runs after the commit or rollback that the caller's own defer performs. +func (s *Store) beginBoundedTx() (*sqlx.Tx, context.CancelFunc, error) { + ctx, cancel := context.WithTimeout(context.Background(), defaultQueryTimeout) + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + cancel() + return nil, nil, err + } + return tx, cancel, nil +} + // get executes a query and scans one row into dest. func (s *Store) get(e sqlx.ExtContext, dest any, query string, args ...any) error { query = e.Rebind(query) @@ -313,6 +333,7 @@ const ( ReasonMaxDepthExceeded = "max_depth_exceeded" ReasonSubtreeMaxDepthExceeded = "subtree_max_depth_exceeded" ReasonParentNotLive = "parent_not_live" + ReasonSubtreeNotOwned = "subtree_not_owned" ReasonDraftCycle = "draft_cycle" ReasonDraftTooDeep = "draft_too_deep" ReasonDraftQuotaExceeded = "draft_quota_exceeded" @@ -376,6 +397,17 @@ func IsErrConflict(err error) bool { return errors.As(err, &e) } +// ReasonLockTimeout marks an ErrConflict raised by a WithSpaceMembershipLock acquisition timeout, +// distinct from the default CAS/unique-constraint conflict. +const ReasonLockTimeout = "lock_timeout" + +// IsErrLockTimeout reports whether err is an ErrConflict raised by a space-membership advisory +// lock acquisition timeout. +func IsErrLockTimeout(err error) bool { + var e *ErrConflict + return errors.As(err, &e) && e.Reason == ReasonLockTimeout +} + // ConflictReason returns the Reason of the ErrConflict in err's chain, or "" if err is not an // ErrConflict or carries no reason. func ConflictReason(err error) string { diff --git a/server/store/store_test.go b/server/store/store_test.go index d9b987c..3928561 100644 --- a/server/store/store_test.go +++ b/server/store/store_test.go @@ -659,7 +659,7 @@ func TestUpdateSpaceForceAndDefault(t *testing.T) { saved, err := s.CreateSpace(newSpace(mmmodel.NewId())) require.NoError(t, err) - // A missing baseline (UpdateAt == 0) must conflict by default, not last-write-wins. + // A missing baseline (UpdateAt == 0) must conflict by default, not overwrite the stored row. noBaselineTitle := "No baseline" _, conflictErr := s.UpdateSpace(saved.Id, &model.SpacePatch{Title: &noBaselineTitle}, 0, false) require.Error(t, conflictErr) @@ -674,7 +674,7 @@ func TestUpdateSpaceForceAndDefault(t *testing.T) { // TestUpdateSpaceForceMergesPatch verifies that a forced update merges the patch into the row // read under lock: fields the patch leaves nil keep a concurrent writer's value instead of -// being clobbered by the forcing caller's stale snapshot. +// being overwritten by the forcing caller's stale snapshot. func TestUpdateSpaceForceMergesPatch(t *testing.T) { s := openTestDB(t) @@ -690,7 +690,7 @@ func TestUpdateSpaceForceMergesPatch(t *testing.T) { forced, forceErr := s.UpdateSpace(saved.Id, &model.SpacePatch{Title: &title}, saved.UpdateAt, true) require.NoError(t, forceErr) require.Equal(t, "forced title", forced.Title) - require.Equal(t, "concurrent description", forced.Description, "force must not clobber fields the patch omits") + require.Equal(t, "concurrent description", forced.Description, "force must not overwrite fields the patch omits") require.Greater(t, forced.UpdateAt, afterConcurrent.UpdateAt) } @@ -824,44 +824,77 @@ func TestGetSpacesForTeam(t *testing.T) { testutil.MustAddChannelMember(t, db, chVisible, memberOfOne) t.Run("returns every team space whose backing channel the user belongs to", func(t *testing.T) { - spaces, err := s.GetSpacesForTeam(teamID, memberOfAll, 0, 100) + spaces, err := s.GetSpacesForTeam(teamID, memberOfAll, false, 0, 100) require.NoError(t, err) require.Len(t, spaces, 2) }) t.Run("filters to the user's channel memberships", func(t *testing.T) { - spaces, err := s.GetSpacesForTeam(teamID, memberOfOne, 0, 100) + spaces, err := s.GetSpacesForTeam(teamID, memberOfOne, false, 0, 100) require.NoError(t, err) require.Len(t, spaces, 1) require.Equal(t, visible.Id, spaces[0].Id) }) t.Run("user with no memberships gets an empty result", func(t *testing.T) { - spaces, err := s.GetSpacesForTeam(teamID, mmmodel.NewId(), 0, 100) + spaces, err := s.GetSpacesForTeam(teamID, mmmodel.NewId(), false, 0, 100) require.NoError(t, err) require.Empty(t, spaces) }) t.Run("pagination excludes hidden spaces before offset/limit", func(t *testing.T) { // Only 1 visible space; with per_page=10 and 2 total, hidden must not count toward has_more. - spaces, err := s.GetSpacesForTeam(teamID, memberOfOne, 0, 10) + spaces, err := s.GetSpacesForTeam(teamID, memberOfOne, false, 0, 10) require.NoError(t, err) require.Len(t, spaces, 1) }) t.Run("rejects empty userID", func(t *testing.T) { - _, err := s.GetSpacesForTeam(teamID, "", 0, 100) + _, err := s.GetSpacesForTeam(teamID, "", false, 0, 100) require.Error(t, err) require.True(t, store.IsErrInvalidInput(err)) }) t.Run("rejects non-positive limit", func(t *testing.T) { for _, limit := range []int{0, -1} { - _, err := s.GetSpacesForTeam(teamID, memberOfAll, 0, limit) + _, err := s.GetSpacesForTeam(teamID, memberOfAll, false, 0, limit) require.Error(t, err) require.True(t, store.IsErrInvalidInput(err), "limit=%d must return ErrInvalidInput; got %v", limit, err) } }) + + t.Run("open non-member space is included only with the open fall-through, and never a private one", func(t *testing.T) { + nonMember := mmmodel.NewId() + + openChannel := mmmodel.NewId() + openSpace := newSpace(openChannel) + openSpace.TeamId = teamID + openSpace.ViewAccess = model.ViewAccessOpen + _, err := s.CreateSpace(openSpace) + require.NoError(t, err) + + privateChannel := mmmodel.NewId() + privateSpace := newSpace(privateChannel) + privateSpace.TeamId = teamID + privateSpace.ViewAccess = model.ViewAccessPrivate + _, err = s.CreateSpace(privateSpace) + require.NoError(t, err) + + withFallthrough, err := s.GetSpacesForTeam(teamID, nonMember, true, 0, 100) + require.NoError(t, err) + var ids []string + for _, sp := range withFallthrough { + ids = append(ids, sp.Id) + } + require.Contains(t, ids, openSpace.Id, "an open space must be visible to a non-member with the fall-through") + require.NotContains(t, ids, privateSpace.Id, "a private space must never be visible via the open fall-through") + + withoutFallthrough, err := s.GetSpacesForTeam(teamID, nonMember, false, 0, 100) + require.NoError(t, err) + for _, sp := range withoutFallthrough { + require.NotEqual(t, openSpace.Id, sp.Id, "the open space must not appear when the caller lacks the fall-through") + } + }) } // TestGetSpacePages_NonPositiveLimit verifies that GetSpacePages rejects limit <= 0 @@ -1290,7 +1323,7 @@ func TestRestorePageAppendsAtEndOfSiblingGroup(t *testing.T) { } // TestDeleteRestoreAdvancesEditAt verifies the CAS token (EditAt) advances across a delete+restore -// cycle, so a client holding the pre-delete token still hits a conflict instead of clobbering state. +// cycle, so a client holding the pre-delete token still hits a conflict instead of overwriting state. func TestDeleteRestoreAdvancesEditAt(t *testing.T) { s := openTestDB(t) @@ -1561,7 +1594,7 @@ func TestUpdatePageForceConcurrentMonotonic(t *testing.T) { // TestUpdatePageForcePreservesUnpatchedFields verifies that a force save merges the patch into // the live row under the lock, so a field the patch leaves nil keeps a concurrent edit instead -// of being clobbered by a stale snapshot. A title-only force save must not revert a body edit +// of being overwritten by a stale snapshot. A title-only force save must not revert a body edit // that landed after the caller's baseEditAt. func TestUpdatePageForcePreservesUnpatchedFields(t *testing.T) { s := openTestDB(t) @@ -1586,7 +1619,7 @@ func TestUpdatePageForcePreservesUnpatchedFields(t *testing.T) { forced, err := s.UpdatePage(created.Id, created.SpaceId, &model.PagePatch{Title: &forcedTitle}, created.EditAt, true, userID) require.NoError(t, err) require.Equal(t, forcedTitle, forced.Title) - require.Equal(t, concurrentBody, forced.Body, "force save must not clobber the concurrent body edit") + require.Equal(t, concurrentBody, forced.Body, "force save must not overwrite the concurrent body edit") // Confirm it persisted, not just the returned struct. persisted, err := s.GetPage(created.Id, false) diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index c9d4f22..0f37edd 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -22,6 +22,10 @@ "docs.createSpace.title": "Create a new space", "docs.createSpace.urlAriaLabel": "Space URL", "docs.createSpace.visibilityLabel": "Space visibility", + "docs.editor.header.draft": "Draft · {spaceId} / {pageId}", + "docs.editor.header.published": "Published · {spaceId} / {pageId}", + "docs.editor.hostMissing": "This Mattermost build does not publish the Docs editor. Update the server to edit pages here.", + "docs.editor.stub.body": "Editor is available and will mount here. Suggestion providers exposed: {providerCount}.", "docs.form.url.edit": "Edit", "docs.form.url.label": "URL:", "docs.genericModal.close": "Close", @@ -57,7 +61,6 @@ "docs.sidebar.add.browse": "Browse spaces", "docs.sidebar.add.create": "Create a space", "docs.sidebar.add.menu": "Add or browse spaces", - "docs.sidebar.category.favorites": "Favorites", "docs.sidebar.category.spaces": "Spaces", "docs.sidebar.createSpace": "Create a space", "docs.sidebar.favorites.empty": "Drag favorite items here or click the star icon on any space", @@ -65,10 +68,8 @@ "docs.sidebar.nav.home": "Home", "docs.sidebar.search.placeholder": "Find docs", "docs.sidebar.space.copyLink": "Copy link", - "docs.sidebar.space.favorite": "Add to favorites", "docs.sidebar.space.leave": "Leave space", "docs.sidebar.space.menu": "Space options for {name}", - "docs.sidebar.space.unfavorite": "Remove from favorites", "docs.sidebar.team.createTeam": "Create a team", "docs.sidebar.team.invite": "Invite people", "docs.sidebar.team.invite.secondary": "Add or invite people to the team", diff --git a/webapp/src/components/spaces_sidebar/space_item_menu.tsx b/webapp/src/components/spaces_sidebar/space_item_menu.tsx index 4d5c9a5..51ce4f0 100644 --- a/webapp/src/components/spaces_sidebar/space_item_menu.tsx +++ b/webapp/src/components/spaces_sidebar/space_item_menu.tsx @@ -36,7 +36,9 @@ const SpaceItemMenu = ({space}: Props) => { // preferences (spec §4, Phase B). // { // id: 'favorite', - // label: , + // label: isFavorite ? + // : + // , // leadingIcon: , // onClick: () => onToggleFavorite(space.id), // },