Сравнить коммиты
33 Коммитов
release-10
...
31b97d9ee1
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
31b97d9ee1 | ||
|
|
b7a67fb9a4 | ||
|
|
3b1f311c5f | ||
|
|
03787f9386 | ||
|
|
f6d3a7827e | ||
|
|
beaa59db54 | ||
|
|
56098dd6f0 | ||
|
|
5563e09593 | ||
|
|
d00e9d48d3 | ||
|
|
24b6762dfb | ||
|
|
71deee9a15 | ||
|
|
4439c6121d | ||
|
|
36ac3a43b1 | ||
|
|
c0ea57ac6c | ||
|
|
79ba3d3d83 | ||
|
|
d9a55e394c | ||
|
|
7c09a18ae9 | ||
|
|
07c4273280 | ||
|
|
5b85331de9 | ||
|
|
a09598945e | ||
|
|
9fab838b16 | ||
|
|
ceeafd4915 | ||
|
|
202d125afa | ||
|
|
977c791e5b | ||
|
|
8000e59335 | ||
|
|
9408b98025 | ||
|
|
073bd3a6b7 | ||
|
|
aba9339a24 | ||
|
|
6fd49f56b5 | ||
|
|
95be6eaf86 | ||
|
|
61d68d2d6e | ||
|
|
e5593b6489 | ||
|
|
1df2909dfc |
127
.gitlab-ci.yml
127
.gitlab-ci.yml
@@ -1,127 +0,0 @@
|
||||
---
|
||||
image: debian:bookworm
|
||||
stages:
|
||||
- build
|
||||
- publish
|
||||
|
||||
variables:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/go/bin:/usr/local/go/bin
|
||||
GO_VERSION: 1.25.8
|
||||
GO_HASHSUM: ceb5e041bbc3893846bd1614d76cb4681c91dadee579426cf21a63f2d7e03be6
|
||||
|
||||
build:
|
||||
stage: build
|
||||
before_script:
|
||||
- mkdir artifacts
|
||||
- apt update
|
||||
- apt install -qq -y build-essential libpng-dev libpng16-16 wget curl git
|
||||
- ulimit -n 8096
|
||||
- cd /tmp
|
||||
- wget -q "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz"
|
||||
- >
|
||||
echo \
|
||||
"${GO_HASHSUM} go${GO_VERSION}.linux-amd64.tar.gz" \
|
||||
> "go${GO_VERSION}.linux-amd64.tar.gz.sha256sum"
|
||||
- sha256sum -c "go${GO_VERSION}.linux-amd64.tar.gz.sha256sum"
|
||||
- tar -C /usr/local -xzf "go${GO_VERSION}.linux-amd64.tar.gz"
|
||||
- cd -
|
||||
script:
|
||||
- >
|
||||
sed -e "s/^\(BUILD_NUMBER\) ?= .*/\1 = $CI_JOB_ID/" \
|
||||
-e "s/^\(BUILD_HASH = \).*/\1$(git rev-parse HEAD)/" \
|
||||
-i server/Makefile
|
||||
- git apply limitless.patch
|
||||
- cd server
|
||||
- make validate-go-version
|
||||
- make setup-go-work
|
||||
- make build-linux-arm64
|
||||
- make build-linux-amd64
|
||||
- cd -
|
||||
- >
|
||||
mv server/bin/mostlymatter \
|
||||
"mostlymatter-amd64-$(basename "$CI_COMMIT_TAG" -limitless)"
|
||||
- >
|
||||
mv server/bin/linux_arm64/mostlymatter \
|
||||
"mostlymatter-arm64-$(basename "$CI_COMMIT_TAG" -limitless)"
|
||||
cache:
|
||||
key: "$CI_COMMIT_TAG"
|
||||
policy: push
|
||||
paths:
|
||||
- "mostlymatter-*"
|
||||
rules:
|
||||
- if: '$CI_COMMIT_TAG =~ /limitless/'
|
||||
|
||||
# Release
|
||||
publish:
|
||||
stage: publish
|
||||
image: framasoft/upload-packages:latest
|
||||
needs:
|
||||
- build
|
||||
before_script:
|
||||
- mkdir -p ~/.minisign
|
||||
- chmod 700 ~/.minisign
|
||||
- >
|
||||
echo 'untrusted comment: minisign encrypted secret key' \
|
||||
> ~/.minisign/minisign.key
|
||||
- echo "$MINISIG_KEY" >> ~/.minisign/minisign.key
|
||||
- chmod 600 ~/.minisign/minisign.key
|
||||
- >
|
||||
echo "$MINISIG_PWD" |
|
||||
minisign -Sm "mostlymatter-amd64-$(basename "$CI_COMMIT_TAG" -limitless)"
|
||||
- >
|
||||
sha512sum "mostlymatter-amd64-$(basename "$CI_COMMIT_TAG" -limitless)" \
|
||||
> "mostlymatter-amd64-$(basename "$CI_COMMIT_TAG" -limitless).sha512"
|
||||
- >
|
||||
echo "$MINISIG_PWD" |
|
||||
minisign -Sm "mostlymatter-arm64-$(basename "$CI_COMMIT_TAG" -limitless)"
|
||||
- >
|
||||
sha512sum "mostlymatter-arm64-$(basename "$CI_COMMIT_TAG" -limitless)" \
|
||||
> "mostlymatter-arm64-$(basename "$CI_COMMIT_TAG" -limitless).sha512"
|
||||
script:
|
||||
- eval $(ssh-agent -s)
|
||||
- ssh-add <(echo "${DEPLOYEMENT_KEY}" | base64 --decode -i)
|
||||
- >
|
||||
echo "put mostlymatter-amd64-$(basename "$CI_COMMIT_TAG" -limitless)" |
|
||||
sftp -o "VerifyHostKeyDNS yes" \
|
||||
-o "StrictHostKeyChecking accept-new" \
|
||||
${DEPLOYEMENT_USER}@${DEPLOYEMENT_HOST}:public/
|
||||
- >
|
||||
echo "put mostlymatter-amd64-$(basename "$CI_COMMIT_TAG" -limitless).minisig" |
|
||||
sftp -o "VerifyHostKeyDNS yes" \
|
||||
-o "StrictHostKeyChecking accept-new" \
|
||||
${DEPLOYEMENT_USER}@${DEPLOYEMENT_HOST}:public/
|
||||
- >
|
||||
echo "put mostlymatter-amd64-$(basename "$CI_COMMIT_TAG" -limitless).sha512" |
|
||||
sftp -o "VerifyHostKeyDNS yes" \
|
||||
-o "StrictHostKeyChecking accept-new" \
|
||||
${DEPLOYEMENT_USER}@${DEPLOYEMENT_HOST}:public/
|
||||
- >
|
||||
echo "put mostlymatter-arm64-$(basename "$CI_COMMIT_TAG" -limitless)" |
|
||||
sftp -o "VerifyHostKeyDNS yes" \
|
||||
-o "StrictHostKeyChecking accept-new" \
|
||||
${DEPLOYEMENT_USER}@${DEPLOYEMENT_HOST}:public/
|
||||
- >
|
||||
echo "put mostlymatter-arm64-$(basename "$CI_COMMIT_TAG" -limitless).minisig" |
|
||||
sftp -o "VerifyHostKeyDNS yes" \
|
||||
-o "StrictHostKeyChecking accept-new" \
|
||||
${DEPLOYEMENT_USER}@${DEPLOYEMENT_HOST}:public/
|
||||
- >
|
||||
echo "put mostlymatter-arm64-$(basename "$CI_COMMIT_TAG" -limitless).sha512" |
|
||||
sftp -o "VerifyHostKeyDNS yes" \
|
||||
-o "StrictHostKeyChecking accept-new" \
|
||||
${DEPLOYEMENT_USER}@${DEPLOYEMENT_HOST}:public/
|
||||
- >
|
||||
cat <<EOF
|
||||
================================================================================================
|
||||
== mostlymatter-$(basename "$CI_COMMIT_TAG" -limitless) published on https://packages.framasoft.org/projects/mostlymatter/ ==
|
||||
================================================================================================
|
||||
EOF
|
||||
cache:
|
||||
key: "$CI_COMMIT_TAG"
|
||||
policy: pull
|
||||
paths:
|
||||
- "mostlymatter-amd64-$CI_COMMIT_TAG"
|
||||
- "mostlymatter-arm64-$CI_COMMIT_TAG"
|
||||
rules:
|
||||
- if: '$DEPLOYEMENT_HOST && $DEPLOYEMENT_USER && $DEPLOYEMENT_KEY && $MINISIG_KEY && $MINISIG_PWD && $CI_COMMIT_TAG =~ /limitless/'
|
||||
@@ -1,109 +0,0 @@
|
||||
# How to use Framasoft’s patch to compile Mostlymatter
|
||||
|
||||
## Setup the repository
|
||||
|
||||
```bash
|
||||
git clone https://framagit.org/framasoft/framateam/mostlymatter.git
|
||||
cd mostlymatter
|
||||
git remote add upstream https://github.com/mattermost/mattermost.git
|
||||
```
|
||||
|
||||
## New version
|
||||
|
||||
Refresh you local repository.
|
||||
```bash
|
||||
git fetch -p --all
|
||||
```
|
||||
|
||||
Set some env vars.
|
||||
```bash
|
||||
export NEW_VERSION=10.5.1
|
||||
```
|
||||
|
||||
As you will need to cherry-pick some commits (the main fork commit and a fix-patch commit), you will need to go on an old release branch.
|
||||
```bash
|
||||
export OLD_VERSION=10.5.0
|
||||
```
|
||||
|
||||
```bash
|
||||
export BASE_VERSION=$(echo "$NEW_VERSION" | sed -e "s/\.[^.]\+$//")
|
||||
git checkout "release-$OLD_VERSION"
|
||||
git log --graph --abbrev-commit --date=relative \
|
||||
--pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr by %an)%Creset' \
|
||||
--max-count=3
|
||||
```
|
||||
|
||||
Note the commits you’ll need (usually, the two first commits).
|
||||
|
||||
Go to the main branch of the version you want and reset the code to this version, then create a new branch with the version you want to compile from the main branch of this version.
|
||||
```bash
|
||||
git branch | grep -q "release-$BASE_VERSION\$" &&
|
||||
git checkout "release-$BASE_VERSION" ||
|
||||
git checkout -b "release-$BASE_VERSION" "upstream/release-$BASE_VERSION"
|
||||
|
||||
git reset --hard "v$NEW_VERSION"
|
||||
git checkout -b "release-$NEW_VERSION" "release-$BASE_VERSION"
|
||||
```
|
||||
|
||||
Cherry-pick the commits (use the oldest first!).
|
||||
|
||||
```bash
|
||||
for i in 4b3d29da 056a5f8c
|
||||
do
|
||||
git cherry-pick "$i"
|
||||
done
|
||||
```
|
||||
|
||||
If you compile a bugfix version (ex: `10.5.1`, using the commits of the `10.5.0` version), you should be just fine
|
||||
But if you compile a new version (ex: `10.6.0`), there is a lot of chances that you need to fix the `limitless.patch` file.
|
||||
|
||||
To test the patch:
|
||||
```bash
|
||||
git apply limitless.patch &&
|
||||
echo -e "\033[0;36mPatch applied successfully\033[0;36m" &&
|
||||
rm -rf server/cmd/mostlymatter &&
|
||||
git checkout -- server
|
||||
```
|
||||
|
||||
If the patch does not apply, fix it. The fix is usually those steps:
|
||||
|
||||
- remove the `server/.golangci.yml` part of the patch
|
||||
- manually apply this part (it’s mostly replacing `mattermost` by `mostlymatter` in this file)
|
||||
- `git apply limitless.patch`
|
||||
- `git add server`
|
||||
- `git diff --cached > limitless.patch`
|
||||
- `git restore --staged -- server`
|
||||
- `git checkout -- server`
|
||||
- `rm -rf server/cmd/mostlymatter`
|
||||
- `git add limitless.patch`
|
||||
- `git commit --amend`
|
||||
|
||||
Now, you can retest the patch:
|
||||
```bash
|
||||
git apply limitless.patch &&
|
||||
echo -e "\033[0;36mPatch applied successfully\033[0;36m" &&
|
||||
rm -rf server/cmd/mostlymatter &&
|
||||
git checkout -- server
|
||||
```
|
||||
|
||||
Tag the new version (`limitless` is needed in the tag name for the CI to run) and push to Gitlab:
|
||||
```bash
|
||||
git tag "v$NEW_VERSION-limitless" -m "v$NEW_VERSION-limitless"
|
||||
git push -u origin "release-$NEW_VERSION"
|
||||
```
|
||||
|
||||
The CI will compile mostlymatter. Note that it will not be available as an artifact.
|
||||
|
||||
You can use the step of [.gitlab-ci.yml](.gitlab-ci.yml) to compile Mostlymatter without using the CI.
|
||||
|
||||
## Publish
|
||||
|
||||
The secrets are set in GitLab CI variables :
|
||||
|
||||
- `MINISIG_KEY`
|
||||
- `MINISIG_PWD`
|
||||
- `DEPLOYEMENT_KEY` (ssh private key encoded with base64)
|
||||
- `DEPLOYEMENT_USER`
|
||||
- `DEPLOYEMENT_HOST`
|
||||
|
||||
The CI will publish the compiled mostlymatter binary through sftp.
|
||||
92
README.md
92
README.md
@@ -1,33 +1,97 @@
|
||||
 Mostlymatter is a fork of [Mattermost](https://mattermost.com) meant to remove the users and messages limits.
|
||||
# [](https://mattermost.com)
|
||||
|
||||
[Mattermost](https://mattermost.com) is an open core, self-hosted collaboration platform that offers chat, workflow automation, voice calling, screen sharing, and AI integration. This repo is the primary source for core development on the Mattermost platform; it's written in Go and React, runs as a single Linux binary, and relies on PostgreSQL. A new compiled version is released under an MIT license every month on the 16th.
|
||||
|
||||
Please go to <https://github.com/mattermost/mattermost/> for installation instructions.
|
||||
[Deploy Mattermost on-premises](https://mattermost.com/deploy/?utm_source=github-mattermost-server-readme), or [try it for free in the cloud](https://mattermost.com/sign-up/?utm_source=github-mattermost-server-readme).
|
||||
|
||||
## Differences between Mostlymatter and Mattermost
|
||||
<img width="1006" alt="mattermost user interface" src="https://user-images.githubusercontent.com/7205829/136107976-7a894c9e-290a-490d-8501-e5fdbfc3785a.png">
|
||||
|
||||
Our fork is limited to the backend (the binary), we don’t modify the front-end and we don’t provide compiled version of the other tools of Mattermost (like `mmctl`).
|
||||
Learn more about the following use cases with Mattermost:
|
||||
|
||||
We multiplied the limits in `server/channels/app/limits.go` by 1,000.
|
||||
- [DevSecOps](https://mattermost.com/solutions/use-cases/devops/?utm_source=github-mattermost-server-readme)
|
||||
- [Incident Resolution](https://mattermost.com/solutions/use-cases/incident-resolution/?utm_source=github-mattermost-server-readme)
|
||||
- [IT Service Desk](https://mattermost.com/solutions/use-cases/it-service-desk/?utm_source=github-mattermost-server-readme)
|
||||
|
||||
The user limits should be 5,000,000 and 11,000,000.
|
||||
Other useful resources:
|
||||
|
||||
We replaced the name `Mattermost` by `Mostlymatter` (and `mattermost` by `mostlymatter`) in the server strings (logging, help pages…) to avoid confusion between our the official build and our own but we kept the copyright comments.
|
||||
- [Download and Install Mattermost](https://docs.mattermost.com/guides/deployment.html) - Install, setup, and configure your own Mattermost instance.
|
||||
- [Product documentation](https://docs.mattermost.com/) - Learn how to run a Mattermost instance and take advantage of all the features.
|
||||
- [Developer documentation](https://developers.mattermost.com/) - Contribute code to Mattermost or build an integration via APIs, Webhooks, slash commands, Apps, and plugins.
|
||||
|
||||
The modifications are contained in the file [`limitless.patch`](limitless.patch).
|
||||
Table of contents
|
||||
=================
|
||||
|
||||
To apply our modifications and compile Mostlymatter, please have a look at [MOSTLYMATTER_HOW_TO.md](MOSTLYMATTER_HOW_TO.md).
|
||||
- [Install Mattermost](#install-mattermost)
|
||||
- [Native mobile and desktop apps](#native-mobile-and-desktop-apps)
|
||||
- [Get security bulletins](#get-security-bulletins)
|
||||
- [Get involved](#get-involved)
|
||||
- [Learn more](#learn-more)
|
||||
- [License](#license)
|
||||
- [Get the latest news](#get-the-latest-news)
|
||||
- [Contributing](#contributing)
|
||||
|
||||
## Get Mostlymatter binaries
|
||||
## Install Mattermost
|
||||
|
||||
Go to <https://packages.framasoft.org/projects/mostlymatter/> to get the binaries you want.
|
||||
- [Download and Install Mattermost Self-Hosted](https://docs.mattermost.com/guides/deployment.html) - Deploy a Mattermost Self-hosted instance in minutes via Docker, Ubuntu, or tar.
|
||||
- [Get started in the cloud](https://mattermost.com/sign-up/?utm_source=github-mattermost-server-readme) to try Mattermost today.
|
||||
- [Developer machine setup](https://developers.mattermost.com/contribute/server/developer-setup) - Follow this guide if you want to write code for Mattermost.
|
||||
|
||||
Follow the instructions on top of the page to verify the binaries you downloaded.
|
||||
|
||||
Other install guides:
|
||||
|
||||
- [Deploy Mattermost on Docker](https://docs.mattermost.com/install/install-docker.html)
|
||||
- [Mattermost Omnibus](https://docs.mattermost.com/install/installing-mattermost-omnibus.html)
|
||||
- [Install Mattermost from Tar](https://docs.mattermost.com/install/install-tar.html)
|
||||
- [Ubuntu 20.04 LTS](https://docs.mattermost.com/install/installing-ubuntu-2004-LTS.html)
|
||||
- [Kubernetes](https://docs.mattermost.com/install/install-kubernetes.html)
|
||||
- [Helm](https://docs.mattermost.com/install/install-kubernetes.html#installing-the-operators-via-helm)
|
||||
- [Debian Buster](https://docs.mattermost.com/install/install-debian.html)
|
||||
- [RHEL 8](https://docs.mattermost.com/install/install-rhel-8.html)
|
||||
- [More server install guides](https://docs.mattermost.com/guides/deployment.html)
|
||||
|
||||
## Native mobile and desktop apps
|
||||
|
||||
In addition to the web interface, you can also download Mattermost clients for [Android](https://mattermost.com/pl/android-app/), [iOS](https://mattermost.com/pl/ios-app/), [Windows PC](https://docs.mattermost.com/install/desktop-app-install.html#windows-10-windows-8-1), [macOS](https://docs.mattermost.com/install/desktop-app-install.html#macos-10-9), and [Linux](https://docs.mattermost.com/install/desktop-app-install.html#linux).
|
||||
|
||||
[<img src="https://user-images.githubusercontent.com/30978331/272826427-6200c98f-7319-42c3-86d4-0b33ae99e01a.png" alt="Get Mattermost on Google Play" height="50px"/>](https://mattermost.com/pl/android-app/) [<img src="https://developer.apple.com/assets/elements/badges/download-on-the-app-store.svg" alt="Get Mattermost on the App Store" height="50px"/>](https://itunes.apple.com/us/app/mattermost/id1257222717?mt=8) [](https://docs.mattermost.com/install/desktop.html#windows-10-windows-8-1-windows-7) [](https://docs.mattermost.com/install/desktop.html#macos-10-9) [](https://docs.mattermost.com/install/desktop.html#linux)
|
||||
|
||||
## Get security bulletins
|
||||
|
||||
Receive notifications of critical security updates. The sophistication of online attackers is perpetually increasing. If you're deploying Mattermost it's highly recommended you subscribe to the Mattermost Security Bulletin mailing list for updates on critical security releases.
|
||||
|
||||
[Subscribe here](https://mattermost.com/security-updates/#sign-up)
|
||||
|
||||
## Get involved
|
||||
|
||||
- [Contribute to Mattermost](https://handbook.mattermost.com/contributors/contributors/ways-to-contribute)
|
||||
- [Find "Help Wanted" projects](https://github.com/mattermost/mattermost-server/issues?page=1&q=is%3Aissue+is%3Aopen+%22Help+Wanted%22&utf8=%E2%9C%93)
|
||||
- [Join Developer Discussion on a Mattermost server for contributors](https://community.mattermost.com/signup_user_complete/?id=f1924a8db44ff3bb41c96424cdc20676)
|
||||
- [Get Help With Mattermost](https://docs.mattermost.com/guides/get-help.html)
|
||||
|
||||
## Learn more
|
||||
|
||||
- [API options - webhooks, slash commands, drivers, and web service](https://api.mattermost.com/)
|
||||
- [See who's using Mattermost](https://mattermost.com/customers/)
|
||||
- [Browse over 700 Mattermost integrations](https://mattermost.com/marketplace/)
|
||||
|
||||
## License
|
||||
|
||||
See the [LICENSE file](LICENSE.txt) for license rights and limitations.
|
||||
|
||||
## License of Mostlymatter’s logo
|
||||
## Get the latest news
|
||||
|
||||
[CC-By-NC v4.0](https://creativecommons.org/licenses/by-nc/4.0/deed.en) [Geoffrey Dorne](https://geoffreydorne.com)
|
||||
- **X** - Follow [Mattermost on X, formerly Twitter](https://twitter.com/mattermost).
|
||||
- **Blog** - Get the latest updates from the [Mattermost blog](https://mattermost.com/blog/).
|
||||
- **Facebook** - Follow [Mattermost on Facebook](https://www.facebook.com/MattermostHQ).
|
||||
- **LinkedIn** - Follow [Mattermost on LinkedIn](https://www.linkedin.com/company/mattermost/).
|
||||
- **Email** - Subscribe to our [newsletter](https://mattermost.us11.list-manage.com/subscribe?u=6cdba22349ae374e188e7ab8e&id=2add1c8034) (1 or 2 per month).
|
||||
- **Mattermost** - Join the ~contributors channel on [the Mattermost Community Server](https://community.mattermost.com).
|
||||
- **IRC** - Join the #matterbridge channel on [Freenode](https://freenode.net/) (thanks to [matterircd](https://github.com/42wim/matterircd)).
|
||||
- **YouTube** - Subscribe to [Mattermost](https://www.youtube.com/@MattermostHQ).
|
||||
|
||||
## Contributing
|
||||
|
||||
[](https://gitpod.io/#https://github.com/mattermost/mattermost)
|
||||
|
||||
Please see [CONTRIBUTING.md](./CONTRIBUTING.md).
|
||||
[Join the Mattermost Contributors server](https://community.mattermost.com/signup_user_complete/?id=codoy5s743rq5mk18i7u5ksz7e) to join community discussions about contributions, development, and more.
|
||||
|
||||
@@ -263,18 +263,20 @@ $(if mme2e_is_token_in_list "webhook-interactions" "$ENABLED_DOCKER_SERVICES"; t
|
||||
# shellcheck disable=SC2016
|
||||
echo '
|
||||
webhook-interactions:
|
||||
image: mattermostdevelopment/mirrored-node:${NODE_VERSION_REQUIRED}
|
||||
command: sh -c "npm install --global --legacy-peer-deps && exec node webhook_serve.js"
|
||||
image: node:${NODE_VERSION_REQUIRED}
|
||||
command: sh -c "npm init -y > /dev/null && npm install express@5.1.0 axios@1.11.0 client-oauth2@github:larkox/js-client-oauth2#e24e2eb5dfcbbbb3a59d095e831dbe0012b0ac49 && exec node webhook_serve.js"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-s", "-o/dev/null", "127.0.0.1:3000"]
|
||||
interval: 10s
|
||||
timeout: 15s
|
||||
retries: 12
|
||||
working_dir: /cypress
|
||||
working_dir: /webhook
|
||||
network_mode: host
|
||||
restart: on-failure
|
||||
volumes:
|
||||
- "../../e2e-tests/cypress/:/cypress:ro"'
|
||||
- "../../e2e-tests/cypress/webhook_serve.js:/webhook/webhook_serve.js:ro"
|
||||
- "../../e2e-tests/cypress/utils/:/webhook/utils:ro"
|
||||
- "../../e2e-tests/cypress/tests/plugins/post_message_as.js:/webhook/tests/plugins/post_message_as.js:ro"'
|
||||
fi)
|
||||
|
||||
$(if mme2e_is_token_in_list "playwright" "$ENABLED_DOCKER_SERVICES"; then
|
||||
|
||||
@@ -10,7 +10,72 @@ mme2e_wait_image "$SERVER_IMAGE" 4 30
|
||||
# Launch mattermost-server, and wait for it to be healthy
|
||||
mme2e_log "Starting E2E containers"
|
||||
${MME2E_DC_SERVER} create
|
||||
${MME2E_DC_SERVER} up -d --remove-orphans
|
||||
|
||||
# `docker compose up -d` returns non-zero the moment any depended container
|
||||
# exits during startup, which masks openldap's own `restart: always` policy.
|
||||
# On a small fraction of ubuntu-24.04 runners the osixia/openldap:1.4.0 image
|
||||
# exits 1 on first boot (suspected init-script race under runner load). Retry
|
||||
# the `up` a bounded number of times, force-recreating openldap between tries
|
||||
# so its first-boot bootstrap re-runs cleanly, and dump rich diagnostics on
|
||||
# every failure so future CI failures contain the actual data we need to
|
||||
# permanently root-cause this. The diagnostics directory is also uploaded as
|
||||
# a workflow artifact (see e2e-tests-*-template.yml `ci/upload-docker-diagnostics`).
|
||||
DIAG_DIR="${PWD}/../docker-diagnostics"
|
||||
mkdir -p "$DIAG_DIR"
|
||||
|
||||
dump_openldap_diagnostics() {
|
||||
local label="$1"
|
||||
local out="$DIAG_DIR/${label}"
|
||||
mkdir -p "$out"
|
||||
mme2e_log "[diagnostics:${label}] capturing openldap state to $out"
|
||||
|
||||
# Container-level state (exit code, OOMKilled, error string, restart count)
|
||||
docker inspect mmserver-openldap-1 >"$out/openldap.inspect.json" 2>&1 || true
|
||||
${MME2E_DC_SERVER} ps -a >"$out/compose.ps.txt" 2>&1 || true
|
||||
${MME2E_DC_SERVER} logs --no-log-prefix -- openldap >"$out/openldap.log" 2>&1 || true
|
||||
|
||||
# Merged compose config — confirms which security_opt / cap_add / image is actually applied
|
||||
${MME2E_DC_SERVER} config >"$out/compose.config.yml" 2>&1 || true
|
||||
|
||||
# Host-level state useful for OOM / AppArmor diagnosis
|
||||
uname -a >"$out/host.uname.txt" 2>&1 || true
|
||||
free -m >"$out/host.free.txt" 2>&1 || true
|
||||
df -h >"$out/host.df.txt" 2>&1 || true
|
||||
docker version >"$out/docker.version.txt" 2>&1 || true
|
||||
docker info >"$out/docker.info.txt" 2>&1 || true
|
||||
docker compose version >"$out/compose.version.txt" 2>&1 || true
|
||||
cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns >"$out/host.apparmor_userns.txt" 2>&1 || true
|
||||
# AppArmor denials and OOM kills land in dmesg — grep them out (needs sudo on GH runners).
|
||||
sudo dmesg | tail -200 >"$out/host.dmesg.tail.txt" 2>&1 || true
|
||||
sudo dmesg | grep -iE 'apparmor|denied|oom|killed|openldap|slapd' >"$out/host.dmesg.relevant.txt" 2>&1 || true
|
||||
|
||||
# Echo the most useful slice straight to the workflow log so it shows up
|
||||
# in the GH Actions UI without needing to download the artifact.
|
||||
mme2e_log "----- openldap inspect (exit/oom/error) -----"
|
||||
docker inspect mmserver-openldap-1 \
|
||||
--format 'ExitCode={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}} Error={{.State.Error}} Restarts={{.RestartCount}} Status={{.State.Status}}' \
|
||||
2>&1 || true
|
||||
mme2e_log "----- openldap log (last 100) -----"
|
||||
${MME2E_DC_SERVER} logs --no-log-prefix --tail=100 -- openldap 2>&1 || true
|
||||
mme2e_log "----- relevant dmesg -----"
|
||||
sudo dmesg | grep -iE 'apparmor|denied|oom|killed|openldap|slapd' | tail -40 2>&1 || true
|
||||
mme2e_log "----- end diagnostics:${label} -----"
|
||||
}
|
||||
|
||||
UP_ATTEMPTS=3
|
||||
for attempt in $(seq 1 $UP_ATTEMPTS); do
|
||||
if ${MME2E_DC_SERVER} up -d --remove-orphans; then
|
||||
break
|
||||
fi
|
||||
dump_openldap_diagnostics "up-attempt-${attempt}"
|
||||
if [ "$attempt" -eq "$UP_ATTEMPTS" ]; then
|
||||
mme2e_log "compose up failed after ${UP_ATTEMPTS} attempts; aborting"
|
||||
exit 1
|
||||
fi
|
||||
# Force-recreate openldap so its first-boot init re-runs from a clean state
|
||||
${MME2E_DC_SERVER} rm -fsv openldap || true
|
||||
sleep 5
|
||||
done
|
||||
|
||||
# Postgres check
|
||||
if ! mme2e_wait_command_success "${MME2E_DC_SERVER} exec -T -- postgres pg_isready -h localhost" "Waiting for postgres to accept connections" "30" "5"; then
|
||||
@@ -45,3 +110,14 @@ for MIGRATION in migration_advanced_permissions_phase_2; do
|
||||
mme2e_log "${MIGRATION}: completed."
|
||||
done
|
||||
mme2e_log "Mattermost container is running and healthy"
|
||||
|
||||
# Wait for webhook-interactions container if running cypress tests
|
||||
if [ "$TEST" = "cypress" ]; then
|
||||
mme2e_log "Checking webhook-interactions container health"
|
||||
${MME2E_DC_SERVER} logs --no-log-prefix -- webhook-interactions 2>&1 | tail -5
|
||||
if ! mme2e_wait_service_healthy webhook-interactions 2 10; then
|
||||
mme2e_log "Webhook interactions container not healthy, retry attempts exhausted. Giving up." >&2
|
||||
exit 1
|
||||
fi
|
||||
mme2e_log "Webhook interactions container is running and healthy"
|
||||
fi
|
||||
|
||||
15
e2e-tests/cypress/reporter-config.json
Обычный файл
15
e2e-tests/cypress/reporter-config.json
Обычный файл
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"reporterEnabled": "mocha-junit-reporter, mochawesome",
|
||||
"mochaJunitReporterReporterOptions": {
|
||||
"mochaFile": "results/junit/test_results[hash].xml",
|
||||
"toConsole": false
|
||||
},
|
||||
"mochawesomeReporterOptions": {
|
||||
"reportDir": "results/mochawesome-report",
|
||||
"reportFilename": "json/tests/[name]",
|
||||
"quiet": true,
|
||||
"overwrite": false,
|
||||
"html": false,
|
||||
"json": true
|
||||
}
|
||||
}
|
||||
@@ -394,8 +394,10 @@ describe('group configuration', () => {
|
||||
// # Save settings
|
||||
savePage();
|
||||
|
||||
// * Check the groupteam via the API to ensure its role wasn't updated
|
||||
// * Check the groupteam via the API to ensure the team was
|
||||
// removed (delete_at != 0) and its role wasn't updated.
|
||||
cy.apiGetGroupTeam(groupID, testTeam.id).then(({body}) => {
|
||||
expect(body.delete_at).to.not.eq(0);
|
||||
expect(body.scheme_admin).to.eq(false);
|
||||
});
|
||||
});
|
||||
@@ -519,8 +521,10 @@ describe('group configuration', () => {
|
||||
// # Save settings
|
||||
savePage();
|
||||
|
||||
// * Check the groupteam via the API to ensure its role wasn't updated
|
||||
// * Check the groupteam via the API to ensure the channel was
|
||||
// removed (delete_at != 0) and its role wasn't updated.
|
||||
cy.apiGetGroupChannel(groupID, testChannel.id).then(({body}) => {
|
||||
expect(body.delete_at).to.not.eq(0);
|
||||
expect(body.scheme_admin).to.eq(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// ***************************************************************
|
||||
// - [#] indicates a test step (e.g. # Go to a page)
|
||||
// - [*] indicates an assertion (e.g. * Check the title)
|
||||
// - Use element ID when selecting an element. Create one if none.
|
||||
// ***************************************************************
|
||||
|
||||
// Stage: @prod
|
||||
// Group: @channels @enterprise @system_console
|
||||
|
||||
describe('System Console', () => {
|
||||
before(() => {
|
||||
// * Check if server has license for ID Loaded Push Notifications
|
||||
cy.apiRequireLicenseForFeature('IDLoadedPushNotifications');
|
||||
|
||||
// # Update to default config
|
||||
cy.apiUpdateConfig({
|
||||
EmailSettings: {
|
||||
PushNotificationContents: 'full',
|
||||
FeedbackName: 'Mattermost Test Team',
|
||||
FeedbackEmail: 'feedback@mattertest.com',
|
||||
},
|
||||
SupportSettings: {
|
||||
SupportEmail: 'support@mattertest.com',
|
||||
},
|
||||
});
|
||||
|
||||
// # Visit Notifications admin console page
|
||||
cy.visit('/admin_console/environment/notifications');
|
||||
cy.get('.admin-console__header').should('be.visible').and('have.text', 'Notifications');
|
||||
});
|
||||
|
||||
it('Push Notification Contents', () => {
|
||||
// * Verify that setting is visible and matches text content
|
||||
cy.findByTestId('EmailSettings.PushNotificationContents').
|
||||
scrollIntoView().should('be.visible').
|
||||
find('label').should('be.visible').and('have.text', 'Push Notification Contents:');
|
||||
|
||||
// * Verify that the help text is visible and matches text content
|
||||
cy.findByTestId('EmailSettings.PushNotificationContentshelp-text').should('be.visible').within((el) => {
|
||||
const contents = [
|
||||
'Generic description with only sender name',
|
||||
' - Includes only the name of the person who sent the message in push notifications, with no information about channel name or message contents. ',
|
||||
'Generic description with sender and channel names',
|
||||
' - Includes the name of the person who sent the message and the channel it was sent in, but not the message contents. ',
|
||||
'Full message content sent in the notification payload',
|
||||
' - Includes the message contents in the push notification payload that is relayed through Apple\'s Push Notification Service (APNS) or Google\'s Firebase Cloud Messaging (FCM). It is ',
|
||||
'highly recommended',
|
||||
' this option only be used with an "https" protocol to encrypt the connection and protect confidential information sent in messages.',
|
||||
'Full message content fetched from the server on receipt',
|
||||
' - The notification payload relayed through APNS or FCM contains no message content, instead it contains a unique message ID used to fetch message content from the server when a push notification is received by a device. If the server cannot be reached, a generic notification will be displayed.',
|
||||
];
|
||||
cy.wrap(el).should('have.text', contents.join(''));
|
||||
|
||||
cy.get('strong').eq(0).should('have.text', contents[0]);
|
||||
cy.get('strong').eq(1).should('have.text', contents[2]);
|
||||
cy.get('strong').eq(2).should('have.text', contents[4]);
|
||||
cy.get('strong').eq(3).should('have.text', contents[6]);
|
||||
cy.get('strong').eq(4).should('have.text', contents[8]);
|
||||
});
|
||||
|
||||
// * Verify that the option/dropdown is visible and has default value
|
||||
cy.findByTestId('EmailSettings.PushNotificationContentsdropdown').
|
||||
should('be.visible').
|
||||
and('have.value', 'full');
|
||||
|
||||
const options = [
|
||||
{label: 'Generic description with only sender name', value: 'generic_no_channel'},
|
||||
{label: 'Generic description with sender and channel names', value: 'generic'},
|
||||
{label: 'Full message content sent in the notification payload', value: 'full'},
|
||||
{label: 'Full message content fetched from the server on receipt', value: 'id_loaded'},
|
||||
];
|
||||
|
||||
// # Select each value and save
|
||||
// * Verify that the config is correctly saved in the server
|
||||
options.forEach((option) => {
|
||||
cy.findByTestId('EmailSettings.PushNotificationContentsdropdown').
|
||||
select(option.label).
|
||||
and('have.value', option.value);
|
||||
cy.get('#saveSetting').click();
|
||||
|
||||
cy.apiGetConfig().then(({config}) => {
|
||||
expect(config.EmailSettings.PushNotificationContents).to.equal(option.value);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('MM-T1210+MM-41671 Can change Support Email setting', () => {
|
||||
// # Scroll Support Email section into view and verify that it's visible
|
||||
cy.findByTestId('SupportSettings.SupportEmail').scrollIntoView().should('be.visible');
|
||||
|
||||
// * Verify that setting label is visible and matches text content
|
||||
cy.findByTestId('SupportSettings.SupportEmaillabel').should('be.visible').and('have.text', 'Support Email Address:');
|
||||
|
||||
// * Verify that the help text is visible and matches text content
|
||||
cy.findByTestId('SupportSettings.SupportEmailhelp-text').find('span').should('be.visible').and('have.text', 'Email address displayed on support emails.');
|
||||
|
||||
const newEmail = 'changed_for_test_support@example.com';
|
||||
|
||||
// * Verify that set value is visible and matches text
|
||||
cy.findByTestId('SupportSettings.SupportEmail').find('input').clear().type(newEmail).should('have.value', newEmail);
|
||||
|
||||
// # Save setting
|
||||
cy.get('#saveSetting').click();
|
||||
|
||||
// * Verify that the config is correctly saved in the server
|
||||
cy.apiGetConfig().then(({config}) => {
|
||||
expect(config.SupportSettings.SupportEmail).to.equal(newEmail);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MM-41671 cannot save the notifications page if mandatory fields are missing', () => {
|
||||
const tests = [
|
||||
{name: 'Support Email cannot be empty', field: 'SupportSettings.SupportEmail'},
|
||||
{name: 'Notification Display Name cannot be empty', field: 'EmailSettings.FeedbackName'},
|
||||
{name: 'Notification Email Address cannot be empty', field: 'SupportSettings.SupportEmail'},
|
||||
];
|
||||
|
||||
tests.forEach((test) => {
|
||||
it(test.name, () => {
|
||||
// # Clear the field
|
||||
cy.findByTestId(test.field).find('input').clear();
|
||||
|
||||
// * Ensures the save button is disabled
|
||||
cy.get('#saveSetting').should('be.disabled');
|
||||
|
||||
// # Insert something in the field
|
||||
cy.findByTestId(test.field).find('input').type(test.field);
|
||||
|
||||
// * Ensures the save button is disabled
|
||||
cy.get('#saveSetting').should('be.not.disabled');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,8 +10,8 @@ import xor from 'lodash.xor';
|
||||
|
||||
export const defaultRolesPermissions = {
|
||||
channel_admin: 'use_channel_mentions remove_reaction manage_public_channel_members use_group_mentions manage_channel_roles manage_private_channel_members add_reaction read_public_channel_groups create_post read_private_channel_groups add_bookmark_public_channel edit_bookmark_public_channel delete_bookmark_public_channel order_bookmark_public_channel add_bookmark_private_channel edit_bookmark_private_channel delete_bookmark_private_channel order_bookmark_private_channel',
|
||||
channel_guest: 'upload_file edit_post create_post use_channel_mentions read_channel read_channel_content add_reaction remove_reaction',
|
||||
channel_user: 'manage_private_channel_members read_public_channel_groups delete_post read_private_channel_groups use_group_mentions manage_private_channel_properties delete_public_channel add_reaction manage_public_channel_properties edit_post upload_file use_channel_mentions get_public_link read_channel read_channel_content delete_private_channel manage_public_channel_members create_post remove_reaction add_bookmark_public_channel edit_bookmark_public_channel delete_bookmark_public_channel order_bookmark_public_channel add_bookmark_private_channel edit_bookmark_private_channel delete_bookmark_private_channel order_bookmark_private_channel',
|
||||
channel_guest: 'upload_file edit_post create_post use_channel_mentions read_channel read_channel_content add_reaction remove_reaction edit_file_attachment',
|
||||
channel_user: 'manage_private_channel_members read_public_channel_groups delete_post read_private_channel_groups use_group_mentions manage_private_channel_properties delete_public_channel add_reaction manage_public_channel_properties edit_post upload_file use_channel_mentions get_public_link read_channel read_channel_content delete_private_channel manage_public_channel_members create_post remove_reaction add_bookmark_public_channel edit_bookmark_public_channel delete_bookmark_public_channel order_bookmark_public_channel add_bookmark_private_channel edit_bookmark_private_channel delete_bookmark_private_channel order_bookmark_private_channel edit_file_attachment',
|
||||
custom_group_user: '',
|
||||
playbook_admin: 'playbook_private_manage_properties playbook_public_make_private playbook_public_manage_members playbook_public_manage_roles playbook_public_manage_properties playbook_private_manage_members playbook_private_manage_roles',
|
||||
playbook_member: 'playbook_public_view playbook_public_manage_members playbook_public_manage_properties playbook_private_view playbook_private_manage_members playbook_private_manage_properties run_create',
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Client4} from '@mattermost/client';
|
||||
import {UserProfile} from '@mattermost/types/users';
|
||||
import {PluginManifest} from '@mattermost/types/plugins';
|
||||
import {PreferenceType} from '@mattermost/types/preferences';
|
||||
import {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
import {defaultTeam} from './util';
|
||||
import {createRandomTeam, getAdminClient, getDefaultAdminUser, makeClient} from './server';
|
||||
import {testConfig} from './test_config';
|
||||
import {defaultTeam} from './util';
|
||||
|
||||
export async function baseGlobalSetup() {
|
||||
let adminClient: Client4;
|
||||
|
||||
@@ -2,10 +2,25 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Client4} from '@mattermost/client';
|
||||
import {AdminConfig} from '@mattermost/types/config';
|
||||
import {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
import {testConfig} from '@/test_config';
|
||||
|
||||
// Extend Client4 with methods used for testing
|
||||
declare module '@mattermost/client' {
|
||||
interface Client4 {
|
||||
updateConfigX: (config: Partial<AdminConfig>) => Promise<AdminConfig>;
|
||||
}
|
||||
}
|
||||
|
||||
// updateConfigX merges the given config with the current config and updates it to the server
|
||||
Client4.prototype.updateConfigX = async function (this: Client4, config: Partial<AdminConfig>) {
|
||||
const currentConfig = await this.getConfig();
|
||||
const newConfig = {...currentConfig, ...config};
|
||||
return await this.updateConfig(newConfig);
|
||||
};
|
||||
|
||||
// Variable to hold cache
|
||||
const clients: Record<string, ClientCache> = {};
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import DeletePostConfirmationDialog from './channels/delete_post_confirmation_di
|
||||
import RestorePostConfirmationDialog from './channels/restore_post_confirmation_dialog';
|
||||
import SystemConsoleFeatureDiscovery from './system_console/sections/system_users/feature_discovery';
|
||||
import SystemConsoleMobileSecurity from './system_console/sections/system_users/mobile_security';
|
||||
import SystemConsoleNotifications from './system_console/sections/site_configuration/notifications';
|
||||
import ScheduledPost from './channels/scheduled_post';
|
||||
import SendMessageNowModal from './channels/send_message_now_modal';
|
||||
import DeleteScheduledPostModal from './channels/delete_scheduled_post_modal';
|
||||
@@ -83,6 +84,7 @@ const components = {
|
||||
SystemUsersColumnToggleMenu,
|
||||
SystemConsoleFeatureDiscovery,
|
||||
SystemConsoleMobileSecurity,
|
||||
SystemConsoleNotifications,
|
||||
MessagePriority,
|
||||
UserProfilePopover,
|
||||
UserAccountMenu,
|
||||
@@ -130,6 +132,7 @@ export {
|
||||
SystemUsersColumnToggleMenu,
|
||||
SystemConsoleFeatureDiscovery,
|
||||
SystemConsoleMobileSecurity,
|
||||
SystemConsoleNotifications,
|
||||
MessagePriority,
|
||||
UserProfilePopover,
|
||||
UserAccountMenu,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Locator, expect} from '@playwright/test';
|
||||
|
||||
/**
|
||||
* System Console -> Site Configuration -> Notifications
|
||||
*/
|
||||
export default class SystemConsoleNotifications {
|
||||
readonly container: Locator;
|
||||
|
||||
// header
|
||||
readonly header: Locator;
|
||||
|
||||
// Notification Display Name
|
||||
readonly notificationDisplayName: Locator;
|
||||
readonly notificationDisplayNameInput: Locator;
|
||||
readonly notificationDisplayNameHelpText: Locator;
|
||||
|
||||
// Notification From Address
|
||||
readonly notificationFromAddress: Locator;
|
||||
readonly notificationFromAddressInput: Locator;
|
||||
readonly notificationFromAddressHelpText: Locator;
|
||||
|
||||
// Support Email Address
|
||||
readonly supportEmailAddress: Locator;
|
||||
readonly supportEmailAddressInput: Locator;
|
||||
readonly supportEmailHelpText: Locator;
|
||||
|
||||
// Push Notification Contents
|
||||
readonly pushNotificationContents: Locator;
|
||||
readonly pushNotificationContentsDropdown: Locator;
|
||||
readonly pushNotificationContentsHelpText: Locator;
|
||||
|
||||
// Save button
|
||||
readonly saveButton: Locator;
|
||||
readonly errorMessage: Locator;
|
||||
|
||||
constructor(container: Locator) {
|
||||
this.container = container;
|
||||
|
||||
// header
|
||||
this.header = this.container.locator('.admin-console__header').getByText('Notifications');
|
||||
|
||||
// Notification Display Name
|
||||
this.notificationDisplayName = this.container.getByTestId('EmailSettings.FeedbackNameinput');
|
||||
this.notificationDisplayNameInput = this.container.getByTestId('EmailSettings.FeedbackNameinput');
|
||||
this.notificationDisplayNameHelpText = this.container.getByTestId('EmailSettings.FeedbackNamehelp-text');
|
||||
|
||||
// Notification From Address
|
||||
this.notificationFromAddress = this.container.getByLabel('Notification From Address:');
|
||||
this.notificationFromAddressInput = this.container.getByTestId('EmailSettings.FeedbackEmailinput');
|
||||
this.notificationFromAddressHelpText = this.container.getByTestId('EmailSettings.FeedbackEmailhelp-text');
|
||||
|
||||
// Support Email Address
|
||||
this.supportEmailAddress = this.container.getByLabel('Support Email Address:');
|
||||
this.supportEmailAddressInput = this.container.getByTestId('SupportSettings.SupportEmailinput');
|
||||
this.supportEmailHelpText = this.container.getByTestId('SupportSettings.SupportEmailhelp-text');
|
||||
|
||||
// Push Notification Contents
|
||||
this.pushNotificationContents = this.container.getByTestId('EmailSettings.PushNotificationContents');
|
||||
this.pushNotificationContentsDropdown = this.container.getByTestId(
|
||||
'EmailSettings.PushNotificationContentsdropdown',
|
||||
);
|
||||
this.pushNotificationContentsHelpText = this.container.getByTestId(
|
||||
'EmailSettings.PushNotificationContentshelp-text',
|
||||
);
|
||||
|
||||
// Save button and error message
|
||||
this.saveButton = this.container.getByTestId('saveSetting');
|
||||
this.errorMessage = this.container.locator('.has-error');
|
||||
}
|
||||
|
||||
async toBeVisible() {
|
||||
await expect(this.container).toBeVisible();
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,11 @@ export default class SystemConsolePage {
|
||||
readonly sidebar;
|
||||
readonly navbar;
|
||||
|
||||
// Site Configuration
|
||||
|
||||
// System Console > Notifications
|
||||
readonly notifications;
|
||||
|
||||
/**
|
||||
* System Console -> User Management -> Users
|
||||
*/
|
||||
@@ -34,6 +39,12 @@ export default class SystemConsolePage {
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
|
||||
// Site Configuration
|
||||
// System Console > Notifications
|
||||
this.notifications = new components.SystemConsoleNotifications(
|
||||
page.getByTestId('sysconsole_section_notifications'),
|
||||
);
|
||||
|
||||
// Areas of the page
|
||||
this.navbar = new components.SystemConsoleNavbar(page.locator('.backstage-navbar'));
|
||||
this.sidebar = new components.SystemConsoleSidebar(page.locator('.admin-sidebar'));
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {AdminConfig} from '@mattermost/types/config';
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
/**
|
||||
* @objective Verify that the Push Notification Contents setting is properly displayed and can be changed to all available options
|
||||
*/
|
||||
test('Push Notification Contents setting displays correctly and saves all options', async ({pw}) => {
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
|
||||
// # Update to default config
|
||||
await adminClient.updateConfigX({
|
||||
EmailSettings: {
|
||||
PushNotificationContents: 'full',
|
||||
FeedbackName: 'Mattermost Test Team',
|
||||
FeedbackEmail: 'feedback@mattertest.com',
|
||||
},
|
||||
SupportSettings: {
|
||||
SupportEmail: 'support@mattertest.com',
|
||||
},
|
||||
} as Partial<AdminConfig>);
|
||||
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to get admin user');
|
||||
}
|
||||
|
||||
// # Log in as admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
// # Visit Notifications admin console page
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
await systemConsolePage.sidebar.goToItem('Notifications');
|
||||
|
||||
// # Wait for Notifications section to load
|
||||
const notifications = systemConsolePage.notifications;
|
||||
await notifications.toBeVisible();
|
||||
|
||||
// * Verify that setting is visible and matches text content
|
||||
await notifications.pushNotificationContents.scrollIntoViewIfNeeded();
|
||||
await expect(notifications.pushNotificationContents).toBeVisible();
|
||||
|
||||
// * Verify that the help text is visible and matches text content
|
||||
const helpText = notifications.pushNotificationContentsHelpText;
|
||||
await expect(helpText).toBeVisible();
|
||||
|
||||
const contents = [
|
||||
'Generic description with only sender name',
|
||||
' - Includes only the name of the person who sent the message in push notifications, with no information about channel name or message contents. ',
|
||||
'Generic description with sender and channel names',
|
||||
' - Includes the name of the person who sent the message and the channel it was sent in, but not the message contents. ',
|
||||
'Full message content sent in the notification payload',
|
||||
" - Includes the message contents in the push notification payload that is relayed through Apple's Push Notification Service (APNS) or Google's Firebase Cloud Messaging (FCM). It is ",
|
||||
'highly recommended',
|
||||
' this option only be used with an "https" protocol to encrypt the connection and protect confidential information sent in messages.',
|
||||
'Full message content fetched from the server on receipt',
|
||||
' - The notification payload relayed through APNS or FCM contains no message content, instead it contains a unique message ID used to fetch message content from the server when a push notification is received by a device. If the server cannot be reached, a generic notification will be displayed.',
|
||||
];
|
||||
await expect(helpText).toHaveText(contents.join(''));
|
||||
|
||||
const strongElements = helpText.locator('strong');
|
||||
await expect(strongElements.nth(0)).toHaveText(contents[0]);
|
||||
await expect(strongElements.nth(1)).toHaveText(contents[2]);
|
||||
await expect(strongElements.nth(2)).toHaveText(contents[4]);
|
||||
await expect(strongElements.nth(3)).toHaveText(contents[6]);
|
||||
await expect(strongElements.nth(4)).toHaveText(contents[8]);
|
||||
|
||||
// * Verify that the option/dropdown is visible and has default value
|
||||
const dropdown = notifications.pushNotificationContentsDropdown;
|
||||
await expect(dropdown).toBeVisible();
|
||||
await expect(dropdown).toHaveValue('full');
|
||||
|
||||
const options = [
|
||||
{label: 'Generic description with only sender name', value: 'generic_no_channel'},
|
||||
{label: 'Generic description with sender and channel names', value: 'generic'},
|
||||
{label: 'Full message content sent in the notification payload', value: 'full'},
|
||||
{label: 'Full message content fetched from the server on receipt', value: 'id_loaded'},
|
||||
];
|
||||
|
||||
// # Select each value and save
|
||||
// * Verify that the config is correctly saved in the server
|
||||
for (const option of options) {
|
||||
await dropdown.selectOption({label: option.label});
|
||||
await expect(dropdown).toHaveValue(option.value);
|
||||
|
||||
await notifications.saveButton.click();
|
||||
|
||||
// * Verify config is saved
|
||||
const {adminClient} = await pw.getAdminClient();
|
||||
const config = await adminClient.getConfig();
|
||||
expect(config.EmailSettings?.PushNotificationContents).toBe(option.value);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify that the Support Email setting can be changed and saved
|
||||
*/
|
||||
test('MM-T1210 Can change Support Email setting', async ({pw}) => {
|
||||
const {adminUser} = await pw.getAdminClient();
|
||||
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to get admin user');
|
||||
}
|
||||
|
||||
// # Log in as admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
// # Visit Notifications admin console page
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
await systemConsolePage.sidebar.goToItem('Notifications');
|
||||
|
||||
// # Wait for Notifications section to load
|
||||
const notifications = systemConsolePage.notifications;
|
||||
await notifications.toBeVisible();
|
||||
|
||||
// # Scroll Support Email section into view and verify that it's visible
|
||||
const supportEmailSetting = notifications.supportEmailAddress;
|
||||
await supportEmailSetting.scrollIntoViewIfNeeded();
|
||||
await expect(supportEmailSetting).toBeVisible();
|
||||
|
||||
// * Verify that the help text is visible and matches text content
|
||||
await expect(notifications.supportEmailHelpText).toBeVisible();
|
||||
await expect(notifications.supportEmailHelpText).toHaveText('Email address displayed on support emails.');
|
||||
|
||||
// # Clear and type new email
|
||||
const newEmail = 'changed_for_test_support@example.com';
|
||||
await notifications.supportEmailAddressInput.clear();
|
||||
await notifications.supportEmailAddressInput.fill(newEmail);
|
||||
|
||||
// * Verify that set value is visible and matches text
|
||||
await expect(notifications.supportEmailAddressInput).toHaveValue(newEmail);
|
||||
|
||||
// # Save setting
|
||||
await notifications.saveButton.click();
|
||||
|
||||
// * Verify that the config is correctly saved in the server
|
||||
const {adminClient} = await pw.getAdminClient();
|
||||
const config = await adminClient.getConfig();
|
||||
expect(config.SupportSettings?.SupportEmail).toBe(newEmail);
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify that the save button is disabled when mandatory fields are empty
|
||||
*/
|
||||
test('MM-41671 cannot save the notifications page if mandatory fields are missing', async ({pw}) => {
|
||||
const {adminUser} = await pw.getAdminClient();
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to get admin user');
|
||||
}
|
||||
|
||||
// # Log in as admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
// # Visit Notifications admin console page
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
await systemConsolePage.sidebar.goToItem('Notifications');
|
||||
|
||||
// # Wait for Notifications section to load
|
||||
const notifications = systemConsolePage.notifications;
|
||||
await notifications.toBeVisible();
|
||||
|
||||
const tests = [
|
||||
{name: 'Support Email Address', fieldInput: notifications.supportEmailAddressInput},
|
||||
{name: 'Notification Display Name', fieldInput: notifications.notificationDisplayNameInput},
|
||||
{name: 'Notification From Address', fieldInput: notifications.notificationFromAddressInput},
|
||||
];
|
||||
|
||||
for (const testCase of tests) {
|
||||
// # Clear the field
|
||||
await expect(testCase.fieldInput).toBeVisible();
|
||||
await testCase.fieldInput.clear();
|
||||
|
||||
// * Error message is shown and save button is disabled
|
||||
await expect(notifications.errorMessage).toHaveText(`"${testCase.name}" is required`);
|
||||
await expect(notifications.saveButton).toBeDisabled();
|
||||
|
||||
// # Insert something in the field
|
||||
await testCase.fieldInput.fill('anything');
|
||||
|
||||
// * Ensure no error message is shown and the save button is not disabled
|
||||
await expect(notifications.errorMessage).toHaveCount(0);
|
||||
await expect(notifications.saveButton).not.toBeDisabled();
|
||||
}
|
||||
});
|
||||
418
limitless.patch
418
limitless.patch
@@ -1,418 +0,0 @@
|
||||
diff --git a/server/.golangci.yml b/server/.golangci.yml
|
||||
index e7b25ff604..7eb81a08a2 100644
|
||||
--- a/server/.golangci.yml
|
||||
+++ b/server/.golangci.yml
|
||||
@@ -133,19 +133,19 @@ linters:
|
||||
channels/store/storetest/scheme_store.go|\
|
||||
channels/store/storetest/shared_channel_store.go|\
|
||||
channels/store/storetest/team_store.go|\
|
||||
channels/store/storetest/thread_store.go|\
|
||||
channels/store/storetest/user_store.go|\
|
||||
- cmd/mattermost/commands/cmdtestlib.go|\
|
||||
- cmd/mattermost/commands/db.go|\
|
||||
- cmd/mattermost/commands/export.go|\
|
||||
- cmd/mattermost/commands/import.go|\
|
||||
- cmd/mattermost/commands/jobserver.go|\
|
||||
- cmd/mattermost/commands/server.go|\
|
||||
- cmd/mattermost/commands/server_test.go|\
|
||||
- cmd/mattermost/commands/test.go|\
|
||||
- cmd/mattermost/commands/version.go|\
|
||||
+ cmd/mostlymatter/commands/cmdtestlib.go|\
|
||||
+ cmd/mostlymatter/commands/db.go|\
|
||||
+ cmd/mostlymatter/commands/export.go|\
|
||||
+ cmd/mostlymatter/commands/import.go|\
|
||||
+ cmd/mostlymatter/commands/jobserver.go|\
|
||||
+ cmd/mostlymatter/commands/server.go|\
|
||||
+ cmd/mostlymatter/commands/server_test.go|\
|
||||
+ cmd/mostlymatter/commands/test.go|\
|
||||
+ cmd/mostlymatter/commands/version.go|\
|
||||
platform/services/cache/lru_striped.go|\
|
||||
platform/services/cache/lru_striped_bench_test.go|\
|
||||
platform/services/cache/lru_striped_test.go|\
|
||||
platform/services/cache/lru_test.go|\
|
||||
platform/services/docextractor/combine.go|\
|
||||
diff --git a/server/Makefile b/server/Makefile
|
||||
index f44e976a09..aa2f7daee5 100644
|
||||
--- a/server/Makefile
|
||||
+++ b/server/Makefile
|
||||
@@ -54,11 +54,11 @@ MMCTL_PKG = github.com/mattermost/mattermost/server/v8/cmd/mmctl/commands
|
||||
MMCTL_BUILD_DATE = $(shell date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||
MMCTL_LDFLAGS += -X "$(MMCTL_PKG).buildDate=$(MMCTL_BUILD_DATE)"
|
||||
|
||||
# Enterprise
|
||||
BUILD_ENTERPRISE_DIR ?= ../../enterprise
|
||||
-BUILD_ENTERPRISE ?= true
|
||||
+BUILD_ENTERPRISE ?= false
|
||||
BUILD_ENTERPRISE_READY = false
|
||||
BUILD_TYPE_NAME = team
|
||||
BUILD_HASH_ENTERPRISE = none
|
||||
ifneq ($(wildcard $(BUILD_ENTERPRISE_DIR)/.),)
|
||||
MMCTL_TESTFLAGS += -ldflags '-X "$(MMCTL_PKG).EnableEnterpriseTests=true" -X "github.com/mattermost/mattermost/server/public/model.BuildEnterpriseReady=true"'
|
||||
@@ -121,20 +121,20 @@ GO_VERSION_VALIDATION_ERR_MSG = Golang version is not supported, please update t
|
||||
GO_COMPATIBILITY_TEST_VERSIONS := 1.22.7 1.23.6
|
||||
|
||||
# GOOS/GOARCH of the build host, used to determine whether we're cross-compiling or not
|
||||
BUILDER_GOOS_GOARCH="$(shell $(GO) env GOOS)_$(shell $(GO) env GOARCH)"
|
||||
|
||||
-PLATFORM_FILES="./cmd/mattermost"
|
||||
+PLATFORM_FILES="./cmd/mostlymatter"
|
||||
|
||||
# Output paths
|
||||
DIST_ROOT=dist
|
||||
-DIST_PATH=$(DIST_ROOT)/mattermost
|
||||
-DIST_PATH_LIN_AMD64=$(DIST_ROOT)/linux_amd64/mattermost
|
||||
-DIST_PATH_LIN_ARM64=$(DIST_ROOT)/linux_arm64/mattermost
|
||||
-DIST_PATH_OSX_AMD64=$(DIST_ROOT)/darwin_amd64/mattermost
|
||||
-DIST_PATH_OSX_ARM64=$(DIST_ROOT)/darwin_arm64/mattermost
|
||||
-DIST_PATH_WIN=$(DIST_ROOT)/windows/mattermost
|
||||
+DIST_PATH=$(DIST_ROOT)/mostlymatter
|
||||
+DIST_PATH_LIN_AMD64=$(DIST_ROOT)/linux_amd64/mostlymatter
|
||||
+DIST_PATH_LIN_ARM64=$(DIST_ROOT)/linux_arm64/mostlymatter
|
||||
+DIST_PATH_OSX_AMD64=$(DIST_ROOT)/darwin_amd64/mostlymatter
|
||||
+DIST_PATH_OSX_ARM64=$(DIST_ROOT)/darwin_arm64/mostlymatter
|
||||
+DIST_PATH_WIN=$(DIST_ROOT)/windows/mostlymatter
|
||||
|
||||
# Packages lists
|
||||
TE_PACKAGES=$(shell $(GO) list ./public/...) $(shell $(GO) list ./... | grep -vE 'server/v8/cmd/mmctl')
|
||||
MMCTL_PACKAGES=$(shell $(GO) list ./... | grep -E 'server/v8/cmd/mmctl')
|
||||
|
||||
@@ -728,11 +728,11 @@ clean: stop-docker ## Clean up everything except persistent server data.
|
||||
rm -f cover.out
|
||||
rm -f ecover.out
|
||||
rm -f *.out
|
||||
rm -f *.test
|
||||
rm -f channels/imports/imports.go
|
||||
- rm -f cmd/mattermost/cprofile*.out
|
||||
+ rm -f cmd/mostlymatter/cprofile*.out
|
||||
|
||||
nuke: clean clean-docker ## Clean plus removes persistent server data.
|
||||
@echo BOOM
|
||||
|
||||
rm -rf data
|
||||
diff --git a/server/channels/app/limits.go b/server/channels/app/limits.go
|
||||
index 7eccd87f3b..13b9c3ac3f 100644
|
||||
--- a/server/channels/app/limits.go
|
||||
+++ b/server/channels/app/limits.go
|
||||
@@ -8,12 +8,12 @@ import (
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
)
|
||||
|
||||
const (
|
||||
- maxUsersLimit = 2_500
|
||||
- maxUsersHardLimit = 5_000
|
||||
+ maxUsersLimit = 5_000_000
|
||||
+ maxUsersHardLimit = 10_000_000
|
||||
)
|
||||
|
||||
func (a *App) GetServerLimits() (*model.ServerLimits, *model.AppError) {
|
||||
limits := &model.ServerLimits{}
|
||||
license := a.License()
|
||||
diff --git a/server/cmd/mattermost/commands/cmdtestlib.go b/server/cmd/mostlymatter/commands/cmdtestlib.go
|
||||
similarity index 100%
|
||||
rename from server/cmd/mattermost/commands/cmdtestlib.go
|
||||
rename to server/cmd/mostlymatter/commands/cmdtestlib.go
|
||||
diff --git a/server/cmd/mattermost/commands/db.go b/server/cmd/mostlymatter/commands/db.go
|
||||
similarity index 97%
|
||||
rename from server/cmd/mattermost/commands/db.go
|
||||
rename to server/cmd/mostlymatter/commands/db.go
|
||||
index 17c96c0c45..4a5c210479 100644
|
||||
--- a/server/cmd/mattermost/commands/db.go
|
||||
+++ b/server/cmd/mostlymatter/commands/db.go
|
||||
@@ -32,25 +32,25 @@ var InitDbCmd = &cobra.Command{
|
||||
Short: "Initialize the database",
|
||||
Long: `Initialize the database for a given DSN, executing the migrations and loading the custom defaults if any.
|
||||
|
||||
This command should be run using a database configuration DSN.`,
|
||||
Example: ` # you can use the config flag to pass the DSN
|
||||
- $ mattermost db init --config postgres://localhost/mattermost
|
||||
+ $ mostlymatter db init --config postgres://localhost/mattermost
|
||||
|
||||
# or you can use the MM_CONFIG environment variable
|
||||
- $ MM_CONFIG=postgres://localhost/mattermost mattermost db init
|
||||
+ $ MM_CONFIG=postgres://localhost/mattermost mostlymatter db init
|
||||
|
||||
# and you can set a custom defaults file to be loaded into the database
|
||||
- $ MM_CUSTOM_DEFAULTS_PATH=custom.json MM_CONFIG=postgres://localhost/mattermost mattermost db init`,
|
||||
+ $ MM_CUSTOM_DEFAULTS_PATH=custom.json MM_CONFIG=postgres://localhost/mattermost mostlymatter db init`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: initDbCmdF,
|
||||
}
|
||||
|
||||
var ResetCmd = &cobra.Command{
|
||||
Use: "reset",
|
||||
Short: "Reset the database to initial state",
|
||||
- Long: "Completely erases the database causing the loss of all data. This will reset Mattermost to its initial state.",
|
||||
+ Long: "Completely erases the database causing the loss of all data. This will reset Mostlymatter to its initial state.",
|
||||
RunE: resetCmdF,
|
||||
}
|
||||
|
||||
var MigrateCmd = &cobra.Command{
|
||||
Use: "migrate",
|
||||
diff --git a/server/cmd/mattermost/commands/exec_command_test.go b/server/cmd/mostlymatter/commands/exec_command_test.go
|
||||
similarity index 100%
|
||||
rename from server/cmd/mattermost/commands/exec_command_test.go
|
||||
rename to server/cmd/mostlymatter/commands/exec_command_test.go
|
||||
diff --git a/server/cmd/mattermost/commands/export.go b/server/cmd/mostlymatter/commands/export.go
|
||||
similarity index 92%
|
||||
rename from server/cmd/mattermost/commands/export.go
|
||||
rename to server/cmd/mostlymatter/commands/export.go
|
||||
index ece6eebacf..bc9d31fe46 100644
|
||||
--- a/server/cmd/mattermost/commands/export.go
|
||||
+++ b/server/cmd/mostlymatter/commands/export.go
|
||||
@@ -18,26 +18,26 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var ExportCmd = &cobra.Command{
|
||||
Use: "export",
|
||||
- Short: "Export data from Mattermost",
|
||||
- Long: "Export data from Mattermost in a format suitable for import into a third-party application or another Mattermost instance",
|
||||
+ Short: "Export data from Mostlymatter",
|
||||
+ Long: "Export data from Mostlymatter in a format suitable for import into a third-party application or another Mostlymatter instance",
|
||||
}
|
||||
|
||||
var ScheduleExportCmd = &cobra.Command{
|
||||
Use: "schedule",
|
||||
- Short: "Schedule an export data job in Mattermost",
|
||||
- Long: "Schedule an export data job in Mattermost (this will run asynchronously via a background worker)",
|
||||
+ Short: "Schedule an export data job in Mostlymatter",
|
||||
+ Long: "Schedule an export data job in Mostlymatter (this will run asynchronously via a background worker)",
|
||||
Example: "export schedule --format=actiance --exportFrom=12345 --timeoutSeconds=12345",
|
||||
RunE: scheduleExportCmdF,
|
||||
}
|
||||
|
||||
var BulkExportCmd = &cobra.Command{
|
||||
Use: "bulk [file]",
|
||||
Short: "Export bulk data.",
|
||||
- Long: "Export data to a file compatible with the Mattermost Bulk Import format.",
|
||||
+ Long: "Export data to a file compatible with the Mostlymatter Bulk Import format.",
|
||||
Example: "export bulk bulk_data.json",
|
||||
RunE: bulkExportCmdF,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
|
||||
diff --git a/server/cmd/mattermost/commands/import.go b/server/cmd/mostlymatter/commands/import.go
|
||||
similarity index 98%
|
||||
rename from server/cmd/mattermost/commands/import.go
|
||||
rename to server/cmd/mostlymatter/commands/import.go
|
||||
index 434403d830..d662c8af65 100644
|
||||
--- a/server/cmd/mattermost/commands/import.go
|
||||
+++ b/server/cmd/mostlymatter/commands/import.go
|
||||
@@ -30,11 +30,11 @@ var SlackImportCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
var BulkImportCmd = &cobra.Command{
|
||||
Use: "bulk [file]",
|
||||
Short: "Import bulk data.",
|
||||
- Long: "Import data from a Mattermost Bulk Import File.",
|
||||
+ Long: "Import data from a Mostlymatter Bulk Import File.",
|
||||
Example: " import bulk bulk_data.json",
|
||||
RunE: bulkImportCmdF,
|
||||
}
|
||||
|
||||
func init() {
|
||||
diff --git a/server/cmd/mattermost/commands/init.go b/server/cmd/mostlymatter/commands/init.go
|
||||
similarity index 100%
|
||||
rename from server/cmd/mattermost/commands/init.go
|
||||
rename to server/cmd/mostlymatter/commands/init.go
|
||||
diff --git a/server/cmd/mattermost/commands/jobserver.go b/server/cmd/mostlymatter/commands/jobserver.go
|
||||
similarity index 88%
|
||||
rename from server/cmd/mattermost/commands/jobserver.go
|
||||
rename to server/cmd/mostlymatter/commands/jobserver.go
|
||||
index 60b570bea6..5ab12fc0cb 100644
|
||||
--- a/server/cmd/mattermost/commands/jobserver.go
|
||||
+++ b/server/cmd/mostlymatter/commands/jobserver.go
|
||||
@@ -16,11 +16,11 @@ import (
|
||||
"github.com/mattermost/mattermost/server/v8/config"
|
||||
)
|
||||
|
||||
var JobserverCmd = &cobra.Command{
|
||||
Use: "jobserver",
|
||||
- Short: "Start the Mattermost job server",
|
||||
+ Short: "Start the Mostlymatter job server",
|
||||
RunE: jobserverCmdF,
|
||||
}
|
||||
|
||||
func init() {
|
||||
JobserverCmd.Flags().Bool("nojobs", false, "Do not run jobs on this jobserver.")
|
||||
@@ -44,12 +44,12 @@ func jobserverCmdF(command *cobra.Command, args []string) error {
|
||||
a.Srv().LoadLicense()
|
||||
|
||||
rctx := request.EmptyContext(a.Log())
|
||||
|
||||
// Run jobs
|
||||
- rctx.Logger().Info("Starting Mattermost job server")
|
||||
- defer rctx.Logger().Info("Stopped Mattermost job server")
|
||||
+ rctx.Logger().Info("Starting Mostlymatter job server")
|
||||
+ defer rctx.Logger().Info("Stopped Mostlymatter job server")
|
||||
|
||||
if !noJobs {
|
||||
a.Srv().Jobs.StartWorkers()
|
||||
defer a.Srv().Jobs.StopWorkers()
|
||||
}
|
||||
@@ -66,9 +66,9 @@ func jobserverCmdF(command *cobra.Command, args []string) error {
|
||||
signalChan := make(chan os.Signal, 1)
|
||||
signal.Notify(signalChan, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-signalChan
|
||||
|
||||
// Cleanup anything that isn't handled by a defer statement
|
||||
- rctx.Logger().Info("Stopping Mattermost job server")
|
||||
+ rctx.Logger().Info("Stopping Mostlymatter job server")
|
||||
|
||||
return nil
|
||||
}
|
||||
diff --git a/server/cmd/mattermost/commands/main_test.go b/server/cmd/mostlymatter/commands/main_test.go
|
||||
similarity index 100%
|
||||
rename from server/cmd/mattermost/commands/main_test.go
|
||||
rename to server/cmd/mostlymatter/commands/main_test.go
|
||||
diff --git a/server/cmd/mattermost/commands/output.go b/server/cmd/mostlymatter/commands/output.go
|
||||
similarity index 100%
|
||||
rename from server/cmd/mattermost/commands/output.go
|
||||
rename to server/cmd/mostlymatter/commands/output.go
|
||||
diff --git a/server/cmd/mattermost/commands/root.go b/server/cmd/mostlymatter/commands/root.go
|
||||
similarity index 70%
|
||||
rename from server/cmd/mattermost/commands/root.go
|
||||
rename to server/cmd/mostlymatter/commands/root.go
|
||||
index 692ac30f53..8d2081ab21 100644
|
||||
--- a/server/cmd/mattermost/commands/root.go
|
||||
+++ b/server/cmd/mostlymatter/commands/root.go
|
||||
@@ -16,13 +16,13 @@ func Run(args []string) error {
|
||||
RootCmd.SetArgs(args)
|
||||
return RootCmd.Execute()
|
||||
}
|
||||
|
||||
var RootCmd = &cobra.Command{
|
||||
- Use: "mattermost",
|
||||
+ Use: "mostlymatter",
|
||||
Short: "Open source, self-hosted Slack-alternative",
|
||||
- Long: `Mattermost offers workplace messaging across web, PC and phones with archiving, search and integration with your existing systems. Documentation available at https://docs.mattermost.com`,
|
||||
+ Long: `Mostlymatter offers workplace messaging across web, PC and phones with archiving, search and integration with your existing systems. Documentation available at https://docs.mattermost.com`,
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
checkForRootUser()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -31,8 +31,8 @@ func init() {
|
||||
}
|
||||
|
||||
// checkForRootUser logs a warning if the process is running as root
|
||||
func checkForRootUser() {
|
||||
if os.Geteuid() == 0 {
|
||||
- mlog.Warn("Running Mattermost as root is not recommended. Please use a non-root user.")
|
||||
+ mlog.Warn("Running Mostlymatter as root is not recommended. Please use a non-root user.")
|
||||
}
|
||||
}
|
||||
diff --git a/server/cmd/mattermost/commands/server.go b/server/cmd/mostlymatter/commands/server.go
|
||||
similarity index 96%
|
||||
rename from server/cmd/mattermost/commands/server.go
|
||||
rename to server/cmd/mostlymatter/commands/server.go
|
||||
index 76d33bfaf3..0100abc12a 100644
|
||||
--- a/server/cmd/mattermost/commands/server.go
|
||||
+++ b/server/cmd/mostlymatter/commands/server.go
|
||||
@@ -24,11 +24,11 @@ import (
|
||||
"github.com/mattermost/mattermost/server/v8/config"
|
||||
)
|
||||
|
||||
var serverCmd = &cobra.Command{
|
||||
Use: "server",
|
||||
- Short: "Run the Mattermost server",
|
||||
+ Short: "Run the Mostlymatter server",
|
||||
RunE: serverCmdF,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -38,11 +38,11 @@ func init() {
|
||||
|
||||
func serverCmdF(command *cobra.Command, args []string) error {
|
||||
interruptChan := make(chan os.Signal, 1)
|
||||
|
||||
if err := utils.TranslationsPreInit(); err != nil {
|
||||
- return errors.Wrap(err, "unable to load Mattermost translation files")
|
||||
+ return errors.Wrap(err, "unable to load Mostlymatter translation files")
|
||||
}
|
||||
|
||||
customDefaults, err := loadCustomDefaults()
|
||||
if err != nil {
|
||||
mlog.Warn("Error loading custom configuration defaults: " + err.Error())
|
||||
diff --git a/server/cmd/mattermost/commands/server_test.go b/server/cmd/mostlymatter/commands/server_test.go
|
||||
similarity index 100%
|
||||
rename from server/cmd/mattermost/commands/server_test.go
|
||||
rename to server/cmd/mostlymatter/commands/server_test.go
|
||||
diff --git a/server/cmd/mattermost/commands/test.go b/server/cmd/mostlymatter/commands/test.go
|
||||
similarity index 100%
|
||||
rename from server/cmd/mattermost/commands/test.go
|
||||
rename to server/cmd/mostlymatter/commands/test.go
|
||||
diff --git a/server/cmd/mattermost/commands/utils.go b/server/cmd/mostlymatter/commands/utils.go
|
||||
similarity index 100%
|
||||
rename from server/cmd/mattermost/commands/utils.go
|
||||
rename to server/cmd/mostlymatter/commands/utils.go
|
||||
diff --git a/server/cmd/mattermost/commands/utils_test.go b/server/cmd/mostlymatter/commands/utils_test.go
|
||||
similarity index 100%
|
||||
rename from server/cmd/mattermost/commands/utils_test.go
|
||||
rename to server/cmd/mostlymatter/commands/utils_test.go
|
||||
diff --git a/server/cmd/mattermost/commands/version.go b/server/cmd/mostlymatter/commands/version.go
|
||||
similarity index 92%
|
||||
rename from server/cmd/mattermost/commands/version.go
|
||||
rename to server/cmd/mostlymatter/commands/version.go
|
||||
index 5a5a5f160d..b0c0dc11ff 100644
|
||||
--- a/server/cmd/mattermost/commands/version.go
|
||||
+++ b/server/cmd/mostlymatter/commands/version.go
|
||||
@@ -14,11 +14,11 @@ var VersionCmd = &cobra.Command{
|
||||
Short: "Display version information",
|
||||
RunE: versionCmdF,
|
||||
}
|
||||
|
||||
func init() {
|
||||
- VersionCmd.Flags().Bool("skip-server-start", false, "Skip the server initialization and return the Mattermost version without the DB version.")
|
||||
+ VersionCmd.Flags().Bool("skip-server-start", false, "Skip the server initialization and return the Mostlymatter version without the DB version.")
|
||||
VersionCmd.Flags().MarkDeprecated("skip-server-start", "This flag is not necessary anymore and the flag will be removed in the future releases. Consider removing it from your scripts.")
|
||||
RootCmd.AddCommand(VersionCmd)
|
||||
}
|
||||
|
||||
func versionCmdF(command *cobra.Command, args []string) error {
|
||||
diff --git a/server/cmd/mattermost/commands/version_test.go b/server/cmd/mostlymatter/commands/version_test.go
|
||||
similarity index 100%
|
||||
rename from server/cmd/mattermost/commands/version_test.go
|
||||
rename to server/cmd/mostlymatter/commands/version_test.go
|
||||
diff --git a/server/cmd/mattermost/main.go b/server/cmd/mostlymatter/main.go
|
||||
similarity index 88%
|
||||
rename from server/cmd/mattermost/main.go
|
||||
rename to server/cmd/mostlymatter/main.go
|
||||
index 5fb4b4a005..1783e812f3 100644
|
||||
--- a/server/cmd/mattermost/main.go
|
||||
+++ b/server/cmd/mostlymatter/main.go
|
||||
@@ -4,11 +4,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
- "github.com/mattermost/mattermost/server/v8/cmd/mattermost/commands"
|
||||
+ "github.com/mattermost/mattermost/server/v8/cmd/mostlymatter/commands"
|
||||
// Import and register app layer slash commands
|
||||
_ "github.com/mattermost/mattermost/server/v8/channels/app/slashcommands"
|
||||
// Plugins
|
||||
_ "github.com/mattermost/mattermost/server/v8/channels/app/oauthproviders/gitlab"
|
||||
|
||||
diff --git a/server/cmd/mattermost/main_test.go b/server/cmd/mostlymatter/main_test.go
|
||||
similarity index 96%
|
||||
rename from server/cmd/mattermost/main_test.go
|
||||
rename to server/cmd/mostlymatter/main_test.go
|
||||
index 8ec8a0bea4..c661c2012c 100644
|
||||
--- a/server/cmd/mattermost/main_test.go
|
||||
+++ b/server/cmd/mostlymatter/main_test.go
|
||||
@@ -10,11 +10,11 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRunMain can be used to track code coverage in integration tests.
|
||||
// To run this:
|
||||
-// go test -coverpkg="<>" -ldflags '<>' -tags maincoverage -c ./cmd/mattermost/
|
||||
+// go test -coverpkg="<>" -ldflags '<>' -tags maincoverage -c ./cmd/mostlymatter/
|
||||
// ./mattermost.test -test.run="^TestRunMain$" -test.coverprofile=coverage.out
|
||||
// And then run your integration tests.
|
||||
func TestRunMain(t *testing.T) {
|
||||
main()
|
||||
}
|
||||
Двоичные данные
mostlymatter/logo.png
Двоичные данные
mostlymatter/logo.png
Двоичный файл не отображается.
|
До Ширина: | Высота: | Размер: 31 KiB |
Двоичные данные
mostlymatter/logo_with_white_background-small.png
Двоичные данные
mostlymatter/logo_with_white_background-small.png
Двоичный файл не отображается.
|
До Ширина: | Высота: | Размер: 8.1 KiB |
Двоичные данные
mostlymatter/logo_with_white_background.png
Двоичные данные
mostlymatter/logo_with_white_background.png
Двоичный файл не отображается.
|
До Ширина: | Высота: | Размер: 38 KiB |
@@ -144,14 +144,14 @@ TEMPLATES_DIR=templates
|
||||
PLUGIN_PACKAGES ?= $(PLUGIN_PACKAGES:)
|
||||
PLUGIN_PACKAGES += mattermost-plugin-calls-v1.11.4
|
||||
PLUGIN_PACKAGES += mattermost-plugin-github-v2.7.0
|
||||
PLUGIN_PACKAGES += mattermost-plugin-gitlab-v1.12.1
|
||||
PLUGIN_PACKAGES += mattermost-plugin-jira-v4.5.0
|
||||
PLUGIN_PACKAGES += mattermost-plugin-gitlab-v1.12.2
|
||||
PLUGIN_PACKAGES += mattermost-plugin-jira-v4.7.0
|
||||
# We need to prepackage both versions of playbooks and install the correct one based on the server license. See MM-60025.
|
||||
PLUGIN_PACKAGES += mattermost-plugin-playbooks-v1.41.1
|
||||
PLUGIN_PACKAGES += mattermost-plugin-playbooks-v2.4.4
|
||||
PLUGIN_PACKAGES += mattermost-plugin-playbooks-v2.4.6
|
||||
PLUGIN_PACKAGES += mattermost-plugin-servicenow-v2.3.4
|
||||
PLUGIN_PACKAGES += mattermost-plugin-zoom-v1.13.0
|
||||
PLUGIN_PACKAGES += mattermost-plugin-agents-v1.4.0
|
||||
PLUGIN_PACKAGES += mattermost-plugin-agents-v1.14.2
|
||||
PLUGIN_PACKAGES += mattermost-plugin-boards-v9.2.4
|
||||
PLUGIN_PACKAGES += mattermost-plugin-msteams-v2.2.2
|
||||
PLUGIN_PACKAGES += mattermost-plugin-user-survey-v1.1.1
|
||||
|
||||
@@ -345,6 +345,11 @@ func patchChannel(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
model.AddEventParameterAuditableToAuditRec(auditRec, "channel", patch)
|
||||
auditRec.AddEventPriorState(oldChannel)
|
||||
|
||||
if patch.GroupConstrained != nil && !oldChannel.SupportsGroupSync() {
|
||||
c.Err = model.NewAppError("patchChannel", "api.channel.patch_update_channel.group_constrained_not_allowed.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch oldChannel.Type {
|
||||
case model.ChannelTypeOpen:
|
||||
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManagePublicChannelProperties); !ok {
|
||||
|
||||
@@ -872,6 +872,36 @@ func TestPatchChannel(t *testing.T) {
|
||||
CheckBadRequestStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("Should block setting group_constrained on group and direct messages", func(t *testing.T) {
|
||||
user1 := th.CreateUser()
|
||||
user2 := th.CreateUser()
|
||||
user3 := th.CreateUser()
|
||||
|
||||
_, err := client.Logout(context.Background())
|
||||
require.NoError(t, err)
|
||||
_, _, err = client.Login(context.Background(), user1.Email, user1.Password)
|
||||
require.NoError(t, err)
|
||||
|
||||
groupChannel, _, err := client.CreateGroupChannel(context.Background(), []string{user1.Id, user2.Id, user3.Id})
|
||||
require.NoError(t, err)
|
||||
|
||||
patch := &model.ChannelPatch{GroupConstrained: model.NewPointer(true)}
|
||||
_, resp, err := client.PatchChannel(context.Background(), groupChannel.Id, patch)
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, resp)
|
||||
|
||||
stats, _, err := client.GetChannelStats(context.Background(), groupChannel.Id, "", false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(3), stats.MemberCount)
|
||||
|
||||
directChannel, _, err := client.CreateDirectChannel(context.Background(), user1.Id, user2.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, resp, err = client.PatchChannel(context.Background(), directChannel.Id, patch)
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("Should not be able to configure channel banner without a license", func(t *testing.T) {
|
||||
_, err := client.Logout(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/v8/channels/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -1057,28 +1058,10 @@ func TestAddChannelsToPolicy(t *testing.T) {
|
||||
validChannelIDs := []string{model.NewId(), model.NewId()}
|
||||
invalidChannelIDs := []string{"invalid_channel_id"}
|
||||
|
||||
// Custom function to compare slices regardless of order
|
||||
unorderedSlicesEqual := func(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
counts := make(map[string]int)
|
||||
for _, item := range a {
|
||||
counts[item]++
|
||||
}
|
||||
for _, item := range b {
|
||||
counts[item]--
|
||||
if counts[item] < 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Custom matcher for unordered slice comparison
|
||||
unorderedSliceMatcher := func(expected []string) func(actual []string) bool {
|
||||
return func(actual []string) bool {
|
||||
return unorderedSlicesEqual(expected, actual)
|
||||
return utils.SliceEqualUnordered(expected, actual)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -377,10 +377,35 @@ func linkGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
groupSyncable := &model.GroupSyncable{
|
||||
GroupId: c.Params.GroupId,
|
||||
SyncableId: syncableID,
|
||||
Type: syncableType,
|
||||
appErr = verifySchemeAdminAssignmentPermission(c, syncableType, syncableID, patch)
|
||||
if appErr != nil {
|
||||
appErr.Where = "Api4.linkGroupSyncable"
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
// Upsert onto the existing row only when it is currently active so
|
||||
// unspecified fields are preserved. A fresh link, or a re-link of a
|
||||
// soft-deleted row, starts from a zero-value struct so that fields
|
||||
// the caller did not (or was not authorized to) set are not carried
|
||||
// over from the previous incarnation. The downstream upsert clears
|
||||
// DeleteAt when re-activating.
|
||||
existing, appErr := c.App.GetGroupSyncable(c.Params.GroupId, syncableID, syncableType)
|
||||
if appErr != nil && appErr.StatusCode != http.StatusNotFound {
|
||||
appErr.Where = "Api4.linkGroupSyncable"
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
var groupSyncable *model.GroupSyncable
|
||||
if existing != nil && existing.DeleteAt == 0 {
|
||||
groupSyncable = existing
|
||||
} else {
|
||||
groupSyncable = &model.GroupSyncable{
|
||||
GroupId: c.Params.GroupId,
|
||||
SyncableId: syncableID,
|
||||
Type: syncableType,
|
||||
}
|
||||
}
|
||||
groupSyncable.Patch(patch)
|
||||
groupSyncable, appErr = c.App.UpsertGroupSyncable(groupSyncable)
|
||||
@@ -392,8 +417,9 @@ func linkGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
auditRec.AddEventResultState(groupSyncable)
|
||||
auditRec.AddEventObjectType("group_syncable")
|
||||
|
||||
syncRoles := patch.SchemeAdmin != nil
|
||||
c.App.Srv().Go(func() {
|
||||
c.App.SyncRolesAndMembership(c.AppContext, syncableID, syncableType, c.Params.GroupId)
|
||||
c.App.SyncRolesAndMembership(c.AppContext, syncableID, syncableType, c.Params.GroupId, syncRoles)
|
||||
})
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
@@ -560,6 +586,13 @@ func patchGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
appErr = verifySchemeAdminAssignmentPermission(c, syncableType, syncableID, patch)
|
||||
if appErr != nil {
|
||||
appErr.Where = "Api4.patchGroupSyncable"
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
groupSyncable, appErr := c.App.GetGroupSyncable(c.Params.GroupId, syncableID, syncableType)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
@@ -577,8 +610,9 @@ func patchGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
auditRec.AddEventResultState(groupSyncable)
|
||||
auditRec.AddEventObjectType("group_syncable")
|
||||
|
||||
syncRoles := patch.SchemeAdmin != nil
|
||||
c.App.Srv().Go(func() {
|
||||
c.App.SyncRolesAndMembership(c.AppContext, syncableID, syncableType, c.Params.GroupId)
|
||||
c.App.SyncRolesAndMembership(c.AppContext, syncableID, syncableType, c.Params.GroupId, syncRoles)
|
||||
})
|
||||
|
||||
b, err := json.Marshal(groupSyncable)
|
||||
@@ -710,6 +744,34 @@ func verifyLinkUnlinkPermission(c *Context, syncableType model.GroupSyncableType
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifySchemeAdminAssignmentPermission requires the caller to hold the
|
||||
// role-management permission for the target syncable
|
||||
// (manage_team_roles / manage_channel_roles), or the sysconsole groups
|
||||
// write permission, before an explicit SchemeAdmin value in the patch is
|
||||
// accepted. A nil patch.SchemeAdmin is a no-op.
|
||||
func verifySchemeAdminAssignmentPermission(c *Context, syncableType model.GroupSyncableType, syncableID string, patch *model.GroupSyncablePatch) *model.AppError {
|
||||
if patch == nil || patch.SchemeAdmin == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteUserManagementGroups) {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch syncableType {
|
||||
case model.GroupSyncableTypeTeam:
|
||||
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), syncableID, model.PermissionManageTeamRoles) {
|
||||
return model.MakePermissionError(c.AppContext.Session(), []*model.Permission{model.PermissionManageTeamRoles})
|
||||
}
|
||||
case model.GroupSyncableTypeChannel:
|
||||
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), syncableID, model.PermissionManageChannelRoles); !ok {
|
||||
return model.MakePermissionError(c.AppContext.Session(), []*model.Permission{model.PermissionManageChannelRoles})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getGroupMembers(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
permissionErr := requireLicense(c)
|
||||
if permissionErr != nil {
|
||||
|
||||
@@ -2847,3 +2847,736 @@ func TestDeleteMembersFromGroup(t *testing.T) {
|
||||
CheckBadRequestStatus(t, response)
|
||||
})
|
||||
}
|
||||
|
||||
// newSchemeAdminTestLdapGroup creates a fresh LDAP-source group with
|
||||
// AllowReference=true.
|
||||
func newSchemeAdminTestLdapGroup(t *testing.T, th *TestHelper) *model.Group {
|
||||
t.Helper()
|
||||
id := model.NewId()
|
||||
g, appErr := th.App.CreateGroup(&model.Group{
|
||||
DisplayName: "dn_" + id,
|
||||
Name: model.NewPointer("name" + id),
|
||||
Source: model.GroupSourceLdap,
|
||||
Description: "description_" + id,
|
||||
RemoteId: model.NewPointer(model.NewId()),
|
||||
AllowReference: true,
|
||||
})
|
||||
require.Nil(t, appErr)
|
||||
return g
|
||||
}
|
||||
|
||||
// findPersistedGroupSyncable returns the persisted GroupSyncable for a
|
||||
// given (groupID, syncableID, syncableType) tuple, including SchemeAdmin.
|
||||
func findPersistedGroupSyncable(t *testing.T, th *TestHelper, groupID, syncableID string, syncableType model.GroupSyncableType) *model.GroupSyncable {
|
||||
t.Helper()
|
||||
syncables, appErr := th.App.GetGroupSyncables(groupID, syncableType)
|
||||
require.Nil(t, appErr)
|
||||
for _, s := range syncables {
|
||||
if s.SyncableId == syncableID {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestLinkGroupTeam_SchemeAdminRequiresElevatedPermission(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicense("ldap"))
|
||||
|
||||
schemeAdminTrue := &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(true),
|
||||
SchemeAdmin: model.NewPointer(true),
|
||||
}
|
||||
|
||||
t.Run("regular team user with invite_user must NOT be able to set scheme_admin: true", func(t *testing.T) {
|
||||
g := newSchemeAdminTestLdapGroup(t, th)
|
||||
|
||||
groupSyncable, response, err := th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, schemeAdminTrue)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, response)
|
||||
assert.Nil(t, groupSyncable)
|
||||
|
||||
persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
|
||||
if persisted != nil {
|
||||
assert.False(t, persisted.SchemeAdmin)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("system admin can still set scheme_admin: true", func(t *testing.T) {
|
||||
g := newSchemeAdminTestLdapGroup(t, th)
|
||||
|
||||
_, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, schemeAdminTrue)
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, response)
|
||||
|
||||
persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
|
||||
require.NotNil(t, persisted)
|
||||
assert.True(t, persisted.SchemeAdmin)
|
||||
})
|
||||
|
||||
t.Run("regular team user can still link with scheme_admin omitted", func(t *testing.T) {
|
||||
g := newSchemeAdminTestLdapGroup(t, th)
|
||||
|
||||
patch := &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(true),
|
||||
}
|
||||
groupSyncable, response, err := th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch)
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, response)
|
||||
require.NotNil(t, groupSyncable)
|
||||
|
||||
persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
|
||||
require.NotNil(t, persisted)
|
||||
assert.False(t, persisted.SchemeAdmin)
|
||||
})
|
||||
|
||||
t.Run("regular team user must NOT be able to link with scheme_admin: false explicitly", func(t *testing.T) {
|
||||
g := newSchemeAdminTestLdapGroup(t, th)
|
||||
|
||||
patch := &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(true),
|
||||
SchemeAdmin: model.NewPointer(false),
|
||||
}
|
||||
groupSyncable, response, err := th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, response)
|
||||
assert.Nil(t, groupSyncable)
|
||||
|
||||
persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
|
||||
if persisted != nil {
|
||||
assert.False(t, persisted.SchemeAdmin)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLinkGroupChannel_SchemeAdminRequiresElevatedPermission(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicense("ldap"))
|
||||
|
||||
// A regular user can only link a channel syncable when the group is
|
||||
// already linked to the parent team, so seed the team link as sysadmin.
|
||||
mkLinkedGroup := func(t *testing.T) *model.Group {
|
||||
t.Helper()
|
||||
g := newSchemeAdminTestLdapGroup(t, th)
|
||||
_, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, response)
|
||||
return g
|
||||
}
|
||||
|
||||
schemeAdminTrue := &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(true),
|
||||
SchemeAdmin: model.NewPointer(true),
|
||||
}
|
||||
|
||||
t.Run("regular channel user with manage_*_channel_members must NOT be able to set scheme_admin: true", func(t *testing.T) {
|
||||
g := mkLinkedGroup(t)
|
||||
|
||||
groupSyncable, response, err := th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, schemeAdminTrue)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, response)
|
||||
assert.Nil(t, groupSyncable)
|
||||
|
||||
persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel)
|
||||
if persisted != nil {
|
||||
assert.False(t, persisted.SchemeAdmin)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("system admin can still set scheme_admin: true", func(t *testing.T) {
|
||||
g := mkLinkedGroup(t)
|
||||
|
||||
_, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, schemeAdminTrue)
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, response)
|
||||
|
||||
persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel)
|
||||
require.NotNil(t, persisted)
|
||||
assert.True(t, persisted.SchemeAdmin)
|
||||
})
|
||||
|
||||
t.Run("regular channel user can still link with scheme_admin omitted", func(t *testing.T) {
|
||||
g := mkLinkedGroup(t)
|
||||
|
||||
patch := &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(true),
|
||||
}
|
||||
groupSyncable, response, err := th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch)
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, response)
|
||||
require.NotNil(t, groupSyncable)
|
||||
|
||||
persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel)
|
||||
require.NotNil(t, persisted)
|
||||
assert.False(t, persisted.SchemeAdmin)
|
||||
})
|
||||
|
||||
t.Run("regular channel user must NOT be able to link with scheme_admin: false explicitly", func(t *testing.T) {
|
||||
g := mkLinkedGroup(t)
|
||||
|
||||
patch := &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(true),
|
||||
SchemeAdmin: model.NewPointer(false),
|
||||
}
|
||||
groupSyncable, response, err := th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, response)
|
||||
assert.Nil(t, groupSyncable)
|
||||
|
||||
persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel)
|
||||
if persisted != nil {
|
||||
assert.False(t, persisted.SchemeAdmin)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPatchGroupTeam_SchemeAdminRequiresElevatedPermission(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicense("ldap"))
|
||||
|
||||
// schemeAdmin controls the seeded SchemeAdmin value on the team syncable.
|
||||
setupLinkedGroup := func(t *testing.T, schemeAdmin bool) *model.Group {
|
||||
t.Helper()
|
||||
g := newSchemeAdminTestLdapGroup(t, th)
|
||||
_, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(true),
|
||||
SchemeAdmin: model.NewPointer(schemeAdmin),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, response)
|
||||
return g
|
||||
}
|
||||
|
||||
schemeAdminTrue := &model.GroupSyncablePatch{
|
||||
SchemeAdmin: model.NewPointer(true),
|
||||
}
|
||||
|
||||
schemeAdminFalse := &model.GroupSyncablePatch{
|
||||
SchemeAdmin: model.NewPointer(false),
|
||||
}
|
||||
|
||||
t.Run("regular team user with invite_user must NOT be able to patch scheme_admin: true", func(t *testing.T) {
|
||||
g := setupLinkedGroup(t, false)
|
||||
|
||||
_, response, err := th.Client.PatchGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, schemeAdminTrue)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, response)
|
||||
|
||||
persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
|
||||
require.NotNil(t, persisted)
|
||||
assert.False(t, persisted.SchemeAdmin)
|
||||
})
|
||||
|
||||
t.Run("system admin can still patch scheme_admin: true", func(t *testing.T) {
|
||||
g := setupLinkedGroup(t, false)
|
||||
|
||||
_, response, err := th.SystemAdminClient.PatchGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, schemeAdminTrue)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, response)
|
||||
|
||||
persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
|
||||
require.NotNil(t, persisted)
|
||||
assert.True(t, persisted.SchemeAdmin)
|
||||
})
|
||||
|
||||
t.Run("regular team user can still patch other fields with scheme_admin omitted", func(t *testing.T) {
|
||||
g := setupLinkedGroup(t, true)
|
||||
|
||||
patch := &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(false),
|
||||
}
|
||||
_, response, err := th.Client.PatchGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, response)
|
||||
|
||||
persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
|
||||
require.NotNil(t, persisted)
|
||||
assert.False(t, persisted.AutoAdd)
|
||||
assert.True(t, persisted.SchemeAdmin)
|
||||
})
|
||||
|
||||
t.Run("regular team user must NOT be able to patch scheme_admin: false", func(t *testing.T) {
|
||||
g := setupLinkedGroup(t, true)
|
||||
|
||||
_, response, err := th.Client.PatchGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, schemeAdminFalse)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, response)
|
||||
|
||||
persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
|
||||
require.NotNil(t, persisted)
|
||||
assert.True(t, persisted.SchemeAdmin)
|
||||
})
|
||||
|
||||
t.Run("system admin can still patch scheme_admin: false", func(t *testing.T) {
|
||||
g := setupLinkedGroup(t, true)
|
||||
|
||||
_, response, err := th.SystemAdminClient.PatchGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, schemeAdminFalse)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, response)
|
||||
|
||||
persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
|
||||
require.NotNil(t, persisted)
|
||||
assert.False(t, persisted.SchemeAdmin)
|
||||
})
|
||||
|
||||
t.Run("sysconsole_write_user_management_groups holder can patch scheme_admin in either direction", func(t *testing.T) {
|
||||
// system_manager bundles sysconsole_write_user_management_groups,
|
||||
// the override honoured by verifySchemeAdminAssignmentPermission.
|
||||
th.LoginSystemManager()
|
||||
|
||||
gPromote := setupLinkedGroup(t, false)
|
||||
_, response, err := th.SystemManagerClient.PatchGroupSyncable(context.Background(), gPromote.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, schemeAdminTrue)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, response)
|
||||
persistedPromote := findPersistedGroupSyncable(t, th, gPromote.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
|
||||
require.NotNil(t, persistedPromote)
|
||||
assert.True(t, persistedPromote.SchemeAdmin)
|
||||
|
||||
gDemote := setupLinkedGroup(t, true)
|
||||
_, response, err = th.SystemManagerClient.PatchGroupSyncable(context.Background(), gDemote.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, schemeAdminFalse)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, response)
|
||||
persistedDemote := findPersistedGroupSyncable(t, th, gDemote.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
|
||||
require.NotNil(t, persistedDemote)
|
||||
assert.False(t, persistedDemote.SchemeAdmin)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPatchGroupChannel_SchemeAdminRequiresElevatedPermission(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicense("ldap"))
|
||||
|
||||
// schemeAdmin controls the seeded SchemeAdmin value on the channel
|
||||
// syncable. The team syncable is seeded so the channel link succeeds.
|
||||
setupLinkedGroup := func(t *testing.T, schemeAdmin bool) *model.Group {
|
||||
t.Helper()
|
||||
g := newSchemeAdminTestLdapGroup(t, th)
|
||||
_, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, response)
|
||||
|
||||
_, response, err = th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(true),
|
||||
SchemeAdmin: model.NewPointer(schemeAdmin),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, response)
|
||||
return g
|
||||
}
|
||||
|
||||
schemeAdminTrue := &model.GroupSyncablePatch{
|
||||
SchemeAdmin: model.NewPointer(true),
|
||||
}
|
||||
|
||||
schemeAdminFalse := &model.GroupSyncablePatch{
|
||||
SchemeAdmin: model.NewPointer(false),
|
||||
}
|
||||
|
||||
t.Run("regular channel user with manage_*_channel_members must NOT be able to patch scheme_admin: true", func(t *testing.T) {
|
||||
g := setupLinkedGroup(t, false)
|
||||
|
||||
_, response, err := th.Client.PatchGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, schemeAdminTrue)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, response)
|
||||
|
||||
persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel)
|
||||
require.NotNil(t, persisted)
|
||||
assert.False(t, persisted.SchemeAdmin)
|
||||
})
|
||||
|
||||
t.Run("system admin can still patch scheme_admin: true", func(t *testing.T) {
|
||||
g := setupLinkedGroup(t, false)
|
||||
|
||||
_, response, err := th.SystemAdminClient.PatchGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, schemeAdminTrue)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, response)
|
||||
|
||||
persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel)
|
||||
require.NotNil(t, persisted)
|
||||
assert.True(t, persisted.SchemeAdmin)
|
||||
})
|
||||
|
||||
t.Run("regular channel user can still patch other fields with scheme_admin omitted", func(t *testing.T) {
|
||||
g := setupLinkedGroup(t, true)
|
||||
|
||||
patch := &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(false),
|
||||
}
|
||||
_, response, err := th.Client.PatchGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, response)
|
||||
|
||||
persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel)
|
||||
require.NotNil(t, persisted)
|
||||
assert.False(t, persisted.AutoAdd)
|
||||
assert.True(t, persisted.SchemeAdmin)
|
||||
})
|
||||
|
||||
t.Run("regular channel user must NOT be able to patch scheme_admin: false", func(t *testing.T) {
|
||||
g := setupLinkedGroup(t, true)
|
||||
|
||||
_, response, err := th.Client.PatchGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, schemeAdminFalse)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, response)
|
||||
|
||||
persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel)
|
||||
require.NotNil(t, persisted)
|
||||
assert.True(t, persisted.SchemeAdmin)
|
||||
})
|
||||
|
||||
t.Run("system admin can still patch scheme_admin: false", func(t *testing.T) {
|
||||
g := setupLinkedGroup(t, true)
|
||||
|
||||
_, response, err := th.SystemAdminClient.PatchGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, schemeAdminFalse)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, response)
|
||||
|
||||
persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel)
|
||||
require.NotNil(t, persisted)
|
||||
assert.False(t, persisted.SchemeAdmin)
|
||||
})
|
||||
|
||||
t.Run("sysconsole_write_user_management_groups holder can patch scheme_admin in either direction", func(t *testing.T) {
|
||||
// system_manager bundles sysconsole_write_user_management_groups,
|
||||
// the override honoured by verifySchemeAdminAssignmentPermission.
|
||||
th.LoginSystemManager()
|
||||
|
||||
gPromote := setupLinkedGroup(t, false)
|
||||
_, response, err := th.SystemManagerClient.PatchGroupSyncable(context.Background(), gPromote.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, schemeAdminTrue)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, response)
|
||||
persistedPromote := findPersistedGroupSyncable(t, th, gPromote.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel)
|
||||
require.NotNil(t, persistedPromote)
|
||||
assert.True(t, persistedPromote.SchemeAdmin)
|
||||
|
||||
gDemote := setupLinkedGroup(t, true)
|
||||
_, response, err = th.SystemManagerClient.PatchGroupSyncable(context.Background(), gDemote.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, schemeAdminFalse)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, response)
|
||||
persistedDemote := findPersistedGroupSyncable(t, th, gDemote.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel)
|
||||
require.NotNil(t, persistedDemote)
|
||||
assert.False(t, persistedDemote.SchemeAdmin)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLinkGroupTeam_LinkOnExistingPreservesSchemeAdmin(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicense("ldap"))
|
||||
|
||||
seedSchemeAdminTrue := func(t *testing.T) *model.Group {
|
||||
t.Helper()
|
||||
g := newSchemeAdminTestLdapGroup(t, th)
|
||||
_, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(true),
|
||||
SchemeAdmin: model.NewPointer(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, response)
|
||||
persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
|
||||
require.NotNil(t, persisted)
|
||||
require.True(t, persisted.SchemeAdmin)
|
||||
return g
|
||||
}
|
||||
|
||||
t.Run("regular team user calling LINK with scheme_admin omitted must not change persisted scheme_admin", func(t *testing.T) {
|
||||
g := seedSchemeAdminTrue(t)
|
||||
|
||||
patch := &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(true),
|
||||
}
|
||||
_, _, _ = th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch)
|
||||
|
||||
persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
|
||||
require.NotNil(t, persisted)
|
||||
assert.True(t, persisted.SchemeAdmin)
|
||||
})
|
||||
|
||||
t.Run("regular team user calling LINK with scheme_admin: false must not change persisted scheme_admin", func(t *testing.T) {
|
||||
g := seedSchemeAdminTrue(t)
|
||||
|
||||
patch := &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(true),
|
||||
SchemeAdmin: model.NewPointer(false),
|
||||
}
|
||||
_, _, _ = th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch)
|
||||
|
||||
persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
|
||||
require.NotNil(t, persisted)
|
||||
assert.True(t, persisted.SchemeAdmin)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLinkGroupChannel_LinkOnExistingPreservesSchemeAdmin(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicense("ldap"))
|
||||
|
||||
seedSchemeAdminTrue := func(t *testing.T) *model.Group {
|
||||
t.Helper()
|
||||
g := newSchemeAdminTestLdapGroup(t, th)
|
||||
_, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, response)
|
||||
|
||||
_, response, err = th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(true),
|
||||
SchemeAdmin: model.NewPointer(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, response)
|
||||
persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel)
|
||||
require.NotNil(t, persisted)
|
||||
require.True(t, persisted.SchemeAdmin)
|
||||
return g
|
||||
}
|
||||
|
||||
t.Run("regular channel user calling LINK with scheme_admin omitted must not change persisted scheme_admin", func(t *testing.T) {
|
||||
g := seedSchemeAdminTrue(t)
|
||||
|
||||
patch := &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(true),
|
||||
}
|
||||
_, _, _ = th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch)
|
||||
|
||||
persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel)
|
||||
require.NotNil(t, persisted)
|
||||
assert.True(t, persisted.SchemeAdmin)
|
||||
})
|
||||
|
||||
t.Run("regular channel user calling LINK with scheme_admin: false must not change persisted scheme_admin", func(t *testing.T) {
|
||||
g := seedSchemeAdminTrue(t)
|
||||
|
||||
patch := &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(true),
|
||||
SchemeAdmin: model.NewPointer(false),
|
||||
}
|
||||
_, _, _ = th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch)
|
||||
|
||||
persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel)
|
||||
require.NotNil(t, persisted)
|
||||
assert.True(t, persisted.SchemeAdmin)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLinkGroupTeam_LinkOnSoftDeletedDoesNotPreserveSchemeAdmin(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicense("ldap"))
|
||||
|
||||
t.Run("regular team user re-linking a soft-deleted syncable with scheme_admin omitted must persist scheme_admin: false", func(t *testing.T) {
|
||||
g := newSchemeAdminTestLdapGroup(t, th)
|
||||
|
||||
_, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(true),
|
||||
SchemeAdmin: model.NewPointer(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, response)
|
||||
|
||||
response, err = th.SystemAdminClient.UnlinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, response)
|
||||
|
||||
patch := &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(true),
|
||||
}
|
||||
_, response, err = th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch)
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, response)
|
||||
|
||||
persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
|
||||
require.NotNil(t, persisted)
|
||||
assert.False(t, persisted.SchemeAdmin)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPatchGroupTeam_OmittedSchemeAdminDoesNotDemoteDirectAdmin(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicense("ldap"))
|
||||
|
||||
g := newSchemeAdminTestLdapGroup(t, th)
|
||||
|
||||
_, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, response)
|
||||
|
||||
th.UpdateUserToTeamAdmin(th.BasicUser2, th.BasicTeam)
|
||||
|
||||
patch := &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(false),
|
||||
}
|
||||
_, response, err = th.Client.PatchGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, response)
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
tm, appErr := th.App.GetTeamMember(th.Context, th.BasicTeam.Id, th.BasicUser2.Id)
|
||||
require.Nil(t, appErr)
|
||||
assert.True(t, tm.SchemeAdmin)
|
||||
}
|
||||
|
||||
func TestPatchGroupChannel_OmittedSchemeAdminDoesNotDemoteDirectAdmin(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicense("ldap"))
|
||||
|
||||
g := newSchemeAdminTestLdapGroup(t, th)
|
||||
|
||||
_, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, response)
|
||||
|
||||
_, response, err = th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, response)
|
||||
|
||||
th.MakeUserChannelAdmin(th.BasicUser2, th.BasicChannel)
|
||||
|
||||
patch := &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(false),
|
||||
}
|
||||
_, response, err = th.Client.PatchGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, response)
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
cm, appErr := th.App.GetChannelMember(th.Context, th.BasicChannel.Id, th.BasicUser2.Id)
|
||||
require.Nil(t, appErr)
|
||||
assert.True(t, cm.SchemeAdmin)
|
||||
}
|
||||
|
||||
func TestLinkGroupTeam_OmittedSchemeAdminDoesNotDemoteDirectAdmin(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicense("ldap"))
|
||||
|
||||
g := newSchemeAdminTestLdapGroup(t, th)
|
||||
|
||||
th.UpdateUserToTeamAdmin(th.BasicUser2, th.BasicTeam)
|
||||
|
||||
patch := &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(true),
|
||||
}
|
||||
_, response, err := th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch)
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, response)
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
tm, appErr := th.App.GetTeamMember(th.Context, th.BasicTeam.Id, th.BasicUser2.Id)
|
||||
require.Nil(t, appErr)
|
||||
assert.True(t, tm.SchemeAdmin)
|
||||
}
|
||||
|
||||
func TestLinkGroupChannel_OmittedSchemeAdminDoesNotDemoteDirectAdmin(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicense("ldap"))
|
||||
|
||||
g := newSchemeAdminTestLdapGroup(t, th)
|
||||
|
||||
_, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, response)
|
||||
|
||||
th.MakeUserChannelAdmin(th.BasicUser2, th.BasicChannel)
|
||||
|
||||
patch := &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(true),
|
||||
}
|
||||
_, response, err = th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch)
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, response)
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
cm, appErr := th.App.GetChannelMember(th.Context, th.BasicChannel.Id, th.BasicUser2.Id)
|
||||
require.Nil(t, appErr)
|
||||
assert.True(t, cm.SchemeAdmin)
|
||||
}
|
||||
|
||||
func TestLinkGroupTeam_SchemeAdminTruePromotesGroupMembers(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicense("ldap"))
|
||||
|
||||
g := newSchemeAdminTestLdapGroup(t, th)
|
||||
|
||||
_, appErr := th.App.UpsertGroupMember(g.Id, th.BasicUser2.Id)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
_, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(true),
|
||||
SchemeAdmin: model.NewPointer(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, response)
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
tm, appErr := th.App.GetTeamMember(th.Context, th.BasicTeam.Id, th.BasicUser2.Id)
|
||||
require.Nil(t, appErr)
|
||||
assert.True(t, tm.SchemeAdmin)
|
||||
}
|
||||
|
||||
func TestLinkGroupTeam_AutoAddOnlyAddsGroupMembers(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicense("ldap"))
|
||||
|
||||
g := newSchemeAdminTestLdapGroup(t, th)
|
||||
|
||||
newUser := th.CreateUser()
|
||||
_, appErr := th.App.UpsertGroupMember(g.Id, newUser.Id)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
_, appErr = th.App.GetTeamMember(th.Context, th.BasicTeam.Id, newUser.Id)
|
||||
require.NotNil(t, appErr)
|
||||
|
||||
_, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{
|
||||
AutoAdd: model.NewPointer(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, response)
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
tm, appErr := th.App.GetTeamMember(th.Context, th.BasicTeam.Id, newUser.Id)
|
||||
require.Nil(t, appErr)
|
||||
assert.Equal(t, newUser.Id, tm.UserId)
|
||||
}
|
||||
|
||||
@@ -44,6 +44,10 @@ func doPostAction(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.Err = model.NewAppError("DoPostAction", "api.post.do_action.action_integration.app_error", nil, "", http.StatusBadRequest).Wrap(err)
|
||||
return
|
||||
}
|
||||
if cookie.PostId != c.Params.PostId {
|
||||
c.SetPermissionError(model.PermissionReadChannelContent)
|
||||
return
|
||||
}
|
||||
channel, err := c.App.GetChannel(c.AppContext, cookie.ChannelId)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
|
||||
@@ -349,3 +349,89 @@ func TestSubmitDialog(t *testing.T) {
|
||||
CheckForbiddenStatus(t, resp)
|
||||
assert.Nil(t, submitResp)
|
||||
}
|
||||
|
||||
func newAttachmentActionPostInChannel(t *testing.T, th *TestHelper, channelID, userID, upstreamURL string) (*model.Post, string) {
|
||||
t.Helper()
|
||||
post := &model.Post{
|
||||
Message: "attachment action post",
|
||||
ChannelId: channelID,
|
||||
UserId: userID,
|
||||
Props: model.StringInterface{
|
||||
model.PostPropsAttachments: []*model.SlackAttachment{
|
||||
{
|
||||
Text: "hello",
|
||||
Actions: []*model.PostAction{
|
||||
{
|
||||
Type: model.PostActionTypeButton,
|
||||
Name: "click",
|
||||
Integration: &model.PostActionIntegration{URL: upstreamURL},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
created, _, appErr := th.App.CreatePostAsUser(th.Context, post, "", true)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
withCookies := model.AddPostActionCookies(created, th.App.PostActionCookieSecret())
|
||||
attachments, ok := withCookies.GetProp(model.PostPropsAttachments).([]*model.SlackAttachment)
|
||||
require.True(t, ok)
|
||||
require.NotEmpty(t, attachments)
|
||||
require.NotEmpty(t, attachments[0].Actions)
|
||||
action := attachments[0].Actions[0]
|
||||
require.NotEmpty(t, action.Id)
|
||||
return withCookies, action.Id
|
||||
}
|
||||
|
||||
func TestDoPostActionCookieChannelAuthorization(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1"
|
||||
})
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
privateChannel := th.CreatePrivateChannel()
|
||||
privatePost, privateActionID := newAttachmentActionPostInChannel(t, th, privateChannel.Id, th.BasicUser.Id, ts.URL)
|
||||
|
||||
_, appErr := th.App.AddUserToChannel(th.Context, th.BasicUser2, th.BasicChannel, false)
|
||||
require.Nil(t, appErr)
|
||||
readablePost, _ := newAttachmentActionPostInChannel(t, th, th.BasicChannel.Id, th.BasicUser.Id, ts.URL)
|
||||
readableAttachments, ok := readablePost.GetProp(model.PostPropsAttachments).([]*model.SlackAttachment)
|
||||
require.True(t, ok)
|
||||
readableCookie := readableAttachments[0].Actions[0].Cookie
|
||||
require.NotEmpty(t, readableCookie)
|
||||
|
||||
nonMember := th.CreateClient()
|
||||
th.LoginBasic2WithClient(nonMember)
|
||||
|
||||
t.Run("non-member cannot act on the private post without a cookie", func(t *testing.T) {
|
||||
resp, err := nonMember.DoPostAction(context.Background(), privatePost.Id, privateActionID)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("a cookie from a readable channel cannot authorize a different post", func(t *testing.T) {
|
||||
resp, err := nonMember.DoPostActionWithCookie(context.Background(), privatePost.Id, privateActionID, "", readableCookie)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("a member can still act using the post's own cookie", func(t *testing.T) {
|
||||
legitAttachments, ok := privatePost.GetProp(model.PostPropsAttachments).([]*model.SlackAttachment)
|
||||
require.True(t, ok)
|
||||
legitCookie := legitAttachments[0].Actions[0].Cookie
|
||||
require.NotEmpty(t, legitCookie)
|
||||
|
||||
resp, err := th.Client.DoPostActionWithCookie(context.Background(), privatePost.Id, privateActionID, "", legitCookie)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1065,6 +1065,12 @@ func updatePost(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check edit_file_attachment permission if file IDs are being changed (files added or removed)
|
||||
checkEditFileAttachmentPermission(c, post.FileIds, originalPost)
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if c.AppContext.Session().UserId != originalPost.UserId {
|
||||
// We don't need to check the member here, since we already checked it above
|
||||
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), originalPost.ChannelId, model.PermissionEditOthersPosts); !ok {
|
||||
@@ -1139,6 +1145,11 @@ func patchPost(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
checkEditFileAttachmentPermission(c, *post.FileIds, originalPost)
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
patchedPost, isMemberForPReviews, err := c.App.PatchPost(c.AppContext, c.Params.PostId, c.App.PostPatchWithProxyRemovedFromImageURLs(&post), nil)
|
||||
|
||||
@@ -1900,6 +1900,124 @@ func TestUpdatePost(t *testing.T) {
|
||||
require.Equal(t, int64(0), postFileInfos[0].DeleteAt)
|
||||
})
|
||||
|
||||
t.Run("should prevent adding files when edit_file_attachment permission is revoked", func(t *testing.T) {
|
||||
th.LoginBasic()
|
||||
|
||||
fileResp, _, err := client.UploadFile(context.Background(), data, channel.Id, "test.png")
|
||||
require.NoError(t, err)
|
||||
fileId := fileResp.FileInfos[0].Id
|
||||
|
||||
postWithoutFiles, _, appErr := th.App.CreatePost(th.Context, &model.Post{
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: channel.Id,
|
||||
Message: "Post without files",
|
||||
}, channel, model.CreatePostFlags{SetOnline: true})
|
||||
require.Nil(t, appErr)
|
||||
|
||||
th.RemovePermissionFromRole(model.PermissionEditFileAttachment.Id, model.ChannelUserRoleId)
|
||||
defer th.AddPermissionToRole(model.PermissionEditFileAttachment.Id, model.ChannelUserRoleId)
|
||||
|
||||
updatePost := &model.Post{
|
||||
Id: postWithoutFiles.Id,
|
||||
ChannelId: channel.Id,
|
||||
Message: "Updated post with file",
|
||||
FileIds: model.StringArray{fileId},
|
||||
}
|
||||
_, resp, err := client.UpdatePost(context.Background(), postWithoutFiles.Id, updatePost)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
require.Equal(t, "You do not have the appropriate permissions.", err.Error())
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("should prevent removing files when edit_file_attachment permission is revoked", func(t *testing.T) {
|
||||
th.LoginBasic()
|
||||
|
||||
fileResp, _, err := client.UploadFile(context.Background(), data, channel.Id, "test.png")
|
||||
require.NoError(t, err)
|
||||
fileId := fileResp.FileInfos[0].Id
|
||||
|
||||
postWithFiles, _, appErr := th.App.CreatePost(th.Context, &model.Post{
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: channel.Id,
|
||||
Message: "Post with files",
|
||||
FileIds: model.StringArray{fileId},
|
||||
}, channel, model.CreatePostFlags{SetOnline: true})
|
||||
require.Nil(t, appErr)
|
||||
|
||||
th.RemovePermissionFromRole(model.PermissionEditFileAttachment.Id, model.ChannelUserRoleId)
|
||||
defer th.AddPermissionToRole(model.PermissionEditFileAttachment.Id, model.ChannelUserRoleId)
|
||||
|
||||
updatePost := &model.Post{
|
||||
Id: postWithFiles.Id,
|
||||
ChannelId: channel.Id,
|
||||
Message: "Updated post without file",
|
||||
FileIds: model.StringArray{},
|
||||
}
|
||||
_, resp, err := client.UpdatePost(context.Background(), postWithFiles.Id, updatePost)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
require.Equal(t, "You do not have the appropriate permissions.", err.Error())
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("should allow updating post with unchanged files when edit_file_attachment permission is revoked", func(t *testing.T) {
|
||||
th.LoginBasic()
|
||||
|
||||
fileResp, _, err := client.UploadFile(context.Background(), data, channel.Id, "test.png")
|
||||
require.NoError(t, err)
|
||||
fileId := fileResp.FileInfos[0].Id
|
||||
|
||||
postWithFiles, _, appErr := th.App.CreatePost(th.Context, &model.Post{
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: channel.Id,
|
||||
Message: "Post with files",
|
||||
FileIds: model.StringArray{fileId},
|
||||
}, channel, model.CreatePostFlags{SetOnline: true})
|
||||
require.Nil(t, appErr)
|
||||
|
||||
th.RemovePermissionFromRole(model.PermissionEditFileAttachment.Id, model.ChannelUserRoleId)
|
||||
defer th.AddPermissionToRole(model.PermissionEditFileAttachment.Id, model.ChannelUserRoleId)
|
||||
|
||||
updatePost := &model.Post{
|
||||
Id: postWithFiles.Id,
|
||||
ChannelId: channel.Id,
|
||||
Message: "Updated message only",
|
||||
FileIds: model.StringArray{fileId},
|
||||
}
|
||||
updatedPost, resp, err := client.UpdatePost(context.Background(), postWithFiles.Id, updatePost)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
require.NotNil(t, updatedPost)
|
||||
assert.Equal(t, "Updated message only", updatedPost.Message)
|
||||
})
|
||||
|
||||
t.Run("should allow changing files when edit_file_attachment permission is present", func(t *testing.T) {
|
||||
th.LoginBasic()
|
||||
|
||||
fileResp, _, err := client.UploadFile(context.Background(), data, channel.Id, "test.png")
|
||||
require.NoError(t, err)
|
||||
fileId := fileResp.FileInfos[0].Id
|
||||
|
||||
postWithoutFiles, _, appErr := th.App.CreatePost(th.Context, &model.Post{
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: channel.Id,
|
||||
Message: "Post without files",
|
||||
}, channel, model.CreatePostFlags{SetOnline: true})
|
||||
require.Nil(t, appErr)
|
||||
|
||||
updatePost := &model.Post{
|
||||
Id: postWithoutFiles.Id,
|
||||
ChannelId: channel.Id,
|
||||
Message: "Updated post with file",
|
||||
FileIds: model.StringArray{fileId},
|
||||
}
|
||||
updatedPost, resp, err := client.UpdatePost(context.Background(), postWithoutFiles.Id, updatePost)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
require.NotNil(t, updatedPost)
|
||||
})
|
||||
|
||||
t.Run("should be able to add and remove files simultaneously", func(t *testing.T) {
|
||||
th.LoginBasic()
|
||||
// create new file
|
||||
@@ -2159,6 +2277,129 @@ func TestPatchPost(t *testing.T) {
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("should prevent patching file ids when edit_file_attachment permission is revoked", func(t *testing.T) {
|
||||
th.LoginBasic()
|
||||
|
||||
fileResp, _, err := client.UploadFile(context.Background(), data, channel.Id, "test.png")
|
||||
require.NoError(t, err)
|
||||
fileId := fileResp.FileInfos[0].Id
|
||||
|
||||
postToEdit, _, err := client.CreatePost(context.Background(), &model.Post{
|
||||
ChannelId: channel.Id,
|
||||
Message: "original message",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
th.RemovePermissionFromRole(model.PermissionEditFileAttachment.Id, model.ChannelUserRoleId)
|
||||
defer th.AddPermissionToRole(model.PermissionEditFileAttachment.Id, model.ChannelUserRoleId)
|
||||
|
||||
patch := &model.PostPatch{
|
||||
FileIds: &model.StringArray{fileId},
|
||||
}
|
||||
_, resp, err := client.PatchPost(context.Background(), postToEdit.Id, patch)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("should prevent removing files via patch when edit_file_attachment permission is revoked", func(t *testing.T) {
|
||||
th.LoginBasic()
|
||||
|
||||
fileResp, _, err := client.UploadFile(context.Background(), data, channel.Id, "test.png")
|
||||
require.NoError(t, err)
|
||||
fileId := fileResp.FileInfos[0].Id
|
||||
|
||||
postToEdit, _, err := client.CreatePost(context.Background(), &model.Post{
|
||||
ChannelId: channel.Id,
|
||||
Message: "post with file",
|
||||
FileIds: model.StringArray{fileId},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
th.RemovePermissionFromRole(model.PermissionEditFileAttachment.Id, model.ChannelUserRoleId)
|
||||
defer th.AddPermissionToRole(model.PermissionEditFileAttachment.Id, model.ChannelUserRoleId)
|
||||
|
||||
emptyFileIds := model.StringArray{}
|
||||
patch := &model.PostPatch{
|
||||
FileIds: &emptyFileIds,
|
||||
}
|
||||
_, resp, err := client.PatchPost(context.Background(), postToEdit.Id, patch)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("should allow patching message without file change when edit_file_attachment permission is revoked", func(t *testing.T) {
|
||||
th.LoginBasic()
|
||||
|
||||
fileResp, _, err := client.UploadFile(context.Background(), data, channel.Id, "test.png")
|
||||
require.NoError(t, err)
|
||||
fileId := fileResp.FileInfos[0].Id
|
||||
|
||||
postToEdit, _, err := client.CreatePost(context.Background(), &model.Post{
|
||||
ChannelId: channel.Id,
|
||||
Message: "original message",
|
||||
FileIds: model.StringArray{fileId},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
th.RemovePermissionFromRole(model.PermissionEditFileAttachment.Id, model.ChannelUserRoleId)
|
||||
defer th.AddPermissionToRole(model.PermissionEditFileAttachment.Id, model.ChannelUserRoleId)
|
||||
|
||||
patch := &model.PostPatch{
|
||||
Message: model.NewPointer("updated message only"),
|
||||
}
|
||||
patchedPost, _, err := client.PatchPost(context.Background(), postToEdit.Id, patch)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "updated message only", patchedPost.Message)
|
||||
})
|
||||
|
||||
t.Run("should allow patching with same file ids when edit_file_attachment permission is revoked", func(t *testing.T) {
|
||||
th.LoginBasic()
|
||||
|
||||
fileResp, _, err := client.UploadFile(context.Background(), data, channel.Id, "test.png")
|
||||
require.NoError(t, err)
|
||||
fileId := fileResp.FileInfos[0].Id
|
||||
|
||||
postToEdit, _, err := client.CreatePost(context.Background(), &model.Post{
|
||||
ChannelId: channel.Id,
|
||||
Message: "original message",
|
||||
FileIds: model.StringArray{fileId},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
th.RemovePermissionFromRole(model.PermissionEditFileAttachment.Id, model.ChannelUserRoleId)
|
||||
defer th.AddPermissionToRole(model.PermissionEditFileAttachment.Id, model.ChannelUserRoleId)
|
||||
|
||||
sameFileIds := model.StringArray{fileId}
|
||||
patch := &model.PostPatch{
|
||||
Message: model.NewPointer("updated message"),
|
||||
FileIds: &sameFileIds,
|
||||
}
|
||||
patchedPost, _, err := client.PatchPost(context.Background(), postToEdit.Id, patch)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "updated message", patchedPost.Message)
|
||||
})
|
||||
|
||||
t.Run("should allow patching files when edit_file_attachment permission is present", func(t *testing.T) {
|
||||
th.LoginBasic()
|
||||
|
||||
fileResp, _, err := client.UploadFile(context.Background(), data, channel.Id, "test.png")
|
||||
require.NoError(t, err)
|
||||
fileId := fileResp.FileInfos[0].Id
|
||||
|
||||
postToEdit, _, err := client.CreatePost(context.Background(), &model.Post{
|
||||
ChannelId: channel.Id,
|
||||
Message: "original message",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
patch := &model.PostPatch{
|
||||
FileIds: &model.StringArray{fileId},
|
||||
}
|
||||
patchedPost, _, err := client.PatchPost(context.Background(), postToEdit.Id, patch)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, patchedPost)
|
||||
})
|
||||
|
||||
t.Run("time limit expired", func(t *testing.T) {
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.ServiceSettings.PostEditTimeLimit = 1
|
||||
|
||||
@@ -6,6 +6,7 @@ package api4
|
||||
import (
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/app"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/utils"
|
||||
)
|
||||
|
||||
func userCreatePostPermissionCheckWithContext(c *Context, channelId string) {
|
||||
@@ -69,3 +70,14 @@ func checkUploadFilePermissionForNewFiles(c *Context, newFileIds []string, origi
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkEditFileAttachmentPermission checks edit_file_attachment permission
|
||||
// when file IDs are being changed (files added or removed) during post edit.
|
||||
func checkEditFileAttachmentPermission(c *Context, newFileIds []string, originalPost *model.Post) {
|
||||
if utils.SliceEqualUnordered(newFileIds, originalPost.FileIds) {
|
||||
return
|
||||
}
|
||||
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), originalPost.ChannelId, model.PermissionEditFileAttachment); !ok {
|
||||
c.SetPermissionError(model.PermissionEditFileAttachment)
|
||||
}
|
||||
}
|
||||
|
||||
94
server/channels/api4/post_utils_test.go
Обычный файл
94
server/channels/api4/post_utils_test.go
Обычный файл
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api4
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/v8/channels/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSameFileIDs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a []string
|
||||
b []string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "both empty",
|
||||
a: []string{},
|
||||
b: []string{},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "both nil",
|
||||
a: nil,
|
||||
b: nil,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "same files same order",
|
||||
a: []string{"file1", "file2", "file3"},
|
||||
b: []string{"file1", "file2", "file3"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "same files different order",
|
||||
a: []string{"file3", "file1", "file2"},
|
||||
b: []string{"file1", "file2", "file3"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "one file added",
|
||||
a: []string{"file1", "file2", "file3"},
|
||||
b: []string{"file1", "file2"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "one file removed",
|
||||
a: []string{"file1"},
|
||||
b: []string{"file1", "file2"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "different files same length",
|
||||
a: []string{"file1", "file2"},
|
||||
b: []string{"file1", "file3"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "duplicate IDs in a",
|
||||
a: []string{"file1", "file1"},
|
||||
b: []string{"file1", "file2"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "duplicate IDs same in both",
|
||||
a: []string{"file1", "file1"},
|
||||
b: []string{"file1", "file1"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "empty vs non-empty",
|
||||
a: []string{},
|
||||
b: []string{"file1"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "nil vs non-empty",
|
||||
a: nil,
|
||||
b: []string{"file1"},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := utils.SliceEqualUnordered(tc.a, tc.b)
|
||||
assert.Equal(t, tc.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -655,6 +655,8 @@ func patchRemoteCluster(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
updatedRC.Sanitize()
|
||||
|
||||
auditRec.Success()
|
||||
auditRec.AddEventResultState(updatedRC)
|
||||
|
||||
|
||||
@@ -488,9 +488,10 @@ func TestGenerateRemoteClusterInvite(t *testing.T) {
|
||||
func TestGetRemoteCluster(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
newRC := &model.RemoteCluster{
|
||||
Name: "remotecluster",
|
||||
SiteURL: "http://example.com",
|
||||
Token: model.NewId(),
|
||||
Name: "remotecluster",
|
||||
SiteURL: "http://example.com",
|
||||
Token: model.NewId(),
|
||||
RemoteToken: model.NewId(),
|
||||
}
|
||||
|
||||
t.Run("Should not work if the remote cluster service is not enabled", func(t *testing.T) {
|
||||
@@ -541,6 +542,7 @@ func TestGetRemoteCluster(t *testing.T) {
|
||||
require.Equal(t, rc.RemoteId, fetchedRC.RemoteId)
|
||||
require.Equal(t, th.BasicTeam.Id, fetchedRC.DefaultTeamId)
|
||||
require.Empty(t, fetchedRC.Token)
|
||||
require.Empty(t, fetchedRC.RemoteToken)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -551,6 +553,7 @@ func TestPatchRemoteCluster(t *testing.T) {
|
||||
DisplayName: "initialvalue",
|
||||
SiteURL: "http://example.com",
|
||||
Token: model.NewId(),
|
||||
RemoteToken: model.NewId(),
|
||||
}
|
||||
|
||||
rcp := &model.RemoteClusterPatch{DisplayName: model.NewPointer("different value")}
|
||||
@@ -606,6 +609,8 @@ func TestPatchRemoteCluster(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "patched!", patchedRC.DisplayName)
|
||||
require.Equal(t, newTeamId, patchedRC.DefaultTeamId)
|
||||
require.Empty(t, patchedRC.Token)
|
||||
require.Empty(t, patchedRC.RemoteToken)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -151,9 +151,9 @@ func patchRole(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
auditRec.AddEventPriorState(oldRole)
|
||||
auditRec.AddEventObjectType("role")
|
||||
|
||||
// manage_system permission is required to patch system_admin
|
||||
// manage_system permission is required to patch system_admin and other protected system roles.
|
||||
requiredPermission := model.PermissionSysconsoleWriteUserManagementPermissions
|
||||
specialProtectedSystemRoles := append(model.NewSystemRoleIDs, model.SystemAdminRoleId)
|
||||
specialProtectedSystemRoles := append(append([]string{}, model.NewSystemRoleIDs...), model.SystemAdminRoleId, model.SystemUserRoleId, model.SystemGuestRoleId)
|
||||
for _, roleID := range specialProtectedSystemRoles {
|
||||
if oldRole.Name == roleID {
|
||||
requiredPermission = model.PermissionManageSystem
|
||||
|
||||
@@ -330,6 +330,63 @@ func TestPatchRole(t *testing.T) {
|
||||
Permissions: &[]string{"create_direct_channel", "manage_incoming_webhooks", "manage_outgoing_webhooks"},
|
||||
}
|
||||
|
||||
t.Run("system manager cannot patch system_user", func(t *testing.T) {
|
||||
systemUserRole, appErr := th.App.GetRoleByName(context.Background(), model.SystemUserRoleId)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
originalPermissions := append([]string{}, systemUserRole.Permissions...)
|
||||
require.NotContains(t, originalPermissions, model.PermissionEditOtherUsers.Id)
|
||||
|
||||
patchedPermissions := append([]string{}, originalPermissions...)
|
||||
patchedPermissions = append(patchedPermissions, model.PermissionEditOtherUsers.Id)
|
||||
|
||||
th.LoginSystemManager()
|
||||
|
||||
_, systemUserResp, err := th.SystemManagerClient.PatchRole(context.Background(), systemUserRole.Id, &model.RolePatch{
|
||||
Permissions: &patchedPermissions,
|
||||
})
|
||||
if assert.Error(t, err, "system_manager must not be able to patch system_user") {
|
||||
CheckForbiddenStatus(t, systemUserResp)
|
||||
}
|
||||
|
||||
systemUserRole, appErr = th.App.GetRoleByName(context.Background(), model.SystemUserRoleId)
|
||||
require.Nil(t, appErr)
|
||||
assert.ElementsMatch(t, originalPermissions, systemUserRole.Permissions)
|
||||
assert.NotContains(t, systemUserRole.Permissions, model.PermissionEditOtherUsers.Id, "system_manager must not be able to inject privileged permissions into system_user")
|
||||
})
|
||||
|
||||
t.Run("system manager cannot patch system_guest", func(t *testing.T) {
|
||||
license := model.NewTestLicense()
|
||||
license.Features.GuestAccountsPermissions = model.NewPointer(true)
|
||||
th.App.Srv().SetLicense(license)
|
||||
t.Cleanup(func() {
|
||||
th.App.Srv().SetLicense(nil)
|
||||
})
|
||||
|
||||
systemGuestRole, appErr := th.App.GetRoleByName(context.Background(), model.SystemGuestRoleId)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
originalPermissions := append([]string{}, systemGuestRole.Permissions...)
|
||||
require.NotContains(t, originalPermissions, model.PermissionEditOtherUsers.Id)
|
||||
|
||||
patchedPermissions := append([]string{}, originalPermissions...)
|
||||
patchedPermissions = append(patchedPermissions, model.PermissionEditOtherUsers.Id)
|
||||
|
||||
th.LoginSystemManager()
|
||||
|
||||
_, systemGuestResp, err := th.SystemManagerClient.PatchRole(context.Background(), systemGuestRole.Id, &model.RolePatch{
|
||||
Permissions: &patchedPermissions,
|
||||
})
|
||||
if assert.Error(t, err, "system_manager must not be able to patch system_guest") {
|
||||
CheckForbiddenStatus(t, systemGuestResp)
|
||||
}
|
||||
|
||||
systemGuestRole, appErr = th.App.GetRoleByName(context.Background(), model.SystemGuestRoleId)
|
||||
require.Nil(t, appErr)
|
||||
assert.ElementsMatch(t, originalPermissions, systemGuestRole.Permissions)
|
||||
assert.NotContains(t, systemGuestRole.Permissions, model.PermissionEditOtherUsers.Id, "system_manager must not be able to inject privileged permissions into system_guest")
|
||||
})
|
||||
|
||||
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
|
||||
received, _, err := client.PatchRole(context.Background(), role.Id, patch)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -137,6 +137,8 @@ func getTeamsForScheme(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
c.App.SanitizeTeams(*c.AppContext.Session(), teams)
|
||||
|
||||
js, err := json.Marshal(teams)
|
||||
if err != nil {
|
||||
c.Err = model.NewAppError("getTeamsForScheme", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
|
||||
@@ -428,6 +428,65 @@ func TestGetTeamsForScheme(t *testing.T) {
|
||||
CheckNotImplementedStatus(t, ri6)
|
||||
}
|
||||
|
||||
func TestGetTeamsForScheme_SanitizesPrivilegedFieldsForUserManager(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t)
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicense("custom_permissions_schemes"))
|
||||
|
||||
err := th.App.SetPhase2PermissionsMigrationStatus(true)
|
||||
require.NoError(t, err)
|
||||
|
||||
scheme := &model.Scheme{
|
||||
DisplayName: model.NewId(),
|
||||
Name: model.NewId(),
|
||||
Description: model.NewId(),
|
||||
Scope: model.SchemeScopeTeam,
|
||||
}
|
||||
scheme, _, err = th.SystemAdminClient.CreateScheme(context.Background(), scheme)
|
||||
require.NoError(t, err)
|
||||
|
||||
knownInviteID := model.NewId()
|
||||
knownEmail := th.GenerateTestEmail()
|
||||
|
||||
privateTeam := &model.Team{
|
||||
Name: GenerateTestTeamName(),
|
||||
DisplayName: "Private Scheme Team",
|
||||
Type: model.TeamInvite,
|
||||
InviteId: knownInviteID,
|
||||
Email: knownEmail,
|
||||
}
|
||||
privateTeam, err = th.App.Srv().Store().Team().Save(privateTeam)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, knownInviteID, privateTeam.InviteId)
|
||||
require.Equal(t, knownEmail, privateTeam.Email)
|
||||
|
||||
privateTeam.SchemeId = &scheme.Id
|
||||
privateTeam, err = th.App.Srv().Store().Team().Update(privateTeam)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, knownInviteID, privateTeam.InviteId)
|
||||
require.Equal(t, knownEmail, privateTeam.Email)
|
||||
|
||||
th.LoginSystemManager()
|
||||
|
||||
t.Run("system manager response is sanitized", func(t *testing.T) {
|
||||
teams, _, err := th.SystemManagerClient.GetTeamsForScheme(context.Background(), scheme.Id, 0, 100)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, teams, 1)
|
||||
assert.Equal(t, privateTeam.Id, teams[0].Id)
|
||||
assert.Empty(t, teams[0].InviteId)
|
||||
})
|
||||
|
||||
t.Run("system admin response is not sanitized", func(t *testing.T) {
|
||||
teams, _, err := th.SystemAdminClient.GetTeamsForScheme(context.Background(), scheme.Id, 0, 100)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, teams, 1)
|
||||
assert.Equal(t, privateTeam.Id, teams[0].Id)
|
||||
assert.Equal(t, knownInviteID, teams[0].InviteId)
|
||||
assert.Equal(t, knownEmail, teams[0].Email)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetChannelsForScheme(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
|
||||
@@ -120,17 +120,20 @@ func createTeam(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Setting AllowOpenInvite or AllowedDomains requires PermissionInviteUser, matching updateTeam/patchTeam.
|
||||
if (team.AllowOpenInvite || team.AllowedDomains != "") && !creatorCanInviteUsersOnTeam(c, &team) {
|
||||
c.SetPermissionError(model.PermissionInviteUser)
|
||||
return
|
||||
}
|
||||
|
||||
rteam, err := c.App.CreateTeamWithUser(c.AppContext, &team, c.AppContext.Session().UserId)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
// Don't sanitize the team here since the user will be a team admin and their session won't reflect that yet
|
||||
// instead check the scheme roles for the team and if the user has the permission to invite users
|
||||
_, schemeUserRole, schemeAdminRole, schemeErr := c.App.GetSchemeRolesForTeam(rteam.Id)
|
||||
if schemeErr != nil || !c.App.RolesGrantPermission([]string{schemeUserRole, schemeAdminRole}, model.PermissionInviteUser.Id) {
|
||||
// If we can't check permissions, fail secure by hiding the invite_id because the team is already created above
|
||||
// The creator's session doesn't yet reflect their team_admin role, so check the team's default roles directly.
|
||||
if !creatorCanInviteUsersOnTeam(c, rteam) {
|
||||
rteam.InviteId = ""
|
||||
}
|
||||
|
||||
@@ -144,6 +147,29 @@ func createTeam(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// creatorCanInviteUsersOnTeam checks whether the creator will have PermissionInviteUser on the new team,
|
||||
// using the team's scheme (if any) or the built-in team roles as defaults.
|
||||
func creatorCanInviteUsersOnTeam(c *Context, team *model.Team) bool {
|
||||
if c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionInviteUser) {
|
||||
return true
|
||||
}
|
||||
|
||||
if team.SchemeId != nil && *team.SchemeId != "" {
|
||||
scheme, appErr := c.App.GetScheme(*team.SchemeId)
|
||||
if appErr != nil {
|
||||
c.Logger.Warn("Failed to fetch scheme while checking invite permission for new team",
|
||||
mlog.String("scheme_id", *team.SchemeId),
|
||||
mlog.Err(appErr),
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
return c.App.RolesGrantPermission([]string{scheme.DefaultTeamUserRole, scheme.DefaultTeamAdminRole}, model.PermissionInviteUser.Id)
|
||||
}
|
||||
|
||||
return c.App.RolesGrantPermission([]string{model.TeamUserRoleId, model.TeamAdminRoleId}, model.PermissionInviteUser.Id)
|
||||
}
|
||||
|
||||
func getTeam(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireTeamId()
|
||||
if c.Err != nil {
|
||||
|
||||
@@ -242,23 +242,216 @@ func TestCreateTeamInviteIdHiddenWithoutInvitePermission(t *testing.T) {
|
||||
defaultRolePermissions := th.SaveDefaultRolePermissions()
|
||||
defer th.RestoreDefaultRolePermissions(defaultRolePermissions)
|
||||
|
||||
// Remove PermissionInviteUser from the default team user role
|
||||
// team_admin inherits from team_user by default, so removing from team_user is enough.
|
||||
th.RemovePermissionFromRole(model.PermissionInviteUser.Id, model.TeamUserRoleId)
|
||||
|
||||
// Regular user creates a team - InviteId should be hidden
|
||||
// since the team user role lacks invite permission
|
||||
rteam, _, err := th.Client.CreateTeam(context.Background(), &model.Team{
|
||||
DisplayName: "Team Without Invite Permission",
|
||||
Name: GenerateTestTeamName(),
|
||||
Email: th.GenerateTestEmail(),
|
||||
Type: model.TeamOpen,
|
||||
AllowedDomains: "simulator.amazonses.com,localhost",
|
||||
DisplayName: "Team Without Invite Permission",
|
||||
Name: GenerateTestTeamName(),
|
||||
Email: th.GenerateTestEmail(),
|
||||
Type: model.TeamOpen,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, rteam.Email, "should not have sanitized email")
|
||||
require.Empty(t, rteam.InviteId, "should have hidden invite_id when user lacks invite permission")
|
||||
}
|
||||
|
||||
func TestCreateTeamInviteUserPermission(t *testing.T) {
|
||||
th := Setup(t)
|
||||
|
||||
defaultRolePermissions := th.SaveDefaultRolePermissions()
|
||||
defer th.RestoreDefaultRolePermissions(defaultRolePermissions)
|
||||
|
||||
th.RemovePermissionFromRole(model.PermissionInviteUser.Id, model.TeamUserRoleId)
|
||||
th.RemovePermissionFromRole(model.PermissionInviteUser.Id, model.TeamAdminRoleId)
|
||||
|
||||
t.Run("AllowOpenInvite=true is rejected with 403", func(t *testing.T) {
|
||||
_, resp, err := th.Client.CreateTeam(context.Background(), &model.Team{
|
||||
DisplayName: "Open Invite Team Without Permission",
|
||||
Name: GenerateTestTeamName(),
|
||||
Email: th.GenerateTestEmail(),
|
||||
Type: model.TeamOpen,
|
||||
AllowOpenInvite: true,
|
||||
})
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("non-empty AllowedDomains is rejected with 403", func(t *testing.T) {
|
||||
creatorDomain := strings.SplitN(th.BasicUser.Email, "@", 2)[1]
|
||||
|
||||
_, resp, err := th.Client.CreateTeam(context.Background(), &model.Team{
|
||||
DisplayName: "Restricted Domains Team Without Permission",
|
||||
Name: GenerateTestTeamName(),
|
||||
Email: th.GenerateTestEmail(),
|
||||
Type: model.TeamOpen,
|
||||
AllowedDomains: creatorDomain,
|
||||
})
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("team without invite-restricted fields is still created", func(t *testing.T) {
|
||||
createdTeam, resp, err := th.Client.CreateTeam(context.Background(), &model.Team{
|
||||
DisplayName: "Plain Team Without Invite Permission",
|
||||
Name: GenerateTestTeamName(),
|
||||
Email: th.GenerateTestEmail(),
|
||||
Type: model.TeamOpen,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, resp)
|
||||
|
||||
assert.False(t, createdTeam.AllowOpenInvite)
|
||||
assert.Empty(t, createdTeam.AllowedDomains)
|
||||
assert.Empty(t, createdTeam.InviteId, "InviteId should be hidden from creators that can't invite users")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateTeamInviteUserPermissionSystemAdmin(t *testing.T) {
|
||||
th := Setup(t)
|
||||
creatorDomain := strings.SplitN(th.SystemAdminUser.Email, "@", 2)[1]
|
||||
|
||||
defaultRolePermissions := th.SaveDefaultRolePermissions()
|
||||
defer th.RestoreDefaultRolePermissions(defaultRolePermissions)
|
||||
|
||||
th.RemovePermissionFromRole(model.PermissionInviteUser.Id, model.TeamUserRoleId)
|
||||
th.RemovePermissionFromRole(model.PermissionInviteUser.Id, model.TeamAdminRoleId)
|
||||
|
||||
createdTeam, resp, err := th.SystemAdminClient.CreateTeam(context.Background(), &model.Team{
|
||||
DisplayName: "System Admin Team With Invite Permission",
|
||||
Name: GenerateTestTeamName(),
|
||||
Email: th.GenerateTestEmail(),
|
||||
Type: model.TeamOpen,
|
||||
AllowOpenInvite: true,
|
||||
AllowedDomains: creatorDomain,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, resp)
|
||||
|
||||
assert.True(t, createdTeam.AllowOpenInvite, "system admins should still be able to create open invite teams")
|
||||
assert.Equal(t, creatorDomain, createdTeam.AllowedDomains, "system admins should still be able to set allowed domains")
|
||||
require.NotEmpty(t, createdTeam.InviteId, "system admins should receive the invite_id when they can invite users")
|
||||
|
||||
persistedTeam, _, err := th.SystemAdminClient.GetTeam(context.Background(), createdTeam.Id, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, persistedTeam.AllowOpenInvite, "system admins should persist open invite team settings")
|
||||
assert.Equal(t, creatorDomain, persistedTeam.AllowedDomains, "system admins should persist allowed domains")
|
||||
}
|
||||
|
||||
// Exercises the scheme branch of creatorCanInviteUsersOnTeam.
|
||||
func TestCreateTeamInviteUserPermissionScheme(t *testing.T) {
|
||||
th := Setup(t)
|
||||
th.App.Srv().SetLicense(model.NewTestLicense("custom_permissions_schemes"))
|
||||
err := th.App.SetPhase2PermissionsMigrationStatus(true)
|
||||
require.NoError(t, err)
|
||||
|
||||
defaultRolePermissions := th.SaveDefaultRolePermissions()
|
||||
defer th.RestoreDefaultRolePermissions(defaultRolePermissions)
|
||||
|
||||
// Remove InviteUser from the built-in team roles; new schemes inherit from these at creation, so their defaults start without it too.
|
||||
th.RemovePermissionFromRole(model.PermissionInviteUser.Id, model.TeamUserRoleId)
|
||||
th.RemovePermissionFromRole(model.PermissionInviteUser.Id, model.TeamAdminRoleId)
|
||||
|
||||
// SystemManager has SchemeWrite but not system-level InviteUser, so it exercises the scheme branch.
|
||||
th.LoginSystemManager()
|
||||
managerClient := th.SystemManagerClient
|
||||
|
||||
t.Run("scheme admin role grants InviteUser - create succeeds", func(t *testing.T) {
|
||||
scheme, _, err := th.SystemAdminClient.CreateScheme(context.Background(), &model.Scheme{
|
||||
DisplayName: "dn_" + model.NewId(),
|
||||
Name: model.NewId(),
|
||||
Scope: model.SchemeScopeTeam,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, scheme.DefaultTeamAdminRole)
|
||||
require.NotEmpty(t, scheme.DefaultTeamUserRole)
|
||||
|
||||
th.AddPermissionToRole(model.PermissionInviteUser.Id, scheme.DefaultTeamAdminRole)
|
||||
|
||||
rteam, resp, err := managerClient.CreateTeam(context.Background(), &model.Team{
|
||||
DisplayName: "Scheme Team With Invite " + model.NewId(),
|
||||
Name: GenerateTestTeamName(),
|
||||
Email: th.GenerateTestEmail(),
|
||||
Type: model.TeamOpen,
|
||||
SchemeId: &scheme.Id,
|
||||
AllowOpenInvite: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, resp)
|
||||
|
||||
assert.True(t, rteam.AllowOpenInvite, "AllowOpenInvite should be preserved when scheme grants InviteUser")
|
||||
assert.NotEmpty(t, rteam.InviteId, "InviteId should be returned when scheme grants InviteUser")
|
||||
})
|
||||
|
||||
t.Run("scheme roles do not grant InviteUser - create is rejected", func(t *testing.T) {
|
||||
scheme, _, err := th.SystemAdminClient.CreateScheme(context.Background(), &model.Scheme{
|
||||
DisplayName: "dn_" + model.NewId(),
|
||||
Name: model.NewId(),
|
||||
Scope: model.SchemeScopeTeam,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, resp, err := managerClient.CreateTeam(context.Background(), &model.Team{
|
||||
DisplayName: "Scheme Team Without Invite " + model.NewId(),
|
||||
Name: GenerateTestTeamName(),
|
||||
Email: th.GenerateTestEmail(),
|
||||
Type: model.TeamOpen,
|
||||
SchemeId: &scheme.Id,
|
||||
AllowOpenInvite: true,
|
||||
})
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("scheme roles do not grant InviteUser but no invite-restricted fields - create succeeds with hidden invite_id", func(t *testing.T) {
|
||||
scheme, _, err := th.SystemAdminClient.CreateScheme(context.Background(), &model.Scheme{
|
||||
DisplayName: "dn_" + model.NewId(),
|
||||
Name: model.NewId(),
|
||||
Scope: model.SchemeScopeTeam,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
rteam, resp, err := managerClient.CreateTeam(context.Background(), &model.Team{
|
||||
DisplayName: "Scheme Team Without Invite Fields " + model.NewId(),
|
||||
Name: GenerateTestTeamName(),
|
||||
Email: th.GenerateTestEmail(),
|
||||
Type: model.TeamOpen,
|
||||
SchemeId: &scheme.Id,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, resp)
|
||||
|
||||
assert.Empty(t, rteam.InviteId, "InviteId should be hidden when scheme does not grant InviteUser")
|
||||
})
|
||||
|
||||
t.Run("scheme admin role grants InviteUser but no invite-restricted fields - create succeeds with invite_id", func(t *testing.T) {
|
||||
scheme, _, err := th.SystemAdminClient.CreateScheme(context.Background(), &model.Scheme{
|
||||
DisplayName: "dn_" + model.NewId(),
|
||||
Name: model.NewId(),
|
||||
Scope: model.SchemeScopeTeam,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, scheme.DefaultTeamAdminRole)
|
||||
|
||||
th.AddPermissionToRole(model.PermissionInviteUser.Id, scheme.DefaultTeamAdminRole)
|
||||
|
||||
rteam, resp, err := managerClient.CreateTeam(context.Background(), &model.Team{
|
||||
DisplayName: "Scheme Team Invite Via Defaults Only " + model.NewId(),
|
||||
Name: GenerateTestTeamName(),
|
||||
Email: th.GenerateTestEmail(),
|
||||
Type: model.TeamOpen,
|
||||
SchemeId: &scheme.Id,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, resp)
|
||||
|
||||
assert.False(t, rteam.AllowOpenInvite)
|
||||
assert.Empty(t, rteam.AllowedDomains)
|
||||
assert.NotEmpty(t, rteam.InviteId, "InviteId should be returned when scheme grants InviteUser without open invite or domain restrictions")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetTeam(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
|
||||
@@ -1676,6 +1676,13 @@ func updateUserActive(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if user.IsBot {
|
||||
if permErr := c.App.SessionHasPermissionToManageBot(c.AppContext, *c.AppContext.Session(), c.Params.UserId); permErr != nil {
|
||||
c.Err = permErr
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if active && user.IsGuest() && !*c.App.Config().GuestAccountsSettings.Enable {
|
||||
c.Err = model.NewAppError("updateUserActive", "api.user.update_active.cannot_enable_guest_when_guest_feature_is_disabled.app_error", nil, "userId="+c.Params.UserId, http.StatusUnauthorized)
|
||||
return
|
||||
|
||||
@@ -2982,6 +2982,84 @@ func TestUpdateUserActive(t *testing.T) {
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("user manager without bot permissions cannot deactivate bot accounts", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.ServiceSettings.EnableBotAccountCreation = true
|
||||
})
|
||||
|
||||
bot, botResp, err := th.SystemAdminClient.CreateBot(context.Background(), &model.Bot{
|
||||
Username: GenerateTestUsername(),
|
||||
DisplayName: "Test Bot",
|
||||
Description: "bot for permission test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, botResp)
|
||||
defer func() {
|
||||
appErr := th.App.PermanentDeleteBot(th.Context, bot.UserId)
|
||||
assert.Nil(t, appErr)
|
||||
}()
|
||||
|
||||
// Give BasicUser the User Manager permission to edit users, but no bot permissions.
|
||||
th.AddPermissionToRole(model.PermissionSysconsoleWriteUserManagementUsers.Id, model.SystemUserRoleId)
|
||||
defer th.RemovePermissionFromRole(model.PermissionSysconsoleWriteUserManagementUsers.Id, model.SystemUserRoleId)
|
||||
|
||||
th.LoginBasic()
|
||||
|
||||
// A User Manager without bot permissions must be blocked.
|
||||
// Because the caller has neither PermissionReadOthersBots nor
|
||||
// PermissionManageOthersBots, SessionHasPermissionToManageBot always
|
||||
// returns 404 to avoid leaking the bot's existence.
|
||||
resp, err := th.Client.UpdateUserActive(context.Background(), bot.UserId, false)
|
||||
require.Error(t, err)
|
||||
CheckNotFoundStatus(t, resp)
|
||||
|
||||
// Confirm the bot is still active.
|
||||
botUser, _, err := th.SystemAdminClient.GetUser(context.Background(), bot.UserId, "")
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, botUser.DeleteAt, "bot should still be active")
|
||||
})
|
||||
|
||||
t.Run("user with bot management permissions can deactivate bot accounts via user active endpoint", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.ServiceSettings.EnableBotAccountCreation = true
|
||||
})
|
||||
|
||||
bot, botResp, err := th.SystemAdminClient.CreateBot(context.Background(), &model.Bot{
|
||||
Username: GenerateTestUsername(),
|
||||
DisplayName: "Test Bot",
|
||||
Description: "bot for permission test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, botResp)
|
||||
defer func() {
|
||||
appErr := th.App.PermanentDeleteBot(th.Context, bot.UserId)
|
||||
assert.Nil(t, appErr)
|
||||
}()
|
||||
|
||||
// Assign both user-management and bot-management permissions to BasicUser.
|
||||
th.AddPermissionToRole(model.PermissionSysconsoleWriteUserManagementUsers.Id, model.SystemUserRoleId)
|
||||
defer th.RemovePermissionFromRole(model.PermissionSysconsoleWriteUserManagementUsers.Id, model.SystemUserRoleId)
|
||||
th.AddPermissionToRole(model.PermissionManageOthersBots.Id, model.SystemUserRoleId)
|
||||
defer th.RemovePermissionFromRole(model.PermissionManageOthersBots.Id, model.SystemUserRoleId)
|
||||
|
||||
th.LoginBasic()
|
||||
|
||||
// A user with ManageOthersBots should be allowed.
|
||||
_, err = th.Client.UpdateUserActive(context.Background(), bot.UserId, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Confirm the bot is now inactive.
|
||||
botUser, _, err := th.SystemAdminClient.GetUser(context.Background(), bot.UserId, "")
|
||||
require.NoError(t, err)
|
||||
require.True(t, botUser.DeleteAt > 0, "bot should be inactive after deactivation")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetUsers(t *testing.T) {
|
||||
@@ -6595,6 +6673,34 @@ func TestDemoteUserToGuest(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("cannot demote bot account", func(t *testing.T) {
|
||||
th.App.Srv().SetLicense(model.NewTestLicense("guest_accounts"))
|
||||
|
||||
prevBotCreation := *th.App.Config().ServiceSettings.EnableBotAccountCreation
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.ServiceSettings.EnableBotAccountCreation = true
|
||||
})
|
||||
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.ServiceSettings.EnableBotAccountCreation = prevBotCreation
|
||||
})
|
||||
|
||||
createdBot, resp, err := th.SystemAdminClient.CreateBot(context.Background(), &model.Bot{
|
||||
Username: "botdemote" + model.NewId(),
|
||||
DisplayName: "Demote Test Bot",
|
||||
Description: "test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, resp)
|
||||
defer func() {
|
||||
appErr := th.App.PermanentDeleteBot(th.Context, createdBot.UserId)
|
||||
require.Nil(t, appErr)
|
||||
}()
|
||||
|
||||
demoteResp, err := th.SystemAdminClient.DemoteUserToGuest(context.Background(), createdBot.UserId)
|
||||
CheckBadRequestStatus(t, demoteResp)
|
||||
CheckErrorID(t, err, "api.user.demote_user_to_guest.bot_not_allowed.app_error")
|
||||
})
|
||||
|
||||
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
|
||||
_, _, err := c.GetUser(context.Background(), user.Id, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -64,11 +64,18 @@ func createIncomingHook(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = c.App.GetUser(hook.UserId); err != nil {
|
||||
var hookUser *model.User
|
||||
if hookUser, err = c.App.GetUser(hook.UserId); err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
if appErr := c.App.ValidateIncomingWebhookUser(c.AppContext, *c.AppContext.Session(), hookUser, channel); appErr != nil {
|
||||
c.LogAudit("fail - invalid webhook user")
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
userId = hook.UserId
|
||||
}
|
||||
|
||||
@@ -162,6 +169,15 @@ func updateIncomingHook(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Moving the hook must not attribute its owner's posts to a channel they cannot access.
|
||||
if updatedHook.ChannelId != oldHook.ChannelId {
|
||||
if appErr := c.App.ValidateIncomingWebhookUserChannelAccess(c.AppContext, oldHook.UserId, channel); appErr != nil {
|
||||
c.LogAudit("fail - invalid webhook user")
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
incomingHook, err := c.App.UpdateIncomingWebhook(oldHook, &updatedHook)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
|
||||
@@ -146,6 +146,76 @@ func TestCreateIncomingWebhook_BypassTeamPermissions(t *testing.T) {
|
||||
CheckForbiddenStatus(t, resp)
|
||||
}
|
||||
|
||||
func TestIncomingWebhookValidateUser(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableIncomingWebhooks = true })
|
||||
|
||||
defaultRolePermissions := th.SaveDefaultRolePermissions()
|
||||
defer th.RestoreDefaultRolePermissions(defaultRolePermissions)
|
||||
th.AddPermissionToRole(model.PermissionManageIncomingWebhooks.Id, model.TeamAdminRoleId)
|
||||
|
||||
th.LoginTeamAdmin()
|
||||
|
||||
t.Run("cannot assign a user who is not a member of the team or channel", func(t *testing.T) {
|
||||
nonMember := th.CreateUser()
|
||||
|
||||
hook := &model.IncomingWebhook{ChannelId: th.BasicChannel.Id, UserId: nonMember.Id}
|
||||
_, resp, err := th.Client.CreateIncomingWebhook(context.Background(), hook)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("cannot assign a user with higher privileges than the requester", func(t *testing.T) {
|
||||
th.LinkUserToTeam(th.SystemAdminUser, th.BasicTeam)
|
||||
_, appErr := th.App.AddUserToChannel(th.Context, th.SystemAdminUser, th.BasicChannel, false)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
hook := &model.IncomingWebhook{ChannelId: th.BasicChannel.Id, UserId: th.SystemAdminUser.Id}
|
||||
_, resp, err := th.Client.CreateIncomingWebhook(context.Background(), hook)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("can assign a user who is a member of the channel", func(t *testing.T) {
|
||||
hook := &model.IncomingWebhook{ChannelId: th.BasicChannel.Id, UserId: th.BasicUser2.Id}
|
||||
created, _, err := th.Client.CreateIncomingWebhook(context.Background(), hook)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, th.BasicUser2.Id, created.UserId)
|
||||
})
|
||||
|
||||
t.Run("update cannot move another user's hook to a channel they cannot access", func(t *testing.T) {
|
||||
hook := &model.IncomingWebhook{ChannelId: th.BasicChannel.Id, UserId: th.BasicUser2.Id}
|
||||
created, _, err := th.Client.CreateIncomingWebhook(context.Background(), hook)
|
||||
require.NoError(t, err)
|
||||
|
||||
privateChannel := th.CreatePrivateChannel()
|
||||
created.ChannelId = privateChannel.Id
|
||||
|
||||
_, resp, err := th.Client.UpdateIncomingWebhook(context.Background(), created)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("update validates the retained owner even when the payload also changes the owner", func(t *testing.T) {
|
||||
hook := &model.IncomingWebhook{ChannelId: th.BasicChannel.Id, UserId: th.BasicUser2.Id}
|
||||
created, _, err := th.Client.CreateIncomingWebhook(context.Background(), hook)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The owner is immutable on update, so changing it alongside the channel must not
|
||||
// let the supplied user stand in for the retained owner's channel access.
|
||||
privateChannel := th.CreatePrivateChannel()
|
||||
created.ChannelId = privateChannel.Id
|
||||
created.UserId = th.TeamAdminUser.Id
|
||||
|
||||
_, resp, err := th.Client.UpdateIncomingWebhook(context.Background(), created)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetIncomingWebhooks(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
|
||||
@@ -129,6 +129,7 @@ func TestDoAdvancedPermissionsMigration(t *testing.T) {
|
||||
model.PermissionManagePrivateChannelMembers.Id,
|
||||
model.PermissionDeletePost.Id,
|
||||
model.PermissionEditPost.Id,
|
||||
model.PermissionEditFileAttachment.Id,
|
||||
model.PermissionAddBookmarkPublicChannel.Id,
|
||||
model.PermissionEditBookmarkPublicChannel.Id,
|
||||
model.PermissionDeleteBookmarkPublicChannel.Id,
|
||||
|
||||
@@ -65,18 +65,19 @@ func (a *App) EnsureBot(rctx request.CTX, pluginID string, bot *model.Bot) (stri
|
||||
if appErr := a.SetPluginKey(pluginID, botUserKey, []byte(user.Id)); appErr != nil {
|
||||
return "", fmt.Errorf("failed to set plugin key: %w", appErr)
|
||||
}
|
||||
} else {
|
||||
rctx.Logger().Error("Plugin attempted to use an account that already exists. Convert user to a bot "+
|
||||
"account in the CLI by running 'mattermost user convert <username> --bot'. If the user is an "+
|
||||
"existing user account you want to preserve, change its username and restart the Mattermost server, "+
|
||||
"after which the plugin will create a bot account with that name. For more information about bot "+
|
||||
"accounts, see https://mattermost.com/pl/default-bot-accounts", mlog.String("username",
|
||||
bot.Username),
|
||||
mlog.String("user_id",
|
||||
user.Id),
|
||||
)
|
||||
return user.Id, nil
|
||||
}
|
||||
return user.Id, nil
|
||||
|
||||
rctx.Logger().Error("Plugin attempted to use an account that already exists. Convert user to a bot "+
|
||||
"account in the CLI by running 'mattermost user convert <username> --bot'. If the user is an "+
|
||||
"existing user account you want to preserve, change its username and restart the Mattermost server, "+
|
||||
"after which the plugin will create a bot account with that name. For more information about bot "+
|
||||
"accounts, see https://mattermost.com/pl/default-bot-accounts", mlog.String("username",
|
||||
bot.Username),
|
||||
mlog.String("user_id",
|
||||
user.Id),
|
||||
)
|
||||
return "", fmt.Errorf("username %q is already taken by a non-bot user", bot.Username)
|
||||
}
|
||||
|
||||
createdBot, err := a.CreateBot(rctx, bot)
|
||||
|
||||
@@ -152,6 +152,23 @@ func TestEnsureBot(t *testing.T) {
|
||||
assert.Equal(t, "another bot", bot.Description)
|
||||
})
|
||||
|
||||
t.Run("ensure bot should fail if username belongs to a non-bot user", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
pluginId := "pluginId"
|
||||
|
||||
// th.BasicUser is a regular (non-bot) user created by InitBasic.
|
||||
// EnsureBot must return an error — not the human user's ID.
|
||||
botID, err := th.App.EnsureBot(th.Context, pluginId, &model.Bot{
|
||||
Username: th.BasicUser.Username,
|
||||
Description: "a bot",
|
||||
OwnerId: th.BasicUser.Id,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, botID)
|
||||
})
|
||||
|
||||
t.Run("ensure bot should pass even after delete bot user", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
@@ -256,6 +256,10 @@ func (a *App) GetOAuthAccessTokenForImplicitFlow(c request.CTX, userID string, a
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if user.DeleteAt != 0 {
|
||||
return nil, model.NewAppError("GetOAuthAccessToken", "api.oauth.get_access_token.expired_code.app_error", nil, "", http.StatusForbidden)
|
||||
}
|
||||
|
||||
session, err := a.newSession(c, oauthApp, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -381,6 +385,10 @@ func (a *App) GetOAuthAccessTokenForCodeFlow(c request.CTX, clientId, grantType,
|
||||
return nil, model.NewAppError("GetOAuthAccessToken", "api.oauth.get_access_token.internal_user.app_error", nil, "", http.StatusNotFound).Wrap(nErr)
|
||||
}
|
||||
|
||||
if user.DeleteAt != 0 {
|
||||
return nil, model.NewAppError("GetOAuthAccessToken", "api.oauth.get_access_token.expired_code.app_error", nil, "", http.StatusForbidden)
|
||||
}
|
||||
|
||||
access, err := a.newSessionUpdateToken(c, oauthApp, accessData, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -793,6 +793,111 @@ func TestDifferentClientCannotUseRefreshToken(t *testing.T) {
|
||||
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
|
||||
}
|
||||
|
||||
func TestOAuthRefreshTokenGrantRejectsDeactivatedUser(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = true })
|
||||
|
||||
oapp := &model.OAuthApp{
|
||||
Name: "RefreshGrantDeactivated_" + model.NewRandomString(10),
|
||||
CreatorId: th.BasicUser2.Id,
|
||||
Homepage: "https://nowhere.com",
|
||||
Description: "test",
|
||||
CallbackUrls: []string{"https://example.com/callback"},
|
||||
ClientSecret: model.NewId(),
|
||||
}
|
||||
oapp, appErr := th.App.CreateOAuthApp(oapp)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
user := th.CreateUser()
|
||||
|
||||
authRequest := &model.AuthorizeRequest{
|
||||
ResponseType: model.AuthCodeResponseType,
|
||||
ClientId: oapp.Id,
|
||||
RedirectURI: oapp.CallbackUrls[0],
|
||||
Scope: "user",
|
||||
State: "test_state",
|
||||
}
|
||||
|
||||
redirectURL, appErr := th.App.AllowOAuthAppAccessToUser(th.Context, user.Id, authRequest)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
uri, parseErr := url.Parse(redirectURL)
|
||||
require.NoError(t, parseErr)
|
||||
code := uri.Query().Get("code")
|
||||
require.NotEmpty(t, code)
|
||||
|
||||
tokenResp, appErr := th.App.GetOAuthAccessTokenForCodeFlow(
|
||||
th.Context,
|
||||
oapp.Id,
|
||||
model.AccessTokenGrantType,
|
||||
oapp.CallbackUrls[0],
|
||||
code,
|
||||
oapp.ClientSecret,
|
||||
"",
|
||||
)
|
||||
require.Nil(t, appErr)
|
||||
require.NotEmpty(t, tokenResp.AccessToken)
|
||||
require.NotEmpty(t, tokenResp.RefreshToken)
|
||||
|
||||
require.NoError(t, th.App.Srv().Store().Session().Remove(tokenResp.AccessToken))
|
||||
|
||||
_, appErr = th.App.UpdateActive(th.Context, user, false)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
refreshResp, appErr := th.App.GetOAuthAccessTokenForCodeFlow(
|
||||
th.Context,
|
||||
oapp.Id,
|
||||
model.RefreshTokenGrantType,
|
||||
oapp.CallbackUrls[0],
|
||||
"",
|
||||
oapp.ClientSecret,
|
||||
tokenResp.RefreshToken,
|
||||
)
|
||||
require.NotNil(t, appErr, "refresh token grant must fail for an inactive user")
|
||||
require.Nil(t, refreshResp)
|
||||
}
|
||||
|
||||
func TestOAuthImplicitGrantRejectsDeactivatedUser(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = true })
|
||||
|
||||
oapp := &model.OAuthApp{
|
||||
Name: "ImplicitGrantDeactivated_" + model.NewRandomString(10),
|
||||
CreatorId: th.BasicUser2.Id,
|
||||
Homepage: "https://nowhere.com",
|
||||
Description: "test",
|
||||
CallbackUrls: []string{"https://example.com/callback"},
|
||||
ClientSecret: model.NewId(),
|
||||
}
|
||||
oapp, appErr := th.App.CreateOAuthApp(oapp)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
user := th.CreateUser()
|
||||
|
||||
_, appErr = th.App.UpdateActive(th.Context, user, false)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
authRequest := &model.AuthorizeRequest{
|
||||
ResponseType: model.ImplicitResponseType,
|
||||
ClientId: oapp.Id,
|
||||
RedirectURI: oapp.CallbackUrls[0],
|
||||
Scope: "user",
|
||||
State: "test_state",
|
||||
}
|
||||
|
||||
session, appErr := th.App.GetOAuthAccessTokenForImplicitFlow(th.Context, user.Id, authRequest)
|
||||
require.NotNil(t, appErr, "implicit grant must fail for an inactive user")
|
||||
require.Nil(t, session)
|
||||
|
||||
accessData, sErr := th.App.Srv().Store().OAuth().GetAccessDataByUserForApp(user.Id, oapp.Id)
|
||||
require.NoError(t, sErr)
|
||||
require.Empty(t, accessData, "no access data may be persisted for an inactive user")
|
||||
}
|
||||
|
||||
func TestParseOAuthStateTokenExtra(t *testing.T) {
|
||||
t.Run("valid token with normal values", func(t *testing.T) {
|
||||
email, action, cookie, err := parseOAuthStateTokenExtra("user@example.com:email_to_sso:randomcookie123")
|
||||
|
||||
@@ -1206,6 +1206,15 @@ func (a *App) getRestrictAcessToChannelConversionToPublic() (permissionsMap, err
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) getAddEditFileAttachmentPermissionMigration() (permissionsMap, error) {
|
||||
return permissionsMap{
|
||||
permissionTransformation{
|
||||
On: permissionExists(model.PermissionEditPost.Id),
|
||||
Add: []string{model.PermissionEditFileAttachment.Id},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DoPermissionsMigrations execute all the permissions migrations need by the current version.
|
||||
func (a *App) DoPermissionsMigrations() error {
|
||||
return a.Srv().doPermissionsMigrations()
|
||||
@@ -1260,6 +1269,7 @@ func (s *Server) doPermissionsMigrations() error {
|
||||
{Key: model.MigrationRemoveGetAnalyticsPermission, Migration: a.removeGetAnalyticsPermissionMigration},
|
||||
{Key: model.MigrationAddSysconsoleMobileSecurityPermission, Migration: a.addSysConsoleMobileSecurityPermission},
|
||||
{Key: model.MigrationKeyAddChannelBannerPermissions, Migration: a.getAddChannelBannerPermissionMigration},
|
||||
{Key: model.MigrationKeyAddEditFileAttachmentPermission, Migration: a.getAddEditFileAttachmentPermissionMigration},
|
||||
}
|
||||
|
||||
roles, err := s.Store().Role().GetAll()
|
||||
|
||||
@@ -66,11 +66,18 @@ func (ps *PlatformService) ClearSessionCacheForUserSkipClusterSend(userID string
|
||||
ps.invalidateWebConnSessionCacheForUserSkipClusterSend(userID)
|
||||
}
|
||||
|
||||
func (ps *PlatformService) ClearSessionCacheForAllUsersSkipClusterSend() {
|
||||
// ClearSessionCacheForAllUsersSkipClusterSend purges the in-memory
|
||||
// session cache and invalidates every WebConn on this node. The hub
|
||||
// fan-out runs even if the cache purge fails; the purge error is
|
||||
// returned so wrappers can propagate it.
|
||||
func (ps *PlatformService) ClearSessionCacheForAllUsersSkipClusterSend() error {
|
||||
ps.logger.Info("Purging sessions cache")
|
||||
if err := ps.ClearAllUsersSessionCacheLocal(); err != nil {
|
||||
err := ps.ClearAllUsersSessionCacheLocal()
|
||||
if err != nil {
|
||||
ps.logger.Error("Failed to purge session cache", mlog.Err(err))
|
||||
}
|
||||
ps.invalidateWebConnSessionCacheForAllUsersSkipClusterSend()
|
||||
return err
|
||||
}
|
||||
|
||||
func (ps *PlatformService) clusterClearSessionCacheForUserHandler(msg *model.ClusterMessage) {
|
||||
@@ -78,7 +85,9 @@ func (ps *PlatformService) clusterClearSessionCacheForUserHandler(msg *model.Clu
|
||||
}
|
||||
|
||||
func (ps *PlatformService) clusterClearSessionCacheForAllUsersHandler(msg *model.ClusterMessage) {
|
||||
ps.ClearSessionCacheForAllUsersSkipClusterSend()
|
||||
if err := ps.ClearSessionCacheForAllUsersSkipClusterSend(); err != nil {
|
||||
ps.logger.Error("Failed to clear session cache for all users from cluster handler", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (ps *PlatformService) clusterBusyStateChgHandler(msg *model.ClusterMessage) {
|
||||
@@ -102,6 +111,17 @@ func (ps *PlatformService) invalidateWebConnSessionCacheForUserSkipClusterSend(u
|
||||
}
|
||||
}
|
||||
|
||||
// invalidateWebConnSessionCacheForAllUsersSkipClusterSend signals
|
||||
// every hub on this node to invalidate the cached session state of
|
||||
// all of its WebConns. Companion to ClearAllUsersSessionCacheLocal.
|
||||
func (ps *PlatformService) invalidateWebConnSessionCacheForAllUsersSkipClusterSend() {
|
||||
for _, hub := range ps.hubs {
|
||||
if hub != nil {
|
||||
hub.InvalidateAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ps *PlatformService) InvalidateAllCachesSkipSend() *model.AppError {
|
||||
ps.logger.Info("Purging all caches")
|
||||
if err := ps.ClearAllUsersSessionCacheLocal(); err != nil {
|
||||
|
||||
97
server/channels/app/platform/cluster_handlers_revoke_test.go
Обычный файл
97
server/channels/app/platform/cluster_handlers_revoke_test.go
Обычный файл
@@ -0,0 +1,97 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
)
|
||||
|
||||
// TestRevokeSessionsFromAllUsersInvalidatesWebConnSession asserts that
|
||||
// after the public RevokeSessionsFromAllUsers entry point returns,
|
||||
// every live WebConn on this node — across multiple users (hashed to
|
||||
// different hubs) and multiple connections per user — has its cached
|
||||
// session reset to the authenticated-as-no-one state.
|
||||
func TestRevokeSessionsFromAllUsersInvalidatesWebConnSession(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
s := httptest.NewServer(dummyWebsocketHandler(t))
|
||||
defer s.Close()
|
||||
|
||||
// Spread connections across multiple hubs via GetHubForUserId's
|
||||
// hash(userID) mod len(hubs) sharding, and use multiple conns per
|
||||
// user to cover the multi-device case.
|
||||
type userConns struct {
|
||||
userID string
|
||||
wcs []*WebConn
|
||||
}
|
||||
users := []*userConns{
|
||||
{userID: th.BasicUser.Id},
|
||||
{userID: th.BasicUser2.Id},
|
||||
}
|
||||
for range 4 {
|
||||
users = append(users, &userConns{userID: model.NewId()})
|
||||
}
|
||||
|
||||
for _, u := range users {
|
||||
preWarmStatusOnline(th, u.userID)
|
||||
}
|
||||
|
||||
const connsPerUser = 2
|
||||
for _, u := range users {
|
||||
for range connsPerUser {
|
||||
session, err := th.Service.CreateSession(th.Context, &model.Session{
|
||||
UserId: u.userID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
session.ExpiresAt = model.GetMillis() + time.Hour.Milliseconds()
|
||||
|
||||
wc := registerDummyWebConn(t, th, s.Listener.Addr(), session)
|
||||
t.Cleanup(func() { wc.Close() })
|
||||
u.wcs = append(u.wcs, wc)
|
||||
|
||||
waitForWebConnRegistered(t, th, session)
|
||||
}
|
||||
}
|
||||
|
||||
for _, u := range users {
|
||||
for _, wc := range u.wcs {
|
||||
require.NotNil(t, wc.GetSession(),
|
||||
"precondition: webconn for user %q must have a cached session before revoke", u.userID)
|
||||
require.Greater(t, wc.GetSessionExpiresAt(), model.GetMillis(),
|
||||
"precondition: cached expiry for user %q must be in the future before revoke", u.userID)
|
||||
require.NotEmpty(t, wc.GetSessionToken(),
|
||||
"precondition: webconn for user %q must have a cached session token before revoke", u.userID)
|
||||
}
|
||||
}
|
||||
|
||||
require.NoError(t, th.Service.RevokeSessionsFromAllUsers(),
|
||||
"RevokeSessionsFromAllUsers should not error")
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
for _, u := range users {
|
||||
for _, wc := range u.wcs {
|
||||
if wc.GetSession() != nil {
|
||||
return false
|
||||
}
|
||||
if wc.GetSessionExpiresAt() != 0 {
|
||||
return false
|
||||
}
|
||||
if wc.GetSessionToken() != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}, 5*time.Second, 25*time.Millisecond,
|
||||
"RevokeSessionsFromAllUsers did not invalidate every live WebConn across all hubs")
|
||||
}
|
||||
112
server/channels/app/platform/cluster_handlers_test.go
Обычный файл
112
server/channels/app/platform/cluster_handlers_test.go
Обычный файл
@@ -0,0 +1,112 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
)
|
||||
|
||||
// waitForWebConnRegistered blocks until the hub has processed the
|
||||
// WebConn registration. Without it, a test can race past the async
|
||||
// register channel and signal invalidation against an empty connIndex.
|
||||
func waitForWebConnRegistered(t *testing.T, th *TestHelper, session *model.Session) {
|
||||
t.Helper()
|
||||
require.Eventually(t, func() bool {
|
||||
return th.Service.SessionIsRegistered(*session)
|
||||
}, 2*time.Second, 10*time.Millisecond,
|
||||
"WebConn for session %q (user %q) was not registered with the hub in time",
|
||||
session.Id, session.UserId)
|
||||
}
|
||||
|
||||
// preWarmStatusOnline marks the user online before any WebConn is
|
||||
// created so the async SetStatusOnline goroutine in NewWebConn skips
|
||||
// the broadcast path. Otherwise the broadcast can race with the
|
||||
// invalidation and re-populate the WebConn's cached session via
|
||||
// IsBasicAuthenticated, flipping the post-invalidate assertions.
|
||||
func preWarmStatusOnline(th *TestHelper, userID string) {
|
||||
th.Service.AddStatusCacheSkipClusterSend(&model.Status{
|
||||
UserId: userID,
|
||||
Status: model.StatusOnline,
|
||||
LastActivityAt: model.GetMillis(),
|
||||
})
|
||||
}
|
||||
|
||||
// TestClearSessionCacheInvalidatesWebConnSession asserts that after either
|
||||
// the per-user or the global session-cache clear runs, every matching
|
||||
// active WebSocket connection has its cached session reset to the
|
||||
// authenticated-as-no-one state (GetSession() == nil and
|
||||
// GetSessionExpiresAt() == 0).
|
||||
func TestClearSessionCacheInvalidatesWebConnSession(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
revoke func(ps *PlatformService, userID string)
|
||||
}{
|
||||
{
|
||||
name: "PerUserRevokeInvalidatesWebConnSession",
|
||||
revoke: func(ps *PlatformService, userID string) {
|
||||
ps.ClearSessionCacheForUserSkipClusterSend(userID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GlobalRevokeInvalidatesWebConnSession",
|
||||
revoke: func(ps *PlatformService, _ string) {
|
||||
_ = ps.ClearSessionCacheForAllUsersSkipClusterSend()
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
s := httptest.NewServer(dummyWebsocketHandler(t))
|
||||
defer s.Close()
|
||||
|
||||
session, err := th.Service.CreateSession(th.Context, &model.Session{
|
||||
UserId: th.BasicUser.Id,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Pin a future expiry so IsBasicAuthenticated trusts the
|
||||
// cached session and doesn't re-validate against the store.
|
||||
session.ExpiresAt = model.GetMillis() + time.Hour.Milliseconds()
|
||||
|
||||
preWarmStatusOnline(th, th.BasicUser.Id)
|
||||
|
||||
wc := registerDummyWebConn(t, th, s.Listener.Addr(), session)
|
||||
defer wc.Close()
|
||||
|
||||
waitForWebConnRegistered(t, th, session)
|
||||
|
||||
require.NotNil(t, wc.GetSession(),
|
||||
"precondition: webconn must have a cached session before revoke")
|
||||
require.Greater(t, wc.GetSessionExpiresAt(), model.GetMillis(),
|
||||
"precondition: webconn cached session expiry must be in the future before revoke")
|
||||
|
||||
tt.revoke(th.Service, th.BasicUser.Id)
|
||||
|
||||
// Hub invalidation is async, so poll for the end state.
|
||||
require.Eventually(t, func() bool {
|
||||
return wc.GetSession() == nil && wc.GetSessionExpiresAt() == 0
|
||||
}, 2*time.Second, 25*time.Millisecond,
|
||||
"webconn cached session was not invalidated after %s; "+
|
||||
"expected GetSession()==nil and GetSessionExpiresAt()==0, "+
|
||||
"but got GetSession()!=nil=%t, GetSessionExpiresAt()=%d, GetSessionToken()=%q",
|
||||
tt.name,
|
||||
wc.GetSession() != nil,
|
||||
wc.GetSessionExpiresAt(),
|
||||
wc.GetSessionToken(),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -110,9 +110,11 @@ func (ps *PlatformService) ClearUserSessionCache(userID string) {
|
||||
}
|
||||
|
||||
func (ps *PlatformService) ClearAllUsersSessionCache() error {
|
||||
if err := ps.ClearAllUsersSessionCacheLocal(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Mirrors the per-user shape: the SkipClusterSend helper handles the
|
||||
// local cache purge and the WebConn hub fan-out, then we broadcast to
|
||||
// peer nodes. The broadcast still runs on local-purge failure so peers
|
||||
// can act independently.
|
||||
err := ps.ClearSessionCacheForAllUsersSkipClusterSend()
|
||||
|
||||
if ps.clusterIFace != nil {
|
||||
msg := &model.ClusterMessage{
|
||||
@@ -121,7 +123,7 @@ func (ps *PlatformService) ClearAllUsersSessionCache() error {
|
||||
}
|
||||
ps.clusterIFace.SendClusterMessage(msg)
|
||||
}
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
func (ps *PlatformService) GetSession(c request.CTX, token string) (*model.Session, error) {
|
||||
|
||||
@@ -84,6 +84,7 @@ type Hub struct {
|
||||
stop chan struct{}
|
||||
didStop chan struct{}
|
||||
invalidateUser chan string
|
||||
invalidateAll chan struct{}
|
||||
activity chan *webConnActivityMessage
|
||||
directMsg chan *webConnDirectMessage
|
||||
explicitStop bool
|
||||
@@ -106,6 +107,7 @@ func newWebHub(ps *PlatformService) *Hub {
|
||||
stop: make(chan struct{}),
|
||||
didStop: make(chan struct{}),
|
||||
invalidateUser: make(chan string),
|
||||
invalidateAll: make(chan struct{}),
|
||||
activity: make(chan *webConnActivityMessage),
|
||||
directMsg: make(chan *webConnDirectMessage),
|
||||
checkRegistered: make(chan *webConnSessionMessage),
|
||||
@@ -453,6 +455,15 @@ func (h *Hub) InvalidateUser(userID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidateAll invalidates the cached session state of every WebConn
|
||||
// registered with this hub. Global counterpart of InvalidateUser.
|
||||
func (h *Hub) InvalidateAll() {
|
||||
select {
|
||||
case h.invalidateAll <- struct{}{}:
|
||||
case <-h.stop:
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateActivity sets the LastUserActivityAt field for the connection
|
||||
// of the user.
|
||||
func (h *Hub) UpdateActivity(userID, sessionToken string, activityAt int64) {
|
||||
@@ -654,6 +665,18 @@ func (h *Hub) Start() {
|
||||
closeAndRemoveConn(connIndex, webConn)
|
||||
}
|
||||
}
|
||||
case <-h.invalidateAll:
|
||||
// Mirrors the invalidateUser arm across every conn,
|
||||
// also clearing the session token so the next
|
||||
// IsBasicAuthenticated check short-circuits instead
|
||||
// of re-fetching from the cache.
|
||||
for webConn := range connIndex.All() {
|
||||
webConn.InvalidateCache()
|
||||
webConn.SetSessionToken("")
|
||||
}
|
||||
if *h.platform.Config().ServiceSettings.EnableWebHubChannelIteration {
|
||||
connIndex.clearChannels()
|
||||
}
|
||||
case activity := <-h.activity:
|
||||
for webConn := range connIndex.ForUser(activity.userID) {
|
||||
if !webConn.Active.Load() {
|
||||
@@ -947,6 +970,16 @@ func (i *hubConnectionIndex) ForChannel(channelID string) iter.Seq[*WebConn] {
|
||||
return maps.Keys(i.byChannelID[channelID])
|
||||
}
|
||||
|
||||
// clearChannels empties the channel-routing index in one shot. Intended
|
||||
// for paths that have already invalidated every conn registered with
|
||||
// the hub: any broadcast addressed to a channel will be filtered out
|
||||
// upstream by ShouldSendEvent, so the routing entries are dead weight
|
||||
// until conns either re-handshake or fully reconnect (both of which
|
||||
// repopulate the index via Add).
|
||||
func (i *hubConnectionIndex) clearChannels() {
|
||||
clear(i.byChannelID)
|
||||
}
|
||||
|
||||
// ForUserActiveCount returns the number of active connections for a userID
|
||||
func (i *hubConnectionIndex) ForUserActiveCount(id string) int {
|
||||
cnt := 0
|
||||
|
||||
@@ -7,12 +7,14 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/utils"
|
||||
@@ -275,13 +277,93 @@ func (a *App) CheckRolesExist(roleNames []string) *model.AppError {
|
||||
}
|
||||
|
||||
func (a *App) sendUpdatedRoleEvent(role *model.Role) *model.AppError {
|
||||
message := model.NewWebSocketEvent(model.WebsocketEventRoleUpdated, "", "", "", nil, "")
|
||||
roleJSON, jsonErr := json.Marshal(role)
|
||||
if jsonErr != nil {
|
||||
return model.NewAppError("sendUpdatedRoleEvent", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr)
|
||||
}
|
||||
message.Add("role", string(roleJSON))
|
||||
a.Publish(message)
|
||||
|
||||
publishEvent := func(teamID, channelID string) {
|
||||
message := model.NewWebSocketEvent(model.WebsocketEventRoleUpdated, teamID, channelID, "", nil, "")
|
||||
message.Add("role", string(roleJSON))
|
||||
a.Publish(message)
|
||||
}
|
||||
|
||||
// Built-in system roles apply to all users; broadcast globally without a DB lookup.
|
||||
if role.BuiltIn {
|
||||
publishEvent("", "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Scheme-managed roles: use SchemeId to look up the owning scheme.
|
||||
if role.SchemeId == nil {
|
||||
// No owning scheme — treat as global (e.g. custom non-scheme role).
|
||||
publishEvent("", "")
|
||||
return nil
|
||||
}
|
||||
scheme, err := a.Srv().Store().Scheme().Get(*role.SchemeId)
|
||||
if err != nil {
|
||||
a.Log().Error("Failed to look up scheme for role event; skipping broadcast",
|
||||
mlog.String("role_id", role.Id),
|
||||
mlog.String("scheme_id", *role.SchemeId),
|
||||
mlog.Err(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
const pageSize = 1000
|
||||
const maxBroadcasts = 100000
|
||||
switch scheme.Scope {
|
||||
case model.SchemeScopeTeam:
|
||||
totalBroadcasts := 0
|
||||
offset := 0
|
||||
for {
|
||||
teams, storeErr := a.Srv().Store().Team().GetTeamsByScheme(scheme.Id, offset, pageSize)
|
||||
if storeErr != nil {
|
||||
return model.NewAppError("sendUpdatedRoleEvent", "app.role.send_updated_role_event.app_error", nil, "", http.StatusInternalServerError).Wrap(storeErr)
|
||||
}
|
||||
for _, team := range teams {
|
||||
publishEvent(team.Id, "")
|
||||
}
|
||||
totalBroadcasts += len(teams)
|
||||
if len(teams) < pageSize {
|
||||
break
|
||||
}
|
||||
if totalBroadcasts >= maxBroadcasts {
|
||||
a.Log().Error("sendUpdatedRoleEvent: hit broadcast limit for team scheme",
|
||||
mlog.String("scheme_id", scheme.Id),
|
||||
mlog.Int("totalBroadcasts", totalBroadcasts))
|
||||
break
|
||||
}
|
||||
offset += pageSize
|
||||
}
|
||||
case model.SchemeScopeChannel:
|
||||
totalBroadcasts := 0
|
||||
offset := 0
|
||||
for {
|
||||
channels, storeErr := a.Srv().Store().Channel().GetChannelsByScheme(scheme.Id, offset, pageSize)
|
||||
if storeErr != nil {
|
||||
return model.NewAppError("sendUpdatedRoleEvent", "app.role.send_updated_role_event.app_error", nil, "", http.StatusInternalServerError).Wrap(storeErr)
|
||||
}
|
||||
for _, channel := range channels {
|
||||
publishEvent("", channel.Id)
|
||||
}
|
||||
totalBroadcasts += len(channels)
|
||||
if len(channels) < pageSize {
|
||||
break
|
||||
}
|
||||
if totalBroadcasts >= maxBroadcasts {
|
||||
a.Log().Error("sendUpdatedRoleEvent: hit broadcast limit for channel scheme",
|
||||
mlog.String("scheme_id", scheme.Id),
|
||||
mlog.Int("totalBroadcasts", totalBroadcasts))
|
||||
break
|
||||
}
|
||||
offset += pageSize
|
||||
}
|
||||
case model.SchemeScopePlaybook, model.SchemeScopeRun:
|
||||
// Playbook/run schemes don't map to teams or channels; broadcast globally.
|
||||
publishEvent("", "")
|
||||
default:
|
||||
return model.NewAppError("sendUpdatedRoleEvent", "app.role.send_updated_role_event.unknown_scope", nil, fmt.Sprintf("unknown scheme scope: %s", scheme.Scope), http.StatusInternalServerError)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
@@ -13,9 +14,11 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
|
||||
)
|
||||
|
||||
type permissionInheritanceTestData struct {
|
||||
@@ -261,3 +264,225 @@ func testPermissionInheritance(t *testing.T, testCallback func(t *testing.T, th
|
||||
// test 24 combinations where the higher-scoped scheme is a TEAM scheme
|
||||
test(teamScheme.DefaultChannelGuestRole, teamScheme.DefaultChannelUserRole, teamScheme.DefaultChannelAdminRole)
|
||||
}
|
||||
|
||||
func TestSendUpdatedRoleEvent(t *testing.T) {
|
||||
t.Run("BuiltIn role broadcasts globally without a DB lookup", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := SetupWithStoreMock(t)
|
||||
|
||||
mockStore := th.App.Srv().Store().(*mocks.Store)
|
||||
mockSchemeStore := mocks.SchemeStore{}
|
||||
mockStore.On("Scheme").Return(&mockSchemeStore)
|
||||
|
||||
role := &model.Role{Name: model.TeamAdminRoleId, BuiltIn: true}
|
||||
appErr := th.App.sendUpdatedRoleEvent(role)
|
||||
require.Nil(t, appErr)
|
||||
mockSchemeStore.AssertNotCalled(t, "Get", mock.Anything)
|
||||
})
|
||||
|
||||
t.Run("Team scheme role calls GetTeamsByScheme and emits per-team events", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := SetupWithStoreMock(t)
|
||||
|
||||
schemeID := model.NewId()
|
||||
roleName := model.NewId()
|
||||
scheme := &model.Scheme{Id: schemeID, Scope: model.SchemeScopeTeam}
|
||||
teams := []*model.Team{{Id: model.NewId()}, {Id: model.NewId()}}
|
||||
|
||||
mockStore := th.App.Srv().Store().(*mocks.Store)
|
||||
mockSchemeStore := mocks.SchemeStore{}
|
||||
mockTeamStore := mocks.TeamStore{}
|
||||
mockSchemeStore.On("Get", schemeID).Return(scheme, nil)
|
||||
mockTeamStore.On("GetTeamsByScheme", schemeID, 0, 1000).Return(teams, nil)
|
||||
mockStore.On("Scheme").Return(&mockSchemeStore)
|
||||
mockStore.On("Team").Return(&mockTeamStore)
|
||||
|
||||
role := &model.Role{Name: roleName, BuiltIn: false, SchemeId: &schemeID}
|
||||
appErr := th.App.sendUpdatedRoleEvent(role)
|
||||
require.Nil(t, appErr)
|
||||
mockSchemeStore.AssertCalled(t, "Get", schemeID)
|
||||
mockTeamStore.AssertCalled(t, "GetTeamsByScheme", schemeID, 0, 1000)
|
||||
})
|
||||
|
||||
t.Run("Channel scheme role calls GetChannelsByScheme and emits per-channel events", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := SetupWithStoreMock(t)
|
||||
|
||||
schemeID := model.NewId()
|
||||
roleName := model.NewId()
|
||||
scheme := &model.Scheme{Id: schemeID, Scope: model.SchemeScopeChannel}
|
||||
channels := model.ChannelList{{Id: model.NewId()}}
|
||||
|
||||
mockStore := th.App.Srv().Store().(*mocks.Store)
|
||||
mockSchemeStore := mocks.SchemeStore{}
|
||||
mockChannelStore := mocks.ChannelStore{}
|
||||
mockSchemeStore.On("Get", schemeID).Return(scheme, nil)
|
||||
mockChannelStore.On("GetChannelsByScheme", schemeID, 0, 1000).Return(channels, nil)
|
||||
mockStore.On("Scheme").Return(&mockSchemeStore)
|
||||
mockStore.On("Channel").Return(&mockChannelStore)
|
||||
|
||||
role := &model.Role{Name: roleName, BuiltIn: false, SchemeId: &schemeID}
|
||||
appErr := th.App.sendUpdatedRoleEvent(role)
|
||||
require.Nil(t, appErr)
|
||||
mockSchemeStore.AssertCalled(t, "Get", schemeID)
|
||||
mockChannelStore.AssertCalled(t, "GetChannelsByScheme", schemeID, 0, 1000)
|
||||
})
|
||||
|
||||
t.Run("Role not in any scheme broadcasts globally", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := SetupWithStoreMock(t)
|
||||
|
||||
mockStore := th.App.Srv().Store().(*mocks.Store)
|
||||
mockSchemeStore := mocks.SchemeStore{}
|
||||
mockTeamStore := mocks.TeamStore{}
|
||||
mockStore.On("Scheme").Return(&mockSchemeStore)
|
||||
mockStore.On("Team").Return(&mockTeamStore)
|
||||
|
||||
role := &model.Role{Name: model.NewId(), BuiltIn: false, SchemeId: nil}
|
||||
appErr := th.App.sendUpdatedRoleEvent(role)
|
||||
require.Nil(t, appErr)
|
||||
mockSchemeStore.AssertNotCalled(t, "Get", mock.Anything)
|
||||
mockTeamStore.AssertNotCalled(t, "GetTeamsByScheme", mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
t.Run("Playbook scope falls back to global broadcast without querying teams or channels", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := SetupWithStoreMock(t)
|
||||
|
||||
schemeID := model.NewId()
|
||||
roleName := model.NewId()
|
||||
scheme := &model.Scheme{Id: schemeID, Scope: model.SchemeScopePlaybook}
|
||||
|
||||
mockStore := th.App.Srv().Store().(*mocks.Store)
|
||||
mockSchemeStore := mocks.SchemeStore{}
|
||||
mockTeamStore := mocks.TeamStore{}
|
||||
mockChannelStore := mocks.ChannelStore{}
|
||||
mockSchemeStore.On("Get", schemeID).Return(scheme, nil)
|
||||
mockStore.On("Scheme").Return(&mockSchemeStore)
|
||||
mockStore.On("Team").Return(&mockTeamStore)
|
||||
mockStore.On("Channel").Return(&mockChannelStore)
|
||||
|
||||
role := &model.Role{Name: roleName, BuiltIn: false, SchemeId: &schemeID}
|
||||
appErr := th.App.sendUpdatedRoleEvent(role)
|
||||
require.Nil(t, appErr)
|
||||
mockTeamStore.AssertNotCalled(t, "GetTeamsByScheme", mock.Anything, mock.Anything, mock.Anything)
|
||||
mockChannelStore.AssertNotCalled(t, "GetChannelsByScheme", mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
t.Run("Scheme store error is logged and skips broadcast", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := SetupWithStoreMock(t)
|
||||
|
||||
schemeID := model.NewId()
|
||||
roleName := model.NewId()
|
||||
mockStore := th.App.Srv().Store().(*mocks.Store)
|
||||
mockSchemeStore := mocks.SchemeStore{}
|
||||
mockSchemeStore.On("Get", schemeID).Return(nil, errors.New("db error"))
|
||||
mockStore.On("Scheme").Return(&mockSchemeStore)
|
||||
|
||||
role := &model.Role{Name: roleName, BuiltIn: false, SchemeId: &schemeID}
|
||||
appErr := th.App.sendUpdatedRoleEvent(role)
|
||||
require.Nil(t, appErr)
|
||||
})
|
||||
|
||||
t.Run("GetTeamsByScheme store error propagates as AppError", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := SetupWithStoreMock(t)
|
||||
|
||||
schemeID := model.NewId()
|
||||
roleName := model.NewId()
|
||||
scheme := &model.Scheme{Id: schemeID, Scope: model.SchemeScopeTeam}
|
||||
|
||||
mockStore := th.App.Srv().Store().(*mocks.Store)
|
||||
mockSchemeStore := mocks.SchemeStore{}
|
||||
mockTeamStore := mocks.TeamStore{}
|
||||
mockSchemeStore.On("Get", schemeID).Return(scheme, nil)
|
||||
mockTeamStore.On("GetTeamsByScheme", schemeID, 0, 1000).Return(nil, errors.New("db error"))
|
||||
mockStore.On("Scheme").Return(&mockSchemeStore)
|
||||
mockStore.On("Team").Return(&mockTeamStore)
|
||||
|
||||
role := &model.Role{Name: roleName, BuiltIn: false, SchemeId: &schemeID}
|
||||
appErr := th.App.sendUpdatedRoleEvent(role)
|
||||
require.NotNil(t, appErr)
|
||||
})
|
||||
|
||||
t.Run("Team scheme paginates across multiple pages", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := SetupWithStoreMock(t)
|
||||
|
||||
schemeID := model.NewId()
|
||||
scheme := &model.Scheme{Id: schemeID, Scope: model.SchemeScopeTeam}
|
||||
|
||||
// Build a full first page (1000 teams) and a partial second page (2 teams).
|
||||
page1 := make([]*model.Team, 1000)
|
||||
for i := range page1 {
|
||||
page1[i] = &model.Team{Id: model.NewId()}
|
||||
}
|
||||
page2 := []*model.Team{{Id: model.NewId()}, {Id: model.NewId()}}
|
||||
|
||||
mockStore := th.App.Srv().Store().(*mocks.Store)
|
||||
mockSchemeStore := mocks.SchemeStore{}
|
||||
mockTeamStore := mocks.TeamStore{}
|
||||
mockSchemeStore.On("Get", schemeID).Return(scheme, nil)
|
||||
mockTeamStore.On("GetTeamsByScheme", schemeID, 0, 1000).Return(page1, nil)
|
||||
mockTeamStore.On("GetTeamsByScheme", schemeID, 1000, 1000).Return(page2, nil)
|
||||
mockStore.On("Scheme").Return(&mockSchemeStore)
|
||||
mockStore.On("Team").Return(&mockTeamStore)
|
||||
|
||||
role := &model.Role{Name: model.NewId(), BuiltIn: false, SchemeId: &schemeID}
|
||||
appErr := th.App.sendUpdatedRoleEvent(role)
|
||||
require.Nil(t, appErr)
|
||||
mockTeamStore.AssertCalled(t, "GetTeamsByScheme", schemeID, 0, 1000)
|
||||
mockTeamStore.AssertCalled(t, "GetTeamsByScheme", schemeID, 1000, 1000)
|
||||
})
|
||||
|
||||
t.Run("Channel scheme paginates across multiple pages", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := SetupWithStoreMock(t)
|
||||
|
||||
schemeID := model.NewId()
|
||||
scheme := &model.Scheme{Id: schemeID, Scope: model.SchemeScopeChannel}
|
||||
|
||||
page1 := make(model.ChannelList, 1000)
|
||||
for i := range page1 {
|
||||
page1[i] = &model.Channel{Id: model.NewId()}
|
||||
}
|
||||
page2 := model.ChannelList{{Id: model.NewId()}, {Id: model.NewId()}, {Id: model.NewId()}}
|
||||
|
||||
mockStore := th.App.Srv().Store().(*mocks.Store)
|
||||
mockSchemeStore := mocks.SchemeStore{}
|
||||
mockChannelStore := mocks.ChannelStore{}
|
||||
mockSchemeStore.On("Get", schemeID).Return(scheme, nil)
|
||||
mockChannelStore.On("GetChannelsByScheme", schemeID, 0, 1000).Return(page1, nil)
|
||||
mockChannelStore.On("GetChannelsByScheme", schemeID, 1000, 1000).Return(page2, nil)
|
||||
mockStore.On("Scheme").Return(&mockSchemeStore)
|
||||
mockStore.On("Channel").Return(&mockChannelStore)
|
||||
|
||||
role := &model.Role{Name: model.NewId(), BuiltIn: false, SchemeId: &schemeID}
|
||||
appErr := th.App.sendUpdatedRoleEvent(role)
|
||||
require.Nil(t, appErr)
|
||||
mockChannelStore.AssertCalled(t, "GetChannelsByScheme", schemeID, 0, 1000)
|
||||
mockChannelStore.AssertCalled(t, "GetChannelsByScheme", schemeID, 1000, 1000)
|
||||
})
|
||||
|
||||
t.Run("GetChannelsByScheme store error propagates as AppError", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := SetupWithStoreMock(t)
|
||||
|
||||
schemeID := model.NewId()
|
||||
roleName := model.NewId()
|
||||
scheme := &model.Scheme{Id: schemeID, Scope: model.SchemeScopeChannel}
|
||||
|
||||
mockStore := th.App.Srv().Store().(*mocks.Store)
|
||||
mockSchemeStore := mocks.SchemeStore{}
|
||||
mockChannelStore := mocks.ChannelStore{}
|
||||
mockSchemeStore.On("Get", schemeID).Return(scheme, nil)
|
||||
mockChannelStore.On("GetChannelsByScheme", schemeID, 0, 1000).Return(nil, errors.New("db error"))
|
||||
mockStore.On("Scheme").Return(&mockSchemeStore)
|
||||
mockStore.On("Channel").Return(&mockChannelStore)
|
||||
|
||||
role := &model.Role{Name: roleName, BuiltIn: false, SchemeId: &schemeID}
|
||||
appErr := th.App.sendUpdatedRoleEvent(role)
|
||||
require.NotNil(t, appErr)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -241,7 +241,9 @@ func (a *App) ClearSessionCacheForUserSkipClusterSend(userID string) {
|
||||
}
|
||||
|
||||
func (a *App) ClearSessionCacheForAllUsersSkipClusterSend() {
|
||||
a.Srv().Platform().ClearSessionCacheForAllUsersSkipClusterSend()
|
||||
if err := a.Srv().Platform().ClearSessionCacheForAllUsersSkipClusterSend(); err != nil {
|
||||
a.Srv().Platform().Log().Error("Failed to clear session cache for all users", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) RevokeSessionsForDeviceId(c request.CTX, userID string, deviceID string, currentSessionId string) *model.AppError {
|
||||
|
||||
@@ -63,6 +63,10 @@ func (sp *ShareProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Com
|
||||
}
|
||||
|
||||
func (sp *ShareProvider) GetAutoCompleteListItems(c request.CTX, a *app.App, commandArgs *model.CommandArgs, arg *model.AutocompleteArg, parsed, toBeParsed string) ([]model.AutocompleteListItem, error) {
|
||||
if !a.HasPermissionTo(commandArgs.UserId, model.PermissionManageSharedChannels) {
|
||||
return []model.AutocompleteListItem{}, nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(parsed, " share "):
|
||||
|
||||
|
||||
@@ -126,3 +126,305 @@ func TestShareProviderDoCommand(t *testing.T) {
|
||||
require.Contains(t, response.Text, args.T("api.command_share.invite_remote_to_channel.error"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestShareProviderGetAutoCompleteListItemsPermission(t *testing.T) {
|
||||
connectionIDArg := func() *model.AutocompleteArg {
|
||||
return &model.AutocompleteArg{Name: "connectionID"}
|
||||
}
|
||||
|
||||
seedRemote := func(t *testing.T, th *TestHelper) *model.RemoteCluster {
|
||||
t.Helper()
|
||||
rc, err := th.App.AddRemoteCluster(&model.RemoteCluster{
|
||||
RemoteId: model.NewId(),
|
||||
Name: "remote-" + model.NewId(),
|
||||
DisplayName: "Remote Display Name Sentinel",
|
||||
SiteURL: "https://remote-sentinel.example.com",
|
||||
Token: model.NewId(),
|
||||
Topics: "topic",
|
||||
CreateAt: model.GetMillis(),
|
||||
LastPingAt: model.GetMillis(),
|
||||
CreatorId: model.NewId(),
|
||||
})
|
||||
require.Nil(t, err)
|
||||
return rc
|
||||
}
|
||||
|
||||
assertNoRemoteData := func(t *testing.T, items []model.AutocompleteListItem, rc *model.RemoteCluster) {
|
||||
t.Helper()
|
||||
for i, item := range items {
|
||||
assert.NotContains(t, item.Item, rc.RemoteId, "item[%d].Item contained RemoteId", i)
|
||||
assert.NotContains(t, item.HelpText, rc.RemoteId, "item[%d].HelpText contained RemoteId", i)
|
||||
assert.NotContains(t, item.HelpText, rc.DisplayName, "item[%d].HelpText contained DisplayName", i)
|
||||
assert.NotContains(t, item.HelpText, rc.SiteURL, "item[%d].HelpText contained SiteURL", i)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("invite without manage_shared_channels permission returns no remote cluster data", func(t *testing.T) {
|
||||
th := setupForSharedChannels(t).initBasic(t)
|
||||
|
||||
require.False(t, th.App.HasPermissionTo(th.BasicUser.Id, model.PermissionManageSharedChannels),
|
||||
"precondition: BasicUser must not have manage_shared_channels for this subtest")
|
||||
|
||||
rc := seedRemote(t, th)
|
||||
|
||||
commandProvider := ShareProvider{}
|
||||
channel := th.CreateChannel(t, th.BasicTeam, WithShared(false))
|
||||
|
||||
args := &model.CommandArgs{
|
||||
T: func(s string, args ...any) string { return s },
|
||||
ChannelId: channel.Id,
|
||||
UserId: th.BasicUser.Id,
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Command: "/share-channel invite --connectionID",
|
||||
}
|
||||
|
||||
items, err := commandProvider.GetAutoCompleteListItems(th.Context, th.App, args, connectionIDArg(), "/share-channel invite ", "")
|
||||
|
||||
if err == nil {
|
||||
assert.Empty(t, items, "expected empty autocomplete list when caller lacks manage_shared_channels")
|
||||
}
|
||||
assertNoRemoteData(t, items, rc)
|
||||
})
|
||||
|
||||
t.Run("uninvite without manage_shared_channels permission returns no remote cluster data", func(t *testing.T) {
|
||||
th := setupForSharedChannels(t).initBasic(t)
|
||||
|
||||
require.False(t, th.App.HasPermissionTo(th.BasicUser.Id, model.PermissionManageSharedChannels),
|
||||
"precondition: BasicUser must not have manage_shared_channels for this subtest")
|
||||
|
||||
rc := seedRemote(t, th)
|
||||
|
||||
commandProvider := ShareProvider{}
|
||||
channel := th.CreateChannel(t, th.BasicTeam, WithShared(false))
|
||||
|
||||
args := &model.CommandArgs{
|
||||
T: func(s string, args ...any) string { return s },
|
||||
ChannelId: channel.Id,
|
||||
UserId: th.BasicUser.Id,
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Command: "/share-channel uninvite --connectionID",
|
||||
}
|
||||
|
||||
items, err := commandProvider.GetAutoCompleteListItems(th.Context, th.App, args, connectionIDArg(), "/share-channel uninvite ", "")
|
||||
|
||||
if err == nil {
|
||||
assert.Empty(t, items, "expected empty autocomplete list when caller lacks manage_shared_channels")
|
||||
}
|
||||
assertNoRemoteData(t, items, rc)
|
||||
})
|
||||
|
||||
t.Run("invite with manage_shared_channels permission returns remote cluster data", func(t *testing.T) {
|
||||
th := setupForSharedChannels(t).initBasic(t)
|
||||
th.addPermissionToRole(t, model.PermissionManageSharedChannels.Id, th.BasicUser.Roles)
|
||||
|
||||
rc := seedRemote(t, th)
|
||||
|
||||
commandProvider := ShareProvider{}
|
||||
channel := th.CreateChannel(t, th.BasicTeam, WithShared(false))
|
||||
|
||||
args := &model.CommandArgs{
|
||||
T: func(s string, args ...any) string { return s },
|
||||
ChannelId: channel.Id,
|
||||
UserId: th.BasicUser.Id,
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Command: "/share-channel invite --connectionID",
|
||||
}
|
||||
|
||||
items, err := commandProvider.GetAutoCompleteListItems(th.Context, th.App, args, connectionIDArg(), "/share-channel invite ", "")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, items, "expected at least one autocomplete item when caller has manage_shared_channels")
|
||||
|
||||
found := false
|
||||
for _, item := range items {
|
||||
if item.Item == rc.RemoteId {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found, "expected seeded RemoteId %q to appear in autocomplete items when caller has manage_shared_channels", rc.RemoteId)
|
||||
})
|
||||
|
||||
t.Run("uninvite with manage_shared_channels permission returns remote cluster data", func(t *testing.T) {
|
||||
th := setupForSharedChannels(t).initBasic(t)
|
||||
th.addPermissionToRole(t, model.PermissionManageSharedChannels.Id, th.BasicUser.Roles)
|
||||
|
||||
rc := seedRemote(t, th)
|
||||
|
||||
commandProvider := ShareProvider{}
|
||||
channel := th.CreateChannel(t, th.BasicTeam, WithShared(false))
|
||||
|
||||
args := &model.CommandArgs{
|
||||
T: func(s string, args ...any) string { return s },
|
||||
ChannelId: channel.Id,
|
||||
UserId: th.BasicUser.Id,
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Command: "/share-channel uninvite --connectionID",
|
||||
}
|
||||
|
||||
items, err := commandProvider.GetAutoCompleteListItems(th.Context, th.App, args, connectionIDArg(), "/share-channel uninvite ", "")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, items, "expected at least one autocomplete item when caller has manage_shared_channels")
|
||||
|
||||
found := false
|
||||
for _, item := range items {
|
||||
if item.Item == rc.RemoteId {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found, "expected seeded RemoteId %q to appear in autocomplete items when caller has manage_shared_channels", rc.RemoteId)
|
||||
})
|
||||
}
|
||||
|
||||
func TestShareProviderGetAutoCompleteListItemsAdjacentRoles(t *testing.T) {
|
||||
connectionIDArg := func() *model.AutocompleteArg {
|
||||
return &model.AutocompleteArg{Name: "connectionID"}
|
||||
}
|
||||
|
||||
seedRemote := func(t *testing.T, th *TestHelper) *model.RemoteCluster {
|
||||
t.Helper()
|
||||
rc, err := th.App.AddRemoteCluster(&model.RemoteCluster{
|
||||
RemoteId: model.NewId(),
|
||||
Name: "remote-" + model.NewId(),
|
||||
DisplayName: "Adjacent Sentinel Display",
|
||||
SiteURL: "https://adjacent-sentinel.example.com",
|
||||
Token: model.NewId(),
|
||||
Topics: "topic",
|
||||
CreateAt: model.GetMillis(),
|
||||
LastPingAt: model.GetMillis(),
|
||||
CreatorId: model.NewId(),
|
||||
})
|
||||
require.Nil(t, err)
|
||||
return rc
|
||||
}
|
||||
|
||||
assertNoRemoteData := func(t *testing.T, items []model.AutocompleteListItem, rc *model.RemoteCluster) {
|
||||
t.Helper()
|
||||
for i, item := range items {
|
||||
assert.NotContains(t, item.Item, rc.RemoteId, "item[%d].Item contained RemoteId", i)
|
||||
assert.NotContains(t, item.HelpText, rc.RemoteId, "item[%d].HelpText contained RemoteId", i)
|
||||
assert.NotContains(t, item.HelpText, rc.DisplayName, "item[%d].HelpText contained DisplayName", i)
|
||||
assert.NotContains(t, item.HelpText, rc.SiteURL, "item[%d].HelpText contained SiteURL", i)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("guest user receives no remote cluster data on invite autocomplete", func(t *testing.T) {
|
||||
th := setupForSharedChannels(t).initBasic(t)
|
||||
|
||||
guest := th.createGuest(t)
|
||||
require.False(t, th.App.HasPermissionTo(guest.Id, model.PermissionManageSharedChannels),
|
||||
"precondition: a freshly-created guest must not have manage_shared_channels")
|
||||
|
||||
rc := seedRemote(t, th)
|
||||
|
||||
commandProvider := ShareProvider{}
|
||||
channel := th.CreateChannel(t, th.BasicTeam, WithShared(false))
|
||||
|
||||
args := &model.CommandArgs{
|
||||
T: func(s string, args ...any) string { return s },
|
||||
ChannelId: channel.Id,
|
||||
UserId: guest.Id,
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Command: "/share-channel invite --connectionID",
|
||||
}
|
||||
|
||||
items, err := commandProvider.GetAutoCompleteListItems(th.Context, th.App, args, connectionIDArg(), "/share-channel invite ", "")
|
||||
if err == nil {
|
||||
assert.Empty(t, items, "expected empty autocomplete list for guest on invite")
|
||||
}
|
||||
assertNoRemoteData(t, items, rc)
|
||||
})
|
||||
|
||||
t.Run("guest user receives no remote cluster data on uninvite autocomplete", func(t *testing.T) {
|
||||
th := setupForSharedChannels(t).initBasic(t)
|
||||
|
||||
guest := th.createGuest(t)
|
||||
require.False(t, th.App.HasPermissionTo(guest.Id, model.PermissionManageSharedChannels),
|
||||
"precondition: a freshly-created guest must not have manage_shared_channels")
|
||||
|
||||
rc := seedRemote(t, th)
|
||||
|
||||
commandProvider := ShareProvider{}
|
||||
channel := th.CreateChannel(t, th.BasicTeam, WithShared(false))
|
||||
|
||||
args := &model.CommandArgs{
|
||||
T: func(s string, args ...any) string { return s },
|
||||
ChannelId: channel.Id,
|
||||
UserId: guest.Id,
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Command: "/share-channel uninvite --connectionID",
|
||||
}
|
||||
|
||||
items, err := commandProvider.GetAutoCompleteListItems(th.Context, th.App, args, connectionIDArg(), "/share-channel uninvite ", "")
|
||||
if err == nil {
|
||||
assert.Empty(t, items, "expected empty autocomplete list for guest on uninvite")
|
||||
}
|
||||
assertNoRemoteData(t, items, rc)
|
||||
})
|
||||
|
||||
t.Run("system admin receives remote cluster data on invite", func(t *testing.T) {
|
||||
th := setupForSharedChannels(t).initBasic(t)
|
||||
|
||||
require.True(t, th.App.HasPermissionTo(th.SystemAdminUser.Id, model.PermissionManageSharedChannels),
|
||||
"precondition: SystemAdminUser must have manage_shared_channels via inherited permissions")
|
||||
|
||||
rc := seedRemote(t, th)
|
||||
|
||||
commandProvider := ShareProvider{}
|
||||
channel := th.CreateChannel(t, th.BasicTeam, WithShared(false))
|
||||
|
||||
args := &model.CommandArgs{
|
||||
T: func(s string, args ...any) string { return s },
|
||||
ChannelId: channel.Id,
|
||||
UserId: th.SystemAdminUser.Id,
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Command: "/share-channel invite --connectionID",
|
||||
}
|
||||
|
||||
items, err := commandProvider.GetAutoCompleteListItems(th.Context, th.App, args, connectionIDArg(), "/share-channel invite ", "")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, items, "expected at least one autocomplete item for system admin")
|
||||
|
||||
found := false
|
||||
for _, item := range items {
|
||||
if item.Item == rc.RemoteId {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found, "expected seeded RemoteId %q to appear for system admin", rc.RemoteId)
|
||||
})
|
||||
|
||||
t.Run("system admin receives remote cluster data on uninvite", func(t *testing.T) {
|
||||
th := setupForSharedChannels(t).initBasic(t)
|
||||
|
||||
require.True(t, th.App.HasPermissionTo(th.SystemAdminUser.Id, model.PermissionManageSharedChannels),
|
||||
"precondition: SystemAdminUser must have manage_shared_channels via inherited permissions")
|
||||
|
||||
rc := seedRemote(t, th)
|
||||
|
||||
commandProvider := ShareProvider{}
|
||||
channel := th.CreateChannel(t, th.BasicTeam, WithShared(false))
|
||||
|
||||
args := &model.CommandArgs{
|
||||
T: func(s string, args ...any) string { return s },
|
||||
ChannelId: channel.Id,
|
||||
UserId: th.SystemAdminUser.Id,
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Command: "/share-channel uninvite --connectionID",
|
||||
}
|
||||
|
||||
items, err := commandProvider.GetAutoCompleteListItems(th.Context, th.App, args, connectionIDArg(), "/share-channel uninvite ", "")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, items, "expected at least one autocomplete item for system admin")
|
||||
|
||||
found := false
|
||||
for _, item := range items {
|
||||
if item.Item == rc.RemoteId {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found, "expected seeded RemoteId %q to appear for system admin", rc.RemoteId)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -268,18 +268,20 @@ func (a *App) SyncSyncableRoles(rctx request.CTX, syncableID string, syncableTyp
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncRolesAndMembership updates the SchemeAdmin status and membership of all of the members of the given
|
||||
// syncable.
|
||||
func (a *App) SyncRolesAndMembership(rctx request.CTX, syncableID string, syncableType model.GroupSyncableType, groupID string) {
|
||||
// SyncRolesAndMembership updates the membership of the given syncable and,
|
||||
// when syncRoles is true, also reconciles SchemeAdmin status for its members.
|
||||
func (a *App) SyncRolesAndMembership(rctx request.CTX, syncableID string, syncableType model.GroupSyncableType, groupID string, syncRoles bool) {
|
||||
group, appErr := a.GetGroup(groupID, nil, nil)
|
||||
if appErr != nil {
|
||||
rctx.Logger().Warn("Error getting group", mlog.Err(appErr))
|
||||
return
|
||||
}
|
||||
|
||||
appErr = a.SyncSyncableRoles(rctx, syncableID, syncableType)
|
||||
if appErr != nil {
|
||||
rctx.Logger().Warn("Error syncing syncable roles", mlog.Err(appErr))
|
||||
if syncRoles {
|
||||
appErr = a.SyncSyncableRoles(rctx, syncableID, syncableType)
|
||||
if appErr != nil {
|
||||
rctx.Logger().Warn("Error syncing syncable roles", mlog.Err(appErr))
|
||||
}
|
||||
}
|
||||
|
||||
var since int64
|
||||
|
||||
@@ -677,3 +677,147 @@ func TestSyncSyncableRoles(t *testing.T) {
|
||||
require.True(t, cm.SchemeAdmin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncRolesAndMembership_RoleSyncGate(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
|
||||
setup := func(t *testing.T) (*model.Team, *model.Channel, *model.Group, *model.User) {
|
||||
t.Helper()
|
||||
|
||||
team := th.CreateTeam()
|
||||
channel := th.CreateChannel(th.Context, team)
|
||||
group := th.CreateGroup()
|
||||
|
||||
_, err := th.App.UpsertGroupSyncable(&model.GroupSyncable{
|
||||
SyncableId: team.Id,
|
||||
Type: model.GroupSyncableTypeTeam,
|
||||
GroupId: group.Id,
|
||||
AutoAdd: true,
|
||||
})
|
||||
require.Nil(t, err)
|
||||
|
||||
_, err = th.App.UpsertGroupSyncable(&model.GroupSyncable{
|
||||
SyncableId: channel.Id,
|
||||
Type: model.GroupSyncableTypeChannel,
|
||||
GroupId: group.Id,
|
||||
AutoAdd: true,
|
||||
})
|
||||
require.Nil(t, err)
|
||||
|
||||
directAdmin := th.CreateUser()
|
||||
_, appErr := th.App.AddTeamMember(th.Context, team.Id, directAdmin.Id)
|
||||
require.Nil(t, appErr)
|
||||
_, appErr = th.App.AddUserToChannel(th.Context, directAdmin, channel, false)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
tm, storeErr := th.App.Srv().Store().Team().GetMember(th.Context, team.Id, directAdmin.Id)
|
||||
require.NoError(t, storeErr)
|
||||
tm.SchemeAdmin = true
|
||||
_, storeErr = th.App.Srv().Store().Team().UpdateMember(th.Context, tm)
|
||||
require.NoError(t, storeErr)
|
||||
|
||||
cm, storeErr := th.App.Srv().Store().Channel().GetMember(th.Context.Context(), channel.Id, directAdmin.Id)
|
||||
require.NoError(t, storeErr)
|
||||
cm.SchemeAdmin = true
|
||||
_, storeErr = th.App.Srv().Store().Channel().UpdateMember(th.Context, cm)
|
||||
require.NoError(t, storeErr)
|
||||
|
||||
return team, channel, group, directAdmin
|
||||
}
|
||||
|
||||
t.Run("syncRoles=false preserves the existing SchemeAdmin on team members", func(t *testing.T) {
|
||||
team, _, group, directAdmin := setup(t)
|
||||
|
||||
th.App.SyncRolesAndMembership(th.Context, team.Id, model.GroupSyncableTypeTeam, group.Id, false)
|
||||
|
||||
tm, appErr := th.App.GetTeamMember(th.Context, team.Id, directAdmin.Id)
|
||||
require.Nil(t, appErr)
|
||||
assert.True(t, tm.SchemeAdmin)
|
||||
})
|
||||
|
||||
t.Run("syncRoles=false preserves the existing SchemeAdmin on channel members", func(t *testing.T) {
|
||||
_, channel, group, directAdmin := setup(t)
|
||||
|
||||
th.App.SyncRolesAndMembership(th.Context, channel.Id, model.GroupSyncableTypeChannel, group.Id, false)
|
||||
|
||||
cm, appErr := th.App.GetChannelMember(th.Context, channel.Id, directAdmin.Id)
|
||||
require.Nil(t, appErr)
|
||||
assert.True(t, cm.SchemeAdmin)
|
||||
})
|
||||
|
||||
t.Run("syncRoles=true reconciles team SchemeAdmin against PermittedSyncableAdmins", func(t *testing.T) {
|
||||
team, _, group, directAdmin := setup(t)
|
||||
|
||||
th.App.SyncRolesAndMembership(th.Context, team.Id, model.GroupSyncableTypeTeam, group.Id, true)
|
||||
|
||||
tm, appErr := th.App.GetTeamMember(th.Context, team.Id, directAdmin.Id)
|
||||
require.Nil(t, appErr)
|
||||
assert.False(t, tm.SchemeAdmin)
|
||||
})
|
||||
|
||||
t.Run("syncRoles=true reconciles channel SchemeAdmin against PermittedSyncableAdmins", func(t *testing.T) {
|
||||
_, channel, group, directAdmin := setup(t)
|
||||
|
||||
th.App.SyncRolesAndMembership(th.Context, channel.Id, model.GroupSyncableTypeChannel, group.Id, true)
|
||||
|
||||
cm, appErr := th.App.GetChannelMember(th.Context, channel.Id, directAdmin.Id)
|
||||
require.Nil(t, appErr)
|
||||
assert.False(t, cm.SchemeAdmin)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSyncRolesAndMembership_AlwaysSyncsMembership(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
|
||||
setup := func(t *testing.T) (*model.Team, *model.Channel, *model.Group, *model.User) {
|
||||
t.Helper()
|
||||
|
||||
team := th.CreateTeam()
|
||||
channel := th.CreateChannel(th.Context, team)
|
||||
group := th.CreateGroup()
|
||||
|
||||
_, err := th.App.UpsertGroupSyncable(&model.GroupSyncable{
|
||||
SyncableId: team.Id,
|
||||
Type: model.GroupSyncableTypeTeam,
|
||||
GroupId: group.Id,
|
||||
AutoAdd: true,
|
||||
})
|
||||
require.Nil(t, err)
|
||||
|
||||
_, err = th.App.UpsertGroupSyncable(&model.GroupSyncable{
|
||||
SyncableId: channel.Id,
|
||||
Type: model.GroupSyncableTypeChannel,
|
||||
GroupId: group.Id,
|
||||
AutoAdd: true,
|
||||
})
|
||||
require.Nil(t, err)
|
||||
|
||||
groupMember := th.CreateUser()
|
||||
_, err = th.App.UpsertGroupMember(group.Id, groupMember.Id)
|
||||
require.Nil(t, err)
|
||||
|
||||
return team, channel, group, groupMember
|
||||
}
|
||||
|
||||
t.Run("syncRoles=false still adds group members to the team", func(t *testing.T) {
|
||||
team, _, group, groupMember := setup(t)
|
||||
|
||||
th.App.SyncRolesAndMembership(th.Context, team.Id, model.GroupSyncableTypeTeam, group.Id, false)
|
||||
|
||||
tm, appErr := th.App.GetTeamMember(th.Context, team.Id, groupMember.Id)
|
||||
require.Nil(t, appErr)
|
||||
assert.Equal(t, groupMember.Id, tm.UserId)
|
||||
})
|
||||
|
||||
t.Run("syncRoles=false still adds group members to the channel", func(t *testing.T) {
|
||||
_, channel, group, groupMember := setup(t)
|
||||
|
||||
th.App.SyncRolesAndMembership(th.Context, channel.Id, model.GroupSyncableTypeChannel, group.Id, false)
|
||||
|
||||
cm, appErr := th.App.GetChannelMember(th.Context, channel.Id, groupMember.Id)
|
||||
require.Nil(t, appErr)
|
||||
assert.Equal(t, groupMember.Id, cm.UserId)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ import (
|
||||
const minFirstPartSize = 5 * 1024 * 1024 // 5MB
|
||||
|
||||
func (a *App) genFileInfoFromReader(name string, file io.ReadSeeker, size int64) (*model.FileInfo, error) {
|
||||
name = model.SanitizeFilename(name)
|
||||
if name == "" {
|
||||
return nil, model.NewAppError("genFileInfoFromReader", "app.upload.gen_file_info.invalid_filename.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
|
||||
info := &model.FileInfo{
|
||||
@@ -276,7 +281,13 @@ func (a *App) UploadData(c request.CTX, us *model.UploadSession, rd io.Reader) (
|
||||
info, genErr := a.genFileInfoFromReader(us.Filename, file, us.FileSize)
|
||||
file.Close()
|
||||
if genErr != nil {
|
||||
return nil, model.NewAppError("UploadData", "app.upload.upload_data.gen_info.app_error", nil, "", http.StatusInternalServerError).Wrap(genErr)
|
||||
var appErr *model.AppError
|
||||
switch {
|
||||
case errors.As(genErr, &appErr):
|
||||
return nil, appErr
|
||||
default:
|
||||
return nil, model.NewAppError("UploadData", "app.upload.upload_data.gen_info.app_error", nil, "", http.StatusInternalServerError).Wrap(genErr)
|
||||
}
|
||||
}
|
||||
|
||||
info.CreatorId = us.UserId
|
||||
|
||||
@@ -1037,6 +1037,10 @@ func (a *App) userDeactivated(c request.CTX, userID string) *model.AppError {
|
||||
c.Logger().Warn("unable to remove auth data by user id", mlog.Err(nErr))
|
||||
}
|
||||
|
||||
if nErr := a.Srv().Store().OAuth().PermanentDeleteAuthDataByUser(userID); nErr != nil {
|
||||
c.Logger().Warn("unable to remove oauth access data by user id", mlog.Err(nErr))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2570,6 +2574,10 @@ func (a *App) PromoteGuestToUser(c request.CTX, user *model.User, requestorId st
|
||||
// DemoteUserToGuest Convert user's roles and all his membership's roles from
|
||||
// regular user roles to guest roles.
|
||||
func (a *App) DemoteUserToGuest(c request.CTX, user *model.User) *model.AppError {
|
||||
if user.IsBot {
|
||||
return model.NewAppError("DemoteUserToGuest", "api.user.demote_user_to_guest.bot_not_allowed.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
demotedUser, nErr := a.ch.srv.userService.DemoteUserToGuest(user)
|
||||
a.InvalidateCacheForUser(user.Id)
|
||||
if nErr != nil {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -420,6 +421,71 @@ func TestUpdateActiveBotsSideEffect(t *testing.T) {
|
||||
require.Nil(t, appErr)
|
||||
}
|
||||
|
||||
func TestUserDeactivationRevokesOAuthAccessTokens(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = true })
|
||||
|
||||
oapp := &model.OAuthApp{
|
||||
Name: "DeactivationCleanup_" + model.NewRandomString(10),
|
||||
CreatorId: th.BasicUser2.Id,
|
||||
Homepage: "https://nowhere.com",
|
||||
Description: "test",
|
||||
CallbackUrls: []string{"https://example.com/callback"},
|
||||
ClientSecret: model.NewId(),
|
||||
}
|
||||
oapp, appErr := th.App.CreateOAuthApp(oapp)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
user := th.CreateUser()
|
||||
|
||||
authRequest := &model.AuthorizeRequest{
|
||||
ResponseType: model.AuthCodeResponseType,
|
||||
ClientId: oapp.Id,
|
||||
RedirectURI: oapp.CallbackUrls[0],
|
||||
Scope: "user",
|
||||
State: "test_state",
|
||||
}
|
||||
|
||||
redirectURL, appErr := th.App.AllowOAuthAppAccessToUser(th.Context, user.Id, authRequest)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
uri, parseErr := url.Parse(redirectURL)
|
||||
require.NoError(t, parseErr)
|
||||
code := uri.Query().Get("code")
|
||||
require.NotEmpty(t, code)
|
||||
|
||||
tokenResp, appErr := th.App.GetOAuthAccessTokenForCodeFlow(
|
||||
th.Context,
|
||||
oapp.Id,
|
||||
model.AccessTokenGrantType,
|
||||
oapp.CallbackUrls[0],
|
||||
code,
|
||||
oapp.ClientSecret,
|
||||
"",
|
||||
)
|
||||
require.Nil(t, appErr)
|
||||
require.NotEmpty(t, tokenResp.AccessToken)
|
||||
require.NotEmpty(t, tokenResp.RefreshToken)
|
||||
|
||||
require.NoError(t, th.App.Srv().Store().Session().Remove(tokenResp.AccessToken))
|
||||
|
||||
preDeactivation, sErr := th.App.Srv().Store().OAuth().GetAccessDataByUserForApp(user.Id, oapp.Id)
|
||||
require.NoError(t, sErr)
|
||||
require.NotEmpty(t, preDeactivation)
|
||||
|
||||
_, appErr = th.App.UpdateActive(th.Context, user, false)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
postDeactivation, sErr := th.App.Srv().Store().OAuth().GetAccessDataByUserForApp(user.Id, oapp.Id)
|
||||
require.NoError(t, sErr)
|
||||
require.Empty(t, postDeactivation, "oauth access tokens for an inactive user must be removed")
|
||||
|
||||
_, sErr = th.App.Srv().Store().OAuth().GetAccessDataByRefreshToken(tokenResp.RefreshToken)
|
||||
require.Error(t, sErr, "refresh token row for an inactive user must be removed")
|
||||
}
|
||||
|
||||
func TestUpdateOAuthUserAttrs(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t)
|
||||
@@ -1844,6 +1910,18 @@ func TestDemoteUserToGuest(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
t.Run("Must reject bot user", func(t *testing.T) {
|
||||
bot := th.CreateBot()
|
||||
user, err := th.App.GetUser(bot.UserId)
|
||||
require.Nil(t, err)
|
||||
require.True(t, user.IsBot)
|
||||
|
||||
appErr := th.App.DemoteUserToGuest(th.Context, user)
|
||||
require.NotNil(t, appErr)
|
||||
assert.Equal(t, "api.user.demote_user_to_guest.bot_not_allowed.app_error", appErr.Id)
|
||||
assert.Equal(t, http.StatusBadRequest, appErr.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("Must invalidate channel stats cache when demoting a user", func(t *testing.T) {
|
||||
user := th.CreateUser()
|
||||
require.Equal(t, "system_user", user.Roles)
|
||||
|
||||
@@ -387,6 +387,29 @@ func (a *App) CreateWebhookPost(c request.CTX, userID string, channel *model.Cha
|
||||
return splits[0], nil
|
||||
}
|
||||
|
||||
// ValidateIncomingWebhookUser ensures a user being assigned as an incoming webhook's owner can
|
||||
// legitimately be attributed posts in the target channel: the user must have access to the
|
||||
// channel and must not hold privileges the requester lacks, so a requester cannot forge posts
|
||||
// as a non-member or higher-privileged user.
|
||||
func (a *App) ValidateIncomingWebhookUser(rctx request.CTX, session model.Session, user *model.User, channel *model.Channel) *model.AppError {
|
||||
if user.IsSystemAdmin() && !a.SessionHasPermissionTo(session, model.PermissionManageSystem) {
|
||||
return model.NewAppError("ValidateIncomingWebhookUser", "api.webhook.incoming.user_role.app_error", nil, "user_id="+user.Id, http.StatusForbidden)
|
||||
}
|
||||
|
||||
return a.ValidateIncomingWebhookUserChannelAccess(rctx, user.Id, channel)
|
||||
}
|
||||
|
||||
// ValidateIncomingWebhookUserChannelAccess ensures the webhook owner can read the channel its
|
||||
// posts are attributed to, preventing attribution to a user who is not a member of the channel
|
||||
// (or its team, for open channels).
|
||||
func (a *App) ValidateIncomingWebhookUserChannelAccess(rctx request.CTX, userID string, channel *model.Channel) *model.AppError {
|
||||
if hasPermission, _ := a.HasPermissionToChannel(rctx, userID, channel.Id, model.PermissionReadChannelContent); !hasPermission {
|
||||
return model.NewAppError("ValidateIncomingWebhookUserChannelAccess", "api.webhook.incoming.user_membership.app_error", nil, "user_id="+userID+", channel_id="+channel.Id, http.StatusForbidden)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) CreateIncomingWebhookForChannel(creatorId string, channel *model.Channel, hook *model.IncomingWebhook) (*model.IncomingWebhook, *model.AppError) {
|
||||
if !*a.Config().ServiceSettings.EnableIncomingWebhooks {
|
||||
return nil, model.NewAppError("CreateIncomingWebhookForChannel", "api.incoming_webhook.disabled.app_error", nil, "", http.StatusNotImplemented)
|
||||
@@ -797,6 +820,17 @@ func (a *App) HandleIncomingWebhook(c request.CTX, hookID string, req *model.Inc
|
||||
if nErr != nil {
|
||||
return model.NewAppError("HandleIncomingWebhook", "web.incoming_webhook.user.app_error", map[string]any{"user": channelName[1:]}, "", http.StatusBadRequest).Wrap(nErr)
|
||||
}
|
||||
// Only allow a DM target the webhook owner shares a team with, so the stored
|
||||
// user_id cannot be used to reach users the owner could not message directly.
|
||||
if hook.UserId != result.Id {
|
||||
commonTeamIDs, teamErr := a.GetCommonTeamIDsForTwoUsers(hook.UserId, result.Id)
|
||||
if teamErr != nil {
|
||||
return teamErr
|
||||
}
|
||||
if len(commonTeamIDs) == 0 {
|
||||
return model.NewAppError("HandleIncomingWebhook", "web.incoming_webhook.permissions.app_error", map[string]any{"user": hook.UserId, "channel": channelName}, "", http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
ch, err := a.GetOrCreateDirectChannel(c, hook.UserId, result.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -22,6 +22,37 @@ import (
|
||||
"github.com/mattermost/mattermost/server/v8/channels/testlib"
|
||||
)
|
||||
|
||||
func TestHandleIncomingWebhookDirectMessage(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableIncomingWebhooks = true })
|
||||
|
||||
hook, appErr := th.App.CreateIncomingWebhookForChannel(th.BasicUser.Id, th.BasicChannel, &model.IncomingWebhook{ChannelId: th.BasicChannel.Id, ChannelLocked: false})
|
||||
require.Nil(t, appErr)
|
||||
defer func() {
|
||||
require.Nil(t, th.App.DeleteIncomingWebhook(hook.Id))
|
||||
}()
|
||||
|
||||
t.Run("rejects DM to a user the owner shares no team with", func(t *testing.T) {
|
||||
stranger := th.CreateUser()
|
||||
err := th.App.HandleIncomingWebhook(th.Context, hook.Id, &model.IncomingWebhookRequest{
|
||||
Text: "out of team dm",
|
||||
ChannelName: "@" + stranger.Username,
|
||||
})
|
||||
require.NotNil(t, err)
|
||||
assert.Equal(t, http.StatusForbidden, err.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("allows DM to a user the owner shares a team with", func(t *testing.T) {
|
||||
err := th.App.HandleIncomingWebhook(th.Context, hook.Id, &model.IncomingWebhookRequest{
|
||||
Text: "team dm",
|
||||
ChannelName: "@" + th.BasicUser2.Username,
|
||||
})
|
||||
require.Nil(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateIncomingWebhookForChannel(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic()
|
||||
|
||||
@@ -279,6 +279,12 @@ channels/db/migrations/mysql/000140_add_lastmemberssyncat_to_sharedchannelremote
|
||||
channels/db/migrations/mysql/000140_add_lastmemberssyncat_to_sharedchannelremotes.up.sql
|
||||
channels/db/migrations/mysql/000141_add_remoteid_channelid_to_post_acknowledgements.down.sql
|
||||
channels/db/migrations/mysql/000141_add_remoteid_channelid_to_post_acknowledgements.up.sql
|
||||
channels/db/migrations/mysql/000142_add_schemeid_to_roles.down.sql
|
||||
channels/db/migrations/mysql/000142_add_schemeid_to_roles.up.sql
|
||||
channels/db/migrations/mysql/000143_backfill_roles_schemeid.down.sql
|
||||
channels/db/migrations/mysql/000143_backfill_roles_schemeid.up.sql
|
||||
channels/db/migrations/mysql/000144_add_roles_schemeid_index.down.sql
|
||||
channels/db/migrations/mysql/000144_add_roles_schemeid_index.up.sql
|
||||
channels/db/migrations/postgres/000001_create_teams.down.sql
|
||||
channels/db/migrations/postgres/000001_create_teams.up.sql
|
||||
channels/db/migrations/postgres/000002_create_team_members.down.sql
|
||||
@@ -559,3 +565,9 @@ channels/db/migrations/postgres/000140_add_lastmemberssyncat_to_sharedchannelrem
|
||||
channels/db/migrations/postgres/000140_add_lastmemberssyncat_to_sharedchannelremotes.up.sql
|
||||
channels/db/migrations/postgres/000141_add_remoteid_channelid_to_post_acknowledgements.down.sql
|
||||
channels/db/migrations/postgres/000141_add_remoteid_channelid_to_post_acknowledgements.up.sql
|
||||
channels/db/migrations/postgres/000142_add_schemeid_to_roles.down.sql
|
||||
channels/db/migrations/postgres/000142_add_schemeid_to_roles.up.sql
|
||||
channels/db/migrations/postgres/000143_backfill_roles_schemeid.down.sql
|
||||
channels/db/migrations/postgres/000143_backfill_roles_schemeid.up.sql
|
||||
channels/db/migrations/postgres/000144_add_roles_schemeid_index.down.sql
|
||||
channels/db/migrations/postgres/000144_add_roles_schemeid_index.up.sql
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE table_name = 'Roles'
|
||||
AND table_schema = DATABASE()
|
||||
AND column_name = 'SchemeId'
|
||||
) > 0,
|
||||
'ALTER TABLE Roles DROP COLUMN SchemeId;',
|
||||
'SELECT 1'
|
||||
));
|
||||
|
||||
PREPARE dropColumnIfExists FROM @preparedStatement;
|
||||
EXECUTE dropColumnIfExists;
|
||||
DEALLOCATE PREPARE dropColumnIfExists;
|
||||
@@ -0,0 +1,14 @@
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE table_name = 'Roles'
|
||||
AND table_schema = DATABASE()
|
||||
AND column_name = 'SchemeId'
|
||||
) > 0,
|
||||
'SELECT 1',
|
||||
'ALTER TABLE Roles ADD COLUMN SchemeId varchar(26);'
|
||||
));
|
||||
|
||||
PREPARE addColumnIfNotExists FROM @preparedStatement;
|
||||
EXECUTE addColumnIfNotExists;
|
||||
DEALLOCATE PREPARE addColumnIfNotExists;
|
||||
@@ -0,0 +1 @@
|
||||
UPDATE Roles SET SchemeId = NULL;
|
||||
@@ -0,0 +1,19 @@
|
||||
UPDATE Roles r
|
||||
INNER JOIN (
|
||||
SELECT role_name, MIN(scheme_id) AS scheme_id
|
||||
FROM (
|
||||
SELECT Id AS scheme_id, DefaultTeamAdminRole AS role_name FROM Schemes
|
||||
UNION ALL SELECT Id, DefaultTeamUserRole FROM Schemes
|
||||
UNION ALL SELECT Id, DefaultTeamGuestRole FROM Schemes
|
||||
UNION ALL SELECT Id, DefaultChannelAdminRole FROM Schemes
|
||||
UNION ALL SELECT Id, DefaultChannelUserRole FROM Schemes
|
||||
UNION ALL SELECT Id, DefaultChannelGuestRole FROM Schemes
|
||||
UNION ALL SELECT Id, DefaultPlaybookAdminRole FROM Schemes
|
||||
UNION ALL SELECT Id, DefaultPlaybookMemberRole FROM Schemes
|
||||
UNION ALL SELECT Id, DefaultRunAdminRole FROM Schemes
|
||||
UNION ALL SELECT Id, DefaultRunMemberRole FROM Schemes
|
||||
) expanded
|
||||
WHERE role_name IS NOT NULL AND role_name <> ''
|
||||
GROUP BY role_name
|
||||
) m ON r.Name = m.role_name
|
||||
SET r.SchemeId = m.scheme_id;
|
||||
@@ -0,0 +1,14 @@
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE table_name = 'Roles'
|
||||
AND table_schema = DATABASE()
|
||||
AND index_name = 'idx_roles_scheme_id'
|
||||
) > 0,
|
||||
'DROP INDEX idx_roles_scheme_id ON Roles;',
|
||||
'SELECT 1'
|
||||
));
|
||||
|
||||
PREPARE removeIndexIfExists FROM @preparedStatement;
|
||||
EXECUTE removeIndexIfExists;
|
||||
DEALLOCATE PREPARE removeIndexIfExists;
|
||||
@@ -0,0 +1,14 @@
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE table_name = 'Roles'
|
||||
AND table_schema = DATABASE()
|
||||
AND index_name = 'idx_roles_scheme_id'
|
||||
) > 0,
|
||||
'SELECT 1',
|
||||
'CREATE INDEX idx_roles_scheme_id ON Roles(SchemeId);'
|
||||
));
|
||||
|
||||
PREPARE createIndexIfNotExists FROM @preparedStatement;
|
||||
EXECUTE createIndexIfNotExists;
|
||||
DEALLOCATE PREPARE createIndexIfNotExists;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE roles DROP COLUMN IF EXISTS schemeid;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE roles ADD COLUMN IF NOT EXISTS schemeid VARCHAR(26);
|
||||
@@ -0,0 +1 @@
|
||||
UPDATE roles SET schemeid = NULL;
|
||||
@@ -0,0 +1,23 @@
|
||||
UPDATE roles
|
||||
SET schemeid = match.scheme_id
|
||||
FROM (
|
||||
SELECT DISTINCT ON (role_name) id AS scheme_id, role_name
|
||||
FROM (
|
||||
SELECT id, unnest(ARRAY[
|
||||
defaultteamadminrole,
|
||||
defaultteamuserrole,
|
||||
defaultteamguestrole,
|
||||
defaultchanneladminrole,
|
||||
defaultchanneluserrole,
|
||||
defaultchannelguestrole,
|
||||
defaultplaybookadminrole,
|
||||
defaultplaybookmemberrole,
|
||||
defaultrunadminrole,
|
||||
defaultrunmemberrole
|
||||
]) AS role_name
|
||||
FROM schemes
|
||||
) expanded
|
||||
WHERE role_name IS NOT NULL AND role_name <> ''
|
||||
ORDER BY role_name, scheme_id
|
||||
) match
|
||||
WHERE roles.name = match.role_name;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- morph:nontransactional
|
||||
DROP INDEX CONCURRENTLY IF EXISTS idx_roles_scheme_id;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- morph:nontransactional
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_roles_scheme_id ON roles(schemeid);
|
||||
@@ -1621,7 +1621,7 @@ func testFileInfoSlashShouldNotBeCharSeparator(t *testing.T, th *SearchTestHelpe
|
||||
require.NoError(t, err)
|
||||
defer th.deleteUserPosts(th.User.Id)
|
||||
|
||||
p1, err := th.createFileInfo(th.User.Id, post.Id, post.ChannelId, "alpha/beta gamma, theta", "alpha/beta gamma, theta", "jpg", "image/jpeg", 0, 0)
|
||||
p1, err := th.createFileInfo(th.User.Id, post.Id, post.ChannelId, "testfile.jpg", "alpha/beta gamma, theta", "jpg", "image/jpeg", 0, 0)
|
||||
require.NoError(t, err)
|
||||
defer th.deleteUserFileInfos(th.User.Id)
|
||||
|
||||
|
||||
@@ -777,6 +777,7 @@ func (s *SqlGroupStore) getGroupSyncable(groupID string, syncableID string, sync
|
||||
groupSyncable.DeleteAt = groupTeam.DeleteAt
|
||||
groupSyncable.UpdateAt = groupTeam.UpdateAt
|
||||
groupSyncable.Type = syncableType
|
||||
groupSyncable.SchemeAdmin = groupTeam.SchemeAdmin
|
||||
case model.GroupSyncableTypeChannel:
|
||||
groupChannel := result.(*groupChannel)
|
||||
groupSyncable.SyncableId = groupChannel.ChannelId
|
||||
@@ -786,6 +787,7 @@ func (s *SqlGroupStore) getGroupSyncable(groupID string, syncableID string, sync
|
||||
groupSyncable.DeleteAt = groupChannel.DeleteAt
|
||||
groupSyncable.UpdateAt = groupChannel.UpdateAt
|
||||
groupSyncable.Type = syncableType
|
||||
groupSyncable.SchemeAdmin = groupChannel.SchemeAdmin
|
||||
default:
|
||||
return nil, fmt.Errorf("unable to convert syncableType: %s", syncableType.String())
|
||||
}
|
||||
@@ -1266,6 +1268,8 @@ func (s *SqlGroupStore) ChannelMembersToRemove(channelID *string) ([]*model.Chan
|
||||
Join("Channels ON Channels.Id = ChannelMembers.ChannelId").
|
||||
LeftJoin("Bots ON Bots.UserId = ChannelMembers.UserId").
|
||||
Where(sq.Eq{"Channels.DeleteAt": 0, "Channels.GroupConstrained": true, "Bots.UserId": nil}).
|
||||
// Only public/private channels support group sync; never treat other channel members as removable.
|
||||
Where(sq.Eq{"Channels.Type": []model.ChannelType{model.ChannelTypeOpen, model.ChannelTypePrivate}}).
|
||||
Where(whereStmt)
|
||||
|
||||
if channelID != nil {
|
||||
@@ -1834,7 +1838,7 @@ func (s *SqlGroupStore) AdminRoleGroupsForSyncableMember(userID, syncableID stri
|
||||
func (s *SqlGroupStore) PermittedSyncableAdmins(syncableID string, syncableType model.GroupSyncableType) ([]string, error) {
|
||||
builder := s.getQueryBuilder().Select("UserId").
|
||||
From(fmt.Sprintf("Group%ss", syncableType)).
|
||||
Join(fmt.Sprintf("GroupMembers ON GroupMembers.GroupId = Group%ss.GroupId AND Group%[1]ss.SchemeAdmin = TRUE AND GroupMembers.DeleteAt = 0", syncableType.String())).Where(fmt.Sprintf("Group%[1]ss.%[1]sId = ?", syncableType.String()), syncableID)
|
||||
Join(fmt.Sprintf("GroupMembers ON GroupMembers.GroupId = Group%ss.GroupId AND Group%[1]ss.SchemeAdmin = TRUE AND Group%[1]ss.DeleteAt = 0 AND GroupMembers.DeleteAt = 0", syncableType.String())).Where(fmt.Sprintf("Group%[1]ss.%[1]sId = ?", syncableType.String()), syncableID)
|
||||
|
||||
var userIDs []string
|
||||
if err := s.GetMaster().SelectBuilder(&userIDs, builder); err != nil {
|
||||
|
||||
@@ -33,6 +33,7 @@ type Role struct {
|
||||
Permissions string
|
||||
SchemeManaged bool
|
||||
BuiltIn bool
|
||||
SchemeId *string
|
||||
}
|
||||
|
||||
type channelRolesPermissions struct {
|
||||
@@ -66,6 +67,7 @@ func NewRoleFromModel(role *model.Role) *Role {
|
||||
Permissions: permissions,
|
||||
SchemeManaged: role.SchemeManaged,
|
||||
BuiltIn: role.BuiltIn,
|
||||
SchemeId: role.SchemeId,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +83,7 @@ func (role Role) ToModel() *model.Role {
|
||||
Permissions: strings.Fields(role.Permissions),
|
||||
SchemeManaged: role.SchemeManaged,
|
||||
BuiltIn: role.BuiltIn,
|
||||
SchemeId: role.SchemeId,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +93,7 @@ func newSqlRoleStore(sqlStore *SqlStore) store.RoleStore {
|
||||
}
|
||||
|
||||
s.tableSelectQuery = s.getQueryBuilder().
|
||||
Select("Id", "Name", "DisplayName", "Description", "CreateAt", "UpdateAt", "DeleteAt", "Permissions", "SchemeManaged", "BuiltIn").
|
||||
Select("Id", "Name", "DisplayName", "Description", "CreateAt", "UpdateAt", "DeleteAt", "Permissions", "SchemeManaged", "BuiltIn", "SchemeId").
|
||||
From("Roles")
|
||||
|
||||
return &s
|
||||
@@ -122,9 +125,10 @@ func (s *SqlRoleStore) Save(role *model.Role) (_ *model.Role, err error) {
|
||||
dbRole.UpdateAt = model.GetMillis()
|
||||
|
||||
res, err := s.GetMaster().NamedExec(`UPDATE Roles
|
||||
SET UpdateAt=:UpdateAt, DeleteAt=:DeleteAt, CreateAt=:CreateAt, Name=:Name, DisplayName=:DisplayName,
|
||||
Description=:Description, Permissions=:Permissions, SchemeManaged=:SchemeManaged, BuiltIn=:BuiltIn
|
||||
WHERE Id=:Id`, &dbRole)
|
||||
SET UpdateAt=:UpdateAt, DeleteAt=:DeleteAt, CreateAt=:CreateAt, Name=:Name, DisplayName=:DisplayName,
|
||||
Description=:Description, Permissions=:Permissions, SchemeManaged=:SchemeManaged, BuiltIn=:BuiltIn,
|
||||
SchemeId=:SchemeId
|
||||
WHERE Id=:Id`, &dbRole)
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to update Role")
|
||||
@@ -155,9 +159,9 @@ func (s *SqlRoleStore) createRole(role *model.Role, transaction *sqlxTxWrapper)
|
||||
dbRole.UpdateAt = dbRole.CreateAt
|
||||
|
||||
if _, err := transaction.NamedExec(`INSERT INTO Roles
|
||||
(Id, Name, DisplayName, Description, Permissions, CreateAt, UpdateAt, DeleteAt, SchemeManaged, BuiltIn)
|
||||
(Id, Name, DisplayName, Description, Permissions, CreateAt, UpdateAt, DeleteAt, SchemeManaged, BuiltIn, SchemeId)
|
||||
VALUES
|
||||
(:Id, :Name, :DisplayName, :Description, :Permissions, :CreateAt, :UpdateAt, :DeleteAt, :SchemeManaged, :BuiltIn)`, dbRole); err != nil {
|
||||
(:Id, :Name, :DisplayName, :Description, :Permissions, :CreateAt, :UpdateAt, :DeleteAt, :SchemeManaged, :BuiltIn, :SchemeId)`, dbRole); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to save Role")
|
||||
}
|
||||
|
||||
@@ -230,7 +234,7 @@ func (s *SqlRoleStore) GetByNames(names []string) ([]*model.Role, error) {
|
||||
err = rows.Scan(
|
||||
&role.Id, &role.Name, &role.DisplayName, &role.Description,
|
||||
&role.CreateAt, &role.UpdateAt, &role.DeleteAt, &role.Permissions,
|
||||
&role.SchemeManaged, &role.BuiltIn)
|
||||
&role.SchemeManaged, &role.BuiltIn, &role.SchemeId)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to scan values")
|
||||
}
|
||||
@@ -394,9 +398,10 @@ func (s *SqlRoleStore) AllChannelSchemeRoles() ([]*model.Role, error) {
|
||||
"Roles.Permissions",
|
||||
"Roles.SchemeManaged",
|
||||
"Roles.BuiltIn",
|
||||
"Roles.SchemeId",
|
||||
).
|
||||
From("Schemes").
|
||||
Join("Roles ON Schemes.DefaultChannelGuestRole = Roles.Name OR Schemes.DefaultChannelUserRole = Roles.Name OR Schemes.DefaultChannelAdminRole = Roles.Name").
|
||||
From("Roles").
|
||||
Join("Schemes ON Roles.SchemeId = Schemes.Id").
|
||||
Where(sq.Eq{"Schemes.Scope": model.SchemeScopeChannel}).
|
||||
Where(sq.Eq{"Roles.DeleteAt": 0}).
|
||||
Where(sq.Eq{"Schemes.DeleteAt": 0})
|
||||
@@ -433,13 +438,14 @@ func (s *SqlRoleStore) ChannelRolesUnderTeamRole(roleName string) ([]*model.Role
|
||||
"ChannelSchemeRoles.Permissions",
|
||||
"ChannelSchemeRoles.SchemeManaged",
|
||||
"ChannelSchemeRoles.BuiltIn",
|
||||
"ChannelSchemeRoles.SchemeId",
|
||||
).
|
||||
From("Roles AS HigherScopedRoles").
|
||||
Join("Schemes AS HigherScopedSchemes ON (HigherScopedRoles.Name = HigherScopedSchemes.DefaultChannelGuestRole OR HigherScopedRoles.Name = HigherScopedSchemes.DefaultChannelUserRole OR HigherScopedRoles.Name = HigherScopedSchemes.DefaultChannelAdminRole)").
|
||||
Join("Teams ON Teams.SchemeId = HigherScopedSchemes.Id").
|
||||
Join("Channels ON Channels.TeamId = Teams.Id").
|
||||
Join("Schemes AS ChannelSchemes ON Channels.SchemeId = ChannelSchemes.Id").
|
||||
Join("Roles AS ChannelSchemeRoles ON (ChannelSchemeRoles.Name = ChannelSchemes.DefaultChannelGuestRole OR ChannelSchemeRoles.Name = ChannelSchemes.DefaultChannelUserRole OR ChannelSchemeRoles.Name = ChannelSchemes.DefaultChannelAdminRole)").
|
||||
Join("Roles AS ChannelSchemeRoles ON ChannelSchemeRoles.SchemeId = ChannelSchemes.Id").
|
||||
Where(sq.Eq{"HigherScopedSchemes.Scope": model.SchemeScopeTeam}).
|
||||
Where(sq.Eq{"HigherScopedRoles.Name": roleName}).
|
||||
Where(sq.Eq{"HigherScopedRoles.DeleteAt": 0}).
|
||||
|
||||
@@ -100,6 +100,12 @@ func (s *SqlSchemeStore) Save(scheme *model.Scheme) (_ *model.Scheme, err error)
|
||||
}
|
||||
|
||||
func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxWrapper) (*model.Scheme, error) {
|
||||
// Generate the scheme ID up front so it can be recorded on each created role.
|
||||
scheme.Id = model.NewId()
|
||||
if scheme.Name == "" {
|
||||
scheme.Name = model.NewId()
|
||||
}
|
||||
|
||||
// Fetch the default system scheme roles to populate default permissions.
|
||||
defaultRoleNames := []string{
|
||||
model.TeamAdminRoleId,
|
||||
@@ -135,6 +141,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW
|
||||
DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNameTeamAdmin, scheme.Name),
|
||||
Permissions: defaultRoles[model.TeamAdminRoleId].Permissions,
|
||||
SchemeManaged: true,
|
||||
SchemeId: &scheme.Id,
|
||||
}
|
||||
|
||||
savedRole, err := s.SqlStore.Role().(*SqlRoleStore).createRole(teamAdminRole, transaction)
|
||||
@@ -149,6 +156,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW
|
||||
DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNameTeamUser, scheme.Name),
|
||||
Permissions: defaultRoles[model.TeamUserRoleId].Permissions,
|
||||
SchemeManaged: true,
|
||||
SchemeId: &scheme.Id,
|
||||
}
|
||||
|
||||
savedRole, err = s.SqlStore.Role().(*SqlRoleStore).createRole(teamUserRole, transaction)
|
||||
@@ -163,6 +171,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW
|
||||
DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNameTeamGuest, scheme.Name),
|
||||
Permissions: defaultRoles[model.TeamGuestRoleId].Permissions,
|
||||
SchemeManaged: true,
|
||||
SchemeId: &scheme.Id,
|
||||
}
|
||||
|
||||
savedRole, err = s.SqlStore.Role().(*SqlRoleStore).createRole(teamGuestRole, transaction)
|
||||
@@ -177,6 +186,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW
|
||||
DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNamePlaybookAdmin, scheme.Name),
|
||||
Permissions: defaultRoles[model.PlaybookAdminRoleId].Permissions,
|
||||
SchemeManaged: true,
|
||||
SchemeId: &scheme.Id,
|
||||
}
|
||||
savedRole, err = s.SqlStore.Role().(*SqlRoleStore).createRole(playbookAdminRole, transaction)
|
||||
if err != nil {
|
||||
@@ -190,6 +200,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW
|
||||
DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNamePlaybookMember, scheme.Name),
|
||||
Permissions: defaultRoles[model.PlaybookMemberRoleId].Permissions,
|
||||
SchemeManaged: true,
|
||||
SchemeId: &scheme.Id,
|
||||
}
|
||||
savedRole, err = s.SqlStore.Role().(*SqlRoleStore).createRole(playbookMemberRole, transaction)
|
||||
if err != nil {
|
||||
@@ -203,6 +214,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW
|
||||
DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNameRunAdmin, scheme.Name),
|
||||
Permissions: defaultRoles[model.RunAdminRoleId].Permissions,
|
||||
SchemeManaged: true,
|
||||
SchemeId: &scheme.Id,
|
||||
}
|
||||
savedRole, err = s.SqlStore.Role().(*SqlRoleStore).createRole(runAdminRole, transaction)
|
||||
if err != nil {
|
||||
@@ -216,6 +228,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW
|
||||
DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNameRunMember, scheme.Name),
|
||||
Permissions: defaultRoles[model.RunMemberRoleId].Permissions,
|
||||
SchemeManaged: true,
|
||||
SchemeId: &scheme.Id,
|
||||
}
|
||||
savedRole, err = s.SqlStore.Role().(*SqlRoleStore).createRole(runMemberRole, transaction)
|
||||
if err != nil {
|
||||
@@ -231,6 +244,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW
|
||||
DisplayName: fmt.Sprintf("Channel Admin Role for Scheme %s", scheme.Name),
|
||||
Permissions: defaultRoles[model.ChannelAdminRoleId].Permissions,
|
||||
SchemeManaged: true,
|
||||
SchemeId: &scheme.Id,
|
||||
}
|
||||
|
||||
if scheme.Scope == model.SchemeScopeChannel {
|
||||
@@ -249,6 +263,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW
|
||||
DisplayName: fmt.Sprintf("Channel User Role for Scheme %s", scheme.Name),
|
||||
Permissions: defaultRoles[model.ChannelUserRoleId].Permissions,
|
||||
SchemeManaged: true,
|
||||
SchemeId: &scheme.Id,
|
||||
}
|
||||
|
||||
if scheme.Scope == model.SchemeScopeChannel {
|
||||
@@ -267,6 +282,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW
|
||||
DisplayName: fmt.Sprintf("Channel Guest Role for Scheme %s", scheme.Name),
|
||||
Permissions: defaultRoles[model.ChannelGuestRoleId].Permissions,
|
||||
SchemeManaged: true,
|
||||
SchemeId: &scheme.Id,
|
||||
}
|
||||
|
||||
if scheme.Scope == model.SchemeScopeChannel {
|
||||
@@ -280,10 +296,6 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW
|
||||
scheme.DefaultChannelGuestRole = savedRole.Name
|
||||
}
|
||||
|
||||
scheme.Id = model.NewId()
|
||||
if scheme.Name == "" {
|
||||
scheme.Name = model.NewId()
|
||||
}
|
||||
scheme.CreateAt = model.GetMillis()
|
||||
scheme.UpdateAt = scheme.CreateAt
|
||||
|
||||
@@ -363,23 +375,11 @@ func (s *SqlSchemeStore) Delete(schemeId string) (*model.Scheme, error) {
|
||||
s.Channel().ClearCaches()
|
||||
|
||||
// Delete the roles belonging to the scheme.
|
||||
roleNames := []string{scheme.DefaultChannelGuestRole, scheme.DefaultChannelUserRole, scheme.DefaultChannelAdminRole}
|
||||
if scheme.Scope == model.SchemeScopeTeam {
|
||||
roleNames = append(roleNames, scheme.DefaultTeamGuestRole, scheme.DefaultTeamUserRole, scheme.DefaultTeamAdminRole)
|
||||
}
|
||||
if scheme.Scope == model.SchemeScopePlaybook {
|
||||
roleNames = append(roleNames, scheme.DefaultPlaybookAdminRole, scheme.DefaultPlaybookMemberRole)
|
||||
}
|
||||
|
||||
if scheme.Scope == model.SchemeScopeRun {
|
||||
roleNames = append(roleNames, scheme.DefaultRunAdminRole, scheme.DefaultRunMemberRole)
|
||||
}
|
||||
|
||||
time := model.GetMillis()
|
||||
|
||||
updateQuery, args, err := s.getQueryBuilder().
|
||||
Update("Roles").
|
||||
Where(sq.Eq{"Name": roleNames}).
|
||||
Where(sq.Eq{"SchemeId": schemeId}).
|
||||
Set("UpdateAt", time).
|
||||
Set("DeleteAt", time).
|
||||
ToSql()
|
||||
@@ -388,7 +388,7 @@ func (s *SqlSchemeStore) Delete(schemeId string) (*model.Scheme, error) {
|
||||
}
|
||||
|
||||
if _, err = s.GetMaster().Exec(updateQuery, args...); err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to update Roles with name in (%s)", roleNames)
|
||||
return nil, errors.Wrapf(err, "failed to update Roles with SchemeId=%s", schemeId)
|
||||
}
|
||||
|
||||
// Delete the scheme itself.
|
||||
|
||||
@@ -1616,9 +1616,19 @@ func testGetGroupSyncable(t *testing.T, rctx request.CTX, ss store.Store) {
|
||||
require.Equal(t, gt1.GroupId, dgt.GroupId)
|
||||
require.Equal(t, gt1.SyncableId, dgt.SyncableId)
|
||||
require.Equal(t, gt1.AutoAdd, dgt.AutoAdd)
|
||||
require.Equal(t, gt1.SchemeAdmin, dgt.SchemeAdmin)
|
||||
require.NotZero(t, gt1.CreateAt)
|
||||
require.NotZero(t, gt1.UpdateAt)
|
||||
require.Zero(t, gt1.DeleteAt)
|
||||
|
||||
// Round-trip SchemeAdmin: true through UpdateGroupSyncable and re-fetch.
|
||||
dgt.SchemeAdmin = true
|
||||
_, err = ss.Group().UpdateGroupSyncable(dgt)
|
||||
require.NoError(t, err)
|
||||
|
||||
dgt, err = ss.Group().GetGroupSyncable(groupTeam.GroupId, groupTeam.SyncableId, model.GroupSyncableTypeTeam)
|
||||
require.NoError(t, err)
|
||||
require.True(t, dgt.SchemeAdmin, "GetGroupSyncable must populate SchemeAdmin from the persisted row")
|
||||
}
|
||||
|
||||
func testGetGroupSyncableErrors(t *testing.T, rctx request.CTX, ss store.Store) {
|
||||
@@ -4989,6 +4999,15 @@ func groupTestPermittedSyncableAdminsTeam(t *testing.T, rctx request.CTX, ss sto
|
||||
// deleted group syncable no longer includes group members
|
||||
_, err = ss.Group().DeleteGroupSyncable(group1.Id, team.Id, model.GroupSyncableTypeTeam)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The persisted row must still carry SchemeAdmin=true after soft-delete;
|
||||
// PermittedSyncableAdmins excludes it via the DeleteAt = 0 predicate, not
|
||||
// via the field having been silently cleared.
|
||||
deletedSyncable, err := ss.Group().GetGroupSyncable(group1.Id, team.Id, model.GroupSyncableTypeTeam)
|
||||
require.NoError(t, err)
|
||||
require.True(t, deletedSyncable.SchemeAdmin)
|
||||
require.NotZero(t, deletedSyncable.DeleteAt)
|
||||
|
||||
actualUserIDs, err = ss.Group().PermittedSyncableAdmins(team.Id, model.GroupSyncableTypeTeam)
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t, []string{user3.Id}, actualUserIDs)
|
||||
@@ -5096,6 +5115,15 @@ func groupTestPermittedSyncableAdminsChannel(t *testing.T, rctx request.CTX, ss
|
||||
// deleted group syncable no longer includes group members
|
||||
_, err = ss.Group().DeleteGroupSyncable(group1.Id, channel.Id, model.GroupSyncableTypeChannel)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The persisted row must still carry SchemeAdmin=true after soft-delete;
|
||||
// PermittedSyncableAdmins excludes it via the DeleteAt = 0 predicate, not
|
||||
// via the field having been silently cleared.
|
||||
deletedSyncable, err := ss.Group().GetGroupSyncable(group1.Id, channel.Id, model.GroupSyncableTypeChannel)
|
||||
require.NoError(t, err)
|
||||
require.True(t, deletedSyncable.SchemeAdmin)
|
||||
require.NotZero(t, deletedSyncable.DeleteAt)
|
||||
|
||||
actualUserIDs, err = ss.Group().PermittedSyncableAdmins(channel.Id, model.GroupSyncableTypeChannel)
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t, []string{user3.Id}, actualUserIDs)
|
||||
|
||||
@@ -456,39 +456,45 @@ func testRoleStoreLowerScopedChannelSchemeRoles(t *testing.T, rctx request.CTX,
|
||||
actualRoles, err := ss.Role().ChannelRolesUnderTeamRole(teamScheme1.DefaultChannelGuestRole)
|
||||
require.NoError(t, err)
|
||||
|
||||
var actualRoleNames []string
|
||||
roleByName := make(map[string]*model.Role, len(actualRoles))
|
||||
for _, role := range actualRoles {
|
||||
actualRoleNames = append(actualRoleNames, role.Name)
|
||||
roleByName[role.Name] = role
|
||||
}
|
||||
|
||||
require.Contains(t, actualRoleNames, channelScheme1.DefaultChannelGuestRole)
|
||||
require.NotContains(t, actualRoleNames, channelScheme2.DefaultChannelGuestRole)
|
||||
require.Contains(t, roleByName, channelScheme1.DefaultChannelGuestRole)
|
||||
require.NotContains(t, roleByName, channelScheme2.DefaultChannelGuestRole)
|
||||
require.NotNil(t, roleByName[channelScheme1.DefaultChannelGuestRole].SchemeId)
|
||||
assert.Equal(t, channelScheme1.Id, *roleByName[channelScheme1.DefaultChannelGuestRole].SchemeId)
|
||||
})
|
||||
|
||||
t.Run("user role for the right team's channels are returned", func(t *testing.T) {
|
||||
actualRoles, err := ss.Role().ChannelRolesUnderTeamRole(teamScheme1.DefaultChannelUserRole)
|
||||
require.NoError(t, err)
|
||||
|
||||
var actualRoleNames []string
|
||||
roleByName := make(map[string]*model.Role, len(actualRoles))
|
||||
for _, role := range actualRoles {
|
||||
actualRoleNames = append(actualRoleNames, role.Name)
|
||||
roleByName[role.Name] = role
|
||||
}
|
||||
|
||||
require.Contains(t, actualRoleNames, channelScheme1.DefaultChannelUserRole)
|
||||
require.NotContains(t, actualRoleNames, channelScheme2.DefaultChannelUserRole)
|
||||
require.Contains(t, roleByName, channelScheme1.DefaultChannelUserRole)
|
||||
require.NotContains(t, roleByName, channelScheme2.DefaultChannelUserRole)
|
||||
require.NotNil(t, roleByName[channelScheme1.DefaultChannelUserRole].SchemeId)
|
||||
assert.Equal(t, channelScheme1.Id, *roleByName[channelScheme1.DefaultChannelUserRole].SchemeId)
|
||||
})
|
||||
|
||||
t.Run("admin role for the right team's channels are returned", func(t *testing.T) {
|
||||
actualRoles, err := ss.Role().ChannelRolesUnderTeamRole(teamScheme1.DefaultChannelAdminRole)
|
||||
require.NoError(t, err)
|
||||
|
||||
var actualRoleNames []string
|
||||
roleByName := make(map[string]*model.Role, len(actualRoles))
|
||||
for _, role := range actualRoles {
|
||||
actualRoleNames = append(actualRoleNames, role.Name)
|
||||
roleByName[role.Name] = role
|
||||
}
|
||||
|
||||
require.Contains(t, actualRoleNames, channelScheme1.DefaultChannelAdminRole)
|
||||
require.NotContains(t, actualRoleNames, channelScheme2.DefaultChannelAdminRole)
|
||||
require.Contains(t, roleByName, channelScheme1.DefaultChannelAdminRole)
|
||||
require.NotContains(t, roleByName, channelScheme2.DefaultChannelAdminRole)
|
||||
require.NotNil(t, roleByName[channelScheme1.DefaultChannelAdminRole].SchemeId)
|
||||
assert.Equal(t, channelScheme1.Id, *roleByName[channelScheme1.DefaultChannelAdminRole].SchemeId)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -497,9 +503,9 @@ func testRoleStoreLowerScopedChannelSchemeRoles(t *testing.T, rctx request.CTX,
|
||||
actualRoles, err := ss.Role().AllChannelSchemeRoles()
|
||||
require.NoError(t, err)
|
||||
|
||||
var actualRoleNames []string
|
||||
roleByName := make(map[string]*model.Role, len(actualRoles))
|
||||
for _, role := range actualRoles {
|
||||
actualRoleNames = append(actualRoleNames, role.Name)
|
||||
roleByName[role.Name] = role
|
||||
}
|
||||
|
||||
allRoleNames := []string{
|
||||
@@ -514,7 +520,27 @@ func testRoleStoreLowerScopedChannelSchemeRoles(t *testing.T, rctx request.CTX,
|
||||
}
|
||||
|
||||
for _, roleName := range allRoleNames {
|
||||
require.Contains(t, actualRoleNames, roleName)
|
||||
require.Contains(t, roleByName, roleName)
|
||||
}
|
||||
|
||||
// Roles for channelScheme1 must carry channelScheme1's ID.
|
||||
for _, roleName := range []string{
|
||||
channelScheme1.DefaultChannelGuestRole,
|
||||
channelScheme1.DefaultChannelUserRole,
|
||||
channelScheme1.DefaultChannelAdminRole,
|
||||
} {
|
||||
require.NotNil(t, roleByName[roleName].SchemeId)
|
||||
assert.Equal(t, channelScheme1.Id, *roleByName[roleName].SchemeId)
|
||||
}
|
||||
|
||||
// Roles for channelScheme2 must carry channelScheme2's ID.
|
||||
for _, roleName := range []string{
|
||||
channelScheme2.DefaultChannelGuestRole,
|
||||
channelScheme2.DefaultChannelUserRole,
|
||||
channelScheme2.DefaultChannelAdminRole,
|
||||
} {
|
||||
require.NotNil(t, roleByName[roleName].SchemeId)
|
||||
assert.Equal(t, channelScheme2.Id, *roleByName[roleName].SchemeId)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -174,6 +174,28 @@ func testSchemeStoreSave(t *testing.T, rctx request.CTX, ss store.Store) {
|
||||
assert.Equal(t, role6.Permissions, []string{"read_channel", "read_channel_content", "create_post"})
|
||||
assert.True(t, role6.SchemeManaged)
|
||||
|
||||
role7, err := ss.Role().GetByName(context.Background(), d1.DefaultPlaybookAdminRole)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, role7.SchemeManaged)
|
||||
|
||||
role8, err := ss.Role().GetByName(context.Background(), d1.DefaultPlaybookMemberRole)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, role8.SchemeManaged)
|
||||
|
||||
role9, err := ss.Role().GetByName(context.Background(), d1.DefaultRunAdminRole)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, role9.SchemeManaged)
|
||||
|
||||
role10, err := ss.Role().GetByName(context.Background(), d1.DefaultRunMemberRole)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, role10.SchemeManaged)
|
||||
|
||||
// Every role created for a scheme must carry the scheme's ID.
|
||||
for _, role := range []*model.Role{role1, role2, role3, role4, role5, role6, role7, role8, role9, role10} {
|
||||
require.NotNil(t, role.SchemeId)
|
||||
assert.Equal(t, d1.Id, *role.SchemeId)
|
||||
}
|
||||
|
||||
// Change the scheme description and update.
|
||||
d1.Description = model.NewId()
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ func GetMockStoreForSetupFunctions() *mocks.Store {
|
||||
systemStore.On("GetByName", "elasticsearch_fix_channel_index_migration").Return(&model.System{Name: "elasticsearch_fix_channel_index_migration", Value: "true"}, nil)
|
||||
systemStore.On("GetByName", model.MigrationAddSysconsoleMobileSecurityPermission).Return(&model.System{Name: model.MigrationAddSysconsoleMobileSecurityPermission, Value: "true"}, nil)
|
||||
systemStore.On("GetByName", model.MigrationKeyAddChannelBannerPermissions).Return(&model.System{Name: model.MigrationKeyAddChannelBannerPermissions, Value: "true"}, nil)
|
||||
systemStore.On("GetByName", model.MigrationKeyAddEditFileAttachmentPermission).Return(&model.System{Name: model.MigrationKeyAddEditFileAttachmentPermission, Value: "true"}, nil)
|
||||
|
||||
systemStore.On("InsertIfExists", mock.AnythingOfType("*model.System")).Return(&model.System{}, nil).Once()
|
||||
systemStore.On("Save", mock.AnythingOfType("*model.System")).Return(nil)
|
||||
|
||||
46
server/channels/testlib/testdata/mysql_migration_warmup.sql
поставляемый
46
server/channels/testlib/testdata/mysql_migration_warmup.sql
поставляемый
Различия файлов скрыты, потому что одна или несколько строк слишком длинны
@@ -264,3 +264,22 @@ func RoundOffToZeroesResolution(n float64, minResolution int) int64 {
|
||||
significantDigits := int64(n) / tens
|
||||
return significantDigits * tens
|
||||
}
|
||||
|
||||
// SliceEqualUnordered returns true if both slices contain the same set of elements,
|
||||
// regardless of order.
|
||||
func SliceEqualUnordered[K comparable](a, b []K) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
set := make(map[K]int, len(a))
|
||||
for _, id := range a {
|
||||
set[id]++
|
||||
}
|
||||
for _, id := range b {
|
||||
set[id]--
|
||||
if set[id] < 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ func desanitize(actual, target *model.Config) {
|
||||
if *target.FileSettings.AmazonS3SecretAccessKey == model.FakeSetting {
|
||||
target.FileSettings.AmazonS3SecretAccessKey = actual.FileSettings.AmazonS3SecretAccessKey
|
||||
}
|
||||
if target.FileSettings.ExportAmazonS3SecretAccessKey != nil && *target.FileSettings.ExportAmazonS3SecretAccessKey == model.FakeSetting {
|
||||
target.FileSettings.ExportAmazonS3SecretAccessKey = actual.FileSettings.ExportAmazonS3SecretAccessKey
|
||||
}
|
||||
|
||||
if *target.EmailSettings.SMTPPassword == model.FakeSetting {
|
||||
target.EmailSettings.SMTPPassword = actual.EmailSettings.SMTPPassword
|
||||
@@ -89,6 +92,18 @@ func desanitize(actual, target *model.Config) {
|
||||
*target.ServiceSettings.SplitKey = *actual.ServiceSettings.SplitKey
|
||||
}
|
||||
|
||||
if target.ServiceSettings.GoogleDeveloperKey != nil && *target.ServiceSettings.GoogleDeveloperKey == model.FakeSetting {
|
||||
target.ServiceSettings.GoogleDeveloperKey = actual.ServiceSettings.GoogleDeveloperKey
|
||||
}
|
||||
|
||||
if target.ServiceSettings.GiphySdkKey != nil && *target.ServiceSettings.GiphySdkKey == model.FakeSetting {
|
||||
target.ServiceSettings.GiphySdkKey = actual.ServiceSettings.GiphySdkKey
|
||||
}
|
||||
|
||||
if target.CacheSettings.RedisPassword != nil && *target.CacheSettings.RedisPassword == model.FakeSetting {
|
||||
target.CacheSettings.RedisPassword = actual.CacheSettings.RedisPassword
|
||||
}
|
||||
|
||||
for id, settings := range target.PluginSettings.Plugins {
|
||||
for k, v := range settings {
|
||||
if v == model.FakeSetting {
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -25,12 +27,15 @@ func TestDesanitize(t *testing.T) {
|
||||
actual.LdapSettings.BindPassword = model.NewPointer("bind_password")
|
||||
actual.FileSettings.PublicLinkSalt = model.NewPointer("public_link_salt")
|
||||
actual.FileSettings.AmazonS3SecretAccessKey = model.NewPointer("amazon_s3_secret_access_key")
|
||||
actual.FileSettings.ExportAmazonS3SecretAccessKey = model.NewPointer("export_amazon_s3_secret_access_key")
|
||||
actual.EmailSettings.SMTPPassword = model.NewPointer("smtp_password")
|
||||
actual.GitLabSettings.Secret = model.NewPointer("secret")
|
||||
actual.OpenIdSettings.Secret = model.NewPointer("secret")
|
||||
actual.SqlSettings.DataSource = model.NewPointer("data_source")
|
||||
actual.SqlSettings.AtRestEncryptKey = model.NewPointer("at_rest_encrypt_key")
|
||||
actual.ElasticsearchSettings.Password = model.NewPointer("password")
|
||||
actual.ServiceSettings.GoogleDeveloperKey = model.NewPointer("google_developer_key")
|
||||
actual.ServiceSettings.GiphySdkKey = model.NewPointer("giphy_sdk_key")
|
||||
actual.SqlSettings.DataSourceReplicas = append(actual.SqlSettings.DataSourceReplicas, "replica0")
|
||||
actual.SqlSettings.DataSourceReplicas = append(actual.SqlSettings.DataSourceReplicas, "replica1")
|
||||
actual.SqlSettings.DataSourceSearchReplicas = append(actual.SqlSettings.DataSourceSearchReplicas, "search_replica0")
|
||||
@@ -53,12 +58,15 @@ func TestDesanitize(t *testing.T) {
|
||||
target.LdapSettings.BindPassword = model.NewPointer(model.FakeSetting)
|
||||
target.FileSettings.PublicLinkSalt = model.NewPointer(model.FakeSetting)
|
||||
target.FileSettings.AmazonS3SecretAccessKey = model.NewPointer(model.FakeSetting)
|
||||
target.FileSettings.ExportAmazonS3SecretAccessKey = model.NewPointer(model.FakeSetting)
|
||||
target.EmailSettings.SMTPPassword = model.NewPointer(model.FakeSetting)
|
||||
target.GitLabSettings.Secret = model.NewPointer(model.FakeSetting)
|
||||
target.OpenIdSettings.Secret = model.NewPointer(model.FakeSetting)
|
||||
target.SqlSettings.DataSource = model.NewPointer(model.FakeSetting)
|
||||
target.SqlSettings.AtRestEncryptKey = model.NewPointer(model.FakeSetting)
|
||||
target.ElasticsearchSettings.Password = model.NewPointer(model.FakeSetting)
|
||||
target.ServiceSettings.GoogleDeveloperKey = model.NewPointer(model.FakeSetting)
|
||||
target.ServiceSettings.GiphySdkKey = model.NewPointer(model.FakeSetting)
|
||||
target.SqlSettings.DataSourceReplicas = []string{model.FakeSetting, model.FakeSetting}
|
||||
target.SqlSettings.DataSourceSearchReplicas = []string{model.FakeSetting, model.FakeSetting}
|
||||
target.PluginSettings.Plugins = map[string]map[string]any{
|
||||
@@ -80,18 +88,104 @@ func TestDesanitize(t *testing.T) {
|
||||
assert.Equal(t, *actual.LdapSettings.BindPassword, *target.LdapSettings.BindPassword)
|
||||
assert.Equal(t, *actual.FileSettings.PublicLinkSalt, *target.FileSettings.PublicLinkSalt)
|
||||
assert.Equal(t, *actual.FileSettings.AmazonS3SecretAccessKey, *target.FileSettings.AmazonS3SecretAccessKey)
|
||||
assert.Equal(t, *actual.FileSettings.ExportAmazonS3SecretAccessKey, *target.FileSettings.ExportAmazonS3SecretAccessKey)
|
||||
assert.Equal(t, *actual.EmailSettings.SMTPPassword, *target.EmailSettings.SMTPPassword)
|
||||
assert.Equal(t, *actual.GitLabSettings.Secret, *target.GitLabSettings.Secret)
|
||||
assert.Equal(t, *actual.OpenIdSettings.Secret, *target.OpenIdSettings.Secret)
|
||||
assert.Equal(t, *actual.SqlSettings.DataSource, *target.SqlSettings.DataSource)
|
||||
assert.Equal(t, *actual.SqlSettings.AtRestEncryptKey, *target.SqlSettings.AtRestEncryptKey)
|
||||
assert.Equal(t, *actual.ElasticsearchSettings.Password, *target.ElasticsearchSettings.Password)
|
||||
assert.Equal(t, *actual.ServiceSettings.GoogleDeveloperKey, *target.ServiceSettings.GoogleDeveloperKey)
|
||||
assert.Equal(t, *actual.ServiceSettings.GiphySdkKey, *target.ServiceSettings.GiphySdkKey)
|
||||
assert.Equal(t, actual.SqlSettings.DataSourceReplicas, target.SqlSettings.DataSourceReplicas)
|
||||
assert.Equal(t, actual.SqlSettings.DataSourceSearchReplicas, target.SqlSettings.DataSourceSearchReplicas)
|
||||
assert.Equal(t, actual.ServiceSettings.SplitKey, target.ServiceSettings.SplitKey)
|
||||
assert.Equal(t, actual.PluginSettings.Plugins, target.PluginSettings.Plugins)
|
||||
}
|
||||
|
||||
// TestDesanitizeRemovesAllFakeSettings verifies that every field masked by
|
||||
// Sanitize has a corresponding entry in desanitize, so FakeSetting is never
|
||||
// written back to stored config. No manual field listing is required: all
|
||||
// string fields are pre-populated via reflection so Sanitize will mask any
|
||||
// secret regardless of its default value.
|
||||
func TestDesanitizeRemovesAllFakeSettings(t *testing.T) {
|
||||
actual := &model.Config{}
|
||||
actual.SetDefaults()
|
||||
populateStrings(reflect.ValueOf(actual), "test-value")
|
||||
|
||||
sanitized := actual.Clone()
|
||||
sanitized.Sanitize(nil, nil)
|
||||
|
||||
desanitize(actual, sanitized)
|
||||
|
||||
assertNoFakeSettings(t, reflect.ValueOf(*sanitized), "Config")
|
||||
}
|
||||
|
||||
// populateStrings sets every empty string reachable from v to value so that
|
||||
// Sanitize will replace it if it is a secret field.
|
||||
func populateStrings(v reflect.Value, value string) {
|
||||
switch v.Kind() {
|
||||
case reflect.Pointer:
|
||||
if v.IsNil() && v.CanSet() {
|
||||
v.Set(reflect.New(v.Type().Elem()))
|
||||
}
|
||||
if !v.IsNil() {
|
||||
if v.Elem().Kind() == reflect.String {
|
||||
if v.Elem().String() == "" {
|
||||
v.Elem().SetString(value)
|
||||
}
|
||||
} else {
|
||||
populateStrings(v.Elem(), value)
|
||||
}
|
||||
}
|
||||
case reflect.Struct:
|
||||
for _, sf := range reflect.VisibleFields(v.Type()) {
|
||||
field := v.FieldByIndex(sf.Index)
|
||||
if field.CanSet() {
|
||||
populateStrings(field, value)
|
||||
}
|
||||
}
|
||||
case reflect.Slice:
|
||||
for i := range v.Len() {
|
||||
populateStrings(v.Index(i), value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertNoFakeSettings walks v recursively and fails if any string field equals
|
||||
// model.FakeSetting, reporting the dotted path of the offending field.
|
||||
func assertNoFakeSettings(t *testing.T, v reflect.Value, path string) {
|
||||
t.Helper()
|
||||
switch v.Kind() {
|
||||
case reflect.Pointer:
|
||||
if !v.IsNil() {
|
||||
assertNoFakeSettings(t, v.Elem(), path)
|
||||
}
|
||||
case reflect.Struct:
|
||||
for i := range v.NumField() {
|
||||
assertNoFakeSettings(t, v.Field(i), path+"."+v.Type().Field(i).Name)
|
||||
}
|
||||
case reflect.String:
|
||||
assert.NotEqual(t, model.FakeSetting, v.String(), "FakeSetting persisted at %s after desanitize", path)
|
||||
case reflect.Slice:
|
||||
for i := range v.Len() {
|
||||
assertNoFakeSettings(t, v.Index(i), fmt.Sprintf("%s[%d]", path, i))
|
||||
}
|
||||
case reflect.Map:
|
||||
for _, key := range v.MapKeys() {
|
||||
elem := v.MapIndex(key)
|
||||
if elem.Kind() == reflect.Interface {
|
||||
elem = elem.Elem()
|
||||
}
|
||||
assertNoFakeSettings(t, elem, fmt.Sprintf("%s[%v]", path, key))
|
||||
}
|
||||
case reflect.Interface:
|
||||
if !v.IsNil() {
|
||||
assertNoFakeSettings(t, v.Elem(), path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixInvalidLocales(t *testing.T) {
|
||||
// utils.TranslationsPreInit errors when TestFixInvalidLocales is run as part of testing the package,
|
||||
// but doesn't error when the test is run individually.
|
||||
|
||||
@@ -248,10 +248,6 @@ exclude (
|
||||
github.com/willf/bitset v1.2.0
|
||||
)
|
||||
|
||||
// Prevent from being upgraded because this library has a minimum requirement
|
||||
// of Go 1.24.
|
||||
replace github.com/ledongthuc/pdf => github.com/ledongthuc/pdf v0.0.0-20240201131950-da5b75280b06
|
||||
|
||||
// Also prevent tablewriter from being upgraded because the downstream dependency
|
||||
// jaytaylor/html2text does not have a go.mod file which makes it bump to the latest
|
||||
// version always. Tablewriter has made breaking changes to its latest release.
|
||||
@@ -259,3 +255,6 @@ replace github.com/olekukonko/tablewriter => github.com/olekukonko/tablewriter v
|
||||
|
||||
// See MM-66167, MM-68222 for more details.
|
||||
replace github.com/vmihailenco/msgpack/v5 => github.com/mattermost/msgpack/v5 v5.0.0-20260408165622-cadfad56a815
|
||||
|
||||
// See MM-63434 for more details.
|
||||
replace github.com/ledongthuc/pdf => github.com/jgheithcock/pdf v0.0.0-20260404175814-28cd6530c1fe
|
||||
|
||||
@@ -352,6 +352,8 @@ github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0/go.mod h1:CVKl
|
||||
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA=
|
||||
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||
github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU=
|
||||
github.com/jgheithcock/pdf v0.0.0-20260404175814-28cd6530c1fe h1:9GAP+hdboArdSUwi82IXaNd+Qq8+cGFQh7xAcwZNN+s=
|
||||
github.com/jgheithcock/pdf v0.0.0-20260404175814-28cd6530c1fe/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4=
|
||||
github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c=
|
||||
github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo=
|
||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||
@@ -403,8 +405,6 @@ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
|
||||
github.com/ledongthuc/pdf v0.0.0-20240201131950-da5b75280b06 h1:kacRlPN7EN++tVpGUorNGPn/4DnB7/DfTY82AOn6ccU=
|
||||
github.com/ledongthuc/pdf v0.0.0-20240201131950-da5b75280b06/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/levigross/exp-html v0.0.0-20120902181939-8df60c69a8f5 h1:W7p+m/AECTL3s/YR5RpQ4hz5SjNeKzZBl1q36ws12s0=
|
||||
github.com/levigross/exp-html v0.0.0-20120902181939-8df60c69a8f5/go.mod h1:QMe2wuKJ0o7zIVE8AqiT8rd8epmm6WDIZ2wyuBqYPzM=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
|
||||
@@ -455,6 +455,10 @@
|
||||
"id": "api.channel.patch_update_channel.forbidden.app_error",
|
||||
"translation": "Failed to update the channel."
|
||||
},
|
||||
{
|
||||
"id": "api.channel.patch_update_channel.group_constrained_not_allowed.app_error",
|
||||
"translation": "You are not allowed to set group_constrained on this channel type."
|
||||
},
|
||||
{
|
||||
"id": "api.channel.patch_update_channel.update_direct_or_group_messages_not_allowed.app_error",
|
||||
"translation": "You are not allowed to update the name, display_name, and purpose of direct or group messages."
|
||||
@@ -4130,6 +4134,10 @@
|
||||
"id": "api.user.demote_user_to_guest.already_guest.app_error",
|
||||
"translation": "Unable to convert the user to guest because is already a guest."
|
||||
},
|
||||
{
|
||||
"id": "api.user.demote_user_to_guest.bot_not_allowed.app_error",
|
||||
"translation": "Bot accounts cannot be converted to guest accounts."
|
||||
},
|
||||
{
|
||||
"id": "api.user.email_to_ldap.not_available.app_error",
|
||||
"translation": "AD/LDAP not available on this server."
|
||||
@@ -4546,6 +4554,14 @@
|
||||
"id": "api.webhook.create_outgoing.triggers.app_error",
|
||||
"translation": "Either trigger_words or channel_id must be set."
|
||||
},
|
||||
{
|
||||
"id": "api.webhook.incoming.user_membership.app_error",
|
||||
"translation": "The webhook user must be a member of the target team or channel."
|
||||
},
|
||||
{
|
||||
"id": "api.webhook.incoming.user_role.app_error",
|
||||
"translation": "You cannot assign a webhook to a user with higher privileges than your own."
|
||||
},
|
||||
{
|
||||
"id": "api.webhook.team_mismatch.app_error",
|
||||
"translation": "Unable to update webhook across teams."
|
||||
@@ -6974,6 +6990,14 @@
|
||||
"id": "app.role.save.invalid_role.app_error",
|
||||
"translation": "The role was not valid."
|
||||
},
|
||||
{
|
||||
"id": "app.role.send_updated_role_event.app_error",
|
||||
"translation": "An error occurred while broadcasting the role update."
|
||||
},
|
||||
{
|
||||
"id": "app.role.send_updated_role_event.unknown_scope",
|
||||
"translation": "An error occurred while broadcasting the role update: unknown scheme scope."
|
||||
},
|
||||
{
|
||||
"id": "app.save_config.app_error",
|
||||
"translation": "An error occurred saving the configuration."
|
||||
@@ -7428,6 +7452,10 @@
|
||||
"id": "app.upload.create.save.app_error",
|
||||
"translation": "Failed to save upload."
|
||||
},
|
||||
{
|
||||
"id": "app.upload.gen_file_info.invalid_filename.app_error",
|
||||
"translation": "Invalid filename."
|
||||
},
|
||||
{
|
||||
"id": "app.upload.get.app_error",
|
||||
"translation": "Failed to get upload."
|
||||
@@ -8964,6 +8992,10 @@
|
||||
"id": "model.channel.is_valid.display_name.app_error",
|
||||
"translation": "Invalid display name."
|
||||
},
|
||||
{
|
||||
"id": "model.channel.is_valid.group_constrained.app_error",
|
||||
"translation": "Only public and private channels can be group constrained."
|
||||
},
|
||||
{
|
||||
"id": "model.channel.is_valid.header.app_error",
|
||||
"translation": "Invalid header."
|
||||
@@ -9868,6 +9900,10 @@
|
||||
"id": "model.file_info.is_valid.id.app_error",
|
||||
"translation": "Invalid value for id."
|
||||
},
|
||||
{
|
||||
"id": "model.file_info.is_valid.name.app_error",
|
||||
"translation": "Invalid value for name."
|
||||
},
|
||||
{
|
||||
"id": "model.file_info.is_valid.path.app_error",
|
||||
"translation": "Invalid value for path."
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestPdfEmptyFile(t *testing.T) {
|
||||
|
||||
func TestPdfFile(t *testing.T) {
|
||||
extractor := pdfExtractor{}
|
||||
contentText := "This is a simple document that contains some text."
|
||||
contentText := "\nThis is a simple document that contains some text."
|
||||
content, err := testutils.ReadTestFile("sample-doc.pdf")
|
||||
require.NoError(t, err)
|
||||
extractedText, err := extractor.Extract("sample-doc.pdf", bytes.NewReader(content), 0)
|
||||
@@ -28,6 +28,21 @@ func TestPdfFile(t *testing.T) {
|
||||
require.Equal(t, contentText, extractedText)
|
||||
}
|
||||
|
||||
func TestPdfDeeplyNestedObjects(t *testing.T) {
|
||||
// Test for MM-63434
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("%PDF-1.0\n")
|
||||
for range 10_000 {
|
||||
buf.WriteString("0\n0\nobj\n")
|
||||
}
|
||||
buf.WriteString("startxref\n0\n%%EOF\n")
|
||||
|
||||
extractor := pdfExtractor{}
|
||||
text, err := extractor.Extract("excessive-nests.pdf", bytes.NewReader(buf.Bytes()), 0)
|
||||
require.Error(t, err)
|
||||
require.Empty(t, text)
|
||||
}
|
||||
|
||||
func TestWrongPdfFile(t *testing.T) {
|
||||
extractor := pdfExtractor{}
|
||||
content, err := testutils.ReadTestFile("sample-doc.docx")
|
||||
|
||||
@@ -507,6 +507,10 @@ func (scs *Service) upsertSyncPost(post *model.Post, targetChannel *model.Channe
|
||||
)
|
||||
}
|
||||
} else if post.DeleteAt > 0 {
|
||||
// make sure the post being deleted is owned by the remote
|
||||
if rpost.GetRemoteID() != rc.RemoteId {
|
||||
return nil, fmt.Errorf("post sync failed: %w", ErrRemoteIDMismatch)
|
||||
}
|
||||
// delete post
|
||||
rpost, appErr = scs.app.DeletePost(rctx, post.Id, post.UserId)
|
||||
if appErr == nil {
|
||||
@@ -516,6 +520,10 @@ func (scs *Service) upsertSyncPost(post *model.Post, targetChannel *model.Channe
|
||||
)
|
||||
}
|
||||
} else if post.EditAt > rpost.EditAt || post.Message != rpost.Message || post.UpdateAt > rpost.UpdateAt || post.Metadata != nil {
|
||||
// make sure the post being edited is owned by the remote
|
||||
if rpost.GetRemoteID() != rc.RemoteId {
|
||||
return nil, fmt.Errorf("post sync failed: %w", ErrRemoteIDMismatch)
|
||||
}
|
||||
scs.transformMentionsOnReceive(rctx, post, targetChannel, rc, mentionTransforms)
|
||||
var priority *model.PostPriority
|
||||
var acknowledgements []*model.PostAcknowledgement
|
||||
|
||||
@@ -128,3 +128,68 @@ func TestUpsertSyncUserStatus(t *testing.T) {
|
||||
mockApp.AssertNotCalled(t, "SaveAndBroadcastStatus")
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpsertSyncPost(t *testing.T) {
|
||||
remoteID := model.NewId()
|
||||
channelID := model.NewId()
|
||||
channel := &model.Channel{Id: channelID, Type: model.ChannelTypeOpen}
|
||||
rc := &model.RemoteCluster{RemoteId: remoteID, Name: "test-remote"}
|
||||
|
||||
setup := func(t *testing.T, existing *model.Post) (*Service, *MockAppIface) {
|
||||
mockPostStore := &mocks.PostStore{}
|
||||
mockPostStore.On("GetSingle", mock.Anything, existing.Id, true).Return(existing, nil)
|
||||
|
||||
mockStore := &mocks.Store{}
|
||||
mockStore.On("Post").Return(mockPostStore)
|
||||
|
||||
logger := mlog.CreateConsoleTestLogger(t)
|
||||
mockServer := &MockServerIface{}
|
||||
mockServer.On("GetStore").Return(mockStore)
|
||||
mockServer.On("Log").Return(logger)
|
||||
|
||||
mockApp := &MockAppIface{}
|
||||
|
||||
return &Service{server: mockServer, app: mockApp}, mockApp
|
||||
}
|
||||
|
||||
t.Run("rejects edit of a post owned by a different remote", func(t *testing.T) {
|
||||
otherRemoteID := model.NewId()
|
||||
postID := model.NewId()
|
||||
existing := &model.Post{Id: postID, ChannelId: channelID, Message: "original", RemoteId: model.NewPointer(otherRemoteID)}
|
||||
|
||||
scs, mockApp := setup(t, existing)
|
||||
|
||||
_, err := scs.upsertSyncPost(&model.Post{Id: postID, ChannelId: channelID, Message: "tampered"}, channel, rc, nil)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrRemoteIDMismatch)
|
||||
mockApp.AssertNotCalled(t, "UpdatePost", mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
t.Run("rejects delete of a post owned by a local user", func(t *testing.T) {
|
||||
postID := model.NewId()
|
||||
existing := &model.Post{Id: postID, ChannelId: channelID, Message: "original", RemoteId: nil}
|
||||
|
||||
scs, mockApp := setup(t, existing)
|
||||
|
||||
_, err := scs.upsertSyncPost(&model.Post{Id: postID, ChannelId: channelID, DeleteAt: model.GetMillis()}, channel, rc, nil)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrRemoteIDMismatch)
|
||||
mockApp.AssertNotCalled(t, "DeletePost", mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
t.Run("allows edit of a post owned by the sending remote", func(t *testing.T) {
|
||||
postID := model.NewId()
|
||||
existing := &model.Post{Id: postID, ChannelId: channelID, Message: "original", RemoteId: model.NewPointer(remoteID)}
|
||||
|
||||
scs, mockApp := setup(t, existing)
|
||||
updated := &model.Post{Id: postID, ChannelId: channelID, Message: "updated"}
|
||||
mockApp.On("UpdatePost", mock.Anything, mock.Anything, mock.Anything).Return(updated, false, (*model.AppError)(nil))
|
||||
|
||||
_, err := scs.upsertSyncPost(&model.Post{Id: postID, ChannelId: channelID, Message: "updated"}, channel, rc, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
mockApp.AssertCalled(t, "UpdatePost", mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -329,6 +329,10 @@ func (o *Channel) IsValid() *AppError {
|
||||
}
|
||||
}
|
||||
|
||||
if o.IsGroupConstrained() && !o.SupportsGroupSync() {
|
||||
return NewAppError("Channel.IsValid", "model.channel.is_valid.group_constrained.app_error", nil, "id="+o.Id, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -356,6 +360,11 @@ func (o *Channel) IsGroupOrDirect() bool {
|
||||
return o.Type == ChannelTypeDirect || o.Type == ChannelTypeGroup
|
||||
}
|
||||
|
||||
// SupportsGroupSync reports whether group_constrained is meaningful for the channel type.
|
||||
func (o *Channel) SupportsGroupSync() bool {
|
||||
return o.Type == ChannelTypeOpen || o.Type == ChannelTypePrivate
|
||||
}
|
||||
|
||||
func (o *Channel) IsOpen() bool {
|
||||
return o.Type == ChannelTypeOpen
|
||||
}
|
||||
|
||||
@@ -35,6 +35,47 @@ func TestChannelPatch(t *testing.T) {
|
||||
require.Equal(t, *p.GroupConstrained, *o.GroupConstrained)
|
||||
}
|
||||
|
||||
func TestChannelSupportsGroupSync(t *testing.T) {
|
||||
require.True(t, (&Channel{Type: ChannelTypeOpen}).SupportsGroupSync())
|
||||
require.True(t, (&Channel{Type: ChannelTypePrivate}).SupportsGroupSync())
|
||||
require.False(t, (&Channel{Type: ChannelTypeDirect}).SupportsGroupSync())
|
||||
require.False(t, (&Channel{Type: ChannelTypeGroup}).SupportsGroupSync())
|
||||
}
|
||||
|
||||
func TestChannelIsValidGroupConstrained(t *testing.T) {
|
||||
base := Channel{
|
||||
Id: NewId(),
|
||||
CreateAt: GetMillis(),
|
||||
UpdateAt: GetMillis(),
|
||||
DisplayName: "x",
|
||||
Name: "valid-name",
|
||||
Header: "h",
|
||||
Purpose: "p",
|
||||
}
|
||||
|
||||
t.Run("group_constrained is allowed on public and private channels", func(t *testing.T) {
|
||||
c := base
|
||||
c.GroupConstrained = NewPointer(true)
|
||||
|
||||
c.Type = ChannelTypeOpen
|
||||
require.Nil(t, c.IsValid())
|
||||
|
||||
c.Type = ChannelTypePrivate
|
||||
require.Nil(t, c.IsValid())
|
||||
})
|
||||
|
||||
t.Run("group_constrained is rejected on direct and group channels", func(t *testing.T) {
|
||||
c := base
|
||||
c.GroupConstrained = NewPointer(true)
|
||||
|
||||
c.Type = ChannelTypeDirect
|
||||
require.NotNil(t, c.IsValid())
|
||||
|
||||
c.Type = ChannelTypeGroup
|
||||
require.NotNil(t, c.IsValid())
|
||||
})
|
||||
}
|
||||
|
||||
func TestChannelIsValid(t *testing.T) {
|
||||
o := Channel{}
|
||||
|
||||
|
||||
@@ -4884,10 +4884,6 @@ func (o *Config) Sanitize(pluginManifests []*Manifest, opts *SanitizeOptions) {
|
||||
*o.ElasticsearchSettings.Password = FakeSetting
|
||||
}
|
||||
|
||||
if o.ElasticsearchSettings.ClientKey != nil && *o.ElasticsearchSettings.ClientKey != "" {
|
||||
*o.ElasticsearchSettings.ClientKey = FakeSetting
|
||||
}
|
||||
|
||||
for i := range o.SqlSettings.DataSourceReplicas {
|
||||
o.SqlSettings.DataSourceReplicas[i] = sanitizeDataSourceField(o.SqlSettings.DataSourceReplicas[i], "SqlSettings.DataSourceReplicas")
|
||||
}
|
||||
|
||||
Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше
Ссылка в новой задаче
Block a user