Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d79fc6c3d | |||
| 80972f5d37 | |||
| f0ade4ad28 | |||
| 737f10f190 | |||
| 813e9a60cb | |||
| 5c25b87cbe | |||
| e48c5fa9a2 | |||
| 303efeddf7 | |||
| fef13a6d3d | |||
| c4973290a6 | |||
| c018268681 | |||
| 79cfb65590 | |||
| 9750845092 | |||
| 7e8171132b | |||
| 910792178b | |||
| 80c5647f3e | |||
| a306549907 | |||
| 295e3d2783 | |||
| 47dcdf8b61 | |||
| 8c6ae9ff33 | |||
| 04fee00a0b | |||
| 9e1fc1ebeb | |||
| 6eceefdacc | |||
| b147814cc4 | |||
| 5a58c81626 | |||
| 1cc460b0d8 | |||
| 104a2ec9e3 | |||
| e87ae56041 | |||
| c95459234b | |||
| f16f852b23 | |||
| b93d7bd19b | |||
| 9a69cef815 | |||
| 2424afbdda | |||
| 0a987467b5 | |||
| ab6f7ca0b2 | |||
| 42fa33d0bf | |||
| 006a3d95d6 | |||
| 1835965f44 | |||
| cb4e8acd9f | |||
| 02418a9531 | |||
| fdbf88831a |
@@ -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
|
||||
@@ -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
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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,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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
Vendored
+1
-1
@@ -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
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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 )
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
-58
@@ -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
|
||||
@@ -12,3 +12,4 @@ build/
|
||||
node_modules/
|
||||
js/
|
||||
css/
|
||||
.phpunit.cache/
|
||||
|
||||
Vendored
+17
@@ -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
@@ -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>
|
||||
|
||||
Vendored
+7
-1
@@ -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
File diff suppressed because it is too large
Load Diff
+7
-7
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
Vendored
+2
-2
@@ -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
@@ -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>
|
||||
Vendored
+1
@@ -8,6 +8,7 @@
|
||||
findUnusedBaselineEntry="true"
|
||||
findUnusedCode="true"
|
||||
phpVersion="8.1"
|
||||
errorBaseline="psalm-baseline.xml"
|
||||
>
|
||||
<projectFiles>
|
||||
<directory name="lib" />
|
||||
|
||||
Vendored
+15
-17
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
Reference in New Issue
Block a user