Compare commits

...

114 Commits

Author SHA1 Message Date
github-actions[bot] 2d79fc6c3d bump: version 0.61.4 → 0.61.5 2026-01-17 20:38:00 +00:00
Chris Coutinho 80972f5d37 Merge pull request #487 from cbcoutinho/fix/astrolabe-token-refresh-internal-url
fix(astrolabe): use internal URL for OAuth token refresh
2026-01-17 21:37:40 +01:00
Chris Coutinho f0ade4ad28 refactor(astrolabe): add PHP property types to fix Psalm errors
Add explicit property type declarations to IdpTokenRefresher,
CredentialsController, OAuthController, and McpServerClient classes.
This improves type safety and allows Psalm to properly infer types,
eliminating MissingPropertyType and many MixedMethodCall errors.

Also adds IClient import where needed and validates getSystemValue
returns to ensure string types before use.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 21:24:56 +01:00
Chris Coutinho 737f10f190 fix(astrolabe): improve token refresh error handling and validation
- Extract magic number to TOKEN_EXPIRY_BUFFER_SECONDS constant
- Add URL validation for astrolabe_internal_url with fallback
- Warn when internal URL uses external port mapping (misconfiguration)
- Differentiate HTTP error handling by status code:
  - Network errors (LocalServerException): warning level
  - Auth errors (401/403): error level (token invalid)
  - Server errors (500+): warning level (transient)
- Reduce log level for IdP selection messages to debug
- Add integration tests for credential storage, isolation, and revoke/reprovision

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 19:43:15 +01:00
Chris Coutinho 813e9a60cb chore: Run npm install 2026-01-17 15:03:33 +01:00
Chris Coutinho 5c25b87cbe chore(astrolabe): remove duplicate .github workflows
GitHub workflows should be defined only in the root .github directory,
not in the subproject directory.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 14:26:20 +01:00
Chris Coutinho e48c5fa9a2 fix(astrolabe): delete stale tokens when refresh fails
- Delete stored token when refresh callback fails or returns null
- Delete stored token when expired with no refresh callback available
- Fix test namespaces (Service → OCA\Astrolabe\Tests\Unit\Service)
- Update tests to verify token deletion on refresh failure

Prevents repeated refresh attempts with stale tokens that will never
succeed, improving error handling and reducing unnecessary API calls.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 14:21:53 +01:00
Chris Coutinho 303efeddf7 refactor(astrolabe): upgrade to @nextcloud/vue 9.3.3 API
- Replace NcCheckboxRadioSwitch :checked with :model-value
- Replace NcCheckboxRadioSwitch @update:checked with @update:model-value
- Replace NcButton type="primary|secondary|tertiary" with variant prop
- Bump @nextcloud/vue minimum version to ^9.3.3

These changes address deprecated APIs removed in @nextcloud/vue v9.0.0:
- :checked/:update:checked was replaced by v-model/modelValue pattern
- type prop for button variants was replaced by variant prop

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 13:09:27 +01:00
Chris Coutinho fef13a6d3d test(astrolabe): add comprehensive unit tests for token refresh and storage
Add unit tests addressing reviewer feedback on test coverage gaps:

IdpTokenRefresher::refreshAccessToken() tests:
- Token refresh with internal Nextcloud OIDC
- Token refresh with external IdP (Keycloak)
- Error handling: missing client_secret, missing MCP URL
- Error handling: invalid responses, HTTP exceptions
- Token rotation validation (missing refresh_token in response)

McpTokenStorage tests (multi-user basic auth):
- OAuth token storage, retrieval, deletion
- Token expiration checks with 60-second buffer
- getAccessToken with automatic refresh callback
- App password storage for background sync
- hasBackgroundSyncAccess() for both OAuth and app passwords
- Background sync type detection and timestamp tracking

Test coverage: 41 tests, 76 assertions (up from 5 tests)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 11:00:45 +01:00
Chris Coutinho c4973290a6 fix(astrolabe): resolve CI failures for code quality checks
- Fix PHP CS Fixer issues (single quotes, indentation)
- Add typed property declarations to ApiController
- Add Psalm baseline to suppress 517 pre-existing errors
- Fix workflow name references (astroglobe → astrolabe)

The CI workflow was previously watching a non-existent path and never
ran. After fixing the path trigger, these pre-existing code quality
issues were discovered. The Psalm baseline allows CI to pass while
tracking technical debt for incremental resolution.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 10:56:36 +01:00
Chris Coutinho c018268681 docs(astrolabe): add config docs and unit tests for internal URL
Address PR #487 reviewer feedback:

