parent
29b37ff195
commit
8a3de90de1
@ -0,0 +1,63 @@
|
|||||||
|
#################################
|
||||||
|
### Conf to run dev instances ###
|
||||||
|
#################################
|
||||||
|
ENV=dev
|
||||||
|
DC_ENV_FILE=.env
|
||||||
|
COMPOSE_IGNORE_ORPHANS=True
|
||||||
|
DOCKER_BUILDKIT=1
|
||||||
|
|
||||||
|
################
|
||||||
|
# Users Config #
|
||||||
|
################
|
||||||
|
TEST_USER=test
|
||||||
|
TEST_USER_PASSWORD=${TEST_USER}
|
||||||
|
TEST_USER_MAIL=${TEST_USER}@yopmail.com
|
||||||
|
|
||||||
|
TEST_USER2=test2
|
||||||
|
TEST_USER2_PASSWORD=${TEST_USER2}
|
||||||
|
TEST_USER2_MAIL=${TEST_USER2}@yopmail.com
|
||||||
|
|
||||||
|
TEST_USER3=test3
|
||||||
|
TEST_USER3_PASSWORD=${TEST_USER3}
|
||||||
|
TEST_USER3_MAIL=${TEST_USER3}@yopmail.com
|
||||||
|
|
||||||
|
###################
|
||||||
|
# Keycloak Config #
|
||||||
|
###################
|
||||||
|
KEYCLOAK_ADMIN=admin
|
||||||
|
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN}
|
||||||
|
KC_HTTP_HOST=127.0.0.1
|
||||||
|
KC_HTTP_PORT=8080
|
||||||
|
|
||||||
|
# Script parameters (use Keycloak and VaultWarden config too)
|
||||||
|
TEST_REALM=test
|
||||||
|
DUMMY_REALM=dummy
|
||||||
|
DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM}
|
||||||
|
|
||||||
|
######################
|
||||||
|
# Vaultwarden Config #
|
||||||
|
######################
|
||||||
|
ROCKET_ADDRESS=0.0.0.0
|
||||||
|
ROCKET_PORT=8000
|
||||||
|
DOMAIN=http://127.0.0.1:${ROCKET_PORT}
|
||||||
|
I_REALLY_WANT_VOLATILE_STORAGE=true
|
||||||
|
|
||||||
|
SSO_ENABLED=true
|
||||||
|
SSO_ONLY=false
|
||||||
|
SSO_CLIENT_ID=VaultWarden
|
||||||
|
SSO_CLIENT_SECRET=VaultWarden
|
||||||
|
SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM}
|
||||||
|
|
||||||
|
SMTP_HOST=127.0.0.1
|
||||||
|
SMTP_PORT=1025
|
||||||
|
SMTP_SECURITY=off
|
||||||
|
SMTP_TIMEOUT=5
|
||||||
|
SMTP_FROM=vaultwarden@test
|
||||||
|
SMTP_FROM_NAME=Vaultwarden
|
||||||
|
|
||||||
|
########################################################
|
||||||
|
# DUMMY values for docker-compose to stop bothering us #
|
||||||
|
########################################################
|
||||||
|
MARIADB_PORT=3305
|
||||||
|
MYSQL_PORT=3307
|
||||||
|
POSTGRES_PORT=5432
|
@ -0,0 +1,6 @@
|
|||||||
|
logs
|
||||||
|
node_modules/
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/playwright/.cache/
|
||||||
|
temp
|
@ -0,0 +1,177 @@
|
|||||||
|
# Integration tests
|
||||||
|
|
||||||
|
This allows running integration tests using [Playwright](https://playwright.dev/).
|
||||||
|
\
|
||||||
|
It usse its own [test.env](/test/scenarios/test.env) with different ports to not collide with a running dev instance.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
This rely on `docker` and the `compose` [plugin](https://docs.docker.com/compose/install/).
|
||||||
|
Databases (`Mariadb`, `Mysql` and `Postgres`) and `Playwright` will run in containers.
|
||||||
|
|
||||||
|
### Running Playwright outside docker
|
||||||
|
|
||||||
|
It's possible to run `Playwright` outside of the container, this remove the need to rebuild the image for each change.
|
||||||
|
You'll additionally need `nodejs` then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npx playwright install-deps
|
||||||
|
npx playwright install firefox
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
To run all the tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright
|
||||||
|
```
|
||||||
|
|
||||||
|
To force a rebuild of the Playwright image:
|
||||||
|
```bash
|
||||||
|
DOCKER_BUILDKIT=1 docker compose --env-file test.env build Playwright
|
||||||
|
```
|
||||||
|
|
||||||
|
To access the ui to easily run test individually and debug if needed (will not work in docker):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --ui
|
||||||
|
```
|
||||||
|
|
||||||
|
### DB
|
||||||
|
|
||||||
|
Projects are configured to allow to run tests only on specific database.
|
||||||
|
\
|
||||||
|
You can use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=mariadb
|
||||||
|
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=mysql
|
||||||
|
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=postgres
|
||||||
|
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite
|
||||||
|
```
|
||||||
|
|
||||||
|
### SSO
|
||||||
|
|
||||||
|
To run the SSO tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project sso-sqlite
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keep services running
|
||||||
|
|
||||||
|
If you want you can keep the Db and Keycloak runnning (states are not impacted by the tests):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PW_KEEP_SERVICE_RUNNNING=true npx playwright test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running specific tests
|
||||||
|
|
||||||
|
To run a whole file you can :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite tests/login.spec.ts
|
||||||
|
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite login
|
||||||
|
```
|
||||||
|
|
||||||
|
To run only a specifc test (It might fail if it has dependency):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite -g "Account creation"
|
||||||
|
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite tests/login.spec.ts:16
|
||||||
|
```
|
||||||
|
|
||||||
|
## Writing scenario
|
||||||
|
|
||||||
|
When creating new scenario use the recorder to more easily identify elements (in general try to rely on visible hint to identify elements and not hidden ids).
|
||||||
|
This does not start the server, you will need to start it manually.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright codegen "http://127.0.0.1:8000"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Override web-vault
|
||||||
|
|
||||||
|
It's possible to change the `web-vault` used by referencing a different `bw_web_builds` commit.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PW_WV_REPO_URL=https://github.com/Timshel/oidc_web_builds.git
|
||||||
|
export PW_WV_COMMIT_HASH=8707dc76df3f0cceef2be5bfae37bb29bd17fae6
|
||||||
|
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env build Playwright
|
||||||
|
```
|
||||||
|
|
||||||
|
# OpenID Connect test setup
|
||||||
|
|
||||||
|
Additionnaly this `docker-compose` template allow to run locally `VaultWarden`, [Keycloak](https://www.keycloak.org/) and [Maildev](https://github.com/timshel/maildev) to test OIDC.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
This rely on `docker` and the `compose` [plugin](https://docs.docker.com/compose/install/).
|
||||||
|
First create a copy of `.env.template` as `.env` (This is done to prevent commiting your custom settings, Ex `SMTP_`).
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Then start the stack (the `profile` is required to run `Vaultwarden`) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
> docker compose --profile vaultwarden --env-file .env up
|
||||||
|
....
|
||||||
|
keycloakSetup_1 | Logging into http://127.0.0.1:8080 as user admin of realm master
|
||||||
|
keycloakSetup_1 | Created new realm with id 'test'
|
||||||
|
keycloakSetup_1 | 74af4933-e386-4e64-ba15-a7b61212c45e
|
||||||
|
oidc_keycloakSetup_1 exited with code 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait until `oidc_keycloakSetup_1 exited with code 0` which indicate the correct setup of the Keycloak realm, client and user (It's normal for this container to stop once the configuration is done).
|
||||||
|
|
||||||
|
Then you can access :
|
||||||
|
|
||||||
|
- `VaultWarden` on http://0.0.0.0:8000 with the default user `test@yopmail.com/test`.
|
||||||
|
- `Keycloak` on http://0.0.0.0:8080/admin/master/console/ with the default user `admin/admin`
|
||||||
|
- `Maildev` on http://0.0.0.0:1080
|
||||||
|
|
||||||
|
To proceed with an SSO login after you enter the email, on the screen prompting for `Master Password` the SSO button should be visible.
|
||||||
|
To use your computer external ip (for example when testing with a phone) you will have to configure `KC_HTTP_HOST` and `DOMAIN`.
|
||||||
|
|
||||||
|
## Running only Keycloak
|
||||||
|
|
||||||
|
You can run just `Keycloak` with `--profile keycloak`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
> docker compose --profile keycloak --env-file .env up
|
||||||
|
```
|
||||||
|
|
||||||
|
When running with a local VaultWarden and the default `web-vault` you'll need to make the SSO button visible using :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -i 's#a\[routerlink="/sso"\],##' web-vault/app/main.*.css
|
||||||
|
```
|
||||||
|
|
||||||
|
Otherwise you'll need to reveal the SSO login button using the debug console (F12)
|
||||||
|
|
||||||
|
```js
|
||||||
|
document.querySelector('a[routerlink="/sso"]').style.setProperty("display", "inline-block", "important");
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rebuilding the Vaultwarden
|
||||||
|
|
||||||
|
To force rebuilding the Vaultwarden image you can run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile vaultwarden --env-file .env build VaultwardenPrebuild Vaultwarden
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All configuration for `keycloak` / `VaultWarden` / `keycloak_setup.sh` can be found in [.env](.env.template).
|
||||||
|
The content of the file will be loaded as environment variables in all containers.
|
||||||
|
|
||||||
|
- `keycloak` [configuration](https://www.keycloak.org/server/all-config) include `KEYCLOAK_ADMIN` / `KEYCLOAK_ADMIN_PASSWORD` and any variable prefixed `KC_` ([more information](https://www.keycloak.org/server/configuration#_example_configuring_the_db_url_host_parameter)).
|
||||||
|
- All `VaultWarden` configuration can be set (EX: `SMTP_*`)
|
||||||
|
|
||||||
|
## Cleanup
|
||||||
|
|
||||||
|
Use `docker compose --profile vaultWarden down`.
|
@ -0,0 +1,40 @@
|
|||||||
|
FROM docker.io/library/debian:bookworm-slim as build
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
ARG KEYCLOAK_VERSION
|
||||||
|
|
||||||
|
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y ca-certificates curl wget \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /
|
||||||
|
|
||||||
|
RUN wget -c https://github.com/keycloak/keycloak/releases/download/${KEYCLOAK_VERSION}/keycloak-${KEYCLOAK_VERSION}.tar.gz -O - | tar -xz
|
||||||
|
|
||||||
|
FROM docker.io/library/debian:bookworm-slim
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
ARG KEYCLOAK_VERSION
|
||||||
|
|
||||||
|
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y ca-certificates curl wget \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ARG JAVA_URL
|
||||||
|
ARG JAVA_VERSION
|
||||||
|
|
||||||
|
ENV JAVA_VERSION=${JAVA_VERSION}
|
||||||
|
|
||||||
|
RUN mkdir -p /opt/openjdk && cd /opt/openjdk \
|
||||||
|
&& wget -c "${JAVA_URL}" -O - | tar -xz
|
||||||
|
|
||||||
|
WORKDIR /
|
||||||
|
|
||||||
|
COPY setup.sh /setup.sh
|
||||||
|
COPY --from=build /keycloak-${KEYCLOAK_VERSION}/bin /opt/keycloak/bin
|
||||||
|
|
||||||
|
CMD "/setup.sh"
|
@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
export PATH=/opt/keycloak/bin:/opt/openjdk/jdk-${JAVA_VERSION}/bin:$PATH
|
||||||
|
export JAVA_HOME=/opt/openjdk/jdk-${JAVA_VERSION}
|
||||||
|
|
||||||
|
STATUS_CODE=0
|
||||||
|
while [[ "$STATUS_CODE" != "404" ]] ; do
|
||||||
|
echo "Will retry in 2 seconds"
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
STATUS_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$DUMMY_AUTHORITY")
|
||||||
|
|
||||||
|
if [[ "$STATUS_CODE" = "200" ]]; then
|
||||||
|
echo "Setup should already be done. Will not run."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KEYCLOAK_ADMIN" --password "$KEYCLOAK_ADMIN_PASSWORD" --client admin-cli
|
||||||
|
|
||||||
|
kcadm.sh create realms -s realm="$TEST_REALM" -s enabled=true -s "accessTokenLifespan=600"
|
||||||
|
kcadm.sh create clients -r test -s "clientId=$SSO_CLIENT_ID" -s "secret=$SSO_CLIENT_SECRET" -s "redirectUris=[\"$DOMAIN/*\"]" -i
|
||||||
|
|
||||||
|
TEST_USER_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER" -s "firstName=$TEST_USER" -s "lastName=$TEST_USER" -s "email=$TEST_USER_MAIL" -s emailVerified=true -s enabled=true -i)
|
||||||
|
kcadm.sh update users/$TEST_USER_ID/reset-password -r "$TEST_REALM" -s type=password -s "value=$TEST_USER_PASSWORD" -n
|
||||||
|
|
||||||
|
TEST_USER2_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER2" -s "firstName=$TEST_USER2" -s "lastName=$TEST_USER2" -s "email=$TEST_USER2_MAIL" -s emailVerified=true -s enabled=true -i)
|
||||||
|
kcadm.sh update users/$TEST_USER2_ID/reset-password -r "$TEST_REALM" -s type=password -s "value=$TEST_USER2_PASSWORD" -n
|
||||||
|
|
||||||
|
TEST_USER3_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER3" -s "firstName=$TEST_USER3" -s "lastName=$TEST_USER3" -s "email=$TEST_USER3_MAIL" -s emailVerified=true -s enabled=true -i)
|
||||||
|
kcadm.sh update users/$TEST_USER3_ID/reset-password -r "$TEST_REALM" -s type=password -s "value=$TEST_USER3_PASSWORD" -n
|
||||||
|
|
||||||
|
# Dummy realm to mark end of setup
|
||||||
|
kcadm.sh create realms -s realm="$DUMMY_REALM" -s enabled=true -s "accessTokenLifespan=600"
|
@ -0,0 +1,40 @@
|
|||||||
|
FROM docker.io/library/debian:bookworm-slim
|
||||||
|
|
||||||
|
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y ca-certificates curl \
|
||||||
|
&& curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \
|
||||||
|
&& chmod a+r /etc/apt/keyrings/docker.asc \
|
||||||
|
&& echo "deb [signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" | tee /etc/apt/sources.list.d/docker.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
containerd.io \
|
||||||
|
docker-buildx-plugin \
|
||||||
|
docker-ce \
|
||||||
|
docker-ce-cli \
|
||||||
|
docker-compose-plugin \
|
||||||
|
git \
|
||||||
|
libmariadb-dev-compat \
|
||||||
|
libpq5 \
|
||||||
|
nodejs \
|
||||||
|
npm \
|
||||||
|
openssl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN mkdir /playwright
|
||||||
|
WORKDIR /playwright
|
||||||
|
|
||||||
|
COPY package.json .
|
||||||
|
RUN npm install && npx playwright install-deps && npx playwright install firefox
|
||||||
|
|
||||||
|
COPY docker-compose.yml test.env ./
|
||||||
|
COPY compose ./compose
|
||||||
|
|
||||||
|
COPY *.ts test.env ./
|
||||||
|
COPY tests ./tests
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/bin/npx", "playwright"]
|
||||||
|
CMD ["test"]
|
@ -0,0 +1,39 @@
|
|||||||
|
FROM playwright_oidc_vaultwarden_prebuilt AS vaultwarden
|
||||||
|
|
||||||
|
FROM node:18-bookworm AS build
|
||||||
|
|
||||||
|
arg REPO_URL
|
||||||
|
arg COMMIT_HASH
|
||||||
|
|
||||||
|
ENV REPO_URL=$REPO_URL
|
||||||
|
ENV COMMIT_HASH=$COMMIT_HASH
|
||||||
|
|
||||||
|
COPY --from=vaultwarden /web-vault /web-vault
|
||||||
|
COPY build.sh /build.sh
|
||||||
|
RUN /build.sh
|
||||||
|
|
||||||
|
######################## RUNTIME IMAGE ########################
|
||||||
|
FROM docker.io/library/debian:bookworm-slim
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Create data folder and Install needed libraries
|
||||||
|
RUN mkdir /data && \
|
||||||
|
apt-get update && apt-get install -y \
|
||||||
|
--no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
libmariadb-dev-compat \
|
||||||
|
libpq5 \
|
||||||
|
openssl && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copies the files from the context (Rocket.toml file and web-vault)
|
||||||
|
# and the binary from the "build" stage to the current stage
|
||||||
|
WORKDIR /
|
||||||
|
|
||||||
|
COPY --from=vaultwarden /start.sh .
|
||||||
|
COPY --from=vaultwarden /vaultwarden .
|
||||||
|
COPY --from=build /web-vault ./web-vault
|
||||||
|
|
||||||
|
ENTRYPOINT ["/start.sh"]
|
@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo $REPO_URL
|
||||||
|
echo $COMMIT_HASH
|
||||||
|
|
||||||
|
if [[ ! -z "$REPO_URL" ]] && [[ ! -z "$COMMIT_HASH" ]] ; then
|
||||||
|
rm -rf /web-vault
|
||||||
|
|
||||||
|
mkdir bw_web_builds;
|
||||||
|
cd bw_web_builds;
|
||||||
|
|
||||||
|
git -c init.defaultBranch=main init
|
||||||
|
git remote add origin "$REPO_URL"
|
||||||
|
git fetch --depth 1 origin "$COMMIT_HASH"
|
||||||
|
git -c advice.detachedHead=false checkout FETCH_HEAD
|
||||||
|
|
||||||
|
export VAULT_VERSION=$(cat Dockerfile | grep "ARG VAULT_VERSION" | cut -d "=" -f2)
|
||||||
|
./scripts/checkout_web_vault.sh
|
||||||
|
./scripts/patch_web_vault.sh
|
||||||
|
./scripts/build_web_vault.sh
|
||||||
|
printf '{"version":"%s"}' "$COMMIT_HASH" > ./web-vault/apps/web/build/vw-version.json
|
||||||
|
|
||||||
|
mv ./web-vault/apps/web/build /web-vault
|
||||||
|
fi
|
@ -0,0 +1,121 @@
|
|||||||
|
services:
|
||||||
|
VaultwardenPrebuild:
|
||||||
|
profiles: ["playwright", "vaultwarden"]
|
||||||
|
container_name: playwright_oidc_vaultwarden_prebuilt
|
||||||
|
image: playwright_oidc_vaultwarden_prebuilt
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
entrypoint: /bin/bash
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
Vaultwarden:
|
||||||
|
profiles: ["playwright", "vaultwarden"]
|
||||||
|
container_name: playwright_oidc_vaultwarden-${ENV:-dev}
|
||||||
|
image: playwright_oidc_vaultwarden-${ENV:-dev}
|
||||||
|
network_mode: "host"
|
||||||
|
build:
|
||||||
|
context: compose/vaultwarden
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
REPO_URL: ${PW_WV_REPO_URL:-}
|
||||||
|
COMMIT_HASH: ${PW_WV_COMMIT_HASH:-}
|
||||||
|
env_file: ${DC_ENV_FILE:-.env}
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL
|
||||||
|
- I_REALLY_WANT_VOLATILE_STORAGE
|
||||||
|
- SMTP_HOST
|
||||||
|
- SMTP_FROM
|
||||||
|
- SMTP_DEBUG
|
||||||
|
- SSO_FRONTEND
|
||||||
|
- SSO_ENABLED
|
||||||
|
- SSO_ONLY
|
||||||
|
restart: "no"
|
||||||
|
depends_on:
|
||||||
|
- VaultwardenPrebuild
|
||||||
|
|
||||||
|
Playwright:
|
||||||
|
profiles: ["playwright"]
|
||||||
|
container_name: playwright_oidc_playwright
|
||||||
|
image: playwright_oidc_playwright
|
||||||
|
network_mode: "host"
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: compose/playwright/Dockerfile
|
||||||
|
environment:
|
||||||
|
- PW_WV_REPO_URL
|
||||||
|
- PW_WV_COMMIT_HASH
|
||||||
|
restart: "no"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ..:/project
|
||||||
|
|
||||||
|
Mariadb:
|
||||||
|
profiles: ["playwright"]
|
||||||
|
container_name: playwright_mariadb
|
||||||
|
image: mariadb:11.2.4
|
||||||
|
env_file: test.env
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||||
|
start_period: 10s
|
||||||
|
interval: 10s
|
||||||
|
ports:
|
||||||
|
- ${MARIADB_PORT}:3306
|
||||||
|
|
||||||
|
Mysql:
|
||||||
|
profiles: ["playwright"]
|
||||||
|
container_name: playwright_mysql
|
||||||
|
image: mysql:8.4.1
|
||||||
|
env_file: test.env
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
|
||||||
|
start_period: 10s
|
||||||
|
interval: 10s
|
||||||
|
ports:
|
||||||
|
- ${MYSQL_PORT}:3306
|
||||||
|
|
||||||
|
Postgres:
|
||||||
|
profiles: ["playwright"]
|
||||||
|
container_name: playwright_postgres
|
||||||
|
image: postgres:16.3
|
||||||
|
env_file: test.env
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
||||||
|
start_period: 20s
|
||||||
|
interval: 30s
|
||||||
|
ports:
|
||||||
|
- ${POSTGRES_PORT}:5432
|
||||||
|
|
||||||
|
Maildev:
|
||||||
|
profiles: ["vaultwarden", "maildev"]
|
||||||
|
container_name: maildev
|
||||||
|
image: timshel/maildev
|
||||||
|
ports:
|
||||||
|
- ${SMTP_PORT}:1025
|
||||||
|
- 1080:1080
|
||||||
|
|
||||||
|
Keycloak:
|
||||||
|
profiles: ["keycloak", "vaultwarden"]
|
||||||
|
container_name: keycloak-${ENV:-dev}
|
||||||
|
image: quay.io/keycloak/keycloak:25.0.4
|
||||||
|
network_mode: "host"
|
||||||
|
command:
|
||||||
|
- start-dev
|
||||||
|
env_file: ${DC_ENV_FILE:-.env}
|
||||||
|
|
||||||
|
KeycloakSetup:
|
||||||
|
profiles: ["keycloak", "vaultwarden"]
|
||||||
|
container_name: keycloakSetup-${ENV:-dev}
|
||||||
|
image: keycloak_setup-${ENV:-dev}
|
||||||
|
build:
|
||||||
|
context: compose/keycloak
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
KEYCLOAK_VERSION: 25.0.4
|
||||||
|
JAVA_URL: https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-x64_bin.tar.gz
|
||||||
|
JAVA_VERSION: 21.0.2
|
||||||
|
network_mode: "host"
|
||||||
|
depends_on:
|
||||||
|
- Keycloak
|
||||||
|
restart: "no"
|
||||||
|
env_file: ${DC_ENV_FILE:-.env}
|
@ -0,0 +1,22 @@
|
|||||||
|
import { firefox, type FullConfig } from '@playwright/test';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const utils = require('./global-utils');
|
||||||
|
|
||||||
|
utils.loadEnv();
|
||||||
|
|
||||||
|
async function globalSetup(config: FullConfig) {
|
||||||
|
// Are we running in docker and the project is mounted ?
|
||||||
|
const path = (fs.existsSync("/project/playwright/playwright.config.ts") ? "/project/playwright" : ".");
|
||||||
|
execSync(`docker compose --project-directory ${path} --profile playwright --env-file test.env build VaultwardenPrebuild`, {
|
||||||
|
env: { ...process.env },
|
||||||
|
stdio: "inherit"
|
||||||
|
});
|
||||||
|
execSync(`docker compose --project-directory ${path} --profile playwright --env-file test.env build Vaultwarden`, {
|
||||||
|
env: { ...process.env },
|
||||||
|
stdio: "inherit"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default globalSetup;
|
@ -0,0 +1,219 @@
|
|||||||
|
import { type Browser, type TestInfo } from '@playwright/test';
|
||||||
|
import { EventEmitter } from "events";
|
||||||
|
import { type Mail, MailServer } from 'maildev';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import dotenvExpand from 'dotenv-expand';
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const { spawn } = require('node:child_process');
|
||||||
|
|
||||||
|
export function loadEnv(){
|
||||||
|
var myEnv = dotenv.config({ path: 'test.env' });
|
||||||
|
dotenvExpand.expand(myEnv);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user1: {
|
||||||
|
email: process.env.TEST_USER_MAIL,
|
||||||
|
name: process.env.TEST_USER,
|
||||||
|
password: process.env.TEST_USER_PASSWORD,
|
||||||
|
},
|
||||||
|
user2: {
|
||||||
|
email: process.env.TEST_USER2_MAIL,
|
||||||
|
name: process.env.TEST_USER2,
|
||||||
|
password: process.env.TEST_USER2_PASSWORD,
|
||||||
|
},
|
||||||
|
user3: {
|
||||||
|
email: process.env.TEST_USER3_MAIL,
|
||||||
|
name: process.env.TEST_USER3,
|
||||||
|
password: process.env.TEST_USER3_PASSWORD,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeMails(mailServer: MailServer, mailIterators: AsyncIterator<Mail>[]) {
|
||||||
|
if( mailServer ) {
|
||||||
|
mailServer.close();
|
||||||
|
}
|
||||||
|
if( mailIterators ) {
|
||||||
|
for (const mails of mailIterators) {
|
||||||
|
if(mails){
|
||||||
|
mails.return();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitFor(url: String, browser: Browser) {
|
||||||
|
var ready = false;
|
||||||
|
var context;
|
||||||
|
|
||||||
|
do {
|
||||||
|
try {
|
||||||
|
context = await browser.newContext();
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const result = await page.goto(url);
|
||||||
|
ready = result.status() === 200;
|
||||||
|
} catch(e) {
|
||||||
|
if( !e.message.includes("CONNECTION_REFUSED") ){
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
} while(!ready);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startComposeService(serviceName: String){
|
||||||
|
console.log(`Starting ${serviceName}`);
|
||||||
|
execSync(`docker compose --profile playwright --env-file test.env up -d ${serviceName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopComposeService(serviceName: String){
|
||||||
|
console.log(`Stopping ${serviceName}`);
|
||||||
|
execSync(`docker compose --profile playwright --env-file test.env stop ${serviceName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wipeSqlite(){
|
||||||
|
console.log(`Delete Vaultwarden container to wipe sqlite`);
|
||||||
|
execSync(`docker compose --env-file test.env stop Vaultwarden`);
|
||||||
|
execSync(`docker compose --env-file test.env rm -f Vaultwarden`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wipeMariaDB(){
|
||||||
|
var mysql = require('mysql2/promise');
|
||||||
|
var ready = false;
|
||||||
|
var connection;
|
||||||
|
|
||||||
|
do {
|
||||||
|
try {
|
||||||
|
connection = await mysql.createConnection({
|
||||||
|
user: process.env.MARIADB_USER,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
database: process.env.MARIADB_DATABASE,
|
||||||
|
password: process.env.MARIADB_PASSWORD,
|
||||||
|
port: process.env.MARIADB_PORT,
|
||||||
|
});
|
||||||
|
|
||||||
|
await connection.execute(`DROP DATABASE ${process.env.MARIADB_DATABASE}`);
|
||||||
|
await connection.execute(`CREATE DATABASE ${process.env.MARIADB_DATABASE}`);
|
||||||
|
console.log('Successfully wiped mariadb');
|
||||||
|
ready = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`Error when wiping mariadb: ${err}`);
|
||||||
|
} finally {
|
||||||
|
if( connection ){
|
||||||
|
connection.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
|
} while(!ready);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wipeMysqlDB(){
|
||||||
|
var mysql = require('mysql2/promise');
|
||||||
|
var ready = false;
|
||||||
|
var connection;
|
||||||
|
|
||||||
|
do{
|
||||||
|
try {
|
||||||
|
connection = await mysql.createConnection({
|
||||||
|
user: process.env.MYSQL_USER,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
database: process.env.MYSQL_DATABASE,
|
||||||
|
password: process.env.MYSQL_PASSWORD,
|
||||||
|
port: process.env.MYSQL_PORT,
|
||||||
|
});
|
||||||
|
|
||||||
|
await connection.execute(`DROP DATABASE ${process.env.MYSQL_DATABASE}`);
|
||||||
|
await connection.execute(`CREATE DATABASE ${process.env.MYSQL_DATABASE}`);
|
||||||
|
console.log('Successfully wiped mysql');
|
||||||
|
ready = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`Error when wiping mysql: ${err}`);
|
||||||
|
} finally {
|
||||||
|
if( connection ){
|
||||||
|
connection.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
|
} while(!ready);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wipePostgres(){
|
||||||
|
const { Client } = require('pg');
|
||||||
|
|
||||||
|
const client = new Client({
|
||||||
|
user: process.env.POSTGRES_USER,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
database: "postgres",
|
||||||
|
password: process.env.POSTGRES_PASSWORD,
|
||||||
|
port: process.env.POSTGRES_PORT,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
await client.query(`DROP DATABASE ${process.env.POSTGRES_DB}`);
|
||||||
|
await client.query(`CREATE DATABASE ${process.env.POSTGRES_DB}`);
|
||||||
|
console.log('Successfully wiped postgres');
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`Error when wiping postgres: ${err}`);
|
||||||
|
} finally {
|
||||||
|
client.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbConfig(testInfo: TestInfo){
|
||||||
|
switch(testInfo.project.name) {
|
||||||
|
case "postgres": return {
|
||||||
|
DATABASE_URL: `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@127.0.0.1:${process.env.POSTGRES_PORT}/${process.env.POSTGRES_DB}`
|
||||||
|
}
|
||||||
|
case "mariadb": return {
|
||||||
|
DATABASE_URL: `mysql://${process.env.MARIADB_USER}:${process.env.MARIADB_PASSWORD}@127.0.0.1:${process.env.MARIADB_PORT}/${process.env.MARIADB_DATABASE}`
|
||||||
|
}
|
||||||
|
case "mysql": return {
|
||||||
|
DATABASE_URL: `mysql://${process.env.MYSQL_USER}:${process.env.MYSQL_PASSWORD}@127.0.0.1:${process.env.MYSQL_PORT}/${process.env.MYSQL_DATABASE}`
|
||||||
|
}
|
||||||
|
default: return { I_REALLY_WANT_VOLATILE_STORAGE: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All parameters passed in `env` need to be added to the docker-compose.yml
|
||||||
|
**/
|
||||||
|
export async function startVaultwarden(browser: Browser, testInfo: TestInfo, env = {}, resetDB: Boolean = true) {
|
||||||
|
if( resetDB ){
|
||||||
|
switch(testInfo.project.name) {
|
||||||
|
case "postgres":
|
||||||
|
await wipePostgres();
|
||||||
|
break;
|
||||||
|
case "mariadb":
|
||||||
|
await wipeMariaDB();
|
||||||
|
break;
|
||||||
|
case "mysql":
|
||||||
|
await wipeMysqlDB();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
wipeSqlite();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Starting Vaultwarden`);
|
||||||
|
execSync(`docker compose --profile playwright --env-file test.env up -d Vaultwarden`, {
|
||||||
|
env: { ...env, ...dbConfig(testInfo) },
|
||||||
|
});
|
||||||
|
await waitFor("/", browser);
|
||||||
|
console.log(`Vaultwarden running on: ${process.env.DOMAIN}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopVaultwarden() {
|
||||||
|
console.log(`Vaultwarden stopping`);
|
||||||
|
execSync(`docker compose --profile playwright --env-file test.env stop Vaultwarden`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restartVaultwarden(page: Page, testInfo: TestInfo, env, resetDB: Boolean = true) {
|
||||||
|
stopVaultwarden();
|
||||||
|
return startVaultwarden(page.context().browser(), testInfo, env, resetDB);
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "scenarios",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.45.1",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"dotenv-expand": "^11.0.6",
|
||||||
|
"maildev": "github:timshel/maildev#3.0.0-rc1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"mysql2": "^3.10.2",
|
||||||
|
"otpauth": "^9.3.2",
|
||||||
|
"pg": "^8.12.0"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,132 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
import { exec } from 'node:child_process';
|
||||||
|
|
||||||
|
const utils = require('./global-utils');
|
||||||
|
|
||||||
|
utils.loadEnv();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './.',
|
||||||
|
/* Run tests in files in parallel */
|
||||||
|
fullyParallel: false,
|
||||||
|
|
||||||
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
|
||||||
|
/* Retry on CI only */
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: 1,
|
||||||
|
|
||||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
|
reporter: 'html',
|
||||||
|
timeout: 20 * 1000,
|
||||||
|
expect: { timeout: 10 * 1000 },
|
||||||
|
|
||||||
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
use: {
|
||||||
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
|
baseURL: process.env.DOMAIN,
|
||||||
|
browserName: 'firefox',
|
||||||
|
locale: 'en-GB',
|
||||||
|
timezoneId: 'Europe/London',
|
||||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Configure projects for major browsers */
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'mariadb-setup',
|
||||||
|
testMatch: 'tests/setups/db-setup.ts',
|
||||||
|
use: { serviceName: "Mariadb" },
|
||||||
|
teardown: 'mariadb-teardown',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mysql-setup',
|
||||||
|
testMatch: 'tests/setups/db-setup.ts',
|
||||||
|
use: { serviceName: "Mysql" },
|
||||||
|
teardown: 'mysql-teardown',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'postgres-setup',
|
||||||
|
testMatch: 'tests/setups/db-setup.ts',
|
||||||
|
use: { serviceName: "Postgres" },
|
||||||
|
teardown: 'postgres-teardown',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sso-setup',
|
||||||
|
testMatch: 'tests/setups/sso-setup.ts',
|
||||||
|
teardown: 'sso-teardown',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'mariadb',
|
||||||
|
testMatch: 'tests/*.spec.ts',
|
||||||
|
testIgnore: 'tests/sso_*.spec.ts',
|
||||||
|
dependencies: ['mariadb-setup'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mysql',
|
||||||
|
testMatch: 'tests/*.spec.ts',
|
||||||
|
testIgnore: 'tests/sso_*.spec.ts',
|
||||||
|
dependencies: ['mysql-setup'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'postgres',
|
||||||
|
testMatch: 'tests/*.spec.ts',
|
||||||
|
testIgnore: 'tests/sso_*.spec.ts',
|
||||||
|
dependencies: ['postgres-setup'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sqlite',
|
||||||
|
testMatch: 'tests/*.spec.ts',
|
||||||
|
testIgnore: 'tests/sso_*.spec.ts',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'sso-mariadb',
|
||||||
|
testMatch: 'tests/sso_*.spec.ts',
|
||||||
|
dependencies: ['sso-setup', 'mariadb-setup'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sso-mysql',
|
||||||
|
testMatch: 'tests/sso_*.spec.ts',
|
||||||
|
dependencies: ['sso-setup', 'mysql-setup'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sso-postgres',
|
||||||
|
testMatch: 'tests/sso_*.spec.ts',
|
||||||
|
dependencies: ['sso-setup', 'postgres-setup'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sso-sqlite',
|
||||||
|
testMatch: 'tests/sso_*.spec.ts',
|
||||||
|
dependencies: ['sso-setup'],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'mariadb-teardown',
|
||||||
|
testMatch: 'tests/setups/db-teardown.ts',
|
||||||
|
use: { serviceName: "Mariadb" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mysql-teardown',
|
||||||
|
testMatch: 'tests/setups/db-teardown.ts',
|
||||||
|
use: { serviceName: "Mysql" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'postgres-teardown',
|
||||||
|
testMatch: 'tests/setups/db-teardown.ts',
|
||||||
|
use: { serviceName: "Postgres" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sso-teardown',
|
||||||
|
testMatch: 'tests/setups/sso-teardown.ts',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
globalSetup: require.resolve('./global-setup'),
|
||||||
|
});
|
@ -0,0 +1,90 @@
|
|||||||
|
##################################################################
|
||||||
|
### Shared Playwright conf test file Vaultwarden and Databases ###
|
||||||
|
##################################################################
|
||||||
|
|
||||||
|
ENV=test
|
||||||
|
DC_ENV_FILE=test.env
|
||||||
|
COMPOSE_IGNORE_ORPHANS=True
|
||||||
|
DOCKER_BUILDKIT=1
|
||||||
|
|
||||||
|
#####################
|
||||||
|
# Playwright Config #
|
||||||
|
#####################
|
||||||
|
PW_KEEP_SERVICE_RUNNNING=${PW_KEEP_SERVICE_RUNNNING:-false}
|
||||||
|
VAULTWARDEN_SMTP_FROM=vaultwarden@playwright.test
|
||||||
|
|
||||||
|
#####################
|
||||||
|
# Maildev Config #
|
||||||
|
#####################
|
||||||
|
MAILDEV_HTTP_PORT=1081
|
||||||
|
MAILDEV_SMTP_PORT=1026
|
||||||
|
MAILDEV_HOST=127.0.0.1
|
||||||
|
|
||||||
|
################
|
||||||
|
# Users Config #
|
||||||
|
################
|
||||||
|
TEST_USER=test
|
||||||
|
TEST_USER_PASSWORD=Master Password
|
||||||
|
TEST_USER_MAIL=${TEST_USER}@example.com
|
||||||
|
|
||||||
|
TEST_USER2=test2
|
||||||
|
TEST_USER2_PASSWORD=Master Password
|
||||||
|
TEST_USER2_MAIL=${TEST_USER2}@example.com
|
||||||
|
|
||||||
|
TEST_USER3=test3
|
||||||
|
TEST_USER3_PASSWORD=Master Password
|
||||||
|
TEST_USER3_MAIL=${TEST_USER3}@example.com
|
||||||
|
|
||||||
|
###################
|
||||||
|
# Keycloak Config #
|
||||||
|
###################
|
||||||
|
KEYCLOAK_ADMIN=admin
|
||||||
|
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN}
|
||||||
|
KC_HTTP_HOST=127.0.0.1
|
||||||
|
KC_HTTP_PORT=8081
|
||||||
|
|
||||||
|
# Script parameters (use Keycloak and VaultWarden config too)
|
||||||
|
TEST_REALM=test
|
||||||
|
DUMMY_REALM=dummy
|
||||||
|
DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM}
|
||||||
|
|
||||||
|
######################
|
||||||
|
# Vaultwarden Config #
|
||||||
|
######################
|
||||||
|
ROCKET_PORT=8003
|
||||||
|
DOMAIN=http://127.0.0.1:${ROCKET_PORT}
|
||||||
|
SMTP_SECURITY=off
|
||||||
|
SMTP_PORT=${MAILDEV_SMTP_PORT}
|
||||||
|
SMTP_FROM_NAME=Vaultwarden
|
||||||
|
SMTP_TIMEOUT=5
|
||||||
|
|
||||||
|
SSO_CLIENT_ID=VaultWarden
|
||||||
|
SSO_CLIENT_SECRET=VaultWarden
|
||||||
|
SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM}
|
||||||
|
SSO_PKCE=true
|
||||||
|
|
||||||
|
###########################
|
||||||
|
# Docker MariaDb container#
|
||||||
|
###########################
|
||||||
|
MARIADB_PORT=3307
|
||||||
|
MARIADB_ROOT_PASSWORD=vaultwarden
|
||||||
|
MARIADB_USER=vaultwarden
|
||||||
|
MARIADB_PASSWORD=vaultwarden
|
||||||
|
MARIADB_DATABASE=vaultwarden
|
||||||
|
|
||||||
|
###########################
|
||||||
|
# Docker Mysql container#
|
||||||
|
###########################
|
||||||
|
MYSQL_PORT=3309
|
||||||
|
MYSQL_ROOT_PASSWORD=vaultwarden
|
||||||
|
MYSQL_USER=vaultwarden
|
||||||
|
MYSQL_PASSWORD=vaultwarden
|
||||||
|
MYSQL_DATABASE=vaultwarden
|
||||||
|
|
||||||
|
############################
|
||||||
|
# Docker Postgres container#
|
||||||
|
############################
|
||||||
|
POSTGRES_PORT=5433
|
||||||
|
POSTGRES_USER=vaultwarden
|
||||||
|
POSTGRES_PASSWORD=vaultwarden
|
||||||
|
POSTGRES_DB=vaultwarden
|
@ -0,0 +1,159 @@
|
|||||||
|
import { test, expect, type TestInfo } from '@playwright/test';
|
||||||
|
import { MailDev } from 'maildev';
|
||||||
|
|
||||||
|
const utils = require('../global-utils');
|
||||||
|
import { createAccount, logUser } from './setups/user';
|
||||||
|
|
||||||
|
let users = utils.loadEnv();
|
||||||
|
|
||||||
|
let mailserver;
|
||||||
|
|
||||||
|
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
|
||||||
|
mailserver = new MailDev({
|
||||||
|
port: process.env.MAILDEV_SMTP_PORT,
|
||||||
|
web: { port: process.env.MAILDEV_HTTP_PORT },
|
||||||
|
})
|
||||||
|
|
||||||
|
await mailserver.listen();
|
||||||
|
|
||||||
|
await utils.startVaultwarden(browser, testInfo, {
|
||||||
|
SMTP_HOST: process.env.MAILDEV_HOST,
|
||||||
|
SMTP_FROM: process.env.VAULTWARDEN_SMTP_FROM,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll('Teardown', async ({}) => {
|
||||||
|
utils.stopVaultwarden();
|
||||||
|
if( mailserver ){
|
||||||
|
await mailserver.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Account creation', async ({ page }) => {
|
||||||
|
const emails = mailserver.iterator(users.user1.email);
|
||||||
|
|
||||||
|
await createAccount(test, page, users.user1);
|
||||||
|
|
||||||
|
const { value: created } = await emails.next();
|
||||||
|
expect(created.subject).toBe("Welcome");
|
||||||
|
expect(created.from[0]?.address).toBe(process.env.VAULTWARDEN_SMTP_FROM);
|
||||||
|
|
||||||
|
// Back to the login page
|
||||||
|
await expect(page).toHaveTitle('Vaultwarden Web');
|
||||||
|
await expect(page.getByTestId("toast-message")).toHaveText(/Your new account has been created/);
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
// Unlock page
|
||||||
|
await page.getByLabel('Master password').fill(users.user1.password);
|
||||||
|
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
||||||
|
|
||||||
|
// We are now in the default vault page
|
||||||
|
await expect(page).toHaveTitle(/Vaults/);
|
||||||
|
|
||||||
|
const { value: logged } = await emails.next();
|
||||||
|
expect(logged.subject).toBe("New Device Logged In From Firefox");
|
||||||
|
expect(logged.to[0]?.address).toBe(process.env.TEST_USER_MAIL);
|
||||||
|
expect(logged.from[0]?.address).toBe(process.env.VAULTWARDEN_SMTP_FROM);
|
||||||
|
|
||||||
|
emails.return();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Login', async ({ context, page }) => {
|
||||||
|
const emails = mailserver.iterator(users.user1.email);
|
||||||
|
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
|
||||||
|
await test.step('new device email', async () => {
|
||||||
|
const { value: logged } = await emails.next();
|
||||||
|
expect(logged.subject).toBe("New Device Logged In From Firefox");
|
||||||
|
expect(logged.from[0]?.address).toBe(process.env.VAULTWARDEN_SMTP_FROM);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('verify email', async () => {
|
||||||
|
await page.getByText('Verify your account\'s email').click();
|
||||||
|
await expect(page.getByTestId("toast-message")).toHaveText(/Check your email inbox for a verification link/);
|
||||||
|
await page.locator('#toast-container').getByRole('button').click();
|
||||||
|
await expect(page.getByTestId("toast-message")).toHaveCount(0);
|
||||||
|
|
||||||
|
const { value: verify } = await emails.next();
|
||||||
|
expect(verify.subject).toBe("Verify Your Email");
|
||||||
|
expect(verify.from[0]?.address).toBe(process.env.VAULTWARDEN_SMTP_FROM);
|
||||||
|
|
||||||
|
const page2 = await context.newPage();
|
||||||
|
await page2.setContent(verify.html);
|
||||||
|
const link = await page2.getByTestId("verify").getAttribute("href");
|
||||||
|
await page2.close();
|
||||||
|
|
||||||
|
await page.goto(link);
|
||||||
|
await expect(page.getByTestId("toast-message")).toHaveText("Account email verified");
|
||||||
|
});
|
||||||
|
|
||||||
|
emails.return();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Activaite 2fa', async ({ context, page }) => {
|
||||||
|
const emails = mailserver.buffer(users.user1.email);
|
||||||
|
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: users.user1.name }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Account settings' }).click();
|
||||||
|
await page.getByLabel('Security').click();
|
||||||
|
await page.getByRole('link', { name: 'Two-step login' }).click();
|
||||||
|
await page.locator('li').filter({ hasText: 'Email Verification codes will' }).getByRole('button').click();
|
||||||
|
await page.getByLabel('Master password (required)').fill(users.user1.password);
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Send email' }).click();
|
||||||
|
|
||||||
|
const codeMail = await emails.next((mail) => mail.subject === "Vaultwarden Login Verification Code");
|
||||||
|
const page2 = await context.newPage();
|
||||||
|
await page2.setContent(codeMail.html);
|
||||||
|
const code = await page2.getByTestId("2fa").innerText();
|
||||||
|
await page2.close();
|
||||||
|
|
||||||
|
await page.getByLabel('2. Enter the resulting 6').fill(code);
|
||||||
|
await page.getByRole('button', { name: 'Turn on' }).click();
|
||||||
|
await page.getByRole('heading', { name: 'Turned on', exact: true });
|
||||||
|
|
||||||
|
emails.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('2fa', async ({ context, page }) => {
|
||||||
|
const emails = mailserver.buffer(users.user1.email);
|
||||||
|
|
||||||
|
await test.step('login', async () => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await page.getByLabel(/Email address/).fill(users.user1.email);
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await page.getByLabel('Master password').fill(users.user1.password);
|
||||||
|
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
||||||
|
|
||||||
|
const codeMail = await emails.next((mail) => mail.subject === "Vaultwarden Login Verification Code");
|
||||||
|
const page2 = await context.newPage();
|
||||||
|
await page2.setContent(codeMail.html);
|
||||||
|
const code = await page2.getByTestId("2fa").innerText();
|
||||||
|
await page2.close();
|
||||||
|
|
||||||
|
await page.getByLabel('Verification code').fill(code);
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveTitle(/Vaults/);
|
||||||
|
})
|
||||||
|
|
||||||
|
await test.step('disable', async () => {
|
||||||
|
await page.getByRole('button', { name: 'Test' }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Account settings' }).click();
|
||||||
|
await page.getByLabel('Security').click();
|
||||||
|
await page.getByRole('link', { name: 'Two-step login' }).click();
|
||||||
|
await page.locator('li').filter({ hasText: 'Email Turned on Verification' }).getByRole('button').click();
|
||||||
|
await page.getByLabel('Master password (required)').click();
|
||||||
|
await page.getByLabel('Master password (required)').fill(users.user1.password);
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Turn off' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Yes' }).click();
|
||||||
|
await expect(page.getByTestId("toast-message")).toHaveText(/Two-step login provider turned off/);
|
||||||
|
});
|
||||||
|
|
||||||
|
emails.close();
|
||||||
|
});
|
@ -0,0 +1,97 @@
|
|||||||
|
import { test, expect, type Page, type TestInfo } from '@playwright/test';
|
||||||
|
import * as OTPAuth from "otpauth";
|
||||||
|
|
||||||
|
import * as utils from "../global-utils";
|
||||||
|
import { createAccount, logUser } from './setups/user';
|
||||||
|
|
||||||
|
let users = utils.loadEnv();
|
||||||
|
let totp;
|
||||||
|
|
||||||
|
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
|
||||||
|
await utils.startVaultwarden(browser, testInfo, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll('Teardown', async ({}, testInfo: TestInfo) => {
|
||||||
|
utils.stopVaultwarden(testInfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Account creation', async ({ page }) => {
|
||||||
|
// Landing page
|
||||||
|
await createAccount(test, page, users.user1);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
// Unlock page
|
||||||
|
await page.getByLabel('Master password').fill(users.user1.password);
|
||||||
|
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
||||||
|
|
||||||
|
// We are now in the default vault page
|
||||||
|
await expect(page).toHaveTitle(/Vaults/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Master password login', async ({ page }) => {
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Authenticator 2fa', async ({ context, page }) => {
|
||||||
|
let totp;
|
||||||
|
|
||||||
|
await test.step('Login', async () => {
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Activate', async () => {
|
||||||
|
await page.getByRole('button', { name: users.user1.name }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Account settings' }).click();
|
||||||
|
await page.getByLabel('Security').click();
|
||||||
|
await page.getByRole('link', { name: 'Two-step login' }).click();
|
||||||
|
await page.locator('li').filter({ hasText: 'Authenticator app Use an' }).getByRole('button').click();
|
||||||
|
await page.getByLabel('Master password (required)').fill(users.user1.password);
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
const secret = await page.getByLabel('Key').innerText();
|
||||||
|
totp = new OTPAuth.TOTP({ secret, period: 30 });
|
||||||
|
|
||||||
|
await page.getByLabel('3. Enter the resulting 6').fill(totp.generate());
|
||||||
|
await page.getByRole('button', { name: 'Turn on' }).click();
|
||||||
|
await page.getByRole('heading', { name: 'Turned on', exact: true });
|
||||||
|
await page.getByLabel('Close').click();
|
||||||
|
})
|
||||||
|
|
||||||
|
await test.step('logout', async () => {
|
||||||
|
await page.getByRole('button', { name: users.user1.name }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Log out' }).click();
|
||||||
|
await expect(page.getByTestId("toast-title")).toHaveText("Logged out");
|
||||||
|
await page.locator('#toast-container').getByRole('button').click();
|
||||||
|
await expect(page.getByTestId("toast-title")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('login', async () => {
|
||||||
|
let timestamp = Date.now(); // Need to use the next token
|
||||||
|
timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000;
|
||||||
|
|
||||||
|
await page.getByLabel(/Email address/).fill(users.user1.email);
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await page.getByLabel('Master password').fill(users.user1.password);
|
||||||
|
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
||||||
|
|
||||||
|
await page.getByLabel('Verification code').fill(totp.generate({timestamp}));
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveTitle(/Vaults/);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('disable', async () => {
|
||||||
|
await page.getByRole('button', { name: 'Test' }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Account settings' }).click();
|
||||||
|
await page.getByLabel('Security').click();
|
||||||
|
await page.getByRole('link', { name: 'Two-step login' }).click();
|
||||||
|
await page.locator('li').filter({ hasText: /Authenticator app/ }).getByRole('button').click();
|
||||||
|
await page.getByLabel('Master password (required)').click();
|
||||||
|
await page.getByLabel('Master password (required)').fill(users.user1.password);
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Turn off' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Yes' }).click();
|
||||||
|
await expect(page.getByTestId("toast-message")).toHaveText(/Two-step login provider turned off/);
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,159 @@
|
|||||||
|
import { test, expect, type TestInfo } from '@playwright/test';
|
||||||
|
import { MailDev } from 'maildev';
|
||||||
|
|
||||||
|
import * as utils from "../global-utils";
|
||||||
|
import { createAccount, logUser } from './setups/user';
|
||||||
|
|
||||||
|
let users = utils.loadEnv();
|
||||||
|
|
||||||
|
let mailserver, user1Mails, user2Mails, user3Mails;
|
||||||
|
|
||||||
|
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
|
||||||
|
mailserver = new MailDev({
|
||||||
|
port: process.env.MAILDEV_SMTP_PORT,
|
||||||
|
web: { port: process.env.MAILDEV_HTTP_PORT },
|
||||||
|
})
|
||||||
|
|
||||||
|
await mailserver.listen();
|
||||||
|
|
||||||
|
await utils.startVaultwarden(browser, testInfo, {
|
||||||
|
SMTP_HOST: process.env.MAILDEV_HOST,
|
||||||
|
SMTP_FROM: process.env.VAULTWARDEN_SMTP_FROM,
|
||||||
|
});
|
||||||
|
|
||||||
|
user1Mails = mailserver.iterator(users.user1.email);
|
||||||
|
user2Mails = mailserver.iterator(users.user2.email);
|
||||||
|
user3Mails = mailserver.iterator(users.user3.email);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll('Teardown', async ({}, testInfo: TestInfo) => {
|
||||||
|
utils.stopVaultwarden(testInfo);
|
||||||
|
utils.closeMails(mailserver, [user1Mails, user2Mails, user3Mails]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Create user3', async ({ page }) => {
|
||||||
|
await createAccount(test, page, users.user3, user3Mails);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Invite users', async ({ page }) => {
|
||||||
|
await createAccount(test, page, users.user1, user1Mails);
|
||||||
|
await logUser(test, page, users.user1, user1Mails);
|
||||||
|
|
||||||
|
await test.step('Create Org', async () => {
|
||||||
|
await page.getByRole('link', { name: 'New organisation' }).click();
|
||||||
|
await page.getByLabel('Organisation name (required)').fill('Test');
|
||||||
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
await page.locator('div').filter({ hasText: 'Members' }).nth(2).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Invite user2', async () => {
|
||||||
|
await page.getByRole('button', { name: 'Invite member' }).click();
|
||||||
|
await page.getByLabel('Email (required)').fill(users.user2.email);
|
||||||
|
await page.getByRole('tab', { name: 'Collections' }).click();
|
||||||
|
await page.locator('label').filter({ hasText: 'Grant access to all current' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
await expect(page.getByTestId("toast-message")).toHaveText('User(s) invited');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Invite user3', async () => {
|
||||||
|
await page.getByRole('button', { name: 'Invite member' }).click();
|
||||||
|
await page.getByLabel('Email (required)').fill(users.user3.email);
|
||||||
|
await page.getByRole('tab', { name: 'Collections' }).click();
|
||||||
|
await page.locator('label').filter({ hasText: 'Grant access to all current' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
await expect(page.getByTestId("toast-message")).toHaveText('User(s) invited');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invited with new account', async ({ page }) => {
|
||||||
|
const { value: invited } = await user2Mails.next();
|
||||||
|
expect(invited.subject).toContain("Join Test")
|
||||||
|
|
||||||
|
await test.step('Create account', async () => {
|
||||||
|
await page.setContent(invited.html);
|
||||||
|
const link = await page.getByTestId("invite").getAttribute("href");
|
||||||
|
await page.goto(link);
|
||||||
|
await expect(page).toHaveTitle(/Create account | Vaultwarden Web/);
|
||||||
|
|
||||||
|
await page.getByLabel('Name').fill(users.user2.name);
|
||||||
|
await page.getByLabel('Master password\n (required)', { exact: true }).fill(users.user2.password);
|
||||||
|
await page.getByLabel('Re-type master password').fill(users.user2.password);
|
||||||
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
|
|
||||||
|
// Back to the login page
|
||||||
|
await expect(page).toHaveTitle('Vaultwarden Web');
|
||||||
|
await expect(page.getByTestId("toast-message")).toHaveText(/Your new account has been created/);
|
||||||
|
|
||||||
|
const { value: welcome } = await user2Mails.next();
|
||||||
|
expect(welcome.subject).toContain("Welcome")
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Login', async () => {
|
||||||
|
await page.getByLabel(/Email address/).fill(users.user2.email);
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
// Unlock page
|
||||||
|
await page.getByLabel('Master password').fill(users.user2.password);
|
||||||
|
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
||||||
|
|
||||||
|
// We are now in the default vault page
|
||||||
|
await expect(page).toHaveTitle(/Vaults/);
|
||||||
|
await expect(page.getByTestId("toast-title")).toHaveText("Invitation accepted");
|
||||||
|
|
||||||
|
const { value: logged } = await user2Mails.next();
|
||||||
|
expect(logged.subject).toContain("New Device Logged");
|
||||||
|
});
|
||||||
|
|
||||||
|
const { value: accepted } = await user1Mails.next();
|
||||||
|
expect(accepted.subject).toContain("Invitation to Test accepted")
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invited with existing account', async ({ page }) => {
|
||||||
|
const { value: invited } = await user3Mails.next();
|
||||||
|
expect(invited.subject).toContain("Join Test")
|
||||||
|
|
||||||
|
await page.setContent(invited.html);
|
||||||
|
const link = await page.getByTestId("invite").getAttribute("href");
|
||||||
|
|
||||||
|
await page.goto(link);
|
||||||
|
|
||||||
|
// We should be on login page with email prefilled
|
||||||
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
// Unlock page
|
||||||
|
await page.getByLabel('Master password').fill(users.user3.password);
|
||||||
|
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
||||||
|
|
||||||
|
// We are now in the default vault page
|
||||||
|
await expect(page).toHaveTitle(/Vaults/);
|
||||||
|
await expect(page.getByTestId("toast-title")).toHaveText("Invitation accepted");
|
||||||
|
|
||||||
|
const { value: logged } = await user3Mails.next();
|
||||||
|
expect(logged.subject).toContain("New Device Logged")
|
||||||
|
|
||||||
|
const { value: accepted } = await user1Mails.next();
|
||||||
|
expect(accepted.subject).toContain("Invitation to Test accepted")
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Confirm invited user', async ({ page }) => {
|
||||||
|
await logUser(test, page, users.user1, user1Mails);
|
||||||
|
await page.getByLabel('Switch products').click();
|
||||||
|
await page.getByRole('link', { name: ' Admin Console' }).click();
|
||||||
|
await page.getByLabel('Members').click();
|
||||||
|
|
||||||
|
await test.step('Accept user2', async () => {
|
||||||
|
await page.getByRole('row', { name: users.user2.name }).getByLabel('Options').click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Confirm' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||||
|
await expect(page.getByTestId("toast-message")).toHaveText(/confirmed/);
|
||||||
|
|
||||||
|
const { value: logged } = await user2Mails.next();
|
||||||
|
expect(logged.subject).toContain("Invitation to Test confirmed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Organization is visible', async ({ page }) => {
|
||||||
|
await logUser(test, page, users.user2, user2Mails);
|
||||||
|
await page.getByLabel('vault: Test').click();
|
||||||
|
});
|
@ -0,0 +1,7 @@
|
|||||||
|
import { test } from './db-test';
|
||||||
|
|
||||||
|
const utils = require('../../global-utils');
|
||||||
|
|
||||||
|
test('DB start', async ({ serviceName }) => {
|
||||||
|
utils.startComposeService(serviceName);
|
||||||
|
});
|
@ -0,0 +1,11 @@
|
|||||||
|
import { test } from './db-test';
|
||||||
|
|
||||||
|
const utils = require('../../global-utils');
|
||||||
|
|
||||||
|
utils.loadEnv();
|
||||||
|
|
||||||
|
test('DB teardown ?', async ({ serviceName }) => {
|
||||||
|
if( process.env.PW_KEEP_SERVICE_RUNNNING !== "true" ) {
|
||||||
|
utils.stopComposeService(serviceName);
|
||||||
|
}
|
||||||
|
});
|
@ -0,0 +1,9 @@
|
|||||||
|
import { test as base } from '@playwright/test';
|
||||||
|
|
||||||
|
export type TestOptions = {
|
||||||
|
serviceName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const test = base.extend<TestOptions>({
|
||||||
|
serviceName: ['', { option: true }],
|
||||||
|
});
|
@ -0,0 +1,19 @@
|
|||||||
|
import { test, expect, type TestInfo } from '@playwright/test';
|
||||||
|
|
||||||
|
const { exec } = require('node:child_process');
|
||||||
|
const utils = require('../../global-utils');
|
||||||
|
|
||||||
|
utils.loadEnv();
|
||||||
|
|
||||||
|
test.beforeAll('Setup', async () => {
|
||||||
|
console.log("Starting Keycloak");
|
||||||
|
exec(`docker compose --profile keycloak --env-file test.env up`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Keycloak is up', async ({ page }) => {
|
||||||
|
test.setTimeout(60000);
|
||||||
|
await utils.waitFor(process.env.SSO_AUTHORITY, page.context().browser());
|
||||||
|
// Dummy authority is created at the end of the setup
|
||||||
|
await utils.waitFor(process.env.DUMMY_AUTHORITY, page.context().browser());
|
||||||
|
console.log(`Keycloak running on: ${process.env.SSO_AUTHORITY}`);
|
||||||
|
});
|
@ -0,0 +1,15 @@
|
|||||||
|
import { test, type FullConfig } from '@playwright/test';
|
||||||
|
|
||||||
|
const { execSync } = require('node:child_process');
|
||||||
|
const utils = require('../../global-utils');
|
||||||
|
|
||||||
|
utils.loadEnv();
|
||||||
|
|
||||||
|
test('Keycloak teardown', async () => {
|
||||||
|
if( process.env.PW_KEEP_SERVICE_RUNNNING === "true" ) {
|
||||||
|
console.log("Keep Keycloak running");
|
||||||
|
} else {
|
||||||
|
console.log("Keycloak stopping");
|
||||||
|
execSync(`docker compose --profile keycloak --env-file test.env stop Keycloak`);
|
||||||
|
}
|
||||||
|
});
|
@ -0,0 +1,82 @@
|
|||||||
|
import { expect, type Page, Test } from '@playwright/test';
|
||||||
|
import { type Mail } from 'maildev';
|
||||||
|
|
||||||
|
export async function createAccount(test: Test, page: Page, user: { email: string, name: string, password: string }, emails: AsyncIterator<Mail>) {
|
||||||
|
await test.step('Create user', async () => {
|
||||||
|
await test.step('Landing page', async () => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByLabel(/Email address/).fill(user.email);
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('SSo start page', async () => {
|
||||||
|
await page.getByRole('link', { name: /Enterprise single sign-on/ }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Keycloak login', async () => {
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible();
|
||||||
|
await page.getByLabel(/Username/).fill(user.name);
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(user.password);
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Create Vault account', async () => {
|
||||||
|
await expect(page.getByText('Set master password')).toBeVisible();
|
||||||
|
await page.getByLabel('Master password', { exact: true }).fill(user.password);
|
||||||
|
await page.getByLabel('Re-type master password').fill(user.password);
|
||||||
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Default vault page', async () => {
|
||||||
|
await expect(page).toHaveTitle(/Vaults/);
|
||||||
|
});
|
||||||
|
|
||||||
|
if( emails ){
|
||||||
|
await test.step('Check emails', async () => {
|
||||||
|
const { value: logged } = await emails.next();
|
||||||
|
expect(logged.subject).toContain("New Device Logged");
|
||||||
|
|
||||||
|
const { value: password } = await emails.next();
|
||||||
|
expect(password.subject).toContain("Master Password Has Been Changed");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logUser(test: Test, page: Page, user: { email: string, password: string }, emails: AsyncIterator<Mail>) {
|
||||||
|
await test.step('Log user', async () => {
|
||||||
|
await test.step('Landing page', async () => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByLabel(/Email address/).fill(user.email);
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('SSo start page', async () => {
|
||||||
|
await page.getByRole('link', { name: /Enterprise single sign-on/ }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Keycloak login', async () => {
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible();
|
||||||
|
await page.getByLabel(/Username/).fill(user.name);
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(user.password);
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Unlock vault', async () => {
|
||||||
|
await expect(page).toHaveTitle('Vaultwarden Web');
|
||||||
|
await page.getByLabel('Master password').fill(user.password);
|
||||||
|
await page.getByRole('button', { name: 'Unlock' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Default vault page', async () => {
|
||||||
|
await expect(page).toHaveTitle(/Vaults/);
|
||||||
|
});
|
||||||
|
|
||||||
|
if( emails ){
|
||||||
|
await test.step('Check email', async () => {
|
||||||
|
const { value: logged } = await emails.next();
|
||||||
|
expect(logged.subject).toContain("New Device Logged");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
@ -0,0 +1,47 @@
|
|||||||
|
import { expect, type Browser,Page } from '@playwright/test';
|
||||||
|
|
||||||
|
export async function createAccount(test, page: Page, user: { email: string, name: string, password: string }, emails) {
|
||||||
|
await test.step('Create user', async () => {
|
||||||
|
// Landing page
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('link', { name: 'Create account' }).click();
|
||||||
|
|
||||||
|
// Back to Vault create account
|
||||||
|
await expect(page).toHaveTitle(/Create account | Vaultwarden Web/);
|
||||||
|
await page.getByLabel(/Email address/).fill(user.email);
|
||||||
|
await page.getByLabel('Name').fill(user.name);
|
||||||
|
await page.getByLabel('Master password\n (required)', { exact: true }).fill(user.password);
|
||||||
|
await page.getByLabel('Re-type master password').fill(user.password);
|
||||||
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
|
|
||||||
|
// Back to the login page
|
||||||
|
await expect(page).toHaveTitle('Vaultwarden Web');
|
||||||
|
await expect(page.getByTestId("toast-message")).toHaveText(/Your new account has been created/);
|
||||||
|
|
||||||
|
if( emails ){
|
||||||
|
const { value: welcome } = await emails.next();
|
||||||
|
expect(welcome.subject).toContain("Welcome");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logUser(test, page: Page, user: { email: string, password: string }, emails) {
|
||||||
|
await test.step('Log user', async () => {
|
||||||
|
// Landing page
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByLabel(/Email address/).fill(user.email);
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
// Unlock page
|
||||||
|
await page.getByLabel('Master password').fill(user.password);
|
||||||
|
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
||||||
|
|
||||||
|
// We are now in the default vault page
|
||||||
|
await expect(page).toHaveTitle(/Vaults/);
|
||||||
|
|
||||||
|
if( emails ){
|
||||||
|
const { value: logged } = await emails.next();
|
||||||
|
expect(logged.subject).toContain("New Device Logged");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
@ -0,0 +1,74 @@
|
|||||||
|
import { test, expect, type TestInfo } from '@playwright/test';
|
||||||
|
import { createAccount, logUser } from './setups/user';
|
||||||
|
import * as utils from "../global-utils";
|
||||||
|
|
||||||
|
let users = utils.loadEnv();
|
||||||
|
|
||||||
|
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
|
||||||
|
await utils.startVaultwarden(browser, testInfo, {
|
||||||
|
SSO_ENABLED: true,
|
||||||
|
SSO_ONLY: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll('Teardown', async ({}, testInfo: TestInfo) => {
|
||||||
|
utils.stopVaultwarden(testInfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Account creation using SSO', async ({ page }) => {
|
||||||
|
// Landing page
|
||||||
|
await createAccount(test, page, users.user1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SSO login', async ({ page }) => {
|
||||||
|
await logUser(test, page, users.user1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Non SSO login', async ({ page }) => {
|
||||||
|
// Landing page
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByLabel(/Email address/).fill(users.user1.email);
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
// Unlock page
|
||||||
|
await page.getByLabel('Master password').fill(users.user1.password);
|
||||||
|
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
||||||
|
|
||||||
|
// We are now in the default vault page
|
||||||
|
await expect(page).toHaveTitle(/Vaults/);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('Non SSO login Failure', async ({ page, browser }, testInfo: TestInfo) => {
|
||||||
|
await utils.restartVaultwarden(page, testInfo, {
|
||||||
|
SSO_ENABLED: true,
|
||||||
|
SSO_ONLY: true
|
||||||
|
}, false);
|
||||||
|
|
||||||
|
// Landing page
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByLabel(/Email address/).fill(users.user1.email);
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
// Unlock page
|
||||||
|
await page.getByLabel('Master password').fill(users.user1.password);
|
||||||
|
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
||||||
|
|
||||||
|
// An error should appear
|
||||||
|
await page.getByLabel('SSO sign-in is required')
|
||||||
|
});
|
||||||
|
|
||||||
|
test('No SSO login', async ({ page }, testInfo: TestInfo) => {
|
||||||
|
await utils.restartVaultwarden(page, testInfo, {
|
||||||
|
SSO_ENABLED: false
|
||||||
|
}, false);
|
||||||
|
|
||||||
|
// Landing page
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByLabel(/Email address/).fill(users.user1.email);
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
// No SSO button
|
||||||
|
await page.getByLabel('Master password');
|
||||||
|
await expect(page.getByRole('link', { name: /Enterprise single sign-on/ })).toHaveCount(0);
|
||||||
|
});
|
@ -0,0 +1,152 @@
|
|||||||
|
import { test, expect, type TestInfo } from '@playwright/test';
|
||||||
|
import { MailDev } from 'maildev';
|
||||||
|
|
||||||
|
import * as utils from "../global-utils";
|
||||||
|
import { createAccount, logUser } from './setups/sso';
|
||||||
|
|
||||||
|
let users = utils.loadEnv();
|
||||||
|
|
||||||
|
let mailserver, user1Mails, user2Mails, user3Mails;
|
||||||
|
|
||||||
|
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
|
||||||
|
mailserver = new MailDev({
|
||||||
|
port: process.env.MAILDEV_SMTP_PORT,
|
||||||
|
web: { port: process.env.MAILDEV_HTTP_PORT },
|
||||||
|
})
|
||||||
|
|
||||||
|
await mailserver.listen();
|
||||||
|
|
||||||
|
await utils.startVaultwarden(browser, testInfo, {
|
||||||
|
SMTP_HOST: process.env.MAILDEV_HOST,
|
||||||
|
SMTP_FROM: process.env.VAULTWARDEN_SMTP_FROM,
|
||||||
|
SSO_ENABLED: true,
|
||||||
|
SSO_ONLY: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
user1Mails = mailserver.iterator(users.user1.email);
|
||||||
|
user2Mails = mailserver.iterator(users.user2.email);
|
||||||
|
user3Mails = mailserver.iterator(users.user3.email);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll('Teardown', async ({}, testInfo: TestInfo) => {
|
||||||
|
utils.stopVaultwarden(testInfo);
|
||||||
|
utils.closeMails(mailserver, [user1Mails, user2Mails, user3Mails]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Create user2', async ({ page }) => {
|
||||||
|
await createAccount(test, page, users.user2, user2Mails);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Invite users', async ({ page }) => {
|
||||||
|
await createAccount(test, page, users.user1, user1Mails);
|
||||||
|
|
||||||
|
await test.step('Create Org', async () => {
|
||||||
|
await page.getByRole('link', { name: 'New organisation' }).click();
|
||||||
|
await page.getByLabel('Organisation name (required)').fill('Test');
|
||||||
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
await page.locator('div').filter({ hasText: 'Members' }).nth(2).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Invite user2', async () => {
|
||||||
|
await page.getByRole('button', { name: 'Invite member' }).click();
|
||||||
|
await page.getByLabel('Email (required)').fill(users.user2.email);
|
||||||
|
await page.getByRole('tab', { name: 'Collections' }).click();
|
||||||
|
await page.locator('label').filter({ hasText: 'Grant access to all current' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
await expect(page.getByTestId("toast-message")).toHaveText('User(s) invited');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Invite user3', async () => {
|
||||||
|
await page.getByRole('button', { name: 'Invite member' }).click();
|
||||||
|
await page.getByLabel('Email (required)').fill(users.user3.email);
|
||||||
|
await page.getByRole('tab', { name: 'Collections' }).click();
|
||||||
|
await page.locator('label').filter({ hasText: 'Grant access to all current' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
await expect(page.getByTestId("toast-message")).toHaveText('User(s) invited');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invited with existing account', async ({ page }) => {
|
||||||
|
const link = await test.step('Extract email link', async () => {
|
||||||
|
const { value: invited } = await user2Mails.next();
|
||||||
|
expect(invited.subject).toContain("Join Test")
|
||||||
|
|
||||||
|
await page.setContent(invited.html);
|
||||||
|
return await page.getByTestId("invite").getAttribute("href");
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Redirect to Keycloak', async () => {
|
||||||
|
await page.goto(link);
|
||||||
|
await expect(page).toHaveTitle("Enterprise single sign-on | Vaultwarden Web");
|
||||||
|
await page.getByRole('button', { name: 'Log in' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Keycloak login', async () => {
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible();
|
||||||
|
await page.getByLabel(/Username/).fill(users.user2.name);
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(users.user2.password);
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Unlock vault', async () => {
|
||||||
|
await expect(page).toHaveTitle('Vaultwarden Web');
|
||||||
|
await page.getByLabel('Master password').fill(users.user2.password);
|
||||||
|
await page.getByRole('button', { name: 'Unlock' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Default vault page', async () => {
|
||||||
|
await expect(page).toHaveTitle(/Vaults/);
|
||||||
|
await expect(page.getByTestId("toast-title")).toHaveText("Invitation accepted");
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Check mails', async () => {
|
||||||
|
const { value: logged } = await user2Mails.next();
|
||||||
|
expect(logged.subject).toContain("New Device Logged")
|
||||||
|
|
||||||
|
const { value: accepted } = await user1Mails.next();
|
||||||
|
expect(accepted.subject).toContain("Invitation to Test accepted")
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invited with new account', async ({ page }) => {
|
||||||
|
const link = await test.step('Extract email link', async () => {
|
||||||
|
const { value: invited } = await user3Mails.next();
|
||||||
|
expect(invited.subject).toContain("Join Test")
|
||||||
|
|
||||||
|
await page.setContent(invited.html);
|
||||||
|
return await page.getByTestId("invite").getAttribute("href");
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Redirect to Keycloak', async () => {
|
||||||
|
await page.goto(link);
|
||||||
|
await expect(page).toHaveTitle("Enterprise single sign-on | Vaultwarden Web");
|
||||||
|
await page.getByRole('button', { name: 'Log in' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Keycloak login', async () => {
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible();
|
||||||
|
await page.getByLabel(/Username/).fill(users.user3.name);
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(users.user3.password);
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Create Vault account', async () => {
|
||||||
|
await expect(page.getByText('Set master password')).toBeVisible();
|
||||||
|
await page.getByLabel('Master password', { exact: true }).fill(users.user3.password);
|
||||||
|
await page.getByLabel('Re-type master password').fill(users.user3.password);
|
||||||
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Default vault page', async () => {
|
||||||
|
await expect(page).toHaveTitle(/Vaults/);
|
||||||
|
await expect(page.getByTestId("toast-title")).toHaveText("Invitation accepted");
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Check mails', async () => {
|
||||||
|
const { value: logged } = await user3Mails.next();
|
||||||
|
expect(logged.subject).toContain("New Device Logged")
|
||||||
|
|
||||||
|
const { value: accepted } = await user1Mails.next();
|
||||||
|
expect(accepted.subject).toContain("Invitation to Test accepted")
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in new issue