recipe-maintainer: public snapshot (secrets + deployment plans removed, single commit)

Sanitized single-commit public mirror of recipe-maintainer.
- Removed test-ssh/.testenv (live creds); added test-ssh/.testenv.example placeholders.
- Removed plans/ and planned-updates/ (deployment-planning docs) so no client/
  deployment domains appear in the public repo.
- All other secret stores were already gitignored.
- docs.coopcloud.tech retained as a submodule (public upstream).
This commit is contained in:
2026-06-16 20:18:24 +00:00
commit f283a371bb
253 changed files with 15975 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
# Recipe Info
Each recipe has its own subdirectory under `recipe-info/` containing upstream information, setup instructions, tests, and other recipe-specific documentation.
## Directory Structure
```
recipe-info/
README.md # This file
testsecrets/ # Synced Docker secrets from test server (gitignored)
<recipe-name>/
recipe.toml # Recipe metadata (domain, instance, dependencies)
upstream.md # Upstream project links, release notes URLs
setup.md # Setup and deployment instructions
test.md # What to test and how to verify
tests/
health_check.py # Basic health/reachability check
*.py # Additional Python test scripts
```
Some recipes also have SSO integration scripts (`setup_*_integration.py`) and credential files (gitignored).
## Recipes
| Recipe | Directory |
|----------------|-----------------------------------|
| Authentik | `recipe-info/authentik/` |
| Bluesky PDS | `recipe-info/bluesky-pds/` |
| CryptPad | `recipe-info/cryptpad/` |
| HedgeDoc | `recipe-info/hedgedoc/` |
| Immich | `recipe-info/immich/` |
| Keycloak | `recipe-info/keycloak/` |
| La Suite Docs | `recipe-info/lasuite-docs/` |
| La Suite Drive | `recipe-info/lasuite-drive/` |
| La Suite Meet | `recipe-info/lasuite-meet/` |
| Matrix Synapse | `recipe-info/matrix-synapse/` |
| Mumble | `recipe-info/mumble/` |
## How to Use
1. Navigate to the recipe's directory.
2. Check `recipe.toml` for recipe metadata (domain, instance name, dependencies).
3. Check `upstream.md` for upstream project links and release notes URLs.
4. Read `setup.md` for deployment and configuration instructions.
5. Read `test.md` for an overview of what to verify and expected behaviour.
6. Run tests via the test runner: `python scripts/test_runner.py <recipe-name>`.
+5
View File
@@ -0,0 +1,5 @@
name = "authentik"
[dependencies]
requires = []
test_requires = ["ld2"] # oidc_integration test needs the ld2 lasuite-docs instance
+30
View File
@@ -0,0 +1,30 @@
# Authentik — First-Time Setup
## Prerequisites
- DNS: `authentik.<domain_suffix>` must resolve to the server
## Steps
1. **Create the app:**
```bash
abra app new authentik --server <SERVER> --domain authentik.<DOMAIN_SUFFIX> --no-input
```
2. **Generate secrets:**
```bash
abra app secret generate authentik.<DOMAIN_SUFFIX> --all -m --no-input
```
Save output to `recipe-info/testsecrets/authentik.<DOMAIN_SUFFIX>`.
3. **Deploy:**
```bash
abra app deploy authentik.<DOMAIN_SUFFIX> --chaos --force --no-input
```
4. **Verify:** curl `https://authentik.<DOMAIN_SUFFIX>` returns HTTP 200.
## Notes
- Admin credentials: username `akadmin`, password from `admin_pass` secret in testsecrets.
- The bootstrap token (`AK_TOKEN`) is the `bootstrap_token` secret — needed by SSO setup scripts for other recipes.
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Setup Authentik OIDC integration for La Suite Docs (ld2).
Creates an OAuth2 provider, application, and test user in Authentik,
then inserts the client secret and updates the Docs env file with
Authentik OIDC endpoints.
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from lib.abra import app_secret_insert
from lib.authentik import AuthentikAdmin
from lib.env import apply_env_overrides, get_abra_env_path, read_env_file
from lib.models import load_default_instance
from lib.secrets import load_secrets
# Configuration
PROVIDER_NAME = "lasuite-docs"
APP_SLUG = "lasuite-docs"
CLIENT_ID = "docs"
TEST_USER = "testuser"
TEST_PASS = "testpass123"
TEST_EMAIL = f"{TEST_USER}@test.example.com"
def main():
inst = load_default_instance()
ak_domain = inst.default_domain("authentik")
# Docs instance uses a custom domain (ld2) to avoid conflict with keycloak-backed docs
DOCS_DOMAIN = f"ld2.{inst.domain_suffix}"
ak_url = f"https://{ak_domain}"
# Get Authentik admin token from synced secrets
ak_secrets = load_secrets(ak_domain)
ak_token = ak_secrets["admin_token"]
ak = AuthentikAdmin(ak_url, ak_token)
# Resolve Authentik UUIDs
uuids = ak.resolve_uuids()
# Step 1: Create OAuth2 provider
provider_pk, client_secret = ak.ensure_provider(
PROVIDER_NAME, CLIENT_ID,
redirect_uris=[{
"matching_mode": "strict",
"url": f"https://{DOCS_DOMAIN}/api/v1.0/callback/",
}],
uuids=uuids,
)
# Step 2: Create application
ak.ensure_application("La Suite Docs", APP_SLUG, provider_pk,
f"https://{DOCS_DOMAIN}")
# Step 3: Create test user
user_pk = ak.ensure_user(TEST_USER, TEST_EMAIL, TEST_PASS)
app_password = ak.ensure_app_password(user_pk)
# Step 4: Insert client secret into Docs via abra
print("=== Insert OIDC client secret into Docs (ld2) ===", flush=True)
env_path = get_abra_env_path(inst.server, DOCS_DOMAIN)
if not env_path.exists():
print(f" WARNING: Docs env file not found at {env_path}", flush=True)
print(f" Create the app first: abra app new lasuite-docs --server {inst.server} --domain {DOCS_DOMAIN} --no-input", flush=True)
print(" Skipping secret insertion and env update", flush=True)
else:
env_data = read_env_file(env_path)
current_version = env_data.get("SECRET_OIDC_RPCS_VERSION", "v1")
next_num = int(current_version.lstrip("v")) + 1
next_version = f"v{next_num}"
print(f" Current secret version: {current_version}", flush=True)
print(f" Inserting as: {next_version}", flush=True)
app_secret_insert(DOCS_DOMAIN, "oidc_rpcs", next_version, client_secret)
# Step 5: Update Docs env with Authentik OIDC settings
print("=== Update Docs OIDC settings in env file ===", flush=True)
apply_env_overrides(env_path, {
"SECRET_OIDC_RPCS_VERSION": next_version,
"AUTH_DOMAIN": ak_domain,
"OIDC_OP_JWKS_ENDPOINT": f"https://{ak_domain}/application/o/{APP_SLUG}/jwks/",
"OIDC_OP_AUTHORIZATION_ENDPOINT": f"https://{ak_domain}/application/o/authorize/",
"OIDC_OP_TOKEN_ENDPOINT": f"https://{ak_domain}/application/o/token/",
"OIDC_OP_USER_ENDPOINT": f"https://{ak_domain}/application/o/userinfo/",
"OIDC_RP_CLIENT_ID": CLIENT_ID,
})
# Step 6: Write credentials file
script_dir = os.path.dirname(os.path.abspath(__file__))
creds_file = os.path.join(script_dir, f"authentik-test-credentials.{inst.domain_suffix}.toml")
print(f"=== Write credentials to {creds_file} ===", flush=True)
with open(creds_file, "w") as f:
f.write(f'# Authentik OIDC credentials for La Suite Docs (ld2) test instance\n')
f.write(f'#\n')
f.write(f'# Authentik instance: {ak_domain}\n')
f.write(f'# Application slug: {APP_SLUG}\n')
f.write(f'# Created by: setup_docs_integration.py\n')
f.write(f'\n')
f.write(f'# Authentik admin\n')
f.write(f'ak_token = "{ak_token}"\n')
f.write(f'\n')
f.write(f'# OIDC provider\n')
f.write(f'ak_app_slug = "{APP_SLUG}"\n')
f.write(f'ak_client_id = "{CLIENT_ID}"\n')
f.write(f'ak_client_secret = "{client_secret}"\n')
f.write(f'\n')
f.write(f'# Authentik OIDC endpoints\n')
f.write(f'ak_token_endpoint = "https://{ak_domain}/application/o/token/"\n')
f.write(f'ak_userinfo_endpoint = "https://{ak_domain}/application/o/userinfo/"\n')
f.write(f'ak_discovery_endpoint = "https://{ak_domain}/application/o/{APP_SLUG}/.well-known/openid-configuration"\n')
f.write(f'\n')
f.write(f'# Test user (password for browser login, app_password for password grant)\n')
f.write(f'ak_test_user = "{TEST_USER}"\n')
f.write(f'ak_test_pass = "{TEST_PASS}"\n')
f.write(f'ak_test_app_password = "{app_password}"\n')
f.write(f'ak_test_email = "{TEST_EMAIL}"\n')
f.write(f'\n')
f.write(f'# Docs instance\n')
f.write(f'docs_domain = "{DOCS_DOMAIN}"\n')
print(f" Written to {creds_file}", flush=True)
print("", flush=True)
print("=== Authentik OIDC integration setup complete ===", flush=True)
print("", flush=True)
print("Next steps:", flush=True)
print(f" 1. Redeploy Docs: abra app deploy {DOCS_DOMAIN} --chaos --force --no-input", flush=True)
print(f" 2. Run OIDC test: python3 recipe-info/authentik/tests/oidc_integration.py", flush=True)
if __name__ == "__main__":
main()
+140
View File
@@ -0,0 +1,140 @@
# Authentik Migration Test
Test that upgrading from 10.1.4+2025.10.2 (using `media:/media` mount) to 10.2.0+2026.2.1 (using `media:/data/media` mount) works correctly and preserves data.
## Overview
1. Deploy the old version
2. Seed test data into the media volume
3. Upgrade to the new version
4. Verify media files are accessible at the new path
## Steps
### 1. Deploy old version
Check out the pre-migration tag and deploy:
```bash
cd ~/.abra/recipes/authentik
git checkout 10.1.4+2025.10.2
```
Deploy:
```bash
abra app deploy authentik.<DOMAIN_SUFFIX> --chaos --force --no-input
```
Wait for the app to become healthy:
```bash
abra app ps authentik.<DOMAIN_SUFFIX> --chaos --no-input -m
```
### 2. Seed test data into the media volume
Create a marker file in the old `/media` volume:
```bash
script -qefc "abra app run authentik.<DOMAIN_SUFFIX> app -- sh -c 'echo migration-test-marker > /media/test-migration-marker.txt && echo created'" /dev/null
```
Verify the file was created:
```bash
script -qefc "abra app run authentik.<DOMAIN_SUFFIX> app -- cat /media/test-migration-marker.txt" /dev/null
```
Expected output: `migration-test-marker`
Record existing files in `/media` for comparison:
```bash
script -qefc "abra app run authentik.<DOMAIN_SUFFIX> app -- ls -la /media/" /dev/null
```
### 3. Upgrade to new version
Switch to the new version (with `media:/data/media` mount point change):
```bash
cd ~/.abra/recipes/authentik
git checkout <new-tag-or-branch>
```
Deploy the upgrade:
```bash
abra app deploy authentik.<DOMAIN_SUFFIX> --chaos --force --no-input
```
Wait for convergence (authentik runs database migrations on startup):
```bash
sleep 60
abra app ps authentik.<DOMAIN_SUFFIX> --chaos --no-input -m
```
### 4. Verify post-upgrade state
#### 4a. Health check
```bash
python3 recipe-info/authentik/tests/health_check.py
```
#### 4b. Marker file preserved at new path
The mount point change means `media:/data/media` — files that were at the volume root now appear under `/data/media/`:
```bash
script -qefc "abra app run authentik.<DOMAIN_SUFFIX> app -- cat /data/media/test-migration-marker.txt" /dev/null
```
Expected output: `migration-test-marker`
#### 4c. Directory listing
Verify the old files are now under `/data/media/`:
```bash
script -qefc "abra app run authentik.<DOMAIN_SUFFIX> app -- ls -la /data/media/" /dev/null
```
Compare with the listing from step 2 — all files from the old `/media/` should now appear under `/data/media/`.
#### 4d. Version label
```bash
abra app ps authentik.<DOMAIN_SUFFIX> --chaos --no-input -m
```
Confirm the version label shows `10.2.0+2026.2.1`.
#### 4e. Full test suite
```bash
python3 recipe-info/authentik/tests/health_check.py
python3 recipe-info/authentik/tests/oidc_integration.py
```
### 5. Clean up test marker
```bash
script -qefc "abra app run authentik.<DOMAIN_SUFFIX> app -- rm /data/media/test-migration-marker.txt" /dev/null
```
## Pass/fail criteria
| Check | Expected |
|-------|----------|
| Pre-upgrade health check | HTTP 200 |
| Marker file created in old `/media` | `migration-test-marker` |
| Post-upgrade health check | HTTP 200 |
| Marker file at `/data/media/test-migration-marker.txt` | `migration-test-marker` |
| `/data/media/` contains old files | Matches pre-upgrade listing |
| Version label updated | `10.2.0+2026.2.1` |
| OIDC integration test | PASS |
All checks must pass for the migration test to pass.
+85
View File
@@ -0,0 +1,85 @@
# Authentik Test Plan
Target: `https://authentik.<DOMAIN_SUFFIX>`
## Services
| Service | Image | Purpose |
|---------|-------|---------|
| app | `ghcr.io/goauthentik/server` | Web server (port 9000) |
| worker | `ghcr.io/goauthentik/server` | Background worker |
| db | `postgres:15` | PostgreSQL database |
## Test Setup
Before running all tests, the following must be in place:
### 1. Deploy authentik
```bash
abra app deploy authentik.<DOMAIN_SUFFIX> --chaos --force --no-input
```
### 2. Deploy the ld2 instance (test dependency)
The OIDC integration test uses a second La Suite Docs instance (`ld2`) as the relying party. This is separate from the primary `lasuite-docs` instance (which uses Keycloak for SSO).
If `ld2` does not exist yet, create it:
```bash
abra app new lasuite-docs --server <SERVER> --domain ld2.<DOMAIN_SUFFIX> --no-input
abra app secret generate ld2.<DOMAIN_SUFFIX> --all -m --no-input
abra app deploy ld2.<DOMAIN_SUFFIX> --chaos --force --no-input
```
If it already exists, just deploy:
```bash
abra app deploy ld2.<DOMAIN_SUFFIX> --chaos --force --no-input
```
### 3. Run the Authentik-Docs integration setup
```bash
python3 recipe-info/authentik/setup_docs_integration.py
```
This configures authentik as the OIDC provider for ld2:
1. Creates an OAuth2 provider (`lasuite-docs`) via the authentik REST API
2. Creates an Application linked to the provider
3. Creates a test user (`testuser` / `testpass123`) with an APP_PASSWORD token
4. Inserts the OIDC client secret into the ld2 Docs app via `abra app secret insert`
5. Updates the ld2 env file with authentik OIDC endpoints
6. Writes credentials to `authentik-test-credentials.<DOMAIN_SUFFIX>.toml`
### 4. Redeploy ld2 with OIDC config
```bash
abra app deploy ld2.<DOMAIN_SUFFIX> --chaos --force --no-input
```
## Automated Tests
- `tests/health_check.py` — HTTP 200 check on the main URL
- `tests/oidc_integration.py` — Full OIDC flow: obtains a token from authentik for a test user, then authenticates against the ld2 La Suite Docs API
### Credentials
| Key | Description |
|-----|-------------|
| `ak_token` | Authentik admin bootstrap token |
| `ak_client_id` / `ak_client_secret` | OIDC client ID and secret |
| `ak_test_user` / `ak_test_pass` | Test user credentials (password for browser login) |
| `ak_test_app_password` | APP_PASSWORD token for password grant (authentik requires this instead of regular passwords) |
| `ak_test_email` | Test user email |
Stored in `authentik-test-credentials.<DOMAIN_SUFFIX>.toml`.
## Manual Verification
1. Open `https://authentik.<DOMAIN_SUFFIX>` in a browser — should show the authentik login page
2. Log in with admin credentials: `akadmin` / `<admin_pass from testsecrets>`
3. Navigate to Admin Interface — should load the admin dashboard
4. Check System → System Tasks — background worker should be processing tasks
5. Navigate to Applications → Providers — verify `lasuite-docs` OAuth2 provider exists
6. Navigate to Applications → Applications — verify `lasuite-docs` application exists
7. Open `https://ld2.<DOMAIN_SUFFIX>` — click Login and verify the OIDC redirect to authentik works
8. Log in as `testuser` / `testpass123` — should redirect back to Docs as the authenticated user
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Health check for Authentik."""
import argparse
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import http_get, resolve_domain
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
domain = args.domain or resolve_domain('authentik')
url = f"https://{domain}"
print(f"Checking Authentik at {url} ...")
status, _ = http_get(url)
if status == 200:
print(f"PASS: Authentik returned HTTP {status}")
else:
print(f"FAIL: Authentik returned HTTP {status} (expected 200)")
sys.exit(1)
if __name__ == '__main__':
main()
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Authentik OIDC integration test."""
import argparse
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import (
http_get, http_post, load_toml_credentials, resolve_domain, _load_settings,
)
def main():
if os.environ.get('SKIP_INTEGRATION') == '1':
print("SKIP: OIDC integration test (SKIP_INTEGRATION=1)")
return
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
# Docs (ld2) domain — computed from settings
settings = _load_settings()
instance = settings["default_instance"]
suffix = settings["instances"][instance]["domain_suffix"]
docs_domain = f"ld2.{suffix}"
docs_url = f"https://{docs_domain}"
ak_domain = args.domain or resolve_domain('authentik')
ak_url = f"https://{ak_domain}"
recipe_dir = os.path.join(os.path.dirname(__file__), '..')
creds = load_toml_credentials(recipe_dir, 'authentik')
if creds is None:
print("FAIL: Credentials file not found: authentik-test-credentials.<domain_suffix>.toml")
print("Run setup_docs_integration.py first.")
sys.exit(1)
print("=== Authentik OIDC Integration Test ===")
print()
# Step 1: Check lasuite-docs (ld2) is deployed
print("Step 1: Checking lasuite-docs (ld2) is deployed ...")
status, _ = http_get(docs_url)
if status == 0:
print(f" FAIL: lasuite-docs is not reachable at {docs_url}")
sys.exit(1)
elif status >= 500:
print(f" FAIL: lasuite-docs returned HTTP {status}")
sys.exit(1)
print(f" OK: lasuite-docs (ld2) is reachable (HTTP {status})")
# Step 2: Verify Authentik OIDC discovery
print("Step 2: Checking Authentik OIDC discovery ...")
discovery_url = creds["ak_discovery_endpoint"]
status, _ = http_get(discovery_url)
if status != 200:
print(f" FAIL: OIDC discovery returned HTTP {status}")
print(f" URL: {discovery_url}")
sys.exit(1)
print(f" PASS: OIDC discovery endpoint OK (app '{creds['ak_app_slug']}')")
# Step 3: Obtain token from Authentik
print("Step 3: Obtaining token from Authentik for test user ...")
print(" Using APP_PASSWORD for password grant (authentik requirement)")
status, data = http_post(
creds["ak_token_endpoint"],
data={
"grant_type": "password",
"client_id": creds["ak_client_id"],
"client_secret": creds["ak_client_secret"],
"username": creds["ak_test_user"],
"password": creds["ak_test_app_password"],
"scope": "openid email profile",
},
content_type="application/x-www-form-urlencoded",
)
access_token = (data or {}).get("access_token", "")
if not access_token:
error = (data or {}).get("error_description", (data or {}).get("error", "unknown"))
print(f" FAIL: Token request failed: {error}")
sys.exit(1)
print(f" PASS: Got access token ({len(access_token)} chars)")
# Step 4: Authenticate against Docs API
print("Step 4: Calling Docs API with Authentik token ...")
status, body = http_get(
f"{docs_url}/api/v1.0/users/me/",
headers={"Authorization": f"Bearer {access_token}"},
timeout=30,
)
if status != 200:
print(f" FAIL: Docs API returned HTTP {status} (expected 200)")
sys.exit(1)
user_email = (body or {}).get("email", "")
expected_email = creds["ak_test_email"]
if user_email == expected_email:
print(f" PASS: Docs returned authenticated user '{user_email}'")
else:
print(f" FAIL: Expected email '{expected_email}', got '{user_email}'")
sys.exit(1)
print()
print("PASS: Authentik OIDC integration test passed")
print(" Authentik issued a valid token, Docs (ld2) accepted it and returned the correct user.")
if __name__ == '__main__':
main()
+15
View File
@@ -0,0 +1,15 @@
# Authentik Upstream
## Main Project
- **Repository:** https://github.com/goauthentik/authentik
- **Releases:** https://github.com/goauthentik/authentik/releases
- **Website:** https://goauthentik.io
- **Docs:** https://docs.goauthentik.io
## Images
| Service | Image | Release Notes |
|---------|-------|---------------|
| app, worker | `ghcr.io/goauthentik/server` | https://github.com/goauthentik/authentik/releases |
| db | `postgres` | https://www.postgresql.org/docs/release/ |
+1
View File
@@ -0,0 +1 @@
name = "bluesky-pds"
+32
View File
@@ -0,0 +1,32 @@
# Bluesky PDS — First-Time Setup
## Prerequisites
- DNS: `bluesky-pds.<domain_suffix>` must resolve to the server
- DNS: Wildcard `*.bluesky-pds.<domain_suffix>` must resolve to the server (for subdomain handles)
## Steps
1. **Create the app:**
```bash
abra app new bluesky-pds --server <SERVER> --domain bluesky-pds.<DOMAIN_SUFFIX> --no-input
```
2. **Generate secrets:**
```bash
abra app secret generate bluesky-pds.<DOMAIN_SUFFIX> --all -m --no-input
```
Save output to `recipe-info/testsecrets/bluesky-pds.<DOMAIN_SUFFIX>`.
3. **Deploy:**
```bash
abra app deploy bluesky-pds.<DOMAIN_SUFFIX> --chaos --force --no-input
```
4. **Create test account:**
```bash
python3 recipe-info/bluesky-pds/tests/create_test_account.py
```
This saves credentials to `recipe-info/bluesky-pds/test-account-<domain>.json`.
5. **Verify:** curl `https://bluesky-pds.<DOMAIN_SUFFIX>/xrpc/_health` returns HTTP 200 with JSON.
+23
View File
@@ -0,0 +1,23 @@
# Bluesky PDS Test Plan
**Target URL:** https://bluesky-pds.<DOMAIN_SUFFIX>
## Automated Checks
- `tests/health_check.py` — Curl the XRPC health endpoint and check for HTTP 200
- `tests/goat_account.py` — Use the goat CLI inside the container to test PDS describe, account creation, listing, and deletion
- `tests/subdomain_tls.py` — Create a temporary account and verify Caddy obtains a valid Let's Encrypt TLS cert for the subdomain handle
## Test Account Setup
Run `tests/create_test_account.py` to create a persistent test account on the PDS. The script saves credentials to `test-account-<domain>.json` (instance-specific). If the account already exists, the script exits without changes.
## Manual Verification
- Open https://bluesky-pds.<DOMAIN_SUFFIX>/xrpc/_health in a browser and confirm it returns a JSON response with `{"version":"..."}`.
- **Log in with a Bluesky client** to confirm the PDS is fully functional:
1. Open https://bsky.app in a browser (or use any AT Protocol client).
2. On the login screen, tap "Hosting provider" and enter `bluesky-pds.<DOMAIN_SUFFIX>` as the custom PDS.
3. Log in with the credentials from `test-account-<domain>.json` (email + password).
4. Confirm you can view the feed and create a test post.
5. Delete the test post afterwards to keep the account clean.
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Create a test admin account on Bluesky PDS."""
import argparse
import json
import os
import re
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import run, resolve_domain
def run_in_container(domain, cmd):
"""Run a command inside the app container via abra."""
result = run(
f'''script -qefc "abra app run {domain} app --no-tty -- sh -c '{cmd}' 2>&1" /dev/null''',
check=False, timeout=120,
)
return result.stdout
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
domain = args.domain or resolve_domain('bluesky-pds')
test_handle = f"testadmin.{domain}"
test_email = f"testadmin@{domain}"
# Generate a random password
import secrets
test_password = f"manual-test-{secrets.token_hex(8)}"
pds_host = "http://localhost:3000"
admin_pw_flag = "--admin-password \\$(cat /run/secrets/pds_admin_password)"
pds_flag = f"--pds-host {pds_host}"
creds_file = os.path.join(os.path.dirname(__file__), '..', f'test-account-{domain}.json')
# Check if the account already exists
print("Checking for existing test account ...")
account_list = run_in_container(domain, f"goat pds admin account list {admin_pw_flag} {pds_flag} 2>&1")
for did in re.findall(r'did:plc:\w+', account_list):
info = run_in_container(domain, f"goat pds admin account info {did} {admin_pw_flag} {pds_flag} 2>&1")
if f'"handle": "{test_handle}"' in info:
print(f"Test account already exists ({did}, handle: {test_handle}).")
print("To recreate, delete it first:")
print(f" abra app run {domain} app -- goat pds admin account delete {did} --admin-password \\$(cat /run/secrets/pds_admin_password) --pds-host {pds_host}")
return
# Create the account
print(f"Creating test account ({test_handle}) ...")
create_output = run_in_container(
domain,
f"goat pds admin account create {admin_pw_flag} {pds_flag} --handle {test_handle} --email {test_email} --password '{test_password}' 2>&1",
)
test_did_match = re.search(r'did:plc:\w+', create_output)
if not test_did_match:
print("FAIL: Could not create test account")
print(f"Output: {create_output}")
sys.exit(1)
test_did = test_did_match.group()
# Save credentials
creds = {
"handle": test_handle,
"email": test_email,
"password": test_password,
"did": test_did,
"pds": f"https://{domain}",
}
with open(creds_file, 'w') as f:
json.dump(creds, f, indent=2)
f.write('\n')
print(f"Account created and credentials saved to {creds_file}")
print(f" Handle: {test_handle}")
print(f" Email: {test_email}")
print(f" DID: {test_did}")
print(f" Password: {test_password}")
if __name__ == '__main__':
main()
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Bluesky PDS goat account test — create, list, delete accounts via goat CLI."""
import argparse
import os
import re
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import run, resolve_domain
def run_in_container(domain, cmd):
"""Run a command inside the app container via abra."""
result = run(
f'''script -qefc "abra app run {domain} app --no-tty -- sh -c '{cmd}' 2>&1" /dev/null''',
check=False, timeout=120,
)
return result.stdout
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
domain = args.domain or resolve_domain('bluesky-pds')
test_handle = f"smoketest.{domain}"
test_email = f"smoketest@{domain}"
test_password = "testpass-goat-account-check"
pds_host = "http://localhost:3000"
admin_pw_flag = "--admin-password \\$(cat /run/secrets/pds_admin_password)"
pds_flag = f"--pds-host {pds_host}"
# Step 1: PDS describe
print("Step 1: Checking PDS describe ...")
describe_output = run_in_container(domain, f"goat pds describe {pds_host} 2>&1")
if f"did:web:{domain}" in describe_output:
print(f"PASS: PDS describe returned expected DID (did:web:{domain})")
else:
print("FAIL: PDS describe did not contain expected DID")
print(f"Output: {describe_output}")
sys.exit(1)
# Step 2: Cleanup previous test account if it exists
print("Step 2: Cleaning up previous test account if present ...")
account_list = run_in_container(domain, f"goat pds admin account list {admin_pw_flag} {pds_flag} 2>&1")
for did in re.findall(r'did:plc:\w+', account_list):
info = run_in_container(domain, f"goat pds admin account info {did} {admin_pw_flag} {pds_flag} 2>&1")
if f'"handle": "{test_handle}"' in info:
print(f" Found existing test account ({did}), deleting ...")
run_in_container(domain, f"goat pds admin account delete {did} {admin_pw_flag} {pds_flag} 2>&1")
print(" Deleted.")
# Step 3: Create a test account
print(f"Step 3: Creating test account ({test_handle}) ...")
create_output = run_in_container(
domain,
f"goat pds admin account create {admin_pw_flag} {pds_flag} --handle {test_handle} --email {test_email} --password {test_password} 2>&1",
)
test_did_match = re.search(r'did:plc:\w+', create_output)
if not test_did_match:
print("FAIL: Could not create test account")
print(f"Output: {create_output}")
sys.exit(1)
test_did = test_did_match.group()
print(f" Account created: {create_output.strip()}")
print(f" DID: {test_did}")
# Step 4: List accounts and verify the test DID is present
print("Step 4: Verifying test account appears in account list ...")
account_list = run_in_container(domain, f"goat pds admin account list {admin_pw_flag} {pds_flag} 2>&1")
if test_did in account_list:
print("PASS: Test account found in account list")
else:
print("FAIL: Test account not found in account list")
sys.exit(1)
# Step 5: Delete the test account
print("Step 5: Deleting test account ...")
delete_output = run_in_container(domain, f"goat pds admin account delete {test_did} {admin_pw_flag} {pds_flag} 2>&1")
print(f" Deleted: {delete_output.strip()}")
# Step 6: Verify deletion
print("Step 6: Verifying test account is gone ...")
account_list = run_in_container(domain, f"goat pds admin account list {admin_pw_flag} {pds_flag} 2>&1")
if test_did in account_list:
print("FAIL: Test account still present after deletion")
sys.exit(1)
print("PASS: Test account successfully deleted")
print("PASS: All goat account tests passed")
if __name__ == '__main__':
main()
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Health check for Bluesky PDS."""
import argparse
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import http_get, resolve_domain
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
domain = args.domain or resolve_domain('bluesky-pds')
url = f"https://{domain}/xrpc/_health"
print(f"Checking Bluesky PDS at {url} ...")
status, _ = http_get(url)
if status == 200:
print(f"PASS: Bluesky PDS returned HTTP {status}")
else:
print(f"FAIL: Bluesky PDS returned HTTP {status} (expected 200)")
sys.exit(1)
if __name__ == '__main__':
main()
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""Test: verify Caddy obtains a valid TLS certificate for a subdomain handle."""
import argparse
import os
import re
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import run, resolve_domain
def run_in_container(domain, cmd):
"""Run a command inside the app container via abra."""
result = run(
f'''script -qefc "abra app run {domain} app --no-tty -- sh -c '{cmd}' 2>&1" /dev/null''',
check=False, timeout=120,
)
return result.stdout
def delete_account(domain, did, admin_pw_flag, pds_flag):
print(f" Deleting {did} ...")
run_in_container(domain, f"goat pds admin account delete {did} {admin_pw_flag} {pds_flag} 2>&1")
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
domain = args.domain or resolve_domain('bluesky-pds')
suffix = str(int(time.time()))
test_handle = f"tlstest{suffix}.{domain}"
test_email = f"tlstest{suffix}@{domain}"
test_password = "testpass-subdomain-tls-check"
pds_host = "http://localhost:3000"
admin_pw_flag = "--admin-password \\$(cat /run/secrets/pds_admin_password)"
pds_flag = f"--pds-host {pds_host}"
max_attempts = 12
retry_interval = 10
# Step 1: Clean up old tlstest accounts
print("Step 1: Cleaning up old tlstest accounts if present ...")
# List all accounts, then check each one for tlstest handles
account_list = run_in_container(
domain,
f"goat pds admin account list {admin_pw_flag} {pds_flag} 2>&1",
)
deleted = []
for did in re.findall(r'did:plc:\w+', account_list):
info = run_in_container(
domain,
f"goat pds admin account info {did} {admin_pw_flag} {pds_flag} 2>&1",
)
if re.search(r'"handle": "tlstest\d+\.', info):
print(f" Found old tlstest account ({did}), deleting ...")
run_in_container(
domain,
f"goat pds admin account delete {did} {admin_pw_flag} {pds_flag} 2>&1",
)
deleted.append(did)
if not deleted:
print(" No old accounts found.")
# Step 2: Create the test account
print(f"Step 2: Creating test account ({test_handle}) ...")
create_output = run_in_container(
domain,
f"goat pds admin account create {admin_pw_flag} {pds_flag} --handle {test_handle} --email {test_email} --password {test_password} 2>&1",
)
test_did_match = re.search(r'did:plc:\w+', create_output)
if not test_did_match:
print("FAIL: Could not create test account")
print(f"Output: {create_output}")
sys.exit(1)
test_did = test_did_match.group()
print(f" Created account {test_did}")
# Step 3: Wait for Caddy to obtain a valid TLS cert
print(f"Step 3: Waiting for valid TLS cert on https://{test_handle} ...")
cert_ok = False
for attempt in range(1, max_attempts + 1):
print(f" Attempt {attempt}/{max_attempts} ...")
result = run(
f'curl -sf --max-time 15 "https://{test_handle}/xrpc/_health" -o /dev/null',
check=False, timeout=20,
)
if result.returncode == 0:
cert_ok = True
break
if attempt < max_attempts:
time.sleep(retry_interval)
if not cert_ok:
print(f"FAIL: Caddy did not obtain a valid TLS cert for {test_handle} after {max_attempts * retry_interval}s")
delete_account(domain, test_did, admin_pw_flag, pds_flag)
sys.exit(1)
print(f"PASS: Valid TLS cert obtained for {test_handle}")
# Step 4: Verify cert details
print("Step 4: Checking certificate subject ...")
result = run(
f'echo | openssl s_client -servername {test_handle} -connect {test_handle}:443 2>/dev/null | openssl x509 -noout -subject 2>/dev/null',
check=False, timeout=15,
)
cert_subject = result.stdout.strip()
print(f" {cert_subject}")
if test_handle in cert_subject:
print(f"PASS: Certificate subject matches {test_handle}")
else:
# Check SANs
result = run(
f'echo | openssl s_client -servername {test_handle} -connect {test_handle}:443 2>/dev/null | openssl x509 -noout -text 2>/dev/null | grep -A1 "Subject Alternative Name"',
check=False, timeout=15,
)
cert_san = result.stdout.strip()
print(f" SANs: {cert_san}")
if test_handle in cert_san:
print(f"PASS: Certificate SAN matches {test_handle}")
else:
print(f"FAIL: Certificate does not match {test_handle}")
delete_account(domain, test_did, admin_pw_flag, pds_flag)
sys.exit(1)
# Step 5: Cleanup
delete_account(domain, test_did, admin_pw_flag, pds_flag)
print("PASS: Subdomain TLS test passed")
if __name__ == '__main__':
main()
+13
View File
@@ -0,0 +1,13 @@
# Bluesky PDS Upstream
## Main Project
- **Repository:** https://github.com/bluesky-social/pds
- **Releases:** https://github.com/bluesky-social/pds/releases
- **AT Protocol Docs:** https://atproto.com
## Images
| Service | Image | Release Notes |
|---------|-------|---------------|
| app | `ghcr.io/bluesky-social/pds` | https://github.com/bluesky-social/pds/releases |
+8
View File
@@ -0,0 +1,8 @@
name = "cryptpad"
[dependencies]
requires = ["authentik"]
[sso]
provider = "authentik"
setup_script = "setup/sso_integration.py"
+52
View File
@@ -0,0 +1,52 @@
# CryptPad — First-Time Setup
## Prerequisites
- DNS: `cryptpad.<domain_suffix>` must resolve to the server
- DNS: `sandbox.cryptpad.<domain_suffix>` must resolve to the server (sandbox iframe domain)
- **Authentik** must be deployed and running (dependency)
## Steps
1. **Create the app:**
```bash
abra app new cryptpad --server <SERVER> --domain cryptpad.<DOMAIN_SUFFIX> --no-input
```
2. **Generate secrets:**
```bash
abra app secret generate cryptpad.<DOMAIN_SUFFIX> --all -m --no-input
```
Save output to `recipe-info/testsecrets/cryptpad.<DOMAIN_SUFFIX>`.
3. **Configure SSO compose file:**
Edit the env file at `~/.abra/servers/<SERVER>/cryptpad.<DOMAIN_SUFFIX>.env` and set:
```
COMPOSE_FILE=compose.yml:compose.sso.yml
```
This enables the SSO overlay that adds OIDC support.
4. **Deploy:**
```bash
abra app deploy cryptpad.<DOMAIN_SUFFIX> --chaos --force --no-input
```
5. **Authentik SSO integration:**
```bash
python3 recipe-info/cryptpad/setup_authentik_integration.py
```
This creates an OAuth2 provider and application in Authentik, creates a test user, inserts the client secret, and updates CryptPad's env file with SSO settings.
6. **Redeploy with SSO settings:**
```bash
abra app deploy cryptpad.<DOMAIN_SUFFIX> --chaos --force --no-input
```
Wait ~2 minutes for the SSO plugin to install and CryptPad to rebuild.
7. **Verify:** curl `https://cryptpad.<DOMAIN_SUFFIX>` returns HTTP 200.
## Notes
- Credentials are saved to `recipe-info/cryptpad/authentik-test-credentials.<DOMAIN_SUFFIX>.toml`.
- OIDC test user: `testuser` / `testpass123`.
- The SSO plugin takes a couple of minutes to install on first deploy.
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""Setup Authentik OIDC integration for CryptPad SSO.
Creates an OAuth2 provider, application, and test user in Authentik,
then updates the CryptPad env file with SSO settings and inserts
the client secret as a Docker secret.
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from lib.abra import app_secret_insert
from lib.authentik import AuthentikAdmin
from lib.env import apply_env_overrides, get_abra_env_path, read_env_file
from lib.models import load_default_instance
from lib.secrets import load_secrets
# Configuration
PROVIDER_NAME = "cryptpad"
APP_SLUG = "cryptpad"
CLIENT_ID = "cryptpad"
TEST_USER = "testuser"
TEST_PASS = "testpass123"
TEST_EMAIL = f"{TEST_USER}@test.example.com"
def main():
inst = load_default_instance()
cpad_domain = inst.default_domain("cryptpad")
ak_domain = inst.default_domain("authentik")
ak_url = f"https://{ak_domain}"
# Get Authentik admin token from synced secrets
ak_secrets = load_secrets(ak_domain)
ak_token = ak_secrets["admin_token"]
ak = AuthentikAdmin(ak_url, ak_token)
# Resolve Authentik UUIDs
uuids = ak.resolve_uuids()
# Step 1: Create OAuth2 provider
provider_pk, client_secret = ak.ensure_provider(
PROVIDER_NAME, CLIENT_ID,
redirect_uris=[{
"matching_mode": "strict",
"url": f"https://{cpad_domain}/ssoauth",
}],
uuids=uuids,
)
# Step 2: Create application
ak.ensure_application("CryptPad", APP_SLUG, provider_pk,
f"https://{cpad_domain}")
# Step 3: Ensure test user with APP_PASSWORD
user_pk = ak.ensure_user(TEST_USER, TEST_EMAIL, TEST_PASS)
app_password = ak.ensure_app_password(user_pk)
# Step 4: Update CryptPad env with SSO settings
print("=== Update CryptPad SSO settings in env file ===", flush=True)
env_path = get_abra_env_path(inst.server, cpad_domain)
if not env_path.exists():
print(f" WARNING: CryptPad env file not found at {env_path}", flush=True)
print(f" Create the app first: abra app new cryptpad --server {inst.server} --domain {cpad_domain} --no-input", flush=True)
print(" Skipping env update", flush=True)
else:
oidc_url = f"{ak_url}/application/o/{APP_SLUG}"
overrides = {
"SSO_ENABLED": "true",
"SSO_PROVIDER_NAME": "Authentik",
"SSO_OIDC_URL": oidc_url,
"SSO_CLIENT_ID": CLIENT_ID,
}
# Remove old SSO_CLIENT_SECRET env var if present (now a Docker secret)
env_data = read_env_file(env_path)
if "SSO_CLIENT_SECRET" in env_data:
print(" Removing old SSO_CLIENT_SECRET env var (now a Docker secret)", flush=True)
# Read file lines and filter out SSO_CLIENT_SECRET
with open(env_path) as f:
lines = f.readlines()
with open(env_path, "w") as f:
for line in lines:
if not line.strip().startswith("SSO_CLIENT_SECRET="):
f.write(line)
apply_env_overrides(env_path, overrides)
# Ensure SSO_CLIENT_SECRET_VERSION exists
env_data = read_env_file(env_path)
if "SSO_CLIENT_SECRET_VERSION" not in env_data:
with open(env_path, "a") as f:
f.write("SSO_CLIENT_SECRET_VERSION=v1\n")
# Insert client secret as Docker secret if not already present
try:
existing = load_secrets(cpad_domain).get("sso_client_s")
except Exception:
existing = None
if existing:
print(" Secret sso_client_s already exists in local testsecrets, skipping insert", flush=True)
else:
env_data = read_env_file(env_path)
current_version = env_data.get("SSO_CLIENT_SECRET_VERSION", "v1")
print(f" Inserting sso_client_s {current_version} ...", flush=True)
app_secret_insert(cpad_domain, "sso_client_s", current_version,
client_secret)
print(f" Inserted sso_client_s {current_version}", flush=True)
# Step 5: Write credentials file
script_dir = os.path.dirname(os.path.abspath(__file__))
creds_file = os.path.join(script_dir, f"authentik-test-credentials.{inst.domain_suffix}.toml")
print(f"=== Write credentials to {creds_file} ===", flush=True)
with open(creds_file, "w") as f:
f.write(f'# Authentik OIDC credentials for CryptPad SSO test instance\n')
f.write(f'#\n')
f.write(f'# Authentik instance: {ak_domain}\n')
f.write(f'# Application slug: {APP_SLUG}\n')
f.write(f'# Created by: setup_authentik_integration.py\n')
f.write(f'\n')
f.write(f'# Authentik admin\n')
f.write(f'ak_token = "{ak_token}"\n')
f.write(f'\n')
f.write(f'# OIDC provider\n')
f.write(f'ak_app_slug = "{APP_SLUG}"\n')
f.write(f'ak_client_id = "{CLIENT_ID}"\n')
f.write(f'ak_client_secret = "{client_secret}"\n')
f.write(f'\n')
f.write(f'# Authentik OIDC endpoints\n')
f.write(f'ak_token_endpoint = "https://{ak_domain}/application/o/token/"\n')
f.write(f'ak_userinfo_endpoint = "https://{ak_domain}/application/o/userinfo/"\n')
f.write(f'ak_discovery_endpoint = "https://{ak_domain}/application/o/{APP_SLUG}/.well-known/openid-configuration"\n')
f.write(f'\n')
f.write(f'# Test user (password for browser login, app_password for password grant)\n')
f.write(f'ak_test_user = "{TEST_USER}"\n')
f.write(f'ak_test_pass = "{TEST_PASS}"\n')
f.write(f'ak_test_app_password = "{app_password}"\n')
f.write(f'ak_test_email = "{TEST_EMAIL}"\n')
f.write(f'\n')
f.write(f'# CryptPad instance\n')
f.write(f'cpad_domain = "{cpad_domain}"\n')
print(f" Written to {creds_file}", flush=True)
print("", flush=True)
print("=== Authentik OIDC integration setup for CryptPad complete ===", flush=True)
print("", flush=True)
print("Next steps:", flush=True)
print(f" 1. Redeploy CryptPad: abra app deploy {cpad_domain} --chaos --force --no-input", flush=True)
print(f" 2. Wait ~2min for SSO plugin to install and CryptPad to rebuild", flush=True)
print(f" 3. Run OIDC test: python3 recipe-info/cryptpad/tests/oidc_login.py", flush=True)
if __name__ == "__main__":
main()
+91
View File
@@ -0,0 +1,91 @@
# CryptPad Tests
## Target
- **URL:** https://cryptpad.<DOMAIN_SUFFIX>
- **Sandbox URL:** https://sandbox.cryptpad.<DOMAIN_SUFFIX>
## Dependencies
- **Authentik** (`authentik.<DOMAIN_SUFFIX>`) — required for SSO/OIDC testing
## Test Setup
Before running all tests, the following must be in place:
### 1. Deploy authentik
```bash
abra app deploy authentik.<DOMAIN_SUFFIX> --chaos --force --no-input
```
### 2. Deploy CryptPad
```bash
abra app deploy cryptpad.<DOMAIN_SUFFIX> --chaos --force --no-input
```
### 3. Run the Authentik integration setup
```bash
python3 recipe-info/cryptpad/setup_authentik_integration.py
```
This configures authentik as the OIDC provider for CryptPad:
1. Creates an OAuth2 provider (`cryptpad`) via the authentik REST API
2. Creates an Application linked to the provider
3. Creates a test user (`testuser` / `testpass123`) with an APP_PASSWORD token
4. Writes OIDC env vars to the CryptPad instance env file (enables `compose.sso.yml`)
5. Writes credentials to `authentik-test-credentials.<DOMAIN_SUFFIX>.toml`
**Important:** The APP_PASSWORD token becomes invalid if authentik is redeployed. If the `oidc_login.py` test fails with "invalid, expired, revoked" token errors, re-run this setup script and redeploy CryptPad.
### 4. Redeploy CryptPad with SSO config
```bash
abra app deploy cryptpad.<DOMAIN_SUFFIX> --chaos --force --no-input
```
Wait ~2 minutes for the SSO plugin to install and CryptPad to rebuild.
## Test Instance SSO Configuration
The test instance has SSO enabled via `compose.sso.yml`. The instance env file includes:
```
COMPOSE_FILE="compose.yml:compose.sso.yml"
```
Note: SSO is **not** enabled by default in `.env.sample`. The test instance has it enabled explicitly to test the SSO integration. If you need to reset the test instance without SSO, change `COMPOSE_FILE` to just `"compose.yml"` and redeploy.
## Automated Tests
- `tests/health_check.py` — Confirms the instance is reachable and returns HTTP 200.
- `tests/oidc_login.py` — Tests SSO/OIDC integration with Authentik. Checks OIDC discovery, APP_PASSWORD token grant, and `/ssoauth` endpoint.
### Credentials
| Key | Description |
|-----|-------------|
| `ak_client_id` / `ak_client_secret` | OIDC client ID and secret |
| `ak_test_user` / `ak_test_pass` | Test user credentials (password for browser login) |
| `ak_test_app_password` | APP_PASSWORD token for password grant (authentik requires this instead of regular passwords) |
| `ak_test_email` | Test user email |
| `ak_discovery_endpoint` | Authentik OIDC discovery URL |
Stored in `authentik-test-credentials.<DOMAIN_SUFFIX>.toml`.
## Manual Verification
1. Open https://cryptpad.<DOMAIN_SUFFIX> in a browser.
2. Confirm the CryptPad landing page loads without errors (not a white screen).
3. Verify the sandbox domain https://sandbox.cryptpad.<DOMAIN_SUFFIX> is reachable.
4. Register a user account and confirm it succeeds.
5. Create a pad and verify real-time editing works.
### SSO Manual Verification
6. Confirm the CryptPad login page shows an SSO login button (labelled "Authentik").
7. Click the SSO login button — it should redirect to Authentik.
8. Log in with `testuser` / `testpass123` on Authentik.
9. After authentication, you should be redirected back to CryptPad and logged in.
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Health check for CryptPad."""
import argparse
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import http_get, resolve_domain
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
domain = args.domain or resolve_domain('cryptpad')
url = f"https://{domain}"
print(f"Checking CryptPad at {url} ...")
status, _ = http_get(url)
if status == 200:
print(f"PASS: CryptPad returned HTTP {status}")
else:
print(f"FAIL: CryptPad returned HTTP {status} (expected 200)")
sys.exit(1)
if __name__ == '__main__':
main()
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""CryptPad SSO/OIDC integration test."""
import argparse
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import (
http_get, http_post, load_toml_credentials, resolve_domain,
)
def main():
if os.environ.get('SKIP_INTEGRATION') == '1':
print("SKIP: OIDC integration test (SKIP_INTEGRATION=1)")
return
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
domain = args.domain or resolve_domain('cryptpad')
url = f"https://{domain}"
recipe_dir = os.path.join(os.path.dirname(__file__), '..')
creds = load_toml_credentials(recipe_dir, 'authentik')
if creds is None:
print("FAIL: Credentials file not found: authentik-test-credentials.<domain_suffix>.toml")
print("Run setup_authentik_integration.py first.")
sys.exit(1)
print("=== CryptPad SSO/OIDC Integration Test ===")
print()
# Step 1: Check CryptPad is deployed
print("Step 1: Checking CryptPad is deployed ...")
status, _ = http_get(url)
if status == 0:
print(f" FAIL: CryptPad is not reachable at {url}")
sys.exit(1)
elif status >= 500:
print(f" FAIL: CryptPad returned HTTP {status}")
sys.exit(1)
print(f" OK: CryptPad is reachable (HTTP {status})")
# Step 2: Verify Authentik OIDC discovery
print("Step 2: Checking Authentik OIDC discovery ...")
discovery_url = creds["ak_discovery_endpoint"]
status, _ = http_get(discovery_url)
if status != 200:
print(f" FAIL: OIDC discovery returned HTTP {status}")
print(f" URL: {discovery_url}")
sys.exit(1)
print(f" PASS: OIDC discovery endpoint OK (app '{creds['ak_app_slug']}')")
# Step 3: Obtain token from Authentik
print("Step 3: Obtaining token from Authentik for test user ...")
print(" Using APP_PASSWORD for password grant (authentik requirement)")
status, data = http_post(
creds["ak_token_endpoint"],
data={
"grant_type": "password",
"client_id": creds["ak_client_id"],
"client_secret": creds["ak_client_secret"],
"username": creds["ak_test_user"],
"password": creds["ak_test_app_password"],
"scope": "openid email profile",
},
content_type="application/x-www-form-urlencoded",
)
access_token = (data or {}).get("access_token", "")
if not access_token:
error = (data or {}).get("error_description", (data or {}).get("error", "unknown"))
print(f" FAIL: Token request failed: {error}")
sys.exit(1)
print(f" PASS: Got access token ({len(access_token)} chars)")
# Step 4: Verify CryptPad's /ssoauth endpoint (SSO plugin loaded)
print("Step 4: Checking CryptPad /ssoauth endpoint ...")
status, _ = http_get(f"{url}/ssoauth")
if status == 404:
print(" FAIL: /ssoauth returned 404 — SSO plugin may not be loaded")
sys.exit(1)
print(f" PASS: /ssoauth endpoint exists (HTTP {status})")
print()
print("PASS: CryptPad SSO/OIDC integration test passed")
print(" Authentik OIDC discovery OK, token grant OK, /ssoauth endpoint exists.")
if __name__ == '__main__':
main()
+14
View File
@@ -0,0 +1,14 @@
# CryptPad Upstream
## Main Project
- **Repository:** https://github.com/cryptpad/cryptpad
- **Releases:** https://github.com/cryptpad/cryptpad/releases
- **Website:** https://cryptpad.org
## Images
| Service | Image | Release Notes |
|---------|-------|---------------|
| app | `cryptpad/cryptpad` | https://github.com/cryptpad/cryptpad/releases |
| web | `nginx` | https://nginx.org/en/CHANGES |
+1
View File
@@ -0,0 +1 @@
name = "custom-html"
+43
View File
@@ -0,0 +1,43 @@
# Custom HTML — First-Time Setup
## Prerequisites
- DNS: `custom-html.<DOMAIN_SUFFIX>` must resolve to the server
## Steps
1. **Create the app:**
```bash
abra app new custom-html --server <SERVER> --domain custom-html.<DOMAIN_SUFFIX> --no-input
```
2. **Deploy:**
```bash
abra app deploy custom-html.<DOMAIN_SUFFIX> --chaos --force --no-input
```
No secrets required.
3. **Copy your HTML content:**
```bash
abra app cp custom-html.<DOMAIN_SUFFIX> index.html app:/usr/share/nginx/html
```
4. **Verify:** curl `https://custom-html.<DOMAIN_SUFFIX>` returns HTTP 200.
## Optional: SSH/SFTP uploads
To allow SFTP file management, edit the app env file and uncomment:
```
COMPOSE_FILE="$COMPOSE_FILE:compose.sftp.yml"
PUBLIC_KEY="ssh-ed25519 AAAA... user@host"
```
Then redeploy. Connect via `ssh -p 2220 sftp@custom-html.<DOMAIN_SUFFIX>`.
## Optional: Git-pull from a repo
Uncomment in the env file:
```
COMPOSE_FILE="$COMPOSE_FILE:compose.git-pull.yml"
GIT_REPO_URL="https://..."
CRON_SCHEDULE="*/5 * * * *"
```
+14
View File
@@ -0,0 +1,14 @@
# Custom HTML Tests
## Target
- **URL:** https://custom-html.<DOMAIN_SUFFIX>
## Automated Checks
- `health_check.py` — Confirms the instance is reachable and returns HTTP 200.
## Manual Verification
1. Open https://custom-html.<DOMAIN_SUFFIX> in a browser.
2. Confirm the nginx default page (or custom HTML content) loads without errors.
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Health check for custom-html."""
import argparse
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import http_get, resolve_domain
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
domain = args.domain or resolve_domain('custom-html')
url = f"https://{domain}"
print(f"Checking custom-html at {url} ...")
status, _ = http_get(url)
if status == 200:
print(f"PASS: custom-html returned HTTP {status}")
else:
print(f"FAIL: custom-html returned HTTP {status} (expected 200)")
sys.exit(1)
if __name__ == '__main__':
main()
+14
View File
@@ -0,0 +1,14 @@
# Custom HTML Upstream
## Main Project
- **Recipe:** https://git.coopcloud.tech/coop-cloud/custom-html
- **nginx:** https://nginx.org
## Images
| Service | Image | Release Notes |
|---------|-------|---------------|
| app | `nginx` | https://nginx.org/en/CHANGES |
| git | `alpine/git` | https://github.com/alpine-docker/git/releases |
| ssh | `linuxserver/openssh-server` | https://github.com/linuxserver/docker-openssh-server/releases |
+1
View File
@@ -0,0 +1 @@
name = "gitea"
+35
View File
@@ -0,0 +1,35 @@
# Gitea — First-Time Setup
## Prerequisites
- DNS: `gitea.<DOMAIN_SUFFIX>` must resolve to the server
## Steps
1. **Create the app:**
```bash
abra app new gitea --server <SERVER> --domain gitea.<DOMAIN_SUFFIX> --no-input
```
2. **Generate secrets:**
```bash
abra app secret generate gitea.<DOMAIN_SUFFIX> --all -m --no-input
```
Save output to `recipe-info/testsecrets/gitea.<DOMAIN_SUFFIX>`.
3. **Deploy:**
```bash
abra app deploy gitea.<DOMAIN_SUFFIX> --chaos --force --no-input
```
4. **Create the first admin user:**
```bash
abra app run gitea.<DOMAIN_SUFFIX> app gitea -c /etc/gitea/app.ini admin user create \
--username admin --admin --random-password --email admin@example.com
```
5. **Verify:** curl `https://gitea.<DOMAIN_SUFFIX>` returns HTTP 200.
## SSH Access (optional)
To expose Gitea's SSH service, configure Traefik to enable the `gitea-ssh` entrypoint and set `GITEA_SSH_PORT=2222` in the app env file.
+19
View File
@@ -0,0 +1,19 @@
# Gitea Tests
## Target
- **URL:** https://gitea.<DOMAIN_SUFFIX>
## Automated Checks
Run the scripts in `tests/` to perform automated testing:
- `health_check.py` — Confirms the instance is reachable and returns HTTP 200.
- `git_push.py` — Creates a repo via the API, clones it, pushes a commit, verifies the commit landed via the API, then deletes the repo. Requires admin credentials in `recipe-info/testsecrets/<domain>`.
## Manual Verification
1. Open https://gitea.<DOMAIN_SUFFIX> in a browser.
2. Confirm the Gitea landing page loads without errors.
3. Log in as the admin user created via `admin user create`.
4. Create a test repository and push a commit to verify git operations work.
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""Test creating a repo and pushing a commit via HTTPS on Gitea."""
import argparse
import base64
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import resolve_domain
WORKSPACE = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
def load_testsecrets(domain):
path = os.path.join(WORKSPACE, 'recipe-info', 'testsecrets', domain)
if not os.path.exists(path):
return {}
secrets = {}
with open(path) as f:
for line in f:
line = line.strip()
if '=' in line:
k, v = line.split('=', 1)
secrets[k.strip()] = v.strip()
return secrets
def api(method, url, data=None, token=None, username=None, password=None):
body = json.dumps(data).encode() if data is not None else None
req = urllib.request.Request(url, data=body, method=method)
req.add_header('Content-Type', 'application/json')
if token:
req.add_header('Authorization', f'token {token}')
elif username and password:
creds = base64.b64encode(f'{username}:{password}'.encode()).decode()
req.add_header('Authorization', f'Basic {creds}')
try:
with urllib.request.urlopen(req, timeout=15) as resp:
raw = resp.read()
return resp.getcode(), json.loads(raw) if raw else {}
except urllib.error.HTTPError as e:
raw = e.read().decode(errors='replace')
try:
return e.code, json.loads(raw)
except Exception:
return e.code, {}
def run_git(args, cwd, env=None):
result = subprocess.run(
['git'] + args, cwd=cwd, capture_output=True, text=True,
env={**os.environ, **(env or {})},
timeout=30,
)
if result.returncode != 0:
raise RuntimeError(f"git {' '.join(args)} failed:\n{result.stderr}")
return result.stdout.strip()
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
parser.add_argument('--username', default=os.environ.get('GITEA_ADMIN_USER'))
parser.add_argument('--password', default=os.environ.get('GITEA_ADMIN_PASS'))
args = parser.parse_args()
domain = args.domain or resolve_domain('gitea')
base_url = f'https://{domain}'
# Load credentials from testsecrets if not provided via args/env
username = args.username
password = args.password
if not username or not password:
secrets = load_testsecrets(domain)
username = username or secrets.get('admin_username')
password = password or secrets.get('admin_password')
if not username or not password:
print(f'FAIL: No credentials found. Pass --username/--password or add '
f'admin_username/admin_password to recipe-info/testsecrets/{domain}')
sys.exit(1)
repo_name = f'test-push-{int(time.time())}'
print(f'Testing git push on {base_url} as {username} ...')
# 1. Create a test repo via API
print(f' Creating repo {repo_name} ...')
status, body = api('POST', f'{base_url}/api/v1/user/repos',
data={'name': repo_name, 'private': False,
'auto_init': False},
username=username, password=password)
if status != 201:
print(f'FAIL: Could not create repo (HTTP {status}): {body}')
sys.exit(1)
clone_url = body.get('clone_url') or f'{base_url}/{username}/{repo_name}.git'
print(f' Repo created: {clone_url}')
tmpdir = tempfile.mkdtemp(prefix='gitea-test-')
try:
# 2. Clone (empty) and make a commit
git_env = {
'GIT_AUTHOR_NAME': 'Test Bot',
'GIT_AUTHOR_EMAIL': 'test@example.com',
'GIT_COMMITTER_NAME': 'Test Bot',
'GIT_COMMITTER_EMAIL': 'test@example.com',
# Embed credentials in the URL via a helper config
'GIT_CONFIG_COUNT': '1',
'GIT_CONFIG_KEY_0': f'url.https://{username}:{password}@{domain}/.insteadOf',
'GIT_CONFIG_VALUE_0': f'https://{domain}/',
}
print(' Cloning repo ...')
run_git(['clone', clone_url, tmpdir], cwd='/tmp', env=git_env)
# Write a test file
test_file = os.path.join(tmpdir, 'README.md')
with open(test_file, 'w') as f:
f.write(f'# {repo_name}\n\nAutomated test commit.\n')
run_git(['add', 'README.md'], cwd=tmpdir, env=git_env)
run_git(['commit', '-m', 'test: automated push test'], cwd=tmpdir, env=git_env)
# 3. Push
print(' Pushing commit ...')
run_git(['push', 'origin', 'HEAD:main'], cwd=tmpdir, env=git_env)
print(' Push succeeded.')
# 4. Verify via API — check the commit landed
print(' Verifying commit via API ...')
status, commits = api('GET',
f'{base_url}/api/v1/repos/{username}/{repo_name}/commits?limit=1',
username=username, password=password)
if status != 200 or not commits:
print(f'FAIL: Could not verify commit via API (HTTP {status}): {commits}')
sys.exit(1)
commit_msg = commits[0].get('commit', {}).get('message', '').strip()
if 'automated push test' not in commit_msg:
print(f'FAIL: Unexpected commit message: {commit_msg!r}')
sys.exit(1)
print(f' Commit verified: {commit_msg!r}')
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
# 5. Delete the test repo
print(f' Deleting test repo {repo_name} ...')
api('DELETE', f'{base_url}/api/v1/repos/{username}/{repo_name}',
username=username, password=password)
print('PASS: git push test completed successfully')
if __name__ == '__main__':
main()
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Health check for Gitea."""
import argparse
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import http_get, resolve_domain
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
domain = args.domain or resolve_domain('gitea')
url = f"https://{domain}"
print(f"Checking Gitea at {url} ...")
status, _ = http_get(url)
if status == 200:
print(f"PASS: Gitea returned HTTP {status}")
else:
print(f"FAIL: Gitea returned HTTP {status} (expected 200)")
sys.exit(1)
if __name__ == '__main__':
main()
+15
View File
@@ -0,0 +1,15 @@
# Gitea Upstream
## Main Project
- **Repository:** https://github.com/go-gitea/gitea
- **Releases:** https://github.com/go-gitea/gitea/releases
- **Website:** https://gitea.io
- **Docker Hub:** https://hub.docker.com/r/gitea/gitea
## Images
| Service | Image | Release Notes |
|---------|-------|---------------|
| app | `gitea/gitea` | https://github.com/go-gitea/gitea/releases |
| db | `mariadb` | https://mariadb.com/kb/en/release-notes/ |
+1
View File
@@ -0,0 +1 @@
name = "hedgedoc"
+25
View File
@@ -0,0 +1,25 @@
# HedgeDoc — First-Time Setup
## Prerequisites
- DNS: `hedgedoc.<domain_suffix>` must resolve to the server
## Steps
1. **Create the app:**
```bash
abra app new hedgedoc --server <SERVER> --domain hedgedoc.<DOMAIN_SUFFIX> --no-input
```
2. **Generate secrets:**
```bash
abra app secret generate hedgedoc.<DOMAIN_SUFFIX> --all -m --no-input
```
Save output to `recipe-info/testsecrets/hedgedoc.<DOMAIN_SUFFIX>`.
3. **Deploy:**
```bash
abra app deploy hedgedoc.<DOMAIN_SUFFIX> --chaos --force --no-input
```
4. **Verify:** curl `https://hedgedoc.<DOMAIN_SUFFIX>` returns HTTP 200.
+20
View File
@@ -0,0 +1,20 @@
# HedgeDoc Tests
## Target
- **URL:** https://hedgedoc.<DOMAIN_SUFFIX>
## Automated Checks
Run the scripts in `tests/` to perform automated testing:
- `health_check.py` — Confirms the instance is reachable and returns HTTP 200.
- `create_note.py` — Creates a note via the API, downloads it, and verifies the content matches. Covers note creation and persistence.
- `websocket_check.py` — Verifies the Socket.IO transport is responding with a valid handshake.
## Manual Verification
Full UI/UX testing (visual rendering, interactive editing experience) still benefits from human browser testing:
1. Open https://hedgedoc.<DOMAIN_SUFFIX> in a browser.
2. Confirm the HedgeDoc landing page loads without errors.
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Create and verify a note on HedgeDoc."""
import argparse
import os
import sys
import time
import urllib.request
import urllib.error
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import resolve_domain
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
domain = args.domain or resolve_domain('hedgedoc')
url = f"https://{domain}"
timestamp = str(int(time.time()))
test_content = f"# Test Note {timestamp}\n\nThis is an automated test note created at {timestamp}."
print(f"Creating a test note on {url} ...")
# POST a new note and capture the redirect URL
data = test_content.encode('utf-8')
req = urllib.request.Request(f"{url}/new", data=data, method="POST")
req.add_header("Content-Type", "text/markdown")
try:
# HedgeDoc returns a redirect - we need to NOT follow it
opener = urllib.request.build_opener(NoRedirectHandler())
resp = opener.open(req, timeout=15)
redirect_url = resp.headers.get('Location', '')
except urllib.error.HTTPError as e:
if e.code in (301, 302, 303, 307, 308):
redirect_url = e.headers.get('Location', '')
else:
print(f"FAIL: POST /new returned HTTP {e.code}")
sys.exit(1)
if not redirect_url:
print("FAIL: No redirect URL returned from POST /new")
sys.exit(1)
# Extract the note ID from the redirect URL (last path segment)
note_id = redirect_url.rstrip('/').split('/')[-1]
print(f"Created note: {note_id}")
# Download the note content and verify it matches
print("Downloading note content ...")
download_req = urllib.request.Request(f"{url}/{note_id}/download")
try:
with urllib.request.urlopen(download_req, timeout=15) as resp:
downloaded = resp.read().decode('utf-8')
except Exception as e:
print(f"FAIL: Could not download note: {e}")
sys.exit(1)
if f"Test Note {timestamp}" in downloaded:
print(f"PASS: Note created and content verified (id: {note_id})")
else:
print("FAIL: Downloaded content does not match posted content")
print(f"Expected to find: Test Note {timestamp}")
print(f"Got: {downloaded}")
sys.exit(1)
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise urllib.error.HTTPError(newurl, code, msg, headers, fp)
if __name__ == '__main__':
main()
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Health check for HedgeDoc."""
import argparse
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import http_get, resolve_domain
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
domain = args.domain or resolve_domain('hedgedoc')
url = f"https://{domain}"
print(f"Checking HedgeDoc at {url} ...")
status, _ = http_get(url)
if status == 200:
print(f"PASS: HedgeDoc returned HTTP {status}")
else:
print(f"FAIL: HedgeDoc returned HTTP {status} (expected 200)")
sys.exit(1)
if __name__ == '__main__':
main()
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Check Socket.IO transport on HedgeDoc."""
import argparse
import os
import sys
import urllib.request
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import resolve_domain
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
domain = args.domain or resolve_domain('hedgedoc')
url = f"https://{domain}"
print(f"Checking Socket.IO transport on {url} ...")
req = urllib.request.Request(f"{url}/socket.io/?EIO=4&transport=polling")
try:
with urllib.request.urlopen(req, timeout=15) as resp:
response = resp.read().decode('utf-8')
except Exception as e:
print(f"FAIL: Could not connect to Socket.IO endpoint: {e}")
sys.exit(1)
if response.startswith('0{'):
print("PASS: Socket.IO handshake successful")
else:
print("FAIL: Unexpected Socket.IO response")
print(f"Got: {response}")
sys.exit(1)
if __name__ == '__main__':
main()
+14
View File
@@ -0,0 +1,14 @@
# HedgeDoc Upstream
## Main Project
- **Repository:** https://github.com/hedgedoc/hedgedoc
- **Releases:** https://github.com/hedgedoc/hedgedoc/releases
- **Website:** https://hedgedoc.org
## Images
| Service | Image | Release Notes |
|---------|-------|---------------|
| app | `quay.io/hedgedoc/hedgedoc` | https://github.com/hedgedoc/hedgedoc/releases |
| db | `postgres` | https://www.postgresql.org/docs/release/ |
+8
View File
@@ -0,0 +1,8 @@
name = "immich"
[dependencies]
requires = ["authentik"]
[sso]
provider = "authentik"
setup_script = "setup/sso_integration.py"
+38
View File
@@ -0,0 +1,38 @@
# Immich — First-Time Setup
## Prerequisites
- DNS: `immich.<domain_suffix>` must resolve to the server
- **Authentik** must be deployed and running (dependency)
## Steps
1. **Create the app:**
```bash
abra app new immich --server <SERVER> --domain immich.<DOMAIN_SUFFIX> --no-input
```
2. **Generate secrets:**
```bash
abra app secret generate immich.<DOMAIN_SUFFIX> --all -m --no-input
```
Save output to `recipe-info/testsecrets/immich.<DOMAIN_SUFFIX>`.
3. **Deploy:**
```bash
abra app deploy immich.<DOMAIN_SUFFIX> --chaos --force --no-input
```
4. **Authentik SSO integration:**
```bash
python3 recipe-info/immich/setup_authentik_integration.py
```
This creates an OAuth2 provider and application in Authentik, creates a test user, creates an Immich admin account via the API, and configures Immich's OAuth settings via the Immich system API.
5. **Verify:** curl `https://immich.<DOMAIN_SUFFIX>` returns HTTP 200.
## Notes
- Credentials are saved to `recipe-info/immich/authentik-test-credentials.<DOMAIN_SUFFIX>.toml`.
- Unlike other recipes, Immich's OAuth is configured via its admin API (not env vars), so no redeploy is needed after SSO setup.
- OIDC test user: `testuser` / `testpass123`.
@@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""Setup Authentik OIDC integration for Immich.
Creates an OAuth2 provider, application, and test user in Authentik,
then creates an Immich admin account and configures OAuth via the
Immich API.
"""
import json
import os
import sys
import urllib.error
import urllib.request
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from lib.authentik import AuthentikAdmin
from lib.models import load_default_instance
from lib.secrets import load_secrets
# Configuration
PROVIDER_NAME = "immich"
APP_SLUG = "immich"
CLIENT_ID = "immich"
TEST_USER = "testuser"
TEST_PASS = "testpass123"
TEST_EMAIL = f"{TEST_USER}@test.example.com"
IMMICH_ADMIN_EMAIL = "admin@immich.test"
IMMICH_ADMIN_PASS = "adminpass123"
IMMICH_ADMIN_NAME = "Admin"
def _immich_request(method, url, data=None, headers=None, timeout=10):
"""Make an HTTP request to the Immich API."""
body = json.dumps(data).encode() if data is not None else None
req = urllib.request.Request(url, data=body, method=method)
req.add_header("Content-Type", "application/json")
for k, v in (headers or {}).items():
req.add_header(k, v)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
if not raw:
return resp.getcode(), None
return resp.getcode(), json.loads(raw)
except urllib.error.HTTPError as e:
try:
raw = e.read().decode(errors="replace")
return e.code, json.loads(raw) if raw.strip() else None
except Exception:
return e.code, None
def main():
inst = load_default_instance()
immich_domain = inst.default_domain("immich")
immich_url = f"https://{immich_domain}"
ak_domain = inst.default_domain("authentik")
ak_url = f"https://{ak_domain}"
# Get Authentik admin token from synced secrets
ak_secrets = load_secrets(ak_domain)
ak_token = ak_secrets["admin_token"]
ak = AuthentikAdmin(ak_url, ak_token)
# Resolve Authentik UUIDs
uuids = ak.resolve_uuids()
# Step 1: Create OAuth2 provider
provider_pk, client_secret = ak.ensure_provider(
PROVIDER_NAME, CLIENT_ID,
redirect_uris=[
{"matching_mode": "strict", "url": f"{immich_url}/auth/login"},
{"matching_mode": "strict", "url": f"{immich_url}/user-settings"},
{"matching_mode": "strict", "url": "app.immich:///oauth-callback"},
],
uuids=uuids,
)
# Step 2: Create application
ak.ensure_application("Immich", APP_SLUG, provider_pk, immich_url)
# Step 3: Ensure test user with APP_PASSWORD
user_pk = ak.ensure_user(TEST_USER, TEST_EMAIL, TEST_PASS)
app_password = ak.ensure_app_password(user_pk)
# Step 4: Configure Immich OAuth via API
print("=== Configure Immich OAuth via API ===", flush=True)
# Create admin account (skip if already exists)
print(" Creating Immich admin account ...", flush=True)
status, resp = _immich_request("POST", f"{immich_url}/api/auth/admin-sign-up", {
"email": IMMICH_ADMIN_EMAIL,
"password": IMMICH_ADMIN_PASS,
"name": IMMICH_ADMIN_NAME,
})
if resp and ("error" in resp or "message" in resp):
msg = resp.get("error", resp.get("message", ""))
if "admin" in msg.lower():
print(" Admin account already exists, continuing", flush=True)
else:
print(f" Admin signup response: {msg}", flush=True)
else:
print(" Admin account created (or already existed)", flush=True)
# Login to get access token
print(" Logging in as Immich admin ...", flush=True)
status, resp = _immich_request("POST", f"{immich_url}/api/auth/login", {
"email": IMMICH_ADMIN_EMAIL,
"password": IMMICH_ADMIN_PASS,
})
if not resp or "accessToken" not in resp:
print(f" FAIL: Could not login to Immich. Response: {resp}", flush=True)
sys.exit(1)
access_token = resp["accessToken"]
print(f" Logged in (token: {access_token[:10]}...)", flush=True)
auth_headers = {"Authorization": f"Bearer {access_token}"}
# Get current system config
print(" Fetching current Immich system config ...", flush=True)
_, config = _immich_request("GET", f"{immich_url}/api/system-config",
headers=auth_headers)
# Merge OAuth settings
print(" Updating OAuth settings ...", flush=True)
oidc_issuer = f"{ak_url}/application/o/{APP_SLUG}/.well-known/openid-configuration"
oauth = config.get("oauth", {})
oauth.update({
"enabled": True,
"issuerUrl": oidc_issuer,
"clientId": CLIENT_ID,
"clientSecret": client_secret,
"scope": "openid email profile",
"autoRegister": True,
"autoLaunch": False,
"buttonText": "Login with Authentik",
"tokenEndpointAuthMethod": "client_secret_post",
"timeout": 30000,
"mobileOverrideEnabled": False,
"mobileRedirectUri": "",
"signingAlgorithm": "RS256",
"storageLabelClaim": "preferred_username",
"storageQuotaClaim": "",
"defaultStorageQuota": 0,
"profileSigningAlgorithm": "none",
"roleClaim": "immich_role",
})
config["oauth"] = oauth
_, put_resp = _immich_request("PUT", f"{immich_url}/api/system-config",
data=config, headers=auth_headers)
oauth_enabled = (put_resp or {}).get("oauth", {}).get("enabled", False)
if oauth_enabled:
print(" OAuth enabled successfully", flush=True)
else:
print(f" WARNING: Could not confirm OAuth enabled. Response: {put_resp}",
flush=True)
# Step 5: Write credentials file
script_dir = os.path.dirname(os.path.abspath(__file__))
creds_file = os.path.join(script_dir, f"authentik-test-credentials.{inst.domain_suffix}.toml")
print(f"=== Write credentials to {creds_file} ===", flush=True)
with open(creds_file, "w") as f:
f.write(f'# Authentik OIDC credentials for Immich test instance\n')
f.write(f'#\n')
f.write(f'# Authentik instance: {ak_domain}\n')
f.write(f'# Application slug: {APP_SLUG}\n')
f.write(f'# Created by: setup_authentik_integration.py\n')
f.write(f'\n')
f.write(f'# Authentik admin\n')
f.write(f'ak_token = "{ak_token}"\n')
f.write(f'\n')
f.write(f'# OIDC provider\n')
f.write(f'ak_app_slug = "{APP_SLUG}"\n')
f.write(f'ak_client_id = "{CLIENT_ID}"\n')
f.write(f'ak_client_secret = "{client_secret}"\n')
f.write(f'\n')
f.write(f'# Authentik OIDC endpoints\n')
f.write(f'ak_token_endpoint = "https://{ak_domain}/application/o/token/"\n')
f.write(f'ak_userinfo_endpoint = "https://{ak_domain}/application/o/userinfo/"\n')
f.write(f'ak_discovery_endpoint = "https://{ak_domain}/application/o/{APP_SLUG}/.well-known/openid-configuration"\n')
f.write(f'\n')
f.write(f'# Test user (password for browser login, app_password for password grant)\n')
f.write(f'ak_test_user = "{TEST_USER}"\n')
f.write(f'ak_test_pass = "{TEST_PASS}"\n')
f.write(f'ak_test_app_password = "{app_password}"\n')
f.write(f'ak_test_email = "{TEST_EMAIL}"\n')
f.write(f'\n')
f.write(f'# Immich instance\n')
f.write(f'immich_domain = "{immich_domain}"\n')
f.write(f'immich_admin_email = "{IMMICH_ADMIN_EMAIL}"\n')
f.write(f'immich_admin_pass = "{IMMICH_ADMIN_PASS}"\n')
print(f" Written to {creds_file}", flush=True)
print("", flush=True)
print("=== Authentik OIDC integration setup for Immich complete ===", flush=True)
print("", flush=True)
print("Next steps:", flush=True)
print(f" 1. Run OIDC test: python3 recipe-info/immich/tests/oidc_login.py", flush=True)
print(f" 2. Manual: open {immich_url} and click 'Login with Authentik'", flush=True)
if __name__ == "__main__":
main()
+69
View File
@@ -0,0 +1,69 @@
# Immich Tests
## Target
- **URL:** https://immich.<DOMAIN_SUFFIX>
## Dependencies
- **Authentik** (`authentik.<DOMAIN_SUFFIX>`) — required for SSO/OIDC testing
## Test Setup
Before running all tests, the following must be in place:
### 1. Deploy authentik
```bash
abra app deploy authentik.<DOMAIN_SUFFIX> --chaos --force --no-input
```
### 2. Deploy Immich
```bash
abra app deploy immich.<DOMAIN_SUFFIX> --chaos --force --no-input
```
### 3. Run the Authentik integration setup
```bash
python3 recipe-info/immich/setup_authentik_integration.py
```
This configures authentik as the OAuth provider for Immich:
1. Creates an OAuth2 provider (`immich`) via the authentik REST API
2. Creates an Application linked to the provider
3. Creates a test user (`testuser` / `testpass123`) with an APP_PASSWORD token
4. Creates an Immich admin account via the Immich API
5. Configures Immich's OAuth settings via the Immich system config API
6. Writes credentials to `authentik-test-credentials.<DOMAIN_SUFFIX>.toml`
**Important:** The APP_PASSWORD token becomes invalid if authentik is redeployed. If the `oidc_login.py` test fails with "invalid, expired, revoked" token errors, re-run this setup script.
### 4. Verify
No redeploy needed — Immich's OAuth is configured via its API, not env vars.
## Automated Tests
- `tests/health_check.py` — Confirms the instance is reachable and returns HTTP 200.
- `tests/oidc_login.py` — Tests SSO/OIDC integration with Authentik. Checks OIDC discovery, APP_PASSWORD token grant, and Immich API authentication.
### Credentials
| Key | Description |
|-----|-------------|
| `ak_client_id` / `ak_client_secret` | OIDC client ID and secret |
| `ak_test_user` / `ak_test_pass` | Test user credentials (password for browser login) |
| `ak_test_app_password` | APP_PASSWORD token for password grant (authentik requires this instead of regular passwords) |
| `ak_test_email` | Test user email |
| `ak_discovery_endpoint` | Authentik OIDC discovery URL |
Stored in `authentik-test-credentials.<DOMAIN_SUFFIX>.toml`.
## Manual Verification
1. Open https://immich.<DOMAIN_SUFFIX> in a browser.
2. Confirm the Immich web interface loads without errors.
3. Confirm the "Login with Authentik" button appears on the login page.
4. Click it and verify redirect to Authentik for authentication.
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Health check for Immich."""
import argparse
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import http_get, resolve_domain
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
domain = args.domain or resolve_domain('immich')
url = f"https://{domain}"
print(f"Checking Immich at {url} ...")
status, _ = http_get(url)
if status == 200:
print(f"PASS: Immich returned HTTP {status}")
else:
print(f"FAIL: Immich returned HTTP {status} (expected 200)")
sys.exit(1)
if __name__ == '__main__':
main()
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Immich OIDC integration test."""
import argparse
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import (
http_get, http_post, load_toml_credentials, resolve_domain,
)
def main():
if os.environ.get('SKIP_INTEGRATION') == '1':
print("SKIP: OIDC integration test (SKIP_INTEGRATION=1)")
return
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
domain = args.domain or resolve_domain('immich')
url = f"https://{domain}"
recipe_dir = os.path.join(os.path.dirname(__file__), '..')
creds = load_toml_credentials(recipe_dir, 'authentik')
if creds is None:
print("FAIL: Credentials file not found: authentik-test-credentials.<domain_suffix>.toml")
print("Run setup_authentik_integration.py first.")
sys.exit(1)
print("=== Immich OIDC Integration Test ===")
print()
# Step 1: Check Immich is deployed
print("Step 1: Checking Immich is deployed ...")
status, _ = http_get(url)
if status == 0:
print(f" FAIL: Immich is not reachable at {url}")
sys.exit(1)
elif status >= 500:
print(f" FAIL: Immich returned HTTP {status}")
sys.exit(1)
print(f" OK: Immich is reachable (HTTP {status})")
# Step 2: Verify Authentik OIDC discovery
print("Step 2: Checking Authentik OIDC discovery ...")
discovery_url = creds["ak_discovery_endpoint"]
status, _ = http_get(discovery_url)
if status != 200:
print(f" FAIL: OIDC discovery returned HTTP {status}")
print(f" URL: {discovery_url}")
sys.exit(1)
print(f" PASS: OIDC discovery endpoint OK (app '{creds['ak_app_slug']}')")
# Step 3: Obtain token from Authentik
print("Step 3: Obtaining token from Authentik for test user ...")
print(" Using APP_PASSWORD for password grant (authentik requirement)")
status, data = http_post(
creds["ak_token_endpoint"],
data={
"grant_type": "password",
"client_id": creds["ak_client_id"],
"client_secret": creds["ak_client_secret"],
"username": creds["ak_test_user"],
"password": creds["ak_test_app_password"],
"scope": "openid email profile",
},
content_type="application/x-www-form-urlencoded",
)
access_token = (data or {}).get("access_token", "")
if not access_token:
error = (data or {}).get("error_description", (data or {}).get("error", "unknown"))
print(f" FAIL: Token request failed: {error}")
sys.exit(1)
print(f" PASS: Got access token ({len(access_token)} chars)")
# Step 4: Verify Immich OAuth endpoint
print("Step 4: Checking Immich OAuth endpoint ...")
status, body = http_post(
f"{url}/api/oauth/authorize",
data={"redirectUri": f"{url}/auth/login"},
)
oauth_url = (body or {}).get("url", "")
if not oauth_url:
error = (body or {}).get("message", (body or {}).get("error", "unknown"))
print(f" FAIL: OAuth authorize endpoint failed: {error}")
sys.exit(1)
print(" PASS: OAuth authorize returned redirect URL")
print()
print("PASS: Immich OIDC integration test passed")
print(" Authentik OIDC discovery OK, token grant OK, Immich OAuth endpoint active.")
if __name__ == '__main__':
main()
+16
View File
@@ -0,0 +1,16 @@
# Immich Upstream
## Main Project
- **Repository:** https://github.com/immich-app/immich
- **Releases:** https://github.com/immich-app/immich/releases
- **Website:** https://immich.app
## Images
| Service | Image | Release Notes |
|---------|-------|---------------|
| app | `ghcr.io/immich-app/immich-server` | https://github.com/immich-app/immich/releases |
| immich-machine-learning | `ghcr.io/immich-app/immich-machine-learning` | https://github.com/immich-app/immich/releases |
| redis | `docker.io/valkey/valkey` | https://github.com/valkey-io/valkey/releases |
| database | `ghcr.io/immich-app/postgres` | https://github.com/immich-app/immich/releases |
+9
View File
@@ -0,0 +1,9 @@
# Keycloak Test Dependencies
## lasuite-docs
La Suite Docs is used as an OIDC relying party for integration testing. The `oidc_integration.py` test verifies that Keycloak can issue tokens and that a real application accepts them.
- **Do not undeploy** lasuite-docs during `/test-context-reset` when testing keycloak.
- The OIDC integration is configured via `recipe-info/lasuite-docs/setup_keycloak_integration.py`.
- Test credentials are stored in `recipe-info/lasuite-docs/keycloak-test-credentials.<DOMAIN_SUFFIX>.toml`.
+5
View File
@@ -0,0 +1,5 @@
name = "keycloak"
[dependencies]
requires = []
test_requires = ["lasuite-docs"] # oidc_integration test needs lasuite-docs deployed
+30
View File
@@ -0,0 +1,30 @@
# Keycloak — First-Time Setup
## Prerequisites
- DNS: `keycloak.<domain_suffix>` must resolve to the server
## Steps
1. **Create the app:**
```bash
abra app new keycloak --server <SERVER> --domain keycloak.<DOMAIN_SUFFIX> --no-input
```
2. **Generate secrets:**
```bash
abra app secret generate keycloak.<DOMAIN_SUFFIX> --all -m --no-input
```
Save output to `recipe-info/testsecrets/keycloak.<DOMAIN_SUFFIX>`.
3. **Deploy:**
```bash
abra app deploy keycloak.<DOMAIN_SUFFIX> --chaos --force --no-input
```
4. **Verify:** curl `https://keycloak.<DOMAIN_SUFFIX>/realms/master` returns HTTP 200.
## Notes
- Keycloak health check uses `/realms/master` (root `/` returns 302).
- Admin credentials: username `admin`, password from `admin_password` secret.
+24
View File
@@ -0,0 +1,24 @@
# Keycloak Tests
## Requires
- lasuite-docs
## Target
- **URL:** `https://keycloak.<DOMAIN_SUFFIX>`
## Automated Checks
Run the scripts in `tests/` to perform automated testing:
- `health_check.py` — Confirms the instance is reachable and returns HTTP 200.
- `oidc_integration.py` — Full OIDC integration test using La Suite Docs as the relying party. Verifies token issuance, OIDC discovery, and JWT validation by authenticating a test user through Keycloak and calling the Docs API with the resulting token. Requires lasuite-docs to be deployed. Set `SKIP_INTEGRATION=1` to skip.
## Manual Verification
1. Open `https://keycloak.<DOMAIN_SUFFIX>` in a browser.
2. Confirm the Keycloak login/welcome page loads without errors.
3. Log in with the temporary admin credentials (username: `admin`, password in `recipe-info/keycloak/secrets.json` under `admin_password`).
4. Verify the Keycloak admin console loads and is functional.
5. Create a real admin user with 2FA and delete the temporary admin (see recipe README for full steps).
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Health check for Keycloak."""
import argparse
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import http_get, resolve_domain
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
domain = args.domain or resolve_domain('keycloak')
url = f"https://{domain}/realms/master"
print(f"Checking Keycloak at {url} ...")
status, _ = http_get(url)
if status == 200:
print(f"PASS: Keycloak returned HTTP {status}")
else:
print(f"FAIL: Keycloak returned HTTP {status} (expected 200)")
sys.exit(1)
if __name__ == '__main__':
main()
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Keycloak OIDC integration test — validates Keycloak can issue tokens
that are accepted by La Suite Docs."""
import argparse
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import (
http_get, http_post, load_toml_credentials, resolve_domain,
)
def main():
if os.environ.get('SKIP_INTEGRATION') == '1':
print("SKIP: SKIP_INTEGRATION=1 is set, skipping OIDC integration test")
sys.exit(0)
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
kc_domain = args.domain or resolve_domain('keycloak')
docs_domain = resolve_domain('lasuite-docs')
kc_url = f"https://{kc_domain}"
docs_url = f"https://{docs_domain}"
# Load credentials from lasuite-docs recipe dir
creds_path = os.path.join(os.path.dirname(__file__), '..', '..', 'lasuite-docs')
creds = load_toml_credentials(creds_path, 'keycloak')
if creds is None:
print("FAIL: Credentials file not found: lasuite-docs/keycloak-test-credentials.<domain_suffix>.toml")
print("Run recipe-info/lasuite-docs/setup_keycloak_integration.py first.")
sys.exit(1)
print("Testing Keycloak OIDC integration with La Suite Docs")
print()
# Step 1: Check that La Suite Docs is deployed and reachable
print("Step 1: Checking La Suite Docs is reachable ...")
status, _ = http_get(docs_url)
if status == 0 or status >= 500:
print(f" FAIL: La Suite Docs at {docs_url} returned HTTP {status}")
print(" Make sure lasuite-docs is deployed before running this test.")
sys.exit(1)
print(f" PASS: La Suite Docs is reachable (HTTP {status})")
# Step 2: Verify OIDC discovery endpoint
print("Step 2: Checking Keycloak OIDC discovery endpoint ...")
discovery_url = f"{kc_url}/realms/{creds['kc_realm']}/.well-known/openid-configuration"
status, data = http_get(discovery_url)
if status != 200:
print(f" FAIL: OIDC discovery endpoint returned HTTP {status} (expected 200)")
sys.exit(1)
issuer = (data or {}).get("issuer", "")
print(f" PASS: OIDC discovery endpoint OK (issuer: {issuer})")
# Step 3: Obtain token from Keycloak
print("Step 3: Obtaining token from Keycloak ...")
token_url = f"{kc_url}/realms/{creds['kc_realm']}/protocol/openid-connect/token"
status, data = http_post(
token_url,
data={
"grant_type": "password",
"client_id": creds["kc_client_id"],
"client_secret": creds["kc_client_secret"],
"username": creds["kc_test_user"],
"password": creds["kc_test_pass"],
"scope": "openid email",
},
content_type="application/x-www-form-urlencoded",
)
access_token = (data or {}).get("access_token", "")
if not access_token:
error = (data or {}).get("error_description", (data or {}).get("error", "unknown"))
print(f" FAIL: Could not obtain token from Keycloak: {error}")
sys.exit(1)
print(f" PASS: Obtained access token from Keycloak ({len(access_token)} chars)")
# Step 4: Use token to authenticate against Docs API
print("Step 4: Accessing Docs API with Keycloak token ...")
status, body = http_get(
f"{docs_url}/api/v1.0/users/me/",
headers={"Authorization": f"Bearer {access_token}"},
)
if status != 200:
print(f" FAIL: Docs API returned HTTP {status} (expected 200)")
sys.exit(1)
user_email = (body or {}).get("email", "")
expected_email = creds.get("kc_test_email", f"{creds['kc_test_user']}@test.example.com")
if user_email == expected_email:
print(f" PASS: Docs API returned user with email '{user_email}'")
else:
print(f" FAIL: Expected email '{expected_email}', got '{user_email}'")
sys.exit(1)
print()
print("PASS: Keycloak OIDC integration test passed — tokens accepted by Docs")
if __name__ == '__main__':
main()
+14
View File
@@ -0,0 +1,14 @@
# Keycloak Upstream
## Main Project
- **Repository:** https://github.com/keycloak/keycloak
- **Releases:** https://github.com/keycloak/keycloak/releases
- **Website:** https://www.keycloak.org
## Images
| Service | Image | Release Notes |
|---------|-------|---------------|
| app | `keycloak/keycloak` | https://github.com/keycloak/keycloak/releases |
| db | `mariadb` | https://mariadb.com/kb/en/release-notes/ |
+8
View File
@@ -0,0 +1,8 @@
name = "lasuite-docs"
[dependencies]
requires = ["keycloak"]
[sso]
provider = "keycloak"
setup_script = "setup/sso_integration.py"
+42
View File
@@ -0,0 +1,42 @@
# La Suite Docs — First-Time Setup
## Prerequisites
- DNS: `lasuite-docs.<domain_suffix>` must resolve to the server
- **Keycloak** must be deployed and running (dependency)
## Steps
1. **Create the app:**
```bash
abra app new lasuite-docs --server <SERVER> --domain lasuite-docs.<DOMAIN_SUFFIX> --no-input
```
2. **Generate secrets:**
```bash
abra app secret generate lasuite-docs.<DOMAIN_SUFFIX> --all -m --no-input
```
Save output to `recipe-info/testsecrets/lasuite-docs.<DOMAIN_SUFFIX>`.
3. **Deploy:**
```bash
abra app deploy lasuite-docs.<DOMAIN_SUFFIX> --chaos --force --no-input
```
4. **Keycloak SSO integration:**
```bash
python3 recipe-info/lasuite-docs/setup_keycloak_integration.py
```
This creates a `lasuite-docs` realm, OIDC client, and test user in Keycloak. It also inserts the client secret and updates the env file.
5. **Redeploy with SSO config:**
```bash
abra app deploy lasuite-docs.<DOMAIN_SUFFIX> --chaos --force --no-input
```
6. **Verify:** curl `https://lasuite-docs.<DOMAIN_SUFFIX>` returns HTTP 200.
## Notes
- Credentials are saved to `recipe-info/lasuite-docs/keycloak-test-credentials.<DOMAIN_SUFFIX>.toml`.
- OIDC test user: `testuser` / `testpass123`.
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Setup Keycloak OIDC integration for La Suite Docs.
Creates a Keycloak realm, OIDC client, and test user, then inserts
the client secret and updates the Docs env file with OIDC settings.
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from lib.abra import app_secret_insert
from lib.env import apply_env_overrides, get_abra_env_path, read_env_file
from lib.keycloak import KeycloakAdmin
from lib.models import load_default_instance
from lib.secrets import load_secrets
# Configuration
REALM = "lasuite-docs"
CLIENT_ID = "docs"
TEST_USER = "testuser"
TEST_PASS = "testpass123"
TEST_EMAIL = f"{TEST_USER}@test.example.com"
def main():
inst = load_default_instance()
docs_domain = inst.default_domain("lasuite-docs")
kc_domain = inst.default_domain("keycloak")
# Get Keycloak admin credentials from synced secrets
kc_secrets = load_secrets(kc_domain)
kc_admin_pass = kc_secrets["admin_password"]
# Try the configured admin username first; fall back to temp-admin.
# temp-admin is created when kc.sh bootstrap-admin user is run for recovery
# and may be the only working admin if the original admin password changed.
kc_url = f"https://{kc_domain}"
for username in ("admin", "temp-admin"):
kc = KeycloakAdmin(kc_url, username, kc_admin_pass)
try:
kc.get_admin_token()
print(f"Authenticated as '{username}'", flush=True)
break
except Exception:
print(f"Login as '{username}' failed, trying next...", flush=True)
else:
print("ERROR: Could not authenticate with Keycloak admin API", flush=True)
sys.exit(1)
# Step 1: Create realm
kc.ensure_realm(REALM)
# Step 2: Create OIDC client
_, client_secret = kc.ensure_client(
REALM, CLIENT_ID,
redirect_uris=[f"https://{docs_domain}/*"],
web_origins=[f"https://{docs_domain}"],
)
# Step 3: Create test user
kc.ensure_user(REALM, TEST_USER, TEST_EMAIL, TEST_PASS)
# Step 4: Insert client secret via abra
print("=== Insert OIDC client secret into Docs ===", flush=True)
env_path = get_abra_env_path(inst.server, docs_domain)
env_data = read_env_file(env_path)
current_version = env_data.get("SECRET_OIDC_RPCS_VERSION", "v1")
next_num = int(current_version.lstrip("v")) + 1
next_version = f"v{next_num}"
print(f" Current secret version: {current_version}", flush=True)
print(f" Inserting as: {next_version}", flush=True)
app_secret_insert(docs_domain, "oidc_rpcs", next_version, client_secret)
# Step 5: Update Docs env with OIDC settings
print("=== Update Docs OIDC settings in env file ===", flush=True)
apply_env_overrides(env_path, {
"SECRET_OIDC_RPCS_VERSION": next_version,
"OIDC_REALM": REALM,
"AUTH_DOMAIN": kc_domain,
"OIDC_RP_CLIENT_ID": CLIENT_ID,
})
# Step 6: Write credentials file
script_dir = os.path.dirname(os.path.abspath(__file__))
creds_file = os.path.join(script_dir, f"keycloak-test-credentials.{inst.domain_suffix}.toml")
print(f"=== Write credentials to {creds_file} ===", flush=True)
with open(creds_file, "w") as f:
f.write(f'# Keycloak OIDC credentials for lasuite-docs test instance\n')
f.write(f'#\n')
f.write(f'# Keycloak instance: {kc_domain}\n')
f.write(f'# Realm: {REALM}\n')
f.write(f'# Created by: setup_keycloak_integration.py\n')
f.write(f'\n')
f.write(f'# Keycloak admin (master realm)\n')
f.write(f'kc_admin_user = "admin"\n')
f.write(f'kc_admin_pass = "{kc_admin_pass}"\n')
f.write(f'\n')
f.write(f'# OIDC client\n')
f.write(f'kc_realm = "{REALM}"\n')
f.write(f'kc_client_id = "{CLIENT_ID}"\n')
f.write(f'kc_client_secret = "{client_secret}"\n')
f.write(f'\n')
f.write(f'# Test user (in {REALM} realm)\n')
f.write(f'kc_test_user = "{TEST_USER}"\n')
f.write(f'kc_test_pass = "{TEST_PASS}"\n')
f.write(f'kc_test_email = "{TEST_EMAIL}"\n')
print(f" Written to {creds_file}", flush=True)
print("", flush=True)
print("=== Keycloak integration setup complete ===", flush=True)
print("", flush=True)
print("Next steps:", flush=True)
print(f" 1. Redeploy Docs: abra app deploy {docs_domain} --chaos --force --no-input", flush=True)
print(f" 2. Run migrations: script -qefc 'abra app cmd {docs_domain} backend migrate --no-input' /dev/null", flush=True)
print(f" 3. Run OIDC test: python3 recipe-info/lasuite-docs/tests/oidc_login.py", flush=True)
if __name__ == "__main__":
main()
+85
View File
@@ -0,0 +1,85 @@
# La Suite Docs Tests
## Requires
- keycloak
## Target
- **URL:** https://lasuite-docs.<DOMAIN_SUFFIX>
- **Keycloak:** https://keycloak.<DOMAIN_SUFFIX> (realm: `lasuite-docs`)
## Prerequisites
Keycloak (`keycloak.<DOMAIN_SUFFIX>`) must be deployed before testing lasuite-docs. The OIDC login test and any manual authentication testing depend on it. If Keycloak is not running, deploy it first with `/recipe-deploy keycloak`.
## Automated Checks
Run the scripts in `tests/` to perform automated testing:
- `tests/health_check.py` — Confirms the instance is reachable and returns HTTP 200.
- `tests/oidc_login.py` — Tests the full OIDC authentication flow end-to-end:
1. Verifies Docs' `/api/v1.0/authenticate/` redirects to Keycloak
2. Obtains an access token from Keycloak via direct access grant (password flow)
3. Calls Docs' `/api/v1.0/users/me/` with the token and verifies the correct user is returned
This test reads credentials from `keycloak-test-credentials.<DOMAIN_SUFFIX>.toml`.
## Keycloak OIDC Integration
La Suite Docs **requires** an OIDC provider. The test instance uses Keycloak at `keycloak.<DOMAIN_SUFFIX>`.
### Setup
Run `setup_keycloak_integration.py` to configure everything automatically. The script:
1. Creates a `lasuite-docs` realm in Keycloak
2. Creates a `docs` OIDC client (confidential, standard flow + direct access grants)
3. Creates a test user (`testuser` / `testpass123`)
4. Inserts the OIDC client secret into the Docs app via `abra app secret insert`
5. Updates the Docs env file with `OIDC_REALM`, `AUTH_DOMAIN`, `OIDC_RP_CLIENT_ID`
6. Writes all credentials to `keycloak-test-credentials.<DOMAIN_SUFFIX>.toml`
After running the setup script, redeploy Docs:
```
abra app deploy lasuite-docs.<DOMAIN_SUFFIX> --chaos --force --no-input
```
The script is idempotent — it skips resources that already exist and resets the test user password.
### Credentials
All Keycloak credentials are stored in `keycloak-test-credentials.<DOMAIN_SUFFIX>.toml` (sourceable):
| Variable | Description |
|----------|-------------|
| `KC_ADMIN_USER` / `KC_ADMIN_PASS` | Keycloak admin (master realm) |
| `KC_REALM` | Keycloak realm name (`lasuite-docs`) |
| `KC_CLIENT_ID` / `KC_CLIENT_SECRET` | OIDC client ID and secret |
| `KC_TEST_USER` / `KC_TEST_PASS` | Test user credentials |
| `KC_TEST_EMAIL` | Test user email |
### Key Endpoints
| Endpoint | Purpose |
|----------|---------|
| `https://lasuite-docs.<DOMAIN_SUFFIX>/api/v1.0/authenticate/` | Initiates OIDC login (302 redirect to Keycloak) |
| `https://lasuite-docs.<DOMAIN_SUFFIX>/api/v1.0/callback/` | OIDC callback (Keycloak redirects here after login) |
| `https://keycloak.<DOMAIN_SUFFIX>/realms/lasuite-docs/protocol/openid-connect/token` | Keycloak token endpoint |
## Post-Deploy Steps
After deploying Docs for the first time:
1. **Keycloak integration:** `python3 recipe-info/lasuite-docs/setup_keycloak_integration.py` then redeploy
Migrations and Minio buckets are created automatically on startup — no manual steps needed.
## Manual Verification
1. Open https://lasuite-docs.<DOMAIN_SUFFIX> in a browser.
2. Confirm the La Suite Docs landing page loads without errors.
3. Click "Login" and verify the OIDC redirect to Keycloak works.
4. Log in with test credentials (`testuser` / `testpass123`).
5. After logging in, verify you can create and edit a document.
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Health check for La Suite Docs."""
import argparse
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import http_get, resolve_domain
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
domain = args.domain or resolve_domain('lasuite-docs')
url = f"https://{domain}"
print(f"Checking La Suite Docs at {url} ...")
status, _ = http_get(url)
if status == 200:
print(f"PASS: La Suite Docs returned HTTP {status}")
else:
print(f"FAIL: La Suite Docs returned HTTP {status} (expected 200)")
sys.exit(1)
if __name__ == '__main__':
main()
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""OIDC integration test for La Suite Docs + Keycloak."""
import argparse
import os
import sys
import urllib.request
import urllib.error
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import (
http_get, http_post, load_toml_credentials, resolve_domain,
)
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
recipe_dir = os.path.join(os.path.dirname(__file__), '..')
creds = load_toml_credentials(recipe_dir, 'keycloak')
if creds is None:
print("FAIL: Credentials file not found: keycloak-test-credentials.<domain_suffix>.toml")
print("Run setup_keycloak_integration.py first.")
sys.exit(1)
docs_domain = args.domain or resolve_domain('lasuite-docs')
kc_domain = resolve_domain('keycloak')
docs_url = f"https://{docs_domain}"
kc_url = f"https://{kc_domain}"
print("Testing OIDC integration: La Suite Docs <-> Keycloak")
print()
# Step 1: Verify Docs redirects to Keycloak
print("Step 1: Checking Docs OIDC redirect ...")
try:
req = urllib.request.Request(f"{docs_url}/api/v1.0/authenticate/")
opener = urllib.request.build_opener(NoRedirectHandler())
resp = opener.open(req, timeout=15)
redirect_url = resp.headers.get('Location', '')
except urllib.error.HTTPError as e:
redirect_url = e.headers.get('Location', '') if e.headers else ''
expected_prefix = f"{kc_url}/realms/{creds['kc_realm']}/protocol/openid-connect/auth"
if expected_prefix in (redirect_url or ''):
print(f" PASS: Docs redirects to Keycloak realm '{creds['kc_realm']}'")
else:
print(f" FAIL: Expected redirect to Keycloak, got: {redirect_url}")
sys.exit(1)
# Step 2: Obtain token from Keycloak
print("Step 2: Obtaining token from Keycloak ...")
token_url = f"{kc_url}/realms/{creds['kc_realm']}/protocol/openid-connect/token"
status, data = http_post(
token_url,
data={
"grant_type": "password",
"client_id": creds["kc_client_id"],
"client_secret": creds["kc_client_secret"],
"username": creds["kc_test_user"],
"password": creds["kc_test_pass"],
"scope": "openid email",
},
content_type="application/x-www-form-urlencoded",
)
access_token = (data or {}).get("access_token", "")
if not access_token:
error = (data or {}).get("error_description", (data or {}).get("error", "unknown"))
print(f" FAIL: Could not obtain token from Keycloak: {error}")
sys.exit(1)
print(f" PASS: Obtained access token from Keycloak ({len(access_token)} chars)")
# Step 3: Use token to access Docs API
print("Step 3: Accessing Docs API with Keycloak token ...")
status, body = http_get(
f"{docs_url}/api/v1.0/users/me/",
headers={"Authorization": f"Bearer {access_token}"},
)
if status != 200:
print(f" FAIL: Docs API returned HTTP {status} (expected 200)")
sys.exit(1)
user_email = (body or {}).get("email", "")
expected_email = f"{creds['kc_test_user']}@test.example.com"
if user_email == expected_email:
print(f" PASS: Docs API returned user with email '{user_email}'")
else:
print(f" FAIL: Expected email '{expected_email}', got '{user_email}'")
sys.exit(1)
print()
print("PASS: OIDC integration test passed — Docs authenticates via Keycloak")
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise urllib.error.HTTPError(newurl, code, msg, headers, fp)
if __name__ == '__main__':
main()
@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""Upload + conversion test for La Suite Docs.
Verifies the end-to-end conversion path:
- `.md` upload → backend → y-provider (yjs)
- `.docx` upload → backend → docspec (BlockNote JSON) → y-provider (yjs)
Exercises CONVERSION_UPLOAD_ENABLED on the backend, backend→y-provider auth
(Y_PROVIDER_API_KEY_FILE) and routing (Y_PROVIDER_API_BASE_URL), and
DOCSPEC_API_URL for the .docx import path.
"""
import argparse
import io
import json
import os
import sys
import urllib.error
import urllib.request
import uuid
import zipfile
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import (
http_post, load_toml_credentials, resolve_domain,
)
def build_minimal_docx() -> bytes:
"""Build a minimal valid .docx (OOXML) in memory."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as z:
z.writestr('[Content_Types].xml',
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Override PartName="/word/document.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
'</Types>')
z.writestr('_rels/.rels',
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" '
'Target="word/document.xml"/>'
'</Relationships>')
z.writestr('word/document.xml',
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
'<w:body><w:p><w:r><w:t>Upload conversion test</w:t></w:r></w:p></w:body>'
'</w:document>')
return buf.getvalue()
def get_oidc_token(kc_url, creds):
"""Direct grant flow against the lasuite-docs realm."""
status, data = http_post(
f"{kc_url}/realms/{creds['kc_realm']}/protocol/openid-connect/token",
data={
"grant_type": "password",
"client_id": creds["kc_client_id"],
"client_secret": creds["kc_client_secret"],
"username": creds["kc_test_user"],
"password": creds["kc_test_pass"],
"scope": "openid email",
},
content_type="application/x-www-form-urlencoded",
)
return (data or {}).get("access_token", "")
def multipart_upload(url, token, filename, content_type, body):
"""POST a file via multipart/form-data with a Bearer token."""
boundary = uuid.uuid4().hex
parts = [
f"--{boundary}\r\n".encode(),
b'Content-Disposition: form-data; name="title"\r\n\r\n',
b"upload-conversion-test\r\n",
f"--{boundary}\r\n".encode(),
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode(),
f"Content-Type: {content_type}\r\n\r\n".encode(),
body,
f"\r\n--{boundary}--\r\n".encode(),
]
req_body = b"".join(parts)
req = urllib.request.Request(
url,
data=req_body,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": f"multipart/form-data; boundary={boundary}",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.status, resp.read().decode()
except urllib.error.HTTPError as e:
return e.code, e.read().decode()
def upload_step(label, docs_url, token, filename, content_type, body):
print(f"Step: Upload {label} ...")
status, raw = multipart_upload(
f"{docs_url}/api/v1.0/documents/", token, filename, content_type, body,
)
if status != 201:
print(f" FAIL: {label} upload returned HTTP {status}: {raw[:200]}")
sys.exit(1)
try:
doc = json.loads(raw)
except json.JSONDecodeError:
print(f" FAIL: {label} response not JSON: {raw[:200]}")
sys.exit(1)
doc_id = doc.get('id')
if not doc_id:
print(f" FAIL: {label} response missing 'id': {raw[:200]}")
sys.exit(1)
print(f" PASS: {label} converted and persisted (document id {doc_id})")
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
recipe_dir = os.path.join(os.path.dirname(__file__), '..')
creds = load_toml_credentials(recipe_dir, 'keycloak')
if creds is None:
print("FAIL: Credentials file not found: keycloak-test-credentials.<domain_suffix>.toml")
print("Run setup_keycloak_integration.py first.")
sys.exit(1)
docs_domain = args.domain or resolve_domain('lasuite-docs')
kc_domain = resolve_domain('keycloak')
docs_url = f"https://{docs_domain}"
kc_url = f"https://{kc_domain}"
print("Testing file upload + conversion (CONVERSION_UPLOAD_ENABLED + Y_PROVIDER)")
print()
print("Step 1: Obtaining access token from Keycloak ...")
token = get_oidc_token(kc_url, creds)
if not token:
print(" FAIL: Could not obtain access token from Keycloak")
sys.exit(1)
print(f" PASS: Obtained access token ({len(token)} chars)")
upload_step(
".md file", docs_url, token,
"upload-test.md", "text/markdown",
b"# Upload Conversion Test\n\nValidating **CONVERSION_UPLOAD_ENABLED** end-to-end.\n",
)
upload_step(
".docx file", docs_url, token,
"upload-test.docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
build_minimal_docx(),
)
print()
print("PASS: Upload + conversion working end-to-end (.md and .docx)")
if __name__ == '__main__':
main()
+20
View File
@@ -0,0 +1,20 @@
# La Suite Docs Upstream
## Main Project
- **Repository:** https://github.com/suitenumerique/docs
- **Releases:** https://github.com/suitenumerique/docs/releases
## Images
| Service | Image | Release Notes |
|---------|-------|---------------|
| app | `lasuite/impress-frontend` | https://github.com/suitenumerique/docs/releases |
| backend | `lasuite/impress-backend` | https://github.com/suitenumerique/docs/releases |
| celery | `lasuite/impress-backend` | https://github.com/suitenumerique/docs/releases |
| y-provider | `lasuite/impress-y-provider` | https://github.com/suitenumerique/docs/releases |
| db | `pgautoupgrade/pgautoupgrade` | https://github.com/pgautoupgrade/docker-pgautoupgrade/releases |
| redis | `redis` | https://github.com/redis/redis/releases |
| minio | `minio/minio` | https://github.com/minio/minio/releases |
| minio-bootstrap | `minio/mc` | https://github.com/minio/mc/releases |
| web | `nginx` | https://nginx.org/en/CHANGES |
+8
View File
@@ -0,0 +1,8 @@
name = "lasuite-drive"
[dependencies]
requires = ["keycloak"]
[sso]
provider = "keycloak"
setup_script = "setup/sso_integration.py"
+53
View File
@@ -0,0 +1,53 @@
# La Suite Drive — First-Time Setup
## Prerequisites
- DNS: `lasuite-drive.<domain_suffix>` must resolve to the server
- **Keycloak** must be deployed and running (dependency)
## Steps
1. **Create the app:**
```bash
abra app new lasuite-drive --server <SERVER> --domain lasuite-drive.<DOMAIN_SUFFIX> --no-input
```
2. **Generate secrets:**
```bash
abra app secret generate lasuite-drive.<DOMAIN_SUFFIX> --all -m --no-input
```
Save output to `recipe-info/testsecrets/lasuite-drive.<DOMAIN_SUFFIX>`.
3. **Deploy:**
```bash
abra app deploy lasuite-drive.<DOMAIN_SUFFIX> --chaos --force --no-input
```
4. **Post-deploy — Migrations:**
```bash
script -qefc 'abra app cmd lasuite-drive.<DOMAIN_SUFFIX> backend migrate --no-input' /dev/null
```
5. **Post-deploy — Minio buckets:**
```bash
abra app restart lasuite-drive.<DOMAIN_SUFFIX> minio-createbuckets --no-input
```
This will appear to hang — that is expected. Wait for it to complete.
6. **Keycloak SSO integration:**
```bash
python3 recipe-info/lasuite-drive/setup_keycloak_integration.py
```
This creates a `lasuite-drive` realm, OIDC client, and test user in Keycloak. It also inserts the client secret and updates the env file.
7. **Redeploy with SSO config:**
```bash
abra app deploy lasuite-drive.<DOMAIN_SUFFIX> --chaos --force --no-input
```
8. **Verify:** curl `https://lasuite-drive.<DOMAIN_SUFFIX>` returns HTTP 200.
## Notes
- Credentials are saved to `recipe-info/lasuite-drive/keycloak-test-credentials.<DOMAIN_SUFFIX>.toml`.
- OIDC test user: `testuser` / `testpass123`.
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Setup Keycloak OIDC integration for La Suite Drive.
Creates a Keycloak realm, OIDC client, and test user, then inserts
the client secret and updates the Drive env file with OIDC settings.
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from lib.abra import app_secret_insert
from lib.env import apply_env_overrides, get_abra_env_path, read_env_file
from lib.keycloak import KeycloakAdmin
from lib.models import load_default_instance
from lib.secrets import load_secrets
# Configuration
REALM = "lasuite-drive"
CLIENT_ID = "drive"
TEST_USER = "testuser"
TEST_PASS = "testpass123"
TEST_EMAIL = f"{TEST_USER}@test.example.com"
def main():
inst = load_default_instance()
drive_domain = inst.default_domain("lasuite-drive")
kc_domain = inst.default_domain("keycloak")
# Get Keycloak admin password from synced secrets
kc_secrets = load_secrets(kc_domain)
kc_admin_pass = kc_secrets["admin_password"]
kc = KeycloakAdmin(f"https://{kc_domain}", "admin", kc_admin_pass)
# Step 1: Create realm
kc.ensure_realm(REALM)
# Step 2: Create OIDC client
_, client_secret = kc.ensure_client(
REALM, CLIENT_ID,
redirect_uris=[f"https://{drive_domain}/*"],
web_origins=[f"https://{drive_domain}"],
)
# Step 3: Create test user
kc.ensure_user(REALM, TEST_USER, TEST_EMAIL, TEST_PASS)
# Step 4: Insert client secret via abra
print("=== Insert OIDC client secret into Drive ===", flush=True)
env_path = get_abra_env_path(inst.server, drive_domain)
env_data = read_env_file(env_path)
current_version = env_data.get("SECRET_OIDC_RPCS_VERSION", "v1")
next_num = int(current_version.lstrip("v")) + 1
next_version = f"v{next_num}"
print(f" Current secret version: {current_version}", flush=True)
print(f" Inserting as: {next_version}", flush=True)
app_secret_insert(drive_domain, "oidc_rpcs", next_version, client_secret)
# Step 5: Update Drive env with OIDC settings
print("=== Update Drive OIDC settings in env file ===", flush=True)
apply_env_overrides(env_path, {
"SECRET_OIDC_RPCS_VERSION": next_version,
"OIDC_REALM": REALM,
"AUTH_DOMAIN": kc_domain,
"OIDC_RP_CLIENT_ID": CLIENT_ID,
})
# Step 6: Write credentials file
script_dir = os.path.dirname(os.path.abspath(__file__))
creds_file = os.path.join(script_dir, f"keycloak-test-credentials.{inst.domain_suffix}.toml")
print(f"=== Write credentials to {creds_file} ===", flush=True)
with open(creds_file, "w") as f:
f.write(f'# Keycloak OIDC credentials for lasuite-drive test instance\n')
f.write(f'#\n')
f.write(f'# Keycloak instance: {kc_domain}\n')
f.write(f'# Realm: {REALM}\n')
f.write(f'# Created by: setup_keycloak_integration.py\n')
f.write(f'\n')
f.write(f'# Keycloak admin (master realm)\n')
f.write(f'kc_admin_user = "admin"\n')
f.write(f'kc_admin_pass = "{kc_admin_pass}"\n')
f.write(f'\n')
f.write(f'# OIDC client\n')
f.write(f'kc_realm = "{REALM}"\n')
f.write(f'kc_client_id = "{CLIENT_ID}"\n')
f.write(f'kc_client_secret = "{client_secret}"\n')
f.write(f'\n')
f.write(f'# Test user (in {REALM} realm)\n')
f.write(f'kc_test_user = "{TEST_USER}"\n')
f.write(f'kc_test_pass = "{TEST_PASS}"\n')
f.write(f'kc_test_email = "{TEST_EMAIL}"\n')
print(f" Written to {creds_file}", flush=True)
print("", flush=True)
print("=== Keycloak integration setup complete ===", flush=True)
print("", flush=True)
print("Next steps:", flush=True)
print(f" 1. Redeploy Drive: abra app deploy {drive_domain} --chaos --force --no-input", flush=True)
print(f" 2. Run migrations: script -qefc 'abra app cmd {drive_domain} backend migrate --no-input' /dev/null", flush=True)
print(f" 3. Run OIDC test: python3 recipe-info/lasuite-drive/tests/oidc_login.py", flush=True)
if __name__ == "__main__":
main()
+100
View File
@@ -0,0 +1,100 @@
# La Suite Drive Tests
## Requires
- keycloak
## Target
- **URL:** https://lasuite-drive.<DOMAIN_SUFFIX>
- **Keycloak:** https://keycloak.<DOMAIN_SUFFIX> (realm: `lasuite-drive`)
## Prerequisites
Keycloak (`keycloak.<DOMAIN_SUFFIX>`) must be deployed before testing lasuite-drive. The OIDC login test and any manual authentication testing depend on it. If Keycloak is not running, deploy it first with `/recipe-deploy keycloak`.
## Automated Checks
Run the scripts in `tests/` to perform automated testing:
- `tests/health_check.py` — Confirms the instance is reachable and returns HTTP 200.
- `tests/wopi_configured.py` — Verifies WOPI discovery endpoints are reachable:
1. Checks Collabora discovery endpoint returns valid WOPI XML
2. Checks OnlyOffice discovery endpoint returns valid WOPI XML
- `tests/wopi_on_startup.py` — Confirms WOPI configuration runs automatically on celery worker startup:
1. Checks celery worker container logs for the entrypoint WOPI trigger message
2. Verifies the trigger completed without errors
- `tests/celery_beat_wopi.py` — Verifies Celery Beat WOPI scheduling:
1. Confirms the `celery-beat` service is running
2. Confirms the old `scheduler` service is removed
3. Waits up to 90s for the WOPI configuration task to fire and checks logs via SSH
**Thorough mode only.** This test sleeps ~15-90 seconds waiting for the Celery Beat scheduler to fire. Skip in quick mode. Requires the test instance to have `WOPI_CONFIGURATION_CRONTAB_MINUTE=*` and `WOPI_CONFIGURATION_CRONTAB_HOUR=*` set so the task fires every minute.
- `tests/oidc_login.py` — Tests the full OIDC authentication flow end-to-end:
1. Verifies Drive's `/api/v1.0/authenticate/` redirects to Keycloak
2. Obtains an access token from Keycloak via direct access grant (password flow)
3. Calls Drive's `/api/v1.0/users/me/` with the token and verifies the correct user is returned
This test reads credentials from `keycloak-test-credentials.<DOMAIN_SUFFIX>.toml`.
## Keycloak OIDC Integration
La Suite Drive **requires** an OIDC provider. The test instance uses Keycloak at `keycloak.<DOMAIN_SUFFIX>`.
### Setup
Run `setup_keycloak_integration.py` to configure everything automatically. The script:
1. Creates a `lasuite-drive` realm in Keycloak
2. Creates a `drive` OIDC client (confidential, standard flow + direct access grants)
3. Creates a test user (`testuser` / `testpass123`)
4. Inserts the OIDC client secret into the Drive app via `abra app secret insert`
5. Updates the Drive env file with `OIDC_REALM`, `AUTH_DOMAIN`, `OIDC_RP_CLIENT_ID`
6. Writes all credentials to `keycloak-test-credentials.<DOMAIN_SUFFIX>.toml`
After running the setup script, redeploy Drive:
```
abra app deploy lasuite-drive.<DOMAIN_SUFFIX> --chaos --force --no-input
```
The script is idempotent — it skips resources that already exist and resets the test user password.
### Credentials
All Keycloak credentials are stored in `keycloak-test-credentials.<DOMAIN_SUFFIX>.toml` (sourceable):
| Variable | Description |
|----------|-------------|
| `KC_ADMIN_USER` / `KC_ADMIN_PASS` | Keycloak admin (master realm) |
| `KC_REALM` | Keycloak realm name (`lasuite-drive`) |
| `KC_CLIENT_ID` / `KC_CLIENT_SECRET` | OIDC client ID and secret |
| `KC_TEST_USER` / `KC_TEST_PASS` | Test user credentials |
| `KC_TEST_EMAIL` | Test user email |
### Key Endpoints
| Endpoint | Purpose |
|----------|---------|
| `https://lasuite-drive.<DOMAIN_SUFFIX>/api/v1.0/authenticate/` | Initiates OIDC login (302 redirect to Keycloak) |
| `https://lasuite-drive.<DOMAIN_SUFFIX>/api/v1.0/callback/` | OIDC callback (Keycloak redirects here after login) |
| `https://keycloak.<DOMAIN_SUFFIX>/realms/lasuite-drive/protocol/openid-connect/token` | Keycloak token endpoint |
## Post-Deploy Steps
After deploying Drive for the first time, run:
1. **Migrations:** `script -qefc 'abra app cmd lasuite-drive.<DOMAIN_SUFFIX> backend migrate --no-input' /dev/null`
2. **Minio buckets:** `abra app restart lasuite-drive.<DOMAIN_SUFFIX> minio-createbuckets --no-input` (will appear to hang — this is expected)
3. **Keycloak integration:** `python3 setup_keycloak_integration.py` then redeploy
## Manual Verification
1. Open https://lasuite-drive.<DOMAIN_SUFFIX> in a browser.
2. Confirm the La Suite Drive landing page loads without errors.
3. Click "Login" and verify the OIDC redirect to Keycloak works.
4. Log in with test credentials (`testuser` / `testpass123`).
5. After logging in, verify you can create and open a document.
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Celery Beat WOPI scheduling test."""
import argparse
import json
import os
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import run, resolve_domain, resolve_server
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
domain = args.domain or resolve_domain('lasuite-drive')
server = resolve_server()
print("Testing Celery Beat WOPI scheduling")
print()
# Step 1: Verify celery-beat service is running
print("Step 1: Checking celery-beat service is running ...")
result = run(f"abra app ps {domain} --chaos --no-input -m", check=False, timeout=60)
try:
data = json.loads(result.stdout)
if 'celery-beat' in data:
print(" PASS: celery-beat service is running")
else:
print(" FAIL: celery-beat service not found in running services")
sys.exit(1)
except (json.JSONDecodeError, TypeError):
print(" FAIL: Could not parse service list")
print(f" Output: {result.stdout}")
sys.exit(1)
# Step 2: Verify old scheduler service is gone
print("Step 2: Checking old scheduler service is removed ...")
if 'scheduler' in data:
print(" FAIL: old scheduler service still appears in service list")
sys.exit(1)
print(" PASS: old scheduler service is not running")
# Step 3: Wait for WOPI task to fire and check logs
print("Step 3: Waiting for WOPI configuration task to fire (up to 90s) ...")
print(" (test instance should have WOPI_CONFIGURATION_CRONTAB_MINUTE=* for every-minute execution)")
found_task = False
beat_logs = ""
celery_logs = ""
for i in range(1, 7):
print(f" Checking logs (attempt {i}/6, waiting 15s) ...")
time.sleep(15)
# Check celery-beat logs for the task being sent
result = run(
f"ssh {server} 'docker logs $(docker ps -q -f name=lasuite-drive.*celery-beat) 2>&1 | tail -20'",
check=False, timeout=30,
)
beat_logs = result.stdout
if any("sending due task configure_wopi_clients" in line.lower() for line in beat_logs.split('\n')):
print(" PASS: Celery Beat is sending the WOPI configuration task")
found_task = True
break
# Check celery worker logs for task execution
result = run(
f"ssh {server} 'docker logs $(docker ps -q -f name=lasuite-drive.*_celery\\.) 2>&1 | grep -i configure_wopi | tail -5'",
check=False, timeout=30,
)
celery_logs = result.stdout
if "configure_wopi" in celery_logs.lower() and "succeeded" in celery_logs.lower():
print(" PASS: WOPI configuration task executed successfully on celery worker")
found_task = True
break
if not found_task:
print(" FAIL: WOPI configuration task not found in logs after 90 seconds")
sys.exit(1)
print()
print("PASS: Celery Beat WOPI scheduling test passed")
if __name__ == '__main__':
main()
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Health check for La Suite Drive."""
import argparse
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import http_get, resolve_domain
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
domain = args.domain or resolve_domain('lasuite-drive')
url = f"https://{domain}"
print(f"Checking La Suite Drive at {url} ...")
status, _ = http_get(url)
if status == 200:
print(f"PASS: La Suite Drive returned HTTP {status}")
else:
print(f"FAIL: La Suite Drive returned HTTP {status} (expected 200)")
sys.exit(1)
if __name__ == '__main__':
main()
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""OIDC integration test for La Suite Drive + Keycloak."""
import argparse
import os
import sys
import urllib.request
import urllib.error
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import (
http_get, http_post, load_toml_credentials, resolve_domain,
)
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
recipe_dir = os.path.join(os.path.dirname(__file__), '..')
creds = load_toml_credentials(recipe_dir, 'keycloak')
if creds is None:
print("FAIL: Credentials file not found: keycloak-test-credentials.<domain_suffix>.toml")
print("Run setup_keycloak_integration.py first.")
sys.exit(1)
drive_domain = args.domain or resolve_domain('lasuite-drive')
kc_domain = resolve_domain('keycloak')
drive_url = f"https://{drive_domain}"
kc_url = f"https://{kc_domain}"
print("Testing OIDC integration: La Suite Drive <-> Keycloak")
print()
# Step 1: Verify Drive redirects to Keycloak
print("Step 1: Checking Drive OIDC redirect ...")
try:
req = urllib.request.Request(f"{drive_url}/api/v1.0/authenticate/")
opener = urllib.request.build_opener(NoRedirectHandler())
resp = opener.open(req, timeout=15)
redirect_url = resp.headers.get('Location', '')
except urllib.error.HTTPError as e:
redirect_url = e.headers.get('Location', '') if e.headers else ''
expected_prefix = f"{kc_url}/realms/{creds['kc_realm']}/protocol/openid-connect/auth"
if expected_prefix in (redirect_url or ''):
print(f" PASS: Drive redirects to Keycloak realm '{creds['kc_realm']}'")
else:
print(f" FAIL: Expected redirect to Keycloak, got: {redirect_url}")
sys.exit(1)
# Step 2: Obtain token from Keycloak
print("Step 2: Obtaining token from Keycloak ...")
token_url = f"{kc_url}/realms/{creds['kc_realm']}/protocol/openid-connect/token"
status, data = http_post(
token_url,
data={
"grant_type": "password",
"client_id": creds["kc_client_id"],
"client_secret": creds["kc_client_secret"],
"username": creds["kc_test_user"],
"password": creds["kc_test_pass"],
"scope": "openid email",
},
content_type="application/x-www-form-urlencoded",
)
access_token = (data or {}).get("access_token", "")
if not access_token:
error = (data or {}).get("error_description", (data or {}).get("error", "unknown"))
print(f" FAIL: Could not obtain token from Keycloak: {error}")
sys.exit(1)
print(f" PASS: Obtained access token from Keycloak ({len(access_token)} chars)")
# Step 3: Use token to access Drive API
print("Step 3: Accessing Drive API with Keycloak token ...")
status, body = http_get(
f"{drive_url}/api/v1.0/users/me/",
headers={"Authorization": f"Bearer {access_token}"},
)
if status != 200:
print(f" FAIL: Drive API returned HTTP {status} (expected 200)")
sys.exit(1)
user_email = (body or {}).get("email", "")
expected_email = f"{creds['kc_test_user']}@test.example.com"
if user_email == expected_email:
print(f" PASS: Drive API returned user with email '{user_email}'")
else:
print(f" FAIL: Expected email '{expected_email}', got '{user_email}'")
sys.exit(1)
print()
print("PASS: OIDC integration test passed — Drive authenticates via Keycloak")
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise urllib.error.HTTPError(newurl, code, msg, headers, fp)
if __name__ == '__main__':
main()
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""WOPI configuration verification test."""
import argparse
import os
import sys
import urllib.request
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import resolve_domain
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
domain = args.domain or resolve_domain('lasuite-drive')
collabora_url = f"https://collabora.{domain}/hosting/discovery"
onlyoffice_url = f"https://onlyoffice.{domain}/hosting/discovery"
print("Testing WOPI configuration")
print()
# Step 1: Check Collabora discovery endpoint
print("Step 1: Checking Collabora discovery endpoint ...")
try:
req = urllib.request.Request(collabora_url)
with urllib.request.urlopen(req, timeout=15) as resp:
body = resp.read().decode('utf-8', errors='replace')
status = resp.getcode()
except Exception as e:
print(f" FAIL: Collabora discovery check failed: {e}")
sys.exit(1)
if status == 200 and 'wopi-discovery' in body:
print(" PASS: Collabora discovery returns valid WOPI XML")
else:
print(f" FAIL: Collabora discovery check failed (HTTP {status})")
sys.exit(1)
# Step 2: Check OnlyOffice discovery endpoint
print("Step 2: Checking OnlyOffice discovery endpoint ...")
try:
req = urllib.request.Request(onlyoffice_url)
with urllib.request.urlopen(req, timeout=15) as resp:
body = resp.read().decode('utf-8', errors='replace')
status = resp.getcode()
except Exception as e:
print(f" FAIL: OnlyOffice discovery check failed: {e}")
sys.exit(1)
if status == 200 and 'wopi-discovery' in body:
print(" PASS: OnlyOffice discovery returns valid WOPI XML")
else:
print(f" FAIL: OnlyOffice discovery check failed (HTTP {status})")
sys.exit(1)
print()
print("PASS: WOPI discovery endpoints are reachable and returning valid XML")
if __name__ == '__main__':
main()
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""WOPI on startup test — verifies celery worker runs WOPI config on startup."""
import argparse
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import run, resolve_server
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
server = resolve_server()
print("Testing WOPI configuration on startup")
print()
# Check celery worker logs for the entrypoint WOPI trigger message
print("Step 1: Checking celery worker logs for WOPI startup trigger ...")
result = run(
f'ssh {server} "docker ps -f name=lasuite-drive --format \'{{{{.Names}}}}\' | grep -E \'celery\\.\' | grep -v beat"',
check=False, timeout=30,
)
celery_name = result.stdout.strip().split('\n')[0].strip()
result = run(
f'ssh {server} "docker logs {celery_name} 2>&1 | head -5"',
check=False, timeout=30,
)
celery_logs = result.stdout
if "running WOPI configuration on startup" in celery_logs:
print(" PASS: Celery worker ran WOPI configuration on startup")
else:
print(" FAIL: WOPI startup trigger not found in celery worker logs")
print(f" Logs: {celery_logs}")
sys.exit(1)
# Verify it didn't fail
if "WOPI configuration failed" in celery_logs:
print(" FAIL: WOPI configuration failed on startup")
print(f" Logs: {celery_logs}")
sys.exit(1)
else:
print(" PASS: WOPI configuration completed without errors")
print()
print("PASS: WOPI on startup test passed")
if __name__ == '__main__':
main()
+22
View File
@@ -0,0 +1,22 @@
# La Suite Drive Upstream
## Main Project
- **Repository:** https://github.com/suitenumerique/drive
- **Releases:** https://github.com/suitenumerique/drive/releases
## Images
| Service | Image | Release Notes |
|---------|-------|---------------|
| app | `lasuite/drive-frontend` | https://github.com/suitenumerique/drive/releases |
| backend | `lasuite/drive-backend` | https://github.com/suitenumerique/drive/releases |
| celery | `lasuite/drive-backend` | https://github.com/suitenumerique/drive/releases |
| scheduler | `lasuite/drive-backend` | https://github.com/suitenumerique/drive/releases |
| collabora | `collabora/code` | https://github.com/CollaboraOnline/online/releases |
| onlyoffice | `onlyoffice/documentserver-de` | https://github.com/ONLYOFFICE/DocumentServer/releases |
| db | `postgres` | https://www.postgresql.org/docs/release/ |
| redis | `redis` | https://github.com/redis/redis/releases |
| minio | `minio/minio` | https://github.com/minio/minio/releases |
| web | `nginx` | https://nginx.org/en/CHANGES |
| mailcatcher | `sj26/mailcatcher` | https://github.com/sj26/mailcatcher/releases |
+8
View File
@@ -0,0 +1,8 @@
name = "lasuite-meet"
[dependencies]
requires = ["keycloak"]
[sso]
provider = "keycloak"
setup_script = "setup/sso_integration.py"
+46
View File
@@ -0,0 +1,46 @@
# La Suite Meet — First-Time Setup
## Prerequisites
- DNS: `lasuite-meet.<domain_suffix>` must resolve to the server
- DNS: `livekit-meet.<domain_suffix>` must resolve to the server (LiveKit signaling)
- **Keycloak** must be deployed and running (dependency)
- Firewall must allow TCP 7881, UDP 7882 (WebRTC), and UDP 443 (TURN relay)
## Steps
1. **Create the app:**
```bash
abra app new lasuite-meet --server <SERVER> --domain lasuite-meet.<DOMAIN_SUFFIX> --no-input
```
2. **Generate secrets:**
```bash
abra app secret generate lasuite-meet.<DOMAIN_SUFFIX> --all -m --no-input
```
Save output to `recipe-info/testsecrets/lasuite-meet.<DOMAIN_SUFFIX>`.
3. **Deploy:**
```bash
abra app deploy lasuite-meet.<DOMAIN_SUFFIX> --chaos --force --no-input
```
4. **Keycloak SSO integration:**
```bash
python3 recipe-info/lasuite-meet/setup_keycloak_integration.py
```
This creates a `lasuite-meet` realm, OIDC client, and two test users in Keycloak. It also inserts the client secret and updates the env file.
5. **Redeploy with SSO config:**
```bash
abra app deploy lasuite-meet.<DOMAIN_SUFFIX> --chaos --force --no-input
```
6. **Verify:** curl `https://lasuite-meet.<DOMAIN_SUFFIX>` returns HTTP 200.
## Notes
- lasuite-meet has no published versions yet — must use `--chaos` for all commands.
- Credentials are saved to `recipe-info/lasuite-meet/keycloak-test-credentials.<DOMAIN_SUFFIX>.toml`.
- OIDC test users: `testuser` / `testpass123` and `testuser2` / `testpass123`.
- TURN relay requires the server to have a direct public IP (not behind NAT). See test.md for details.
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Setup Keycloak OIDC integration for La Suite Meet.
Creates a Keycloak realm, OIDC client, and two test users, then inserts
the client secret and updates the Meet env file with OIDC settings.
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from lib.abra import app_secret_insert
from lib.env import apply_env_overrides, get_abra_env_path, read_env_file
from lib.keycloak import KeycloakAdmin
from lib.models import load_default_instance
from lib.secrets import load_secrets
# Configuration
REALM = "lasuite-meet"
CLIENT_ID = "meet"
TEST_USER = "testuser"
TEST_PASS = "testpass123"
TEST_EMAIL = f"{TEST_USER}@test.example.com"
TEST_USER2 = "testuser2"
TEST_PASS2 = "testpass456"
TEST_EMAIL2 = f"{TEST_USER2}@test.example.com"
def main():
inst = load_default_instance()
meet_domain = inst.default_domain("lasuite-meet")
kc_domain = inst.default_domain("keycloak")
# Get Keycloak admin password from synced secrets
kc_secrets = load_secrets(kc_domain)
kc_admin_pass = kc_secrets["admin_password"]
kc = KeycloakAdmin(f"https://{kc_domain}", "admin", kc_admin_pass)
# Step 1: Create realm
kc.ensure_realm(REALM)
# Step 2: Create OIDC client
_, client_secret = kc.ensure_client(
REALM, CLIENT_ID,
redirect_uris=[f"https://{meet_domain}/*"],
web_origins=[f"https://{meet_domain}"],
)
# Step 3: Create test users
kc.ensure_user(REALM, TEST_USER, TEST_EMAIL, TEST_PASS)
kc.ensure_user(REALM, TEST_USER2, TEST_EMAIL2, TEST_PASS2,
last_name="User Two")
# Step 4: Insert client secret via abra
print("=== Insert OIDC client secret into Meet ===", flush=True)
env_path = get_abra_env_path(inst.server, meet_domain)
env_data = read_env_file(env_path)
current_version = env_data.get("SECRET_OIDC_RPCS_VERSION", "v1")
next_num = int(current_version.lstrip("v")) + 1
next_version = f"v{next_num}"
print(f" Current secret version: {current_version}", flush=True)
print(f" Inserting as: {next_version}", flush=True)
app_secret_insert(meet_domain, "oidc_rpcs", next_version, client_secret)
# Step 5: Update Meet env with OIDC settings
print("=== Update Meet OIDC settings in env file ===", flush=True)
apply_env_overrides(env_path, {
"SECRET_OIDC_RPCS_VERSION": next_version,
"OIDC_REALM": REALM,
"AUTH_DOMAIN": kc_domain,
"OIDC_RP_CLIENT_ID": CLIENT_ID,
})
# Step 6: Write credentials file
script_dir = os.path.dirname(os.path.abspath(__file__))
creds_file = os.path.join(script_dir, f"keycloak-test-credentials.{inst.domain_suffix}.toml")
print(f"=== Write credentials to {creds_file} ===", flush=True)
with open(creds_file, "w") as f:
f.write(f'# Keycloak OIDC credentials for lasuite-meet test instance\n')
f.write(f'#\n')
f.write(f'# Keycloak instance: {kc_domain}\n')
f.write(f'# Realm: {REALM}\n')
f.write(f'# Created by: setup_keycloak_integration.py\n')
f.write(f'\n')
f.write(f'# Keycloak admin (master realm)\n')
f.write(f'kc_admin_user = "admin"\n')
f.write(f'kc_admin_pass = "{kc_admin_pass}"\n')
f.write(f'\n')
f.write(f'# OIDC client\n')
f.write(f'kc_realm = "{REALM}"\n')
f.write(f'kc_client_id = "{CLIENT_ID}"\n')
f.write(f'kc_client_secret = "{client_secret}"\n')
f.write(f'\n')
f.write(f'# Test user 1 (in {REALM} realm)\n')
f.write(f'kc_test_user = "{TEST_USER}"\n')
f.write(f'kc_test_pass = "{TEST_PASS}"\n')
f.write(f'kc_test_email = "{TEST_EMAIL}"\n')
f.write(f'\n')
f.write(f'# Test user 2 (in {REALM} realm)\n')
f.write(f'kc_test_user2 = "{TEST_USER2}"\n')
f.write(f'kc_test_pass2 = "{TEST_PASS2}"\n')
f.write(f'kc_test_email2 = "{TEST_EMAIL2}"\n')
print(f" Written to {creds_file}", flush=True)
print("", flush=True)
print("=== Keycloak integration setup complete ===", flush=True)
print("", flush=True)
print("Next steps:", flush=True)
print(f" 1. Redeploy Meet: abra app deploy {meet_domain} --chaos --force --no-input", flush=True)
print(f" 2. Run OIDC test: python3 recipe-info/lasuite-meet/tests/oidc_login.py", flush=True)
if __name__ == "__main__":
main()
+77
View File
@@ -0,0 +1,77 @@
# La Suite Meet — Test Documentation
## Test instance
- **Domain:** lasuite-meet.<DOMAIN_SUFFIX>
- **LiveKit domain:** livekit-meet.<DOMAIN_SUFFIX>
- **Keycloak:** keycloak.<DOMAIN_SUFFIX> (shared with other lasuite recipes)
## Services
| Service | Health check | Notes |
|---------|-------------|-------|
| app (frontend) | curl http://localhost:8080 | React SPA served by nginx |
| backend | python manage.py check | Django + Gunicorn on port 8000 |
| celery | celery inspect ping | Async task worker |
| db | pg_isready | PostgreSQL 18 |
| redis | redis-cli ping | Cache + Celery broker + LiveKit coordination |
| livekit | N/A (external ports) | WebRTC SFU on 7880 (signaling), 7881 (TCP), 7882 (UDP) |
| web (nginx) | curl http://localhost:8083 | Reverse proxy |
## Automated tests
| Script | What it tests |
|--------|--------------|
| `tests/health_check.py` | HTTP 200 from the main domain |
| `tests/oidc_login.py` | Full OIDC flow: redirect to Keycloak, obtain token, call API |
| `tests/meeting_flow.py` | Two users create, join, and delete a room; verifies LiveKit tokens |
| `tests/webrtc-media.py` | End-to-end WebRTC: TURN/STUN probe, two users publish/receive audio via LiveKit SDK |
### Network requirements for webrtc-media.py
The WebRTC media test requires:
- Python 3 with `livekit` and `requests` packages (`pip install livekit requests`)
- Either direct ICE connectivity (TCP 7881 / UDP 7882) or TURN relay (UDP 443)
- With TURN enabled, clients behind CGNAT/symmetric NAT can connect via relay
## Manual checks
- Visit `https://lasuite-meet.<DOMAIN_SUFFIX>` — should show Meet login page
- Click login — should redirect to Keycloak
- After OIDC login — should be able to create/join a meeting room
- Check LiveKit signaling: `wss://livekit-meet.<DOMAIN_SUFFIX>` should be reachable
## TURN server
TURN is enabled by default via `compose.turn.yml` and `LIVEKIT_TURN_ENABLED=true`. It publishes UDP 443 on the host for TURN relay traffic, improving connectivity for users behind CGNAT/symmetric NAT.
### Verifying TURN
1. Check LiveKit logs for TURN startup:
```bash
ssh <server> "docker service logs <stack>_livekit --since 5m 2>&1 | grep -i turn"
```
2. Verify UDP 443 is listening on the server:
```bash
ssh <server> "ss -ulnp | grep 443"
```
3. Run `webrtc-media.py` — it sends a STUN Binding Request to UDP 443 and verifies a response
4. Check LiveKit logs for `connectionType` to confirm relay vs direct ICE
### Disabling TURN
Remove `compose.turn.yml` from `COMPOSE_FILE` in the app `.env` and set `LIVEKIT_TURN_ENABLED=false`.
### TURN and servers behind NAT
LiveKit's built-in TURN server requires the server to have a **direct public IP**. On servers behind NAT (where `LIVEKIT_NODE_IP` is the gateway's public IP, not the server's own), TURN relay traffic hits a "hairpin NAT" problem: the TURN relay inside the container sends to the public IP, but the packet exits through the NAT gateway which doesn't route it back.
**Symptoms:** TURN allocations succeed (relay candidates appear in LiveKit logs), but ICE connection never succeeds (`connectionType: "unknown"`).
**Workaround for NAT servers:** Use an external TURN server (e.g., coturn) instead of LiveKit's built-in TURN, or configure the NAT gateway to do hairpin NAT for the relay ports.
## Dependencies
- Keycloak must be deployed and configured (run `setup_keycloak_integration.py`)
- Firewall must allow TCP 7881 and UDP 7882 on the server
- For TURN: firewall must also allow UDP 443 and UDP 30000-30009 (relay ports)
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1771848320,
"narHash": "sha256-0MAd+0mun3K/Ns8JATeHT1sX28faLII5hVLq0L3BdZU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "2fc6539b481e1d2569f25f8799236694180c0993",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+40
View File
@@ -0,0 +1,40 @@
{
description = "La Suite Meet test environment";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { nixpkgs, ... }:
let
systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f {
pkgs = import nixpkgs { inherit system; };
});
in {
devShells = forAllSystems ({ pkgs }: {
default = pkgs.mkShell {
packages = with pkgs; [
(python3.withPackages (ps: with ps; [
requests
numpy
pip
]))
libva
];
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.libva ];
shellHook = ''
export VENV_DIR="$PWD/.venv"
if [ ! -d "$VENV_DIR" ]; then
echo "Creating venv and installing livekit SDK..."
python3 -m venv "$VENV_DIR" --system-site-packages
"$VENV_DIR/bin/pip" install --quiet livekit
fi
source "$VENV_DIR/bin/activate"
echo "La Suite Meet test shell ready"
echo " python3 webrtc-media.py"
'';
};
});
};
}
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Health check for La Suite Meet."""
import argparse
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import http_get, resolve_domain
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
domain = args.domain or resolve_domain('lasuite-meet')
url = f"https://{domain}"
print(f"Checking La Suite Meet at {url} ...")
status, _ = http_get(url)
if status == 200:
print(f"PASS: La Suite Meet returned HTTP {status}")
else:
print(f"FAIL: La Suite Meet returned HTTP {status} (expected 200)")
sys.exit(1)
if __name__ == '__main__':
main()
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""Meeting flow test for La Suite Meet — create room, join as two users, clean up."""
import argparse
import base64
import json
import os
import sys
import time
import urllib.error
import urllib.request
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import (
http_get, http_post, load_toml_credentials, resolve_domain,
)
def get_meet_token(kc_url, creds, username, password):
"""Obtain a Keycloak token for the given user."""
status, data = http_post(
f"{kc_url}/realms/{creds['kc_realm']}/protocol/openid-connect/token",
data={
"grant_type": "password",
"client_id": creds["kc_client_id"],
"client_secret": creds["kc_client_secret"],
"username": username,
"password": password,
"scope": "openid email",
},
content_type="application/x-www-form-urlencoded",
)
return (data or {}).get("access_token", "")
def decode_jwt_payload(token):
"""Decode the payload segment of a JWT token."""
try:
payload_b64 = token.split('.')[1]
# Add padding
payload_b64 += '=' * (4 - len(payload_b64) % 4)
# Replace URL-safe chars
payload_b64 = payload_b64.replace('-', '+').replace('_', '/')
decoded = base64.b64decode(payload_b64)
return json.loads(decoded)
except Exception:
return {}
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
recipe_dir = os.path.join(os.path.dirname(__file__), '..')
creds = load_toml_credentials(recipe_dir, 'keycloak')
if creds is None:
print("FAIL: Credentials file not found: keycloak-test-credentials.<domain_suffix>.toml")
print("Run setup_keycloak_integration.py first.")
sys.exit(1)
meet_domain = args.domain or resolve_domain('lasuite-meet')
kc_domain = resolve_domain('keycloak')
meet_url = f"https://{meet_domain}"
kc_url = f"https://{kc_domain}"
room_name = f"autotest-{int(time.time())}"
print("Testing meeting flow: create room, join as two users, clean up")
print()
# Step 1: Authenticate both users via Keycloak
print("Step 1: Authenticating both users via Keycloak ...")
token_user1 = get_meet_token(kc_url, creds, creds["kc_test_user"], creds["kc_test_pass"])
if not token_user1:
print(f" FAIL: Could not authenticate {creds['kc_test_user']}")
sys.exit(1)
print(f" PASS: {creds['kc_test_user']} authenticated ({len(token_user1)} chars)")
token_user2 = get_meet_token(kc_url, creds, creds["kc_test_user2"], creds["kc_test_pass2"])
if not token_user2:
print(f" FAIL: Could not authenticate {creds['kc_test_user2']}")
sys.exit(1)
print(f" PASS: {creds['kc_test_user2']} authenticated ({len(token_user2)} chars)")
# Step 2: User 1 creates a room
print(f"Step 2: User 1 creates room '{room_name}' ...")
status, create_body = http_post(
f"{meet_url}/api/v1.0/rooms/",
data={"name": room_name, "access_level": "public"},
headers={"Authorization": f"Bearer {token_user1}"},
)
if status != 201:
print(f" FAIL: Room creation returned HTTP {status} (expected 201)")
sys.exit(1)
room_id = create_body["id"]
room_slug = create_body["slug"]
user1_lk_room = create_body["livekit"]["room"]
user1_lk_token = create_body["livekit"]["token"]
if not room_id or not user1_lk_token:
print(" FAIL: Room created but missing id or LiveKit token")
sys.exit(1)
print(f" PASS: Room created (id={room_id}, slug={room_slug})")
print(f" PASS: User 1 received LiveKit token (room={user1_lk_room})")
# Step 3: User 2 joins the room
print(f"Step 3: User 2 joins room '{room_slug}' ...")
status, join_body = http_get(
f"{meet_url}/api/v1.0/rooms/{room_id}/",
headers={"Authorization": f"Bearer {token_user2}"},
)
if status != 200:
print(f" FAIL: Room retrieval returned HTTP {status} (expected 200)")
# Clean up
req = urllib.request.Request(f"{meet_url}/api/v1.0/rooms/{room_id}/", method="DELETE")
req.add_header("Authorization", f"Bearer {token_user1}")
try:
urllib.request.urlopen(req, timeout=10)
except Exception:
pass
sys.exit(1)
user2_lk_room = (join_body or {}).get("livekit", {}).get("room", "")
user2_lk_token = (join_body or {}).get("livekit", {}).get("token", "")
if not user2_lk_token:
print(" FAIL: User 2 did not receive a LiveKit token")
# Clean up
req = urllib.request.Request(f"{meet_url}/api/v1.0/rooms/{room_id}/", method="DELETE")
req.add_header("Authorization", f"Bearer {token_user1}")
try:
urllib.request.urlopen(req, timeout=10)
except Exception:
pass
sys.exit(1)
print(f" PASS: User 2 received LiveKit token (room={user2_lk_room})")
# Step 4: Verify both tokens reference the same room
print("Step 4: Verifying LiveKit tokens ...")
if user1_lk_room != user2_lk_room:
print(f" FAIL: LiveKit room mismatch: user1={user1_lk_room} user2={user2_lk_room}")
sys.exit(1)
print(f" PASS: Both users have tokens for the same LiveKit room ({user1_lk_room})")
# Decode JWT payloads and verify distinct identities
user1_payload = decode_jwt_payload(user1_lk_token)
user2_payload = decode_jwt_payload(user2_lk_token)
user1_identity = user1_payload.get("sub", "")
user2_identity = user2_payload.get("sub", "")
if user1_identity and user2_identity and user1_identity != user2_identity:
print(f" PASS: Tokens have distinct identities (user1={user1_identity[:12]}..., user2={user2_identity[:12]}...)")
else:
print(" WARN: Could not verify distinct JWT identities (non-fatal)")
# Step 5: User 1 deletes the room
print("Step 5: User 1 deletes the room ...")
req = urllib.request.Request(f"{meet_url}/api/v1.0/rooms/{room_id}/", method="DELETE")
req.add_header("Authorization", f"Bearer {token_user1}")
try:
with urllib.request.urlopen(req, timeout=10) as resp:
delete_status = resp.getcode()
except urllib.error.HTTPError as e:
delete_status = e.code
if delete_status != 204:
print(f" FAIL: Room deletion returned HTTP {delete_status} (expected 204)")
sys.exit(1)
print(" PASS: Room deleted (HTTP 204)")
# Step 6: Verify room is gone
print("Step 6: Verifying room no longer exists ...")
status, _ = http_get(
f"{meet_url}/api/v1.0/rooms/{room_id}/",
headers={"Authorization": f"Bearer {token_user1}"},
)
if status == 404:
print(" PASS: Room returns 404 (deleted)")
elif status == 200:
print(" PASS: Room ID no longer resolves to original room")
else:
print(f" WARN: Unexpected HTTP {status} when checking deleted room (non-fatal)")
print()
print("PASS: Meeting flow test passed — two users can create, join, and clean up rooms")
if __name__ == '__main__':
main()
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""OIDC integration test for La Suite Meet + Keycloak."""
import argparse
import os
import sys
import urllib.request
import urllib.error
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import (
http_get, http_post, load_toml_credentials, resolve_domain,
)
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
recipe_dir = os.path.join(os.path.dirname(__file__), '..')
creds = load_toml_credentials(recipe_dir, 'keycloak')
if creds is None:
print("FAIL: Credentials file not found: keycloak-test-credentials.<domain_suffix>.toml")
print("Run setup_keycloak_integration.py first.")
sys.exit(1)
meet_domain = args.domain or resolve_domain('lasuite-meet')
kc_domain = resolve_domain('keycloak')
meet_url = f"https://{meet_domain}"
kc_url = f"https://{kc_domain}"
print("Testing OIDC integration: La Suite Meet <-> Keycloak")
print()
# Step 1: Verify Meet redirects to Keycloak
print("Step 1: Checking Meet OIDC redirect ...")
try:
req = urllib.request.Request(f"{meet_url}/api/v1.0/authenticate/")
opener = urllib.request.build_opener(NoRedirectHandler())
resp = opener.open(req, timeout=15)
redirect_url = resp.headers.get('Location', '')
except urllib.error.HTTPError as e:
redirect_url = e.headers.get('Location', '') if e.headers else ''
expected_prefix = f"{kc_url}/realms/{creds['kc_realm']}/protocol/openid-connect/auth"
if expected_prefix in (redirect_url or ''):
print(f" PASS: Meet redirects to Keycloak realm '{creds['kc_realm']}'")
else:
print(f" FAIL: Expected redirect to Keycloak, got: {redirect_url}")
sys.exit(1)
# Step 2: Obtain token from Keycloak
print("Step 2: Obtaining token from Keycloak ...")
token_url = f"{kc_url}/realms/{creds['kc_realm']}/protocol/openid-connect/token"
status, data = http_post(
token_url,
data={
"grant_type": "password",
"client_id": creds["kc_client_id"],
"client_secret": creds["kc_client_secret"],
"username": creds["kc_test_user"],
"password": creds["kc_test_pass"],
"scope": "openid email",
},
content_type="application/x-www-form-urlencoded",
)
access_token = (data or {}).get("access_token", "")
if not access_token:
error = (data or {}).get("error_description", (data or {}).get("error", "unknown"))
print(f" FAIL: Could not obtain token from Keycloak: {error}")
sys.exit(1)
print(f" PASS: Obtained access token from Keycloak ({len(access_token)} chars)")
# Step 3: Use token to access Meet API
print("Step 3: Accessing Meet API with Keycloak token ...")
status, body = http_get(
f"{meet_url}/api/v1.0/users/me/",
headers={"Authorization": f"Bearer {access_token}"},
)
if status != 200:
print(f" FAIL: Meet API returned HTTP {status} (expected 200)")
sys.exit(1)
user_email = (body or {}).get("email", "")
expected_email = f"{creds['kc_test_user']}@test.example.com"
if user_email == expected_email:
print(f" PASS: Meet API returned user with email '{user_email}'")
else:
print(f" FAIL: Expected email '{expected_email}', got '{user_email}'")
sys.exit(1)
print()
print("PASS: OIDC integration test passed — Meet authenticates via Keycloak")
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise urllib.error.HTTPError(newurl, code, msg, headers, fp)
if __name__ == '__main__':
main()
@@ -0,0 +1,413 @@
#!/usr/bin/env python3
"""
WebRTC media flow test for La Suite Meet + LiveKit.
Tests the full media path between two participants:
1. Preliminary connectivity checks (TCP 7881, UDP 7882, UDP 443 TURN, WSS signaling)
2. Both users authenticate via Keycloak OIDC
3. User 1 creates a room via the Meet API
4. User 2 joins the room
5. Both connect to LiveKit via the SDK
6. User 1 publishes a dummy audio track
7. User 2 verifies audio frames arrive
8. Clean up: disconnect both, delete the room
This verifies:
- WSS signaling through Traefik (port 7880)
- ICE negotiation (direct or TURN relay)
- Media transport over host-exposed ports (TCP 7881 / UDP 7882)
- TURN relay via UDP 443 for clients behind restrictive NATs
Network requirements:
The test can succeed via direct ICE (ports 7881/7882) or via TURN relay
(UDP 443). Users behind CGNAT/symmetric NAT will use TURN automatically.
The preliminary checks report which paths are available.
Prerequisites:
pip install livekit requests
Usage:
python3 webrtc-media.py
"""
import asyncio
import json
import os
import socket
import ssl
import struct
import sys
import time
from pathlib import Path
from urllib.parse import urlparse
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
import requests
from livekit import rtc
from utils.tests.helpers import resolve_domain
SAMPLE_RATE = 48000
NUM_CHANNELS = 1
SAMPLES_PER_CHANNEL = 480 # 10ms at 48kHz
TIMEOUT = 45 # seconds to wait for audio frames (allow time for ICE TCP fallback)
def load_credentials():
"""Load Keycloak test credentials from domain-specific TOML file."""
import tomllib
from utils.tests.helpers import resolve_domain_suffix
suffix = resolve_domain_suffix()
creds_file = Path(__file__).resolve().parent.parent / f"keycloak-test-credentials.{suffix}.toml"
if not creds_file.exists():
print(f"FAIL: Credentials file not found: {creds_file}")
print("Run setup_keycloak_integration.py first.")
sys.exit(1)
with open(creds_file, 'rb') as f:
return tomllib.load(f)
# ---------------------------------------------------------------------------
# Preliminary connectivity checks
# ---------------------------------------------------------------------------
def check_tcp(host, port, timeout=5):
"""Check if a TCP port is reachable."""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(timeout)
try:
s.connect((host, port))
s.close()
return True
except (socket.timeout, ConnectionRefusedError, OSError):
return False
def check_udp(host, port, timeout=5):
"""Check if a UDP port accepts packets (send-only, best effort)."""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(timeout)
try:
s.sendto(b"\x00", (host, port))
s.close()
return True
except OSError:
return False
def check_stun(host, port, timeout=5):
"""Send a STUN Binding Request and verify we get a valid response.
This proves the TURN/STUN server is actually listening and responding,
not just that a UDP send succeeded (which almost always does).
"""
txn_id = os.urandom(12)
# STUN Binding Request: type=0x0001, length=0, magic=0x2112A442
msg = struct.pack("!HHI", 0x0001, 0, 0x2112A442) + txn_id
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(timeout)
try:
s.sendto(msg, (host, port))
data, _ = s.recvfrom(1024)
except (socket.timeout, OSError):
return False
finally:
s.close()
if len(data) < 20:
return False
resp_type, _, magic = struct.unpack("!HHI", data[:8])
resp_txn = data[8:20]
# Valid STUN Binding Response: type=0x0101, correct magic + transaction ID
return resp_type == 0x0101 and magic == 0x2112A442 and resp_txn == txn_id
def check_wss(host, port=443, timeout=5):
"""Check if a WSS (TLS) connection can be established."""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(timeout)
ctx = ssl.create_default_context()
ss = ctx.wrap_socket(s, server_hostname=host)
try:
ss.connect((host, port))
ss.close()
return True
except (socket.timeout, ConnectionRefusedError, OSError, ssl.SSLError):
return False
def run_connectivity_checks(meet_domain, livekit_domain):
"""Run preliminary connectivity checks. Returns (all_critical_pass, turn_available)."""
server_ip = socket.gethostbyname(meet_domain)
livekit_ip = socket.gethostbyname(livekit_domain)
print(f" Server IP: {server_ip} ({meet_domain})")
print(f" LiveKit IP: {livekit_ip} ({livekit_domain})")
all_pass = True
turn_available = False
# HTTPS / Traefik
if check_tcp(server_ip, 443):
print(f" PASS: TCP {server_ip}:443 (HTTPS/Traefik)")
else:
print(f" FAIL: TCP {server_ip}:443 (HTTPS/Traefik) unreachable")
all_pass = False
# WSS signaling
if check_wss(livekit_domain):
print(f" PASS: WSS {livekit_domain}:443 (LiveKit signaling)")
else:
print(f" FAIL: WSS {livekit_domain}:443 (LiveKit signaling) unreachable")
all_pass = False
# LiveKit TCP media port
if check_tcp(server_ip, 7881):
print(f" PASS: TCP {server_ip}:7881 (LiveKit media/TCP)")
else:
print(f" WARN: TCP {server_ip}:7881 (LiveKit media/TCP) unreachable")
# LiveKit UDP media port
if check_udp(server_ip, 7882):
print(f" PASS: UDP {server_ip}:7882 (LiveKit media/UDP) send OK")
else:
print(f" WARN: UDP {server_ip}:7882 (LiveKit media/UDP) send failed")
# TURN/UDP port — use STUN probe to verify the server actually responds
if check_stun(server_ip, 443):
print(f" PASS: UDP {server_ip}:443 (TURN/UDP) STUN response received")
turn_available = True
else:
print(f" WARN: UDP {server_ip}:443 (TURN/UDP) no STUN response — TURN relay unavailable")
if turn_available:
print(" => TURN relay available — clients behind restrictive NATs can connect")
else:
print(" => TURN relay NOT available — only direct ICE connections will work")
return all_pass, turn_available
# ---------------------------------------------------------------------------
# API helpers
# ---------------------------------------------------------------------------
def get_oidc_token(kc_url, realm, client_id, client_secret, username, password):
"""Get an access token from Keycloak via direct access grant."""
resp = requests.post(
f"{kc_url}/realms/{realm}/protocol/openid-connect/token",
data={
"grant_type": "password",
"client_id": client_id,
"client_secret": client_secret,
"username": username,
"password": password,
"scope": "openid email",
},
)
resp.raise_for_status()
return resp.json()["access_token"]
def create_room(meet_url, token, room_name):
"""Create a public room. Returns (room_id, livekit_config)."""
resp = requests.post(
f"{meet_url}/api/v1.0/rooms/",
headers={"Authorization": f"Bearer {token}"},
json={"name": room_name, "access_level": "public"},
)
if resp.status_code != 201:
print(f" FAIL: Room creation returned HTTP {resp.status_code}: {resp.text}")
sys.exit(1)
data = resp.json()
return data["id"], data["livekit"]
def join_room(meet_url, token, room_id):
"""Join an existing room. Returns livekit config."""
resp = requests.get(
f"{meet_url}/api/v1.0/rooms/{room_id}/",
headers={"Authorization": f"Bearer {token}"},
)
resp.raise_for_status()
return resp.json()["livekit"]
def delete_room(meet_url, token, room_id):
"""Delete a room."""
requests.delete(
f"{meet_url}/api/v1.0/rooms/{room_id}/",
headers={"Authorization": f"Bearer {token}"},
)
# ---------------------------------------------------------------------------
# WebRTC test
# ---------------------------------------------------------------------------
async def push_audio(source, stop_event):
"""Push silent PCM frames until stopped."""
frame = rtc.AudioFrame.create(SAMPLE_RATE, NUM_CHANNELS, SAMPLES_PER_CHANNEL)
while not stop_event.is_set():
await source.capture_frame(frame)
async def run_webrtc_test(lk_url, token_a, token_b):
"""
Connect two participants to LiveKit. A publishes audio, B verifies reception.
Returns (success, frames_received, publisher_identity, error_message).
"""
ws_url = lk_url.replace("https://", "wss://").replace("http://", "ws://")
room_opts = rtc.RoomOptions(auto_subscribe=True)
room_a = rtc.Room()
room_b = rtc.Room()
stop_audio = asyncio.Event()
track_received = asyncio.Event()
result = {"frames": 0, "publisher": ""}
@room_b.on("track_subscribed")
def on_track_subscribed(track, publication, participant):
result["publisher"] = participant.identity
async def read_frames():
stream = rtc.AudioStream(track)
async for _ in stream:
result["frames"] += 1
if result["frames"] >= 5:
break
await stream.aclose()
track_received.set()
asyncio.ensure_future(read_frames())
try:
# Participant A connects and publishes
await room_a.connect(ws_url, token_a, options=room_opts)
source = rtc.AudioSource(SAMPLE_RATE, NUM_CHANNELS)
audio_track = rtc.LocalAudioTrack.create_audio_track("test-audio", source)
options = rtc.TrackPublishOptions()
options.source = rtc.TrackSource.SOURCE_MICROPHONE
await room_a.local_participant.publish_track(audio_track, options)
audio_task = asyncio.ensure_future(push_audio(source, stop_audio))
await asyncio.sleep(1)
# Participant B connects and waits for track
await room_b.connect(ws_url, token_b, options=room_opts)
await asyncio.wait_for(track_received.wait(), timeout=TIMEOUT)
return True, result["frames"], result["publisher"], ""
except asyncio.TimeoutError:
return False, 0, "", "timed out waiting for audio frames"
except rtc.ConnectError as e:
return False, 0, "", f"LiveKit connection failed: {e}"
except Exception as e:
return False, 0, "", f"unexpected error: {e}"
finally:
stop_audio.set()
try:
await room_b.disconnect()
except Exception:
pass
try:
await room_a.disconnect()
except Exception:
pass
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
creds = load_credentials()
meet_domain = resolve_domain("lasuite-meet")
# LiveKit signaling runs on its own subdomain (LIVEKIT_DOMAIN in the recipe
# .env), which is `livekit-meet.<suffix>` — not a `livekit.` prefix on the
# meet domain.
from utils.tests.helpers import resolve_domain_suffix
livekit_domain = f"livekit-meet.{resolve_domain_suffix()}"
meet_url = f"https://{meet_domain}"
kc_url = f"https://{resolve_domain('keycloak')}"
room_name = f"webrtc-test-{int(time.time())}"
print("Testing WebRTC media flow: publish audio, verify reception")
print()
# Preliminary checks
print("Step 1: Connectivity checks ...")
critical_pass, turn_available = run_connectivity_checks(meet_domain, livekit_domain)
if not critical_pass:
print()
print("FAIL: Critical connectivity checks failed — cannot proceed with WebRTC test")
sys.exit(1)
# Authenticate
print("Step 2: Authenticating both users ...")
token1 = get_oidc_token(
kc_url, creds["kc_realm"], creds["kc_client_id"], creds["kc_client_secret"],
creds["kc_test_user"], creds["kc_test_pass"],
)
token2 = get_oidc_token(
kc_url, creds["kc_realm"], creds["kc_client_id"], creds["kc_client_secret"],
creds["kc_test_user2"], creds["kc_test_pass2"],
)
print(" PASS: Both users authenticated")
# Create room
print(f"Step 3: Creating room '{room_name}' ...")
room_id, lk1 = create_room(meet_url, token1, room_name)
lk2 = join_room(meet_url, token2, room_id)
print(f" PASS: Room created (id={room_id})")
print(f" PASS: LiveKit tokens obtained for both users")
print(f" LiveKit URL: {lk1['url']}")
# WebRTC media test
print("Step 4: Testing WebRTC media flow ...")
print(" Connecting participant A, publishing audio ...")
print(" Connecting participant B, waiting for audio frames ...")
success, frames, publisher, error = asyncio.run(
run_webrtc_test(lk1["url"], lk1["token"], lk2["token"])
)
# Clean up
print("Step 5: Cleaning up ...")
delete_room(meet_url, token1, room_id)
print(" Room deleted")
# Result
print()
if success:
print("PASS: WebRTC media flow verified")
print(f" Audio frames received: {frames}")
print(f" Publisher identity: {publisher}")
if turn_available:
print(" TURN was available — media may have used relay or direct ICE")
print(" (check LiveKit logs for connectionType to confirm relay vs direct)")
else:
print(f"FAIL: WebRTC media flow test failed — {error}")
if "timed out" in error or "connection" in error.lower():
print()
if not turn_available:
print(" Hint: Neither direct ICE (7881/7882) nor TURN relay (UDP 443)")
print(" are reachable. If the server has TURN enabled, verify UDP 443")
print(" is published and not blocked by firewall.")
else:
print(" Hint: TURN relay port (UDP 443) is reachable but media still")
print(" failed. Check LiveKit logs to verify TURN server started.")
print(" Run: ssh <server> docker service logs <stack>_livekit | grep -i turn")
print()
print(" Try running from a machine with less restrictive network access,")
print(" or verify TURN is enabled in the LiveKit config.")
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,292 @@
#!/usr/bin/env python3
"""
TURN relay test for La Suite Meet + LiveKit.
Forces relay-only ICE transport (TRANSPORT_RELAY) to verify the TURN relay
data path works end-to-end. This simulates clients behind restrictive NATs
(CGNAT, symmetric NAT, corporate firewalls) that cannot establish direct
ICE connections and must use TURN relay.
The test:
1. Verifies TURN server responds to STUN probe (UDP 443)
2. Both users authenticate via Keycloak OIDC
3. User 1 creates a room, User 2 joins
4. Both connect to LiveKit with ice_transport_type=TRANSPORT_RELAY
5. User 1 publishes a dummy audio track
6. User 2 verifies audio frames arrive via relay
7. Clean up
This test will FAIL if the TURN relay data path is broken (e.g., due to
docker-proxy source address mangling in Docker Swarm).
Prerequisites:
pip install livekit requests
Usage:
python3 webrtc-relay.py
"""
import asyncio
import os
import socket
import struct
import sys
import time
from pathlib import Path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
import requests
from livekit import rtc
from utils.tests.helpers import resolve_domain
SAMPLE_RATE = 48000
NUM_CHANNELS = 1
SAMPLES_PER_CHANNEL = 480 # 10ms at 48kHz
TIMEOUT = 30 # seconds — relay should connect quickly if it works at all
def load_credentials():
"""Load Keycloak test credentials from domain-specific TOML file."""
import tomllib
from utils.tests.helpers import resolve_domain_suffix
suffix = resolve_domain_suffix()
creds_file = Path(__file__).resolve().parent.parent / f"keycloak-test-credentials.{suffix}.toml"
if not creds_file.exists():
print(f"FAIL: Credentials file not found: {creds_file}")
print("Run setup_keycloak_integration.py first.")
sys.exit(1)
with open(creds_file, 'rb') as f:
return tomllib.load(f)
def check_stun(host, port, timeout=5):
"""Send a STUN Binding Request and verify we get a valid response."""
txn_id = os.urandom(12)
msg = struct.pack("!HHI", 0x0001, 0, 0x2112A442) + txn_id
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(timeout)
try:
s.sendto(msg, (host, port))
data, _ = s.recvfrom(1024)
except (socket.timeout, OSError):
return False
finally:
s.close()
if len(data) < 20:
return False
resp_type, _, magic = struct.unpack("!HHI", data[:8])
resp_txn = data[8:20]
return resp_type == 0x0101 and magic == 0x2112A442 and resp_txn == txn_id
def get_oidc_token(kc_url, realm, client_id, client_secret, username, password):
"""Get an access token from Keycloak via direct access grant."""
resp = requests.post(
f"{kc_url}/realms/{realm}/protocol/openid-connect/token",
data={
"grant_type": "password",
"client_id": client_id,
"client_secret": client_secret,
"username": username,
"password": password,
"scope": "openid email",
},
)
resp.raise_for_status()
return resp.json()["access_token"]
def create_room(meet_url, token, room_name):
"""Create a public room. Returns (room_id, livekit_config)."""
resp = requests.post(
f"{meet_url}/api/v1.0/rooms/",
headers={"Authorization": f"Bearer {token}"},
json={"name": room_name, "access_level": "public"},
)
if resp.status_code != 201:
print(f" FAIL: Room creation returned HTTP {resp.status_code}: {resp.text}")
sys.exit(1)
data = resp.json()
return data["id"], data["livekit"]
def join_room(meet_url, token, room_id):
"""Join an existing room. Returns livekit config."""
resp = requests.get(
f"{meet_url}/api/v1.0/rooms/{room_id}/",
headers={"Authorization": f"Bearer {token}"},
)
resp.raise_for_status()
return resp.json()["livekit"]
def delete_room(meet_url, token, room_id):
"""Delete a room."""
requests.delete(
f"{meet_url}/api/v1.0/rooms/{room_id}/",
headers={"Authorization": f"Bearer {token}"},
)
async def push_audio(source, stop_event):
"""Push silent PCM frames until stopped."""
frame = rtc.AudioFrame.create(SAMPLE_RATE, NUM_CHANNELS, SAMPLES_PER_CHANNEL)
while not stop_event.is_set():
await source.capture_frame(frame)
async def run_relay_test(lk_url, token_a, token_b):
"""
Connect two participants using TURN relay only.
A publishes audio, B verifies reception.
Returns (success, frames_received, publisher_identity, error_message).
"""
ws_url = lk_url.replace("https://", "wss://").replace("http://", "ws://")
# Force relay-only ICE — no direct host/srflx candidates
relay_config = rtc.RtcConfiguration(
ice_transport_type=rtc.IceTransportType.TRANSPORT_RELAY,
)
room_opts = rtc.RoomOptions(
auto_subscribe=True,
rtc_config=relay_config,
)
room_a = rtc.Room()
room_b = rtc.Room()
stop_audio = asyncio.Event()
track_received = asyncio.Event()
result = {"frames": 0, "publisher": ""}
@room_b.on("track_subscribed")
def on_track_subscribed(track, publication, participant):
result["publisher"] = participant.identity
async def read_frames():
stream = rtc.AudioStream(track)
async for _ in stream:
result["frames"] += 1
if result["frames"] >= 5:
break
await stream.aclose()
track_received.set()
asyncio.ensure_future(read_frames())
try:
# Participant A connects and publishes
await room_a.connect(ws_url, token_a, options=room_opts)
print(" Participant A connected (relay-only)")
source = rtc.AudioSource(SAMPLE_RATE, NUM_CHANNELS)
audio_track = rtc.LocalAudioTrack.create_audio_track("test-audio", source)
options = rtc.TrackPublishOptions()
options.source = rtc.TrackSource.SOURCE_MICROPHONE
await room_a.local_participant.publish_track(audio_track, options)
audio_task = asyncio.ensure_future(push_audio(source, stop_audio))
await asyncio.sleep(1)
# Participant B connects and waits for track
await room_b.connect(ws_url, token_b, options=room_opts)
print(" Participant B connected (relay-only)")
await asyncio.wait_for(track_received.wait(), timeout=TIMEOUT)
return True, result["frames"], result["publisher"], ""
except asyncio.TimeoutError:
return False, 0, "", "timed out waiting for audio frames (relay path broken?)"
except rtc.ConnectError as e:
return False, 0, "", f"LiveKit connection failed: {e}"
except Exception as e:
return False, 0, "", f"unexpected error: {e}"
finally:
stop_audio.set()
try:
await room_b.disconnect()
except Exception:
pass
try:
await room_a.disconnect()
except Exception:
pass
def main():
creds = load_credentials()
meet_domain = resolve_domain("lasuite-meet")
livekit_domain = f"livekit.{meet_domain}"
meet_url = f"https://{meet_domain}"
kc_url = f"https://{resolve_domain('keycloak')}"
room_name = f"relay-test-{int(time.time())}"
print("Testing TURN relay path: force relay-only ICE, publish audio, verify reception")
print()
# Check TURN is available
print("Step 1: Verify TURN server responds ...")
server_ip = socket.gethostbyname(livekit_domain)
print(f" LiveKit IP: {server_ip} ({livekit_domain})")
if not check_stun(server_ip, 443):
print(f" FAIL: No STUN response from {server_ip}:443 — TURN server not reachable")
print(" Cannot run relay test without TURN.")
sys.exit(1)
print(f" PASS: TURN server responds on UDP {server_ip}:443")
# Authenticate
print("Step 2: Authenticating both users ...")
token1 = get_oidc_token(
kc_url, creds["kc_realm"], creds["kc_client_id"], creds["kc_client_secret"],
creds["kc_test_user"], creds["kc_test_pass"],
)
token2 = get_oidc_token(
kc_url, creds["kc_realm"], creds["kc_client_id"], creds["kc_client_secret"],
creds["kc_test_user2"], creds["kc_test_pass2"],
)
print(" PASS: Both users authenticated")
# Create room
print(f"Step 3: Creating room '{room_name}' ...")
room_id, lk1 = create_room(meet_url, token1, room_name)
lk2 = join_room(meet_url, token2, room_id)
print(f" PASS: Room created (id={room_id})")
print(f" LiveKit URL: {lk1['url']}")
# Relay test
print("Step 4: Testing TURN relay media flow (relay-only ICE) ...")
success, frames, publisher, error = asyncio.run(
run_relay_test(lk1["url"], lk1["token"], lk2["token"])
)
# Clean up
print("Step 5: Cleaning up ...")
delete_room(meet_url, token1, room_id)
print(" Room deleted")
# Result
print()
if success:
print("PASS: TURN relay media flow verified")
print(f" Audio frames received: {frames}")
print(f" Publisher identity: {publisher}")
print(" Media was transported entirely via TURN relay (no direct ICE)")
else:
print(f"FAIL: TURN relay test failed — {error}")
print()
print(" The TURN server is reachable and relay candidates are generated,")
print(" but media cannot flow through the relay path. This is typically")
print(" caused by docker-proxy source address mangling in Docker Swarm:")
print(" the TURN relay and SFU are in the same container but communicate")
print(" via the external IP through docker-proxy, which changes the source")
print(" address and breaks TURN permission checks.")
sys.exit(1)
if __name__ == "__main__":
main()
+20
View File
@@ -0,0 +1,20 @@
# La Suite Meet — Upstream Info
## Main Project
- **Repository:** https://github.com/suitenumerique/meet
- **Releases:** https://github.com/suitenumerique/meet/releases
- **Changelog:** https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md
- **Docker Compose docs:** https://github.com/suitenumerique/meet/blob/main/docs/installation/compose.md
## Images
| Service | Image | Release Notes |
|---------|-------|---------------|
| app | `lasuite/meet-frontend` | https://github.com/suitenumerique/meet/releases |
| backend | `lasuite/meet-backend` | https://github.com/suitenumerique/meet/releases |
| celery | `lasuite/meet-backend` | https://github.com/suitenumerique/meet/releases |
| livekit | `livekit/livekit-server` | https://github.com/livekit/livekit/releases |
| db | `pgautoupgrade/pgautoupgrade` | https://github.com/pgautoupgrade/docker-pgautoupgrade/releases |
| redis | `redis` | https://github.com/redis/redis/releases |
| web | `nginx` | https://nginx.org/en/CHANGES |
+1
View File
@@ -0,0 +1 @@
name = "lichen-markdown"
+31
View File
@@ -0,0 +1,31 @@
# Setup
## Prerequisites
- DNS A record for `lichen-markdown.<DOMAIN_SUFFIX>` pointing to `<SERVER>`
## Deploy
```bash
abra app new lichen-markdown --server <SERVER> --domain lichen-markdown.<DOMAIN_SUFFIX> --no-input
abra app secret generate lichen-markdown.<DOMAIN_SUFFIX> --all -m --no-input
abra app deploy lichen-markdown.<DOMAIN_SUFFIX> --chaos --force --no-input
```
## Environment variables
| Variable | Default | Description |
|----------|---------|-------------|
| `LICHEN_USERNAME` | `admin` | CMS login username |
| `LETS_ENCRYPT_ENV` | `production` | `production` or `staging` |
| `ENABLE_BACKUPS` | `true` | Enable backupbot |
## Secrets
| Secret | Description |
|--------|-------------|
| `lichen_password` | CMS login password (auto-generated) |
## Accessing the CMS
Visit `https://lichen-markdown.<DOMAIN_SUFFIX>/admin` and log in with `LICHEN_USERNAME` / `lichen_password`.
+13
View File
@@ -0,0 +1,13 @@
# Test plan
Target URL: `https://lichen-markdown.t1cc.commoninternet.net`
## Automated
- `health_check.py` — HTTP 200 at the root URL
## Manual
- Open the URL and confirm the default lichen-markdown homepage loads
- Visit `/admin` and confirm a login prompt appears
- Log in with credentials from `recipe-info/testsecrets/lichen-markdown.t1cc.commoninternet.net` and confirm the CMS editor loads
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Health check for lichen-markdown."""
import argparse
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import http_get, resolve_domain
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
domain = args.domain or resolve_domain('lichen-markdown')
url = f"https://{domain}"
print(f"Checking lichen-markdown at {url} ...")
status, _ = http_get(url)
if status == 200:
print(f"PASS: lichen-markdown returned HTTP {status}")
else:
print(f"FAIL: lichen-markdown returned HTTP {status} (expected 200)")
sys.exit(1)
if __name__ == '__main__':
main()
+11
View File
@@ -0,0 +1,11 @@
# Upstream
## Images
| Image | Source |
|-------|--------|
| `notplants/lichen-markdown` | [codeberg.org/ukrudt.net/lichen-markdown](https://codeberg.org/ukrudt.net/lichen-markdown) |
## Release page
https://codeberg.org/ukrudt.net/lichen-markdown/releases
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Setup Keycloak OIDC integration for Lichen.
Creates a Keycloak realm, OIDC client, and test user, then inserts
the OIDC secrets and enables the OIDC compose overlay for lichen.
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from lib.abra import app_secret_insert
from lib.env import apply_env_overrides, get_abra_env_path, read_env_file
from lib.keycloak import KeycloakAdmin
from lib.models import load_default_instance
from lib.secrets import load_secrets
# Configuration
REALM = "lichen"
CLIENT_ID = "lichen"
TEST_USER = "testuser"
TEST_PASS = "testpass123"
TEST_EMAIL = f"{TEST_USER}@test.example.com"
def main():
inst = load_default_instance()
lichen_domain = inst.default_domain("lichen")
kc_domain = inst.default_domain("keycloak")
# Get Keycloak admin password from synced secrets
kc_secrets = load_secrets(kc_domain)
kc_admin_pass = kc_secrets["admin_password"]
kc = KeycloakAdmin(f"https://{kc_domain}", "admin", kc_admin_pass)
# Step 1: Create realm
kc.ensure_realm(REALM)
# Step 2: Create OIDC client
_, client_secret = kc.ensure_client(
REALM, CLIENT_ID,
redirect_uris=[f"https://{lichen_domain}/*"],
web_origins=[f"https://{lichen_domain}"],
)
# Step 3: Create test user
kc.ensure_user(REALM, TEST_USER, TEST_EMAIL, TEST_PASS)
# Step 4: Insert OIDC secrets via abra
print("=== Insert OIDC secrets into Lichen ===", flush=True)
issuer_url = f"https://{kc_domain}/realms/{REALM}"
app_secret_insert(lichen_domain, "oidc_issuer_url", "v1", issuer_url)
app_secret_insert(lichen_domain, "oidc_client_id", "v1", CLIENT_ID)
app_secret_insert(lichen_domain, "oidc_client_secret", "v1", client_secret)
# Step 5: Update lichen env with OIDC compose overlay
print("=== Update Lichen env with OIDC overlay ===", flush=True)
env_path = get_abra_env_path(inst.server, lichen_domain)
apply_env_overrides(env_path, {
"COMPOSE_FILE": '"compose.yml:compose.oidc.yml"',
"SECRET_OIDC_ISSUER_URL_VERSION": "v1",
"SECRET_OIDC_CLIENT_ID_VERSION": "v1",
"SECRET_OIDC_CLIENT_SECRET_VERSION": "v1",
"LICHEN_TOML_VERSION": "v1",
})
# Step 6: Write credentials file
script_dir = os.path.dirname(os.path.abspath(__file__))
creds_file = os.path.join(script_dir, f"keycloak-test-credentials.{inst.domain_suffix}.toml")
print(f"=== Write credentials to {creds_file} ===", flush=True)
with open(creds_file, "w") as f:
f.write(f'# Keycloak OIDC credentials for lichen test instance\n')
f.write(f'#\n')
f.write(f'# Keycloak instance: {kc_domain}\n')
f.write(f'# Realm: {REALM}\n')
f.write(f'# Created by: setup_keycloak_integration.py\n')
f.write(f'\n')
f.write(f'# Keycloak admin (master realm)\n')
f.write(f'kc_admin_user = "admin"\n')
f.write(f'kc_admin_pass = "{kc_admin_pass}"\n')
f.write(f'\n')
f.write(f'# OIDC client\n')
f.write(f'kc_realm = "{REALM}"\n')
f.write(f'kc_client_id = "{CLIENT_ID}"\n')
f.write(f'kc_client_secret = "{client_secret}"\n')
f.write(f'kc_issuer_url = "{issuer_url}"\n')
f.write(f'\n')
f.write(f'# Test user (in {REALM} realm)\n')
f.write(f'kc_test_user = "{TEST_USER}"\n')
f.write(f'kc_test_pass = "{TEST_PASS}"\n')
f.write(f'kc_test_email = "{TEST_EMAIL}"\n')
print(f" Written to {creds_file}", flush=True)
print("", flush=True)
print("=== Keycloak integration setup complete ===", flush=True)
print("", flush=True)
print("Next steps:", flush=True)
print(f" 1. Redeploy lichen: abra app deploy {lichen_domain} --chaos --force --no-input", flush=True)
print(f" 2. Run OIDC test: python3 recipe-info/lichen/tests/oidc_integration.py", flush=True)
if __name__ == "__main__":
main()
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Basic health check for Lichen — verifies the dashboard and API are up."""
import argparse
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import http_get, resolve_domain
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
domain = args.domain or resolve_domain('lichen')
url = f"https://{domain}"
print("Lichen health check")
print()
# Step 1: TLS check endpoint
print("Step 1: Checking /tls-check endpoint ...")
status, _ = http_get(f"{url}/tls-check")
if status != 200:
print(f" FAIL: /tls-check returned HTTP {status}")
sys.exit(1)
print(f" PASS: /tls-check returned HTTP {status}")
# Step 2: Dashboard redirects to login (auth enabled)
print("Step 2: Checking dashboard redirects to login ...")
status, _ = http_get(url)
if status not in (200, 303, 302):
print(f" FAIL: Dashboard returned HTTP {status}")
sys.exit(1)
print(f" PASS: Dashboard returned HTTP {status}")
# Step 3: API endpoint
print("Step 3: Checking /api/sites endpoint ...")
status, _ = http_get(f"{url}/api/sites")
if status == 0 or status >= 500:
print(f" FAIL: API returned HTTP {status}")
sys.exit(1)
print(f" PASS: API returned HTTP {status}")
print()
print("PASS: Lichen health check passed")
if __name__ == '__main__':
main()
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""Lichen OIDC integration test — validates that Keycloak SSO login works
with lichen's OIDC provider support.
Tests:
1. Keycloak OIDC discovery endpoint is accessible
2. Can obtain a token from Keycloak using test credentials
3. Lichen's /oidc/start endpoint redirects to Keycloak
4. Lichen's login page shows the SSO login button
"""
import argparse
import os
import sys
import urllib.parse
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import (
http_get, http_post, load_toml_credentials, resolve_domain,
)
def main():
if os.environ.get('SKIP_INTEGRATION') == '1':
print("SKIP: SKIP_INTEGRATION=1 is set, skipping OIDC integration test")
sys.exit(0)
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
lichen_domain = args.domain or resolve_domain('lichen')
kc_domain = resolve_domain('keycloak')
lichen_url = f"https://{lichen_domain}"
kc_url = f"https://{kc_domain}"
# Load credentials
creds_path = os.path.join(os.path.dirname(__file__), '..')
creds = load_toml_credentials(creds_path, 'keycloak')
if creds is None:
print("FAIL: Credentials file not found")
print("Run recipe-info/lichen/setup_keycloak_integration.py first.")
sys.exit(1)
print("Testing Lichen OIDC integration with Keycloak")
print()
# Step 1: Check lichen is reachable
print("Step 1: Checking Lichen is reachable ...")
status, _ = http_get(lichen_url)
if status == 0 or status >= 500:
print(f" FAIL: Lichen at {lichen_url} returned HTTP {status}")
sys.exit(1)
print(f" PASS: Lichen is reachable (HTTP {status})")
# Step 2: Verify OIDC discovery endpoint on Keycloak
print("Step 2: Checking Keycloak OIDC discovery endpoint ...")
discovery_url = f"{kc_url}/realms/{creds['kc_realm']}/.well-known/openid-configuration"
status, data = http_get(discovery_url)
if status != 200:
print(f" FAIL: OIDC discovery endpoint returned HTTP {status}")
sys.exit(1)
issuer = (data or {}).get("issuer", "")
print(f" PASS: OIDC discovery endpoint OK (issuer: {issuer})")
# Step 3: Obtain token from Keycloak via direct grant
print("Step 3: Obtaining token from Keycloak ...")
token_url = f"{kc_url}/realms/{creds['kc_realm']}/protocol/openid-connect/token"
status, data = http_post(
token_url,
data={
"grant_type": "password",
"client_id": creds["kc_client_id"],
"client_secret": creds["kc_client_secret"],
"username": creds["kc_test_user"],
"password": creds["kc_test_pass"],
"scope": "openid email profile",
},
content_type="application/x-www-form-urlencoded",
)
access_token = (data or {}).get("access_token", "")
if not access_token:
error = (data or {}).get("error_description", (data or {}).get("error", "unknown"))
print(f" FAIL: Could not obtain token: {error}")
sys.exit(1)
print(f" PASS: Obtained access token ({len(access_token)} chars)")
# Step 4: Check lichen login page shows OIDC/SSO option
print("Step 4: Checking Lichen login page has SSO option ...")
status, _ = http_get(f"{lichen_url}/login/")
if status != 200:
print(f" FAIL: Login page returned HTTP {status}")
sys.exit(1)
# Fetch the raw HTML to check for OIDC link
import urllib.request
req = urllib.request.Request(f"{lichen_url}/login/")
with urllib.request.urlopen(req, timeout=10) as resp:
html = resp.read().decode()
if "/oidc/start" in html:
print(" PASS: Login page contains /oidc/start link")
else:
print(" FAIL: Login page does not contain /oidc/start link")
print(" Make sure compose.oidc.yml is enabled and lichen is redeployed")
sys.exit(1)
# Step 5: Check /oidc/start redirects to Keycloak
print("Step 5: Checking /oidc/start redirects to Keycloak ...")
req = urllib.request.Request(f"{lichen_url}/oidc/start")
try:
with urllib.request.urlopen(req, timeout=10) as resp:
# Shouldn't reach here — expect redirect
print(f" WARN: Got HTTP {resp.getcode()} instead of redirect")
except urllib.error.HTTPError as e:
if 300 <= e.code < 400:
location = e.headers.get("Location", "")
if kc_domain in location:
print(f" PASS: Redirects to Keycloak ({e.code})")
else:
print(f" FAIL: Redirect location doesn't point to Keycloak: {location}")
sys.exit(1)
else:
print(f" FAIL: Expected redirect, got HTTP {e.code}")
sys.exit(1)
except Exception as e:
# urllib follows redirects by default, so check if we ended up at keycloak
pass
# For urllib which follows redirects, let's use a non-following approach
import http.client
import ssl
ctx = ssl.create_default_context()
conn = http.client.HTTPSConnection(lichen_domain, context=ctx)
conn.request("GET", "/oidc/start")
resp = conn.getresponse()
if 300 <= resp.status < 400:
location = resp.getheader("Location", "")
if kc_domain in location or "realms" in location:
print(f" PASS: /oidc/start redirects to Keycloak (HTTP {resp.status})")
else:
print(f" FAIL: Redirect doesn't point to Keycloak: {location}")
sys.exit(1)
elif resp.status == 200:
# urllib may have followed the redirect to Keycloak login page
print(f" PASS: /oidc/start returned 200 (redirect was followed)")
else:
print(f" FAIL: /oidc/start returned HTTP {resp.status}")
sys.exit(1)
conn.close()
print()
print("PASS: Lichen OIDC integration test passed")
if __name__ == '__main__':
main()
+1
View File
@@ -0,0 +1 @@
name = "matrix-synapse"
+25
View File
@@ -0,0 +1,25 @@
# Matrix Synapse — First-Time Setup
## Prerequisites
- DNS: `matrix-synapse.<domain_suffix>` must resolve to the server
## Steps
1. **Create the app:**
```bash
abra app new matrix-synapse --server <SERVER> --domain matrix-synapse.<DOMAIN_SUFFIX> --no-input
```
2. **Generate secrets:**
```bash
abra app secret generate matrix-synapse.<DOMAIN_SUFFIX> --all -m --no-input
```
Save output to `recipe-info/testsecrets/matrix-synapse.<DOMAIN_SUFFIX>`.
3. **Deploy:**
```bash
abra app deploy matrix-synapse.<DOMAIN_SUFFIX> --chaos --force --no-input
```
4. **Verify:** curl `https://matrix-synapse.<DOMAIN_SUFFIX>` returns HTTP 200.
+137
View File
@@ -0,0 +1,137 @@
#!/bin/bash
# Test: verify synapse_auto_compressor actually compresses bloated state
#
# Creates state groups WITHOUT edges (full snapshots), which is how
# Synapse creates bloat in practice. The compressor should create
# edges between them and convert full snapshots to deltas.
set -euo pipefail
SERVER="cctest.autonomic.zone"
STACK_NAME="matrix-synapse_cctest_autonomic_zone"
echo "=== Test: compress-state removes redundant state_groups_state rows ==="
DB_CONTAINER=$(ssh "$SERVER" "docker ps --filter name=${STACK_NAME}_db --format '{{.ID}}'" | head -1)
if [ -z "$DB_CONTAINER" ]; then
echo "FAIL: Could not find db container"
exit 1
fi
echo "DB container: $DB_CONTAINER"
run_sql() {
local tmpfile=$(ssh "$SERVER" mktemp)
echo "$1" | ssh "$SERVER" "cat > $tmpfile"
ssh "$SERVER" "docker cp $tmpfile $DB_CONTAINER:/tmp/_query.sql && docker exec $DB_CONTAINER psql -U synapse -d synapse -t -A -f /tmp/_query.sql && rm -f $tmpfile"
}
ROOM_ID='!compress_test3:test.local'
NUM_MEMBERS=20
NUM_GROUPS=200
# Full cleanup: all test data AND all compressor progress tables
run_sql "
DELETE FROM state_groups_state WHERE room_id LIKE '!compress_test%';
DELETE FROM state_group_edges WHERE state_group IN (SELECT id FROM state_groups WHERE room_id LIKE '!compress_test%');
DELETE FROM state_groups WHERE room_id LIKE '!compress_test%';
TRUNCATE state_compressor_state;
TRUNCATE state_compressor_progress;
TRUNCATE state_compressor_total_progress;
" > /dev/null 2>&1 || true
MAX_SG=$(run_sql "SELECT COALESCE(MAX(id), 0) FROM state_groups;")
echo "Current max state_group id: $MAX_SG"
BASE=$((MAX_SG + 1000))
echo "Generating $NUM_GROUPS full-snapshot state groups (no edges) with $NUM_MEMBERS members..."
SQL_FILE=$(mktemp /tmp/compress_test_XXXXXX.sql)
echo "BEGIN;" > "$SQL_FILE"
declare -A CURRENT_EVENT
for m in $(seq 1 $NUM_MEMBERS); do
CURRENT_EVENT[$m]="mem_${m}_v0"
done
for g in $(seq 0 $((NUM_GROUPS - 1))); do
SG_ID=$((BASE + g))
echo "INSERT INTO state_groups (id, room_id, event_id) VALUES ($SG_ID, '$ROOM_ID', 'ev_g${g}');" >> "$SQL_FILE"
# NO edges — each group is a standalone full snapshot
# One member changes per group
CHANGING=$(( (g % NUM_MEMBERS) + 1 ))
CURRENT_EVENT[$CHANGING]="mem_${CHANGING}_v${g}"
# Store ALL state in every group
for m in $(seq 1 $NUM_MEMBERS); do
EV="${CURRENT_EVENT[$m]}"
echo "INSERT INTO state_groups_state (state_group, room_id, type, state_key, event_id) VALUES ($SG_ID, '$ROOM_ID', 'm.room.member', '@user${m}:test.local', '${EV}');" >> "$SQL_FILE"
done
echo "INSERT INTO state_groups_state (state_group, room_id, type, state_key, event_id) VALUES ($SG_ID, '$ROOM_ID', 'm.room.create', '', 'create_ev');" >> "$SQL_FILE"
echo "INSERT INTO state_groups_state (state_group, room_id, type, state_key, event_id) VALUES ($SG_ID, '$ROOM_ID', 'm.room.name', '', 'name_ev');" >> "$SQL_FILE"
echo "INSERT INTO state_groups_state (state_group, room_id, type, state_key, event_id) VALUES ($SG_ID, '$ROOM_ID', 'm.room.topic', '', 'topic_ev');" >> "$SQL_FILE"
done
echo "COMMIT;" >> "$SQL_FILE"
echo "Generated $(wc -l < "$SQL_FILE") lines of SQL"
echo "Inserting test data..."
scp -q "$SQL_FILE" "$SERVER:/tmp/compress_test.sql"
ssh "$SERVER" "docker cp /tmp/compress_test.sql $DB_CONTAINER:/tmp/compress_test.sql"
ssh "$SERVER" "docker exec $DB_CONTAINER psql -U synapse -d synapse -f /tmp/compress_test.sql" > /dev/null 2>&1
echo "Insert complete."
rm -f "$SQL_FILE"
ROWS_AFTER_INSERT=$(run_sql "SELECT COUNT(*) FROM state_groups_state WHERE room_id = '$ROOM_ID';")
EDGES_BEFORE=$(run_sql "SELECT COUNT(*) FROM state_group_edges WHERE state_group IN (SELECT id FROM state_groups WHERE room_id = '$ROOM_ID');")
echo "Rows in state_groups_state: $ROWS_AFTER_INSERT"
echo "Edges in state_group_edges: $EDGES_BEFORE"
echo ""
echo "Running synapse_auto_compressor..."
COMPRESS_CONTAINER=$(ssh "$SERVER" "docker ps --filter name=${STACK_NAME}_compress-state --format '{{.ID}}'" | head -1)
if [ -z "$COMPRESS_CONTAINER" ]; then
echo "FAIL: Could not find compress-state container"
exit 1
fi
DB_PASS=$(ssh "$SERVER" "docker exec $DB_CONTAINER cat /run/secrets/db_password")
COMPRESS_OUTPUT=$(ssh "$SERVER" "docker exec $COMPRESS_CONTAINER /build/synapse_auto_compressor \
-p 'postgresql://synapse:${DB_PASS}@db:5432/synapse' \
-c 100 -n 10 2>&1") || true
echo "$COMPRESS_OUTPUT"
ROWS_AFTER_COMPRESS=$(run_sql "SELECT COUNT(*) FROM state_groups_state WHERE room_id = '$ROOM_ID';")
EDGES_AFTER=$(run_sql "SELECT COUNT(*) FROM state_group_edges WHERE state_group IN (SELECT id FROM state_groups WHERE room_id = '$ROOM_ID');")
echo ""
echo "=== Results ==="
echo "Rows BEFORE compression: $ROWS_AFTER_INSERT"
echo "Rows AFTER compression: $ROWS_AFTER_COMPRESS"
SAVED=$((ROWS_AFTER_INSERT - ROWS_AFTER_COMPRESS))
echo "Rows saved: $SAVED"
echo "Edges BEFORE: $EDGES_BEFORE"
echo "Edges AFTER: $EDGES_AFTER"
if [ "$SAVED" -gt 0 ]; then
PCT=$((SAVED * 100 / ROWS_AFTER_INSERT))
echo "Compression ratio: ${PCT}%"
echo ""
echo "PASS: Compressor removed $SAVED redundant rows (${PCT}% reduction)"
else
echo ""
echo "FAIL: Compressor did not remove any rows"
exit 1
fi
# Clean up
echo ""
echo "Cleaning up test data..."
run_sql "DELETE FROM state_groups_state WHERE room_id = '$ROOM_ID';
DELETE FROM state_group_edges WHERE state_group IN (SELECT id FROM state_groups WHERE room_id = '$ROOM_ID');
DELETE FROM state_groups WHERE room_id = '$ROOM_ID';" > /dev/null
ssh "$SERVER" "rm -f /tmp/compress_test.sql"
echo "Cleanup complete."
+70
View File
@@ -0,0 +1,70 @@
#!/bin/bash
# Test: verify room complexity limit blocks joining large remote rooms
#
# Tries to join a known large remote room (#community:matrix.org) and
# verifies Synapse rejects the join due to complexity limits.
# Requires: ROOM_COMPLEXITY_LIMIT set low enough (e.g. 10.0) and federation enabled.
set -euo pipefail
SERVER="cctest.autonomic.zone"
DOMAIN="matrix-synapse.cctest.autonomic.zone"
STACK_NAME="matrix-synapse_cctest_autonomic_zone"
ADMIN_USER="complexity_test_admin"
ADMIN_PASS="complextest_pass_123"
echo "=== Test: room complexity limit blocks large remote rooms ==="
# Register admin user
echo "Registering admin user..."
ssh "$SERVER" "docker exec \$(docker ps --filter name=${STACK_NAME}_app -q) \
register_new_matrix_user -u $ADMIN_USER -p $ADMIN_PASS -a -c /data/homeserver.yaml http://localhost:8008 2>&1" || true
# Get token
echo "Getting token..."
TOKEN=$(ssh "$SERVER" "docker exec \$(docker ps --filter name=${STACK_NAME}_app -q) \
curl -s -X POST http://localhost:8008/_matrix/client/r0/login \
-H 'Content-Type: application/json' \
-d '{\"type\":\"m.login.password\",\"user\":\"$ADMIN_USER\",\"password\":\"$ADMIN_PASS\"}'" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
if [ -z "$TOKEN" ]; then
echo "FAIL: Could not get token"
exit 1
fi
echo "Token: ${TOKEN:0:20}..."
# Verify complexity limit is set
echo ""
echo "Checking homeserver config..."
COMPLEXITY=$(ssh "$SERVER" "docker exec \$(docker ps --filter name=${STACK_NAME}_app -q) \
grep 'complexity:' /data/homeserver.yaml" | awk '{print $2}')
echo "Configured complexity limit: $COMPLEXITY"
# Try to join #community:matrix.org (a large room with ~30k state events, complexity ~60)
# This should be rejected because complexity 60 > limit 10
LARGE_ROOM="%23community:matrix.org"
echo ""
echo "Attempting to join #community:matrix.org (should be rejected)..."
RESULT=$(ssh "$SERVER" "docker exec \$(docker ps --filter name=${STACK_NAME}_app -q) \
curl -s -X POST 'http://localhost:8008/_matrix/client/r0/join/${LARGE_ROOM}' \
-H 'Authorization: Bearer $TOKEN' \
-H 'Content-Type: application/json' \
-d '{}'")
echo "Response: $RESULT"
# Check for complexity error
if echo "$RESULT" | grep -qi "complex\|too large\|M_RESOURCE_LIMIT_EXCEEDED"; then
echo ""
echo "PASS: Room join rejected due to complexity limit"
elif echo "$RESULT" | grep -qi "error"; then
echo ""
echo "Got an error (may be federation related, not complexity):"
echo "$RESULT" | python3 -m json.tool 2>/dev/null || echo "$RESULT"
echo ""
echo "INCONCLUSIVE: Got an error but not clearly a complexity rejection"
exit 1
else
echo ""
echo "FAIL: Room join was not rejected — complexity limit may not be working"
exit 1
fi
+219
View File
@@ -0,0 +1,219 @@
#!/bin/bash
# Test: verify all abra.sh database maintenance and purge commands
#
# Creates test data (admin user, rooms, messages) then exercises
# every abra.sh command and checks for expected output.
set -euo pipefail
SERVER="cctest.autonomic.zone"
DOMAIN="matrix-synapse.cctest.autonomic.zone"
STACK_NAME="matrix-synapse_cctest_autonomic_zone"
ADMIN_USER="purgetest_admin"
ADMIN_PASS="purgetest_pass_123"
PASS=0
FAIL=0
check() {
local name="$1"
local result="$2"
local expected="$3"
if echo "$result" | grep -q "$expected"; then
echo " PASS: $name"
PASS=$((PASS + 1))
else
echo " FAIL: $name (expected '$expected')"
echo " Got: $result"
FAIL=$((FAIL + 1))
fi
}
# Run abra cmd and strip ANSI escape sequences
run_db_cmd() {
script -qefc "abra app cmd $DOMAIN db $* --chaos --no-input" /dev/null 2>&1 | sed 's/\x1b\[[0-9;?]*[a-zA-Z]//g' | grep -v '^\]'
}
run_app_cmd() {
script -qefc "abra app cmd $DOMAIN app $* --chaos --no-input" /dev/null 2>&1 | sed 's/\x1b\[[0-9;?]*[a-zA-Z]//g' | grep -v '^\]'
}
# Run curl inside the app container
app_curl() {
ssh "$SERVER" "docker exec \$(docker ps --filter name=${STACK_NAME}_app -q) curl -s $*"
}
echo "=== Test: abra.sh database maintenance and purge commands ==="
echo ""
# --- Setup: create admin user ---
echo "--- Setup ---"
echo "Registering admin user..."
REGISTER_OUT=$(run_app_cmd register_admin $ADMIN_USER $ADMIN_PASS 2>&1) || true
if echo "$REGISTER_OUT" | grep -q "Success\|already"; then
echo " Admin user ready"
else
echo " Register output: $REGISTER_OUT"
fi
echo "Getting admin token..."
TOKEN_RAW=$(run_app_cmd get_token $ADMIN_USER $ADMIN_PASS)
TOKEN=$(echo "$TOKEN_RAW" | grep -oE 'syt_[A-Za-z0-9_]+' | head -1)
if [ -z "$TOKEN" ]; then
echo " FATAL: Could not get admin token"
echo " Raw: $TOKEN_RAW"
exit 1
fi
echo " Token: ${TOKEN:0:20}..."
# --- Create test data ---
echo ""
echo "--- Creating test data ---"
# Create a room with messages
ROOM_ID=$(app_curl -X POST "http://localhost:8008/_matrix/client/r0/createRoom" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "'{"'"'"name"'"'":"'"'"Purge Test Room"'"'"}'" 2>/dev/null \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('room_id',''))" 2>/dev/null) || true
if [ -z "$ROOM_ID" ]; then
# Try with simpler quoting
ROOM_ID=$(ssh "$SERVER" "docker exec \$(docker ps --filter name=${STACK_NAME}_app -q) \
curl -s -X POST http://localhost:8008/_matrix/client/r0/createRoom \
-H 'Authorization: Bearer $TOKEN' \
-H 'Content-Type: application/json' \
-d '{\"name\":\"Purge Test Room\"}'")
ROOM_ID=$(echo "$ROOM_ID" | python3 -c "import sys,json; print(json.load(sys.stdin).get('room_id',''))")
fi
if [ -z "$ROOM_ID" ]; then
echo " FATAL: Could not create room"
exit 1
fi
echo " Created room: $ROOM_ID"
# Send some messages
for i in $(seq 1 5); do
ssh "$SERVER" "docker exec \$(docker ps --filter name=${STACK_NAME}_app -q) \
curl -s -X PUT 'http://localhost:8008/_matrix/client/r0/rooms/${ROOM_ID}/send/m.room.message/msg${i}' \
-H 'Authorization: Bearer $TOKEN' \
-H 'Content-Type: application/json' \
-d '{\"msgtype\":\"m.text\",\"body\":\"Test message ${i}\"}'" > /dev/null
done
echo " Sent 5 messages"
# Create a second room that the admin will leave (to test empty_rooms / purge_empty_rooms)
EMPTY_ROOM_ID=$(ssh "$SERVER" "docker exec \$(docker ps --filter name=${STACK_NAME}_app -q) \
curl -s -X POST http://localhost:8008/_matrix/client/r0/createRoom \
-H 'Authorization: Bearer $TOKEN' \
-H 'Content-Type: application/json' \
-d '{\"name\":\"Empty Test Room\"}'")
EMPTY_ROOM_ID=$(echo "$EMPTY_ROOM_ID" | python3 -c "import sys,json; print(json.load(sys.stdin).get('room_id',''))")
echo " Created empty room: $EMPTY_ROOM_ID"
# Leave and forget the empty room
ssh "$SERVER" "docker exec \$(docker ps --filter name=${STACK_NAME}_app -q) \
curl -s -X POST 'http://localhost:8008/_matrix/client/r0/rooms/${EMPTY_ROOM_ID}/leave' \
-H 'Authorization: Bearer $TOKEN'" > /dev/null
ssh "$SERVER" "docker exec \$(docker ps --filter name=${STACK_NAME}_app -q) \
curl -s -X POST 'http://localhost:8008/_matrix/client/r0/rooms/${EMPTY_ROOM_ID}/forget' \
-H 'Authorization: Bearer $TOKEN'" > /dev/null
echo " Left and forgot empty room"
echo ""
echo "--- Testing db commands ---"
# Test db_size
echo "Testing db_size..."
RESULT=$(run_db_cmd db_size)
check "db_size shows database size" "$RESULT" "db_size"
check "db_size shows top tables" "$RESULT" "total_size"
# Test state_bloat
echo "Testing state_bloat..."
RESULT=$(run_db_cmd state_bloat)
check "state_bloat shows header" "$RESULT" "state_entries"
# Test empty_rooms
echo "Testing empty_rooms..."
RESULT=$(run_db_cmd empty_rooms)
check "empty_rooms shows header" "$RESULT" "room_id"
# Test reindex
echo "Testing reindex..."
RESULT=$(run_db_cmd reindex)
check "reindex completes" "$RESULT" "REINDEX complete"
check "reindex shows size" "$RESULT" "db_size"
# Test vacuum_full
echo "Testing vacuum_full..."
RESULT=$(run_db_cmd vacuum_full)
check "vacuum_full completes" "$RESULT" "VACUUM FULL complete"
check "vacuum_full shows size" "$RESULT" "db_size"
echo ""
echo "--- Testing app commands ---"
# Test register_admin
echo "Testing register_admin..."
RESULT=$(run_app_cmd register_admin testadmin_new testpass_new_456)
check "register_admin succeeds" "$RESULT" "Success"
# Test get_token
echo "Testing get_token..."
RESULT=$(run_app_cmd get_token $ADMIN_USER $ADMIN_PASS)
check "get_token returns token" "$RESULT" "syt_"
# Test get_token with bad password
echo "Testing get_token with bad password..."
RESULT=$(run_app_cmd get_token $ADMIN_USER wrongpassword)
check "get_token rejects bad password" "$RESULT" "Invalid"
# Test purge_remote_media
echo "Testing purge_remote_media..."
RESULT=$(run_app_cmd purge_remote_media 30 $TOKEN)
check "purge_remote_media returns deleted count" "$RESULT" "deleted"
# Test purge_history
echo "Testing purge_history..."
RESULT=$(run_app_cmd purge_history $ROOM_ID 0 $TOKEN)
check "purge_history returns purge_id" "$RESULT" "purge_id"
# Test purge_empty_rooms
echo "Testing purge_empty_rooms..."
RESULT=$(run_app_cmd purge_empty_rooms $TOKEN)
check "purge_empty_rooms finds empty room" "$RESULT" "$EMPTY_ROOM_ID"
# Test purge_room (purge the main test room)
echo "Testing purge_room..."
RESULT=$(run_app_cmd purge_room $ROOM_ID $TOKEN)
check "purge_room returns result" "$RESULT" "kicked_users"
# Test usage messages
echo ""
echo "--- Testing usage messages ---"
RESULT=$(run_app_cmd purge_remote_media 2>&1) || true
check "purge_remote_media shows usage" "$RESULT" "Usage"
RESULT=$(run_app_cmd purge_room 2>&1) || true
check "purge_room shows usage" "$RESULT" "Usage"
RESULT=$(run_app_cmd purge_history 2>&1) || true
check "purge_history shows usage" "$RESULT" "Usage"
RESULT=$(run_app_cmd purge_empty_rooms 2>&1) || true
check "purge_empty_rooms shows usage" "$RESULT" "Usage"
RESULT=$(run_app_cmd get_token 2>&1) || true
check "get_token shows usage" "$RESULT" "Usage"
RESULT=$(run_app_cmd register_admin 2>&1) || true
check "register_admin shows usage" "$RESULT" "Usage"
echo ""
echo "=== Results ==="
echo "PASS: $PASS"
echo "FAIL: $FAIL"
if [ "$FAIL" -gt 0 ]; then
exit 1
fi
+1
View File
@@ -0,0 +1 @@
name = "mumble"

Some files were not shown because too many files have changed in this diff Show More