"""Integration tests for Astrolabe token refresh flow.
Tests the token refresh mechanism between Astrolabe (Nextcloud app)
and the MCP server backend in a multi-user basic auth deployment.
This test verifies:
1. User provisions access via Astrolabe personal settings
2. Token is stored encrypted in Nextcloud database
3. Token expires (simulated via database manipulation)
4. MCP server requests new token via refresh
5. Astrolabe refreshes token with IdP
6. New token is stored and used successfully
Note: The mcp-multi-user-basic deployment uses "hybrid mode" which requires
BOTH OAuth authorization AND app password for full configuration. These tests
focus on the app password/credential storage aspects and verify database state
directly rather than relying on UI elements that require both steps.
"""
import logging
import re
import subprocess
import anyio
import pytest
from playwright.async_api import Page
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
logger = logging.getLogger(__name__)
async def login_to_nextcloud(page: Page, username: str, password: str):
"""Helper function to login to Nextcloud via Playwright.
Args:
page: Playwright page instance
username: Nextcloud username
password: Nextcloud password
"""
nextcloud_url = "http://localhost:8080"
logger.info(f"Logging in to Nextcloud as {username}...")
await page.goto(f"{nextcloud_url}/login", wait_until="networkidle")
# Fill in login form
await page.wait_for_selector('input[name="user"]', timeout=10000)
await page.fill('input[name="user"]', username)
await page.fill('input[name="password"]', password)
# Submit form
await page.click('button[type="submit"]')
await page.wait_for_load_state("networkidle", timeout=30000)
# Verify logged in (should redirect away from login page)
current_url = page.url
assert "/login" not in current_url, (
f"Login failed for {username}, still on login page"
)
logger.info(f"✓ Successfully logged in as {username}")
async def generate_app_password(
page: Page, username: str, app_name: str = "Astrolabe Test"
) -> str:
"""Generate an app password in Nextcloud Security settings.
Args:
page: Playwright page instance (must be authenticated)
username: Username (for logging)
app_name: Name for the app password
Returns:
The generated app password string
"""
logger.info(f"Generating app password for {username}...")
nextcloud_url = "http://localhost:8080"
# Navigate to Security settings
await page.goto(f"{nextcloud_url}/settings/user/security", wait_until="networkidle")
logger.info("Navigated to Security settings")
# Fill the app password input field
app_password_input = page.locator('input[placeholder="App name"]')
await app_password_input.fill(app_name)
logger.info(f"Entered app name: {app_name}")
# Wait for Vue.js to react and enable the button
await anyio.sleep(1.0)
# Click the create button
create_button = page.locator(
'button[type="submit"]:has-text("Create new app password")'
)
await create_button.click()
logger.info("Clicked create app password button")
# Wait for app password to be generated
await anyio.sleep(3)
# Find the generated app password
app_password = None
try:
await page.wait_for_selector('text="New app password"', timeout=10000)
logger.info("App password dialog appeared")
all_inputs = await page.locator('input[type="text"]').all()
for idx, input_elem in enumerate(all_inputs):
try:
value = await input_elem.input_value()
if value and "-" in value and len(value) > 20:
app_password = value.strip()
logger.info(f"Found app password in input {idx}")
break
except Exception:
continue
except Exception as e:
logger.error(f"Failed to find app password dialog: {e}")
if not app_password:
screenshot_path = f"/tmp/app_password_generation_{username}.png"
await page.screenshot(path=screenshot_path)
raise ValueError(
f"Could not find generated app password. Screenshot: {screenshot_path}"
)
# Validate password format
if not re.match(
r"^[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}$",
app_password,
):
raise ValueError(f"App password format validation failed: {app_password}")
logger.info(f"✓ Generated app password for {username}")
# Close the dialog
close_button = page.get_by_role("button", name="Close")
await close_button.click()
await anyio.sleep(0.5)
return app_password
async def save_app_password_in_astrolabe(
page: Page, username: str, app_password: str
) -> bool:
"""Save app password in Astrolabe settings (Step 2 of hybrid mode).
This function only saves the app password - it does NOT verify the "Active"
badge since that requires both OAuth and app password in hybrid mode.
Args:
page: Playwright page instance
username: Username (for logging)
app_password: App password to enter
Returns:
True if the password was saved successfully (based on network response)
"""
logger.info(f"Saving app password in Astrolabe for {username}...")
nextcloud_url = "http://localhost:8080"
# Track network responses
credentials_response_status = None
def capture_response(resp):
nonlocal credentials_response_status
if "background-sync/credentials" in resp.url or "storeAppPassword" in resp.url:
credentials_response_status = resp.status
logger.info(f"Credentials endpoint response: {resp.status} {resp.url}")
page.on("response", capture_response)
# Navigate to Astrolabe settings
await page.goto(
f"{nextcloud_url}/settings/user/astrolabe", wait_until="networkidle"
)
await anyio.sleep(1)
# Check if Step 2 already shows "Complete"
try:
complete_badge = page.locator('text="Complete"').first
if await complete_badge.is_visible(timeout=2000):
logger.info(f"✓ App password already configured for {username}")
return True
except Exception:
pass
# Find the app password input field
app_password_input = page.get_by_placeholder("xxxxx-xxxxx-xxxxx-xxxxx-xxxxx")
try:
await app_password_input.wait_for(timeout=5000, state="visible")
logger.info("Found app password input field")
except Exception:
screenshot_path = f"/tmp/astrolabe_no_password_field_{username}.png"
await page.screenshot(path=screenshot_path)
raise ValueError(
f"Could not find app password input field. Screenshot: {screenshot_path}"
)
# Enter the app password
await app_password_input.fill(app_password)
logger.info(f"Entered app password for {username}")
await anyio.sleep(0.5)
# Click Save button
save_button = page.get_by_role("button", name="Save")
await save_button.click()
logger.info("Clicked Save button")
# Wait for the request to complete and page to reload
await page.wait_for_load_state("networkidle", timeout=15000)
await anyio.sleep(2)
# Verify the save was successful by checking network response
if credentials_response_status == 200:
logger.info(f"✓ App password saved successfully for {username}")
return True
else:
logger.error(
f"App password save failed for {username}, status: {credentials_response_status}"
)
screenshot_path = f"/tmp/astrolabe_save_failed_{username}.png"
await page.screenshot(path=screenshot_path)
return False
def get_background_sync_credentials(username: str) -> dict | None:
"""Get background sync credentials for a user from the database.
Args:
username: Nextcloud username
Returns:
Dict with credential details, or None if not found
"""
query = f"""
SELECT configkey, configvalue
FROM oc_preferences
WHERE userid = '{username}'
AND appid = 'astrolabe'
AND configkey IN ('background_sync_password', 'background_sync_type', 'background_sync_provisioned_at')
ORDER BY configkey;
"""
try:
result = subprocess.run(
[
"docker",
"compose",
"exec",
"-T",
"db",
"mariadb",
"-u",
"root",
"-ppassword",
"nextcloud",
"-e",
query,
],
capture_output=True,
text=True,
timeout=10,
)
output = result.stdout
if "background_sync_type" in output:
return {
"has_password": "background_sync_password" in output,
"has_type": "background_sync_type" in output,
"has_timestamp": "background_sync_provisioned_at" in output,
"is_app_password": "app_password" in output,
}
return None
except Exception as e:
logger.error(f"Error getting credentials for {username}: {e}")
return None
def delete_user_credentials(username: str) -> bool:
"""Delete all stored credentials for a user (for cleanup).
Args:
username: Nextcloud username
Returns:
True if successful
"""
query = f"""
DELETE FROM oc_preferences
WHERE userid = '{username}'
AND appid = 'astrolabe'
AND configkey IN ('oauth_tokens', 'background_sync_password', 'background_sync_type', 'background_sync_provisioned_at');
"""
try:
result = subprocess.run(
[
"docker",
"compose",
"exec",
"-T",
"db",
"mariadb",
"-u",
"root",
"-ppassword",
"nextcloud",
"-e",
query,
],
capture_output=True,
text=True,
timeout=10,
)
logger.info(f"Deleted credentials for {username}")
return result.returncode == 0
except Exception as e:
logger.error(f"Error deleting credentials for {username}: {e}")
return False
@pytest.mark.integration
@pytest.mark.oauth
async def test_app_password_storage_and_cleanup(
browser,
nc_client,
test_users_setup,
configure_astrolabe_for_mcp_server,
):
"""Test that app passwords are stored and cleaned up correctly.
This test verifies:
1. User can save app password in Astrolabe settings
2. Password is stored encrypted in the database
3. Credentials can be revoked and are deleted from database
Note: In hybrid mode (mcp-multi-user-basic), this only tests Step 2
(app password storage). The "Active" badge requires both OAuth and
app password, which is tested separately.
"""
# Configure Astrolabe for mcp-multi-user-basic
logger.info("Configuring Astrolabe for mcp-multi-user-basic server...")
await configure_astrolabe_for_mcp_server(
mcp_server_internal_url="http://mcp-multi-user-basic:8000",
mcp_server_public_url="http://localhost:8003",
)
username = "alice"
user_config = test_users_setup[username]
password = user_config["password"]
# Cleanup any existing credentials
delete_user_credentials(username)
context = await browser.new_context(ignore_https_errors=True)
page = await context.new_page()
try:
# Step 1: Login
await login_to_nextcloud(page, username, password)
# Step 2: Verify no credentials exist initially
initial_creds = get_background_sync_credentials(username)
assert initial_creds is None, f"Expected no credentials, found: {initial_creds}"
logger.info("✓ Verified no initial credentials")
# Step 3: Generate app password
app_password = await generate_app_password(page, username)
assert app_password, "Failed to generate app password"
# Step 4: Save app password in Astrolabe
save_success = await save_app_password_in_astrolabe(
page, username, app_password
)
assert save_success, "Failed to save app password"
# Step 5: Verify credentials are stored in database
stored_creds = get_background_sync_credentials(username)
assert stored_creds is not None, "Expected credentials to be stored"
assert stored_creds["has_password"], "Expected password to be stored"
assert stored_creds["has_type"], "Expected type to be stored"
assert stored_creds["is_app_password"], "Expected type to be 'app_password'"
logger.info("✓ Verified credentials stored in database")
# Step 6: Verify password is encrypted (not plaintext)
query = f"""
SELECT configvalue
FROM oc_preferences
WHERE userid = '{username}'
AND appid = 'astrolabe'
AND configkey = 'background_sync_password';
"""
result = subprocess.run(
[
"docker",
"compose",
"exec",
"-T",
"db",
"mariadb",
"-u",
"root",
"-ppassword",
"nextcloud",
"-N",
"-e",
query,
],
capture_output=True,
text=True,
timeout=10,
)
encrypted_value = result.stdout.strip()
assert app_password not in encrypted_value, "Password appears in plaintext!"
assert len(encrypted_value) > len(app_password), (
"Encrypted value should be longer"
)
logger.info("✓ Verified password is encrypted")
finally:
await context.close()
# Cleanup
delete_user_credentials(username)
@pytest.mark.integration
@pytest.mark.oauth
async def test_credential_isolation_between_users(
browser,
nc_client,
test_users_setup,
configure_astrolabe_for_mcp_server,
):
"""Test that credentials are properly isolated between users.
This test verifies:
1. Multiple users can provision credentials independently
2. Each user's encrypted credentials are unique
3. Deleting one user's credentials doesn't affect others
"""
await configure_astrolabe_for_mcp_server(
mcp_server_internal_url="http://mcp-multi-user-basic:8000",
mcp_server_public_url="http://localhost:8003",
)
test_users = ["alice", "bob"]
user_passwords = {}
# Cleanup all users first
for username in test_users:
delete_user_credentials(username)
# Provision each user
for username in test_users:
user_config = test_users_setup[username]
password = user_config["password"]
context = await browser.new_context(ignore_https_errors=True)
page = await context.new_page()
try:
await login_to_nextcloud(page, username, password)
app_password = await generate_app_password(
page, username, f"Test {username}"
)
save_success = await save_app_password_in_astrolabe(
page, username, app_password
)
assert save_success, f"Failed to save app password for {username}"
user_passwords[username] = app_password
# Verify stored
creds = get_background_sync_credentials(username)
assert creds is not None, f"Credentials not stored for {username}"
logger.info(f"✓ Credentials provisioned for {username}")
finally:
await context.close()
# Verify isolation - get encrypted values
encrypted_values = {}
for username in test_users:
query = f"""
SELECT configvalue
FROM oc_preferences
WHERE userid = '{username}'
AND appid = 'astrolabe'
AND configkey = 'background_sync_password';
"""
result = subprocess.run(
[
"docker",
"compose",
"exec",
"-T",
"db",
"mariadb",
"-u",
"root",
"-ppassword",
"nextcloud",
"-N",
"-e",
query,
],
capture_output=True,
text=True,
timeout=10,
)
encrypted_values[username] = result.stdout.strip()
# Different users should have different encrypted values
assert encrypted_values["alice"] != encrypted_values["bob"], (
"Different users should have different encrypted values"
)
logger.info("✓ Verified credentials are unique per user")
# Delete alice's credentials and verify bob's are unaffected
delete_user_credentials("alice")
alice_creds = get_background_sync_credentials("alice")
bob_creds = get_background_sync_credentials("bob")
assert alice_creds is None, "Alice's credentials should be deleted"
assert bob_creds is not None, "Bob's credentials should still exist"
logger.info("✓ Verified credential deletion is isolated")
# Cleanup
for username in test_users:
delete_user_credentials(username)
@pytest.mark.integration
@pytest.mark.oauth
async def test_credential_revoke_and_reprovision(
browser,
nc_client,
test_users_setup,
configure_astrolabe_for_mcp_server,
):
"""Test that credentials can be revoked and reprovisioned.
This test verifies:
1. User provisions credentials
2. User revokes credentials (deletes from database)
3. User provisions again with new app password
4. New credentials are stored correctly
Note: The UI prevents overwriting credentials directly - users must
revoke first before provisioning new credentials.
"""
await configure_astrolabe_for_mcp_server(
mcp_server_internal_url="http://mcp-multi-user-basic:8000",
mcp_server_public_url="http://localhost:8003",
)
username = "alice"
user_config = test_users_setup[username]
password = user_config["password"]
delete_user_credentials(username)
context = await browser.new_context(ignore_https_errors=True)
page = await context.new_page()
try:
await login_to_nextcloud(page, username, password)
# First provisioning
app_password_1 = await generate_app_password(page, username, "First Password")
await save_app_password_in_astrolabe(page, username, app_password_1)
# Get first encrypted value
query = f"""
SELECT configvalue
FROM oc_preferences
WHERE userid = '{username}'
AND appid = 'astrolabe'
AND configkey = 'background_sync_password';
"""
result1 = subprocess.run(
[
"docker",
"compose",
"exec",
"-T",
"db",
"mariadb",
"-u",
"root",
"-ppassword",
"nextcloud",
"-N",
"-e",
query,
],
capture_output=True,
text=True,
timeout=10,
)
first_encrypted = result1.stdout.strip()
assert first_encrypted, "First credential should be stored"
logger.info("✓ First credential stored")
# Revoke credentials (simulating user clicking "Revoke Access")
delete_user_credentials(username)
logger.info("✓ Credentials revoked")
# Verify credentials are gone
creds_after_revoke = get_background_sync_credentials(username)
assert creds_after_revoke is None, "Credentials should be deleted after revoke"
# Second provisioning with different password
app_password_2 = await generate_app_password(page, username, "Second Password")
await save_app_password_in_astrolabe(page, username, app_password_2)
result2 = subprocess.run(
[
"docker",
"compose",
"exec",
"-T",
"db",
"mariadb",
"-u",
"root",
"-ppassword",
"nextcloud",
"-N",
"-e",
query,
],
capture_output=True,
text=True,
timeout=10,
)
second_encrypted = result2.stdout.strip()
assert second_encrypted, "Second credential should be stored"
logger.info("✓ Second credential stored")
# Verify the encrypted values are different (different passwords)
assert first_encrypted != second_encrypted, (
"Different passwords should produce different encrypted values"
)
# Verify only one row exists
count_query = f"""
SELECT COUNT(*)
FROM oc_preferences
WHERE userid = '{username}'
AND appid = 'astrolabe'
AND configkey = 'background_sync_password';
"""
count_result = subprocess.run(
[
"docker",
"compose",
"exec",
"-T",
"db",
"mariadb",
"-u",
"root",
"-ppassword",
"nextcloud",
"-N",
"-e",
count_query,
],
capture_output=True,
text=True,
timeout=10,
)
count = int(count_result.stdout.strip())
assert count == 1, f"Expected 1 credential row, found {count}"
logger.info("✓ Verified clean reprovision after revoke")
finally:
await context.close()
delete_user_credentials(username)