Compare commits

...

41 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
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
55 changed files with 4810 additions and 1239 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
+30
View File
@@ -5,6 +5,36 @@ 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
+1 -1
View File
@@ -1,6 +1,6 @@
FROM docker.io/library/python:3.12-slim-trixie@sha256:d75c4b6cdd039ae966a34cd3ccab9e0e5f7299280ad76fe1744882d86eedce0b
COPY --from=ghcr.io/astral-sh/uv:0.9.25@sha256:13e233d08517abdafac4ead26c16d881cd77504a2c40c38c905cf3a0d70131a6 /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)
+1 -1
View File
@@ -17,7 +17,7 @@ FROM docker.io/library/python:3.12-slim-trixie@sha256:d75c4b6cdd039ae966a34cd3cc
WORKDIR /app
# Install uv for fast dependency management
COPY --from=ghcr.io/astral-sh/uv:0.9.25@sha256:13e233d08517abdafac4ead26c16d881cd77504a2c40c38c905cf3a0d70131a6 /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)
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.commitizen]
name = "cz_conventional_commits"
version = "0.57.0"
version = "0.57.6"
tag_format = "nextcloud-mcp-server-$version"
version_scheme = "semver"
update_changelog_on_bump = true
+29
View File
@@ -14,6 +14,35 @@ 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
+2 -2
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.57.0
appVersion: "0.61.2"
version: 0.57.6
appVersion: "0.61.5"
keywords:
- nextcloud
- mcp
+3 -3
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:1a75afcd53b38aa72205ab38a66121ed9f9e8c99f4e70b0dccc858e60ad57b7d
image: docker.io/library/nextcloud:32.0.4@sha256:9ca3f78fcca340ea32ab7bf1a01b2a2fd3eae64ffc0e791fd71eb9d72c3d2efe
restart: always
ports:
- 127.0.0.1:8080:80
@@ -208,7 +208,7 @@ services:
- oauth-tokens:/app/data
keycloak:
image: quay.io/keycloak/keycloak:26.5.0@sha256:5fdd7cda82e58775ed124294c7e16fabc33166d38dfc4aabebda7d64e7a964bf
image: quay.io/keycloak/keycloak:26.5.1@sha256:b80a48090594367bd8cf6fe2019466ac4ea49de4d0830fb2a43256eda37b18f5
command:
- "start-dev"
- "--import-realm"
+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:
+7 -2
View File
@@ -387,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 = {}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "nextcloud-mcp-server"
version = "0.61.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"}
@@ -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)
@@ -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.8.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@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
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/
+17
View File
@@ -25,6 +25,23 @@ 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
+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.8.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,
@@ -112,7 +112,7 @@ class CredentialsController extends Controller {
// 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");
$this->logger->warning('MCP server URL not configured, app password stored locally only');
return new JSONResponse([
'success' => true,
'partial_success' => true,
+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.8.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>
Generated
+1 -1
View File
@@ -1988,7 +1988,7 @@ wheels = [
[[package]]
name = "nextcloud-mcp-server"
version = "0.61.2"
version = "0.61.5"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },