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:
@@ -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>`.
|
||||
@@ -0,0 +1,5 @@
|
||||
name = "authentik"
|
||||
|
||||
[dependencies]
|
||||
requires = []
|
||||
test_requires = ["ld2"] # oidc_integration test needs the ld2 lasuite-docs instance
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Executable
+28
@@ -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
@@ -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()
|
||||
@@ -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/ |
|
||||
@@ -0,0 +1 @@
|
||||
name = "bluesky-pds"
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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 |
|
||||
@@ -0,0 +1,8 @@
|
||||
name = "cryptpad"
|
||||
|
||||
[dependencies]
|
||||
requires = ["authentik"]
|
||||
|
||||
[sso]
|
||||
provider = "authentik"
|
||||
setup_script = "setup/sso_integration.py"
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
Executable
+28
@@ -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()
|
||||
Executable
+91
@@ -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()
|
||||
@@ -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 |
|
||||
@@ -0,0 +1 @@
|
||||
name = "custom-html"
|
||||
@@ -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 * * * *"
|
||||
```
|
||||
@@ -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()
|
||||
@@ -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 |
|
||||
@@ -0,0 +1 @@
|
||||
name = "gitea"
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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/ |
|
||||
@@ -0,0 +1 @@
|
||||
name = "hedgedoc"
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Executable
+75
@@ -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()
|
||||
Executable
+28
@@ -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
@@ -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()
|
||||
@@ -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/ |
|
||||
@@ -0,0 +1,8 @@
|
||||
name = "immich"
|
||||
|
||||
[dependencies]
|
||||
requires = ["authentik"]
|
||||
|
||||
[sso]
|
||||
provider = "authentik"
|
||||
setup_script = "setup/sso_integration.py"
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
Executable
+28
@@ -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()
|
||||
Executable
+96
@@ -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()
|
||||
@@ -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 |
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,5 @@
|
||||
name = "keycloak"
|
||||
|
||||
[dependencies]
|
||||
requires = []
|
||||
test_requires = ["lasuite-docs"] # oidc_integration test needs lasuite-docs deployed
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
Executable
+28
@@ -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
@@ -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()
|
||||
@@ -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/ |
|
||||
@@ -0,0 +1,8 @@
|
||||
name = "lasuite-docs"
|
||||
|
||||
[dependencies]
|
||||
requires = ["keycloak"]
|
||||
|
||||
[sso]
|
||||
provider = "keycloak"
|
||||
setup_script = "setup/sso_integration.py"
|
||||
@@ -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()
|
||||
@@ -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
@@ -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()
|
||||
Executable
+102
@@ -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()
|
||||
@@ -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 |
|
||||
@@ -0,0 +1,8 @@
|
||||
name = "lasuite-drive"
|
||||
|
||||
[dependencies]
|
||||
requires = ["keycloak"]
|
||||
|
||||
[sso]
|
||||
provider = "keycloak"
|
||||
setup_script = "setup/sso_integration.py"
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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 |
|
||||
@@ -0,0 +1,8 @@
|
||||
name = "lasuite-meet"
|
||||
|
||||
[dependencies]
|
||||
requires = ["keycloak"]
|
||||
|
||||
[sso]
|
||||
provider = "keycloak"
|
||||
setup_script = "setup/sso_integration.py"
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
Executable
+102
@@ -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()
|
||||
@@ -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 |
|
||||
@@ -0,0 +1 @@
|
||||
name = "lichen-markdown"
|
||||
@@ -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`.
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
name = "matrix-synapse"
|
||||
@@ -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
@@ -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."
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
name = "mumble"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user