- Add documentation for `astrolabe_internal_url` config option
- Add unit tests for `IdpTokenRefresher::getNextcloudBaseUrl()`
- Fix CI workflow paths (astroglobe -> astrolabe)
- Add PHPUnit job to CI workflow for PHP 8.1, 8.2, 8.3
- Remove obsolete ApiTest that tested non-existent method

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 22:24:43 +01:00
Chris Coutinho 79cfb65590 fix(astrolabe): use internal URL for OAuth token refresh
The IdpTokenRefresher was incorrectly using overwrite.cli.url (the
external URL like http://localhost:8080) for internal token refresh
requests. This URL is not accessible from inside Docker containers
since port 8080 is only mapped on the host machine.

Changed getNextcloudBaseUrl() to:
- Always use http://localhost (internal port 80) by default
- Added optional astrolabe_internal_url config for custom setups
- Removed overwrite.cli.url usage (intended for external URLs only)

This fixes 401 errors in Astrolabe semantic search when OAuth tokens
need to be refreshed in containerized deployments.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 19:42:54 +01:00
github-actions[bot] 9750845092 bump: version 0.57.5 → 0.57.6 2026-01-16 17:14:58 +00:00
Chris Coutinho 7e8171132b Merge pull request #484 from cbcoutinho/renovate/docker.io-library-mariadb-lts
chore(deps): update docker.io/library/mariadb:lts docker digest to 345fa26
2026-01-16 18:14:40 +01:00
Chris Coutinho 910792178b Merge pull request #485 from cbcoutinho/renovate/docker.io-library-nextcloud-32.x
chore(deps): update docker.io/library/nextcloud docker tag to v32.0.4
2026-01-16 18:14:29 +01:00
Chris Coutinho 80c5647f3e Merge pull request #486 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.26
2026-01-16 18:14:21 +01:00
renovate-bot-cbcoutinho[bot] a306549907 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.26 2026-01-16 11:11:51 +00:00
renovate-bot-cbcoutinho[bot] 295e3d2783 chore(deps): update docker.io/library/nextcloud docker tag to v32.0.4 2026-01-16 11:11:33 +00:00
renovate-bot-cbcoutinho[bot] 47dcdf8b61 chore(deps): update docker.io/library/mariadb:lts docker digest to 345fa26 2026-01-16 11:11:15 +00:00
github-actions[bot] 8c6ae9ff33 bump: version 0.57.4 → 0.57.5 2026-01-16 10:28:00 +00:00
Chris Coutinho 04fee00a0b Merge pull request #482 from cbcoutinho/renovate/quay.io-keycloak-keycloak-26.x
chore(deps): update quay.io/keycloak/keycloak docker tag to v26.5.1
2026-01-16 11:27:39 +01:00
github-actions[bot] 9e1fc1ebeb bump: version 0.8.1 → 0.8.2 2026-01-16 09:54:03 +00:00
github-actions[bot] 6eceefdacc bump: version 0.57.3 → 0.57.4 2026-01-16 09:54:03 +00:00
github-actions[bot] b147814cc4 bump: version 0.61.3 → 0.61.4 2026-01-16 09:54:02 +00:00
Chris Coutinho 5a58c81626 Merge pull request #483 from cbcoutinho/fix/astrolabe-oauth-hybrid-mode
fix(astrolabe): fix OAuth flow for hybrid mode
2026-01-16 10:53:45 +01:00
Chris Coutinho 1cc460b0d8 fix(astrolabe): Address reviewer feedback for hybrid mode
Addresses code review feedback:

Personal.php:
- Consolidate template variables to use camelCase consistently
- Remove duplicate snake_case variables (auth_mode, supports_app_passwords)
- Add oauthUrl to standard OAuth mode parameters (fixes fallback issue)
- Add requesttoken for CSRF protection

personal.php (template):
- Use null coalescing for safe variable access
- Reuse computed $isHybridMode variable instead of duplicate check
- Remove complex fallback URL logic (oauthUrl now always provided)

IdpTokenRefresher.php:
- Use Nextcloud's overwrite.cli.url config when available
- Fall back to http://localhost for container deployments
- Better supports non-containerized environments

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 10:44:52 +01:00
Chris Coutinho 104a2ec9e3 test: Add unit tests for status endpoint OIDC config
Add unit tests for /api/v1/status endpoint focusing on OIDC config:
- Test hybrid mode (multi_user_basic + enable_offline_access) returns OIDC
- Test pure multi_user_basic mode without offline_access omits OIDC
- Test OAuth mode returns OIDC config
- Test single-user BasicAuth mode omits OIDC config
- Test partial OIDC config (only discovery_url or only issuer)

Also updates docs/authentication.md with Astrolabe hybrid mode setup:
- Two-step credential setup (OAuth + app password)
- Technical details for each credential type
- Request direction table explaining why two credentials needed

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 10:43:59 +01:00
Chris Coutinho e87ae56041 fix(astrolabe): Fix NcSelect options and CSS loading
- Use :input-label prop for NcSelect field labels instead of :label
  (the :label prop sets the option label property key, not the visible label)
- Fix CSS loading in admin.php and personal.php templates to use
  astrolabe-main (the bundled CSS file)
- Update minimum Nextcloud version to 31 (required for Vue 3)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 17:21:22 +01:00
Chris Coutinho c95459234b fix(astrolabe): fix OAuth flow and settings UI for hybrid mode
In hybrid mode (multi_user_basic + offline_access), users need BOTH:
- OAuth token for Astrolabe→MCP API calls
- App password for MCP→Nextcloud background sync

Changes:
- Personal.php: Pass correct oauthUrl pointing to Astrolabe's OAuth
  controller instead of MCP server's browser OAuth. Check both OAuth
  token AND app password status in hybrid mode.
- personal.php template: Show two-step workflow UI requiring both
  credentials before showing "Active" status. Each step shows
  completion badges.
- IdpTokenRefresher.php: Use http://localhost for internal token
  refresh requests (consistent with OAuthController). External URLs
  like localhost:8080 don't work from inside the container.

Fixes 401 errors when searching in Astrolabe with hybrid deployment.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 16:14:00 +01:00
Chris Coutinho f16f852b23 fix(api): return OIDC config in hybrid mode for Astrolabe OAuth flow
The /api/v1/status endpoint now returns OIDC configuration (discovery_url,
issuer) when running in hybrid mode (multi_user_basic + offline_access),
not just in pure OAuth mode.

This allows Astrolabe to discover the IdP and complete the OAuth flow
for obtaining tokens to call MCP server management APIs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 16:13:50 +01:00
github-actions[bot] b93d7bd19b bump: version 0.57.2 → 0.57.3 2026-01-15 13:34:11 +00:00
Chris Coutinho 9a69cef815 Merge pull request #474 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.3
chore(deps): update docker.io/library/nextcloud:32.0.3 docker digest to b865818
2026-01-15 14:33:56 +01:00
github-actions[bot] 2424afbdda bump: version 0.8.0 → 0.8.1 2026-01-15 11:23:42 +00:00
github-actions[bot] 0a987467b5 bump: version 0.57.1 → 0.57.2 2026-01-15 11:23:42 +00:00
github-actions[bot] ab6f7ca0b2 bump: version 0.61.2 → 0.61.3 2026-01-15 11:23:41 +00:00
Chris Coutinho 42fa33d0bf Merge pull request #480 from cbcoutinho/fix/astrolabe-vue3-bindings
fix(astrolabe): update Vue component bindings for Vue 3 compatibility
2026-01-15 12:23:21 +01:00
Chris Coutinho 006a3d95d6 fix(astrolabe): address review feedback for Vue 3 bindings
- Change limit initialization from string '20' to number 20 in App.vue
- Update AdminSettings.vue NcTextField to use v-model instead of legacy
  :value/@update:value bindings
- Update AdminSettings.vue NcSelect components to use :model-value with
  computed getters and @update:model-value for proper object-to-id
  conversion (same pattern as App.vue)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 12:16:08 +01:00
renovate-bot-cbcoutinho[bot] 1835965f44 chore(deps): update quay.io/keycloak/keycloak docker tag to v26.5.1 2026-01-15 11:11:00 +00:00
Chris Coutinho cb4e8acd9f fix(astrolabe): update Vue component bindings for Vue 3 compatibility
The astrolabe app was using Vue 2 style bindings that don't work with
@nextcloud/vue 9.x and Vue 3:

- NcTextField: Changed from :value/@update:value to v-model
- NcSelect: Changed from v-model (with computed prop) to
  :model-value/@update:model-value

The legacy :value and @update:value props were being ignored because
@nextcloud/vue 9.x components use modelValue/update:modelValue internally.
This caused the search button to remain disabled and the algorithm
dropdown to be unresponsive.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 12:06:20 +01:00
github-actions[bot] 02418a9531 bump: version 0.57.0 → 0.57.1 2026-01-15 09:00:41 +00:00
github-actions[bot] f89151d099 bump: version 0.61.1 → 0.61.2 2026-01-15 09:00:41 +00:00
Chris Coutinho dc86386bf8 Merge pull request #479 from cbcoutinho/fix/helm-version-bump-on-app-change
fix(ci): bump helm chart version when MCP appVersion changes
2026-01-15 10:00:19 +01:00
Chris Coutinho 929c40709a fix(ci): bump helm chart version when MCP appVersion changes
When the MCP server version is bumped, the Helm chart's appVersion is
updated but the chart version was not. This caused users to not see
new chart releases when the app version changed.

Now the Helm chart version is bumped (PATCH) whenever:
1. There are helm-scoped commits (existing behavior)
2. OR when the MCP server version is bumped (new behavior)

This ensures Helm users always get notified of new releases containing
updated app versions.
2026-01-15 09:59:09 +01:00
github-actions[bot] a60560256d bump: version 0.7.2 → 0.8.0 2026-01-15 08:50:54 +00:00
github-actions[bot] aa583ab973 bump: version 0.61.0 → 0.61.1 2026-01-15 08:50:53 +00:00
Chris Coutinho 4103924b83 Merge pull request #478 from cbcoutinho/fix/astrolabe-appname-define
fix(astrolabe): define appName and appVersion for @nextcloud/vue
2026-01-15 09:50:32 +01:00
Chris Coutinho c192bd2ec9 chore: update docker-compose.yml env var names for semantic search 2026-01-15 09:48:38 +01:00
Chris Coutinho 2005d2841f Merge pull request #476 from cbcoutinho/renovate/docker.io-library-python-3.12-slim-trixie
chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to d75c4b6
2026-01-15 09:12:46 +01:00
Chris Coutinho c6295b48a5 fix(astrolabe): define appName and appVersion for @nextcloud/vue
The @nextcloud/vue library (v9.x) requires appName and appVersion to be
defined as global constants at build time. Without these, the library
logs an error: "The '@nextcloud/vue' library was used without setting /
replacing the 'appName'."

This fix reads the app ID and version from appinfo/info.xml and injects
them via Vite's define option, matching how @nextcloud/webpack-vue-config
handles this for webpack-based apps.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 08:59:31 +01:00
Chris Coutinho 7444c73a5a Merge pull request #475 from cbcoutinho/renovate/docker.io-library-nginx-alpine
chore(deps): update docker.io/library/nginx:alpine docker digest to 66d420c
2026-01-15 07:49:40 +01:00
Chris Coutinho cf0781d2fe bump: version 0.56.2 → 0.57.0 2026-01-15 07:32:46 +01:00
Chris Coutinho 6681cd0603 Merge pull request #477 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.25
2026-01-15 07:22:06 +01:00
github-actions[bot] c305a549d3 bump: version 0.60.4 → 0.61.0 2026-01-14 19:52:32 +00:00
Chris Coutinho 1f1dd94598 Merge pull request #473 from cbcoutinho/fix/multi-user-basicauth-app-password-storage
fix(auth): Store app passwords locally for multi-user BasicAuth background sync
2026-01-14 20:52:12 +01:00
Chris Coutinho 01ad2b3d21 refactor: Use get_settings() for vector sync enabled check
Replace direct os.getenv() calls with get_settings().vector_sync_enabled
to ensure consistent behavior with both VECTOR_SYNC_ENABLED (deprecated)
and ENABLE_SEMANTIC_SEARCH environment variables.

Also add webhook management documentation guide.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:30:51 +01:00
Chris Coutinho e4cddef343 fix: Add missing annotations for deck remove/unassign operations
- Add destructiveHint=True to deck_remove_label_from_card and
  deck_unassign_user_from_card (ADR-017 compliance)
- Set idempotentHint=True since remove operations produce same end state
- Update test_annotations.py to exclude nc_webdav_create_directory from
  non-idempotent check (MKCOL is idempotent by design - returns 405 if exists)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:02:20 +01:00
Chris Coutinho f15baefe7e feat: Add rate limiting and extract helpers for app password endpoints
Security improvements:
- Add in-memory rate limiter for app password provisioning (5 attempts/hour/user)
- Returns 429 Too Many Requests with Retry-After header when limit exceeded
- Rate limiting is per-user to prevent cross-user DoS

Code quality improvements:
- Extract _extract_basic_auth() helper to reduce duplication across 3 endpoints
- Move base64, re imports to module level
- Add APP_PASSWORD_PATTERN constant for regex validation
- Add NEXTCLOUD_VALIDATION_TIMEOUT constant (10s)

Test coverage:
- Add test_provision_app_password_rate_limiting
- Add test_rate_limiting_is_per_user
- Add autouse fixture to clear rate limit state between tests
- Total: 15 tests for management API endpoints

Addresses reviewer feedback on PR #473.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:02:00 +01:00
renovate-bot-cbcoutinho[bot] 585ed46f2d chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.25 2026-01-14 11:12:07 +00:00
renovate-bot-cbcoutinho[bot] dbbbab5320 chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to d75c4b6 2026-01-14 11:11:49 +00:00
renovate-bot-cbcoutinho[bot] e5844b3da8 chore(deps): update docker.io/library/nginx:alpine docker digest to 66d420c 2026-01-14 11:11:44 +00:00
renovate-bot-cbcoutinho[bot] fdbf88831a chore(deps): update docker.io/library/nextcloud:32.0.3 docker digest to b865818 2026-01-14 11:11:39 +00:00
Chris Coutinho 6affad1c8b refactor: Extract storage helper and improve PHP error handling
Management API:
- Extract _get_app_password_storage() helper function
- Reduces code duplication across 3 endpoints
- Adds TYPE_CHECKING import for type hints

PHP CredentialsController:
- Add partial_success field to distinguish full vs partial success
- Add local_storage and mcp_sync boolean fields for clarity
- Rename 'warning' to 'mcp_error' for consistency
- Improves UI feedback when MCP server sync fails

Response structure now clearly indicates:
- Full success: partial_success=false, local_storage=true, mcp_sync=true
- Partial success: partial_success=true, local_storage=true, mcp_sync=false
- Full failure: success=false (unchanged)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 21:50:34 +01:00
Chris Coutinho 370c3ff444 test: Add comprehensive tests for app password storage and provisioning
- Add 12 unit tests for RefreshTokenStorage app password methods
  - Basic CRUD operations (store, get, delete)
  - Encryption verification (passwords encrypted at rest)
  - Error handling (missing encryption key, wrong key)
  - Multi-user independence

- Add 13 unit tests for Management API endpoints
  - POST /api/v1/users/{user_id}/app-password provisioning
  - GET /api/v1/users/{user_id}/app-password status
  - DELETE /api/v1/users/{user_id}/app-password deletion
  - Auth validation (BasicAuth, username matching)
  - Nextcloud credential validation

- Rewrite 10 integration tests for new architecture
  - Remove AstrolabeClient/OAuth dependency
  - Use local RefreshTokenStorage for app passwords
  - Test BasicAuth and OAuth mode separation
  - Test NotProvisionedError scenarios

Addresses reviewer feedback on PR #473 requiring test coverage.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 21:44:23 +01:00
Chris Coutinho e486e92f91 fix(auth): Store app passwords locally for multi-user BasicAuth background sync
Previously, the multi-user BasicAuth mode attempted to retrieve app passwords
via OAuth client_credentials grant, which Nextcloud OIDC doesn't support.

This fix implements local storage for app passwords:
- Add app_passwords table via Alembic migration (002)
- Add store/get/delete methods to RefreshTokenStorage
- Add management API endpoints for app password provisioning:
  - POST /api/v1/users/{user_id}/app-password
  - GET /api/v1/users/{user_id}/app-password
  - DELETE /api/v1/users/{user_id}/app-password
- Update oauth_sync.py to read from local storage
- Update Astrolabe to send app passwords to MCP server after validation
- Add app-hook to configure mcp_server_url in Nextcloud

The flow is now:
1. User creates app password in Nextcloud Security settings
2. User enters it in Astrolabe Personal Settings
3. Astrolabe validates against Nextcloud, then sends to MCP server
4. MCP server stores encrypted app password locally
5. Background sync uses locally stored password

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 15:44:11 +01:00
Chris Coutinho 7465e962d4 Merge pull request #472 from cbcoutinho/renovate/docker.io-library-python-3.12-slim-trixie
chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to 7325cf2
2026-01-13 13:48:28 +01:00
Chris Coutinho 99fe764c5e Merge pull request #471 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.3
chore(deps): update docker.io/library/nextcloud:32.0.3 docker digest to 1a75afc
2026-01-13 13:46:27 +01:00
renovate-bot-cbcoutinho[bot] 46f896b526 chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to 7325cf2 2026-01-13 11:11:33 +00:00
renovate-bot-cbcoutinho[bot] a61572e8ef chore(deps): update docker.io/library/nextcloud:32.0.3 docker digest to 1a75afc 2026-01-13 11:11:28 +00:00
github-actions[bot] a474996df4 bump: version 0.60.3 → 0.60.4 2026-01-12 12:37:11 +00:00
Chris Coutinho 5d6dd5ad38 Merge pull request #470 from cbcoutinho/fix/469-deck-reorder-card
fix(deck): use correct endpoint for reorder_card to fix cross-stack moves
2026-01-12 13:36:51 +01:00
Chris Coutinho 21e4d3effd fix(deck): use correct endpoint for reorder_card to fix cross-stack moves
The reorder_card method was using the API route
/api/v1.0/boards/{boardId}/stacks/{stackId}/cards/{cardId}/reorder
which has a parameter conflict: the URL's {stackId} (current stack)
overrides the body's stackId (target stack) in Nextcloud's routing.

This caused cards to stay in their original stack even when the API
reported success.

Switched to the non-API route /cards/{cardId}/reorder which correctly
reads stackId from the request body, matching the behavior of the
working curl command reported in the issue.

Also added the required OCS-APIRequest headers that were missing.

Fixes #469

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:29:03 +01:00
Chris Coutinho 817df43af1 Merge pull request #465 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.3
chore(deps): update docker.io/library/nextcloud:32.0.3 docker digest to 271a9c9
2026-01-10 21:40:04 +01:00
Chris Coutinho 906b9d892c Merge pull request #467 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.24
2026-01-10 21:39:50 +01:00
Chris Coutinho 534723c9f6 Merge pull request #466 from cbcoutinho/renovate/docker.io-library-nginx-alpine
chore(deps): update docker.io/library/nginx:alpine docker digest to c083c37
2026-01-10 21:39:26 +01:00
renovate-bot-cbcoutinho[bot] 1d5832ed3a chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.24 2026-01-10 11:10:53 +00:00
renovate-bot-cbcoutinho[bot] 844bd589e0 chore(deps): update docker.io/library/nginx:alpine docker digest to c083c37 2026-01-10 11:10:49 +00:00
renovate-bot-cbcoutinho[bot] 127af15623 chore(deps): update docker.io/library/nextcloud:32.0.3 docker digest to 271a9c9 2026-01-10 11:10:43 +00:00
Chris Coutinho ff5fc5d5b2 Merge pull request #427 from cbcoutinho/renovate/icewind1991-nextcloud-version-matrix-1.x
chore(deps): update icewind1991/nextcloud-version-matrix action to v1.3.1
2026-01-09 12:40:16 +01:00
Chris Coutinho 158865d99f Merge pull request #463 from cbcoutinho/renovate/anthropics-claude-code-action-digest
chore(deps): update anthropics/claude-code-action digest to 1b8ee3b
2026-01-09 12:30:25 +01:00
Chris Coutinho 94674eca27 Update .github/workflows/claude.yml 2026-01-09 12:30:20 +01:00
Chris Coutinho a8b5d6e701 Update .github/workflows/claude-code-review.yml 2026-01-09 12:30:14 +01:00
Chris Coutinho e0675b2127 Merge pull request #405 from cbcoutinho/renovate/docker.io-library-nginx-alpine
chore(deps): update docker.io/library/nginx:alpine docker digest to 8491795
2026-01-09 12:28:48 +01:00
Chris Coutinho 86582bdb8f Merge pull request #464 from cbcoutinho/renovate/docker.io-qdrant-qdrant-1.x
chore(deps): update docker.io/qdrant/qdrant docker tag to v1.16.3
2026-01-09 12:28:29 +01:00
renovate-bot-cbcoutinho[bot] dc8009a785 chore(deps): update docker.io/qdrant/qdrant docker tag to v1.16.3 2026-01-09 11:14:26 +00:00
renovate-bot-cbcoutinho[bot] b5e658e1ff chore(deps): update docker.io/library/nginx:alpine docker digest to 8491795 2026-01-09 11:14:22 +00:00
renovate-bot-cbcoutinho[bot] 6a19c2d136 chore(deps): update anthropics/claude-code-action digest to 1b8ee3b 2026-01-09 11:14:15 +00:00
Chris Coutinho 99e359ffbf Merge pull request #374 from cbcoutinho/renovate/qdrant-qdrant-replacement
chore(deps): replace qdrant/qdrant docker tag with docker.io/qdrant/qdrant
2026-01-08 13:17:17 +01:00
Chris Coutinho f16f4e8cb5 Merge pull request #461 from cbcoutinho/renovate/downloads.unstructured.io-unstructured-io-unstructured-api-latest
chore(deps): update downloads.unstructured.io/unstructured-io/unstructured-api:latest docker digest to db5fcc8
2026-01-08 13:17:03 +01:00
Chris Coutinho 8597f2a272 Merge pull request #462 from cbcoutinho/renovate/ollama-1.x
chore(deps): update helm release ollama to v1.37.0
2026-01-08 13:16:35 +01:00
renovate-bot-cbcoutinho[bot] 11f67e2bc4 chore(deps): update helm release ollama to v1.37.0 2026-01-08 11:11:18 +00:00
renovate-bot-cbcoutinho[bot] 2e49a16e49 chore(deps): update downloads.unstructured.io/unstructured-io/unstructured-api:latest docker digest to db5fcc8 2026-01-08 11:10:49 +00:00
renovate-bot-cbcoutinho[bot] 713fddeaa5 chore(deps): replace qdrant/qdrant docker tag with docker.io/qdrant/qdrant 2026-01-08 11:10:22 +00:00
Chris Coutinho 0dfefb0516 Merge pull request #459 from cbcoutinho/renovate/hoverkraft-tech-compose-action-2.x
chore(deps): update hoverkraft-tech/compose-action action to v2.4.3
2026-01-07 19:55:20 +01:00
Chris Coutinho 63d2aeaa43 Merge pull request #460 from cbcoutinho/renovate/astral-sh-setup-uv-7.x
chore(deps): update astral-sh/setup-uv action to v7.2.0
2026-01-07 19:52:15 +01:00
renovate-bot-cbcoutinho[bot] 07f0a7c0dc chore(deps): update astral-sh/setup-uv action to v7.2.0 2026-01-07 11:12:25 +00:00
renovate-bot-cbcoutinho[bot] 84bde6d5ed chore(deps): update hoverkraft-tech/compose-action action to v2.4.3 2026-01-07 11:12:10 +00:00
Chris Coutinho 9695f8a6d7 Merge pull request #457 from cbcoutinho/renovate/anthropics-claude-code-action-digest
chore(deps): update anthropics/claude-code-action digest to c9ec2b0
2026-01-07 08:29:06 +01:00
Chris Coutinho a2c410e8d2 Merge pull request #458 from cbcoutinho/renovate/quay.io-keycloak-keycloak-26.x
chore(deps): update quay.io/keycloak/keycloak docker tag to v26.5.0
2026-01-07 08:14:01 +01:00
Chris Coutinho 271b5f6155 Merge pull request #451 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.22
2026-01-07 07:48:28 +01:00
renovate-bot-cbcoutinho[bot] ba4f7c1429 chore(deps): update quay.io/keycloak/keycloak docker tag to v26.5.0 2026-01-06 11:11:19 +00:00
renovate-bot-cbcoutinho[bot] c763e96596 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.22 2026-01-06 11:10:52 +00:00
renovate-bot-cbcoutinho[bot] 23e9cbaec5 chore(deps): update anthropics/claude-code-action digest to c9ec2b0 2026-01-06 11:10:45 +00:00
Chris Coutinho ddd5defa40 Merge pull request #406 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.3
chore(deps): update docker.io/library/nextcloud:32.0.3 docker digest to 0ddb0f9
2026-01-03 13:17:23 -06:00
Chris Coutinho 723dcc524d Merge pull request #450 from cbcoutinho/renovate/docker.io-library-python-3.12-slim-trixie
chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to a75662d
2026-01-03 09:05:41 -06:00
Chris Coutinho 46eba0a693 Merge pull request #456 from cbcoutinho/renovate/downloads.unstructured.io-unstructured-io-unstructured-api-latest
chore(deps): update downloads.unstructured.io/unstructured-io/unstructured-api:latest docker digest to efbcfaf
2026-01-03 09:05:29 -06:00
renovate-bot-cbcoutinho[bot] b61980a623 chore(deps): update downloads.unstructured.io/unstructured-io/unstructured-api:latest docker digest to efbcfaf 2026-01-03 11:12:11 +00:00
renovate-bot-cbcoutinho[bot] 65cc894e21 chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to a75662d 2026-01-03 11:12:06 +00:00
renovate-bot-cbcoutinho[bot] 700996e100 chore(deps): update docker.io/library/nextcloud:32.0.3 docker digest to 0ddb0f9 2026-01-02 11:10:19 +00:00
github-actions[bot] 546f0c0674 bump: version 0.60.2 → 0.60.3 2025-12-31 05:37:15 +00:00
Chris Coutinho e625eab689 Merge pull request #453 from cbcoutinho/fix/452
fix: DeckClient.update_card partial update bugs
2025-12-30 23:36:57 -06:00
github-actions[bot] ef9e1b3ff8 bump: version 0.7.1 → 0.7.2 2025-12-30 17:38:00 +00:00
Chris Coutinho dd23191987 fix(astrolabe): Fix CSS loading for Nextcloud apps
Two issues prevented CSS from loading correctly:

1. Entry point naming mismatch: Vite output `main.css` but Nextcloud's
   `Util::addStyle('astrolabe', 'astrolabe-main')` expected `astrolabe-main.css`

2. CSS code splitting: Vite extracted @nextcloud/vue component styles
   into separate chunks (e.g., NcUserBubble-*.css) that Nextcloud doesn't
   load automatically. Without these styles, the UI rendered incorrectly.

Changes:
- Rename entry point from `main` to `astrolabe-main`
- Add `cssCodeSplit: false` to bundle all CSS into the entry point
- Update assetFileNames to output consistent `astrolabe-main.css`

This increases CSS bundle from 11KB to 286KB but ensures all component
styles are available when the page loads.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 11:37:43 -06:00
github-actions[bot] 55312b1032 bump: version 0.7.0 → 0.7.1 2025-12-30 04:50:14 +00:00
renovate-bot-cbcoutinho[bot] a987643f8e chore(deps): update icewind1991/nextcloud-version-matrix action to v1.3.1 2025-12-21 11:09:21 +00:00
78 changed files with 7279 additions and 1470 deletions
@@ -1,24 +1,24 @@
# Consolidated CI workflow for Astroglobe Nextcloud app
# Consolidated CI workflow for Astrolabe Nextcloud app
#
# Runs on PRs that modify the astroglobe directory
# Runs on PRs that modify the astrolabe directory
# Based on Nextcloud app skeleton workflows
#
# SPDX-FileCopyrightText: 2025 Nextcloud MCP Server contributors
# SPDX-License-Identifier: MIT
name: Astroglobe CI
name: Astrolabe CI
on:
pull_request:
paths:
- 'third_party/astroglobe/**'
- '.github/workflows/astroglobe-ci.yml'
- 'third_party/astrolabe/**'
- '.github/workflows/astrolabe-ci.yml'
permissions:
contents: read
concurrency:
group: astroglobe-ci-${{ github.head_ref || github.run_id }}
group: astrolabe-ci-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
@@ -37,18 +37,18 @@ jobs:
with:
filters: |
frontend:
- 'third_party/astroglobe/src/**'
- 'third_party/astroglobe/package.json'
- 'third_party/astroglobe/package-lock.json'
- 'third_party/astroglobe/vite.config.js'
- 'third_party/astroglobe/**/*.js'
- 'third_party/astroglobe/**/*.ts'
- 'third_party/astroglobe/**/*.vue'
- 'third_party/astrolabe/src/**'
- 'third_party/astrolabe/package.json'
- 'third_party/astrolabe/package-lock.json'
- 'third_party/astrolabe/vite.config.js'
- 'third_party/astrolabe/**/*.js'
- 'third_party/astrolabe/**/*.ts'
- 'third_party/astrolabe/**/*.vue'
php:
- 'third_party/astroglobe/lib/**'
- 'third_party/astroglobe/appinfo/**'
- 'third_party/astroglobe/composer.json'
- 'third_party/astroglobe/psalm.xml'
- 'third_party/astrolabe/lib/**'
- 'third_party/astrolabe/appinfo/**'
- 'third_party/astrolabe/composer.json'
- 'third_party/astrolabe/psalm.xml'
# Node.js build and lint
node-build:
@@ -58,7 +58,7 @@ jobs:
name: Node.js build
defaults:
run:
working-directory: third_party/astroglobe
working-directory: third_party/astrolabe
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -67,7 +67,7 @@ jobs:
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
id: versions
with:
path: third_party/astroglobe
path: third_party/astrolabe
fallbackNode: '^20'
fallbackNpm: '^10'
@@ -99,7 +99,7 @@ jobs:
name: ESLint
defaults:
run:
working-directory: third_party/astroglobe
working-directory: third_party/astrolabe
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -108,7 +108,7 @@ jobs:
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
id: versions
with:
path: third_party/astroglobe
path: third_party/astrolabe
fallbackNode: '^20'
fallbackNpm: '^10'
@@ -137,7 +137,7 @@ jobs:
name: Stylelint
defaults:
run:
working-directory: third_party/astroglobe
working-directory: third_party/astrolabe
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -146,7 +146,7 @@ jobs:
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
id: versions
with:
path: third_party/astroglobe
path: third_party/astrolabe
fallbackNode: '^20'
fallbackNpm: '^10'
@@ -175,7 +175,7 @@ jobs:
name: PHP CS Fixer
defaults:
run:
working-directory: third_party/astroglobe
working-directory: third_party/astrolabe
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -184,7 +184,7 @@ jobs:
id: versions
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
with:
filename: third_party/astroglobe/appinfo/info.xml
filename: third_party/astrolabe/appinfo/info.xml
- name: Set up php${{ steps.versions.outputs.php-min }}
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
@@ -212,7 +212,7 @@ jobs:
name: Psalm
defaults:
run:
working-directory: third_party/astroglobe
working-directory: third_party/astrolabe
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -221,7 +221,7 @@ jobs:
id: versions
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
with:
filename: third_party/astroglobe/appinfo/info.xml
filename: third_party/astrolabe/appinfo/info.xml
- name: Set up php${{ steps.versions.outputs.php-min }}
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
@@ -242,7 +242,7 @@ jobs:
id: ocp-versions
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
with:
filename: third_party/astroglobe/appinfo/info.xml
filename: third_party/astrolabe/appinfo/info.xml
- name: Install OCP for static analysis
run: |
@@ -253,14 +253,62 @@ jobs:
- name: Run Psalm
run: composer run psalm -- --threads=1 --monochrome --no-progress --output-format=github
# PHPUnit Tests
phpunit:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.php != 'false'
defaults:
run:
working-directory: third_party/astrolabe
strategy:
matrix:
php-versions: ['8.1', '8.2', '8.3']
name: PHPUnit (PHP ${{ matrix.php-versions }})
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up PHP ${{ matrix.php-versions }}
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
with:
php-version: ${{ matrix.php-versions }}
extensions: ctype, curl, dom, gd, iconv, intl, json, mbstring, openssl, posix, sqlite, xml, zip
coverage: none
ini-file: development
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install dependencies
run: |
composer remove nextcloud/ocp --dev || true
composer i
- name: Get OCP version matrix
id: ocp-versions
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
with:
filename: third_party/astrolabe/appinfo/info.xml
- name: Install OCP for testing
run: |
OCP_VERSION=$(echo '${{ steps.ocp-versions.outputs.ocp-matrix }}' | jq -r '.include[0]."ocp-version"')
composer require --dev "nextcloud/ocp:$OCP_VERSION" --ignore-platform-reqs --with-dependencies
- name: Run PHPUnit
run: composer run test:unit
# Summary job
summary:
permissions:
contents: none
runs-on: ubuntu-latest
needs: [changes, node-build, eslint, stylelint, php-cs, psalm]
needs: [changes, node-build, eslint, stylelint, php-cs, psalm, phpunit]
if: always()
name: astroglobe-ci-summary
name: astrolabe-ci-summary
steps:
- name: Summary status
run: |
@@ -268,7 +316,7 @@ jobs:
echo "Frontend checks failed"
exit 1
fi
if ${{ needs.changes.outputs.php != 'false' && (needs.php-cs.result != 'success' || needs.psalm.result != 'success') }}; then
if ${{ needs.changes.outputs.php != 'false' && (needs.php-cs.result != 'success' || needs.psalm.result != 'success' || needs.phpunit.result != 'success') }}; then
echo "PHP checks failed"
exit 1
fi
+13 -2
View File
@@ -87,21 +87,32 @@ jobs:
mcp_commit_count=$(git log "$commit_range" --oneline --grep="^(feat|fix|docs|refactor|perf|test|build|ci|chore)" -E | \
{ grep -v "(helm)" || true; } | { grep -v "(astrolabe)" || true; } | wc -l)
MCP_BUMPED=false
if [ "$mcp_commit_count" -gt 0 ]; then
echo "Found $mcp_commit_count commits for MCP server since $last_mcp_tag"
echo "Bumping MCP server version..."
./scripts/bump-mcp.sh
BUMPED_COMPONENTS="$BUMPED_COMPONENTS mcp"
MCP_BUMPED=true
else
echo "No commits found for MCP server since $last_mcp_tag"
fi
# Bump Helm chart (scope: helm)
# Bump Helm chart (scope: helm OR when MCP appVersion changes)
echo "Checking Helm chart for version bump..."
HELM_HAS_COMMITS=false
if has_commits_since_tag "nextcloud-mcp-server-" "(feat|fix|docs|refactor|perf|test|build|ci|chore)\(helm\)(!)?:"; then
echo "Bumping Helm chart version..."
HELM_HAS_COMMITS=true
fi
if [ "$HELM_HAS_COMMITS" = true ]; then
echo "Bumping Helm chart version (helm-scoped commits)..."
./scripts/bump-helm.sh
BUMPED_COMPONENTS="$BUMPED_COMPONENTS helm"
elif [ "$MCP_BUMPED" = true ]; then
echo "Bumping Helm chart version (appVersion changed)..."
./scripts/bump-helm.sh --increment PATCH
BUMPED_COMPONENTS="$BUMPED_COMPONENTS helm"
fi
# Bump Astrolabe (scope: astrolabe)
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@7145c3e0510bcdbdd29f67cc4a8c1958f1acfa2f # v1
uses: anthropics/claude-code-action@1b8ee3b94104046d71fde52ec3557651ad8c0d71 # v1.0.29
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@7145c3e0510bcdbdd29f67cc4a8c1958f1acfa2f # v1
uses: anthropics/claude-code-action@1b8ee3b94104046d71fde52ec3557651ad8c0d71 # v1.0.29
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Run docker compose with vector sync
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
uses: hoverkraft-tech/compose-action@05da55b2bb8a5a759d1c4732095044bd9018c050 # v2.4.3
with:
compose-file: |
./docker-compose.yml
@@ -42,7 +42,7 @@ jobs:
VECTOR_SYNC_SCAN_INTERVAL: "5"
- name: Install the latest version of uv
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
- name: Wait for Nextcloud to be ready
run: |
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Install uv
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
- name: Install Python 3.11
run: uv python install 3.11
- name: Build
+3 -3
View File
@@ -11,7 +11,7 @@ jobs:
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install the latest version of uv
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
- name: Check format
run: |
uv run --frozen ruff format --diff
@@ -66,14 +66,14 @@ jobs:
- name: Run docker compose
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
uses: hoverkraft-tech/compose-action@05da55b2bb8a5a759d1c4732095044bd9018c050 # v2.4.3
with:
compose-file: "./docker-compose.yml"
#compose-flags: "--profile qdrant"
up-flags: "--build"
- name: Install the latest version of uv
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
- name: Install Playwright dependencies
run: |
+72
View File
@@ -5,6 +5,78 @@ All notable changes to the Nextcloud MCP Server will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/).
## v0.61.5 (2026-01-17)
### Fix
- **astrolabe**: improve token refresh error handling and validation
- **astrolabe**: delete stale tokens when refresh fails
- **astrolabe**: resolve CI failures for code quality checks
- **astrolabe**: use internal URL for OAuth token refresh
### Refactor
- **astrolabe**: add PHP property types to fix Psalm errors
- **astrolabe**: upgrade to @nextcloud/vue 9.3.3 API
## v0.61.4 (2026-01-16)
### Fix
- **astrolabe**: Address reviewer feedback for hybrid mode
- **astrolabe**: Fix NcSelect options and CSS loading
- **astrolabe**: fix OAuth flow and settings UI for hybrid mode
- **api**: return OIDC config in hybrid mode for Astrolabe OAuth flow
## v0.61.3 (2026-01-15)
### Fix
- **astrolabe**: address review feedback for Vue 3 bindings
- **astrolabe**: update Vue component bindings for Vue 3 compatibility
## v0.61.2 (2026-01-15)
### Fix
- **ci**: bump helm chart version when MCP appVersion changes
## v0.61.1 (2026-01-15)
### Fix
- **astrolabe**: define appName and appVersion for @nextcloud/vue
## v0.61.0 (2026-01-14)
### Feat
- Add rate limiting and extract helpers for app password endpoints
### Fix
- Add missing annotations for deck remove/unassign operations
- **auth**: Store app passwords locally for multi-user BasicAuth background sync
### Refactor
- Use get_settings() for vector sync enabled check
- Extract storage helper and improve PHP error handling
## v0.60.4 (2026-01-12)
### Fix
- **deck**: use correct endpoint for reorder_card to fix cross-stack moves
## v0.60.3 (2025-12-31)
### Fix
- **deck**: Always preserve fields in update_card for partial updates
- **astrolabe**: Fix CSS loading for Nextcloud apps
- **astrolabe**: Fix revoke access button HTTP method mismatch
## v0.60.2 (2025-12-29)
### Fix
+2 -2
View File
@@ -1,6 +1,6 @@
FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
FROM docker.io/library/python:3.12-slim-trixie@sha256:d75c4b6cdd039ae966a34cd3ccab9e0e5f7299280ad76fe1744882d86eedce0b
COPY --from=ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 /uv /uvx /bin/
COPY --from=ghcr.io/astral-sh/uv:0.9.26@sha256:9a23023be68b2ed09750ae636228e903a54a05ea56ed03a934d00fe9fbeded4b /uv /uvx /bin/
# Install dependencies
# 1. git (required for caldav dependency from git)
+2 -2
View File
@@ -12,12 +12,12 @@
# - Per-session app password authentication
# - Multi-user support via Smithery session config
FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
FROM docker.io/library/python:3.12-slim-trixie@sha256:d75c4b6cdd039ae966a34cd3ccab9e0e5f7299280ad76fe1744882d86eedce0b
WORKDIR /app
# Install uv for fast dependency management
COPY --from=ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 /uv /uvx /bin/
COPY --from=ghcr.io/astral-sh/uv:0.9.26@sha256:9a23023be68b2ed09750ae636228e903a54a05ea56ed03a934d00fe9fbeded4b /uv /uvx /bin/
# Install dependencies
# 1. git (required for caldav dependency from git)
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# Configure MCP server URL for Astrolabe background sync
# This URL is used by Astrolabe to send app passwords to the MCP server
set -e
# The MCP multi-user BasicAuth service runs on port 8000 inside the container
# From Nextcloud's perspective (inside Docker network), we reach it via service name
MCP_SERVER_URL="${MCP_SERVER_URL:-http://mcp-multi-user-basic:8000}"
echo "Configuring MCP server URL: $MCP_SERVER_URL"
# Set the mcp_server_url in config.php via occ
php occ config:system:set mcp_server_url --value="$MCP_SERVER_URL"
echo "MCP server URL configured successfully"
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.commitizen]
name = "cz_conventional_commits"
version = "0.56.2"
version = "0.57.6"
tag_format = "nextcloud-mcp-server-$version"
version_scheme = "semver"
update_changelog_on_bump = true
+49
View File
@@ -14,6 +14,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Configurable resource limits
- Grafana dashboard annotations
## nextcloud-mcp-server-0.57.6 (2026-01-16)
## nextcloud-mcp-server-0.57.5 (2026-01-16)
## nextcloud-mcp-server-0.57.4 (2026-01-16)
### Fix
- **astrolabe**: Address reviewer feedback for hybrid mode
- **astrolabe**: Fix NcSelect options and CSS loading
- **astrolabe**: fix OAuth flow and settings UI for hybrid mode
- **api**: return OIDC config in hybrid mode for Astrolabe OAuth flow
## nextcloud-mcp-server-0.57.3 (2026-01-15)
## nextcloud-mcp-server-0.57.2 (2026-01-15)
### Fix
- **astrolabe**: address review feedback for Vue 3 bindings
- **astrolabe**: update Vue component bindings for Vue 3 compatibility
## nextcloud-mcp-server-0.57.1 (2026-01-15)
### Fix
- **ci**: bump helm chart version when MCP appVersion changes
- **astrolabe**: define appName and appVersion for @nextcloud/vue
## nextcloud-mcp-server-0.57.0 (2026-01-15)
### Feat
- Add rate limiting and extract helpers for app password endpoints
### Fix
- Add missing annotations for deck remove/unassign operations
- **auth**: Store app passwords locally for multi-user BasicAuth background sync
- **deck**: use correct endpoint for reorder_card to fix cross-stack moves
- **deck**: Always preserve fields in update_card for partial updates
- **astrolabe**: Fix CSS loading for Nextcloud apps
- **astrolabe**: Fix revoke access button HTTP method mismatch
### Refactor
- Use get_settings() for vector sync enabled check
- Extract storage helper and improve PHP error handling
## nextcloud-mcp-server-0.56.2 (2025-12-29)
### Fix
+3 -3
View File
@@ -4,6 +4,6 @@ dependencies:
version: 1.16.3
- name: ollama
repository: https://otwld.github.io/ollama-helm
version: 1.36.0
digest: sha256:7f0979ec4110ff41ebeb55bf586b41366a350cc39fe65a2da7d2da03f723fe9b
generated: "2025-12-22T11:09:39.166328543Z"
version: 1.37.0
digest: sha256:0ce3bb4b5e95a3b8fde3f5f374d7b62aeafcb0dcf8a60b9d95978530b6c05b68
generated: "2026-01-08T11:11:12.857375888Z"
+3 -3
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: nextcloud-mcp-server
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
type: application
version: 0.56.2
appVersion: "0.60.2"
version: 0.57.6
appVersion: "0.61.5"
keywords:
- nextcloud
- mcp
@@ -31,6 +31,6 @@ dependencies:
repository: https://qdrant.github.io/qdrant-helm
condition: qdrant.networkMode.deploySubchart
- name: ollama
version: "1.36.0"
version: "1.37.0"
repository: https://otwld.github.io/ollama-helm
condition: ollama.enabled
+11 -15
View File
@@ -3,7 +3,7 @@ services:
# https://hub.docker.com/_/mariadb
db:
# Note: Check the recommend version here: https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html#server
image: docker.io/library/mariadb:lts@sha256:1cac8492bd78b1ec693238dc600be173397efd7b55eabc725abc281dc855b482
image: docker.io/library/mariadb:lts@sha256:345fa26d595e8c7fe298e0c4098ed400356f502458769c8902229b3437d6da2b
restart: always
command: --transaction-isolation=READ-COMMITTED
volumes:
@@ -23,7 +23,7 @@ services:
restart: always
app:
image: docker.io/library/nextcloud:32.0.3@sha256:53231a9fb9233af2c15bfe70fc03ebe639fd53243fa42a9369884b1e0008deae
image: docker.io/library/nextcloud:32.0.4@sha256:9ca3f78fcca340ea32ab7bf1a01b2a2fd3eae64ffc0e791fd71eb9d72c3d2efe
restart: always
ports:
- 127.0.0.1:8080:80
@@ -54,14 +54,14 @@ services:
retries: 30
recipes:
image: docker.io/library/nginx:alpine@sha256:052b75ab72f690f33debaa51c7e08d9b969a0447a133eb2b99cc905d9188cb2b
image: docker.io/library/nginx:alpine@sha256:66d420cc54ef85bcc1d72220e83d7aaa6c4850bd2904794e3a56f09fd4ccb66e
restart: always
volumes:
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
unstructured:
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:54282d3a25f33fd6cf69bc45b3d37770f213593f58b6dfe5e85fe546376b2807
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:db5fcc831eb673ec835c41e8d47f993fdde276562285d6837cebb03f958536a2
restart: always
ports:
- 127.0.0.1:8002:8000
@@ -88,8 +88,8 @@ services:
- NEXTCLOUD_PASSWORD=admin
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
# Vector sync configuration (ADR-007)
#- VECTOR_SYNC_ENABLED=true
# Semantic search configuration (ADR-007, ADR-021)
#- ENABLE_SEMANTIC_SEARCH=true
- VECTOR_SYNC_SCAN_INTERVAL=60
- VECTOR_SYNC_PROCESSOR_WORKERS=1
@@ -140,14 +140,13 @@ services:
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8003
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
- ENABLE_MULTI_USER_BASIC_AUTH=true
#- ENABLE_OFFLINE_ACCESS=true
- ENABLE_BACKGROUND_OPERATIONS=true
# Token storage (required for middleware initialization)
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
- TOKEN_STORAGE_DB=/app/data/tokens.db
- VECTOR_SYNC_ENABLED=true
- ENABLE_SEMANTIC_SEARCH=true
- VECTOR_SYNC_SCAN_INTERVAL=60
- VECTOR_SYNC_PROCESSOR_WORKERS=1
@@ -180,7 +179,6 @@ services:
- NEXTCLOUD_OIDC_SCOPES=openid profile email notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write
# Refresh token storage (ADR-002 Tier 1)
#- ENABLE_OFFLINE_ACCESS=true
- ENABLE_BACKGROUND_OPERATIONS=true
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
- TOKEN_STORAGE_DB=/app/data/tokens.db
@@ -189,9 +187,8 @@ services:
# Tokens must contain BOTH MCP and Nextcloud audiences
# No token exchange needed - tokens work for both MCP auth and Nextcloud APIs
# Vector sync configuration (ADR-007)
# Semantic search configuration (ADR-007, ADR-021)
- ENABLE_SEMANTIC_SEARCH=true
#- VECTOR_SYNC_ENABLED=true
- VECTOR_SYNC_SCAN_INTERVAL=60
- VECTOR_SYNC_PROCESSOR_WORKERS=1
@@ -211,7 +208,7 @@ services:
- oauth-tokens:/app/data
keycloak:
image: quay.io/keycloak/keycloak:26.4.7@sha256:9409c59bdfb65dbffa20b11e6f18b8abb9281d480c7ca402f51ed3d5977e6007
image: quay.io/keycloak/keycloak:26.5.1@sha256:b80a48090594367bd8cf6fe2019466ac4ea49de4d0830fb2a43256eda37b18f5
command:
- "start-dev"
- "--import-realm"
@@ -259,7 +256,6 @@ services:
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8888/realms/nextcloud-mcp
# Refresh token storage (ADR-002 Tier 1 & 2)
#- ENABLE_OFFLINE_ACCESS=true
- ENABLE_BACKGROUND_OPERATIONS=true
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
- TOKEN_STORAGE_DB=/app/data/tokens.db
@@ -293,13 +289,13 @@ services:
- 127.0.0.1:8081:8081
environment:
- SMITHERY_DEPLOYMENT=true
- VECTOR_SYNC_ENABLED=false
- ENABLE_SEMANTIC_SEARCH=false
- PORT=8081
profiles:
- smithery
qdrant:
image: qdrant/qdrant:v1.16.2@sha256:dab6de32f7b2cc599985a7c764db3e8b062f70508fb85ca074aa856f829bf335
image: docker.io/qdrant/qdrant:v1.16.3@sha256:0425e3e03e7fd9b3dc95c4214546afe19de2eb2e28ca621441a56663ac6e1f46
restart: always
ports:
- 127.0.0.1:6333:6333 # REST API
+49
View File
@@ -223,6 +223,55 @@ NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
| Token Storage | None | Refresh tokens only | All tokens |
| Deployment Complexity | Low | Medium | High |
### Astrolabe User Setup (Hybrid Mode)
When Astrolabe connects to an MCP server running in hybrid mode, users must complete a **two-step credential setup**:
#### Step 1: OAuth Authorization (Search Access)
**Purpose**: Allows Astrolabe to call MCP server APIs on the user's behalf.
**Flow**:
1. User opens Astrolabe Personal Settings in Nextcloud
2. Clicks "Authorize" button
3. Redirected to Astrolabe's OAuth controller (`/apps/astrolabe/oauth/initiate`)
4. OAuth controller discovers IdP from MCP server's `/api/v1/status` endpoint
5. User authenticates with Identity Provider (Nextcloud OIDC or external IdP)
6. Tokens stored in Nextcloud user config (`McpTokenStorage`)
7. Astrolabe can now perform semantic searches via MCP API
**Technical Details**:
- Token audience: MCP server
- Token storage: Nextcloud app config (`oc_preferences`)
- Used for: `/api/v1/search`, `/api/v1/status` (authenticated endpoints)
#### Step 2: App Password (Background Indexing)
**Purpose**: Allows MCP server to access Nextcloud content for background sync.
**Flow**:
1. User generates app password in Nextcloud Security settings
2. Enters app password in Astrolabe Personal Settings
3. App password validated against Nextcloud and stored (encrypted)
4. MCP server can now index user's content in the background
**Technical Details**:
- Credential type: Nextcloud app password
- Token storage: MCP server's refresh token database
- Used for: Background indexing, content sync to vector database
#### Why Two Credentials?
| Direction | Auth Method | Purpose |
|-----------|-------------|---------|
| Astrolabe → MCP Server | OAuth Bearer Token | User searches, settings management |
| MCP Server → Nextcloud | BasicAuth (App Password) | Background content indexing |
The separation ensures:
- **Security**: Each credential has limited scope
- **Audit Trail**: OAuth tokens identify users; app passwords enable background ops
- **User Control**: Users explicitly grant each type of access
### See Also
- [OAuth Architecture](oauth-architecture.md) - Progressive Consent (Flow 2) details
- [Configuration](configuration.md#enable_offline_access) - Hybrid mode configuration
+22
View File
@@ -531,6 +531,28 @@ docker-compose up
---
## Astrolabe Internal URL
The Astrolabe Nextcloud app may need to make internal HTTP requests to the local web server (e.g., for OAuth token refresh). By default, it uses `http://localhost` which works for standard Docker containers where PHP and Apache run together.
| Variable | Description | Default |
|----------|-------------|---------|
| `astrolabe_internal_url` | Internal URL for server-to-server requests within container | `http://localhost` |
**When to configure:**
- Custom container setups where the internal web server is not on `localhost:80`
- Kubernetes deployments with service discovery
- Multi-container setups with separate web server containers
**Example (Nextcloud config.php):**
```php
'astrolabe_internal_url' => 'http://web-server.internal:8080',
```
**Note:** This is for internal PHP-to-Apache requests, NOT for external client URLs. The default (`http://localhost`) works for standard Docker containers where PHP and Apache run together.
---
## Loading Environment Variables
After creating your `.env` file, load the environment variables:
+357
View File
@@ -0,0 +1,357 @@
# Webhook Management Guide
This guide explains how to enable and disable webhooks for vector sync in each MCP server deployment mode. Webhooks enable near-real-time synchronization of content changes to the vector database, complementing the default polling-based sync.
**Related ADRs:**
- ADR-010: Webhook-Based Vector Sync
- ADR-020: Deployment Modes and Configuration Validation
## Prerequisites
Before enabling webhooks, ensure:
1. **Nextcloud 30+** with `webhook_listeners` app enabled
2. **Astrolabe app** installed in Nextcloud (provides settings UI and credentials API)
3. **MCP server** accessible from Nextcloud via HTTP(S)
4. **Vector sync enabled** on the MCP server
## Webhook Architecture Overview
The webhook system has two components:
1. **Webhook Registration** - Configuring Nextcloud to send change notifications to the MCP server
2. **Background Sync Credentials** - Allowing the MCP server to access Nextcloud APIs on behalf of users
Both must be configured for webhooks to function properly.
## Deployment Mode Specifics
### 1. Single-User BasicAuth
**Configuration:**
```bash
NEXTCLOUD_HOST=http://localhost:8080
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password
VECTOR_SYNC_ENABLED=true
```
**Enable Webhooks:**
1. Register webhooks using occ commands (requires Nextcloud admin):
```bash
# Enable webhook_listeners app
php occ app:enable webhook_listeners
# Register webhooks for vector sync
php occ webhook_listeners:add \
--event "OCP\Files\Events\Node\NodeCreatedEvent" \
--uri "http://mcp-server:8000/webhooks/nextcloud" \
--method POST
# Repeat for other events (see Event Types below)
```
2. Optionally reduce polling frequency:
```bash
VECTOR_SYNC_SCAN_INTERVAL=86400 # 24 hours
```
**Disable Webhooks:**
```bash
# List registered webhooks
php occ webhook_listeners:list
# Remove specific webhook by ID
php occ webhook_listeners:remove <webhook-id>
```
**Notes:**
- Simplest mode - admin credentials used for all operations
- No per-user provisioning required
- Background sync runs as the configured admin user
---
### 2. Multi-User BasicAuth Pass-Through
**Configuration:**
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
ENABLE_MULTI_USER_BASIC_AUTH=true
ENABLE_BACKGROUND_OPERATIONS=true
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/app/data/tokens.db
VECTOR_SYNC_ENABLED=true
# OAuth client for Astrolabe API access
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
```
**Credential Architecture:**
This mode uses **two separate credential mechanisms**:
1. **OAuth Session** (for management API access, including webhooks):
- Obtained via browser OAuth flow (`/oauth/login`)
- Stores refresh token in MCP server's `tokens.db`
- Used for webhook registration/management APIs
2. **App Password** (for background sync):
- Generated in Nextcloud Security settings
- Stored encrypted in Nextcloud's `oc_preferences` via Astrolabe
- Used by background scanners to access Nextcloud APIs
**Enable Webhooks:**
#### Step 1: Complete OAuth Login (for Management API)
Users must authorize the MCP server to access their Nextcloud:
1. Navigate to **Nextcloud Settings → Astrolabe** (Personal settings)
2. Click **"Authorize via OAuth"** under "Option 1"
3. Complete OAuth consent flow
4. Verify the page shows "Background Sync Access: Active"
#### Step 2: Configure App Password (for Background Sync)
Since OAuth refresh tokens have short expiry, users should also configure an app password:
1. Navigate to **Nextcloud Settings → Security**
2. Generate a new app password (name it "Astrolabe" or "MCP Server")
3. Return to **Nextcloud Settings → Astrolabe**
4. Under "Option 2: App Password", paste the app password
5. Click **Save**
#### Step 3: Register Webhooks (Admin)
Same as Single-User BasicAuth:
```bash
php occ webhook_listeners:add \
--event "OCP\Files\Events\Node\NodeCreatedEvent" \
--uri "http://mcp-server:8003/webhooks/nextcloud" \
--method POST
```
**Disable Webhooks:**
*Per-User:*
1. Navigate to **Nextcloud Settings → Astrolabe**
2. Click **"Revoke Access"** (for OAuth tokens) or **"Revoke Access"** (for app password)
*System-Wide:*
```bash
php occ webhook_listeners:remove <webhook-id>
```
**Troubleshooting:**
If OAuth login fails with "Access forbidden - Your client is not authorized":
1. Check if OAuth client is registered:
```sql
SELECT id, name, client_identifier FROM oc_oidc_clients
WHERE dcr = 1 ORDER BY id DESC LIMIT 5;
```
2. Restart MCP server to trigger DCR re-registration
3. Verify `NEXTCLOUD_OIDC_CLIENT_ID` and `NEXTCLOUD_OIDC_CLIENT_SECRET` are set
If background sync fails with "User no longer provisioned":
1. Verify app password is stored:
```sql
SELECT userid, configkey FROM oc_preferences
WHERE appid = 'astrolabe' AND userid = 'username';
```
2. Ensure user completed **both** OAuth login AND app password setup
---
### 3. OAuth Single-Audience (Default OAuth Mode)
**Configuration:**
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
# No NEXTCLOUD_USERNAME/PASSWORD
ENABLE_BACKGROUND_OPERATIONS=true
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/app/data/tokens.db
VECTOR_SYNC_ENABLED=true
```
**Enable Webhooks:**
#### Step 1: User Provisioning
Users authorize via OAuth with `offline_access` scope:
1. MCP client initiates OAuth flow
2. User consents to requested scopes including `offline_access`
3. MCP server stores refresh token for background operations
Alternatively, via Astrolabe UI:
1. Navigate to **Nextcloud Settings → Astrolabe**
2. Click **"Authorize via OAuth"**
3. Complete consent flow
#### Step 2: Register Webhooks (Admin)
```bash
php occ webhook_listeners:add \
--event "OCP\Files\Events\Node\NodeCreatedEvent" \
--uri "http://mcp-server:8001/webhooks/nextcloud" \
--method POST
```
**Disable Webhooks:**
*Per-User:*
- Via Astrolabe UI: Click "Disable Indexing" or "Disconnect"
- Via MCP tool: Use `revoke_nextcloud_access` if available
*System-Wide:*
```bash
php occ webhook_listeners:remove <webhook-id>
```
---
### 4. OAuth Token Exchange (RFC 8693)
**Configuration:**
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
ENABLE_TOKEN_EXCHANGE=true
ENABLE_BACKGROUND_OPERATIONS=true
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/app/data/tokens.db
VECTOR_SYNC_ENABLED=true
```
**Enable/Disable Webhooks:**
Same process as OAuth Single-Audience. The token exchange happens transparently when the MCP server accesses Nextcloud APIs.
---
### 5. Smithery Stateless
**Configuration:**
- Configuration from session URL params
- `VECTOR_SYNC_ENABLED=false` (required)
**Webhooks:**
**Not supported.** This mode is stateless with no persistent storage or background operations.
---
## Webhook Event Types
Register these webhook events for full vector sync coverage:
### File/Note Events
```bash
# Use BeforeNodeDeletedEvent for deletions (includes node.id)
php occ webhook_listeners:add --event "OCP\Files\Events\Node\NodeCreatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
php occ webhook_listeners:add --event "OCP\Files\Events\Node\NodeWrittenEvent" --uri "$MCP_URL/webhooks/nextcloud"
php occ webhook_listeners:add --event "OCP\Files\Events\Node\BeforeNodeDeletedEvent" --uri "$MCP_URL/webhooks/nextcloud"
```
### Calendar Events
```bash
php occ webhook_listeners:add --event "OCP\Calendar\Events\CalendarObjectCreatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
php occ webhook_listeners:add --event "OCP\Calendar\Events\CalendarObjectUpdatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
php occ webhook_listeners:add --event "OCP\Calendar\Events\CalendarObjectDeletedEvent" --uri "$MCP_URL/webhooks/nextcloud"
```
### Tables Events
```bash
php occ webhook_listeners:add --event "OCA\Tables\Event\RowAddedEvent" --uri "$MCP_URL/webhooks/nextcloud"
php occ webhook_listeners:add --event "OCA\Tables\Event\RowUpdatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
php occ webhook_listeners:add --event "OCA\Tables\Event\RowDeletedEvent" --uri "$MCP_URL/webhooks/nextcloud"
```
## Webhook Presets (via Astrolabe UI)
The Astrolabe app provides preset webhook configurations that can be enabled/disabled via the Admin settings UI:
| Preset | Events Covered |
|--------|----------------|
| `notes_sync` | File create/update/delete for .md files |
| `calendar_sync` | Calendar object events |
| `tables_sync` | Tables row events |
| `forms_sync` | Forms submission events |
| `files_sync` | All file events (optional, high volume) |
**Enable Presets:**
1. Navigate to **Nextcloud Settings → Astrolabe** (Admin settings)
2. Toggle desired presets in "Webhook Configuration"
**Note:** Presets require the MCP server's management API to be accessible. The API uses OAuth bearer tokens from the user's session.
## Security Considerations
### Webhook Authentication
Configure `WEBHOOK_SECRET` to require authentication for incoming webhooks:
```bash
# MCP Server
WEBHOOK_SECRET=<generate-random-secret>
# Nextcloud webhook registration
php occ webhook_listeners:add \
--event "..." \
--uri "$MCP_URL/webhooks/nextcloud" \
--header "Authorization: Bearer <secret>"
```
### Token Storage
- Refresh tokens and app passwords are encrypted using `TOKEN_ENCRYPTION_KEY`
- Store the key securely (environment variable, secrets manager)
- Different users have isolated credential storage
## Monitoring
### MCP Server Logs
```bash
# Docker
docker compose logs mcp-multi-user-basic | grep -i webhook
# Key log messages
# - "Queued document from webhook: ..." - Success
# - "Webhook authentication failed" - Auth error
# - "User X no longer provisioned" - Missing credentials
```
### Nextcloud Logs
```bash
docker compose exec app cat /var/www/html/data/nextcloud.log | \
jq 'select(.message | contains("webhook"))' | tail
```
### Database Checks
```sql
-- Check registered webhooks
SELECT * FROM oc_webhook_listeners;
-- Check OAuth clients
SELECT id, name, token_type FROM oc_oidc_clients WHERE dcr = 1;
-- Check user credentials in Astrolabe
SELECT userid, configkey FROM oc_preferences WHERE appid = 'astrolabe';
```
## Common Issues
### "Access forbidden - Your client is not authorized to connect"
**Cause:** OAuth client registration expired or not present in Nextcloud
**Fix:** Restart MCP server to trigger DCR re-registration
### "User X no longer provisioned, stopping scanner"
**Cause:** Background sync credentials missing or expired
**Fix:** User must complete credential provisioning (see mode-specific steps)
### "Failed to fetch" in browser console during OAuth
**Cause:** Network issue between browser and MCP server callback endpoint
**Fix:** Verify MCP server is accessible at the configured `NEXTCLOUD_MCP_SERVER_URL`
### Webhooks not firing
**Causes:**
1. `webhook_listeners` app not enabled
2. Webhook not registered for the event type
3. Background job workers not running
**Fix:**
```bash
php occ app:enable webhook_listeners
php occ background:cron # or configure systemd cron
```
@@ -0,0 +1,50 @@
"""Add app_passwords table for multi-user BasicAuth mode
This migration adds support for storing app passwords that are provisioned
via Astrolabe's personal settings. This enables background sync in
multi-user BasicAuth mode without requiring OAuth.
Revision ID: 002
Revises: 001
Create Date: 2026-01-13 12:00:00.000000
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "002"
down_revision = "001"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Add app_passwords table for multi-user BasicAuth mode."""
# App passwords table for multi-user BasicAuth background sync
op.execute(
"""
CREATE TABLE IF NOT EXISTS app_passwords (
user_id TEXT PRIMARY KEY,
encrypted_password BLOB NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
)
# Index for efficient user lookups
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_app_passwords_updated
ON app_passwords(updated_at)
"""
)
def downgrade() -> None:
"""Drop app_passwords table."""
op.execute("DROP INDEX IF EXISTS idx_app_passwords_updated")
op.execute("DROP TABLE IF EXISTS app_passwords")
+414 -3
View File
@@ -10,12 +10,18 @@ All endpoints use OAuth bearer token authentication via UnifiedTokenVerifier.
The PHP app obtains tokens through PKCE flow and uses them to access these endpoints.
"""
import base64
import logging
import re
import time
from collections import defaultdict
from importlib.metadata import version
from typing import Any
from typing import TYPE_CHECKING, Any
import httpx
if TYPE_CHECKING:
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from starlette.requests import Request
from starlette.responses import JSONResponse
@@ -25,6 +31,23 @@ logger = logging.getLogger(__name__)
# Get package version from metadata
__version__ = version("nextcloud-mcp-server")
# App password format regex (Nextcloud format: xxxxx-xxxxx-xxxxx-xxxxx-xxxxx)
APP_PASSWORD_PATTERN = re.compile(
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}$"
)
# Timeout for Nextcloud API validation requests (seconds)
NEXTCLOUD_VALIDATION_TIMEOUT = 10.0
# Rate limiting configuration for app password provisioning
# Limits: 5 attempts per user per hour
RATE_LIMIT_MAX_ATTEMPTS = 5
RATE_LIMIT_WINDOW_SECONDS = 3600 # 1 hour
# In-memory rate limiter storage
# Structure: {user_id: [(timestamp, success), ...]}
_rate_limit_attempts: dict[str, list[tuple[float, bool]]] = defaultdict(list)
# Track server start time for uptime calculation
_server_start_time = time.time()
@@ -181,6 +204,141 @@ def _validate_query_string(query: str, max_length: int = 10000) -> None:
raise ValueError(f"Query too long: maximum {max_length} characters")
async def _get_app_password_storage(request: Request) -> "RefreshTokenStorage":
"""Get or initialize RefreshTokenStorage for app password operations.
Checks app.state.storage first, then falls back to creating from environment.
This helper avoids repeated storage initialization logic across endpoints.
Args:
request: Starlette request with app state
Returns:
Initialized RefreshTokenStorage instance
"""
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
storage = getattr(request.app.state, "storage", None)
if not storage:
# Multi-user BasicAuth mode may not have oauth_context
# Initialize storage from environment
storage = RefreshTokenStorage.from_env()
await storage.initialize()
return storage
def _check_rate_limit(user_id: str) -> tuple[bool, int]:
"""Check if user is rate limited for app password operations.
Implements a sliding window rate limiter to prevent brute-force attacks
on the app password provisioning endpoint.
Args:
user_id: User identifier to check
Returns:
Tuple of (is_allowed, seconds_until_retry)
- is_allowed: True if request should be allowed
- seconds_until_retry: Seconds to wait if rate limited (0 if allowed)
"""
current_time = time.time()
window_start = current_time - RATE_LIMIT_WINDOW_SECONDS
# Clean up old attempts outside the window
_rate_limit_attempts[user_id] = [
(ts, success)
for ts, success in _rate_limit_attempts[user_id]
if ts > window_start
]
# Count recent attempts (both successful and failed)
recent_attempts = len(_rate_limit_attempts[user_id])
if recent_attempts >= RATE_LIMIT_MAX_ATTEMPTS:
# Find when the oldest attempt in the window will expire
oldest_attempt = min(ts for ts, _ in _rate_limit_attempts[user_id])
seconds_until_retry = int(
oldest_attempt + RATE_LIMIT_WINDOW_SECONDS - current_time
)
return False, max(1, seconds_until_retry)
return True, 0
def _record_rate_limit_attempt(user_id: str, success: bool) -> None:
"""Record an app password provisioning attempt for rate limiting.
Args:
user_id: User identifier
success: Whether the attempt was successful
"""
_rate_limit_attempts[user_id].append((time.time(), success))
def _extract_basic_auth(
request: Request, path_user_id: str
) -> tuple[str, str, JSONResponse | None]:
"""Extract and validate BasicAuth credentials from request.
Validates:
1. Authorization header is present and valid BasicAuth format
2. Username in credentials matches the path user_id
Args:
request: Starlette request with Authorization header
path_user_id: User ID from the URL path to verify against
Returns:
Tuple of (username, password, error_response)
- If successful: (username, password, None)
- If failed: ("", "", JSONResponse with error)
"""
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Basic "):
return (
"",
"",
JSONResponse(
{"success": False, "error": "Missing BasicAuth credentials"},
status_code=401,
),
)
try:
# Decode BasicAuth
encoded = auth_header.split(" ", 1)[1]
decoded = base64.b64decode(encoded).decode("utf-8")
username, password = decoded.split(":", 1)
except Exception:
return (
"",
"",
JSONResponse(
{"success": False, "error": "Invalid BasicAuth format"},
status_code=401,
),
)
# Verify username matches path user_id
if username != path_user_id:
logger.warning(
f"Username mismatch in app password operation for path user {path_user_id}"
)
return (
"",
"",
JSONResponse(
{"success": False, "error": "Username does not match path user_id"},
status_code=403,
),
)
return username, password, None
async def get_server_status(request: Request) -> JSONResponse:
"""GET /api/v1/status - Server status and version.
@@ -229,8 +387,13 @@ async def get_server_status(request: Request) -> JSONResponse:
if mode == AuthMode.MULTI_USER_BASIC:
response_data["supports_app_passwords"] = settings.enable_offline_access
# Include OIDC configuration if in OAuth mode
if auth_mode == "oauth":
# Include OIDC configuration if OAuth is available
# This includes OAuth mode AND hybrid mode (multi_user_basic + offline_access)
# Astrolabe needs OIDC config to discover IdP for OAuth flow in hybrid mode
oauth_provisioning_available = auth_mode == "oauth" or (
mode == AuthMode.MULTI_USER_BASIC and settings.enable_offline_access
)
if oauth_provisioning_available:
# Provide IdP discovery information for NC PHP app
oidc_config = {}
@@ -510,6 +673,254 @@ async def revoke_user_access(request: Request) -> JSONResponse:
)
async def provision_app_password(request: Request) -> JSONResponse:
"""POST /api/v1/users/{user_id}/app-password - Store app password for background sync.
This endpoint is used by Astrolabe (Nextcloud PHP app) to provision app passwords
for multi-user BasicAuth mode background sync.
The request must include BasicAuth credentials where:
- username: Nextcloud user ID (must match path user_id)
- password: The app password being provisioned
The MCP server validates the app password against Nextcloud before storing it.
This proves the user owns the password and has access to Nextcloud.
Security model:
- User identity is verified via BasicAuth against Nextcloud
- App password is encrypted before storage
- Only the user who owns the password can provision it
- Rate limited to prevent brute-force attacks
"""
from nextcloud_mcp_server.config import get_settings
# Get user_id from path
path_user_id = request.path_params.get("user_id")
if not path_user_id:
return JSONResponse(
{"success": False, "error": "Missing user_id in path"},
status_code=400,
)
# Check rate limit before processing
is_allowed, retry_after = _check_rate_limit(path_user_id)
if not is_allowed:
logger.warning(
f"Rate limit exceeded for app password provisioning: {path_user_id}"
)
return JSONResponse(
{
"success": False,
"error": f"Rate limit exceeded. Try again in {retry_after} seconds.",
},
status_code=429,
headers={"Retry-After": str(retry_after)},
)
# Extract and validate BasicAuth credentials
username, app_password, error_response = _extract_basic_auth(request, path_user_id)
if error_response is not None:
_record_rate_limit_attempt(path_user_id, success=False)
return error_response
# Validate app password format
if not APP_PASSWORD_PATTERN.match(app_password):
_record_rate_limit_attempt(path_user_id, success=False)
return JSONResponse(
{"success": False, "error": "Invalid app password format"},
status_code=400,
)
# Get Nextcloud host from settings
settings = get_settings()
nextcloud_host = settings.nextcloud_host
if not nextcloud_host:
logger.error("NEXTCLOUD_HOST not configured")
return JSONResponse(
{"success": False, "error": "Server not configured"},
status_code=500,
)
# Validate app password against Nextcloud
try:
async with httpx.AsyncClient(timeout=NEXTCLOUD_VALIDATION_TIMEOUT) as client:
# Use OCS API to verify credentials
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
response = await client.get(
test_url,
auth=(username, app_password),
params={"format": "json"},
headers={"OCS-APIRequest": "true"},
)
if response.status_code != 200:
logger.warning(
f"App password validation failed for user: HTTP {response.status_code}"
)
_record_rate_limit_attempt(path_user_id, success=False)
return JSONResponse(
{"success": False, "error": "Invalid app password"},
status_code=401,
)
# Verify the user ID from response matches
data = response.json()
ocs_user_id = data.get("ocs", {}).get("data", {}).get("id")
if ocs_user_id != username:
logger.warning("User ID mismatch in OCS response")
_record_rate_limit_attempt(path_user_id, success=False)
return JSONResponse(
{"success": False, "error": "User ID mismatch"},
status_code=403,
)
except httpx.RequestError as e:
logger.error(f"Failed to validate app password: {e}")
return JSONResponse(
{"success": False, "error": "Failed to validate credentials"},
status_code=500,
)
# Store the validated app password
try:
storage = await _get_app_password_storage(request)
await storage.store_app_password(username, app_password)
_record_rate_limit_attempt(path_user_id, success=True)
logger.info(f"Provisioned app password for user: {username}")
return JSONResponse(
{
"success": True,
"message": f"App password stored for {username}",
}
)
except Exception as e:
error_msg = _sanitize_error_for_client(e, "provision_app_password")
return JSONResponse(
{"success": False, "error": error_msg},
status_code=500,
)
async def get_app_password_status(request: Request) -> JSONResponse:
"""GET /api/v1/users/{user_id}/app-password - Check if user has provisioned app password.
Returns status of background sync access for multi-user BasicAuth mode.
Requires BasicAuth with the user's app password for authentication.
"""
# Get user_id from path
path_user_id = request.path_params.get("user_id")
if not path_user_id:
return JSONResponse(
{"success": False, "error": "Missing user_id in path"},
status_code=400,
)
# Extract and validate BasicAuth credentials
username, _, error_response = _extract_basic_auth(request, path_user_id)
if error_response is not None:
return error_response
try:
storage = await _get_app_password_storage(request)
app_password = await storage.get_app_password(username)
return JSONResponse(
{
"success": True,
"user_id": username,
"has_app_password": app_password is not None,
}
)
except Exception as e:
error_msg = _sanitize_error_for_client(e, "get_app_password_status")
return JSONResponse(
{"success": False, "error": error_msg},
status_code=500,
)
async def delete_app_password(request: Request) -> JSONResponse:
"""DELETE /api/v1/users/{user_id}/app-password - Delete stored app password.
Removes the user's app password from MCP server storage.
Requires BasicAuth with the user's credentials.
"""
from nextcloud_mcp_server.config import get_settings
# Get user_id from path
path_user_id = request.path_params.get("user_id")
if not path_user_id:
return JSONResponse(
{"success": False, "error": "Missing user_id in path"},
status_code=400,
)
# Extract and validate BasicAuth credentials
username, password, error_response = _extract_basic_auth(request, path_user_id)
if error_response is not None:
return error_response
# Validate credentials against Nextcloud
settings = get_settings()
nextcloud_host = settings.nextcloud_host
try:
async with httpx.AsyncClient(timeout=NEXTCLOUD_VALIDATION_TIMEOUT) as client:
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
response = await client.get(
test_url,
auth=(username, password),
params={"format": "json"},
headers={"OCS-APIRequest": "true"},
)
if response.status_code != 200:
return JSONResponse(
{"success": False, "error": "Invalid credentials"},
status_code=401,
)
except httpx.RequestError as e:
logger.error(f"Failed to validate credentials: {e}")
return JSONResponse(
{"success": False, "error": "Failed to validate credentials"},
status_code=500,
)
try:
storage = await _get_app_password_storage(request)
deleted = await storage.delete_app_password(username)
if deleted:
logger.info(f"Deleted app password for user: {username}")
return JSONResponse(
{
"success": True,
"message": f"App password deleted for {username}",
}
)
else:
return JSONResponse(
{
"success": True,
"message": "No app password found to delete",
}
)
except Exception as e:
error_msg = _sanitize_error_for_client(e, "delete_app_password")
return JSONResponse(
{"success": False, "error": error_msg},
status_code=500,
)
async def get_installed_apps(request: Request) -> JSONResponse:
"""GET /api/v1/apps - Get list of installed Nextcloud apps.
+30 -4
View File
@@ -2012,7 +2012,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
checks["auth_mode"] = "multi_user_basic"
checks["auth_configured"] = "ok"
# Indicate if app passwords are supported (when offline_access enabled)
checks["supports_app_passwords"] = settings.enable_offline_access
checks["supports_app_passwords"] = get_settings().enable_offline_access
elif mode == AuthMode.SINGLE_USER_BASIC:
username = os.getenv("NEXTCLOUD_USERNAME")
password = os.getenv("NEXTCLOUD_PASSWORD")
@@ -2029,9 +2029,9 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Check Qdrant status if using network mode (external Qdrant service)
# In-memory and persistent modes use embedded Qdrant, no external service to check
vector_sync_enabled = (
os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
)
# Note: get_settings() supports both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED
settings = get_settings()
vector_sync_enabled = settings.vector_sync_enabled
qdrant_url = os.getenv("QDRANT_URL") # Only set in network mode
if vector_sync_enabled and qdrant_url:
@@ -2114,13 +2114,16 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
if enable_management_apis:
from nextcloud_mcp_server.api.management import (
create_webhook,
delete_app_password,
delete_webhook,
get_app_password_status,
get_chunk_context,
get_installed_apps,
get_server_status,
get_user_session,
get_vector_sync_status,
list_webhooks,
provision_app_password,
revoke_user_access,
unified_search,
vector_search,
@@ -2148,6 +2151,28 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
methods=["POST"],
)
)
# App password endpoints for multi-user BasicAuth mode
routes.append(
Route(
"/api/v1/users/{user_id}/app-password",
provision_app_password,
methods=["POST"],
)
)
routes.append(
Route(
"/api/v1/users/{user_id}/app-password",
get_app_password_status,
methods=["GET"],
)
)
routes.append(
Route(
"/api/v1/users/{user_id}/app-password",
delete_app_password,
methods=["DELETE"],
)
)
routes.append(
Route("/api/v1/vector-viz/search", vector_search, methods=["POST"])
)
@@ -2166,6 +2191,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
logger.info(
"Management API endpoints enabled: /api/v1/status, /api/v1/vector-sync/status, "
"/api/v1/users/{user_id}/session, /api/v1/users/{user_id}/revoke, "
"/api/v1/users/{user_id}/app-password, "
"/api/v1/vector-viz/search, /api/v1/search, /api/v1/apps, "
"/api/v1/webhooks"
)
+174
View File
@@ -1240,6 +1240,180 @@ class RefreshTokenStorage:
return deleted
# ============================================================================
# App Password Storage (multi-user BasicAuth mode)
# ============================================================================
async def store_app_password(
self,
user_id: str,
app_password: str,
) -> None:
"""
Store encrypted app password for background sync (multi-user BasicAuth mode).
Args:
user_id: Nextcloud user ID
app_password: Nextcloud app password to store
"""
if not self._initialized:
await self.initialize()
if not self.cipher:
raise RuntimeError(
"Encryption key not configured. "
"Set TOKEN_ENCRYPTION_KEY for app password storage."
)
encrypted_password = self.cipher.encrypt(app_password.encode())
now = int(time.time())
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
INSERT OR REPLACE INTO app_passwords
(user_id, encrypted_password, created_at, updated_at)
VALUES (
?,
?,
COALESCE((SELECT created_at FROM app_passwords WHERE user_id = ?), ?),
?
)
""",
(user_id, encrypted_password, user_id, now, now),
)
await db.commit()
duration = time.time() - start_time
record_db_operation("sqlite", "insert", duration, "success")
logger.info(f"Stored app password for user {user_id}")
except Exception:
duration = time.time() - start_time
record_db_operation("sqlite", "insert", duration, "error")
raise
# Audit log
await self._audit_log(
event="store_app_password",
user_id=user_id,
auth_method="app_password",
)
async def get_app_password(self, user_id: str) -> Optional[str]:
"""
Retrieve and decrypt app password for a user.
Args:
user_id: Nextcloud user ID
Returns:
Decrypted app password, or None if not found
"""
if not self._initialized:
await self.initialize()
if not self.cipher:
raise RuntimeError(
"Encryption key not configured. "
"Set TOKEN_ENCRYPTION_KEY for app password retrieval."
)
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"SELECT encrypted_password FROM app_passwords WHERE user_id = ?",
(user_id,),
) as cursor:
row = await cursor.fetchone()
if not row:
logger.debug(f"No app password found for user {user_id}")
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "success")
return None
encrypted_password = row[0]
decrypted_password = self.cipher.decrypt(encrypted_password).decode()
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "success")
logger.debug(f"Retrieved app password for user {user_id}")
return decrypted_password
except Exception as e:
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "error")
logger.error(f"Failed to decrypt app password for user {user_id}: {e}")
return None
async def delete_app_password(self, user_id: str) -> bool:
"""
Delete app password for a user.
Args:
user_id: Nextcloud user ID
Returns:
True if password was deleted, False if not found
"""
if not self._initialized:
await self.initialize()
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"DELETE FROM app_passwords WHERE user_id = ?",
(user_id,),
)
await db.commit()
deleted = cursor.rowcount > 0
duration = time.time() - start_time
record_db_operation("sqlite", "delete", duration, "success")
if deleted:
logger.info(f"Deleted app password for user {user_id}")
await self._audit_log(
event="delete_app_password",
user_id=user_id,
auth_method="app_password",
)
else:
logger.debug(f"No app password to delete for user {user_id}")
return deleted
except Exception:
duration = time.time() - start_time
record_db_operation("sqlite", "delete", duration, "error")
raise
async def get_all_app_password_user_ids(self) -> list[str]:
"""
Get list of all user IDs with stored app passwords.
Returns:
List of user IDs
"""
if not self._initialized:
await self.initialize()
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"SELECT user_id FROM app_passwords ORDER BY updated_at DESC"
) as cursor:
rows = await cursor.fetchall()
user_ids = [row[0] for row in rows]
logger.debug(f"Found {len(user_ids)} users with app passwords")
return user_ids
async def generate_encryption_key() -> str:
"""
+7 -6
View File
@@ -20,6 +20,7 @@ from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import get_settings
logger = logging.getLogger(__name__)
@@ -106,9 +107,9 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None:
"status": str, # "syncing" or "idle"
}
"""
# Check if vector sync is enabled
vector_sync_enabled = os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
if not vector_sync_enabled:
# Check if vector sync is enabled (supports both old and new env var names)
settings = get_settings()
if not settings.vector_sync_enabled:
return None
try:
@@ -127,10 +128,8 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None:
# Get Qdrant client and query indexed count
indexed_count = 0
try:
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
settings = get_settings()
qdrant_client = await get_qdrant_client()
# Count documents in collection
@@ -634,7 +633,9 @@ async def user_info_html(request: Request) -> HTMLResponse:
"""
# Check if vector sync is enabled (needed for Welcome tab)
vector_sync_enabled = os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
# Note: get_settings() supports both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED
settings = get_settings()
vector_sync_enabled = settings.vector_sync_enabled
# Render template
template = _jinja_env.get_template("user_info.html")
+7 -1
View File
@@ -386,11 +386,17 @@ class DeckClient(BaseNextcloudClient):
order: int,
target_stack_id: int,
) -> None:
# Use the non-API route /cards/{cardId}/reorder which correctly reads
# stackId from the body. The API route /api/.../stacks/{stackId}/cards/...
# has a parameter conflict where URL stackId overrides body stackId.
# See: https://github.com/cbcoutinho/nextcloud-mcp-server/issues/469
json_data = {"order": order, "stackId": target_stack_id}
headers = self._get_deck_headers()
await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/reorder",
f"/apps/deck/cards/{card_id}/reorder",
json=json_data,
headers=headers,
)
# Labels
+6 -2
View File
@@ -637,7 +637,9 @@ def configure_deck_tools(mcp: FastMCP):
@mcp.tool(
title="Remove Label from Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
annotations=ToolAnnotations(
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@require_scopes("deck:write")
@instrument_tool
@@ -692,7 +694,9 @@ def configure_deck_tools(mcp: FastMCP):
@mcp.tool(
title="Unassign User from Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
annotations=ToolAnnotations(
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@require_scopes("deck:write")
@instrument_tool
+4 -6
View File
@@ -1,7 +1,6 @@
"""Semantic search MCP tools using vector database."""
import logging
import os
import anyio
from httpx import RequestError
@@ -658,12 +657,11 @@ def configure_semantic_tools(mcp: FastMCP):
after creating or updating content across all indexed apps.
"""
# Check if vector sync is enabled
vector_sync_enabled = (
os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
)
# Check if vector sync is enabled (supports both old and new env var names)
from nextcloud_mcp_server.config import get_settings
if not vector_sync_enabled:
settings = get_settings()
if not settings.vector_sync_enabled:
return VectorSyncStatusResponse(
indexed_count=0,
pending_count=0,
+22 -19
View File
@@ -8,8 +8,8 @@ Manages background vector sync for multi-user deployments:
Authentication strategies are mutually exclusive by deployment mode:
Multi-user BasicAuth mode (ENABLE_MULTI_USER_BASIC_AUTH=true):
- Uses app passwords obtained via Astrolabe Management API
- Users provision via Astrolabe personal settings
- Uses app passwords stored locally in MCP server's database
- Users provision via Astrolabe personal settings, which sends to MCP API
- OAuth is NOT used
OAuth mode (with external IdP like Keycloak):
@@ -33,7 +33,6 @@ from anyio.streams.memory import (
)
from httpx import BasicAuth
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.scanner import DocumentTask, scan_user_documents
@@ -71,15 +70,18 @@ class UserSyncState:
async def get_user_client_basic_auth(
user_id: str,
nextcloud_host: str,
storage: "RefreshTokenStorage | None" = None,
) -> NextcloudClient:
"""Get an authenticated NextcloudClient using app password (BasicAuth mode).
For multi-user BasicAuth deployments where users provision app passwords
via Astrolabe personal settings. OAuth is NOT used in this mode.
via Astrolabe personal settings. The app password is stored locally in the
MCP server's database after being provisioned through the management API.
Args:
user_id: User identifier
nextcloud_host: Nextcloud base URL
storage: Optional RefreshTokenStorage instance (created from env if not provided)
Returns:
Authenticated NextcloudClient with BasicAuth
@@ -87,21 +89,15 @@ async def get_user_client_basic_auth(
Raises:
NotProvisionedError: If user has not provisioned an app password
"""
settings = get_settings()
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
if not settings.oidc_client_id or not settings.oidc_client_secret:
raise NotProvisionedError(
"Astrolabe client credentials not configured. "
"Set OIDC_CLIENT_ID and OIDC_CLIENT_SECRET for app password retrieval."
)
# Get or create storage instance
if storage is None:
storage = RefreshTokenStorage.from_env()
await storage.initialize()
astrolabe = AstrolabeClient(
nextcloud_host=nextcloud_host,
client_id=settings.oidc_client_id,
client_secret=settings.oidc_client_secret,
)
app_password = await astrolabe.get_user_app_password(user_id)
# Retrieve app password from local storage
app_password = await storage.get_app_password(user_id)
if not app_password:
raise NotProvisionedError(
@@ -419,8 +415,15 @@ async def user_manager_task(
while not shutdown_event.is_set():
try:
# Get current provisioned users
provisioned_users = set(await refresh_token_storage.get_all_user_ids())
# Get current provisioned users based on mode
if use_basic_auth:
# BasicAuth mode: query app_passwords table
provisioned_users = set(
await refresh_token_storage.get_all_app_password_user_ids()
)
else:
# OAuth mode: query refresh_tokens table
provisioned_users = set(await refresh_token_storage.get_all_user_ids())
active_users = set(user_states.keys())
# Start scanners for new users
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "nextcloud-mcp-server"
version = "0.60.2"
version = "0.61.5"
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
authors = [
{name = "Chris Coutinho", email = "chris@coutinho.io"}
@@ -1,18 +1,21 @@
"""Integration tests for app password provisioning via Astrolabe.
"""Integration tests for app password provisioning via management API.
Tests the complete flow for multi-user BasicAuth mode:
1. User stores app password via Astrolabe API
2. MCP server retrieves it via OAuth client credentials
3. Background sync uses it to access Nextcloud (NOT OAuth refresh tokens)
1. User stores app password via management API endpoint
2. MCP server stores it locally (encrypted)
3. Background sync uses locally stored password to access Nextcloud
These tests verify that BasicAuth and OAuth are completely separate concerns
with no fallback between them.
"""
import pytest
import tempfile
from pathlib import Path
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
from nextcloud_mcp_server.config import get_settings
import pytest
from cryptography.fernet import Fernet
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.vector.oauth_sync import (
NotProvisionedError,
get_user_client,
@@ -21,140 +24,60 @@ from nextcloud_mcp_server.vector.oauth_sync import (
)
@pytest.mark.integration
async def test_astrolabe_client_initialization():
"""Test AstrolabeClient can be instantiated."""
client = AstrolabeClient(
nextcloud_host="http://localhost:8080",
client_id="test-client",
client_secret="test-secret",
)
@pytest.fixture
def encryption_key():
"""Generate a test encryption key."""
return Fernet.generate_key().decode()
assert client is not None
assert client.nextcloud_host == "http://localhost:8080"
assert client.client_id == "test-client"
assert client.client_secret == "test-secret"
assert client._token_cache is None
@pytest.fixture
async def temp_storage(encryption_key):
"""Create temporary storage instance with encryption for testing."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test_provisioning.db"
storage = RefreshTokenStorage(
db_path=str(db_path), encryption_key=encryption_key
)
await storage.initialize()
yield storage
@pytest.mark.integration
async def test_astrolabe_client_get_access_token_requires_oidc():
"""Test that getting access token requires OIDC discovery endpoint."""
client = AstrolabeClient(
nextcloud_host="http://localhost:8080",
client_id="test-client",
client_secret="test-secret",
)
async def test_basic_auth_mode_uses_local_storage(temp_storage, mocker):
"""Test that BasicAuth mode uses locally stored app passwords.
# This will fail without proper OIDC setup, which is expected
# The test verifies the client follows the OAuth client credentials flow
try:
token = await client.get_access_token()
# If we get here, OIDC is configured
assert token is not None
except Exception as e:
# Expected if OIDC not fully configured for test client
# 400/401/403/404 all indicate the flow is working but credentials are invalid
assert any(code in str(e) for code in ["400", "401", "403", "404"])
@pytest.mark.integration
async def test_get_user_app_password_returns_none_for_unconfigured_user():
"""Test that get_user_app_password returns None for users without app passwords."""
# This requires valid OAuth client credentials
settings = get_settings()
if not settings.oidc_client_id or not settings.oidc_client_secret:
pytest.skip("OAuth client credentials not configured")
client = AstrolabeClient(
nextcloud_host=settings.nextcloud_host or "http://localhost:8080",
client_id=settings.oidc_client_id,
client_secret=settings.oidc_client_secret,
)
# Try to get app password for a user that hasn't provisioned one
try:
app_password = await client.get_user_app_password("nonexistent_user")
# Should return None for unconfigured user (404 response)
assert app_password is None
except Exception as e:
# May fail with auth error if OAuth not fully configured
assert any(code in str(e) for code in ["400", "401", "403", "404"])
@pytest.mark.integration
async def test_basic_auth_mode_uses_app_password_only(mocker):
"""Test that BasicAuth mode uses ONLY app passwords, NOT OAuth tokens.
In multi-user BasicAuth mode, OAuth refresh tokens are NOT used.
This is a complete separation of concerns.
In multi-user BasicAuth mode, app passwords are stored locally
in the MCP server's database after being provisioned via the API.
"""
# Mock settings to have client credentials
mock_settings = mocker.MagicMock()
mock_settings.oidc_client_id = "test-client-id"
mock_settings.oidc_client_secret = "test-client-secret"
mocker.patch(
"nextcloud_mcp_server.vector.oauth_sync.get_settings",
return_value=mock_settings,
)
# Store an app password in local storage
await temp_storage.store_app_password("test_user", "JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB")
# Mock AstrolabeClient to return an app password
mock_astrolabe = mocker.AsyncMock()
mock_astrolabe.get_user_app_password.return_value = "test-app-password-12345"
mocker.patch(
"nextcloud_mcp_server.vector.oauth_sync.AstrolabeClient",
return_value=mock_astrolabe,
)
# Call get_user_client in BasicAuth mode
_client = await get_user_client(
# Call get_user_client_basic_auth with local storage
client = await get_user_client_basic_auth(
user_id="test_user",
token_broker=None, # No token broker needed for BasicAuth mode
nextcloud_host="http://localhost:8080",
use_basic_auth=True,
storage=temp_storage,
)
# Verify app password was requested
mock_astrolabe.get_user_app_password.assert_called_once_with("test_user")
# Verify client was created successfully with correct username
assert _client is not None
assert _client.username == "test_user"
# Verify client was created with correct credentials
assert client is not None
assert client.username == "test_user"
@pytest.mark.integration
async def test_basic_auth_mode_raises_error_without_app_password(mocker):
async def test_basic_auth_mode_raises_error_without_app_password(temp_storage):
"""Test that BasicAuth mode raises NotProvisionedError if no app password.
There is NO fallback to OAuth - if no app password, user must provision one.
"""
# Mock settings to have client credentials
mock_settings = mocker.MagicMock()
mock_settings.oidc_client_id = "test-client-id"
mock_settings.oidc_client_secret = "test-client-secret"
mocker.patch(
"nextcloud_mcp_server.vector.oauth_sync.get_settings",
return_value=mock_settings,
)
# Don't store any app password
# Mock AstrolabeClient to return None (no app password)
mock_astrolabe = mocker.AsyncMock()
mock_astrolabe.get_user_app_password.return_value = None
mocker.patch(
"nextcloud_mcp_server.vector.oauth_sync.AstrolabeClient",
return_value=mock_astrolabe,
)
# Call get_user_client in BasicAuth mode - should raise NotProvisionedError
# Call get_user_client_basic_auth - should raise NotProvisionedError
with pytest.raises(NotProvisionedError) as exc_info:
await get_user_client(
await get_user_client_basic_auth(
user_id="test_user",
token_broker=None,
nextcloud_host="http://localhost:8080",
use_basic_auth=True,
storage=temp_storage,
)
# Verify error message mentions app password provisioning
@@ -162,6 +85,33 @@ async def test_basic_auth_mode_raises_error_without_app_password(mocker):
assert "test_user" in str(exc_info.value)
@pytest.mark.integration
async def test_get_user_client_dispatches_to_basic_auth(temp_storage, mocker):
"""Test that get_user_client dispatches to BasicAuth mode correctly."""
# Store an app password
await temp_storage.store_app_password("alice", "aaaaa-bbbbb-ccccc-ddddd-eeeee")
# Mock RefreshTokenStorage.from_env at the source module
mocker.patch(
"nextcloud_mcp_server.auth.storage.RefreshTokenStorage.from_env",
return_value=temp_storage,
)
# Also mock initialize since from_env returns an uninitialized instance
mocker.patch.object(temp_storage, "initialize", return_value=None)
# Call get_user_client in BasicAuth mode
client = await get_user_client(
user_id="alice",
token_broker=None, # No token broker needed for BasicAuth mode
nextcloud_host="http://localhost:8080",
use_basic_auth=True,
)
# Verify client was created successfully
assert client is not None
assert client.username == "alice"
@pytest.mark.integration
async def test_oauth_mode_uses_refresh_token_only(mocker):
"""Test that OAuth mode uses ONLY refresh tokens, NOT app passwords.
@@ -183,7 +133,7 @@ async def test_oauth_mode_uses_refresh_token_only(mocker):
use_basic_auth=False, # OAuth mode
)
# Verify token broker was called (NOT Astrolabe)
# Verify token broker was called
mock_token_broker.get_background_token.assert_called_once()
@@ -213,38 +163,6 @@ async def test_oauth_mode_raises_error_without_token(mocker):
assert "test_user" in str(exc_info.value)
@pytest.mark.integration
async def test_get_user_client_basic_auth_function(mocker):
"""Test the dedicated get_user_client_basic_auth function."""
# Mock settings to have client credentials
mock_settings = mocker.MagicMock()
mock_settings.oidc_client_id = "test-client-id"
mock_settings.oidc_client_secret = "test-client-secret"
mocker.patch(
"nextcloud_mcp_server.vector.oauth_sync.get_settings",
return_value=mock_settings,
)
# Mock AstrolabeClient
mock_astrolabe = mocker.AsyncMock()
mock_astrolabe.get_user_app_password.return_value = "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
mocker.patch(
"nextcloud_mcp_server.vector.oauth_sync.AstrolabeClient",
return_value=mock_astrolabe,
)
# Call dedicated function
client = await get_user_client_basic_auth(
user_id="alice",
nextcloud_host="http://localhost:8080",
)
assert client is not None
assert client.username == "alice"
mock_astrolabe.get_user_app_password.assert_called_once_with("alice")
@pytest.mark.integration
async def test_get_user_client_oauth_function(mocker):
"""Test the dedicated get_user_client_oauth function."""
@@ -276,3 +194,69 @@ async def test_oauth_mode_requires_token_broker():
nextcloud_host="http://localhost:8080",
use_basic_auth=False, # OAuth mode
)
@pytest.mark.integration
async def test_multiple_users_basic_auth_mode(temp_storage, mocker):
"""Test that multiple users can be provisioned independently."""
# Store app passwords for multiple users
users = {
"alice": "aaaaa-aaaaa-aaaaa-aaaaa-aaaaa",
"bob": "bbbbb-bbbbb-bbbbb-bbbbb-bbbbb",
"charlie": "ccccc-ccccc-ccccc-ccccc-ccccc",
}
for user_id, password in users.items():
await temp_storage.store_app_password(user_id, password)
# Verify each user can get a client
for user_id in users.keys():
client = await get_user_client_basic_auth(
user_id=user_id,
nextcloud_host="http://localhost:8080",
storage=temp_storage,
)
assert client is not None
assert client.username == user_id
@pytest.mark.integration
async def test_get_all_provisioned_users(temp_storage):
"""Test that we can list all provisioned users for BasicAuth mode."""
# Store app passwords for multiple users
await temp_storage.store_app_password("alice", "aaaaa-aaaaa-aaaaa-aaaaa-aaaaa")
await temp_storage.store_app_password("bob", "bbbbb-bbbbb-bbbbb-bbbbb-bbbbb")
# Get all provisioned users
user_ids = await temp_storage.get_all_app_password_user_ids()
assert len(user_ids) == 2
assert "alice" in user_ids
assert "bob" in user_ids
@pytest.mark.integration
async def test_revoke_app_password(temp_storage):
"""Test that deleting app password revokes background access."""
# Provision user
await temp_storage.store_app_password("alice", "aaaaa-aaaaa-aaaaa-aaaaa-aaaaa")
# Verify user is provisioned
user_ids = await temp_storage.get_all_app_password_user_ids()
assert "alice" in user_ids
# Revoke access
deleted = await temp_storage.delete_app_password("alice")
assert deleted is True
# Verify user is no longer provisioned
user_ids = await temp_storage.get_all_app_password_user_ids()
assert "alice" not in user_ids
# Verify get_user_client now raises NotProvisionedError
with pytest.raises(NotProvisionedError):
await get_user_client_basic_auth(
user_id="alice",
nextcloud_host="http://localhost:8080",
storage=temp_storage,
)
@@ -0,0 +1,695 @@
"""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)
+177
View File
@@ -0,0 +1,177 @@
"""Integration tests for Deck card reorder functionality.
Tests issue #469: Moving Deck card from one column (stack) to another not working.
https://github.com/cbcoutinho/nextcloud-mcp-server/issues/469
"""
import logging
import uuid
import pytest
from nextcloud_mcp_server.client import NextcloudClient
logger = logging.getLogger(__name__)
pytestmark = pytest.mark.integration
@pytest.fixture
async def board_with_two_stacks(nc_client: NextcloudClient):
"""Create a temporary board with two stacks for testing card movement.
Yields:
tuple: (board_data, source_stack_data, target_stack_data)
"""
unique_suffix = uuid.uuid4().hex[:8]
board_title = f"Reorder Test Board {unique_suffix}"
board = None
logger.info(f"Creating board with two stacks: {board_title}")
try:
board = await nc_client.deck.create_board(board_title, "0000FF")
board_id = board.id
# Create source stack (stack 1)
source_stack = await nc_client.deck.create_stack(
board_id, f"Source Stack {unique_suffix}", order=1
)
source_stack_data = {
"id": source_stack.id,
"title": source_stack.title,
"order": source_stack.order,
}
logger.info(f"Created source stack with ID: {source_stack.id}")
# Create target stack (stack 2)
target_stack = await nc_client.deck.create_stack(
board_id, f"Target Stack {unique_suffix}", order=2
)
target_stack_data = {
"id": target_stack.id,
"title": target_stack.title,
"order": target_stack.order,
}
logger.info(f"Created target stack with ID: {target_stack.id}")
board_data = {
"id": board_id,
"title": board.title,
"color": board.color,
}
yield (board_data, source_stack_data, target_stack_data)
finally:
if board:
logger.info(f"Cleaning up board ID: {board.id}")
try:
await nc_client.deck.delete_board(board.id)
except Exception as e:
logger.warning(f"Error cleaning up board: {e}")
async def test_reorder_card_move_to_different_stack(
nc_client: NextcloudClient, board_with_two_stacks: tuple
):
"""Test moving a card from one stack to another (issue #469).
This test reproduces the bug where the reorder_card API reports success
but the card doesn't actually move to the target stack.
"""
board_data, source_stack_data, target_stack_data = board_with_two_stacks
board_id = board_data["id"]
source_stack_id = source_stack_data["id"]
target_stack_id = target_stack_data["id"]
# Create a card in the source stack
unique_suffix = uuid.uuid4().hex[:8]
card_title = f"Test Card {unique_suffix}"
card = await nc_client.deck.create_card(
board_id, source_stack_id, card_title, description="Card to be moved"
)
card_id = card.id
logger.info(f"Created card ID: {card_id} in source stack ID: {source_stack_id}")
try:
# Verify card is in source stack
card_before = await nc_client.deck.get_card(board_id, source_stack_id, card_id)
assert card_before.stackId == source_stack_id, (
f"Card should start in source stack {source_stack_id}, "
f"but is in {card_before.stackId}"
)
logger.info(f"Verified card is in source stack: {source_stack_id}")
# Move card to target stack
logger.info(
f"Moving card {card_id} from stack {source_stack_id} "
f"to stack {target_stack_id}"
)
await nc_client.deck.reorder_card(
board_id=board_id,
stack_id=source_stack_id,
card_id=card_id,
order=0,
target_stack_id=target_stack_id,
)
logger.info("reorder_card API call completed")
# Verify card moved to target stack
# Note: After moving, the card should be accessible from the target stack
card_after = await nc_client.deck.get_card(board_id, target_stack_id, card_id)
assert card_after.stackId == target_stack_id, (
f"Card should have moved to target stack {target_stack_id}, "
f"but is in {card_after.stackId}"
)
logger.info(f"SUCCESS: Card moved to target stack {target_stack_id}")
finally:
# Clean up - try to delete from target stack first, then source
try:
await nc_client.deck.delete_card(board_id, target_stack_id, card_id)
except Exception:
try:
await nc_client.deck.delete_card(board_id, source_stack_id, card_id)
except Exception as e:
logger.warning(f"Error cleaning up card: {e}")
async def test_reorder_card_within_same_stack(
nc_client: NextcloudClient, board_with_two_stacks: tuple
):
"""Test reordering a card within the same stack (should work)."""
board_data, source_stack_data, _ = board_with_two_stacks
board_id = board_data["id"]
source_stack_id = source_stack_data["id"]
# Create two cards in the source stack
unique_suffix = uuid.uuid4().hex[:8]
card1 = await nc_client.deck.create_card(
board_id, source_stack_id, f"Card 1 {unique_suffix}", order=0
)
card2 = await nc_client.deck.create_card(
board_id, source_stack_id, f"Card 2 {unique_suffix}", order=1
)
logger.info(f"Created cards {card1.id} (order 0) and {card2.id} (order 1)")
try:
# Reorder card1 to position after card2
await nc_client.deck.reorder_card(
board_id=board_id,
stack_id=source_stack_id,
card_id=card1.id,
order=2, # Move to position 2
target_stack_id=source_stack_id, # Same stack
)
logger.info(f"Reordered card {card1.id} to order 2")
# Verify card is still in the same stack
card_after = await nc_client.deck.get_card(board_id, source_stack_id, card1.id)
assert card_after.stackId == source_stack_id
logger.info("Card reorder within same stack succeeded")
finally:
try:
await nc_client.deck.delete_card(board_id, source_stack_id, card1.id)
await nc_client.deck.delete_card(board_id, source_stack_id, card2.id)
except Exception as e:
logger.warning(f"Error cleaning up cards: {e}")
+6 -1
View File
@@ -89,8 +89,13 @@ async def test_create_operations_not_idempotent(nc_mcp_client: ClientSession):
"""Verify create operations are marked as non-idempotent."""
tools = await nc_mcp_client.list_tools()
# Exceptions: operations that are actually idempotent
# - calendar_create_meeting: creates or returns existing meeting
# - nc_webdav_create_directory: MKCOL returns 405 if exists (same end state)
idempotent_exceptions = {"calendar_create_meeting", "nc_webdav_create_directory"}
for tool in tools.tools:
if "create" in tool.name.lower() and "calendar_create_meeting" not in tool.name:
if "create" in tool.name.lower() and tool.name not in idempotent_exceptions:
assert tool.annotations is not None, f"Tool {tool.name} missing annotations"
assert tool.annotations.idempotentHint is not True, (
f"Create tool {tool.name} should not be idempotent (creates new resources)"
+227
View File
@@ -0,0 +1,227 @@
"""
Unit tests for App Password Storage functionality.
Tests the app password methods in RefreshTokenStorage for multi-user
BasicAuth mode background sync.
"""
import tempfile
from pathlib import Path
import pytest
from cryptography.fernet import Fernet
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
pytestmark = pytest.mark.unit
@pytest.fixture
def encryption_key():
"""Generate a test encryption key."""
return Fernet.generate_key().decode()
@pytest.fixture
async def temp_storage(encryption_key):
"""Create temporary storage instance with encryption for testing."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test_app_passwords.db"
storage = RefreshTokenStorage(
db_path=str(db_path), encryption_key=encryption_key
)
await storage.initialize()
yield storage
async def test_store_app_password(temp_storage):
"""Test storing an app password."""
await temp_storage.store_app_password(
user_id="testuser",
app_password="JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB",
)
# Verify it can be retrieved
retrieved = await temp_storage.get_app_password("testuser")
assert retrieved == "JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB"
async def test_store_app_password_replaces_existing(temp_storage):
"""Test that storing a new app password replaces the existing one."""
await temp_storage.store_app_password(
user_id="testuser",
app_password="aaaaa-bbbbb-ccccc-ddddd-eeeee",
)
await temp_storage.store_app_password(
user_id="testuser",
app_password="fffff-ggggg-hhhhh-iiiii-jjjjj",
)
retrieved = await temp_storage.get_app_password("testuser")
assert retrieved == "fffff-ggggg-hhhhh-iiiii-jjjjj"
async def test_get_app_password_nonexistent(temp_storage):
"""Test retrieving app password for non-existent user."""
retrieved = await temp_storage.get_app_password("nonexistent")
assert retrieved is None
async def test_delete_app_password(temp_storage):
"""Test deleting an app password."""
await temp_storage.store_app_password(
user_id="testuser",
app_password="JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB",
)
deleted = await temp_storage.delete_app_password("testuser")
assert deleted is True
# Verify it's gone
retrieved = await temp_storage.get_app_password("testuser")
assert retrieved is None
async def test_delete_app_password_nonexistent(temp_storage):
"""Test deleting non-existent app password."""
deleted = await temp_storage.delete_app_password("nonexistent")
assert deleted is False
async def test_get_all_app_password_user_ids(temp_storage):
"""Test listing all users with app passwords."""
await temp_storage.store_app_password("alice", "aaaaa-aaaaa-aaaaa-aaaaa-aaaaa")
await temp_storage.store_app_password("bob", "bbbbb-bbbbb-bbbbb-bbbbb-bbbbb")
await temp_storage.store_app_password("charlie", "ccccc-ccccc-ccccc-ccccc-ccccc")
user_ids = await temp_storage.get_all_app_password_user_ids()
assert len(user_ids) == 3
assert "alice" in user_ids
assert "bob" in user_ids
assert "charlie" in user_ids
async def test_get_all_app_password_user_ids_empty(temp_storage):
"""Test listing users when none have app passwords."""
user_ids = await temp_storage.get_all_app_password_user_ids()
assert len(user_ids) == 0
async def test_app_password_encryption(encryption_key):
"""Test that app passwords are encrypted at rest."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test_encryption.db"
storage = RefreshTokenStorage(
db_path=str(db_path), encryption_key=encryption_key
)
await storage.initialize()
# Store a password
test_password = "JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB"
await storage.store_app_password("testuser", test_password)
# Read directly from database to verify encryption
import aiosqlite
async with aiosqlite.connect(str(db_path)) as db:
async with db.execute(
"SELECT encrypted_password FROM app_passwords WHERE user_id = ?",
("testuser",),
) as cursor:
row = await cursor.fetchone()
# The stored value should be encrypted (not plain text)
encrypted_bytes = row[0]
assert encrypted_bytes != test_password.encode()
# Encrypted data should be longer due to Fernet overhead
assert len(encrypted_bytes) > len(test_password)
async def test_app_password_requires_encryption_key():
"""Test that app password operations require encryption key."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test_no_key.db"
storage = RefreshTokenStorage(db_path=str(db_path), encryption_key=None)
await storage.initialize()
# Storing should fail without encryption key
with pytest.raises(RuntimeError, match="Encryption key not configured"):
await storage.store_app_password(
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
)
# Getting should also fail without encryption key
with pytest.raises(RuntimeError, match="Encryption key not configured"):
await storage.get_app_password("testuser")
async def test_multiple_users_independence(temp_storage):
"""Test that different users maintain independent app passwords."""
users = ["alice", "bob", "charlie", "diana"]
# Store unique passwords for each user
for i, user in enumerate(users):
password = (
f"{user[0]}{user[0]}{user[0]}{user[0]}{user[0]}-" * 4
+ f"{user[0]}{user[0]}{user[0]}{user[0]}{user[0]}"
)
await temp_storage.store_app_password(user, password)
# Verify each user has their correct password
for user in users:
expected = (
f"{user[0]}{user[0]}{user[0]}{user[0]}{user[0]}-" * 4
+ f"{user[0]}{user[0]}{user[0]}{user[0]}{user[0]}"
)
retrieved = await temp_storage.get_app_password(user)
assert retrieved == expected
# Delete one user's password
await temp_storage.delete_app_password("bob")
# Verify other users unchanged
for user in ["alice", "charlie", "diana"]:
retrieved = await temp_storage.get_app_password(user)
assert retrieved is not None
# Verify bob's password is gone
assert await temp_storage.get_app_password("bob") is None
async def test_app_password_with_special_characters(temp_storage):
"""Test storing passwords with various alphanumeric patterns."""
# Nextcloud app passwords use alphanumeric characters
passwords = [
"AAAAA-BBBBB-CCCCC-DDDDD-EEEEE", # uppercase
"aaaaa-bbbbb-ccccc-ddddd-eeeee", # lowercase
"12345-67890-12345-67890-12345", # numbers
"aB1cD-eF2gH-iJ3kL-mN4oP-qR5sT", # mixed
]
for i, password in enumerate(passwords):
user = f"user{i}"
await temp_storage.store_app_password(user, password)
retrieved = await temp_storage.get_app_password(user)
assert retrieved == password
async def test_decryption_with_wrong_key(encryption_key):
"""Test that decryption fails with wrong key."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test_wrong_key.db"
# Store with original key
storage1 = RefreshTokenStorage(
db_path=str(db_path), encryption_key=encryption_key
)
await storage1.initialize()
await storage1.store_app_password("testuser", "JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB")
# Try to read with different key
wrong_key = Fernet.generate_key().decode()
storage2 = RefreshTokenStorage(db_path=str(db_path), encryption_key=wrong_key)
await storage2.initialize()
# Decryption should fail and return None (graceful handling)
retrieved = await storage2.get_app_password("testuser")
assert retrieved is None
@@ -0,0 +1,624 @@
"""
Unit tests for Management API app password endpoints.
Tests the REST API endpoints for multi-user BasicAuth mode app password management:
- POST /api/v1/users/{user_id}/app-password - Provision app password
- GET /api/v1/users/{user_id}/app-password - Check status
- DELETE /api/v1/users/{user_id}/app-password - Delete app password
"""
import base64
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from cryptography.fernet import Fernet
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.testclient import TestClient
from nextcloud_mcp_server.api import management
from nextcloud_mcp_server.api.management import (
delete_app_password,
get_app_password_status,
provision_app_password,
)
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
pytestmark = pytest.mark.unit
@pytest.fixture(autouse=True)
def clear_rate_limit():
"""Clear rate limit state before each test."""
management._rate_limit_attempts.clear()
yield
management._rate_limit_attempts.clear()
@pytest.fixture
def encryption_key():
"""Generate a test encryption key."""
return Fernet.generate_key().decode()
@pytest.fixture
async def temp_storage(encryption_key):
"""Create temporary storage instance with encryption for testing."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test_management.db"
storage = RefreshTokenStorage(
db_path=str(db_path), encryption_key=encryption_key
)
await storage.initialize()
yield storage
def create_basic_auth_header(username: str, password: str) -> str:
"""Create BasicAuth header value."""
credentials = f"{username}:{password}"
encoded = base64.b64encode(credentials.encode()).decode()
return f"Basic {encoded}"
def create_test_app(storage):
"""Create a test Starlette app with the management endpoints."""
app = Starlette(
routes=[
Route(
"/api/v1/users/{user_id}/app-password",
provision_app_password,
methods=["POST"],
),
Route(
"/api/v1/users/{user_id}/app-password",
get_app_password_status,
methods=["GET"],
),
Route(
"/api/v1/users/{user_id}/app-password",
delete_app_password,
methods=["DELETE"],
),
]
)
app.state.storage = storage
return app
async def test_provision_app_password_missing_auth():
"""Test that missing auth returns 401."""
app = Starlette(
routes=[
Route(
"/api/v1/users/{user_id}/app-password",
provision_app_password,
methods=["POST"],
),
]
)
client = TestClient(app)
response = client.post("/api/v1/users/testuser/app-password")
assert response.status_code == 401
assert "Missing BasicAuth" in response.json()["error"]
async def test_provision_app_password_invalid_auth_format():
"""Test that invalid auth format returns 401."""
app = Starlette(
routes=[
Route(
"/api/v1/users/{user_id}/app-password",
provision_app_password,
methods=["POST"],
),
]
)
client = TestClient(app)
response = client.post(
"/api/v1/users/testuser/app-password",
headers={"Authorization": "Basic invalid-not-base64!!!"},
)
assert response.status_code == 401
assert "Invalid BasicAuth" in response.json()["error"]
async def test_provision_app_password_username_mismatch():
"""Test that username mismatch returns 403."""
app = Starlette(
routes=[
Route(
"/api/v1/users/{user_id}/app-password",
provision_app_password,
methods=["POST"],
),
]
)
client = TestClient(app)
# Try to provision for "testuser" but auth as "otheruser"
response = client.post(
"/api/v1/users/testuser/app-password",
headers={
"Authorization": create_basic_auth_header(
"otheruser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
)
},
)
assert response.status_code == 403
assert "does not match" in response.json()["error"]
async def test_provision_app_password_invalid_format():
"""Test that invalid app password format returns 400."""
app = Starlette(
routes=[
Route(
"/api/v1/users/{user_id}/app-password",
provision_app_password,
methods=["POST"],
),
]
)
client = TestClient(app)
# Use invalid password format (not xxxxx-xxxxx-xxxxx-xxxxx-xxxxx)
response = client.post(
"/api/v1/users/testuser/app-password",
headers={
"Authorization": create_basic_auth_header("testuser", "invalid-password")
},
)
assert response.status_code == 400
assert "Invalid app password format" in response.json()["error"]
async def test_provision_app_password_success(temp_storage, mocker):
"""Test successful app password provisioning."""
# Mock settings (imported locally in the function)
mocker.patch(
"nextcloud_mcp_server.config.get_settings",
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
)
# Mock httpx client for Nextcloud validation
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"ocs": {"data": {"id": "testuser"}}}
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
mocker.patch(
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
return_value=mock_client,
)
# Create app with storage
app = create_test_app(temp_storage)
client = TestClient(app)
response = client.post(
"/api/v1/users/testuser/app-password",
headers={
"Authorization": create_basic_auth_header(
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
)
},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "stored" in data["message"].lower()
# Verify password was stored
stored_password = await temp_storage.get_app_password("testuser")
assert stored_password == "aaaaa-bbbbb-ccccc-ddddd-eeeee"
async def test_provision_app_password_nextcloud_validation_fails(mocker):
"""Test that failed Nextcloud validation returns 401."""
mocker.patch(
"nextcloud_mcp_server.config.get_settings",
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
)
# Mock httpx client to return 401
mock_response = MagicMock()
mock_response.status_code = 401
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
mocker.patch(
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
return_value=mock_client,
)
app = Starlette(
routes=[
Route(
"/api/v1/users/{user_id}/app-password",
provision_app_password,
methods=["POST"],
),
]
)
client = TestClient(app)
response = client.post(
"/api/v1/users/testuser/app-password",
headers={
"Authorization": create_basic_auth_header(
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
)
},
)
assert response.status_code == 401
assert "Invalid app password" in response.json()["error"]
async def test_get_app_password_status_provisioned(temp_storage, mocker):
"""Test checking status when app password is provisioned."""
# Store an app password
await temp_storage.store_app_password("testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee")
app = create_test_app(temp_storage)
client = TestClient(app)
response = client.get(
"/api/v1/users/testuser/app-password",
headers={
"Authorization": create_basic_auth_header(
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
)
},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["user_id"] == "testuser"
assert data["has_app_password"] is True
async def test_get_app_password_status_not_provisioned(temp_storage, mocker):
"""Test checking status when app password is not provisioned."""
app = create_test_app(temp_storage)
client = TestClient(app)
response = client.get(
"/api/v1/users/testuser/app-password",
headers={
"Authorization": create_basic_auth_header(
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
)
},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["user_id"] == "testuser"
assert data["has_app_password"] is False
async def test_get_app_password_status_username_mismatch():
"""Test that username mismatch returns 403 for status check."""
app = Starlette(
routes=[
Route(
"/api/v1/users/{user_id}/app-password",
get_app_password_status,
methods=["GET"],
),
]
)
client = TestClient(app)
response = client.get(
"/api/v1/users/testuser/app-password",
headers={
"Authorization": create_basic_auth_header(
"otheruser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
)
},
)
assert response.status_code == 403
async def test_delete_app_password_success(temp_storage, mocker):
"""Test successful app password deletion."""
# Store an app password
await temp_storage.store_app_password("testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee")
# Mock settings (imported locally in the function)
mocker.patch(
"nextcloud_mcp_server.config.get_settings",
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
)
# Mock httpx client for Nextcloud validation
mock_response = MagicMock()
mock_response.status_code = 200
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
mocker.patch(
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
return_value=mock_client,
)
app = create_test_app(temp_storage)
client = TestClient(app)
response = client.delete(
"/api/v1/users/testuser/app-password",
headers={
"Authorization": create_basic_auth_header(
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
)
},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "deleted" in data["message"].lower()
# Verify password was removed
stored_password = await temp_storage.get_app_password("testuser")
assert stored_password is None
async def test_delete_app_password_not_found(temp_storage, mocker):
"""Test deleting non-existent app password."""
# Mock settings (imported locally in the function)
mocker.patch(
"nextcloud_mcp_server.config.get_settings",
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
)
# Mock httpx client for Nextcloud validation
mock_response = MagicMock()
mock_response.status_code = 200
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
mocker.patch(
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
return_value=mock_client,
)
app = create_test_app(temp_storage)
client = TestClient(app)
response = client.delete(
"/api/v1/users/testuser/app-password",
headers={
"Authorization": create_basic_auth_header(
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
)
},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "no app password found" in data["message"].lower()
async def test_delete_app_password_invalid_credentials(mocker):
"""Test that invalid credentials returns 401 for deletion."""
mocker.patch(
"nextcloud_mcp_server.config.get_settings",
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
)
# Mock httpx client to return 401
mock_response = MagicMock()
mock_response.status_code = 401
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
mocker.patch(
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
return_value=mock_client,
)
app = Starlette(
routes=[
Route(
"/api/v1/users/{user_id}/app-password",
delete_app_password,
methods=["DELETE"],
),
]
)
client = TestClient(app)
response = client.delete(
"/api/v1/users/testuser/app-password",
headers={
"Authorization": create_basic_auth_header(
"testuser", "wrong-password-xxxxx"
)
},
)
assert response.status_code == 401
assert "Invalid credentials" in response.json()["error"]
async def test_delete_app_password_username_mismatch():
"""Test that username mismatch returns 403 for deletion."""
app = Starlette(
routes=[
Route(
"/api/v1/users/{user_id}/app-password",
delete_app_password,
methods=["DELETE"],
),
]
)
client = TestClient(app)
response = client.delete(
"/api/v1/users/testuser/app-password",
headers={
"Authorization": create_basic_auth_header(
"otheruser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
)
},
)
assert response.status_code == 403
async def test_provision_app_password_rate_limiting(mocker):
"""Test that rate limiting blocks excessive provisioning attempts."""
mocker.patch(
"nextcloud_mcp_server.config.get_settings",
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
)
# Mock httpx client to return 401 (failed validation)
mock_response = MagicMock()
mock_response.status_code = 401
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
mocker.patch(
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
return_value=mock_client,
)
app = Starlette(
routes=[
Route(
"/api/v1/users/{user_id}/app-password",
provision_app_password,
methods=["POST"],
),
]
)
client = TestClient(app)
# Make 5 failed attempts (should all return 401)
for i in range(5):
response = client.post(
"/api/v1/users/testuser/app-password",
headers={
"Authorization": create_basic_auth_header(
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
)
},
)
assert response.status_code == 401, f"Attempt {i + 1} should return 401"
# 6th attempt should be rate limited (429)
response = client.post(
"/api/v1/users/testuser/app-password",
headers={
"Authorization": create_basic_auth_header(
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
)
},
)
assert response.status_code == 429
assert "Rate limit exceeded" in response.json()["error"]
assert "Retry-After" in response.headers
async def test_rate_limiting_is_per_user(mocker):
"""Test that rate limiting is applied per user, not globally."""
mocker.patch(
"nextcloud_mcp_server.config.get_settings",
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
)
# Mock httpx client to return 401
mock_response = MagicMock()
mock_response.status_code = 401
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
mocker.patch(
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
return_value=mock_client,
)
app = Starlette(
routes=[
Route(
"/api/v1/users/{user_id}/app-password",
provision_app_password,
methods=["POST"],
),
]
)
client = TestClient(app)
# Make 5 failed attempts for user1 (hits rate limit)
for _ in range(5):
client.post(
"/api/v1/users/user1/app-password",
headers={
"Authorization": create_basic_auth_header(
"user1", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
)
},
)
# user1 should be rate limited
response = client.post(
"/api/v1/users/user1/app-password",
headers={
"Authorization": create_basic_auth_header(
"user1", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
)
},
)
assert response.status_code == 429
# user2 should NOT be rate limited (different user)
response = client.post(
"/api/v1/users/user2/app-password",
headers={
"Authorization": create_basic_auth_header(
"user2", "bbbbb-ccccc-ddddd-eeeee-fffff"
)
},
)
assert response.status_code == 401 # Fails validation, but not rate limited
@@ -0,0 +1,337 @@
"""
Unit tests for Management API status endpoint.
Tests the /api/v1/status endpoint focusing on:
- OIDC config availability in different auth modes
- Hybrid mode (multi_user_basic + enable_offline_access) returning OIDC config
- OAuth mode returning OIDC config
- Non-OAuth modes NOT returning OIDC config
"""
from unittest.mock import MagicMock, patch
import pytest
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.testclient import TestClient
from nextcloud_mcp_server.api.management import get_server_status
from nextcloud_mcp_server.config_validators import AuthMode
pytestmark = pytest.mark.unit
def create_test_app():
"""Create a test Starlette app with the status endpoint."""
return Starlette(
routes=[
Route("/api/v1/status", get_server_status, methods=["GET"]),
]
)
def create_mock_settings(
enable_multi_user_basic: bool = False,
enable_offline_access: bool = False,
oidc_discovery_url: str | None = None,
oidc_issuer: str | None = None,
vector_sync_enabled: bool = False,
nextcloud_url: str = "http://localhost",
enable_token_exchange: bool = False,
mcp_client_id: str | None = None,
mcp_client_secret: str | None = None,
):
"""Create mock settings with specified auth configuration."""
settings = MagicMock()
settings.enable_multi_user_basic_auth = enable_multi_user_basic
settings.enable_offline_access = enable_offline_access
settings.oidc_discovery_url = oidc_discovery_url
settings.oidc_issuer = oidc_issuer
settings.vector_sync_enabled = vector_sync_enabled
settings.nextcloud_url = nextcloud_url
settings.enable_token_exchange = enable_token_exchange
settings.mcp_client_id = mcp_client_id
settings.mcp_client_secret = mcp_client_secret
return settings
class TestStatusEndpointOidcConfig:
"""Tests for OIDC configuration in status endpoint."""
def test_hybrid_mode_returns_oidc_config(self):
"""Test that hybrid mode (multi_user_basic + offline_access) returns OIDC config."""
mock_settings = create_mock_settings(
enable_multi_user_basic=True,
enable_offline_access=True,
oidc_discovery_url="http://keycloak/.well-known/openid-configuration",
oidc_issuer="http://keycloak/realms/test",
)
# get_settings and detect_auth_mode are imported inside the function
with (
patch(
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
),
patch(
"nextcloud_mcp_server.config_validators.detect_auth_mode",
return_value=AuthMode.MULTI_USER_BASIC,
),
):
app = create_test_app()
client = TestClient(app)
response = client.get("/api/v1/status")
assert response.status_code == 200
data = response.json()
# Verify auth mode
assert data["auth_mode"] == "multi_user_basic"
assert data["supports_app_passwords"] is True
# Verify OIDC config is present (key feature for hybrid mode)
assert "oidc" in data
assert (
data["oidc"]["discovery_url"]
== "http://keycloak/.well-known/openid-configuration"
)
assert data["oidc"]["issuer"] == "http://keycloak/realms/test"
def test_hybrid_mode_without_oidc_settings_no_oidc_key(self):
"""Test that hybrid mode without OIDC settings doesn't include empty oidc key."""
mock_settings = create_mock_settings(
enable_multi_user_basic=True,
enable_offline_access=True,
oidc_discovery_url=None,
oidc_issuer=None,
)
with (
patch(
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
),
patch(
"nextcloud_mcp_server.config_validators.detect_auth_mode",
return_value=AuthMode.MULTI_USER_BASIC,
),
):
app = create_test_app()
client = TestClient(app)
response = client.get("/api/v1/status")
assert response.status_code == 200
data = response.json()
# OIDC key should NOT be present if no OIDC settings configured
assert "oidc" not in data
def test_multi_user_basic_without_offline_access_no_oidc(self):
"""Test that multi_user_basic WITHOUT offline_access doesn't return OIDC config."""
mock_settings = create_mock_settings(
enable_multi_user_basic=True,
enable_offline_access=False, # Key difference: no offline access
oidc_discovery_url="http://keycloak/.well-known/openid-configuration",
oidc_issuer="http://keycloak/realms/test",
)
with (
patch(
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
),
patch(
"nextcloud_mcp_server.config_validators.detect_auth_mode",
return_value=AuthMode.MULTI_USER_BASIC,
),
):
app = create_test_app()
client = TestClient(app)
response = client.get("/api/v1/status")
assert response.status_code == 200
data = response.json()
# Verify auth mode
assert data["auth_mode"] == "multi_user_basic"
assert data["supports_app_passwords"] is False
# OIDC config should NOT be present (not hybrid mode)
assert "oidc" not in data
def test_oauth_mode_returns_oidc_config(self):
"""Test that OAuth mode returns OIDC config."""
mock_settings = create_mock_settings(
enable_multi_user_basic=False,
enable_offline_access=True,
oidc_discovery_url="http://nextcloud/.well-known/openid-configuration",
oidc_issuer="http://nextcloud",
)
with (
patch(
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
),
patch(
"nextcloud_mcp_server.config_validators.detect_auth_mode",
return_value=AuthMode.OAUTH_SINGLE_AUDIENCE,
),
):
app = create_test_app()
client = TestClient(app)
response = client.get("/api/v1/status")
assert response.status_code == 200
data = response.json()
# Verify auth mode
assert data["auth_mode"] == "oauth"
# Verify OIDC config is present
assert "oidc" in data
assert (
data["oidc"]["discovery_url"]
== "http://nextcloud/.well-known/openid-configuration"
)
def test_single_user_basic_no_oidc(self):
"""Test that single-user BasicAuth mode doesn't return OIDC config."""
mock_settings = create_mock_settings(
enable_multi_user_basic=False,
enable_offline_access=False,
oidc_discovery_url="http://keycloak/.well-known/openid-configuration",
oidc_issuer="http://keycloak/realms/test",
)
with (
patch(
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
),
patch(
"nextcloud_mcp_server.config_validators.detect_auth_mode",
return_value=AuthMode.SINGLE_USER_BASIC,
),
):
app = create_test_app()
client = TestClient(app)
response = client.get("/api/v1/status")
assert response.status_code == 200
data = response.json()
# Verify auth mode
assert data["auth_mode"] == "basic"
# OIDC config should NOT be present
assert "oidc" not in data
# supports_app_passwords should NOT be present (only for multi_user_basic)
assert "supports_app_passwords" not in data
def test_oidc_partial_config_only_discovery_url(self):
"""Test OIDC config with only discovery URL set."""
mock_settings = create_mock_settings(
enable_multi_user_basic=True,
enable_offline_access=True,
oidc_discovery_url="http://keycloak/.well-known/openid-configuration",
oidc_issuer=None, # Only discovery URL
)
with (
patch(
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
),
patch(
"nextcloud_mcp_server.config_validators.detect_auth_mode",
return_value=AuthMode.MULTI_USER_BASIC,
),
):
app = create_test_app()
client = TestClient(app)
response = client.get("/api/v1/status")
assert response.status_code == 200
data = response.json()
assert "oidc" in data
assert (
data["oidc"]["discovery_url"]
== "http://keycloak/.well-known/openid-configuration"
)
assert "issuer" not in data["oidc"]
def test_oidc_partial_config_only_issuer(self):
"""Test OIDC config with only issuer set."""
mock_settings = create_mock_settings(
enable_multi_user_basic=True,
enable_offline_access=True,
oidc_discovery_url=None, # Only issuer
oidc_issuer="http://keycloak/realms/test",
)
with (
patch(
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
),
patch(
"nextcloud_mcp_server.config_validators.detect_auth_mode",
return_value=AuthMode.MULTI_USER_BASIC,
),
):
app = create_test_app()
client = TestClient(app)
response = client.get("/api/v1/status")
assert response.status_code == 200
data = response.json()
assert "oidc" in data
assert "discovery_url" not in data["oidc"]
assert data["oidc"]["issuer"] == "http://keycloak/realms/test"
class TestStatusEndpointBasicResponse:
"""Tests for basic status endpoint response fields."""
def test_status_includes_version(self):
"""Test that status endpoint includes version."""
mock_settings = create_mock_settings()
with (
patch(
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
),
patch(
"nextcloud_mcp_server.config_validators.detect_auth_mode",
return_value=AuthMode.SINGLE_USER_BASIC,
),
):
app = create_test_app()
client = TestClient(app)
response = client.get("/api/v1/status")
assert response.status_code == 200
data = response.json()
assert "version" in data
assert "uptime_seconds" in data
assert "management_api_version" in data
assert data["management_api_version"] == "1.0"
def test_status_includes_vector_sync_enabled(self):
"""Test that status endpoint includes vector_sync_enabled."""
mock_settings = create_mock_settings(vector_sync_enabled=True)
with (
patch(
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
),
patch(
"nextcloud_mcp_server.config_validators.detect_auth_mode",
return_value=AuthMode.SINGLE_USER_BASIC,
),
):
app = create_test_app()
client = TestClient(app)
response = client.get("/api/v1/status")
assert response.status_code == 200
data = response.json()
assert data["vector_sync_enabled"] is True
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.commitizen]
name = "cz_conventional_commits"
version = "0.7.0"
version = "0.8.2"
tag_format = "astrolabe-v$version"
version_scheme = "semver"
update_changelog_on_bump = true
-50
View File
@@ -1,50 +0,0 @@
version: 2
updates:
- package-ecosystem: composer
directory: "/"
schedule:
interval: weekly
day: saturday
time: "03:00"
timezone: Europe/Paris
open-pull-requests-limit: 10
- package-ecosystem: composer
directory: "/vendor-bin/cs-fixer"
schedule:
interval: weekly
day: saturday
time: "03:00"
timezone: Europe/Paris
open-pull-requests-limit: 10
- package-ecosystem: composer
directory: "/vendor-bin/openapi-extractor"
schedule:
interval: weekly
day: saturday
time: "03:00"
timezone: Europe/Paris
open-pull-requests-limit: 10
- package-ecosystem: composer
directory: "/vendor-bin/phpunit"
schedule:
interval: weekly
day: saturday
time: "03:00"
timezone: Europe/Paris
open-pull-requests-limit: 10
- package-ecosystem: composer
directory: "/vendor-bin/psalm"
schedule:
interval: weekly
day: saturday
time: "03:00"
timezone: Europe/Paris
open-pull-requests-limit: 10
- package-ecosystem: npm
directory: "/"
schedule:
interval: weekly
day: saturday
time: "03:00"
timezone: Europe/Paris
open-pull-requests-limit: 10
@@ -1,36 +0,0 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: MIT
name: Block unconventional commits
on:
pull_request:
types: [opened, ready_for_review, reopened, synchronize]
permissions:
contents: read
concurrency:
group: block-unconventional-commits-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
block-unconventional-commits:
name: Block unconventional commits
runs-on: ubuntu-latest-low
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- uses: webiny/action-conventional-commits@8bc41ff4e7d423d56fa4905f6ff79209a78776c7 # v1.3.0
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-36
View File
@@ -1,36 +0,0 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: MIT
name: Block fixup and squash commits
on:
pull_request:
types: [opened, ready_for_review, reopened, synchronize]
permissions:
contents: read
concurrency:
group: fixup-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
commit-message-check:
if: github.event.pull_request.draft == false
permissions:
pull-requests: write
name: Block fixup and squash commits
runs-on: ubuntu-latest-low
steps:
- name: Run check
uses: skjnldsv/block-fixup-merge-action@c138ea99e45e186567b64cf065ce90f7158c236a # v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
-100
View File
@@ -1,100 +0,0 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: MIT
name: Lint eslint
on: pull_request
permissions:
contents: read
concurrency:
group: lint-eslint-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest-low
permissions:
contents: read
pull-requests: read
outputs:
src: ${{ steps.changes.outputs.src}}
steps:
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
continue-on-error: true
with:
filters: |
src:
- '.github/workflows/**'
- 'src/**'
- 'appinfo/info.xml'
- 'package.json'
- 'package-lock.json'
- 'tsconfig.json'
- '.eslintrc.*'
- '.eslintignore'
- '**.js'
- '**.ts'
- '**.vue'
lint:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.src != 'false'
name: NPM lint
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Read package.json node and npm engines version
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
id: versions
with:
fallbackNode: '^20'
fallbackNpm: '^10'
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ steps.versions.outputs.nodeVersion }}
- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
- name: Install dependencies
env:
CYPRESS_INSTALL_BINARY: 0
PUPPETEER_SKIP_DOWNLOAD: true
run: npm ci
- name: Lint
run: npm run lint
summary:
permissions:
contents: none
runs-on: ubuntu-latest-low
needs: [changes, lint]
if: always()
# This is the summary, we just avoid to rename it so that branch protection rules still match
name: eslint
steps:
- name: Summary status
run: if ${{ needs.changes.outputs.src != 'false' && needs.lint.result != 'success' }}; then exit 1; fi
@@ -1,38 +0,0 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: MIT
name: Lint info.xml
on: pull_request
permissions:
contents: read
concurrency:
group: lint-info-xml-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
xml-linters:
runs-on: ubuntu-latest-low
name: info.xml lint
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Download schema
run: wget https://raw.githubusercontent.com/nextcloud/appstore/master/nextcloudappstore/api/v1/release/info.xsd
- name: Lint info.xml
uses: ChristophWurst/xmllint-action@36f2a302f84f8c83fceea0b9c59e1eb4a616d3c1 # v1.2
with:
xml-file: ./appinfo/info.xml
xml-schema-file: ./info.xsd
-52
View File
@@ -1,52 +0,0 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: MIT
name: Lint php-cs
on: pull_request
permissions:
contents: read
concurrency:
group: lint-php-cs-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
name: php-cs
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Get php version
id: versions
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
- name: Set up php${{ steps.versions.outputs.php-min }}
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
with:
php-version: ${{ steps.versions.outputs.php-min }}
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite
coverage: none
ini-file: development
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install dependencies
run: |
composer remove nextcloud/ocp --dev
composer i
- name: Lint
run: composer run cs:check || ( echo 'Please run `composer run cs:fix` to format your code' && exit 1 )
-75
View File
@@ -1,75 +0,0 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: MIT
name: Lint php
on: pull_request
permissions:
contents: read
concurrency:
group: lint-php-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
matrix:
runs-on: ubuntu-latest-low
outputs:
php-versions: ${{ steps.versions.outputs.php-versions }}
steps:
- name: Checkout app
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Get version matrix
id: versions
uses: icewind1991/nextcloud-version-matrix@c2bf575a3516752db5ce2915499d3f694885e2c7 # v1.0.0
php-lint:
runs-on: ubuntu-latest
needs: matrix
strategy:
matrix:
php-versions: ${{fromJson(needs.matrix.outputs.php-versions)}}
name: php-lint
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up php ${{ matrix.php-versions }}
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
with:
php-version: ${{ matrix.php-versions }}
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite
coverage: none
ini-file: development
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Lint
run: composer run lint
summary:
permissions:
contents: none
runs-on: ubuntu-latest-low
needs: php-lint
if: always()
name: php-lint-summary
steps:
- name: Summary status
run: if ${{ needs.php-lint.result != 'success' && needs.php-lint.result != 'skipped' }}; then exit 1; fi
@@ -1,53 +0,0 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: MIT
name: Lint stylelint
on: pull_request
permissions:
contents: read
concurrency:
group: lint-stylelint-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
name: stylelint
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Read package.json node and npm engines version
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
id: versions
with:
fallbackNode: '^20'
fallbackNpm: '^10'
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ steps.versions.outputs.nodeVersion }}
- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
- name: Install dependencies
env:
CYPRESS_INSTALL_BINARY: 0
run: npm ci
- name: Lint
run: npm run stylelint
-107
View File
@@ -1,107 +0,0 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: MIT
name: Node
on: pull_request
permissions:
contents: read
concurrency:
group: node-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest-low
permissions:
contents: read
pull-requests: read
outputs:
src: ${{ steps.changes.outputs.src}}
steps:
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
continue-on-error: true
with:
filters: |
src:
- '.github/workflows/**'
- 'src/**'
- 'appinfo/info.xml'
- 'package.json'
- 'package-lock.json'
- 'tsconfig.json'
- '**.js'
- '**.ts'
- '**.vue'
build:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.src != 'false'
name: NPM build
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Read package.json node and npm engines version
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
id: versions
with:
fallbackNode: '^20'
fallbackNpm: '^10'
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ steps.versions.outputs.nodeVersion }}
- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
- name: Install dependencies & build
env:
CYPRESS_INSTALL_BINARY: 0
PUPPETEER_SKIP_DOWNLOAD: true
run: |
npm ci
npm run build --if-present
- name: Check webpack build changes
run: |
bash -c "[[ ! \"`git status --porcelain `\" ]] || (echo 'Please recompile and commit the assets, see the section \"Show changes on failure\" for details' && exit 1)"
- name: Show changes on failure
if: failure()
run: |
git status
git --no-pager diff
exit 1 # make it red to grab attention
summary:
permissions:
contents: none
runs-on: ubuntu-latest-low
needs: [changes, build]
if: always()
# This is the summary, we just avoid to rename it so that branch protection rules still match
name: node
steps:
- name: Summary status
run: if ${{ needs.changes.outputs.src != 'false' && needs.build.result != 'success' }}; then exit 1; fi
@@ -1,81 +0,0 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# SPDX-FileCopyrightText: 2023-2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: MIT
name: Npm audit fix and compile
on:
workflow_dispatch:
schedule:
# At 2:30 on Sundays
- cron: '30 2 * * 0'
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
branches: ['main', 'master', 'stable31', 'stable30']
name: npm-audit-fix-${{ matrix.branches }}
steps:
- name: Checkout
id: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
ref: ${{ matrix.branches }}
continue-on-error: true
- name: Read package.json node and npm engines version
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
id: versions
with:
fallbackNode: '^20'
fallbackNpm: '^10'
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ steps.versions.outputs.nodeVersion }}
- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
- name: Fix npm audit
id: npm-audit
uses: nextcloud-libraries/npm-audit-action@1b1728b2b4a7a78d69de65608efcf4db0e3e42d0 # v0.2.0
- name: Run npm ci and npm run build
if: steps.checkout.outcome == 'success'
env:
CYPRESS_INSTALL_BINARY: 0
run: |
npm ci
npm run build --if-present
- name: Create Pull Request
if: steps.checkout.outcome == 'success'
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
with:
token: ${{ secrets.COMMAND_BOT_PAT }}
commit-message: 'fix(deps): Fix npm audit'
committer: GitHub <noreply@github.com>
author: nextcloud-command <nextcloud-command@users.noreply.github.com>
signoff: true
branch: automated/noid/${{ matrix.branches }}-fix-npm-audit
title: '[${{ matrix.branches }}] Fix npm audit'
body: ${{ steps.npm-audit.outputs.markdown }}
labels: |
dependencies
3. to review
-96
View File
@@ -1,96 +0,0 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-FileCopyrightText: 2024 Arthur Schiwon <blizzz@arthur-schiwon.de>
# SPDX-License-Identifier: MIT
name: OpenAPI
on: pull_request
permissions:
contents: read
concurrency:
group: openapi-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
openapi:
runs-on: ubuntu-latest
if: ${{ github.repository_owner != 'nextcloud-gmbh' }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Get php version
id: php_versions
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
- name: Set up php
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
with:
php-version: ${{ steps.php_versions.outputs.php-available }}
extensions: xml
coverage: none
ini-file: development
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Check Typescript OpenApi types
id: check_typescript_openapi
uses: andstor/file-existence-action@076e0072799f4942c8bc574a82233e1e4d13e9d6 # v3.0.0
with:
files: "src/types/openapi/openapi*.ts"
- name: Read package.json node and npm engines version
if: steps.check_typescript_openapi.outputs.files_exists == 'true'
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
id: node_versions
# Continue if no package.json
continue-on-error: true
with:
fallbackNode: '^20'
fallbackNpm: '^10'
- name: Set up node ${{ steps.node_versions.outputs.nodeVersion }}
if: ${{ steps.node_versions.outputs.nodeVersion }}
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ steps.node_versions.outputs.nodeVersion }}
- name: Set up npm ${{ steps.node_versions.outputs.npmVersion }}
if: ${{ steps.node_versions.outputs.nodeVersion }}
run: npm i -g 'npm@${{ steps.node_versions.outputs.npmVersion }}'
- name: Install dependencies
if: ${{ steps.node_versions.outputs.nodeVersion }}
env:
CYPRESS_INSTALL_BINARY: 0
PUPPETEER_SKIP_DOWNLOAD: true
run: |
npm ci
- name: Set up dependencies
run: composer i
- name: Regenerate OpenAPI
run: composer run openapi
- name: Check openapi*.json and typescript changes
run: |
bash -c "[[ ! \"`git status --porcelain `\" ]] || (echo 'Please run \"composer run openapi\" and commit the openapi*.json files and (if applicable) src/types/openapi/openapi*.ts, see the section \"Show changes on failure\" for details' && exit 1)"
- name: Show changes on failure
if: failure()
run: |
git status
git --no-pager diff
exit 1 # make it red to grab attention
@@ -1,87 +0,0 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: MIT
name: Static analysis
on: pull_request
concurrency:
group: psalm-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
jobs:
matrix:
runs-on: ubuntu-latest-low
outputs:
ocp-matrix: ${{ steps.versions.outputs.ocp-matrix }}
steps:
- name: Checkout app
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Get version matrix
id: versions
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
- name: Check enforcement of minimum PHP version ${{ steps.versions.outputs.php-min }} in psalm.xml
run: grep 'phpVersion="${{ steps.versions.outputs.php-min }}' psalm.xml
static-analysis:
runs-on: ubuntu-latest
needs: matrix
strategy:
# do not stop on another job's failure
fail-fast: false
matrix: ${{ fromJson(needs.matrix.outputs.ocp-matrix) }}
name: static-psalm-analysis ${{ matrix.ocp-version }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up php${{ matrix.php-min }}
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
with:
php-version: ${{ matrix.php-min }}
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite
coverage: none
ini-file: development
# Temporary workaround for missing pcntl_* in PHP 8.3
ini-values: disable_functions=
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install dependencies
run: |
composer remove nextcloud/ocp --dev
composer i
- name: Install dependencies # zizmor: ignore[template-injection]
run: composer require --dev 'nextcloud/ocp:${{ matrix.ocp-version }}' --ignore-platform-reqs --with-dependencies
- name: Run coding standards check
run: composer run psalm -- --threads=1 --monochrome --no-progress --output-format=github
summary:
runs-on: ubuntu-latest-low
needs: static-analysis
if: always()
name: static-psalm-analysis-summary
steps:
- name: Summary status
run: if ${{ needs.static-analysis.result != 'success' }}; then exit 1; fi
@@ -1,58 +0,0 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# SPDX-FileCopyrightText: 2023-2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: MIT
name: Auto approve nextcloud/ocp
on:
pull_request_target: # zizmor: ignore[dangerous-triggers]
branches:
- main
- master
- stable*
permissions:
contents: read
concurrency:
group: update-nextcloud-ocp-approve-merge-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
auto-approve-merge:
if: github.actor == 'nextcloud-command'
runs-on: ubuntu-latest-low
permissions:
# for hmarr/auto-approve-action to approve PRs
pull-requests: write
# for alexwilson/enable-github-automerge-action to approve PRs
contents: write
steps:
- name: Disabled on forks
if: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
run: |
echo 'Can not approve PRs from forks'
exit 1
- uses: mdecoleman/pr-branch-name@55795d86b4566d300d237883103f052125cc7508 # v3.0.0
id: branchname
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
# GitHub actions bot approve
- uses: hmarr/auto-approve-action@b40d6c9ed2fa10c9a2749eca7eb004418a705501 # v2
if: startsWith(steps.branchname.outputs.branch, 'automated/noid/') && endsWith(steps.branchname.outputs.branch, 'update-nextcloud-ocp')
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
# Enable GitHub auto merge
- name: Auto merge
uses: alexwilson/enable-github-automerge-action@56e3117d1ae1540309dc8f7a9f2825bc3c5f06ff # v2.0.0
if: startsWith(steps.branchname.outputs.branch, 'automated/noid/') && endsWith(steps.branchname.outputs.branch, 'update-nextcloud-ocp')
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -1,101 +0,0 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: MIT
name: Update nextcloud/ocp
on:
workflow_dispatch:
schedule:
- cron: '5 2 * * 0'
permissions:
contents: read
jobs:
update-nextcloud-ocp:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
branches: ['master']
target: ['stable30']
name: update-nextcloud-ocp-${{ matrix.branches }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
ref: ${{ matrix.branches }}
submodules: true
- name: Set up php8.2
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
with:
php-version: 8.2
# https://docs.nextcloud.com/server/stable/admin_manual/installation/source_installation.html#prerequisites-for-manual-installation
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite
coverage: none
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Read codeowners
id: codeowners
run: |
grep '/appinfo/info.xml' .github/CODEOWNERS | cut -f 2- -d ' ' | xargs | awk '{ print "codeowners="$0 }' >> $GITHUB_OUTPUT
continue-on-error: true
- name: Composer install
run: composer install
- name: Composer update nextcloud/ocp
id: update_branch
run: composer require --dev nextcloud/ocp:dev-${{ matrix.target }}
- name: Raise on issue on failure
uses: dacbd/create-issue-action@cdb57ab6ff8862aa09fee2be6ba77a59581921c2 # v2.0.0
if: ${{ failure() && steps.update_branch.conclusion == 'failure' }}
with:
token: ${{ secrets.GITHUB_TOKEN }}
title: 'Failed to update nextcloud/ocp package'
body: 'Please check the output of the GitHub action and manually resolve the issues<br>${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}<br>${{ steps.codeowners.outputs.codeowners }}'
- name: Reset checkout 3rdparty
run: |
git clean -f 3rdparty
git checkout 3rdparty
continue-on-error: true
- name: Reset checkout vendor
run: |
git clean -f vendor
git checkout vendor
continue-on-error: true
- name: Reset checkout vendor-bin
run: |
git clean -f vendor-bin
git checkout vendor-bin
continue-on-error: true
- name: Create Pull Request
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
with:
token: ${{ secrets.COMMAND_BOT_PAT }}
commit-message: 'chore(dev-deps): Bump nextcloud/ocp package'
committer: GitHub <noreply@github.com>
author: nextcloud-command <nextcloud-command@users.noreply.github.com>
signoff: true
branch: 'automated/noid/${{ matrix.branches }}-update-nextcloud-ocp'
title: '[${{ matrix.branches }}] Update nextcloud/ocp dependency'
body: |
Auto-generated update of [nextcloud/ocp](https://github.com/nextcloud-deps/ocp/) dependency
labels: |
dependencies
3. to review
+1
View File
@@ -12,3 +12,4 @@ build/
node_modules/
js/
css/
.phpunit.cache/
+50
View File
@@ -25,6 +25,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Requires external MCP server deployment
- See documentation for setup: https://github.com/cbcoutinho/nextcloud-mcp-server
## astrolabe-v0.8.2 (2026-01-16)
### Fix
- **astrolabe**: Address reviewer feedback for hybrid mode
- **astrolabe**: Fix NcSelect options and CSS loading
- **astrolabe**: fix OAuth flow and settings UI for hybrid mode
- **api**: return OIDC config in hybrid mode for Astrolabe OAuth flow
## astrolabe-v0.8.1 (2026-01-15)
### Fix
- **astrolabe**: address review feedback for Vue 3 bindings
- **astrolabe**: update Vue component bindings for Vue 3 compatibility
- **ci**: bump helm chart version when MCP appVersion changes
## astrolabe-v0.8.0 (2026-01-15)
### Feat
- Add rate limiting and extract helpers for app password endpoints
### Fix
- **astrolabe**: define appName and appVersion for @nextcloud/vue
- Add missing annotations for deck remove/unassign operations
- **auth**: Store app passwords locally for multi-user BasicAuth background sync
- **deck**: use correct endpoint for reorder_card to fix cross-stack moves
- **deck**: Always preserve fields in update_card for partial updates
### Refactor
- Use get_settings() for vector sync enabled check
- Extract storage helper and improve PHP error handling
## astrolabe-v0.7.2 (2025-12-30)
### Fix
- **astrolabe**: Fix CSS loading for Nextcloud apps
## astrolabe-v0.7.1 (2025-12-30)
### Fix
- **astrolabe**: Fix revoke access button HTTP method mismatch
- **oauth**: Enable browser OAuth routes for Management API in hybrid mode
- **mcp**: Move all imports to the top of modules
## astrolabe-v0.7.0 (2025-12-26)
### Feat
+2 -2
View File
@@ -29,7 +29,7 @@ Astrolabe connects to a semantic search service that understands the meaning of
See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for configuration details.
]]></description>
<version>0.7.0</version>
<version>0.8.2</version>
<licence>agpl</licence>
<author homepage="https://github.com/cbcoutinho">Chris Coutinho</author>
<namespace>Astrolabe</namespace>
@@ -40,7 +40,7 @@ See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for conf
<screenshot>https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/third_party/astrolabe/screenshots/01-unified-search-astrolabe.png?raw=1</screenshot>
<screenshot>https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/third_party/astrolabe/screenshots/03-chunk-viewer-open.png?raw=1</screenshot>
<dependencies>
<nextcloud min-version="30" max-version="32"/>
<nextcloud min-version="31" max-version="32"/>
</dependencies>
<settings>
<personal>OCA\Astrolabe\Settings\Personal</personal>
+7 -1
View File
@@ -14,6 +14,11 @@
"OCA\\Astrolabe\\": "lib/"
}
},
"autoload-dev": {
"psr-4": {
"OCP\\": "vendor/nextcloud/ocp/OCP/"
}
},
"scripts": {
"post-install-cmd": [
"@composer bin all install --ansi"
@@ -25,7 +30,7 @@
"cs:check": "php-cs-fixer fix --dry-run --diff",
"cs:fix": "php-cs-fixer fix",
"psalm": "psalm --threads=1 --no-cache",
"test:unit": "phpunit tests -c tests/phpunit.xml --colors=always --fail-on-warning --fail-on-risky",
"test:unit": "./vendor/bin/phpunit -c tests/unit/phpunit.xml --colors=always",
"openapi": "generate-spec",
"rector": "rector && composer cs:fix"
},
@@ -35,6 +40,7 @@
},
"require-dev": {
"nextcloud/ocp": "dev-stable30",
"phpunit/phpunit": "^10.0",
"roave/security-advisories": "dev-latest"
},
"config": {
+1671 -2
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -26,13 +26,13 @@ use Psr\Log\LoggerInterface;
* Handles form submissions and AJAX requests from settings panels.
*/
class ApiController extends Controller {
private $client;
private $userSession;
private $urlGenerator;
private $logger;
private $tokenStorage;
private $config;
private $tokenRefresher;
private McpServerClient $client;
private IUserSession $userSession;
private IURLGenerator $urlGenerator;
private LoggerInterface $logger;
private McpTokenStorage $tokenStorage;
private IConfig $config;
private IdpTokenRefresher $tokenRefresher;
public function __construct(
string $appName,
@@ -23,13 +23,13 @@ use Psr\Log\LoggerInterface;
* Handles storing and validating app passwords for multi-user BasicAuth mode.
*/
class CredentialsController extends Controller {
private $tokenStorage;
private $userSession;
private $logger;
private $config;
private $client;
private $httpClientService;
private $urlGenerator;
private McpTokenStorage $tokenStorage;
private IUserSession $userSession;
private LoggerInterface $logger;
private IConfig $config;
private McpServerClient $client;
private IClientService $httpClientService;
private IURLGenerator $urlGenerator;
public function __construct(
string $appName,
@@ -94,24 +94,90 @@ class CredentialsController extends Controller {
], Http::STATUS_UNAUTHORIZED);
}
// Store encrypted app password
// Store encrypted app password locally in Nextcloud
try {
$this->tokenStorage->storeBackgroundSyncPassword($userId, $appPassword);
$this->logger->info("Successfully stored app password for user: $userId");
return new JSONResponse([
'success' => true,
'message' => 'App password saved successfully'
], Http::STATUS_OK);
$this->logger->info("Stored app password locally for user: $userId");
} catch (\Exception $e) {
$this->logger->error("Failed to store app password for user $userId", [
$this->logger->error("Failed to store app password locally for user $userId", [
'error' => $e->getMessage()
]);
return new JSONResponse([
'success' => false,
'error' => 'Failed to save app password'
'error' => 'Failed to save app password locally'
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
// Send app password to MCP server for background sync
// Get MCP server URL from system config (set in config.php)
$mcpServerUrl = $this->config->getSystemValue('mcp_server_url', '');
if (empty($mcpServerUrl)) {
$this->logger->warning('MCP server URL not configured, app password stored locally only');
return new JSONResponse([
'success' => true,
'partial_success' => true,
'local_storage' => true,
'mcp_sync' => false,
'message' => 'App password saved locally (MCP server not configured)'
], Http::STATUS_OK);
}
try {
$httpClient = $this->httpClientService->newClient();
// Send to MCP server with BasicAuth (user proves ownership of password)
$mcpEndpoint = rtrim($mcpServerUrl, '/') . '/api/v1/users/' . urlencode($userId) . '/app-password';
$this->logger->debug("Sending app password to MCP server: $mcpEndpoint");
$response = $httpClient->post($mcpEndpoint, [
'auth' => [$userId, $appPassword],
'headers' => [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'timeout' => 10,
]);
$statusCode = $response->getStatusCode();
$body = json_decode($response->getBody(), true);
if ($statusCode === 200 && ($body['success'] ?? false)) {
$this->logger->info("Successfully provisioned app password to MCP server for user: $userId");
return new JSONResponse([
'success' => true,
'partial_success' => false,
'local_storage' => true,
'mcp_sync' => true,
'message' => 'App password saved successfully'
], Http::STATUS_OK);
} else {
$error = $body['error'] ?? 'Unknown error';
$this->logger->error("MCP server rejected app password for user $userId: $error");
// Return partial success since it was stored locally but MCP sync failed
return new JSONResponse([
'success' => true,
'partial_success' => true,
'local_storage' => true,
'mcp_sync' => false,
'message' => 'App password saved locally (MCP server sync failed)',
'mcp_error' => $error
], Http::STATUS_OK);
}
} catch (\Exception $e) {
$this->logger->error("Failed to send app password to MCP server for user $userId", [
'error' => $e->getMessage()
]);
// Return partial success since it was stored locally but MCP was unreachable
return new JSONResponse([
'success' => true,
'partial_success' => true,
'local_storage' => true,
'mcp_sync' => false,
'message' => 'App password saved locally (MCP server unreachable)',
'mcp_error' => $e->getMessage()
], Http::STATUS_OK);
}
}
/**
+10 -9
View File
@@ -12,6 +12,7 @@ use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\RedirectResponse;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\Http\Client\IClient;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use OCP\IL10N;
@@ -32,15 +33,15 @@ use Psr\Log\LoggerInterface;
* - Confidential clients: PKCE + client_secret (defense in depth)
*/
class OAuthController extends Controller {
private $config;
private $session;
private $userSession;
private $urlGenerator;
private $tokenStorage;
private $logger;
private $l;
private $httpClient;
private $client;
private IConfig $config;
private ISession $session;
private IUserSession $userSession;
private IURLGenerator $urlGenerator;
private McpTokenStorage $tokenStorage;
private LoggerInterface $logger;
private IL10N $l;
private IClient $httpClient;
private McpServerClient $client;
public function __construct(
string $appName,
+73 -23
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace OCA\Astrolabe\Service;
use OCP\Http\Client\IClient;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use Psr\Log\LoggerInterface;
@@ -18,10 +19,10 @@ use Psr\Log\LoggerInterface;
* Public clients without client_secret cannot refresh tokens.
*/
class IdpTokenRefresher {
private $config;
private $httpClient;
private $logger;
private $mcpServerClient;
private IConfig $config;
private IClient $httpClient;
private LoggerInterface $logger;
private McpServerClient $mcpServerClient;
public function __construct(
IConfig $config,
@@ -38,25 +39,47 @@ class IdpTokenRefresher {
/**
* Get Nextcloud base URL for constructing internal OIDC endpoint URLs.
*
* @return string Base URL (e.g., "https://nextcloud.example.com")
* IMPORTANT: This is for INTERNAL server-to-server requests (PHP to local Apache),
* NOT for external client URLs. We must use the internal container URL, not the
* external URL that browsers see.
*
* Configuration priority:
* 1. astrolabe_internal_url - Explicit internal URL (for custom container setups)
* 2. http://localhost - Default for Docker containers (web server on port 80)
*
* NOTE: We intentionally DO NOT use overwrite.cli.url here because:
* - overwrite.cli.url is the EXTERNAL URL (e.g., http://localhost:8080)
* - External URLs are not accessible from inside the container
* - This method is for internal HTTP requests to the local web server
*
* @return string Base URL for internal requests (e.g., "http://localhost")
*/
private function getNextcloudBaseUrl(): string {
// Prefer explicit CLI URL override
$baseUrl = $this->config->getSystemValue('overwrite.cli.url', '');
if (!empty($baseUrl)) {
return rtrim($baseUrl, '/');
// Check for explicit internal URL config (for custom container setups)
$internalUrl = $this->config->getSystemValue('astrolabe_internal_url', '');
if (!is_string($internalUrl)) {
$internalUrl = '';
}
if (!empty($internalUrl)) {
// Validate URL format
if (!filter_var($internalUrl, FILTER_VALIDATE_URL)) {
$this->logger->warning('Invalid astrolabe_internal_url format, using default', [
'configured_url' => $internalUrl,
]);
return 'http://localhost';
}
// Warn if it looks like an external URL (common misconfiguration)
if (preg_match('/:\d{4,5}$/', $internalUrl)) {
$this->logger->warning('astrolabe_internal_url appears to use external port mapping', [
'configured_url' => $internalUrl,
'hint' => 'Internal URLs should use port 80, not mapped ports like :8080',
]);
}
return rtrim($internalUrl, '/');
}
// Fallback to first trusted domain with protocol
$trustedDomains = $this->config->getSystemValue('trusted_domains', []);
if (!empty($trustedDomains)) {
$protocol = $this->config->getSystemValue('overwriteprotocol', 'https');
return $protocol . '://' . $trustedDomains[0];
}
// Last resort: localhost (log warning)
$this->logger->warning('IdpTokenRefresher: No Nextcloud URL configured, using localhost fallback');
// Default: container environment with web server on localhost:80
// This works because PHP runs inside the same container as Apache
return 'http://localhost';
}
@@ -99,7 +122,7 @@ class IdpTokenRefresher {
// External IdP configured - use OIDC discovery
$discoveryUrl = $statusData['oidc']['discovery_url'];
$this->logger->info('IdpTokenRefresher: Using external IdP', [
$this->logger->debug('IdpTokenRefresher: Using external IdP', [
'discovery_url' => $discoveryUrl,
]);
@@ -115,7 +138,7 @@ class IdpTokenRefresher {
// Nextcloud's OIDC app - use internal URL
$tokenEndpoint = $this->getNextcloudBaseUrl() . '/apps/oidc/token';
$this->logger->info('IdpTokenRefresher: Using Nextcloud OIDC app', [
$this->logger->debug('IdpTokenRefresher: Using Nextcloud OIDC app', [
'token_endpoint' => $tokenEndpoint,
]);
}
@@ -160,11 +183,38 @@ class IdpTokenRefresher {
return $tokenData;
} catch (\Exception $e) {
$this->logger->error('IdpTokenRefresher: Token refresh failed', [
} catch (\OCP\Http\Client\LocalServerException $e) {
// Network/connection error - may be transient
$this->logger->warning('IdpTokenRefresher: Network error during refresh', [
'error' => $e->getMessage(),
]);
return null;
} catch (\Exception $e) {
$statusCode = null;
if (method_exists($e, 'getCode')) {
$statusCode = $e->getCode();
}
// Log with appropriate level based on error type
if ($statusCode === 401 || $statusCode === 403) {
// Auth error - token is invalid, should be deleted
$this->logger->error('IdpTokenRefresher: Auth error - token invalid', [
'status_code' => $statusCode,
'error' => $e->getMessage(),
]);
} elseif ($statusCode >= 500) {
// Server error - may be transient
$this->logger->warning('IdpTokenRefresher: Server error during refresh', [
'status_code' => $statusCode,
'error' => $e->getMessage(),
]);
} else {
$this->logger->error('IdpTokenRefresher: Token refresh failed', [
'status_code' => $statusCode,
'error' => $e->getMessage(),
]);
}
return null;
}
}
}
+7 -5
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace OCA\Astrolabe\Service;
use OCP\Http\Client\IClient;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use Psr\Log\LoggerInterface;
@@ -16,10 +17,10 @@ use Psr\Log\LoggerInterface;
* for all management operations.
*/
class McpServerClient {
private $httpClient;
private $config;
private $logger;
private $baseUrl;
private IClient $httpClient;
private IConfig $config;
private LoggerInterface $logger;
private string $baseUrl;
public function __construct(
IClientService $clientService,
@@ -31,7 +32,8 @@ class McpServerClient {
$this->logger = $logger;
// Get MCP server configuration from Nextcloud config
$this->baseUrl = $this->config->getSystemValue('mcp_server_url', 'http://localhost:8000');
$baseUrl = $this->config->getSystemValue('mcp_server_url', 'http://localhost:8000');
$this->baseUrl = is_string($baseUrl) ? $baseUrl : 'http://localhost:8000';
}
/**
+16 -5
View File
@@ -15,6 +15,9 @@ use Psr\Log\LoggerInterface;
* Handles token expiration checking and refresh logic.
*/
class McpTokenStorage {
/** Buffer time in seconds before actual expiry to trigger refresh */
private const TOKEN_EXPIRY_BUFFER_SECONDS = 60;
private $config;
private $crypto;
private $logger;
@@ -112,7 +115,7 @@ class McpTokenStorage {
/**
* Check if a token is expired or about to expire.
*
* Uses a 60-second buffer to refresh tokens before they actually expire.
* Uses TOKEN_EXPIRY_BUFFER_SECONDS buffer to refresh tokens before they actually expire.
*
* @param array $token Token data array
* @return bool True if expired or about to expire
@@ -122,8 +125,8 @@ class McpTokenStorage {
return true;
}
// Expire 60 seconds early to avoid race conditions
return time() >= ($token['expires_at'] - 60);
// Expire early to avoid race conditions
return time() >= ($token['expires_at'] - self::TOKEN_EXPIRY_BUFFER_SECONDS);
}
/**
@@ -191,11 +194,19 @@ class McpTokenStorage {
$this->logger->error("Failed to refresh token for user $userId", [
'error' => $e->getMessage()
]);
// Fall through to return null
// Delete stale token to prevent repeated refresh attempts
$this->deleteUserToken($userId);
return null;
}
// Refresh callback returned null or invalid data - delete stale token
$this->deleteUserToken($userId);
$this->logger->info("Deleted stale token for user $userId after refresh failure");
return null;
}
// Token expired and no refresh available
// Token expired and no refresh callback available - delete stale token
$this->deleteUserToken($userId);
$this->logger->info("Token expired for user $userId, no refresh available");
return null;
}
+47 -53
View File
@@ -79,60 +79,46 @@ class Personal implements ISettings {
// Check if user has MCP OAuth token
$token = $this->tokenStorage->getUserToken($userId);
// For multi_user_basic mode with app password support, check if user has app password
// For multi_user_basic mode with app password support (hybrid mode)
// User needs BOTH:
// 1. OAuth token for Astrolabe→MCP API calls (stored in McpTokenStorage)
// 2. App password for MCP→Nextcloud background sync
if ($authMode === 'multi_user_basic' && $supportsAppPasswords) {
// Check if user has already provided an app password
$hasBackgroundAccess = $this->tokenStorage->hasBackgroundSyncAccess($userId);
// Check both credentials
$hasOAuthToken = ($token !== null && !$this->tokenStorage->isExpired($token));
$hasAppPassword = $this->tokenStorage->hasBackgroundSyncAccess($userId);
$backgroundSyncType = $this->tokenStorage->getBackgroundSyncType($userId);
$backgroundSyncProvisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
if (!$hasBackgroundAccess) {
// No app password yet - show app password entry form
return new TemplateResponse(
Application::APP_ID,
'settings/personal',
[
'serverUrl' => $this->client->getPublicServerUrl(), // Changed from server_url to serverUrl
'serverStatus' => $serverStatus,
'auth_mode' => $authMode,
'authMode' => $authMode, // Add camelCase version for template
'supports_app_passwords' => $supportsAppPasswords,
'supportsAppPasswords' => $supportsAppPasswords, // Add camelCase version
'session' => null, // No session yet
'hasBackgroundAccess' => false, // FIXED: Add missing parameter
'backgroundAccessGranted' => false, // FIXED: Add missing parameter
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
'hasToken' => false, // No OAuth token in multi_user_basic mode
'requesttoken' => \OCP\Util::callRegister(),
],
TemplateResponse::RENDER_AS_BLANK
);
} else {
// User has app password - show active status
$backgroundSyncType = $this->tokenStorage->getBackgroundSyncType($userId);
$backgroundSyncProvisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
// OAuth URL for Astrolabe's own OAuth controller (NOT MCP server's browser OAuth)
$oauthUrl = $this->urlGenerator->linkToRoute('astrolabe.oauth.initiateOAuth');
$parameters = [
'userId' => $userId,
'serverStatus' => $serverStatus,
'session' => null, // No user session for app passwords
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
'backgroundAccessGranted' => true, // App password grants background access
'serverUrl' => $this->client->getPublicServerUrl(),
'hasToken' => false, // No OAuth token
'hasBackgroundAccess' => true,
'backgroundSyncType' => $backgroundSyncType,
'backgroundSyncProvisionedAt' => $backgroundSyncProvisionedAt,
'authMode' => $authMode,
'supportsAppPasswords' => $supportsAppPasswords,
'requesttoken' => \OCP\Util::callRegister(),
];
// Consolidated template parameters (camelCase convention)
$parameters = [
'userId' => $userId,
'serverUrl' => $this->client->getPublicServerUrl(),
'serverStatus' => $serverStatus,
'authMode' => $authMode,
'supportsAppPasswords' => $supportsAppPasswords,
'session' => null, // No session in hybrid mode
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
// OAuth token status (for Astrolabe→MCP API calls)
'hasOAuthToken' => $hasOAuthToken,
'oauthUrl' => $oauthUrl,
// App password status (for MCP→Nextcloud background sync)
'hasBackgroundAccess' => $hasAppPassword,
'backgroundAccessGranted' => $hasAppPassword, // Legacy alias
'backgroundSyncType' => $backgroundSyncType,
'backgroundSyncProvisionedAt' => $backgroundSyncProvisionedAt,
'requesttoken' => \OCP\Util::callRegister(),
];
return new TemplateResponse(
Application::APP_ID,
'settings/personal',
$parameters,
TemplateResponse::RENDER_AS_BLANK
);
}
return new TemplateResponse(
Application::APP_ID,
'settings/personal',
$parameters,
TemplateResponse::RENDER_AS_BLANK
);
}
// For OAuth modes, if no token or token is expired, show OAuth authorization UI
elseif (!$token || $this->tokenStorage->isExpired($token)) {
@@ -198,6 +184,9 @@ class Personal implements ISettings {
$backgroundSyncType = $this->tokenStorage->getBackgroundSyncType($userId);
$backgroundSyncProvisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
// OAuth URL for standard OAuth mode (in case user needs to re-authorize)
$oauthUrl = $this->urlGenerator->linkToRoute('astrolabe.oauth.initiateOAuth');
// Provide initial state for Vue.js frontend (if needed)
$this->initialState->provideInitialState('user-data', [
'userId' => $userId,
@@ -205,17 +194,22 @@ class Personal implements ISettings {
'session' => $userSession,
]);
// Consolidated template parameters (camelCase convention)
$parameters = [
'userId' => $userId,
'serverUrl' => $this->client->getPublicServerUrl(),
'serverStatus' => $serverStatus,
'session' => $userSession,
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
'backgroundAccessGranted' => $userSession['background_access_granted'] ?? false,
'serverUrl' => $this->client->getPublicServerUrl(),
'hasToken' => true,
// OAuth status
'hasOAuthToken' => true,
'oauthUrl' => $oauthUrl,
// Background sync status
'hasBackgroundAccess' => $hasBackgroundAccess,
'backgroundAccessGranted' => $userSession['background_access_granted'] ?? false, // Legacy
'backgroundSyncType' => $backgroundSyncType,
'backgroundSyncProvisionedAt' => $backgroundSyncProvisionedAt,
'requesttoken' => \OCP\Util::callRegister(),
];
return new TemplateResponse(
+17 -15
View File
@@ -1,12 +1,12 @@
{
"name": "astrolabe",
"version": "0.6.0",
"version": "0.8.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "astrolabe",
"version": "0.6.0",
"version": "0.8.2",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@nextcloud/axios": "^2.5.1",
@@ -14,7 +14,7 @@
"@nextcloud/initial-state": "^3.0.0",
"@nextcloud/l10n": "^3.1.0",
"@nextcloud/router": "^3.0.1",
"@nextcloud/vue": "^9.0.0",
"@nextcloud/vue": "^9.3.3",
"markdown-it": "^14.1.0",
"pdfjs-dist": "^4.0.379",
"plotly.js-dist-min": "^2.35.3",
@@ -1657,9 +1657,9 @@
}
},
"node_modules/@nextcloud/vue": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-9.3.1.tgz",
"integrity": "sha512-zNit83SI7IPT5iT9QsYPCYNwBYvKEqzLvWKTeJemqg9MZ8JGIC3/jjENeXzDolrTN/PixHns5lOYVCejATE1ag==",
"version": "9.3.3",
"resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-9.3.3.tgz",
"integrity": "sha512-M/M4L9vp1AJQ8RRk75mbMwUo7sOwWDaTDmAwgpTa9LARDe5e6UBJoMhOmiz5EPkYRHLn2SLE+baOIXVmtVMdqw==",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@ckpack/vue-color": "^1.6.0",
@@ -1671,7 +1671,7 @@
"@nextcloud/event-bus": "^3.3.3",
"@nextcloud/initial-state": "^3.0.0",
"@nextcloud/l10n": "^3.4.1",
"@nextcloud/logger": "^3.0.2",
"@nextcloud/logger": "^3.0.3",
"@nextcloud/router": "^3.1.0",
"@nextcloud/sharing": "^0.3.0",
"@vuepic/vue-datepicker": "^11.0.3",
@@ -1684,9 +1684,9 @@
"emoji-mart-vue-fast": "^15.0.5",
"escape-html": "^1.0.3",
"floating-vue": "^5.2.2",
"focus-trap": "^7.6.6",
"focus-trap": "7.6.6",
"linkifyjs": "^4.3.2",
"p-queue": "^9.0.1",
"p-queue": "^9.1.0",
"rehype-external-links": "^3.0.0",
"rehype-highlight": "^7.0.2",
"rehype-react": "^8.0.0",
@@ -1696,14 +1696,14 @@
"remark-unlink-protocols": "^1.0.0",
"splitpanes": "^4.0.4",
"striptags": "^3.2.0",
"tabbable": "^6.3.0",
"tabbable": "^6.4.0",
"tributejs": "^5.1.3",
"ts-md5": "^2.0.1",
"unified": "^11.0.5",
"unist-builder": "^4.0.0",
"unist-util-visit": "^5.0.0",
"vue": "^3.5.18",
"vue-router": "^4.6.3",
"vue-router": "^4.6.4",
"vue-select": "^4.0.0-beta.6"
},
"engines": {
@@ -7751,9 +7751,9 @@
}
},
"node_modules/p-queue": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.0.1.tgz",
"integrity": "sha512-RhBdVhSwJb7Ocn3e8ULk4NMwBEuOxe+1zcgphUy9c2e5aR/xbEsdVXxHJ3lynw6Qiqu7OINEyHlZkiblEpaq7w==",
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz",
"integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^5.0.1",
@@ -9693,7 +9693,9 @@
}
},
"node_modules/tabbable": {
"version": "6.3.0",
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
"license": "MIT"
},
"node_modules/table": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "astrolabe",
"version": "0.7.0",
"version": "0.8.2",
"license": "AGPL-3.0-or-later",
"engines": {
"node": "^22.0.0",
@@ -23,7 +23,7 @@
"@nextcloud/initial-state": "^3.0.0",
"@nextcloud/l10n": "^3.1.0",
"@nextcloud/router": "^3.0.1",
"@nextcloud/vue": "^9.0.0",
"@nextcloud/vue": "^9.3.3",
"markdown-it": "^14.1.0",
"pdfjs-dist": "^4.0.379",
"plotly.js-dist-min": "^2.35.3",
+512
View File
@@ -0,0 +1,512 @@
<?xml version="1.0" encoding="UTF-8"?>
<files psalm-version="5.26.1@d747f6500b38ac4f7dfc5edbcae6e4b637d7add0">
<file src="lib/Controller/ApiController.php">
<DeprecatedMethod>
<code><![CDATA[setAppValue]]></code>
<code><![CDATA[setAppValue]]></code>
<code><![CDATA[setAppValue]]></code>
<code><![CDATA[setAppValue]]></code>
</DeprecatedMethod>
<InvalidArrayOffset>
<code><![CDATA[$result['coordinates_3d']]]></code>
<code><![CDATA[$result['pca_variance']]]></code>
<code><![CDATA[$result['query_coords']]]></code>
<code><![CDATA[$webhook['eventFilter']]]></code>
</InvalidArrayOffset>
<MissingClosureReturnType>
<code><![CDATA[function (string $refreshToken) {]]></code>
<code><![CDATA[function (string $refreshToken) {]]></code>
<code><![CDATA[function (string $refreshToken) {]]></code>
<code><![CDATA[function (string $refreshToken) {]]></code>
<code><![CDATA[function (string $refreshToken) {]]></code>
</MissingClosureReturnType>
<MixedArgument>
<code><![CDATA[!empty($eventConfig['filter']) ? $eventConfig['filter'] : null]]></code>
<code><![CDATA[$accessToken]]></code>
<code><![CDATA[$algorithm]]></code>
<code><![CDATA[$eventConfig['event']]]></code>
<code><![CDATA[$fusion]]></code>
</MixedArgument>
<MixedArrayAccess>
<code><![CDATA[$data['algorithm']]]></code>
<code><![CDATA[$data['fusion']]]></code>
<code><![CDATA[$data['limit']]]></code>
<code><![CDATA[$data['scoreThreshold']]]></code>
<code><![CDATA[$eventConfig['event']]]></code>
<code><![CDATA[$eventConfig['event']]]></code>
<code><![CDATA[$eventConfig['filter']]]></code>
<code><![CDATA[$presetEvent['event']]]></code>
<code><![CDATA[$presetEvent['event']]]></code>
<code><![CDATA[$presetEvent['filter']]]></code>
<code><![CDATA[$presetEvent['filter']]]></code>
</MixedArrayAccess>
<MixedAssignment>
<code><![CDATA[$accessToken]]></code>
<code><![CDATA[$algorithm]]></code>
<code><![CDATA[$data]]></code>
<code><![CDATA[$eventConfig]]></code>
<code><![CDATA[$fusion]]></code>
<code><![CDATA[$presetEvent]]></code>
<code><![CDATA[$presetEvent]]></code>
<code><![CDATA[$presetFilter]]></code>
<code><![CDATA[$presetFilter]]></code>
<code><![CDATA[$response['coordinates_3d']]]></code>
<code><![CDATA[$response['pca_variance']]]></code>
<code><![CDATA[$response['query_coords']]]></code>
<code><![CDATA[$webhookFilter]]></code>
</MixedAssignment>
<PossiblyUndefinedArrayOffset>
<code><![CDATA[$webhook['event']]]></code>
<code><![CDATA[$webhook['event']]]></code>
<code><![CDATA[$webhook['event']]]></code>
<code><![CDATA[$webhook['id']]]></code>
</PossiblyUndefinedArrayOffset>
<RiskyTruthyFalsyComparison>
<code><![CDATA[!$accessToken]]></code>
<code><![CDATA[!$accessToken]]></code>
<code><![CDATA[!$accessToken]]></code>
<code><![CDATA[!$accessToken]]></code>
<code><![CDATA[!$accessToken]]></code>
<code><![CDATA[!$newTokenData]]></code>
<code><![CDATA[!$newTokenData]]></code>
<code><![CDATA[!$newTokenData]]></code>
<code><![CDATA[!$newTokenData]]></code>
<code><![CDATA[!$newTokenData]]></code>
<code><![CDATA[!$token]]></code>
<code><![CDATA[empty($webhook['eventFilter'])]]></code>
</RiskyTruthyFalsyComparison>
<TypeDoesNotContainType>
<code><![CDATA[is_array($status)]]></code>
<code><![CDATA[is_array($status)]]></code>
</TypeDoesNotContainType>
<UnusedClass>
<code><![CDATA[ApiController]]></code>
</UnusedClass>
</file>
<file src="lib/Controller/CredentialsController.php">
<MixedArgument>
<code><![CDATA[$mcpServerUrl]]></code>
</MixedArgument>
<MixedArrayAccess>
<code><![CDATA[$body['error']]]></code>
<code><![CDATA[$body['success']]]></code>
</MixedArrayAccess>
<MixedAssignment>
<code><![CDATA[$body]]></code>
<code><![CDATA[$error]]></code>
<code><![CDATA[$mcpServerUrl]]></code>
</MixedAssignment>
<PossiblyInvalidArgument>
<code><![CDATA[$response->getBody()]]></code>
</PossiblyInvalidArgument>
<RiskyTruthyFalsyComparison>
<code><![CDATA[$body['success'] ?? false]]></code>
</RiskyTruthyFalsyComparison>
<UnusedClass>
<code><![CDATA[CredentialsController]]></code>
</UnusedClass>
</file>
<file src="lib/Controller/OAuthController.php">
<MixedArgument>
<code><![CDATA[$authEndpoint]]></code>
<code><![CDATA[$codeVerifier]]></code>
<code><![CDATA[$discoveryUrl]]></code>
<code><![CDATA[$discoveryUrl]]></code>
<code><![CDATA[$internalBaseUrl]]></code>
<code><![CDATA[$mcpServerUrl]]></code>
<code><![CDATA[$mcpServerUrl]]></code>
<code><![CDATA[$tokenData['access_token']]]></code>
<code><![CDATA[$tokenData['refresh_token'] ?? '']]></code>
<code><![CDATA[$tokenEndpoint]]></code>
<code><![CDATA[$userId]]></code>
<code><![CDATA[time() + ($tokenData['expires_in'] ?? 3600)]]></code>
</MixedArgument>
<MixedArrayAccess>
<code><![CDATA[$discovery['authorization_endpoint']]]></code>
<code><![CDATA[$discovery['token_endpoint']]]></code>
<code><![CDATA[$discovery['token_endpoint']]]></code>
<code><![CDATA[$statusData['auth_mode']]]></code>
<code><![CDATA[$statusData['oidc']]]></code>
<code><![CDATA[$statusData['oidc']]]></code>
<code><![CDATA[$statusData['oidc']]]></code>
<code><![CDATA[$statusData['oidc']['discovery_url']]]></code>
<code><![CDATA[$statusData['oidc']['discovery_url']]]></code>
<code><![CDATA[$statusData['oidc']['discovery_url']]]></code>
</MixedArrayAccess>
<MixedAssignment>
<code><![CDATA[$authEndpoint]]></code>
<code><![CDATA[$clientSecret]]></code>
<code><![CDATA[$clientSecret]]></code>
<code><![CDATA[$codeVerifier]]></code>
<code><![CDATA[$discovery]]></code>
<code><![CDATA[$discovery]]></code>
<code><![CDATA[$discoveryUrl]]></code>
<code><![CDATA[$discoveryUrl]]></code>
<code><![CDATA[$mcpServerPublicUrl]]></code>
<code><![CDATA[$mcpServerUrl]]></code>
<code><![CDATA[$mcpServerUrl]]></code>
<code><![CDATA[$postData['client_secret']]]></code>
<code><![CDATA[$statusData]]></code>
<code><![CDATA[$statusData]]></code>
<code><![CDATA[$storedState]]></code>
<code><![CDATA[$tokenData]]></code>
<code><![CDATA[$tokenEndpoint]]></code>
<code><![CDATA[$userId]]></code>
</MixedAssignment>
<MixedInferredReturnType>
<code><![CDATA[array]]></code>
</MixedInferredReturnType>
<MixedOperand>
<code><![CDATA[$authEndpoint]]></code>
<code><![CDATA[$tokenData['expires_in'] ?? 3600]]></code>
</MixedOperand>
<MixedReturnStatement>
<code><![CDATA[$tokenData]]></code>
</MixedReturnStatement>
<PossiblyInvalidArgument>
<code><![CDATA[$response->getBody()]]></code>
<code><![CDATA[$response->getBody()]]></code>
<code><![CDATA[$responseBody]]></code>
<code><![CDATA[$responseBody]]></code>
<code><![CDATA[$statusResponse->getBody()]]></code>
<code><![CDATA[$statusResponse->getBody()]]></code>
</PossiblyInvalidArgument>
<RiskyTruthyFalsyComparison>
<code><![CDATA[$error]]></code>
</RiskyTruthyFalsyComparison>
<UnusedClass>
<code><![CDATA[OAuthController]]></code>
</UnusedClass>
</file>
<file src="lib/Listener/AstrolabeAdminSettingsListener.php">
<MixedAssignment>
<code><![CDATA[$value]]></code>
<code><![CDATA[$value]]></code>
</MixedAssignment>
<PossiblyUnusedMethod>
<code><![CDATA[__construct]]></code>
</PossiblyUnusedMethod>
<RedundantCondition>
<code><![CDATA[$event instanceof DeclarativeSettingsSetValueEvent]]></code>
</RedundantCondition>
</file>
<file src="lib/Search/SemanticSearchProvider.php">
<DeprecatedMethod>
<code><![CDATA[getAppValue]]></code>
<code><![CDATA[getAppValue]]></code>
<code><![CDATA[getAppValue]]></code>
<code><![CDATA[getAppValue]]></code>
</DeprecatedMethod>
<MixedArgument>
<code><![CDATA[$chunkNum]]></code>
<code><![CDATA[$docType]]></code>
<code><![CDATA[$mimeType]]></code>
<code><![CDATA[$result['page_count']]]></code>
<code><![CDATA[$result['page_number']]]></code>
<code><![CDATA[$result['total_chunks']]]></code>
<code><![CDATA[$title]]></code>
</MixedArgument>
<MixedAssignment>
<code><![CDATA[$chunkEnd]]></code>
<code><![CDATA[$chunkNum]]></code>
<code><![CDATA[$chunkStart]]></code>
<code><![CDATA[$docType]]></code>
<code><![CDATA[$docType]]></code>
<code><![CDATA[$id]]></code>
<code><![CDATA[$mimeType]]></code>
<code><![CDATA[$params['board_id']]]></code>
<code><![CDATA[$params['page_number']]]></code>
<code><![CDATA[$params['path']]]></code>
<code><![CDATA[$params['title']]]></code>
<code><![CDATA[$score]]></code>
<code><![CDATA[$title]]></code>
</MixedAssignment>
<MixedOperand>
<code><![CDATA[$result['chunk_index']]]></code>
<code><![CDATA[$score]]></code>
</MixedOperand>
<PossiblyUnusedMethod>
<code><![CDATA[__construct]]></code>
</PossiblyUnusedMethod>
<RiskyTruthyFalsyComparison>
<code><![CDATA[$cursor]]></code>
<code><![CDATA[empty($results['error'])]]></code>
<code><![CDATA[empty($status['error'])]]></code>
</RiskyTruthyFalsyComparison>
</file>
<file src="lib/Service/IdpTokenRefresher.php">
<MixedArgument>
<code><![CDATA[$discoveryUrl]]></code>
<code><![CDATA[$tokenData]]></code>
<code><![CDATA[$tokenEndpoint]]></code>
</MixedArgument>
<MixedArrayAccess>
<code><![CDATA[$discovery['token_endpoint']]]></code>
<code><![CDATA[$statusData['oidc']]]></code>
<code><![CDATA[$statusData['oidc']['discovery_url']]]></code>
</MixedArrayAccess>
<MixedAssignment>
<code><![CDATA[$clientSecret]]></code>
<code><![CDATA[$discovery]]></code>
<code><![CDATA[$discoveryUrl]]></code>
<code><![CDATA[$internalUrl]]></code>
<code><![CDATA[$mcpServerUrl]]></code>
<code><![CDATA[$statusData]]></code>
<code><![CDATA[$tokenData]]></code>
<code><![CDATA[$tokenEndpoint]]></code>
</MixedAssignment>
<MixedInferredReturnType>
<code><![CDATA[array|null]]></code>
</MixedInferredReturnType>
<MixedOperand>
<code><![CDATA[$mcpServerUrl]]></code>
</MixedOperand>
<MixedReturnStatement>
<code><![CDATA[$tokenData]]></code>
</MixedReturnStatement>
<PossiblyInvalidArgument>
<code><![CDATA[$discoveryResponse->getBody()]]></code>
<code><![CDATA[$response->getBody()]]></code>
<code><![CDATA[$statusResponse->getBody()]]></code>
</PossiblyInvalidArgument>
<PossiblyUnusedMethod>
<code><![CDATA[__construct]]></code>
</PossiblyUnusedMethod>
</file>
<file src="lib/Service/McpServerClient.php">
<MixedArgument>
<code><![CDATA[$clientId]]></code>
</MixedArgument>
<MixedAssignment>
<code><![CDATA[$baseUrl]]></code>
<code><![CDATA[$clientId]]></code>
<code><![CDATA[$data]]></code>
<code><![CDATA[$data]]></code>
<code><![CDATA[$data]]></code>
<code><![CDATA[$data]]></code>
<code><![CDATA[$data]]></code>
<code><![CDATA[$data]]></code>
<code><![CDATA[$data]]></code>
<code><![CDATA[$data]]></code>
<code><![CDATA[$data]]></code>
<code><![CDATA[$data]]></code>
<code><![CDATA[$data]]></code>
</MixedAssignment>
<MixedInferredReturnType>
<code><![CDATA[array]]></code>
<code><![CDATA[array{
* apps?: array<string>,
* error?: string
* }]]></code>
<code><![CDATA[array{
* id?: int,
* event?: string,
* uri?: string,
* event_filter?: array,
* enabled?: bool,
* error?: string
* }]]></code>
<code><![CDATA[array{
* results?: array,
* pca_coordinates?: array,
* algorithm_used?: string,
* total_documents?: int,
* error?: string
* }]]></code>
<code><![CDATA[array{
* results?: array<array{
* id?: string|int,
* title?: string,
* doc_type?: string,
* excerpt?: string,
* score?: float,
* path?: string,
* board_id?: int,
* card_id?: int
* }>,
* total_found?: int,
* algorithm_used?: string,
* error?: string
* }]]></code>
<code><![CDATA[array{
* session_id?: string,
* background_access_granted?: bool,
* background_access_details?: array,
* idp_profile?: array,
* error?: string
* }]]></code>
<code><![CDATA[array{
* status?: string,
* indexed_documents?: int,
* pending_documents?: int,
* last_sync_time?: string,
* documents_per_second?: float,
* errors_24h?: int,
* error?: string
* }]]></code>
<code><![CDATA[array{
* version?: string,
* auth_mode?: string,
* vector_sync_enabled?: bool,
* uptime_seconds?: int,
* management_api_version?: string,
* error?: string
* }]]></code>
<code><![CDATA[array{
* webhooks?: array<array{
* id?: int,
* event?: string,
* uri?: string,
* event_filter?: array,
* enabled?: bool
* }>,
* error?: string
* }]]></code>
<code><![CDATA[array{success?: bool, error?: string}]]></code>
<code><![CDATA[array{success?: bool, message?: string, error?: string}]]></code>
<code><![CDATA[string]]></code>
</MixedInferredReturnType>
<MixedReturnStatement>
<code><![CDATA[$data]]></code>
<code><![CDATA[$data]]></code>
<code><![CDATA[$data]]></code>
<code><![CDATA[$data]]></code>
<code><![CDATA[$data]]></code>
<code><![CDATA[$data]]></code>
<code><![CDATA[$data]]></code>
<code><![CDATA[$data]]></code>
<code><![CDATA[$data]]></code>
<code><![CDATA[$data]]></code>
<code><![CDATA[$data]]></code>
<code><![CDATA[$this->config->getSystemValue('mcp_server_public_url', $this->baseUrl)]]></code>
</MixedReturnStatement>
<PossiblyInvalidArgument>
<code><![CDATA[$response->getBody()]]></code>
<code><![CDATA[$response->getBody()]]></code>
<code><![CDATA[$response->getBody()]]></code>
<code><![CDATA[$response->getBody()]]></code>
<code><![CDATA[$response->getBody()]]></code>
<code><![CDATA[$response->getBody()]]></code>
<code><![CDATA[$response->getBody()]]></code>
<code><![CDATA[$response->getBody()]]></code>
<code><![CDATA[$response->getBody()]]></code>
<code><![CDATA[$response->getBody()]]></code>
<code><![CDATA[$response->getBody()]]></code>
</PossiblyInvalidArgument>
<PossiblyUnusedMethod>
<code><![CDATA[__construct]]></code>
<code><![CDATA[isServerReachable]]></code>
</PossiblyUnusedMethod>
</file>
<file src="lib/Service/McpTokenStorage.php">
<InvalidReturnStatement>
<code><![CDATA[$tokenData]]></code>
</InvalidReturnStatement>
<InvalidReturnType>
<code><![CDATA[array|null]]></code>
</InvalidReturnType>
<MixedArgument>
<code><![CDATA[$newTokenData['access_token']]]></code>
<code><![CDATA[$newTokenData['refresh_token'] ?? $token['refresh_token']]]></code>
<code><![CDATA[time() + ($newTokenData['expires_in'] ?? 3600)]]></code>
</MixedArgument>
<MixedAssignment>
<code><![CDATA[$newTokenData]]></code>
</MixedAssignment>
<MixedInferredReturnType>
<code><![CDATA[string|null]]></code>
</MixedInferredReturnType>
<MixedOperand>
<code><![CDATA[$newTokenData['expires_in'] ?? 3600]]></code>
<code><![CDATA[$token['expires_at']]]></code>
</MixedOperand>
<MixedReturnStatement>
<code><![CDATA[$newTokenData['access_token']]]></code>
<code><![CDATA[$token['access_token']]]></code>
</MixedReturnStatement>
<PossiblyUnusedMethod>
<code><![CDATA[__construct]]></code>
</PossiblyUnusedMethod>
<RiskyTruthyFalsyComparison>
<code><![CDATA[!$token]]></code>
<code><![CDATA[$refreshCallback]]></code>
</RiskyTruthyFalsyComparison>
</file>
<file src="lib/Service/WebhookPresets.php">
<MissingClosureParamType>
<code><![CDATA[$eventConfig]]></code>
</MissingClosureParamType>
<MissingClosureReturnType>
<code><![CDATA[fn ($eventConfig) => $eventConfig['event']]]></code>
</MissingClosureReturnType>
<MixedArgument>
<code><![CDATA[$preset['events']]]></code>
</MixedArgument>
<MixedArrayAccess>
<code><![CDATA[$eventConfig['event']]]></code>
</MixedArrayAccess>
<MixedReturnTypeCoercion>
<code><![CDATA[array<string>]]></code>
<code><![CDATA[array_map(
fn ($eventConfig) => $eventConfig['event'],
$preset['events']
)]]></code>
</MixedReturnTypeCoercion>
<PossiblyUnusedMethod>
<code><![CDATA[getPresetEvents]]></code>
</PossiblyUnusedMethod>
</file>
<file src="lib/Settings/Admin.php">
<DeprecatedMethod>
<code><![CDATA[getAppValue]]></code>
<code><![CDATA[getAppValue]]></code>
<code><![CDATA[getAppValue]]></code>
<code><![CDATA[getAppValue]]></code>
</DeprecatedMethod>
<MixedAssignment>
<code><![CDATA[$clientId]]></code>
<code><![CDATA[$clientSecret]]></code>
<code><![CDATA[$serverUrl]]></code>
</MixedAssignment>
<PossiblyUnusedMethod>
<code><![CDATA[__construct]]></code>
</PossiblyUnusedMethod>
<UnusedProperty>
<code><![CDATA[$client]]></code>
</UnusedProperty>
</file>
<file src="lib/Settings/AdminSection.php">
<UnusedClass>
<code><![CDATA[AdminSection]]></code>
</UnusedClass>
</file>
<file src="lib/Settings/AstrolabeAdminSettings.php">
<PossiblyUnusedMethod>
<code><![CDATA[__construct]]></code>
</PossiblyUnusedMethod>
</file>
<file src="lib/Settings/Personal.php">
<InvalidArrayOffset>
<code><![CDATA[$serverStatus['supports_app_passwords']]]></code>
</InvalidArrayOffset>
<MixedArgument>
<code><![CDATA[$accessToken]]></code>
</MixedArgument>
<MixedAssignment>
<code><![CDATA[$accessToken]]></code>
<code><![CDATA[$supportsAppPasswords]]></code>
</MixedAssignment>
<RiskyTruthyFalsyComparison>
<code><![CDATA[!$token]]></code>
<code><![CDATA[$supportsAppPasswords]]></code>
</RiskyTruthyFalsyComparison>
<UnusedClass>
<code><![CDATA[Personal]]></code>
</UnusedClass>
</file>
<file src="lib/Settings/PersonalSection.php">
<UnusedClass>
<code><![CDATA[PersonalSection]]></code>
</UnusedClass>
</file>
</files>
+1
View File
@@ -8,6 +8,7 @@
findUnusedBaselineEntry="true"
findUnusedCode="true"
phpVersion="8.1"
errorBaseline="psalm-baseline.xml"
>
<projectFiles>
<directory name="lib" />
+15 -17
View File
@@ -48,22 +48,21 @@
<div class="mcp-search-card">
<div class="mcp-search-row">
<NcTextField
:value="query"
v-model="query"
:label="t('astrolabe', 'Search query')"
:placeholder="t('astrolabe', 'Enter your search query...')"
class="mcp-search-input"
@update:value="query = $event"
@keyup.enter="performSearch" />
<NcSelect
v-model="selectedAlgorithmOption"
:model-value="selectedAlgorithmOption"
:options="algorithmOptions"
:placeholder="t('astrolabe', 'Algorithm')"
class="mcp-algorithm-select"
@input="algorithm = $event ? $event.id : 'hybrid'" />
@update:model-value="algorithm = $event ? $event.id : 'hybrid'" />
<NcButton
type="primary"
variant="primary"
:disabled="!query.trim() || loading"
@click="performSearch">
<template #icon>
@@ -75,7 +74,7 @@
<!-- Advanced Options Toggle -->
<NcButton
type="tertiary"
variant="tertiary"
class="mcp-advanced-toggle"
@click="showAdvanced = !showAdvanced">
<template #icon>
@@ -94,9 +93,9 @@
<NcCheckboxRadioSwitch
v-for="docType in docTypeOptions"
:key="docType.id"
:checked="selectedDocTypes.includes(docType.id)"
:model-value="selectedDocTypes.includes(docType.id)"
type="checkbox"
@update:checked="toggleDocType(docType.id, $event)">
@update:model-value="toggleDocType(docType.id, $event)">
{{ docType.label }}
</NcCheckboxRadioSwitch>
</div>
@@ -105,11 +104,10 @@
<div class="mcp-option-group">
<label>{{ t('astrolabe', 'Result Limit') }}</label>
<NcTextField
:value="limit"
v-model="limit"
type="number"
:min="1"
:max="100"
@update:value="limit = Number($event)" />
:max="100" />
</div>
<div class="mcp-option-group">
@@ -154,9 +152,9 @@
<div class="mcp-viz-header">
<h3>{{ t('astrolabe', 'Vector Space Visualization') }}</h3>
<NcCheckboxRadioSwitch
:checked="showQueryPoint"
:model-value="showQueryPoint"
type="switch"
@update:checked="showQueryPoint = $event; updatePlot()">
@update:model-value="showQueryPoint = $event; updatePlot()">
{{ t('astrolabe', 'Show query point') }}
</NcCheckboxRadioSwitch>
</div>
@@ -175,7 +173,7 @@
<span class="mcp-result-type">{{ result.doc_type || 'unknown' }}</span>
<div class="mcp-result-actions">
<NcButton
type="tertiary"
variant="tertiary"
:aria-label="t('astrolabe', 'Show Chunk')"
@click="viewChunk(result)">
<template #icon>
@@ -282,7 +280,7 @@
</div>
</div>
<NcButton type="secondary" :disabled="statusLoading" @click="loadVectorStatus">
<NcButton variant="secondary" :disabled="statusLoading" @click="loadVectorStatus">
<template #icon>
<Refresh :size="20" />
</template>
@@ -307,7 +305,7 @@
</a>
<span v-else>{{ viewerTitle }}</span>
</h3>
<NcButton type="tertiary" @click="closeViewer">
<NcButton variant="tertiary" @click="closeViewer">
<template #icon>
<Close :size="20" />
</template>
@@ -445,7 +443,7 @@ export default {
algorithm: 'hybrid',
showAdvanced: false,
selectedDocTypes: [],
limit: '20',
limit: 20,
scoreThreshold: 0,
loading: false,
error: null,
+24 -14
View File
@@ -6,7 +6,7 @@
<p><strong>{{ t('astrolabe', 'Cannot connect to MCP server') }}</strong></p>
<p>{{ error }}</p>
<p class="help-text">{{ t('astrolabe', 'Ensure MCP server is running and accessible. Check config.php for correct mcp_server_url.') }}</p>
<NcButton type="primary" @click="retryConnection">
<NcButton variant="primary" @click="retryConnection">
<template #icon>
<Refresh :size="20" />
</template>
@@ -58,7 +58,7 @@
<div class="metric-value">{{ formatNumber(vectorSyncStatus.documents_per_second, 1) }} docs/sec</div>
</div>
</div>
<NcButton type="secondary" @click="refreshStatus">
<NcButton variant="secondary" @click="refreshStatus">
<template #icon>
<Refresh :size="20" />
</template>
@@ -85,7 +85,7 @@
</p>
<p v-else>{{ webhooksError }}</p>
<div class="webhook-auth-actions">
<NcButton type="primary" @click="openPersonalSettings">
<NcButton variant="primary" @click="openPersonalSettings">
{{ t('astrolabe', 'Go to Personal Settings') }}
</NcButton>
</div>
@@ -113,7 +113,7 @@
</div>
<div class="preset-actions">
<NcButton
:type="preset.enabled ? 'secondary' : 'primary'"
:variant="preset.enabled ? 'secondary' : 'primary'"
:disabled="preset.toggling"
@click="toggleWebhookPreset(preset)">
{{ preset.toggling ? t('astrolabe', 'Please wait...') : (preset.enabled ? t('astrolabe', 'Disable') : t('astrolabe', 'Enable')) }}
@@ -152,19 +152,21 @@
<div class="settings-form">
<NcSelect
v-model="settings.algorithm"
:model-value="selectedAlgorithmOption"
:options="algorithmOptions"
:label="t('astrolabe', 'Search Algorithm')"
class="form-field" />
:input-label="t('astrolabe', 'Search Algorithm')"
class="form-field"
@update:model-value="settings.algorithm = $event ? $event.id : 'hybrid'" />
<p class="help-text">
{{ t('astrolabe', 'Hybrid combines semantic understanding with keyword matching. Semantic finds conceptually similar content. BM25 matches exact keywords.') }}
</p>
<NcSelect
v-model="settings.fusion"
:model-value="selectedFusionOption"
:options="fusionOptions"
:label="t('astrolabe', 'Fusion Method')"
class="form-field" />
:input-label="t('astrolabe', 'Fusion Method')"
class="form-field"
@update:model-value="settings.fusion = $event ? $event.id : 'rrf'" />
<p class="help-text">
{{ t('astrolabe', 'Only applies to hybrid search. RRF balances results well for most queries. DBSF may work better when keyword matches are over/under-weighted.') }}
</p>
@@ -184,20 +186,19 @@
</div>
<NcTextField
:value="settings.limit"
v-model="settings.limit"
:label="t('astrolabe', 'Maximum Results')"
type="number"
:min="5"
:max="100"
:step="5"
class="form-field"
@update:value="settings.limit = Number($event)" />
class="form-field" />
<p class="help-text">
{{ t('astrolabe', 'Maximum number of results to return per search query (5-100).') }}
</p>
<div class="form-actions">
<NcButton type="primary" :disabled="saving" @click="saveSettings">
<NcButton variant="primary" :disabled="saving" @click="saveSettings">
{{ saving ? t('astrolabe', 'Saving...') : t('astrolabe', 'Save Settings') }}
</NcButton>
</div>
@@ -276,6 +277,15 @@ const fusionOptions = computed(() => [
{ id: 'dbsf', label: t('astrolabe', 'DBSF - Distribution-Based Score Fusion') },
])
// Computed properties for NcSelect (converts between stored ID and option object)
const selectedAlgorithmOption = computed(() =>
algorithmOptions.value.find(opt => opt.id === settings.value.algorithm) || algorithmOptions.value[0],
)
const selectedFusionOption = computed(() =>
fusionOptions.value.find(opt => opt.id === settings.value.fusion) || fusionOptions.value[0],
)
// Methods
async function loadServerStatus() {
loading.value = true
+1 -1
View File
@@ -7,7 +7,7 @@
*/
script('astrolabe', 'astrolabe-adminSettings');
style('astrolabe', 'astrolabe-adminSettings');
style('astrolabe', 'astrolabe-main'); // All CSS bundled into main
?>
<div id="astrolabe-admin-settings" class="section">
+127 -42
View File
@@ -18,7 +18,7 @@
$urlGenerator = \OC::$server->getURLGenerator();
script('astrolabe', 'astrolabe-personalSettings');
style('astrolabe', 'astrolabe-personalSettings');
style('astrolabe', 'astrolabe-main'); // All CSS bundled into main
?>
<div class="section">
@@ -43,7 +43,17 @@ style('astrolabe', 'astrolabe-personalSettings');
<div class="section">
<h2><?php p($l->t('Background Sync Access')); ?></h2>
<?php if ($_['hasBackgroundAccess'] || $_['backgroundAccessGranted']): ?>
<?php
// Determine if hybrid mode (multi_user_basic + app passwords)
// In hybrid mode, user needs BOTH OAuth AND app password to be "fully configured"
$isHybridMode = ($_['authMode'] ?? '') === 'multi_user_basic' && !empty($_['supportsAppPasswords']);
$hasOAuthToken = !empty($_['hasOAuthToken']);
$hasBackgroundAccess = !empty($_['hasBackgroundAccess']) || !empty($_['backgroundAccessGranted']);
// In hybrid mode: both credentials required; otherwise just background access
$isFullyConfigured = $isHybridMode ? ($hasOAuthToken && $hasBackgroundAccess) : $hasBackgroundAccess;
?>
<?php if ($isFullyConfigured): ?>
<!-- Already configured -->
<div class="mcp-background-status">
<p>
@@ -110,54 +120,129 @@ style('astrolabe', 'astrolabe-personalSettings');
</div>
<?php else: ?>
<!-- Not configured - show provisioning options -->
<p class="mcp-help-text">
<?php p($l->t('Enable background sync to allow the MCP server to access your Nextcloud data for background operations like content indexing.')); ?>
</p>
<div class="mcp-grant-section">
<h4><?php p($l->t('Option 1: OAuth Refresh Token (Recommended for Future)')); ?></h4>
<?php if ($isHybridMode): ?>
<!-- Hybrid mode: User needs BOTH OAuth AND app password -->
<p class="mcp-help-text">
<?php p($l->t('When Nextcloud fully supports OAuth for app APIs. Currently waiting for upstream PR to merge.')); ?>
</p>
<a href="<?php p($_['serverUrl']); ?>/oauth/login?next=<?php p(urlencode($urlGenerator->getAbsoluteURL($urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])))); ?>" class="button">
<span class="icon icon-confirm"></span>
<?php p($l->t('Authorize via OAuth')); ?>
</a>
</div>
<div class="mcp-grant-section">
<h4><?php p($l->t('Option 2: App Password (Works Today - Recommended)')); ?></h4>
<p class="mcp-help-text">
<?php p($l->t('Generate an app password in Security settings and provide it below. This is the recommended interim solution.')); ?>
<?php p($l->t('To use semantic search, you need to complete two setup steps:')); ?>
</p>
<div class="mcp-app-password-steps">
<p><strong><?php p($l->t('Step 1:')); ?></strong>
<a href="<?php p($urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'security'])); ?>" target="_blank">
<?php p($l->t('Generate app password in Security settings')); ?>
<!-- Step 1: OAuth Authorization (for Astrolabe→MCP API calls) -->
<div class="mcp-grant-section">
<h4>
<?php if (!empty($_['hasOAuthToken'])): ?>
<span class="badge badge-success"><span class="icon icon-checkmark-white"></span> <?php p($l->t('Complete')); ?></span>
<?php else: ?>
<span class="badge badge-warning"><?php p($l->t('Required')); ?></span>
<?php endif; ?>
<?php p($l->t('Step 1: Authorize Search Access')); ?>
</h4>
<p class="mcp-help-text">
<?php p($l->t('Authorize Astrolabe to perform searches on your behalf.')); ?>
</p>
<?php if (empty($_['hasOAuthToken'])): ?>
<a href="<?php p($_['oauthUrl']); ?>" class="button primary">
<span class="icon icon-confirm"></span>
<?php p($l->t('Authorize')); ?>
</a>
<?php else: ?>
<p><span class="icon icon-checkmark"></span> <?php p($l->t('Search access authorized.')); ?></p>
<?php endif; ?>
</div>
<!-- Step 2: App Password (for MCP→Nextcloud background sync) -->
<div class="mcp-grant-section">
<h4>
<?php if (!empty($_['hasBackgroundAccess'])): ?>
<span class="badge badge-success"><span class="icon icon-checkmark-white"></span> <?php p($l->t('Complete')); ?></span>
<?php else: ?>
<span class="badge badge-warning"><?php p($l->t('Required')); ?></span>
<?php endif; ?>
<?php p($l->t('Step 2: Enable Background Indexing')); ?>
</h4>
<p class="mcp-help-text">
<?php p($l->t('Provide an app password to allow background indexing of your content.')); ?>
</p>
<?php if (empty($_['hasBackgroundAccess'])): ?>
<div class="mcp-app-password-steps">
<p>
<a href="<?php p($urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'security'])); ?>" target="_blank">
<?php p($l->t('Generate app password in Security settings')); ?>
</a>
</p>
<form method="post" action="<?php p($urlGenerator->linkToRoute('astrolabe.credentials.storeAppPassword')); ?>" id="mcp-app-password-form">
<input type="hidden" name="requesttoken" value="<?php p($_['requesttoken']); ?>">
<div class="mcp-input-group">
<input type="password" name="appPassword" id="mcp-app-password-input"
placeholder="xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
pattern="[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}"
required>
<button type="submit" class="button primary" id="mcp-save-app-password-button">
<span class="icon icon-checkmark"></span>
<?php p($l->t('Save')); ?>
</button>
</div>
<p class="mcp-help-text">
<?php p($l->t('The app password will be validated and securely encrypted before storage.')); ?>
</p>
</form>
</div>
<?php else: ?>
<p><span class="icon icon-checkmark"></span> <?php p($l->t('Background indexing enabled.')); ?></p>
<?php endif; ?>
</div>
<?php else: ?>
<!-- Standard OAuth or BasicAuth mode -->
<p class="mcp-help-text">
<?php p($l->t('Enable background sync to allow the MCP server to access your Nextcloud data for background operations like content indexing.')); ?>
</p>
<div class="mcp-grant-section">
<h4><?php p($l->t('Option 1: OAuth Refresh Token (Recommended for Future)')); ?></h4>
<p class="mcp-help-text">
<?php p($l->t('When Nextcloud fully supports OAuth for app APIs. Currently waiting for upstream PR to merge.')); ?>
</p>
<a href="<?php p($_['oauthUrl']); ?>" class="button">
<span class="icon icon-confirm"></span>
<?php p($l->t('Authorize via OAuth')); ?>
</a>
</div>
<div class="mcp-grant-section">
<h4><?php p($l->t('Option 2: App Password (Works Today - Recommended)')); ?></h4>
<p class="mcp-help-text">
<?php p($l->t('Generate an app password in Security settings and provide it below. This is the recommended interim solution.')); ?>
</p>
<p><strong><?php p($l->t('Step 2:')); ?></strong> <?php p($l->t('Enter the app password below:')); ?></p>
<form method="post" action="<?php p($urlGenerator->linkToRoute('astrolabe.credentials.storeAppPassword')); ?>" id="mcp-app-password-form">
<input type="hidden" name="requesttoken" value="<?php p($_['requesttoken']); ?>">
<div class="mcp-input-group">
<input type="password" name="appPassword" id="mcp-app-password-input"
placeholder="xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
pattern="[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}"
required>
<button type="submit" class="button primary" id="mcp-save-app-password-button">
<span class="icon icon-checkmark"></span>
<?php p($l->t('Save')); ?>
</button>
</div>
<p class="mcp-help-text">
<?php p($l->t('The app password will be validated and securely encrypted before storage.')); ?>
<div class="mcp-app-password-steps">
<p><strong><?php p($l->t('Step 1:')); ?></strong>
<a href="<?php p($urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'security'])); ?>" target="_blank">
<?php p($l->t('Generate app password in Security settings')); ?>
</a>
</p>
</form>
<p><strong><?php p($l->t('Step 2:')); ?></strong> <?php p($l->t('Enter the app password below:')); ?></p>
<form method="post" action="<?php p($urlGenerator->linkToRoute('astrolabe.credentials.storeAppPassword')); ?>" id="mcp-app-password-form">
<input type="hidden" name="requesttoken" value="<?php p($_['requesttoken']); ?>">
<div class="mcp-input-group">
<input type="password" name="appPassword" id="mcp-app-password-input"
placeholder="xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
pattern="[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}"
required>
<button type="submit" class="button primary" id="mcp-save-app-password-button">
<span class="icon icon-checkmark"></span>
<?php p($l->t('Save')); ?>
</button>
</div>
<p class="mcp-help-text">
<?php p($l->t('The app password will be validated and securely encrypted before storage.')); ?>
</p>
</form>
</div>
</div>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
-19
View File
@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace Controller;
use OCA\Astrolabe\AppInfo\Application;
use OCA\Astrolabe\Controller\ApiController;
use OCP\IRequest;
use PHPUnit\Framework\TestCase;
final class ApiTest extends TestCase {
public function testIndex(): void {
$request = $this->createMock(IRequest::class);
$controller = new ApiController(Application::APP_ID, $request);
$this->assertEquals($controller->index()->getData()['message'], 'Hello world!');
}
}
@@ -0,0 +1,429 @@
<?php
declare(strict_types=1);
namespace OCA\Astrolabe\Tests\Unit\Service;
use OCA\Astrolabe\Service\IdpTokenRefresher;
use OCA\Astrolabe\Service\McpServerClient;
use OCP\Http\Client\IClient;
use OCP\Http\Client\IClientService;
use OCP\Http\Client\IResponse;
use OCP\IConfig;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
/**
* Unit tests for IdpTokenRefresher.
*
* Tests the internal URL resolution logic and token refresh flows.
*/
final class IdpTokenRefresherTest extends TestCase {
private IConfig&MockObject $config;
private IClientService&MockObject $clientService;
private IClient&MockObject $httpClient;
private LoggerInterface&MockObject $logger;
private McpServerClient&MockObject $mcpServerClient;
private IdpTokenRefresher $refresher;
protected function setUp(): void {
parent::setUp();
$this->config = $this->createMock(IConfig::class);
$this->clientService = $this->createMock(IClientService::class);
$this->httpClient = $this->createMock(IClient::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->mcpServerClient = $this->createMock(McpServerClient::class);
$this->clientService->method('newClient')->willReturn($this->httpClient);
$this->refresher = new IdpTokenRefresher(
$this->config,
$this->clientService,
$this->logger,
$this->mcpServerClient
);
}
// =========================================================================
// getNextcloudBaseUrl() tests
// =========================================================================
/**
* @dataProvider provideBaseUrlTestCases
*/
public function testGetNextcloudBaseUrl(string $configValue, string $expected): void {
$this->config->method('getSystemValue')
->with('astrolabe_internal_url', '')
->willReturn($configValue);
// Use reflection to test private method
$reflection = new \ReflectionClass($this->refresher);
$method = $reflection->getMethod('getNextcloudBaseUrl');
$method->setAccessible(true);
$result = $method->invoke($this->refresher);
$this->assertEquals($expected, $result);
}
/**
* Provides test cases for getNextcloudBaseUrl().
*
* @return array<string, array{string, string}>
*/
public static function provideBaseUrlTestCases(): array {
return [
'default - no config' => ['', 'http://localhost'],
'custom internal url' => ['http://web:8080', 'http://web:8080'],
'custom url with trailing slash' => ['http://web:8080/', 'http://web:8080'],
'kubernetes service' => ['http://nextcloud.default.svc:80', 'http://nextcloud.default.svc:80'],
'https internal url' => ['https://internal.example.com', 'https://internal.example.com'],
];
}
// =========================================================================
// refreshAccessToken() tests
// =========================================================================
public function testRefreshAccessTokenFailsWithoutClientSecret(): void {
$this->config->method('getSystemValue')
->willReturnMap([
['astrolabe_client_secret', '', ''],
]);
$this->logger->expects($this->once())
->method('warning')
->with($this->stringContains('no client secret configured'));
$result = $this->refresher->refreshAccessToken('test-refresh-token');
$this->assertNull($result);
}
public function testRefreshAccessTokenFailsWithoutMcpServerUrl(): void {
$this->config->method('getSystemValue')
->willReturnMap([
['astrolabe_client_secret', '', 'test-secret'],
['mcp_server_url', '', ''],
]);
$this->logger->expects($this->once())
->method('error')
->with(
$this->stringContains('Token refresh failed'),
$this->callback(fn ($ctx) => str_contains($ctx['error'], 'MCP server URL not configured'))
);
$result = $this->refresher->refreshAccessToken('test-refresh-token');
$this->assertNull($result);
}
public function testRefreshAccessTokenWithInternalNextcloudOidc(): void {
// Setup config
$this->config->method('getSystemValue')
->willReturnMap([
['astrolabe_client_secret', '', 'test-secret'],
['mcp_server_url', '', 'http://mcp-server:8000'],
['astrolabe_internal_url', '', ''],
]);
$this->mcpServerClient->method('getClientId')
->willReturn('test-client-id');
// Mock MCP server status response (no external IdP configured)
$statusResponse = $this->createMock(IResponse::class);
$statusResponse->method('getBody')
->willReturn(json_encode([
'version' => '1.0.0',
'auth_mode' => 'multi_user_oauth',
// No 'oidc.discovery_url' = use internal Nextcloud OIDC
]));
// Mock token endpoint response
$tokenResponse = $this->createMock(IResponse::class);
$tokenResponse->method('getBody')
->willReturn(json_encode([
'access_token' => 'new-access-token',
'refresh_token' => 'new-refresh-token',
'expires_in' => 3600,
'token_type' => 'Bearer',
]));
// Setup HTTP client to return appropriate responses
$this->httpClient->method('get')
->with('http://mcp-server:8000/api/v1/status')
->willReturn($statusResponse);
$this->httpClient->method('post')
->with(
'http://localhost/apps/oidc/token',
$this->callback(function ($options) {
// Verify the POST body contains expected parameters
$body = $options['body'] ?? '';
return str_contains($body, 'grant_type=refresh_token')
&& str_contains($body, 'client_id=test-client-id')
&& str_contains($body, 'client_secret=test-secret')
&& str_contains($body, 'refresh_token=test-refresh-token');
})
)
->willReturn($tokenResponse);
$result = $this->refresher->refreshAccessToken('test-refresh-token');
$this->assertNotNull($result);
$this->assertEquals('new-access-token', $result['access_token']);
$this->assertEquals('new-refresh-token', $result['refresh_token']);
$this->assertEquals(3600, $result['expires_in']);
}
public function testRefreshAccessTokenWithExternalIdp(): void {
// Setup config
$this->config->method('getSystemValue')
->willReturnMap([
['astrolabe_client_secret', '', 'test-secret'],
['mcp_server_url', '', 'http://mcp-server:8000'],
]);
$this->mcpServerClient->method('getClientId')
->willReturn('test-client-id');
// Mock MCP server status response (external IdP configured)
$statusResponse = $this->createMock(IResponse::class);
$statusResponse->method('getBody')
->willReturn(json_encode([
'version' => '1.0.0',
'auth_mode' => 'multi_user_oauth',
'oidc' => [
'discovery_url' => 'https://keycloak.example.com/realms/test/.well-known/openid-configuration',
],
]));
// Mock OIDC discovery response
$discoveryResponse = $this->createMock(IResponse::class);
$discoveryResponse->method('getBody')
->willReturn(json_encode([
'issuer' => 'https://keycloak.example.com/realms/test',
'token_endpoint' => 'https://keycloak.example.com/realms/test/protocol/openid-connect/token',
'authorization_endpoint' => 'https://keycloak.example.com/realms/test/protocol/openid-connect/auth',
]));
// Mock token endpoint response
$tokenResponse = $this->createMock(IResponse::class);
$tokenResponse->method('getBody')
->willReturn(json_encode([
'access_token' => 'keycloak-access-token',
'refresh_token' => 'keycloak-refresh-token',
'expires_in' => 300,
'token_type' => 'Bearer',
]));
// Setup HTTP client calls in order
$this->httpClient->method('get')
->willReturnCallback(function ($url) use ($statusResponse, $discoveryResponse) {
if (str_contains($url, 'status')) {
return $statusResponse;
}
if (str_contains($url, '.well-known/openid-configuration')) {
return $discoveryResponse;
}
throw new \Exception("Unexpected URL: $url");
});
$this->httpClient->method('post')
->with(
'https://keycloak.example.com/realms/test/protocol/openid-connect/token',
$this->anything()
)
->willReturn($tokenResponse);
$result = $this->refresher->refreshAccessToken('test-refresh-token');
$this->assertNotNull($result);
$this->assertEquals('keycloak-access-token', $result['access_token']);
$this->assertEquals('keycloak-refresh-token', $result['refresh_token']);
$this->assertEquals(300, $result['expires_in']);
}
public function testRefreshAccessTokenFailsOnMissingRefreshTokenInResponse(): void {
// Setup config
$this->config->method('getSystemValue')
->willReturnMap([
['astrolabe_client_secret', '', 'test-secret'],
['mcp_server_url', '', 'http://mcp-server:8000'],
['astrolabe_internal_url', '', ''],
]);
$this->mcpServerClient->method('getClientId')
->willReturn('test-client-id');
// Mock MCP server status response
$statusResponse = $this->createMock(IResponse::class);
$statusResponse->method('getBody')
->willReturn(json_encode(['version' => '1.0.0']));
// Mock token response WITHOUT refresh_token (token rotation failure)
$tokenResponse = $this->createMock(IResponse::class);
$tokenResponse->method('getBody')
->willReturn(json_encode([
'access_token' => 'new-access-token',
// Missing refresh_token!
'expires_in' => 3600,
]));
$this->httpClient->method('get')->willReturn($statusResponse);
$this->httpClient->method('post')->willReturn($tokenResponse);
$this->logger->expects($this->once())
->method('error')
->with(
$this->stringContains('No refresh token in response'),
$this->anything()
);
$result = $this->refresher->refreshAccessToken('test-refresh-token');
$this->assertNull($result);
}
public function testRefreshAccessTokenHandlesHttpException(): void {
// Setup config
$this->config->method('getSystemValue')
->willReturnMap([
['astrolabe_client_secret', '', 'test-secret'],
['mcp_server_url', '', 'http://mcp-server:8000'],
]);
// HTTP client throws exception
$this->httpClient->method('get')
->willThrowException(new \Exception('Connection refused'));
$this->logger->expects($this->once())
->method('error')
->with(
$this->stringContains('Token refresh failed'),
$this->callback(fn ($ctx) => str_contains($ctx['error'], 'Connection refused'))
);
$result = $this->refresher->refreshAccessToken('test-refresh-token');
$this->assertNull($result);
}
public function testRefreshAccessTokenHandlesInvalidStatusResponse(): void {
// Setup config
$this->config->method('getSystemValue')
->willReturnMap([
['astrolabe_client_secret', '', 'test-secret'],
['mcp_server_url', '', 'http://mcp-server:8000'],
]);
// Mock invalid JSON response
$statusResponse = $this->createMock(IResponse::class);
$statusResponse->method('getBody')
->willReturn('not valid json');
$this->httpClient->method('get')->willReturn($statusResponse);
$this->logger->expects($this->once())
->method('error')
->with(
$this->stringContains('Token refresh failed'),
$this->callback(fn ($ctx) => str_contains($ctx['error'], 'Invalid status response'))
);
$result = $this->refresher->refreshAccessToken('test-refresh-token');
$this->assertNull($result);
}
public function testRefreshAccessTokenHandlesInvalidDiscoveryResponse(): void {
// Setup config
$this->config->method('getSystemValue')
->willReturnMap([
['astrolabe_client_secret', '', 'test-secret'],
['mcp_server_url', '', 'http://mcp-server:8000'],
]);
$this->mcpServerClient->method('getClientId')
->willReturn('test-client-id');
// Mock MCP server status response with external IdP
$statusResponse = $this->createMock(IResponse::class);
$statusResponse->method('getBody')
->willReturn(json_encode([
'oidc' => [
'discovery_url' => 'https://keycloak.example.com/.well-known/openid-configuration',
],
]));
// Mock invalid discovery response (missing token_endpoint)
$discoveryResponse = $this->createMock(IResponse::class);
$discoveryResponse->method('getBody')
->willReturn(json_encode([
'issuer' => 'https://keycloak.example.com',
// Missing token_endpoint!
]));
$this->httpClient->method('get')
->willReturnCallback(function ($url) use ($statusResponse, $discoveryResponse) {
if (str_contains($url, 'status')) {
return $statusResponse;
}
return $discoveryResponse;
});
$this->logger->expects($this->once())
->method('error')
->with(
$this->stringContains('Token refresh failed'),
$this->callback(fn ($ctx) => str_contains($ctx['error'], 'Invalid OIDC discovery response'))
);
$result = $this->refresher->refreshAccessToken('test-refresh-token');
$this->assertNull($result);
}
public function testRefreshAccessTokenHandlesInvalidTokenResponse(): void {
// Setup config
$this->config->method('getSystemValue')
->willReturnMap([
['astrolabe_client_secret', '', 'test-secret'],
['mcp_server_url', '', 'http://mcp-server:8000'],
['astrolabe_internal_url', '', ''],
]);
$this->mcpServerClient->method('getClientId')
->willReturn('test-client-id');
// Mock MCP server status response
$statusResponse = $this->createMock(IResponse::class);
$statusResponse->method('getBody')
->willReturn(json_encode(['version' => '1.0.0']));
// Mock token response without access_token
$tokenResponse = $this->createMock(IResponse::class);
$tokenResponse->method('getBody')
->willReturn(json_encode([
'error' => 'invalid_grant',
'error_description' => 'Refresh token expired',
]));
$this->httpClient->method('get')->willReturn($statusResponse);
$this->httpClient->method('post')->willReturn($tokenResponse);
$this->logger->expects($this->once())
->method('error')
->with(
$this->stringContains('Token refresh failed'),
$this->callback(fn ($ctx) => str_contains($ctx['error'], 'Invalid token response'))
);
$result = $this->refresher->refreshAccessToken('test-refresh-token');
$this->assertNull($result);
}
}
@@ -0,0 +1,527 @@
<?php
declare(strict_types=1);
namespace OCA\Astrolabe\Tests\Unit\Service;
use OCA\Astrolabe\Service\McpTokenStorage;
use OCP\IConfig;
use OCP\Security\ICrypto;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
/**
* Unit tests for McpTokenStorage.
*
* Tests OAuth token storage and app password functionality for multi-user basic auth.
*/
final class McpTokenStorageTest extends TestCase {
private IConfig&MockObject $config;
private ICrypto&MockObject $crypto;
private LoggerInterface&MockObject $logger;
private McpTokenStorage $storage;
protected function setUp(): void {
parent::setUp();
$this->config = $this->createMock(IConfig::class);
$this->crypto = $this->createMock(ICrypto::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->storage = new McpTokenStorage(
$this->config,
$this->crypto,
$this->logger
);
}
// =========================================================================
// OAuth Token Storage Tests
// =========================================================================
public function testStoreUserToken(): void {
$userId = 'testuser';
$accessToken = 'access-token-123';
$refreshToken = 'refresh-token-456';
$expiresAt = time() + 3600;
$expectedTokenData = [
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'expires_at' => $expiresAt,
];
$this->crypto->expects($this->once())
->method('encrypt')
->with(json_encode($expectedTokenData))
->willReturn('encrypted-data');
$this->config->expects($this->once())
->method('setUserValue')
->with($userId, 'astrolabe', 'oauth_tokens', 'encrypted-data');
$this->storage->storeUserToken($userId, $accessToken, $refreshToken, $expiresAt);
}
public function testGetUserTokenReturnsTokenData(): void {
$userId = 'testuser';
$tokenData = [
'access_token' => 'access-token-123',
'refresh_token' => 'refresh-token-456',
'expires_at' => time() + 3600,
];
$this->config->method('getUserValue')
->with($userId, 'astrolabe', 'oauth_tokens', '')
->willReturn('encrypted-data');
$this->crypto->method('decrypt')
->with('encrypted-data')
->willReturn(json_encode($tokenData));
$result = $this->storage->getUserToken($userId);
$this->assertEquals($tokenData, $result);
}
public function testGetUserTokenReturnsNullWhenNoTokenStored(): void {
$userId = 'testuser';
$this->config->method('getUserValue')
->with($userId, 'astrolabe', 'oauth_tokens', '')
->willReturn('');
$result = $this->storage->getUserToken($userId);
$this->assertNull($result);
}
public function testGetUserTokenReturnsNullOnDecryptionFailure(): void {
$userId = 'testuser';
$this->config->method('getUserValue')
->willReturn('encrypted-data');
$this->crypto->method('decrypt')
->willThrowException(new \Exception('Decryption failed'));
$result = $this->storage->getUserToken($userId);
$this->assertNull($result);
}
public function testDeleteUserToken(): void {
$userId = 'testuser';
$this->config->expects($this->once())
->method('deleteUserValue')
->with($userId, 'astrolabe', 'oauth_tokens');
$this->storage->deleteUserToken($userId);
}
// =========================================================================
// Token Expiration Tests
// =========================================================================
public function testIsExpiredReturnsTrueWhenNoExpiresAt(): void {
$token = ['access_token' => 'test'];
$this->assertTrue($this->storage->isExpired($token));
}
public function testIsExpiredReturnsTrueWhenExpired(): void {
$token = [
'access_token' => 'test',
'expires_at' => time() - 100, // Expired 100 seconds ago
];
$this->assertTrue($this->storage->isExpired($token));
}
public function testIsExpiredReturnsTrueWhenAboutToExpire(): void {
$token = [
'access_token' => 'test',
'expires_at' => time() + 30, // Expires in 30 seconds (within 60s buffer)
];
$this->assertTrue($this->storage->isExpired($token));
}
public function testIsExpiredReturnsFalseWhenValid(): void {
$token = [
'access_token' => 'test',
'expires_at' => time() + 3600, // Expires in 1 hour
];
$this->assertFalse($this->storage->isExpired($token));
}
// =========================================================================
// getAccessToken with Refresh Callback Tests
// =========================================================================
public function testGetAccessTokenReturnsNullWhenNoToken(): void {
$userId = 'testuser';
$this->config->method('getUserValue')
->willReturn('');
$result = $this->storage->getAccessToken($userId);
$this->assertNull($result);
}
public function testGetAccessTokenReturnsTokenWhenValid(): void {
$userId = 'testuser';
$tokenData = [
'access_token' => 'valid-access-token',
'refresh_token' => 'refresh-token',
'expires_at' => time() + 3600, // Valid for 1 hour
];
$this->config->method('getUserValue')
->willReturn('encrypted-data');
$this->crypto->method('decrypt')
->willReturn(json_encode($tokenData));
$result = $this->storage->getAccessToken($userId);
$this->assertEquals('valid-access-token', $result);
}
public function testGetAccessTokenRefreshesExpiredToken(): void {
$userId = 'testuser';
$expiredTokenData = [
'access_token' => 'expired-access-token',
'refresh_token' => 'old-refresh-token',
'expires_at' => time() - 100, // Expired
];
$newTokenData = [
'access_token' => 'new-access-token',
'refresh_token' => 'new-refresh-token',
'expires_in' => 3600,
];
// First call returns expired token, subsequent calls for storing new token
$this->config->method('getUserValue')
->willReturn('encrypted-data');
$this->crypto->method('decrypt')
->willReturn(json_encode($expiredTokenData));
// Encrypt is called when storing the new token
$this->crypto->method('encrypt')
->willReturn('new-encrypted-data');
$this->config->expects($this->once())
->method('setUserValue')
->with($userId, 'astrolabe', 'oauth_tokens', 'new-encrypted-data');
// Refresh callback
$refreshCallback = function (string $refreshToken) use ($newTokenData) {
$this->assertEquals('old-refresh-token', $refreshToken);
return $newTokenData;
};
$result = $this->storage->getAccessToken($userId, $refreshCallback);
$this->assertEquals('new-access-token', $result);
}
public function testGetAccessTokenReturnsNullWhenRefreshFailsAndDeletesToken(): void {
$userId = 'testuser';
$expiredTokenData = [
'access_token' => 'expired-access-token',
'refresh_token' => 'old-refresh-token',
'expires_at' => time() - 100, // Expired
];
$this->config->method('getUserValue')
->willReturn('encrypted-data');
$this->crypto->method('decrypt')
->willReturn(json_encode($expiredTokenData));
// Expect stale token to be deleted when refresh fails
$this->config->expects($this->once())
->method('deleteUserValue')
->with($userId, 'astrolabe', 'oauth_tokens');
// Refresh callback returns null (failure)
$refreshCallback = fn (string $refreshToken) => null;
$result = $this->storage->getAccessToken($userId, $refreshCallback);
$this->assertNull($result);
}
public function testGetAccessTokenReturnsNullWhenExpiredAndNoCallbackAndDeletesToken(): void {
$userId = 'testuser';
$expiredTokenData = [
'access_token' => 'expired-access-token',
'refresh_token' => 'old-refresh-token',
'expires_at' => time() - 100, // Expired
];
$this->config->method('getUserValue')
->willReturn('encrypted-data');
$this->crypto->method('decrypt')
->willReturn(json_encode($expiredTokenData));
// Expect stale token to be deleted when expired with no callback
$this->config->expects($this->once())
->method('deleteUserValue')
->with($userId, 'astrolabe', 'oauth_tokens');
// No refresh callback provided
$result = $this->storage->getAccessToken($userId, null);
$this->assertNull($result);
}
// =========================================================================
// App Password Storage Tests (Multi-User Basic Auth)
// =========================================================================
public function testStoreBackgroundSyncPassword(): void {
$userId = 'testuser';
$appPassword = 'app-password-secret';
$this->crypto->expects($this->once())
->method('encrypt')
->with($appPassword)
->willReturn('encrypted-password');
// Expect three setUserValue calls: password, type, timestamp
$this->config->expects($this->exactly(3))
->method('setUserValue')
->willReturnCallback(function ($uid, $app, $key, $value) use ($userId) {
$this->assertEquals($userId, $uid);
$this->assertEquals('astrolabe', $app);
$this->assertContains($key, [
'background_sync_password',
'background_sync_type',
'background_sync_provisioned_at'
]);
return null;
});
$this->storage->storeBackgroundSyncPassword($userId, $appPassword);
}
public function testGetBackgroundSyncPasswordReturnsPassword(): void {
$userId = 'testuser';
$appPassword = 'app-password-secret';
$this->config->method('getUserValue')
->with($userId, 'astrolabe', 'background_sync_password', '')
->willReturn('encrypted-password');
$this->crypto->method('decrypt')
->with('encrypted-password')
->willReturn($appPassword);
$result = $this->storage->getBackgroundSyncPassword($userId);
$this->assertEquals($appPassword, $result);
}
public function testGetBackgroundSyncPasswordReturnsNullWhenNotSet(): void {
$userId = 'testuser';
$this->config->method('getUserValue')
->with($userId, 'astrolabe', 'background_sync_password', '')
->willReturn('');
$result = $this->storage->getBackgroundSyncPassword($userId);
$this->assertNull($result);
}
public function testGetBackgroundSyncPasswordReturnsNullOnDecryptionFailure(): void {
$userId = 'testuser';
$this->config->method('getUserValue')
->willReturn('encrypted-password');
$this->crypto->method('decrypt')
->willThrowException(new \Exception('Decryption failed'));
$result = $this->storage->getBackgroundSyncPassword($userId);
$this->assertNull($result);
}
public function testDeleteBackgroundSyncPassword(): void {
$userId = 'testuser';
// Expect three deleteUserValue calls
$this->config->expects($this->exactly(3))
->method('deleteUserValue')
->willReturnCallback(function ($uid, $app, $key) use ($userId) {
$this->assertEquals($userId, $uid);
$this->assertEquals('astrolabe', $app);
$this->assertContains($key, [
'background_sync_password',
'background_sync_type',
'background_sync_provisioned_at'
]);
return null;
});
$this->storage->deleteBackgroundSyncPassword($userId);
}
// =========================================================================
// Background Sync Access Check Tests
// =========================================================================
public function testHasBackgroundSyncAccessReturnsTrueWithOAuthToken(): void {
$userId = 'testuser';
$tokenData = [
'access_token' => 'access-token',
'refresh_token' => 'refresh-token',
'expires_at' => time() + 3600,
];
$this->config->method('getUserValue')
->willReturnCallback(function ($uid, $app, $key, $default) use ($tokenData) {
if ($key === 'oauth_tokens') {
return 'encrypted-oauth-data';
}
return $default;
});
$this->crypto->method('decrypt')
->willReturn(json_encode($tokenData));
$result = $this->storage->hasBackgroundSyncAccess($userId);
$this->assertTrue($result);
}
public function testHasBackgroundSyncAccessReturnsTrueWithAppPassword(): void {
$userId = 'testuser';
$this->config->method('getUserValue')
->willReturnCallback(function ($uid, $app, $key, $default) {
if ($key === 'oauth_tokens') {
return ''; // No OAuth tokens
}
if ($key === 'background_sync_password') {
return 'encrypted-password';
}
return $default;
});
$this->crypto->method('decrypt')
->willReturn('decrypted-app-password');
$result = $this->storage->hasBackgroundSyncAccess($userId);
$this->assertTrue($result);
}
public function testHasBackgroundSyncAccessReturnsFalseWithNeither(): void {
$userId = 'testuser';
$this->config->method('getUserValue')
->willReturn(''); // No tokens or passwords
$result = $this->storage->hasBackgroundSyncAccess($userId);
$this->assertFalse($result);
}
// =========================================================================
// Background Sync Type Tests
// =========================================================================
public function testGetBackgroundSyncTypeReturnsAppPassword(): void {
$userId = 'testuser';
$this->config->method('getUserValue')
->willReturnCallback(function ($uid, $app, $key, $default) {
if ($key === 'background_sync_type') {
return 'app_password';
}
return $default;
});
$result = $this->storage->getBackgroundSyncType($userId);
$this->assertEquals('app_password', $result);
}
public function testGetBackgroundSyncTypeFallsBackToOAuth(): void {
$userId = 'testuser';
$tokenData = [
'access_token' => 'access-token',
'refresh_token' => 'refresh-token',
'expires_at' => time() + 3600,
];
$this->config->method('getUserValue')
->willReturnCallback(function ($uid, $app, $key, $default) {
if ($key === 'background_sync_type') {
return ''; // Type not explicitly set
}
if ($key === 'oauth_tokens') {
return 'encrypted-oauth-data';
}
return $default;
});
$this->crypto->method('decrypt')
->willReturn(json_encode($tokenData));
$result = $this->storage->getBackgroundSyncType($userId);
$this->assertEquals('oauth', $result);
}
public function testGetBackgroundSyncTypeReturnsNullWhenNotProvisioned(): void {
$userId = 'testuser';
$this->config->method('getUserValue')
->willReturn('');
$result = $this->storage->getBackgroundSyncType($userId);
$this->assertNull($result);
}
// =========================================================================
// Background Sync Provisioned Timestamp Tests
// =========================================================================
public function testGetBackgroundSyncProvisionedAtReturnsTimestamp(): void {
$userId = 'testuser';
$timestamp = time();
$this->config->method('getUserValue')
->with($userId, 'astrolabe', 'background_sync_provisioned_at', '')
->willReturn((string)$timestamp);
$result = $this->storage->getBackgroundSyncProvisionedAt($userId);
$this->assertEquals($timestamp, $result);
}
public function testGetBackgroundSyncProvisionedAtReturnsNullWhenNotSet(): void {
$userId = 'testuser';
$this->config->method('getUserValue')
->with($userId, 'astrolabe', 'background_sync_provisioned_at', '')
->willReturn('');
$result = $this->storage->getBackgroundSyncProvisionedAt($userId);
$this->assertNull($result);
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
/**
* Bootstrap for unit tests.
*
* Unit tests use mocked dependencies and don't require a full Nextcloud
* environment. This bootstrap only loads the composer autoloader which
* includes the OCP interface definitions needed for mocking.
*/
require_once __DIR__ . '/../../vendor/autoload.php';
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
bootstrap="bootstrap.php"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
colors="true"
failOnWarning="true"
failOnRisky="true"
cacheDirectory=".phpunit.cache">
<testsuite name="Astrolabe Unit Tests">
<directory suffix="Test.php">.</directory>
</testsuite>
<source>
<include>
<directory suffix=".php">../../lib</directory>
</include>
</source>
</phpunit>
+15 -3
View File
@@ -1,15 +1,26 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import { readFileSync } from 'fs'
// Read app info from info.xml for @nextcloud/vue
const infoXml = readFileSync(resolve(__dirname, 'appinfo/info.xml'), 'utf-8')
const appName = infoXml.match(/<id>([^<]+)<\/id>/)?.[1] || 'astrolabe'
const appVersion = infoXml.match(/<version>([^<]+)<\/version>/)?.[1] || ''
export default defineConfig({
plugins: [vue()],
define: {
appName: JSON.stringify(appName),
appVersion: JSON.stringify(appVersion),
},
build: {
outDir: '.',
emptyOutDir: false,
cssCodeSplit: false, // Bundle all CSS into entry points (Nextcloud doesn't load CSS chunks)
rollupOptions: {
input: {
main: resolve(__dirname, 'src/main.js'),
'astrolabe-main': resolve(__dirname, 'src/main.js'),
'astrolabe-adminSettings': resolve(__dirname, 'src/adminSettings.js'),
'astrolabe-personalSettings': resolve(__dirname, 'src/personalSettings.js'),
},
@@ -17,9 +28,10 @@ export default defineConfig({
entryFileNames: 'js/[name].mjs',
chunkFileNames: 'js/[name]-[hash].chunk.mjs',
assetFileNames: (assetInfo) => {
// Output CSS to css/ directory, JS/other assets to js/
// With cssCodeSplit:false, all CSS goes to a single file
// Name it astrolabe-main.css to match Nextcloud's Util::addStyle expectation
if (assetInfo.name && assetInfo.name.endsWith('.css')) {
return 'css/[name][extname]';
return 'css/astrolabe-main.css';
}
return 'js/[name][extname]';
},
Generated
+1 -1
View File
@@ -1988,7 +1988,7 @@ wheels = [
[[package]]
name = "nextcloud-mcp-server"
version = "0.60.2"
version = "0.61.5"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },