test(astrolabe): migrate suite to session-JWT auth model
Astrolabe was refactored to mint session-derived JWTs (TokenGenerationRequest Event) and a one-click background-indexing opt-in, dropping the OAuth authorize/callback/refresh surface. Bump the submodule and bring the test suite in line: - New test_astrolabe_session_jwt_search.py: a logged-in user searches via the minted JWT with no provisioning (replaces the obsolete login_flow_provisioning OAuth-authorize test; token_refresh test deleted — refresh flow is gone). - settings_buttons: assert the new revoke endpoint + that oauth/disconnect is gone (404). - multi_user_background_sync / plotly / chunk_context: drop the OAuth authorize step; provision via the one-click "Enable background indexing" button (#mcp-enable-background-button -> #mcp-revoke-background-button) instead of generating + pasting an app password. - docker-compose.yml: mount the astrolabe submodule into the app container. - third_party/astrolabe: bump to the one-click opt-in commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ae54956f27
commit
4ed228613e
+1
-1
@@ -36,7 +36,7 @@ services:
|
||||
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
|
||||
# The post-installation hook will register /opt/apps as an additional app directory
|
||||
#- ./third_party:/opt/apps:ro
|
||||
#- ./third_party/astrolabe:/opt/apps/astrolabe:ro
|
||||
- ./third_party/astrolabe:/opt/apps/astrolabe:ro
|
||||
#- ./third_party/oidc:/opt/apps/oidc:ro
|
||||
environment:
|
||||
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
||||
|
||||
@@ -133,7 +133,6 @@ async def test_chunk_context_endpoint_uses_app_password(
|
||||
try:
|
||||
await login_to_nextcloud(page, username, password)
|
||||
auth_result = await complete_astrolabe_authorization(page, username, password)
|
||||
assert auth_result["step1"], "OAuth authorization did not complete"
|
||||
assert auth_result["step2"], "App password provisioning did not complete"
|
||||
|
||||
auth_header = _build_basic_auth_header(username, password)
|
||||
|
||||
@@ -7,18 +7,17 @@ lives in a separate repository (https://github.com/cbcoutinho/astrolabe).
|
||||
|
||||
This test verifies that multiple users can independently:
|
||||
1. Log in to Nextcloud
|
||||
2. Generate an app password in Security settings
|
||||
3. Enter the app password in Astrolabe personal settings
|
||||
4. Enable background sync for the mcp-multi-user-basic service
|
||||
5. Verify app password is stored in the database
|
||||
2. Click the one-click "Enable background indexing" opt-in in Astrolabe settings
|
||||
3. Have a dedicated app password minted from their session and handed to the
|
||||
MCP server (core/getapppassword — no Security-settings step, no copy-paste)
|
||||
4. Verify the app password is stored in the database
|
||||
|
||||
Tests the complete app password provisioning flow:
|
||||
user login → Security settings → app password generation → Astrolabe settings →
|
||||
app password entry → background sync activation → database verification.
|
||||
Tests the one-click background-indexing provisioning flow:
|
||||
user login → Astrolabe settings → Enable background indexing → session app
|
||||
password minted + forwarded to MCP → background sync active → DB verification.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
import anyio
|
||||
@@ -91,621 +90,62 @@ async def navigate_to_astrolabe_settings(page: Page):
|
||||
logger.info("✓ Successfully loaded Astrolabe settings page")
|
||||
|
||||
|
||||
async def authorize_search_access(page: Page, username: str) -> bool:
|
||||
"""Complete Step 1: OAuth Authorization for Astrolabe.
|
||||
async def enable_background_sync(page: Page, username: str) -> bool:
|
||||
"""Provision background indexing via the one-click opt-in button.
|
||||
|
||||
Handles the OAuth flow:
|
||||
1. Check if already authorized (Step 1 shows "Complete")
|
||||
2. Click "Authorize" link
|
||||
3. Handle Nextcloud OIDC consent screen
|
||||
4. Wait for redirect back to Astrolabe settings
|
||||
5. Verify "Complete" badge appears on Step 1
|
||||
The refactored settings page mints a dedicated app password from the
|
||||
current Nextcloud session (core/getapppassword) and hands it to the MCP
|
||||
server — there is no app-password generation in Security settings and no
|
||||
copy-paste. Idempotent: if already enabled (the revoke form is shown
|
||||
instead of the enable button), returns True without acting.
|
||||
|
||||
Args:
|
||||
page: Playwright page instance (must be on Astrolabe settings page)
|
||||
username: Username for logging
|
||||
|
||||
Returns:
|
||||
True if authorization completed successfully
|
||||
"""
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
logger.info("Authorizing search access (Step 1) for %s...", username)
|
||||
|
||||
# Check if already on Astrolabe settings page, if not navigate there
|
||||
if "/settings/user/astrolabe" not in page.url:
|
||||
await navigate_to_astrolabe_settings(page)
|
||||
|
||||
# Wait for page to fully render
|
||||
await anyio.sleep(1)
|
||||
|
||||
# Check if already authorized (either "Active" badge or Step 1 "Complete" badge)
|
||||
try:
|
||||
# Check for "Active" badge (fully configured state)
|
||||
active_badge = page.get_by_text("Active", exact=True)
|
||||
if await active_badge.count() > 0 and await active_badge.is_visible():
|
||||
logger.info("✓ Already fully authorized for %s (Active badge)", username)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
step1_section = page.locator('h4:has-text("Step 1")')
|
||||
if await step1_section.count() > 0:
|
||||
# Look for "Complete" text in the Step 1 section's parent
|
||||
step1_parent = step1_section.locator("..")
|
||||
complete_badge = step1_parent.get_by_text("Complete", exact=True)
|
||||
if await complete_badge.count() > 0 and await complete_badge.is_visible():
|
||||
logger.info("✓ Step 1 already complete for %s", username)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Find and click the "Authorize" button
|
||||
authorize_button = page.locator('a.button.primary:has-text("Authorize")')
|
||||
|
||||
try:
|
||||
await authorize_button.wait_for(timeout=5000, state="visible")
|
||||
logger.info("Found Authorize button for %s", username)
|
||||
except Exception:
|
||||
# Take screenshot for debugging
|
||||
screenshot_path = f"/tmp/astrolabe_no_authorize_button_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(
|
||||
"Could not find Authorize button for %s. Screenshot: %s",
|
||||
username,
|
||||
screenshot_path,
|
||||
)
|
||||
raise ValueError(f"Authorize button not found for {username}")
|
||||
|
||||
# Click the Authorize button - this will redirect to OAuth provider
|
||||
# Use force=True to bypass stability check which can timeout due to CSS transitions
|
||||
await authorize_button.click(force=True)
|
||||
logger.info("Clicked Authorize button for %s", username)
|
||||
|
||||
# Wait for OAuth redirect to complete
|
||||
await page.wait_for_load_state("networkidle", timeout=30000)
|
||||
logger.info("After networkidle, current URL: %s", page.url)
|
||||
|
||||
# Take screenshot to see current state
|
||||
await page.screenshot(path=f"/tmp/astrolabe_after_authorize_{username}.png")
|
||||
logger.info("Screenshot saved: /tmp/astrolabe_after_authorize_%s.png", username)
|
||||
|
||||
# Handle OIDC consent screen if present
|
||||
consent_handled = await _handle_oauth_consent_screen(page, username)
|
||||
if consent_handled:
|
||||
logger.info("✓ OAuth consent granted for %s", username)
|
||||
else:
|
||||
logger.info(
|
||||
"No consent screen required for %s (may be previously authorized)", username
|
||||
)
|
||||
|
||||
# Wait for redirect back to Astrolabe settings
|
||||
# The OAuth callback will redirect back to /settings/user/astrolabe
|
||||
try:
|
||||
await page.wait_for_url(
|
||||
f"**{nextcloud_url}/settings/user/astrolabe**", timeout=30000
|
||||
)
|
||||
logger.info("Redirected back to Astrolabe settings for %s", username)
|
||||
except Exception:
|
||||
# Check if we're already on settings page
|
||||
if "/settings/user/astrolabe" not in page.url:
|
||||
logger.warning(
|
||||
"Not redirected to Astrolabe settings, current URL: %s", page.url
|
||||
)
|
||||
# Navigate manually
|
||||
await page.goto(
|
||||
f"{nextcloud_url}/settings/user/astrolabe", wait_until="networkidle"
|
||||
)
|
||||
|
||||
# Wait for page to reload and render
|
||||
await anyio.sleep(2)
|
||||
|
||||
# Verify authorization completed - check for various success indicators
|
||||
# When fully configured, shows "Active" badge; when only Step 1 done, shows "Complete"
|
||||
try:
|
||||
# First check if "Active" badge is shown (fully configured state)
|
||||
active_badge = page.get_by_text("Active", exact=True)
|
||||
if await active_badge.count() > 0 and await active_badge.is_visible():
|
||||
logger.info(
|
||||
"✓ OAuth authorization complete for %s (Active badge)", username
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Check for Step 1 "Complete" badge (partial configuration)
|
||||
step1_section = page.locator('h4:has-text("Step 1")')
|
||||
if await step1_section.count() > 0:
|
||||
step1_parent = step1_section.locator("..")
|
||||
complete_badge = step1_parent.get_by_text("Complete", exact=True)
|
||||
await complete_badge.wait_for(timeout=5000, state="visible")
|
||||
logger.info("✓ Step 1 OAuth authorization complete for %s", username)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Neither badge found - authorization failed
|
||||
screenshot_path = f"/tmp/astrolabe_step1_not_complete_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(
|
||||
"Authorization badge not visible for %s. Screenshot: %s",
|
||||
username,
|
||||
screenshot_path,
|
||||
)
|
||||
raise ValueError(f"OAuth authorization did not complete for {username}")
|
||||
|
||||
|
||||
async def _handle_oauth_consent_screen(page: Page, username: str) -> bool:
|
||||
"""Handle the OIDC consent screen during OAuth flow.
|
||||
|
||||
Reuses the proven pattern from tests/conftest.py.
|
||||
|
||||
Args:
|
||||
page: Playwright page instance
|
||||
username: Username for logging
|
||||
|
||||
Returns:
|
||||
True if consent was handled, False if no consent screen was found
|
||||
"""
|
||||
try:
|
||||
logger.info("Checking for consent screen at URL: %s", page.url)
|
||||
|
||||
# Check if consent screen is present - try multiple selectors
|
||||
# The consent screen may be #oidc-consent or use a different format
|
||||
consent_div = await page.query_selector("#oidc-consent")
|
||||
|
||||
if consent_div:
|
||||
logger.info("Consent screen detected via #oidc-consent for %s", username)
|
||||
# Get consent screen data attributes for logging
|
||||
client_name = await consent_div.get_attribute("data-client-name")
|
||||
scopes_attr = await consent_div.get_attribute("data-scopes")
|
||||
logger.info(" Client: %s", client_name)
|
||||
logger.info(" Requested scopes: %s", scopes_attr)
|
||||
else:
|
||||
# Check for Allow button directly (different consent screen format)
|
||||
allow_button = page.locator('button:has-text("Allow")')
|
||||
if await allow_button.count() > 0:
|
||||
logger.info("Consent screen detected via Allow button for %s", username)
|
||||
else:
|
||||
logger.info("No consent screen found for %s at %s", username, page.url)
|
||||
await page.screenshot(path=f"/tmp/no_consent_screen_{username}.png")
|
||||
logger.info("Screenshot: /tmp/no_consent_screen_%s.png", username)
|
||||
return False
|
||||
|
||||
# Wait for Vue.js to render the Allow button
|
||||
try:
|
||||
await page.wait_for_selector('button:has-text("Allow")', timeout=10000)
|
||||
logger.info(" Allow button rendered by Vue.js")
|
||||
except Exception as e:
|
||||
screenshot_path = f"/tmp/consent_no_allow_button_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(" Timeout waiting for Allow button: %s", e)
|
||||
raise
|
||||
|
||||
# Check all scope checkboxes
|
||||
scope_checkboxes = await page.query_selector_all('input[type="checkbox"]')
|
||||
if scope_checkboxes:
|
||||
logger.info(" Found %s scope checkboxes", len(scope_checkboxes))
|
||||
for i, checkbox in enumerate(scope_checkboxes):
|
||||
is_checked = await checkbox.is_checked()
|
||||
is_disabled = await checkbox.is_disabled()
|
||||
if not is_checked and not is_disabled:
|
||||
await checkbox.check()
|
||||
logger.info(" ✓ Checked scope checkbox %s", i + 1)
|
||||
|
||||
# Click the Allow button using JavaScript (handles viewport issues)
|
||||
allow_button_locator = page.locator('button:has-text("Allow")')
|
||||
|
||||
# Debug: take screenshot before clicking Allow
|
||||
await page.screenshot(path=f"/tmp/consent_before_allow_{username}.png")
|
||||
logger.info(
|
||||
" Screenshot before Allow: /tmp/consent_before_allow_%s.png", username
|
||||
)
|
||||
|
||||
button_count = await allow_button_locator.count()
|
||||
logger.info(" Found %s Allow button(s)", button_count)
|
||||
|
||||
if button_count > 0:
|
||||
current_url = page.url
|
||||
logger.info(" Current URL: %s", current_url)
|
||||
logger.info(" Clicking Allow button for %s...", username)
|
||||
|
||||
# Use JavaScript click to handle consent buttons (proven pattern from conftest.py)
|
||||
# This is more reliable than Playwright's click for Vue.js rendered buttons
|
||||
await page.evaluate(
|
||||
"""
|
||||
const buttons = document.querySelectorAll('button');
|
||||
for (const btn of buttons) {
|
||||
if (btn.textContent.trim() === 'Allow') {
|
||||
btn.click();
|
||||
break;
|
||||
}
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
# Wait for URL to change (Vue.js uses window.location.href after fetch)
|
||||
# networkidle doesn't detect fetch-based redirects
|
||||
try:
|
||||
await page.wait_for_url(
|
||||
lambda url: url != current_url,
|
||||
timeout=30000,
|
||||
)
|
||||
logger.info(" URL changed to: %s", page.url)
|
||||
except Exception as wait_error:
|
||||
# If URL didn't change, check console for errors
|
||||
logger.warning(" URL didn't change after click: %s", wait_error)
|
||||
await page.screenshot(path=f"/tmp/consent_after_allow_{username}.png")
|
||||
|
||||
# Try alternative: manually POST consent and navigate
|
||||
logger.info(" Trying manual consent submission...")
|
||||
try:
|
||||
redirect_url = await page.evaluate(
|
||||
"""
|
||||
async () => {
|
||||
const selectedScopes = Array.from(document.querySelectorAll('input[type="checkbox"]:checked'))
|
||||
.map(cb => cb.value).join(' ');
|
||||
|
||||
const response = await fetch('/index.php/apps/oidc/consent/grant', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'requesttoken': OC.requestToken,
|
||||
},
|
||||
body: 'scopes=' + encodeURIComponent(selectedScopes),
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
return response.url || '/index.php/apps/oidc/authorize';
|
||||
}
|
||||
"""
|
||||
)
|
||||
logger.info(" Manual consent returned URL: %s", redirect_url)
|
||||
await page.goto(redirect_url, wait_until="networkidle")
|
||||
except Exception as manual_error:
|
||||
logger.error(" Manual consent also failed: %s", manual_error)
|
||||
raise
|
||||
|
||||
await page.screenshot(path=f"/tmp/consent_after_allow_{username}.png")
|
||||
logger.info(" Consent granted for %s", username)
|
||||
return True
|
||||
else:
|
||||
logger.error(" Allow button not found for %s", username)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error handling consent screen for %s: %s", username, e)
|
||||
raise
|
||||
|
||||
|
||||
async def generate_app_password(
|
||||
page: Page, username: str, app_name: str = "Astrolabe Background Sync"
|
||||
) -> str:
|
||||
"""Generate an app password in Nextcloud Security settings.
|
||||
|
||||
Args:
|
||||
page: Playwright page instance (must be authenticated)
|
||||
page: Playwright page instance (must be logged in)
|
||||
username: Username (for logging)
|
||||
app_name: Name for the app password
|
||||
|
||||
Returns:
|
||||
The generated app password string
|
||||
True once background indexing is enabled.
|
||||
"""
|
||||
logger.info("Generating app password for %s...", 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 (selector confirmed via Playwright MCP)
|
||||
app_password_input = page.locator('input[placeholder="App name"]')
|
||||
await app_password_input.fill(app_name)
|
||||
logger.info("Entered app name: %s", app_name)
|
||||
|
||||
# Wait for Vue.js to react and enable the button (needs 1 second, not 0.5)
|
||||
await anyio.sleep(1.0)
|
||||
logger.info("Waited for Vue.js to process input and enable button")
|
||||
|
||||
# Click the create button - use force=True to bypass stability check (CSS transitions)
|
||||
create_button = page.locator(
|
||||
'button[type="submit"]:has-text("Create new app password")'
|
||||
)
|
||||
try:
|
||||
await create_button.click(force=True, timeout=10000)
|
||||
except Exception:
|
||||
# Fallback: JavaScript click
|
||||
logger.info("Using JavaScript click for create button...")
|
||||
await page.evaluate(
|
||||
"""
|
||||
const btn = document.querySelector('button[type="submit"]');
|
||||
if (btn) btn.click();
|
||||
"""
|
||||
)
|
||||
logger.info("Clicked create app password button")
|
||||
|
||||
# Wait for app password to be generated and displayed in the dialog
|
||||
await anyio.sleep(3) # Give it more time to generate and display
|
||||
|
||||
# Debug screenshot after clicking create
|
||||
await page.screenshot(path=f"/tmp/app_password_after_create_{username}.png")
|
||||
logger.info(
|
||||
"Screenshot after create: /tmp/app_password_after_create_%s.png", username
|
||||
)
|
||||
|
||||
# Find the Login input field which should have the username value
|
||||
# Then find the Password input field which is in the same form
|
||||
app_password = None
|
||||
try:
|
||||
# Wait for heading "New app password" to appear
|
||||
await page.wait_for_selector('text="New app password"', timeout=10000)
|
||||
logger.info("App password dialog appeared with heading")
|
||||
|
||||
# Get all visible input elements
|
||||
all_inputs = await page.locator('input[type="text"]').all()
|
||||
logger.info("Found %s text input elements", len(all_inputs))
|
||||
|
||||
# Check each input to find the one with the app password
|
||||
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(
|
||||
"Found app password in input %s: '%s' (length: %s)",
|
||||
idx,
|
||||
app_password,
|
||||
len(app_password),
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug("Could not get value from input %s: %s", idx, e)
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to find app password dialog or extract password: %s", e)
|
||||
|
||||
if not app_password:
|
||||
# Take screenshot for debugging
|
||||
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 before returning
|
||||
|
||||
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,
|
||||
):
|
||||
logger.error(
|
||||
"Extracted password does not match expected format: '%s'", app_password
|
||||
)
|
||||
logger.error("Password repr: %s", repr(app_password))
|
||||
screenshot_path = f"/tmp/app_password_invalid_format_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
raise ValueError(
|
||||
f"App password format validation failed. Screenshot: {screenshot_path}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✓ Generated app password for %s: %s... (validated)",
|
||||
username,
|
||||
app_password[:10],
|
||||
)
|
||||
|
||||
# Close dialog with Escape key (bypasses CSS layout issues with h2 intercepting clicks)
|
||||
logger.info("Closing app password dialog with Escape key...")
|
||||
await page.keyboard.press("Escape")
|
||||
await anyio.sleep(0.5) # Wait for dialog close animation
|
||||
logger.info("Closed app password dialog")
|
||||
|
||||
return app_password
|
||||
|
||||
|
||||
async def enable_background_sync_via_app_password(
|
||||
page: Page, username: str, app_password: str
|
||||
):
|
||||
"""Enable background sync by entering app password in Astrolabe settings.
|
||||
|
||||
Args:
|
||||
page: Playwright page instance
|
||||
username: Username (for logging)
|
||||
app_password: App password to enter
|
||||
|
||||
Returns:
|
||||
True if background sync was enabled successfully
|
||||
"""
|
||||
logger.info("Enabling background sync via app password for %s...", username)
|
||||
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
# Set up network request and console listeners BEFORE navigation
|
||||
network_requests = []
|
||||
network_responses = []
|
||||
console_messages = []
|
||||
|
||||
def log_request(req):
|
||||
network_requests.append(f"{req.method} {req.url}")
|
||||
|
||||
def log_response(resp):
|
||||
response_info = f"{resp.status} {resp.url}"
|
||||
network_responses.append(response_info)
|
||||
logger.info("Response: %s", response_info)
|
||||
|
||||
def log_console(msg):
|
||||
console_messages.append(f"[{msg.type}] {msg.text}")
|
||||
|
||||
page.on("request", log_request)
|
||||
page.on("response", log_response)
|
||||
page.on("console", log_console)
|
||||
|
||||
# Navigate to Astrolabe settings
|
||||
logger.info("Enabling background indexing for %s...", username)
|
||||
await page.goto(
|
||||
f"{nextcloud_url}/settings/user/astrolabe", wait_until="networkidle"
|
||||
"http://localhost:8080/settings/user/astrolabe", wait_until="networkidle"
|
||||
)
|
||||
|
||||
# Wait for page to load
|
||||
await anyio.sleep(1)
|
||||
|
||||
# Check if already complete (look for Step 2 "Complete" badge or overall "Active" state)
|
||||
try:
|
||||
# First check for overall "Active" badge (both steps complete)
|
||||
active_text = page.get_by_text("Active", exact=True)
|
||||
if await active_text.is_visible(timeout=2000):
|
||||
logger.info("✓ Background sync already active for %s", username)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
if await page.locator("#mcp-revoke-background-button").count() > 0:
|
||||
logger.info("✓ Background indexing already enabled for %s", username)
|
||||
return True
|
||||
|
||||
try:
|
||||
# Check for Step 2 "Complete" badge (app password already set)
|
||||
step2_section = page.locator('h4:has-text("Step 2")')
|
||||
if await step2_section.count() > 0:
|
||||
step2_parent = step2_section.locator("..")
|
||||
complete_badge = step2_parent.get_by_text("Complete", exact=True)
|
||||
if await complete_badge.count() > 0 and await complete_badge.is_visible():
|
||||
logger.info("✓ Step 2 (app password) already complete for %s", username)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Find the app password input field using the placeholder text
|
||||
# Based on manual testing: textbox with placeholder "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
|
||||
app_password_input = page.get_by_placeholder("xxxxx-xxxxx-xxxxx-xxxxx-xxxxx")
|
||||
enable_button = page.locator("#mcp-enable-background-button")
|
||||
await enable_button.wait_for(timeout=5000, state="visible")
|
||||
await enable_button.click()
|
||||
logger.info("Clicked 'Enable background indexing' for %s", username)
|
||||
|
||||
# On success the page JS reloads to the enabled state (revoke form shown).
|
||||
try:
|
||||
await app_password_input.wait_for(timeout=5000, state="visible")
|
||||
logger.info("Found app password input field")
|
||||
await page.locator("#mcp-revoke-background-button").wait_for(
|
||||
timeout=15000, state="visible"
|
||||
)
|
||||
logger.info("✓ Background indexing enabled for %s", username)
|
||||
return True
|
||||
except Exception:
|
||||
# Take screenshot for debugging
|
||||
screenshot_path = f"/tmp/astrolabe_no_password_field_{username}.png"
|
||||
screenshot_path = f"/tmp/astrolabe_enable_failed_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
raise ValueError(
|
||||
f"Could not find app password input field for {username}. Screenshot: {screenshot_path}"
|
||||
f"Background indexing did not enable for {username}. "
|
||||
f"Screenshot: {screenshot_path}"
|
||||
)
|
||||
|
||||
# Enter the app password
|
||||
await app_password_input.fill(app_password)
|
||||
logger.info("Entered app password for %s", username)
|
||||
|
||||
# Wait a moment for any validation to complete
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
# Take screenshot before clicking Save to check for warnings
|
||||
screenshot_path = f"/tmp/before_save_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.info("Screenshot taken before Save: %s", screenshot_path)
|
||||
|
||||
# Find and click the Save button
|
||||
save_button = page.get_by_role("button", name="Save")
|
||||
|
||||
# Check if Save button is enabled
|
||||
is_disabled = await save_button.is_disabled()
|
||||
logger.info("Save button disabled state: %s", is_disabled)
|
||||
|
||||
await save_button.click()
|
||||
logger.info("Clicked Save button")
|
||||
|
||||
# Give the request time to complete before checking logs
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
# Log network requests after clicking Save
|
||||
logger.info("Network requests after Save for %s:", username)
|
||||
for req in network_requests[-10:]: # Last 10 requests
|
||||
logger.info(" %s", req)
|
||||
|
||||
# Log network responses after clicking Save
|
||||
logger.info("Network responses after Save for %s:", username)
|
||||
for resp in network_responses[-10:]: # Last 10 responses
|
||||
logger.info(" %s", resp)
|
||||
|
||||
# Check specifically for the credentials POST response
|
||||
credentials_responses = [
|
||||
r for r in network_responses if "background-sync/credentials" in r
|
||||
]
|
||||
if credentials_responses:
|
||||
logger.info("Credentials endpoint response: %s", credentials_responses[-1])
|
||||
if "200" not in credentials_responses[-1]:
|
||||
logger.error(
|
||||
"Credentials POST did not return 200 OK: %s", credentials_responses[-1]
|
||||
)
|
||||
else:
|
||||
logger.warning("No response found for credentials endpoint!")
|
||||
|
||||
# Wait for the page to reload after successful save
|
||||
# The JavaScript in personalSettings.js does: setTimeout(() => window.location.reload(), 1000)
|
||||
await page.wait_for_load_state("networkidle", timeout=15000)
|
||||
await anyio.sleep(2)
|
||||
|
||||
# Log any console messages
|
||||
if console_messages:
|
||||
logger.info("Console messages for %s:", username)
|
||||
for msg in console_messages:
|
||||
logger.info(" %s", msg)
|
||||
|
||||
# Check for error notifications (toast messages)
|
||||
try:
|
||||
error_toast = page.locator(".toastify.toast-error, .toast-error")
|
||||
if await error_toast.count() > 0:
|
||||
error_text = await error_toast.first.text_content()
|
||||
logger.error("Error notification for %s: %s", username, error_text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Verify Step 2 "Complete" badge or overall "Active" badge appears after reload
|
||||
try:
|
||||
# First try to find "Active" badge (both steps complete)
|
||||
active_text = page.get_by_text("Active", exact=True)
|
||||
if await active_text.count() > 0:
|
||||
await active_text.wait_for(timeout=5000, state="visible")
|
||||
logger.info(
|
||||
"✓ Background sync enabled for %s - Active badge visible", username
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Check for Step 2 "Complete" badge
|
||||
step2_section = page.locator('h4:has-text("Step 2")')
|
||||
if await step2_section.count() > 0:
|
||||
step2_parent = step2_section.locator("..")
|
||||
complete_badge = step2_parent.get_by_text("Complete", exact=True)
|
||||
await complete_badge.wait_for(timeout=5000, state="visible")
|
||||
logger.info(
|
||||
"✓ Step 2 (app password) enabled for %s - Complete badge visible",
|
||||
username,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If neither badge found, raise error
|
||||
screenshot_path = f"/tmp/astrolabe_after_password_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(
|
||||
"Neither Active nor Complete badge appeared for %s. Screenshot: %s",
|
||||
username,
|
||||
screenshot_path,
|
||||
)
|
||||
raise ValueError(f"Background sync setup did not complete for {username}")
|
||||
|
||||
|
||||
async def complete_astrolabe_authorization(
|
||||
page: Page, username: str, password: str
|
||||
) -> dict:
|
||||
"""Complete full Astrolabe two-step authorization.
|
||||
"""Provision background indexing for a user (one-click app-password opt-in).
|
||||
|
||||
Performs the complete authorization flow:
|
||||
1. Navigate to Astrolabe settings
|
||||
2. OAuth authorization (Step 1) if needed
|
||||
3. Generate app password in Security settings
|
||||
4. App password entry (Step 2) if needed
|
||||
The auth refactor dropped the per-user OAuth step entirely — search now
|
||||
uses a session-minted JWT, so the only remaining "authorization" is the
|
||||
one-click background-indexing opt-in (a dedicated app password minted from
|
||||
the session and handed to the MCP server).
|
||||
|
||||
Args:
|
||||
page: Playwright page instance (must be logged in)
|
||||
@@ -713,64 +153,15 @@ async def complete_astrolabe_authorization(
|
||||
password: Nextcloud password (for reference, not used directly)
|
||||
|
||||
Returns:
|
||||
Dict with {"step1": bool, "step2": bool, "app_password": str | None}
|
||||
Dict with {"step1": True (no-op, kept for caller compat),
|
||||
"step2": bool, "app_password": None}
|
||||
"""
|
||||
logger.info("Starting full Astrolabe authorization for %s...", username)
|
||||
logger.info("Provisioning Astrolabe background indexing for %s...", username)
|
||||
|
||||
result = {"step1": False, "step2": False, "app_password": None}
|
||||
|
||||
# Navigate to Astrolabe settings
|
||||
await navigate_to_astrolabe_settings(page)
|
||||
|
||||
# Step 1: OAuth authorization
|
||||
try:
|
||||
result["step1"] = await authorize_search_access(page, username)
|
||||
logger.info("✓ Step 1 complete for %s", username)
|
||||
except Exception as e:
|
||||
logger.error("Step 1 failed for %s: %s", username, e)
|
||||
raise
|
||||
|
||||
# Navigate back to settings if needed (OAuth might have redirected elsewhere)
|
||||
if "/settings/user/astrolabe" not in page.url:
|
||||
await navigate_to_astrolabe_settings(page)
|
||||
|
||||
# Check if Step 2 is already complete
|
||||
try:
|
||||
step2_section = page.locator('h4:has-text("Step 2")')
|
||||
if await step2_section.count() > 0:
|
||||
step2_parent = step2_section.locator("..")
|
||||
complete_badge = step2_parent.get_by_text("Complete", exact=True)
|
||||
if await complete_badge.count() > 0 and await complete_badge.is_visible():
|
||||
logger.info("✓ Step 2 already complete for %s", username)
|
||||
result["step2"] = True
|
||||
return result
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Also check for overall "Active" badge
|
||||
try:
|
||||
active_text = page.get_by_text("Active", exact=True)
|
||||
if await active_text.count() > 0 and await active_text.is_visible():
|
||||
logger.info("✓ Authorization already fully active for %s", username)
|
||||
result["step2"] = True
|
||||
return result
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Step 2: Generate app password and enter it
|
||||
app_password = await generate_app_password(page, username)
|
||||
result["app_password"] = app_password
|
||||
|
||||
try:
|
||||
result["step2"] = await enable_background_sync_via_app_password(
|
||||
page, username, app_password
|
||||
)
|
||||
logger.info("✓ Step 2 complete for %s", username)
|
||||
except Exception as e:
|
||||
logger.error("Step 2 failed for %s: %s", username, e)
|
||||
raise
|
||||
|
||||
logger.info("✓ Full Astrolabe authorization complete for %s", username)
|
||||
# step1 is retained as always-True for backward compat with callers — there
|
||||
# is no longer an OAuth authorize step to perform.
|
||||
result = {"step1": True, "step2": False, "app_password": None}
|
||||
result["step2"] = await enable_background_sync(page, username)
|
||||
return result
|
||||
|
||||
|
||||
@@ -993,15 +384,11 @@ async def test_multi_user_astrolabe_background_sync_enablement(
|
||||
# Step 1: Login to Nextcloud
|
||||
await login_to_nextcloud(page, username, password)
|
||||
|
||||
# Step 2: Generate app password in Security settings
|
||||
app_password = await generate_app_password(page, username)
|
||||
# Step 2: One-click "Enable background indexing" (mints a dedicated
|
||||
# app password from the session and hands it to the MCP server).
|
||||
sync_enabled = await enable_background_sync(page, username)
|
||||
|
||||
# Step 3: Enable background sync by entering app password in Astrolabe
|
||||
sync_enabled = await enable_background_sync_via_app_password(
|
||||
page, username, app_password
|
||||
)
|
||||
|
||||
# Step 4: Verify app password was stored in database
|
||||
# Step 3: Verify app password was stored in database
|
||||
app_password_stored = await verify_app_password_created(username)
|
||||
|
||||
# Give it time to complete
|
||||
@@ -1009,7 +396,6 @@ async def test_multi_user_astrolabe_background_sync_enablement(
|
||||
|
||||
results[username] = {
|
||||
"settings_accessed": True,
|
||||
"app_password_generated": bool(app_password),
|
||||
"sync_enabled": sync_enabled,
|
||||
"app_password_stored": app_password_stored,
|
||||
"background_sync_active": sync_enabled and app_password_stored,
|
||||
@@ -1017,7 +403,6 @@ async def test_multi_user_astrolabe_background_sync_enablement(
|
||||
|
||||
logger.info("\\n%s results:", username)
|
||||
logger.info(" Settings accessed: ✓")
|
||||
logger.info(" App password generated: %s", "✓" if app_password else "✗")
|
||||
logger.info(" Sync enabled: %s", "✓" if sync_enabled else "✗")
|
||||
logger.info(
|
||||
" App password stored: %s", "✓" if app_password_stored else "✗"
|
||||
@@ -1081,7 +466,7 @@ async def test_multi_user_astrolabe_background_sync_enablement(
|
||||
|
||||
|
||||
async def revoke_background_sync_access(page: Page, username: str) -> bool:
|
||||
"""Revoke background sync access by clicking the Revoke Access button.
|
||||
"""Revoke background sync access by clicking the "Disable background indexing" button.
|
||||
|
||||
Args:
|
||||
page: Playwright page instance (must be authenticated)
|
||||
@@ -1122,37 +507,35 @@ async def revoke_background_sync_access(page: Page, username: str) -> bool:
|
||||
# Wait for page to load
|
||||
await anyio.sleep(1)
|
||||
|
||||
# Check if "Active" badge is visible (indicating background sync is enabled)
|
||||
# The revoke form (#mcp-revoke-background-form) is only rendered while
|
||||
# background indexing is enabled.
|
||||
revoke_button = page.locator("#mcp-revoke-background-button")
|
||||
try:
|
||||
active_text = page.get_by_text("Active", exact=True)
|
||||
if not await active_text.is_visible(timeout=2000):
|
||||
if await revoke_button.count() == 0:
|
||||
logger.warning(
|
||||
"Background sync not active for %s, nothing to revoke", username
|
||||
"Background indexing not enabled for %s, nothing to revoke", username
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
logger.warning("Could not find Active badge for %s", username)
|
||||
logger.warning("Could not find revoke button for %s", username)
|
||||
return False
|
||||
|
||||
# Find the "Revoke Access" button
|
||||
revoke_button = page.get_by_role("button", name="Revoke Access")
|
||||
|
||||
try:
|
||||
await revoke_button.wait_for(timeout=5000, state="visible")
|
||||
logger.info("Found Revoke Access button")
|
||||
logger.info("Found 'Disable background indexing' button")
|
||||
except Exception:
|
||||
screenshot_path = f"/tmp/astrolabe_no_revoke_button_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
raise ValueError(
|
||||
f"Could not find Revoke Access button for {username}. Screenshot: {screenshot_path}"
|
||||
f"Could not find revoke button for {username}. Screenshot: {screenshot_path}"
|
||||
)
|
||||
|
||||
# Set up dialog handler for confirmation dialog
|
||||
page.once("dialog", lambda dialog: dialog.accept())
|
||||
|
||||
# Click the Revoke Access button
|
||||
# Click the "Disable background indexing" button
|
||||
await revoke_button.click()
|
||||
logger.info("Clicked Revoke Access button")
|
||||
logger.info("Clicked the revoke button")
|
||||
|
||||
# Wait for the request to complete and page to reload
|
||||
await page.wait_for_load_state("networkidle", timeout=15000)
|
||||
@@ -1198,12 +581,12 @@ async def revoke_background_sync_access(page: Page, username: str) -> bool:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Verify "Active" badge is no longer visible
|
||||
# After revoke + reload the settings page returns to the un-provisioned
|
||||
# state: the revoke button is gone and the app-password input is shown again.
|
||||
try:
|
||||
active_text = page.get_by_text("Active", exact=True)
|
||||
if await active_text.is_visible(timeout=2000):
|
||||
logger.error("Active badge still visible for %s after revoke!", username)
|
||||
screenshot_path = f"/tmp/astrolabe_revoke_still_active_{username}.png"
|
||||
if await page.locator("#mcp-revoke-background-button").is_visible(timeout=2000):
|
||||
logger.error("Revoke button still visible for %s after revoke!", username)
|
||||
screenshot_path = f"/tmp/astrolabe_revoke_still_enabled_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
return False
|
||||
except Exception:
|
||||
@@ -1280,11 +663,11 @@ async def test_revoke_background_sync_access(
|
||||
test_users_setup,
|
||||
configure_astrolabe_for_mcp_server,
|
||||
):
|
||||
"""Test that users can revoke background sync access via the Revoke Access button.
|
||||
"""Test that users can revoke background sync access via the "Disable background indexing" button.
|
||||
|
||||
This test verifies:
|
||||
1. User enables background sync via app password
|
||||
2. User clicks "Revoke Access" button
|
||||
2. User clicks "Disable background indexing" button
|
||||
3. Confirmation dialog is handled
|
||||
4. POST request is sent to /api/v1/background-sync/credentials/revoke
|
||||
5. "Active" badge disappears from settings page
|
||||
@@ -1319,14 +702,9 @@ async def test_revoke_background_sync_access(
|
||||
# Step 1: Login to Nextcloud
|
||||
await login_to_nextcloud(page, username, password)
|
||||
|
||||
# Step 2: Complete full authorization (OAuth Step 1 + App Password Step 2)
|
||||
# Provision background indexing (app-password opt-in; no OAuth step).
|
||||
auth_result = await complete_astrolabe_authorization(page, username, password)
|
||||
assert auth_result["step1"], (
|
||||
f"OAuth authorization (Step 1) failed for {username}"
|
||||
)
|
||||
assert auth_result["step2"], (
|
||||
f"App password setup (Step 2) failed for {username}"
|
||||
)
|
||||
assert auth_result["step2"], f"App password provisioning failed for {username}"
|
||||
|
||||
# Step 3: Verify background sync is enabled
|
||||
assert await verify_app_password_created(username), (
|
||||
|
||||
@@ -108,7 +108,7 @@ async def navigate_to_astrolabe_main(page: Page):
|
||||
@pytest.mark.multi_user_basic
|
||||
@pytest.mark.timeout(
|
||||
300
|
||||
) # 5 minutes - this test involves OAuth, app password, and vector sync
|
||||
) # 5 minutes - this test involves app-password provisioning + vector sync
|
||||
async def test_astrolabe_plotly_visualization_with_basic_auth(
|
||||
browser,
|
||||
test_users_setup,
|
||||
@@ -143,7 +143,7 @@ async def test_astrolabe_plotly_visualization_with_basic_auth(
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
# Phase 2: Complete full Astrolabe authorization (OAuth + app password)
|
||||
# Phase 2: Provision background indexing (app-password opt-in; no OAuth)
|
||||
await login_to_nextcloud(page, username, password)
|
||||
auth_result = await complete_astrolabe_authorization(page, username, password)
|
||||
logger.info("Authorization result: %s", auth_result)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Astrolabe session-derived JWT search path (card 120 auth refactor).
|
||||
|
||||
Cross-system interface test. The astrolabe app was refactored to mint a
|
||||
short-lived JWT for the current Nextcloud session user on demand (via the
|
||||
`oidc` app's ``TokenGenerationRequestEvent``), replacing the old OAuth
|
||||
authorize + offline_access + stored-refresh-token flow. This test proves the
|
||||
new path end-to-end:
|
||||
|
||||
logged-in NC user → GET /apps/astrolabe/api/search → astrolabe mints a JWT
|
||||
(McpTokenMinter) → calls the MCP server with ``Authorization: Bearer`` →
|
||||
MCP validates the JWT (unified_verifier, aud=astrolabe_client_id) → results.
|
||||
|
||||
The headline behavioural change is that a user needs **no provisioning** to
|
||||
search: there is no authorize redirect and ``has_background_access`` stays
|
||||
False (app-password provisioning is now only for *background indexing*, a
|
||||
separate opt-in covered by the background-sync tests).
|
||||
|
||||
Astrolabe is installed + configured (astrolabe_client_id, mcp_server_url) by
|
||||
the container app-hooks; the test skips if that wiring is absent. Driven over
|
||||
HTTP with BasicAuth (which establishes a Nextcloud session for the request) —
|
||||
no browser needed.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.login_flow]
|
||||
|
||||
NEXTCLOUD_URL = "http://localhost:8080"
|
||||
ASTROLABE_API = f"{NEXTCLOUD_URL}/apps/astrolabe/api"
|
||||
_HEADERS = {"OCS-APIRequest": "true"}
|
||||
|
||||
|
||||
async def _astrolabe_configured(client: httpx.AsyncClient, auth) -> bool:
|
||||
"""Readiness probe: astrolabe must be able to reach its MCP server."""
|
||||
try:
|
||||
resp = await client.get(
|
||||
f"{ASTROLABE_API}/vector-status", auth=auth, headers=_HEADERS
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
if resp.status_code != 200:
|
||||
return False
|
||||
return bool(resp.json().get("success"))
|
||||
|
||||
|
||||
async def test_session_user_searches_without_provisioning(test_users_setup):
|
||||
"""A non-admin session user searches with no OAuth/provisioning step.
|
||||
|
||||
success=True proves astrolabe minted a JWT from the session and the MCP
|
||||
server accepted it. Results may be empty (nothing indexed) — the auth
|
||||
chain, not recall, is under test here.
|
||||
"""
|
||||
auth = httpx.BasicAuth("bob", test_users_setup["bob"]["password"])
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
if not await _astrolabe_configured(client, auth):
|
||||
pytest.skip("Astrolabe not wired to an MCP server in this stack")
|
||||
|
||||
# No provisioning: search must work purely from the session JWT.
|
||||
status = await client.get(
|
||||
f"{ASTROLABE_API}/v1/background-sync/status", auth=auth, headers=_HEADERS
|
||||
)
|
||||
assert status.json()["has_background_access"] is False, (
|
||||
"precondition: bob has not opted into background indexing"
|
||||
)
|
||||
|
||||
resp = await client.get(
|
||||
f"{ASTROLABE_API}/search",
|
||||
params={"query": "quarterly planning", "limit": 3},
|
||||
auth=auth,
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["success"] is True, (
|
||||
f"session-JWT search must succeed without provisioning; got {body}"
|
||||
)
|
||||
assert "results" in body and "algorithm_used" in body
|
||||
|
||||
|
||||
async def test_admin_session_search_succeeds():
|
||||
"""The same JWT-mint path works for the admin session user."""
|
||||
admin_pw = os.environ["NEXTCLOUD_PASSWORD"]
|
||||
auth = httpx.BasicAuth(os.environ["NEXTCLOUD_USERNAME"], admin_pw)
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
if not await _astrolabe_configured(client, auth):
|
||||
pytest.skip("Astrolabe not wired to an MCP server in this stack")
|
||||
resp = await client.get(
|
||||
f"{ASTROLABE_API}/search",
|
||||
params={"query": "infrastructure", "limit": 3},
|
||||
auth=auth,
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["success"] is True
|
||||
|
||||
|
||||
async def test_search_requires_authentication():
|
||||
"""Unauthenticated search is rejected (no anonymous JWT minting)."""
|
||||
async with httpx.AsyncClient(follow_redirects=False, timeout=30) as client:
|
||||
resp = await client.get(
|
||||
f"{ASTROLABE_API}/search",
|
||||
params={"query": "x"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert resp.status_code in (401, 302, 303, 307, 308), resp.status_code
|
||||
@@ -1,96 +1,63 @@
|
||||
"""Integration tests for Astrolabe personal settings page buttons.
|
||||
"""Integration tests for Astrolabe personal-settings background-sync endpoints.
|
||||
|
||||
Cross-system interface test: Tests the MCP server's integration with the
|
||||
Astrolabe Nextcloud app, which is installed from the Nextcloud app store via
|
||||
app-hooks/post-installation/20-install-astrolabe-app.sh. Astrolabe source
|
||||
lives in a separate repository (https://github.com/cbcoutinho/astrolabe).
|
||||
Cross-system interface test. The astrolabe app (installed by
|
||||
app-hooks/post-installation/20-install-astrolabe-app.sh; source in
|
||||
./third_party/astrolabe) was refactored to session-minted JWTs — the old
|
||||
per-user OAuth flow and its ``/apps/astrolabe/oauth/disconnect`` route are
|
||||
gone. Background indexing is now an app-password opt-in with a single revoke
|
||||
endpoint.
|
||||
|
||||
Tests the button functionality on /settings/user/astrolabe:
|
||||
1. Disable Indexing button (POST to /apps/astrolabe/api/revoke)
|
||||
2. Disconnect button (POST to /apps/astrolabe/oauth/disconnect)
|
||||
|
||||
These tests verify that:
|
||||
- The endpoints respond correctly to POST requests
|
||||
- CSRF token validation works
|
||||
- User actions are properly handled
|
||||
- Appropriate redirects occur
|
||||
These tests assert the *current* HTTP surface of the settings page:
|
||||
- the revoke endpoint exists and is auth-gated
|
||||
(POST /apps/astrolabe/api/v1/background-sync/credentials/revoke)
|
||||
- the obsolete OAuth disconnect route is gone (404)
|
||||
- the personal settings page route resolves
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_disable_indexing_button_endpoint_exists():
|
||||
"""Test that the Disable Indexing endpoint is accessible."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Try without authentication - should return 401 or redirect
|
||||
response = await client.post(
|
||||
"http://localhost:8080/apps/astrolabe/api/revoke",
|
||||
follow_redirects=False,
|
||||
)
|
||||
NEXTCLOUD_URL = "http://localhost:8080"
|
||||
ASTROLABE = f"{NEXTCLOUD_URL}/apps/astrolabe"
|
||||
|
||||
# Should get 401 Unauthorized or 30x redirect
|
||||
assert response.status_code in [401, 301, 302, 303, 307, 308], (
|
||||
f"Expected 401 or redirect without auth, got {response.status_code}"
|
||||
# Auth failures (no session) surface as 401 or a login redirect.
|
||||
_UNAUTH = {401, 302, 303, 307, 308}
|
||||
|
||||
|
||||
async def test_revoke_endpoint_requires_auth():
|
||||
"""The background-sync revoke endpoint exists and rejects anonymous calls."""
|
||||
async with httpx.AsyncClient(follow_redirects=False) as client:
|
||||
resp = await client.post(
|
||||
f"{ASTROLABE}/api/v1/background-sync/credentials/revoke",
|
||||
headers={"OCS-APIRequest": "true"},
|
||||
)
|
||||
# Must NOT be 404 — the route must exist — and must be auth-gated.
|
||||
assert resp.status_code != 404, "revoke route missing"
|
||||
assert resp.status_code in _UNAUTH, (
|
||||
f"expected auth rejection, got {resp.status_code}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_disconnect_button_endpoint_exists():
|
||||
"""Test that the Disconnect endpoint is accessible."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Try without authentication - should return 401 or redirect
|
||||
response = await client.post(
|
||||
"http://localhost:8080/apps/astrolabe/oauth/disconnect",
|
||||
follow_redirects=False,
|
||||
)
|
||||
async def test_obsolete_oauth_disconnect_route_removed():
|
||||
"""The pre-refactor OAuth disconnect route must no longer exist.
|
||||
|
||||
# Should get 401 Unauthorized or 30x redirect
|
||||
assert response.status_code in [401, 301, 302, 303, 307, 308], (
|
||||
f"Expected 401 or redirect without auth, got {response.status_code}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_settings_page_renders_buttons():
|
||||
"""Test that the settings page template includes button forms.
|
||||
|
||||
This test verifies that the PHP template renders the form elements.
|
||||
It doesn't require authentication since we're just checking the route exists.
|
||||
Regression guard for the auth refactor: ``/apps/astrolabe/oauth/disconnect``
|
||||
(and the rest of the OAuth authorize/callback/disconnect surface) was
|
||||
removed in favour of session-minted JWTs.
|
||||
"""
|
||||
async with httpx.AsyncClient(follow_redirects=False) as client:
|
||||
# Try to access settings page
|
||||
response = await client.get("http://localhost:8080/settings/user/astrolabe")
|
||||
|
||||
# Should get 401/redirect if not authenticated (expected)
|
||||
# or 200 if user session exists from browser testing
|
||||
assert response.status_code in [200, 401, 302, 303, 307, 308], (
|
||||
f"Unexpected status code: {response.status_code}"
|
||||
)
|
||||
resp = await client.post(f"{ASTROLABE}/oauth/disconnect")
|
||||
assert resp.status_code == 404, (
|
||||
f"obsolete oauth/disconnect route still resolves ({resp.status_code})"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.skip(
|
||||
reason="Requires manual authentication - test with Playwright instead"
|
||||
)
|
||||
async def test_disconnect_button_functionality():
|
||||
"""Test that clicking Disconnect button clears user OAuth tokens.
|
||||
|
||||
NOTE: This test is skipped because programmatic login to Nextcloud is complex.
|
||||
Use Playwright-based tests or manual testing instead.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.skip(
|
||||
reason="Requires manual authentication - test with Playwright instead"
|
||||
)
|
||||
async def test_disable_indexing_button_functionality():
|
||||
"""Test that clicking Disable Indexing button revokes background access.
|
||||
|
||||
NOTE: This test is skipped because programmatic login to Nextcloud is complex.
|
||||
Use Playwright-based tests or manual testing instead.
|
||||
"""
|
||||
pass
|
||||
async def test_settings_page_route_resolves():
|
||||
"""The personal settings page route exists (auth-gated when no session)."""
|
||||
async with httpx.AsyncClient(follow_redirects=False) as client:
|
||||
resp = await client.get(f"{NEXTCLOUD_URL}/settings/user/astrolabe")
|
||||
assert resp.status_code in ({200} | _UNAUTH), (
|
||||
f"unexpected status for settings page: {resp.status_code}"
|
||||
)
|
||||
|
||||
Vendored
+1
-1
Submodule third_party/astrolabe updated: 64153f5a3f...71a18287d4
Reference in New Issue
Block a user