Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 723337754f | |||
| 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 | |||
| 1835965f44 | |||
| 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
|
# Based on Nextcloud app skeleton workflows
|
||||||
#
|
#
|
||||||
# SPDX-FileCopyrightText: 2025 Nextcloud MCP Server contributors
|
# SPDX-FileCopyrightText: 2025 Nextcloud MCP Server contributors
|
||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
name: Astroglobe CI
|
name: Astrolabe CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'third_party/astroglobe/**'
|
- 'third_party/astrolabe/**'
|
||||||
- '.github/workflows/astroglobe-ci.yml'
|
- '.github/workflows/astrolabe-ci.yml'
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: astroglobe-ci-${{ github.head_ref || github.run_id }}
|
group: astrolabe-ci-${{ github.head_ref || github.run_id }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -37,18 +37,18 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
frontend:
|
frontend:
|
||||||
- 'third_party/astroglobe/src/**'
|
- 'third_party/astrolabe/src/**'
|
||||||
- 'third_party/astroglobe/package.json'
|
- 'third_party/astrolabe/package.json'
|
||||||
- 'third_party/astroglobe/package-lock.json'
|
- 'third_party/astrolabe/package-lock.json'
|
||||||
- 'third_party/astroglobe/vite.config.js'
|
- 'third_party/astrolabe/vite.config.js'
|
||||||
- 'third_party/astroglobe/**/*.js'
|
- 'third_party/astrolabe/**/*.js'
|
||||||
- 'third_party/astroglobe/**/*.ts'
|
- 'third_party/astrolabe/**/*.ts'
|
||||||
- 'third_party/astroglobe/**/*.vue'
|
- 'third_party/astrolabe/**/*.vue'
|
||||||
php:
|
php:
|
||||||
- 'third_party/astroglobe/lib/**'
|
- 'third_party/astrolabe/lib/**'
|
||||||
- 'third_party/astroglobe/appinfo/**'
|
- 'third_party/astrolabe/appinfo/**'
|
||||||
- 'third_party/astroglobe/composer.json'
|
- 'third_party/astrolabe/composer.json'
|
||||||
- 'third_party/astroglobe/psalm.xml'
|
- 'third_party/astrolabe/psalm.xml'
|
||||||
|
|
||||||
# Node.js build and lint
|
# Node.js build and lint
|
||||||
node-build:
|
node-build:
|
||||||
@@ -58,7 +58,7 @@ jobs:
|
|||||||
name: Node.js build
|
name: Node.js build
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: third_party/astroglobe
|
working-directory: third_party/astrolabe
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
@@ -67,7 +67,7 @@ jobs:
|
|||||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||||
id: versions
|
id: versions
|
||||||
with:
|
with:
|
||||||
path: third_party/astroglobe
|
path: third_party/astrolabe
|
||||||
fallbackNode: '^20'
|
fallbackNode: '^20'
|
||||||
fallbackNpm: '^10'
|
fallbackNpm: '^10'
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ jobs:
|
|||||||
name: ESLint
|
name: ESLint
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: third_party/astroglobe
|
working-directory: third_party/astrolabe
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
@@ -108,7 +108,7 @@ jobs:
|
|||||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||||
id: versions
|
id: versions
|
||||||
with:
|
with:
|
||||||
path: third_party/astroglobe
|
path: third_party/astrolabe
|
||||||
fallbackNode: '^20'
|
fallbackNode: '^20'
|
||||||
fallbackNpm: '^10'
|
fallbackNpm: '^10'
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ jobs:
|
|||||||
name: Stylelint
|
name: Stylelint
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: third_party/astroglobe
|
working-directory: third_party/astrolabe
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
@@ -146,7 +146,7 @@ jobs:
|
|||||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||||
id: versions
|
id: versions
|
||||||
with:
|
with:
|
||||||
path: third_party/astroglobe
|
path: third_party/astrolabe
|
||||||
fallbackNode: '^20'
|
fallbackNode: '^20'
|
||||||
fallbackNpm: '^10'
|
fallbackNpm: '^10'
|
||||||
|
|
||||||
@@ -175,7 +175,7 @@ jobs:
|
|||||||
name: PHP CS Fixer
|
name: PHP CS Fixer
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: third_party/astroglobe
|
working-directory: third_party/astrolabe
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
@@ -184,7 +184,7 @@ jobs:
|
|||||||
id: versions
|
id: versions
|
||||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||||
with:
|
with:
|
||||||
filename: third_party/astroglobe/appinfo/info.xml
|
filename: third_party/astrolabe/appinfo/info.xml
|
||||||
|
|
||||||
- name: Set up php${{ steps.versions.outputs.php-min }}
|
- name: Set up php${{ steps.versions.outputs.php-min }}
|
||||||
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
|
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
|
||||||
@@ -212,7 +212,7 @@ jobs:
|
|||||||
name: Psalm
|
name: Psalm
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: third_party/astroglobe
|
working-directory: third_party/astrolabe
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
@@ -221,7 +221,7 @@ jobs:
|
|||||||
id: versions
|
id: versions
|
||||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||||
with:
|
with:
|
||||||
filename: third_party/astroglobe/appinfo/info.xml
|
filename: third_party/astrolabe/appinfo/info.xml
|
||||||
|
|
||||||
- name: Set up php${{ steps.versions.outputs.php-min }}
|
- name: Set up php${{ steps.versions.outputs.php-min }}
|
||||||
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
|
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
|
||||||
@@ -242,7 +242,7 @@ jobs:
|
|||||||
id: ocp-versions
|
id: ocp-versions
|
||||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||||
with:
|
with:
|
||||||
filename: third_party/astroglobe/appinfo/info.xml
|
filename: third_party/astrolabe/appinfo/info.xml
|
||||||
|
|
||||||
- name: Install OCP for static analysis
|
- name: Install OCP for static analysis
|
||||||
run: |
|
run: |
|
||||||
@@ -253,14 +253,62 @@ jobs:
|
|||||||
- name: Run Psalm
|
- name: Run Psalm
|
||||||
run: composer run psalm -- --threads=1 --monochrome --no-progress --output-format=github
|
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 job
|
||||||
summary:
|
summary:
|
||||||
permissions:
|
permissions:
|
||||||
contents: none
|
contents: none
|
||||||
runs-on: ubuntu-latest
|
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()
|
if: always()
|
||||||
name: astroglobe-ci-summary
|
name: astrolabe-ci-summary
|
||||||
steps:
|
steps:
|
||||||
- name: Summary status
|
- name: Summary status
|
||||||
run: |
|
run: |
|
||||||
@@ -268,7 +316,7 @@ jobs:
|
|||||||
echo "Frontend checks failed"
|
echo "Frontend checks failed"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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"
|
echo "PHP checks failed"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -5,6 +5,29 @@ 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/),
|
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/).
|
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)
|
## v0.61.3 (2026-01-15)
|
||||||
|
|
||||||
### Fix
|
### Fix
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:d75c4b6cdd039ae966a34cd3ccab9e0e5f7299280ad76fe1744882d86eedce0b
|
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
|
# Install dependencies
|
||||||
# 1. git (required for caldav dependency from git)
|
# 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
|
WORKDIR /app
|
||||||
|
|
||||||
# Install uv for fast dependency management
|
# 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
|
# Install dependencies
|
||||||
# 1. git (required for caldav dependency from git)
|
# 1. git (required for caldav dependency from git)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tool.commitizen]
|
[tool.commitizen]
|
||||||
name = "cz_conventional_commits"
|
name = "cz_conventional_commits"
|
||||||
version = "0.57.2"
|
version = "0.57.7"
|
||||||
tag_format = "nextcloud-mcp-server-$version"
|
tag_format = "nextcloud-mcp-server-$version"
|
||||||
version_scheme = "semver"
|
version_scheme = "semver"
|
||||||
update_changelog_on_bump = true
|
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
|
- Configurable resource limits
|
||||||
- Grafana dashboard annotations
|
- Grafana dashboard annotations
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.7 (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
|
||||||
|
|
||||||
|
## 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)
|
## nextcloud-mcp-server-0.57.2 (2026-01-15)
|
||||||
|
|
||||||
### Fix
|
### Fix
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
|||||||
name: nextcloud-mcp-server
|
name: nextcloud-mcp-server
|
||||||
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||||
type: application
|
type: application
|
||||||
version: 0.57.2
|
version: 0.57.7
|
||||||
appVersion: "0.61.3"
|
appVersion: "0.61.5"
|
||||||
keywords:
|
keywords:
|
||||||
- nextcloud
|
- nextcloud
|
||||||
- mcp
|
- mcp
|
||||||
|
|||||||
+3
-3
@@ -3,7 +3,7 @@ services:
|
|||||||
# https://hub.docker.com/_/mariadb
|
# https://hub.docker.com/_/mariadb
|
||||||
db:
|
db:
|
||||||
# Note: Check the recommend version here: https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html#server
|
# 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
|
restart: always
|
||||||
command: --transaction-isolation=READ-COMMITTED
|
command: --transaction-isolation=READ-COMMITTED
|
||||||
volumes:
|
volumes:
|
||||||
@@ -23,7 +23,7 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: docker.io/library/nextcloud:32.0.3@sha256:1a75afcd53b38aa72205ab38a66121ed9f9e8c99f4e70b0dccc858e60ad57b7d
|
image: docker.io/library/nextcloud:32.0.4@sha256:9ca3f78fcca340ea32ab7bf1a01b2a2fd3eae64ffc0e791fd71eb9d72c3d2efe
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:8080:80
|
- 127.0.0.1:8080:80
|
||||||
@@ -208,7 +208,7 @@ services:
|
|||||||
- oauth-tokens:/app/data
|
- oauth-tokens:/app/data
|
||||||
|
|
||||||
keycloak:
|
keycloak:
|
||||||
image: quay.io/keycloak/keycloak:26.5.0@sha256:5fdd7cda82e58775ed124294c7e16fabc33166d38dfc4aabebda7d64e7a964bf
|
image: quay.io/keycloak/keycloak:26.5.1@sha256:b80a48090594367bd8cf6fe2019466ac4ea49de4d0830fb2a43256eda37b18f5
|
||||||
command:
|
command:
|
||||||
- "start-dev"
|
- "start-dev"
|
||||||
- "--import-realm"
|
- "--import-realm"
|
||||||
|
|||||||
@@ -223,6 +223,55 @@ NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
|
|||||||
| Token Storage | None | Refresh tokens only | All tokens |
|
| Token Storage | None | Refresh tokens only | All tokens |
|
||||||
| Deployment Complexity | Low | Medium | High |
|
| 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
|
### See Also
|
||||||
- [OAuth Architecture](oauth-architecture.md) - Progressive Consent (Flow 2) details
|
- [OAuth Architecture](oauth-architecture.md) - Progressive Consent (Flow 2) details
|
||||||
- [Configuration](configuration.md#enable_offline_access) - Hybrid mode configuration
|
- [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
|
## Loading Environment Variables
|
||||||
|
|
||||||
After creating your `.env` file, load the 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:
|
if mode == AuthMode.MULTI_USER_BASIC:
|
||||||
response_data["supports_app_passwords"] = settings.enable_offline_access
|
response_data["supports_app_passwords"] = settings.enable_offline_access
|
||||||
|
|
||||||
# Include OIDC configuration if in OAuth mode
|
# Include OIDC configuration if OAuth is available
|
||||||
if auth_mode == "oauth":
|
# 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
|
# Provide IdP discovery information for NC PHP app
|
||||||
oidc_config = {}
|
oidc_config = {}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.61.3"
|
version = "0.61.5"
|
||||||
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
{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]
|
[tool.commitizen]
|
||||||
name = "cz_conventional_commits"
|
name = "cz_conventional_commits"
|
||||||
version = "0.8.0"
|
version = "0.8.2"
|
||||||
tag_format = "astrolabe-v$version"
|
tag_format = "astrolabe-v$version"
|
||||||
version_scheme = "semver"
|
version_scheme = "semver"
|
||||||
update_changelog_on_bump = true
|
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/
|
node_modules/
|
||||||
js/
|
js/
|
||||||
css/
|
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
|
- Requires external MCP server deployment
|
||||||
- See documentation for setup: https://github.com/cbcoutinho/nextcloud-mcp-server
|
- 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)
|
## astrolabe-v0.8.0 (2026-01-15)
|
||||||
|
|
||||||
### Feat
|
### 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.
|
See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for configuration details.
|
||||||
]]></description>
|
]]></description>
|
||||||
<version>0.8.0</version>
|
<version>0.8.2</version>
|
||||||
<licence>agpl</licence>
|
<licence>agpl</licence>
|
||||||
<author homepage="https://github.com/cbcoutinho">Chris Coutinho</author>
|
<author homepage="https://github.com/cbcoutinho">Chris Coutinho</author>
|
||||||
<namespace>Astrolabe</namespace>
|
<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/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>
|
<screenshot>https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/third_party/astrolabe/screenshots/03-chunk-viewer-open.png?raw=1</screenshot>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<nextcloud min-version="30" max-version="32"/>
|
<nextcloud min-version="31" max-version="32"/>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
<settings>
|
<settings>
|
||||||
<personal>OCA\Astrolabe\Settings\Personal</personal>
|
<personal>OCA\Astrolabe\Settings\Personal</personal>
|
||||||
|
|||||||
Vendored
+7
-1
@@ -14,6 +14,11 @@
|
|||||||
"OCA\\Astrolabe\\": "lib/"
|
"OCA\\Astrolabe\\": "lib/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"OCP\\": "vendor/nextcloud/ocp/OCP/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"post-install-cmd": [
|
"post-install-cmd": [
|
||||||
"@composer bin all install --ansi"
|
"@composer bin all install --ansi"
|
||||||
@@ -25,7 +30,7 @@
|
|||||||
"cs:check": "php-cs-fixer fix --dry-run --diff",
|
"cs:check": "php-cs-fixer fix --dry-run --diff",
|
||||||
"cs:fix": "php-cs-fixer fix",
|
"cs:fix": "php-cs-fixer fix",
|
||||||
"psalm": "psalm --threads=1 --no-cache",
|
"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",
|
"openapi": "generate-spec",
|
||||||
"rector": "rector && composer cs:fix"
|
"rector": "rector && composer cs:fix"
|
||||||
},
|
},
|
||||||
@@ -35,6 +40,7 @@
|
|||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"nextcloud/ocp": "dev-stable30",
|
"nextcloud/ocp": "dev-stable30",
|
||||||
|
"phpunit/phpunit": "^10.0",
|
||||||
"roave/security-advisories": "dev-latest"
|
"roave/security-advisories": "dev-latest"
|
||||||
},
|
},
|
||||||
"config": {
|
"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.
|
* Handles form submissions and AJAX requests from settings panels.
|
||||||
*/
|
*/
|
||||||
class ApiController extends Controller {
|
class ApiController extends Controller {
|
||||||
private $client;
|
private McpServerClient $client;
|
||||||
private $userSession;
|
private IUserSession $userSession;
|
||||||
private $urlGenerator;
|
private IURLGenerator $urlGenerator;
|
||||||
private $logger;
|
private LoggerInterface $logger;
|
||||||
private $tokenStorage;
|
private McpTokenStorage $tokenStorage;
|
||||||
private $config;
|
private IConfig $config;
|
||||||
private $tokenRefresher;
|
private IdpTokenRefresher $tokenRefresher;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
string $appName,
|
string $appName,
|
||||||
|
|||||||
@@ -23,13 +23,13 @@ use Psr\Log\LoggerInterface;
|
|||||||
* Handles storing and validating app passwords for multi-user BasicAuth mode.
|
* Handles storing and validating app passwords for multi-user BasicAuth mode.
|
||||||
*/
|
*/
|
||||||
class CredentialsController extends Controller {
|
class CredentialsController extends Controller {
|
||||||
private $tokenStorage;
|
private McpTokenStorage $tokenStorage;
|
||||||
private $userSession;
|
private IUserSession $userSession;
|
||||||
private $logger;
|
private LoggerInterface $logger;
|
||||||
private $config;
|
private IConfig $config;
|
||||||
private $client;
|
private McpServerClient $client;
|
||||||
private $httpClientService;
|
private IClientService $httpClientService;
|
||||||
private $urlGenerator;
|
private IURLGenerator $urlGenerator;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
string $appName,
|
string $appName,
|
||||||
@@ -112,7 +112,7 @@ class CredentialsController extends Controller {
|
|||||||
// Get MCP server URL from system config (set in config.php)
|
// Get MCP server URL from system config (set in config.php)
|
||||||
$mcpServerUrl = $this->config->getSystemValue('mcp_server_url', '');
|
$mcpServerUrl = $this->config->getSystemValue('mcp_server_url', '');
|
||||||
if (empty($mcpServerUrl)) {
|
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([
|
return new JSONResponse([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'partial_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\Attribute\NoCSRFRequired;
|
||||||
use OCP\AppFramework\Http\RedirectResponse;
|
use OCP\AppFramework\Http\RedirectResponse;
|
||||||
use OCP\AppFramework\Http\TemplateResponse;
|
use OCP\AppFramework\Http\TemplateResponse;
|
||||||
|
use OCP\Http\Client\IClient;
|
||||||
use OCP\Http\Client\IClientService;
|
use OCP\Http\Client\IClientService;
|
||||||
use OCP\IConfig;
|
use OCP\IConfig;
|
||||||
use OCP\IL10N;
|
use OCP\IL10N;
|
||||||
@@ -32,15 +33,15 @@ use Psr\Log\LoggerInterface;
|
|||||||
* - Confidential clients: PKCE + client_secret (defense in depth)
|
* - Confidential clients: PKCE + client_secret (defense in depth)
|
||||||
*/
|
*/
|
||||||
class OAuthController extends Controller {
|
class OAuthController extends Controller {
|
||||||
private $config;
|
private IConfig $config;
|
||||||
private $session;
|
private ISession $session;
|
||||||
private $userSession;
|
private IUserSession $userSession;
|
||||||
private $urlGenerator;
|
private IURLGenerator $urlGenerator;
|
||||||
private $tokenStorage;
|
private McpTokenStorage $tokenStorage;
|
||||||
private $logger;
|
private LoggerInterface $logger;
|
||||||
private $l;
|
private IL10N $l;
|
||||||
private $httpClient;
|
private IClient $httpClient;
|
||||||
private $client;
|
private McpServerClient $client;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
string $appName,
|
string $appName,
|
||||||
|
|||||||
+73
-23
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace OCA\Astrolabe\Service;
|
namespace OCA\Astrolabe\Service;
|
||||||
|
|
||||||
|
use OCP\Http\Client\IClient;
|
||||||
use OCP\Http\Client\IClientService;
|
use OCP\Http\Client\IClientService;
|
||||||
use OCP\IConfig;
|
use OCP\IConfig;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
@@ -18,10 +19,10 @@ use Psr\Log\LoggerInterface;
|
|||||||
* Public clients without client_secret cannot refresh tokens.
|
* Public clients without client_secret cannot refresh tokens.
|
||||||
*/
|
*/
|
||||||
class IdpTokenRefresher {
|
class IdpTokenRefresher {
|
||||||
private $config;
|
private IConfig $config;
|
||||||
private $httpClient;
|
private IClient $httpClient;
|
||||||
private $logger;
|
private LoggerInterface $logger;
|
||||||
private $mcpServerClient;
|
private McpServerClient $mcpServerClient;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
IConfig $config,
|
IConfig $config,
|
||||||
@@ -38,25 +39,47 @@ class IdpTokenRefresher {
|
|||||||
/**
|
/**
|
||||||
* Get Nextcloud base URL for constructing internal OIDC endpoint URLs.
|
* 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 {
|
private function getNextcloudBaseUrl(): string {
|
||||||
// Prefer explicit CLI URL override
|
// Check for explicit internal URL config (for custom container setups)
|
||||||
$baseUrl = $this->config->getSystemValue('overwrite.cli.url', '');
|
$internalUrl = $this->config->getSystemValue('astrolabe_internal_url', '');
|
||||||
|
if (!is_string($internalUrl)) {
|
||||||
if (!empty($baseUrl)) {
|
$internalUrl = '';
|
||||||
return rtrim($baseUrl, '/');
|
}
|
||||||
|
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
|
// Default: container environment with web server on localhost:80
|
||||||
$trustedDomains = $this->config->getSystemValue('trusted_domains', []);
|
// This works because PHP runs inside the same container as Apache
|
||||||
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');
|
|
||||||
return 'http://localhost';
|
return 'http://localhost';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +122,7 @@ class IdpTokenRefresher {
|
|||||||
// External IdP configured - use OIDC discovery
|
// External IdP configured - use OIDC discovery
|
||||||
$discoveryUrl = $statusData['oidc']['discovery_url'];
|
$discoveryUrl = $statusData['oidc']['discovery_url'];
|
||||||
|
|
||||||
$this->logger->info('IdpTokenRefresher: Using external IdP', [
|
$this->logger->debug('IdpTokenRefresher: Using external IdP', [
|
||||||
'discovery_url' => $discoveryUrl,
|
'discovery_url' => $discoveryUrl,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -115,7 +138,7 @@ class IdpTokenRefresher {
|
|||||||
// Nextcloud's OIDC app - use internal URL
|
// Nextcloud's OIDC app - use internal URL
|
||||||
$tokenEndpoint = $this->getNextcloudBaseUrl() . '/apps/oidc/token';
|
$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,
|
'token_endpoint' => $tokenEndpoint,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -160,11 +183,38 @@ class IdpTokenRefresher {
|
|||||||
|
|
||||||
return $tokenData;
|
return $tokenData;
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\OCP\Http\Client\LocalServerException $e) {
|
||||||
$this->logger->error('IdpTokenRefresher: Token refresh failed', [
|
// Network/connection error - may be transient
|
||||||
|
$this->logger->warning('IdpTokenRefresher: Network error during refresh', [
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
return null;
|
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;
|
namespace OCA\Astrolabe\Service;
|
||||||
|
|
||||||
|
use OCP\Http\Client\IClient;
|
||||||
use OCP\Http\Client\IClientService;
|
use OCP\Http\Client\IClientService;
|
||||||
use OCP\IConfig;
|
use OCP\IConfig;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
@@ -16,10 +17,10 @@ use Psr\Log\LoggerInterface;
|
|||||||
* for all management operations.
|
* for all management operations.
|
||||||
*/
|
*/
|
||||||
class McpServerClient {
|
class McpServerClient {
|
||||||
private $httpClient;
|
private IClient $httpClient;
|
||||||
private $config;
|
private IConfig $config;
|
||||||
private $logger;
|
private LoggerInterface $logger;
|
||||||
private $baseUrl;
|
private string $baseUrl;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
IClientService $clientService,
|
IClientService $clientService,
|
||||||
@@ -31,7 +32,8 @@ class McpServerClient {
|
|||||||
$this->logger = $logger;
|
$this->logger = $logger;
|
||||||
|
|
||||||
// Get MCP server configuration from Nextcloud config
|
// 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.
|
* Handles token expiration checking and refresh logic.
|
||||||
*/
|
*/
|
||||||
class McpTokenStorage {
|
class McpTokenStorage {
|
||||||
|
/** Buffer time in seconds before actual expiry to trigger refresh */
|
||||||
|
private const TOKEN_EXPIRY_BUFFER_SECONDS = 60;
|
||||||
|
|
||||||
private $config;
|
private $config;
|
||||||
private $crypto;
|
private $crypto;
|
||||||
private $logger;
|
private $logger;
|
||||||
@@ -112,7 +115,7 @@ class McpTokenStorage {
|
|||||||
/**
|
/**
|
||||||
* Check if a token is expired or about to expire.
|
* 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
|
* @param array $token Token data array
|
||||||
* @return bool True if expired or about to expire
|
* @return bool True if expired or about to expire
|
||||||
@@ -122,8 +125,8 @@ class McpTokenStorage {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expire 60 seconds early to avoid race conditions
|
// Expire early to avoid race conditions
|
||||||
return time() >= ($token['expires_at'] - 60);
|
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", [
|
$this->logger->error("Failed to refresh token for user $userId", [
|
||||||
'error' => $e->getMessage()
|
'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");
|
$this->logger->info("Token expired for user $userId, no refresh available");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-53
@@ -79,60 +79,46 @@ class Personal implements ISettings {
|
|||||||
// Check if user has MCP OAuth token
|
// Check if user has MCP OAuth token
|
||||||
$token = $this->tokenStorage->getUserToken($userId);
|
$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) {
|
if ($authMode === 'multi_user_basic' && $supportsAppPasswords) {
|
||||||
// Check if user has already provided an app password
|
// Check both credentials
|
||||||
$hasBackgroundAccess = $this->tokenStorage->hasBackgroundSyncAccess($userId);
|
$hasOAuthToken = ($token !== null && !$this->tokenStorage->isExpired($token));
|
||||||
|
$hasAppPassword = $this->tokenStorage->hasBackgroundSyncAccess($userId);
|
||||||
|
$backgroundSyncType = $this->tokenStorage->getBackgroundSyncType($userId);
|
||||||
|
$backgroundSyncProvisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
|
||||||
|
|
||||||
if (!$hasBackgroundAccess) {
|
// OAuth URL for Astrolabe's own OAuth controller (NOT MCP server's browser OAuth)
|
||||||
// No app password yet - show app password entry form
|
$oauthUrl = $this->urlGenerator->linkToRoute('astrolabe.oauth.initiateOAuth');
|
||||||
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);
|
|
||||||
|
|
||||||
$parameters = [
|
// Consolidated template parameters (camelCase convention)
|
||||||
'userId' => $userId,
|
$parameters = [
|
||||||
'serverStatus' => $serverStatus,
|
'userId' => $userId,
|
||||||
'session' => null, // No user session for app passwords
|
'serverUrl' => $this->client->getPublicServerUrl(),
|
||||||
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
|
'serverStatus' => $serverStatus,
|
||||||
'backgroundAccessGranted' => true, // App password grants background access
|
'authMode' => $authMode,
|
||||||
'serverUrl' => $this->client->getPublicServerUrl(),
|
'supportsAppPasswords' => $supportsAppPasswords,
|
||||||
'hasToken' => false, // No OAuth token
|
'session' => null, // No session in hybrid mode
|
||||||
'hasBackgroundAccess' => true,
|
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
|
||||||
'backgroundSyncType' => $backgroundSyncType,
|
// OAuth token status (for Astrolabe→MCP API calls)
|
||||||
'backgroundSyncProvisionedAt' => $backgroundSyncProvisionedAt,
|
'hasOAuthToken' => $hasOAuthToken,
|
||||||
'authMode' => $authMode,
|
'oauthUrl' => $oauthUrl,
|
||||||
'supportsAppPasswords' => $supportsAppPasswords,
|
// App password status (for MCP→Nextcloud background sync)
|
||||||
'requesttoken' => \OCP\Util::callRegister(),
|
'hasBackgroundAccess' => $hasAppPassword,
|
||||||
];
|
'backgroundAccessGranted' => $hasAppPassword, // Legacy alias
|
||||||
|
'backgroundSyncType' => $backgroundSyncType,
|
||||||
|
'backgroundSyncProvisionedAt' => $backgroundSyncProvisionedAt,
|
||||||
|
'requesttoken' => \OCP\Util::callRegister(),
|
||||||
|
];
|
||||||
|
|
||||||
return new TemplateResponse(
|
return new TemplateResponse(
|
||||||
Application::APP_ID,
|
Application::APP_ID,
|
||||||
'settings/personal',
|
'settings/personal',
|
||||||
$parameters,
|
$parameters,
|
||||||
TemplateResponse::RENDER_AS_BLANK
|
TemplateResponse::RENDER_AS_BLANK
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// For OAuth modes, if no token or token is expired, show OAuth authorization UI
|
// For OAuth modes, if no token or token is expired, show OAuth authorization UI
|
||||||
elseif (!$token || $this->tokenStorage->isExpired($token)) {
|
elseif (!$token || $this->tokenStorage->isExpired($token)) {
|
||||||
@@ -198,6 +184,9 @@ class Personal implements ISettings {
|
|||||||
$backgroundSyncType = $this->tokenStorage->getBackgroundSyncType($userId);
|
$backgroundSyncType = $this->tokenStorage->getBackgroundSyncType($userId);
|
||||||
$backgroundSyncProvisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($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)
|
// Provide initial state for Vue.js frontend (if needed)
|
||||||
$this->initialState->provideInitialState('user-data', [
|
$this->initialState->provideInitialState('user-data', [
|
||||||
'userId' => $userId,
|
'userId' => $userId,
|
||||||
@@ -205,17 +194,22 @@ class Personal implements ISettings {
|
|||||||
'session' => $userSession,
|
'session' => $userSession,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Consolidated template parameters (camelCase convention)
|
||||||
$parameters = [
|
$parameters = [
|
||||||
'userId' => $userId,
|
'userId' => $userId,
|
||||||
|
'serverUrl' => $this->client->getPublicServerUrl(),
|
||||||
'serverStatus' => $serverStatus,
|
'serverStatus' => $serverStatus,
|
||||||
'session' => $userSession,
|
'session' => $userSession,
|
||||||
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
|
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
|
||||||
'backgroundAccessGranted' => $userSession['background_access_granted'] ?? false,
|
// OAuth status
|
||||||
'serverUrl' => $this->client->getPublicServerUrl(),
|
'hasOAuthToken' => true,
|
||||||
'hasToken' => true,
|
'oauthUrl' => $oauthUrl,
|
||||||
|
// Background sync status
|
||||||
'hasBackgroundAccess' => $hasBackgroundAccess,
|
'hasBackgroundAccess' => $hasBackgroundAccess,
|
||||||
|
'backgroundAccessGranted' => $userSession['background_access_granted'] ?? false, // Legacy
|
||||||
'backgroundSyncType' => $backgroundSyncType,
|
'backgroundSyncType' => $backgroundSyncType,
|
||||||
'backgroundSyncProvisionedAt' => $backgroundSyncProvisionedAt,
|
'backgroundSyncProvisionedAt' => $backgroundSyncProvisionedAt,
|
||||||
|
'requesttoken' => \OCP\Util::callRegister(),
|
||||||
];
|
];
|
||||||
|
|
||||||
return new TemplateResponse(
|
return new TemplateResponse(
|
||||||
|
|||||||
+17
-15
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "astrolabe",
|
"name": "astrolabe",
|
||||||
"version": "0.6.0",
|
"version": "0.8.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "astrolabe",
|
"name": "astrolabe",
|
||||||
"version": "0.6.0",
|
"version": "0.8.2",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nextcloud/axios": "^2.5.1",
|
"@nextcloud/axios": "^2.5.1",
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"@nextcloud/initial-state": "^3.0.0",
|
"@nextcloud/initial-state": "^3.0.0",
|
||||||
"@nextcloud/l10n": "^3.1.0",
|
"@nextcloud/l10n": "^3.1.0",
|
||||||
"@nextcloud/router": "^3.0.1",
|
"@nextcloud/router": "^3.0.1",
|
||||||
"@nextcloud/vue": "^9.0.0",
|
"@nextcloud/vue": "^9.3.3",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"pdfjs-dist": "^4.0.379",
|
"pdfjs-dist": "^4.0.379",
|
||||||
"plotly.js-dist-min": "^2.35.3",
|
"plotly.js-dist-min": "^2.35.3",
|
||||||
@@ -1657,9 +1657,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nextcloud/vue": {
|
"node_modules/@nextcloud/vue": {
|
||||||
"version": "9.3.1",
|
"version": "9.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-9.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-9.3.3.tgz",
|
||||||
"integrity": "sha512-zNit83SI7IPT5iT9QsYPCYNwBYvKEqzLvWKTeJemqg9MZ8JGIC3/jjENeXzDolrTN/PixHns5lOYVCejATE1ag==",
|
"integrity": "sha512-M/M4L9vp1AJQ8RRk75mbMwUo7sOwWDaTDmAwgpTa9LARDe5e6UBJoMhOmiz5EPkYRHLn2SLE+baOIXVmtVMdqw==",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ckpack/vue-color": "^1.6.0",
|
"@ckpack/vue-color": "^1.6.0",
|
||||||
@@ -1671,7 +1671,7 @@
|
|||||||
"@nextcloud/event-bus": "^3.3.3",
|
"@nextcloud/event-bus": "^3.3.3",
|
||||||
"@nextcloud/initial-state": "^3.0.0",
|
"@nextcloud/initial-state": "^3.0.0",
|
||||||
"@nextcloud/l10n": "^3.4.1",
|
"@nextcloud/l10n": "^3.4.1",
|
||||||
"@nextcloud/logger": "^3.0.2",
|
"@nextcloud/logger": "^3.0.3",
|
||||||
"@nextcloud/router": "^3.1.0",
|
"@nextcloud/router": "^3.1.0",
|
||||||
"@nextcloud/sharing": "^0.3.0",
|
"@nextcloud/sharing": "^0.3.0",
|
||||||
"@vuepic/vue-datepicker": "^11.0.3",
|
"@vuepic/vue-datepicker": "^11.0.3",
|
||||||
@@ -1684,9 +1684,9 @@
|
|||||||
"emoji-mart-vue-fast": "^15.0.5",
|
"emoji-mart-vue-fast": "^15.0.5",
|
||||||
"escape-html": "^1.0.3",
|
"escape-html": "^1.0.3",
|
||||||
"floating-vue": "^5.2.2",
|
"floating-vue": "^5.2.2",
|
||||||
"focus-trap": "^7.6.6",
|
"focus-trap": "7.6.6",
|
||||||
"linkifyjs": "^4.3.2",
|
"linkifyjs": "^4.3.2",
|
||||||
"p-queue": "^9.0.1",
|
"p-queue": "^9.1.0",
|
||||||
"rehype-external-links": "^3.0.0",
|
"rehype-external-links": "^3.0.0",
|
||||||
"rehype-highlight": "^7.0.2",
|
"rehype-highlight": "^7.0.2",
|
||||||
"rehype-react": "^8.0.0",
|
"rehype-react": "^8.0.0",
|
||||||
@@ -1696,14 +1696,14 @@
|
|||||||
"remark-unlink-protocols": "^1.0.0",
|
"remark-unlink-protocols": "^1.0.0",
|
||||||
"splitpanes": "^4.0.4",
|
"splitpanes": "^4.0.4",
|
||||||
"striptags": "^3.2.0",
|
"striptags": "^3.2.0",
|
||||||
"tabbable": "^6.3.0",
|
"tabbable": "^6.4.0",
|
||||||
"tributejs": "^5.1.3",
|
"tributejs": "^5.1.3",
|
||||||
"ts-md5": "^2.0.1",
|
"ts-md5": "^2.0.1",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
"unist-builder": "^4.0.0",
|
"unist-builder": "^4.0.0",
|
||||||
"unist-util-visit": "^5.0.0",
|
"unist-util-visit": "^5.0.0",
|
||||||
"vue": "^3.5.18",
|
"vue": "^3.5.18",
|
||||||
"vue-router": "^4.6.3",
|
"vue-router": "^4.6.4",
|
||||||
"vue-select": "^4.0.0-beta.6"
|
"vue-select": "^4.0.0-beta.6"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -7751,9 +7751,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/p-queue": {
|
"node_modules/p-queue": {
|
||||||
"version": "9.0.1",
|
"version": "9.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz",
|
||||||
"integrity": "sha512-RhBdVhSwJb7Ocn3e8ULk4NMwBEuOxe+1zcgphUy9c2e5aR/xbEsdVXxHJ3lynw6Qiqu7OINEyHlZkiblEpaq7w==",
|
"integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"eventemitter3": "^5.0.1",
|
"eventemitter3": "^5.0.1",
|
||||||
@@ -9693,7 +9693,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tabbable": {
|
"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"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/table": {
|
"node_modules/table": {
|
||||||
|
|||||||
Vendored
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "astrolabe",
|
"name": "astrolabe",
|
||||||
"version": "0.8.0",
|
"version": "0.8.2",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^22.0.0",
|
"node": "^22.0.0",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"@nextcloud/initial-state": "^3.0.0",
|
"@nextcloud/initial-state": "^3.0.0",
|
||||||
"@nextcloud/l10n": "^3.1.0",
|
"@nextcloud/l10n": "^3.1.0",
|
||||||
"@nextcloud/router": "^3.0.1",
|
"@nextcloud/router": "^3.0.1",
|
||||||
"@nextcloud/vue": "^9.0.0",
|
"@nextcloud/vue": "^9.3.3",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"pdfjs-dist": "^4.0.379",
|
"pdfjs-dist": "^4.0.379",
|
||||||
"plotly.js-dist-min": "^2.35.3",
|
"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"
|
findUnusedBaselineEntry="true"
|
||||||
findUnusedCode="true"
|
findUnusedCode="true"
|
||||||
phpVersion="8.1"
|
phpVersion="8.1"
|
||||||
|
errorBaseline="psalm-baseline.xml"
|
||||||
>
|
>
|
||||||
<projectFiles>
|
<projectFiles>
|
||||||
<directory name="lib" />
|
<directory name="lib" />
|
||||||
|
|||||||
Vendored
+9
-9
@@ -62,7 +62,7 @@
|
|||||||
@update:model-value="algorithm = $event ? $event.id : 'hybrid'" />
|
@update:model-value="algorithm = $event ? $event.id : 'hybrid'" />
|
||||||
|
|
||||||
<NcButton
|
<NcButton
|
||||||
type="primary"
|
variant="primary"
|
||||||
:disabled="!query.trim() || loading"
|
:disabled="!query.trim() || loading"
|
||||||
@click="performSearch">
|
@click="performSearch">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
|
|
||||||
<!-- Advanced Options Toggle -->
|
<!-- Advanced Options Toggle -->
|
||||||
<NcButton
|
<NcButton
|
||||||
type="tertiary"
|
variant="tertiary"
|
||||||
class="mcp-advanced-toggle"
|
class="mcp-advanced-toggle"
|
||||||
@click="showAdvanced = !showAdvanced">
|
@click="showAdvanced = !showAdvanced">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
@@ -93,9 +93,9 @@
|
|||||||
<NcCheckboxRadioSwitch
|
<NcCheckboxRadioSwitch
|
||||||
v-for="docType in docTypeOptions"
|
v-for="docType in docTypeOptions"
|
||||||
:key="docType.id"
|
:key="docType.id"
|
||||||
:checked="selectedDocTypes.includes(docType.id)"
|
:model-value="selectedDocTypes.includes(docType.id)"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@update:checked="toggleDocType(docType.id, $event)">
|
@update:model-value="toggleDocType(docType.id, $event)">
|
||||||
{{ docType.label }}
|
{{ docType.label }}
|
||||||
</NcCheckboxRadioSwitch>
|
</NcCheckboxRadioSwitch>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,9 +152,9 @@
|
|||||||
<div class="mcp-viz-header">
|
<div class="mcp-viz-header">
|
||||||
<h3>{{ t('astrolabe', 'Vector Space Visualization') }}</h3>
|
<h3>{{ t('astrolabe', 'Vector Space Visualization') }}</h3>
|
||||||
<NcCheckboxRadioSwitch
|
<NcCheckboxRadioSwitch
|
||||||
:checked="showQueryPoint"
|
:model-value="showQueryPoint"
|
||||||
type="switch"
|
type="switch"
|
||||||
@update:checked="showQueryPoint = $event; updatePlot()">
|
@update:model-value="showQueryPoint = $event; updatePlot()">
|
||||||
{{ t('astrolabe', 'Show query point') }}
|
{{ t('astrolabe', 'Show query point') }}
|
||||||
</NcCheckboxRadioSwitch>
|
</NcCheckboxRadioSwitch>
|
||||||
</div>
|
</div>
|
||||||
@@ -173,7 +173,7 @@
|
|||||||
<span class="mcp-result-type">{{ result.doc_type || 'unknown' }}</span>
|
<span class="mcp-result-type">{{ result.doc_type || 'unknown' }}</span>
|
||||||
<div class="mcp-result-actions">
|
<div class="mcp-result-actions">
|
||||||
<NcButton
|
<NcButton
|
||||||
type="tertiary"
|
variant="tertiary"
|
||||||
:aria-label="t('astrolabe', 'Show Chunk')"
|
:aria-label="t('astrolabe', 'Show Chunk')"
|
||||||
@click="viewChunk(result)">
|
@click="viewChunk(result)">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
@@ -280,7 +280,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NcButton type="secondary" :disabled="statusLoading" @click="loadVectorStatus">
|
<NcButton variant="secondary" :disabled="statusLoading" @click="loadVectorStatus">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Refresh :size="20" />
|
<Refresh :size="20" />
|
||||||
</template>
|
</template>
|
||||||
@@ -305,7 +305,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<span v-else>{{ viewerTitle }}</span>
|
<span v-else>{{ viewerTitle }}</span>
|
||||||
</h3>
|
</h3>
|
||||||
<NcButton type="tertiary" @click="closeViewer">
|
<NcButton variant="tertiary" @click="closeViewer">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Close :size="20" />
|
<Close :size="20" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<p><strong>{{ t('astrolabe', 'Cannot connect to MCP server') }}</strong></p>
|
<p><strong>{{ t('astrolabe', 'Cannot connect to MCP server') }}</strong></p>
|
||||||
<p>{{ error }}</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>
|
<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>
|
<template #icon>
|
||||||
<Refresh :size="20" />
|
<Refresh :size="20" />
|
||||||
</template>
|
</template>
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
<div class="metric-value">{{ formatNumber(vectorSyncStatus.documents_per_second, 1) }} docs/sec</div>
|
<div class="metric-value">{{ formatNumber(vectorSyncStatus.documents_per_second, 1) }} docs/sec</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NcButton type="secondary" @click="refreshStatus">
|
<NcButton variant="secondary" @click="refreshStatus">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Refresh :size="20" />
|
<Refresh :size="20" />
|
||||||
</template>
|
</template>
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<p v-else>{{ webhooksError }}</p>
|
<p v-else>{{ webhooksError }}</p>
|
||||||
<div class="webhook-auth-actions">
|
<div class="webhook-auth-actions">
|
||||||
<NcButton type="primary" @click="openPersonalSettings">
|
<NcButton variant="primary" @click="openPersonalSettings">
|
||||||
{{ t('astrolabe', 'Go to Personal Settings') }}
|
{{ t('astrolabe', 'Go to Personal Settings') }}
|
||||||
</NcButton>
|
</NcButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="preset-actions">
|
<div class="preset-actions">
|
||||||
<NcButton
|
<NcButton
|
||||||
:type="preset.enabled ? 'secondary' : 'primary'"
|
:variant="preset.enabled ? 'secondary' : 'primary'"
|
||||||
:disabled="preset.toggling"
|
:disabled="preset.toggling"
|
||||||
@click="toggleWebhookPreset(preset)">
|
@click="toggleWebhookPreset(preset)">
|
||||||
{{ preset.toggling ? t('astrolabe', 'Please wait...') : (preset.enabled ? t('astrolabe', 'Disable') : t('astrolabe', 'Enable')) }}
|
{{ preset.toggling ? t('astrolabe', 'Please wait...') : (preset.enabled ? t('astrolabe', 'Disable') : t('astrolabe', 'Enable')) }}
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
<NcSelect
|
<NcSelect
|
||||||
:model-value="selectedAlgorithmOption"
|
:model-value="selectedAlgorithmOption"
|
||||||
:options="algorithmOptions"
|
:options="algorithmOptions"
|
||||||
:label="t('astrolabe', 'Search Algorithm')"
|
:input-label="t('astrolabe', 'Search Algorithm')"
|
||||||
class="form-field"
|
class="form-field"
|
||||||
@update:model-value="settings.algorithm = $event ? $event.id : 'hybrid'" />
|
@update:model-value="settings.algorithm = $event ? $event.id : 'hybrid'" />
|
||||||
<p class="help-text">
|
<p class="help-text">
|
||||||
@@ -164,7 +164,7 @@
|
|||||||
<NcSelect
|
<NcSelect
|
||||||
:model-value="selectedFusionOption"
|
:model-value="selectedFusionOption"
|
||||||
:options="fusionOptions"
|
:options="fusionOptions"
|
||||||
:label="t('astrolabe', 'Fusion Method')"
|
:input-label="t('astrolabe', 'Fusion Method')"
|
||||||
class="form-field"
|
class="form-field"
|
||||||
@update:model-value="settings.fusion = $event ? $event.id : 'rrf'" />
|
@update:model-value="settings.fusion = $event ? $event.id : 'rrf'" />
|
||||||
<p class="help-text">
|
<p class="help-text">
|
||||||
@@ -198,7 +198,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="form-actions">
|
<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') }}
|
{{ saving ? t('astrolabe', 'Saving...') : t('astrolabe', 'Save Settings') }}
|
||||||
</NcButton>
|
</NcButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
script('astrolabe', 'astrolabe-adminSettings');
|
script('astrolabe', 'astrolabe-adminSettings');
|
||||||
style('astrolabe', 'astrolabe-adminSettings');
|
style('astrolabe', 'astrolabe-main'); // All CSS bundled into main
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div id="astrolabe-admin-settings" class="section">
|
<div id="astrolabe-admin-settings" class="section">
|
||||||
|
|||||||
+127
-42
@@ -18,7 +18,7 @@
|
|||||||
$urlGenerator = \OC::$server->getURLGenerator();
|
$urlGenerator = \OC::$server->getURLGenerator();
|
||||||
|
|
||||||
script('astrolabe', 'astrolabe-personalSettings');
|
script('astrolabe', 'astrolabe-personalSettings');
|
||||||
style('astrolabe', 'astrolabe-personalSettings');
|
style('astrolabe', 'astrolabe-main'); // All CSS bundled into main
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
@@ -43,7 +43,17 @@ style('astrolabe', 'astrolabe-personalSettings');
|
|||||||
<div class="section">
|
<div class="section">
|
||||||
<h2><?php p($l->t('Background Sync Access')); ?></h2>
|
<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 -->
|
<!-- Already configured -->
|
||||||
<div class="mcp-background-status">
|
<div class="mcp-background-status">
|
||||||
<p>
|
<p>
|
||||||
@@ -110,54 +120,129 @@ style('astrolabe', 'astrolabe-personalSettings');
|
|||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<!-- Not configured - show provisioning options -->
|
<!-- Not configured - show provisioning options -->
|
||||||
<p class="mcp-help-text">
|
<?php if ($isHybridMode): ?>
|
||||||
<?php p($l->t('Enable background sync to allow the MCP server to access your Nextcloud data for background operations like content indexing.')); ?>
|
<!-- Hybrid mode: User needs BOTH OAuth AND app password -->
|
||||||
</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">
|
<p class="mcp-help-text">
|
||||||
<?php p($l->t('When Nextcloud fully supports OAuth for app APIs. Currently waiting for upstream PR to merge.')); ?>
|
<?php p($l->t('To use semantic search, you need to complete two setup steps:')); ?>
|
||||||
</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.')); ?>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mcp-app-password-steps">
|
<!-- Step 1: OAuth Authorization (for Astrolabe→MCP API calls) -->
|
||||||
<p><strong><?php p($l->t('Step 1:')); ?></strong>
|
<div class="mcp-grant-section">
|
||||||
<a href="<?php p($urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'security'])); ?>" target="_blank">
|
<h4>
|
||||||
<?php p($l->t('Generate app password in Security settings')); ?>
|
<?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>
|
</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>
|
||||||
|
|
||||||
<p><strong><?php p($l->t('Step 2:')); ?></strong> <?php p($l->t('Enter the app password below:')); ?></p>
|
<div class="mcp-app-password-steps">
|
||||||
|
<p><strong><?php p($l->t('Step 1:')); ?></strong>
|
||||||
<form method="post" action="<?php p($urlGenerator->linkToRoute('astrolabe.credentials.storeAppPassword')); ?>" id="mcp-app-password-form">
|
<a href="<?php p($urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'security'])); ?>" target="_blank">
|
||||||
<input type="hidden" name="requesttoken" value="<?php p($_['requesttoken']); ?>">
|
<?php p($l->t('Generate app password in Security settings')); ?>
|
||||||
<div class="mcp-input-group">
|
</a>
|
||||||
<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>
|
</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>
|
||||||
</div>
|
<?php endif; ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</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>
|
||||||
@@ -1988,7 +1988,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.61.3"
|
version = "0.61.5"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
|
|||||||
Reference in New Issue
Block a user