Сравнить коммиты

33 Коммитов

Автор SHA1 Сообщение Дата
sabril
31b97d9ee1 chore(e2e): backport openldap retry logic to release-10.11 (#36054) (#36975)
Automatic Merge
2026-06-10 07:59:58 +02:00
Julien Tant
b7a67fb9a4 Bump prepackaged Playbooks plugin to v2.4.6 (#36968)
Automatic Merge
2026-06-09 07:29:43 +02:00
Ben Cooke
3b1f311c5f cherry pick (#36953) 2026-06-05 13:50:41 -04:00
Mattermost Build
03787f9386 Automated cherry pick of #36814 (#36894)
Automatic Merge
2026-06-05 14:29:47 +02:00
Maria A Nunez
f6d3a7827e [release-10.11] MM-69010: Validate incoming webhook user membership (#36917)
Automatic Merge
2026-06-05 09:59:53 +02:00
Maria A Nunez
beaa59db54 [release-10.11] Restrict group_constrained to channels that support group sync (#36916)
Automatic Merge
2026-06-05 09:29:59 +02:00
Maria A Nunez
56098dd6f0 Harden post action request verification (#36840) (#36910)
Automatic Merge
2026-06-05 08:59:59 +02:00
Maria A Nunez
5563e09593 MM-68983: Tighten OAuth token issuance and cleanup on user deactivation (#36743) (#36853)
Automatic Merge
2026-06-03 08:54:16 +02:00
Mattermost Build
d00e9d48d3 Automated cherry pick of #36640 (#36792)
Automatic Merge
2026-06-02 20:24:05 +02:00
Maria A Nunez
24b6762dfb MM-68845: Tighten authorization on /share-channel autocomplete (#36662) (#36808)
Automatic Merge
2026-05-29 16:24:08 +02:00
unified-ci-app[bot]
71deee9a15 Update latest patch version to 10.11.20 (#36757)
Automatic Merge
2026-05-27 10:54:12 +02:00
Christopher Speller
4439c6121d Update Agents plugin to v1.14.2 (#36741) 2026-05-27 05:29:21 +00:00
Harshil Sharma
36ac3a43b1 Edit attachment permission (#36227) (#36723)
Automatic Merge
2026-05-25 12:24:05 +02:00
unified-ci-app[bot]
c0ea57ac6c Update latest patch version to 10.11.19 (#36669)
Automatic Merge
2026-05-21 10:23:38 +02:00
Jesse Hallam
79ba3d3d83 Fix config Sanitize fields missing from desanitize, causing FakeSetting to be persisted (#36619) (#36653)
* Add TestDesanitizeRemovesAllFakeSettings to catch future omissions

Walks every string field in the config after a Sanitize+desanitize
round-trip and fails if any still holds FakeSetting. This catches the
case where a field is added to Sanitize without a corresponding
desanitize entry.

* Fix ElasticsearchSettings.ClientKey being incorrectly masked as a secret

ClientKey is a file path, not a secret value. Masking it caused the
asterisk string to be persisted to the database on config writes, which
broke TLS client auth on restart.

* Fix desanitize missing entries for fields added in 504fb96fdd98

504fb96fdd98 masked five fields in Sanitize without adding the
corresponding desanitize entries, meaning a config save through the
API would permanently overwrite those fields with FakeSetting:

- FileSettings.ExportAmazonS3SecretAccessKey
- ServiceSettings.GoogleDeveloperKey
- ServiceSettings.GiphySdkKey
- CacheSettings.RedisPassword
- AutoTranslationSettings.LibreTranslate.APIKey

* fixup! Add TestDesanitizeRemovesAllFakeSettings to catch future omissions

* fixup! Fix desanitize missing entries for fields added in 504fb96fdd98

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
2026-05-20 11:08:12 -04:00
Julien Tant
d9a55e394c MM-68702: Reject demoting bot accounts to guest (#36487) (#36634)
Automatic Merge
2026-05-20 07:53:39 +02:00
Maria A Nunez
7c09a18ae9 [release-10.11] Cherry pick of #36332 (#36639)
Automatic Merge
2026-05-20 07:23:39 +02:00
sabril
07c4273280 ci: backport e2e-tests/cypress/reporter-config.json to release-10.11 (#36565)
server.run_specs.sh invokes Cypress with
  --reporter cypress-multi-reporters --reporter-options configFile=reporter-config.json
but the file was never added to this branch (it landed on master in 981ff0bc46
as part of a larger CI refactor). Without it, every spec run fails with
ENOENT against reporter-config.json before the first test executes.

Backports only the single 15-line config file, byte-identical to master.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:42:31 +08:00
Mattermost Build
5b85331de9 Automated cherry pick of #36469 (#36540)
Automatic Merge
2026-05-14 10:23:40 +02:00
nang2049
a09598945e Update jira prepackaged version (#36442) (#36547)
Automatic Merge
2026-05-13 16:23:43 +02:00
Andre Vasconcelos
9fab838b16 Bumping prepackaged gitlab version to 1.12.2 (#36430) (#36447)
Automatic Merge
2026-05-13 12:23:44 +02:00
unified-ci-app[bot]
ceeafd4915 Update latest patch version to 10.11.18 (#36536)
Automatic Merge
2026-05-13 08:53:38 +02:00
Maria A Nunez
202d125afa [release-10.11] MM-68547: Tighten authorization on group syncable link and patch endpoints (#36434)
Automatic Merge
2026-05-06 08:53:51 +02:00
Nick Misasi
977c791e5b MM-68382: Align team creation invite permission checks (#36188) (#36402)
Automatic Merge
2026-05-05 08:23:37 +02:00
Mattermost Build
8000e59335 Automated cherry pick of #36197 (#36382)
* [MM-68393] Tighten protected role patch authorization (#36197)

* [MM-68393] Tighten protected role patch authorization

Harden role patch authorization for protected system roles and cover the restricted paths with focused API tests.

Made-with: Cursor

* [MM-68393] Fix role patch test shadowing

Rename shadowing response variables in the protected role patch tests so govet passes in core and enterprise check-style jobs.

Made-with: Cursor

* [MM-68393] Block privileged role permissions

Made-with: Cursor
(cherry picked from commit 99b73d4c4acf5ff3546c2548a5aaa804c2aa1b04)

* Fix role patch tests for release-10.11 context and LoginSystemManager APIs

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Nick Misasi <nick.misasi@mattermost.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 11:04:37 -04:00
David Krauser
9408b98025 Manual cherry pick of #35497 (#36341)
Automatic Merge
2026-05-04 09:17:40 +02:00
JG Heithcock
073bd3a6b7 Bumping prepackaged Playbooks plugin version to v2.4.5 (#36362) 2026-05-01 13:58:42 -07:00
Mattermost Build
aba9339a24 Automated cherry pick of #36064 (#36320)
Automatic Merge
2026-05-01 00:17:38 +02:00
Mattermost Build
6fd49f56b5 MM-68526: Harden remote cluster patch response (#36288) (#36313)
Automatic Merge
2026-04-28 20:47:45 +02:00
JG Heithcock
95be6eaf86 [MM-63434] Use forked PDF library with parsing depth limit (cherry-pick #35947) (#36177)
* [MM-63434] Use forked PDF library with parsing depth limit (#35947)

* [MM-63434] Use forked PDF library with parsing depth limit

Replace github.com/ledongthuc/pdf with a fork that limits object
nesting depth during parsing. Add test coverage.

* Reverting incorrect merge that lost the change to msgpack

The error was in merge 64bdff88d8

* Remove stale ledongthuc/pdf checksums after fork replace (go mod tidy)

* Fix TestPdfFile expected text for forked PDF extractor (release-10.11)

The jgheithcock/pdf fork returns a leading newline for sample-doc.pdf; align
with master and 11.x cherry-picks.
2026-04-26 18:07:07 -03:00
Mattermost Build
61d68d2d6e MM-68439 Centralize filename handling for FileInfo (#36223) (#36255)
Automatic Merge
2026-04-24 09:17:57 +02:00
sabril
e5593b6489 fix flaky notifications tests and migrated to playwright (#34449) (#36225)
Co-authored-by: Mattermost Build <build@mattermost.com>
2026-04-23 14:35:15 +08:00
unified-ci-app[bot]
1df2909dfc Update latest patch version to 10.11.16 (#36205)
Co-authored-by: unified-ci-app[bot] <121569378+unified-ci-app[bot]@users.noreply.github.com>
2026-04-23 10:22:43 +08:00
125 изменённых файлов: 4663 добавлений и 996 удалений

Просмотреть файл

@@ -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 Framasofts 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 youll 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 (its 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.

Просмотреть файл

@@ -1,33 +1,97 @@
![](mostlymatter/logo_with_white_background-small.png) Mostlymatter is a fork of [Mattermost](https://mattermost.com) meant to remove the users and messages limits.
# [![Mattermost logo](https://user-images.githubusercontent.com/7205829/137170381-fe86eef0-bccc-4fdd-8e92-b258884ebdd7.png)](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 dont modify the front-end and we dont 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) [![Get Mattermost on Windows PC](https://user-images.githubusercontent.com/33878967/33095357-39cab8d2-ceb8-11e7-89a6-67dccc571ca3.png)](https://docs.mattermost.com/install/desktop.html#windows-10-windows-8-1-windows-7) [![Get Mattermost on Mac OSX](https://user-images.githubusercontent.com/33878967/33095355-39a36f2a-ceb8-11e7-9b33-73d4f6d5d6c1.png)](https://docs.mattermost.com/install/desktop.html#macos-10-9) [![Get Mattermost on Linux](https://user-images.githubusercontent.com/33878967/33095354-3990e256-ceb8-11e7-965d-b00a16e578de.png)](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 Mostlymatters 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
[![Small Image](https://img.shields.io/badge/Contribute%20with-Gitpod-908a85?logo=gitpod)](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 Обычный файл
Просмотреть файл

@@ -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();
}
});

Просмотреть файл

@@ -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

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 31 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 8.1 KiB

Двоичные данные
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 Обычный файл
Просмотреть файл

@@ -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 {

Просмотреть файл

@@ -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")
}

Просмотреть файл

@@ -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)

Различия файлов скрыты, потому что одна или несколько строк слишком длинны

Просмотреть файл

@@ -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")
}

Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше