Compare commits
115 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 | |||
| 0a987467b5 | |||
| ab6f7ca0b2 | |||
| 42fa33d0bf | |||
| 006a3d95d6 | |||
| 1835965f44 | |||
| cb4e8acd9f | |||
| 02418a9531 | |||
| f89151d099 | |||
| dc86386bf8 | |||
| 929c40709a | |||
| a60560256d | |||
| aa583ab973 | |||
| 4103924b83 | |||
| c192bd2ec9 | |||
| 2005d2841f | |||
| c6295b48a5 | |||
| 7444c73a5a | |||
| cf0781d2fe | |||
| 6681cd0603 | |||
| c305a549d3 | |||
| 1f1dd94598 | |||
| 01ad2b3d21 | |||
| e4cddef343 | |||
| f15baefe7e | |||
| 585ed46f2d | |||
| dbbbab5320 | |||
| e5844b3da8 | |||
| fdbf88831a | |||
| 6affad1c8b | |||
| 370c3ff444 | |||
| e486e92f91 | |||
| 7465e962d4 | |||
| 99fe764c5e | |||
| 46f896b526 | |||
| a61572e8ef | |||
| a474996df4 | |||
| 5d6dd5ad38 | |||
| 21e4d3effd | |||
| 817df43af1 | |||
| 906b9d892c | |||
| 534723c9f6 | |||
| 1d5832ed3a | |||
| 844bd589e0 | |||
| 127af15623 | |||
| ff5fc5d5b2 | |||
| 158865d99f | |||
| 94674eca27 | |||
| a8b5d6e701 | |||
| e0675b2127 | |||
| 86582bdb8f | |||
| dc8009a785 | |||
| b5e658e1ff | |||
| 6a19c2d136 | |||
| 99e359ffbf | |||
| f16f4e8cb5 | |||
| 8597f2a272 | |||
| 11f67e2bc4 | |||
| 2e49a16e49 | |||
| 713fddeaa5 | |||
| 0dfefb0516 | |||
| 63d2aeaa43 | |||
| 07f0a7c0dc | |||
| 84bde6d5ed | |||
| 9695f8a6d7 | |||
| a2c410e8d2 | |||
| 271b5f6155 | |||
| ba4f7c1429 | |||
| c763e96596 | |||
| 23e9cbaec5 | |||
| ddd5defa40 | |||
| 723dcc524d | |||
| 46eba0a693 | |||
| b61980a623 | |||
| 65cc894e21 | |||
| 700996e100 | |||
| 546f0c0674 | |||
| e625eab689 | |||
| ef9e1b3ff8 | |||
| dd23191987 | |||
| 55312b1032 | |||
| a987643f8e |
@@ -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
|
||||||
@@ -87,21 +87,32 @@ jobs:
|
|||||||
mcp_commit_count=$(git log "$commit_range" --oneline --grep="^(feat|fix|docs|refactor|perf|test|build|ci|chore)" -E | \
|
mcp_commit_count=$(git log "$commit_range" --oneline --grep="^(feat|fix|docs|refactor|perf|test|build|ci|chore)" -E | \
|
||||||
{ grep -v "(helm)" || true; } | { grep -v "(astrolabe)" || true; } | wc -l)
|
{ grep -v "(helm)" || true; } | { grep -v "(astrolabe)" || true; } | wc -l)
|
||||||
|
|
||||||
|
MCP_BUMPED=false
|
||||||
if [ "$mcp_commit_count" -gt 0 ]; then
|
if [ "$mcp_commit_count" -gt 0 ]; then
|
||||||
echo "Found $mcp_commit_count commits for MCP server since $last_mcp_tag"
|
echo "Found $mcp_commit_count commits for MCP server since $last_mcp_tag"
|
||||||
echo "Bumping MCP server version..."
|
echo "Bumping MCP server version..."
|
||||||
./scripts/bump-mcp.sh
|
./scripts/bump-mcp.sh
|
||||||
BUMPED_COMPONENTS="$BUMPED_COMPONENTS mcp"
|
BUMPED_COMPONENTS="$BUMPED_COMPONENTS mcp"
|
||||||
|
MCP_BUMPED=true
|
||||||
else
|
else
|
||||||
echo "No commits found for MCP server since $last_mcp_tag"
|
echo "No commits found for MCP server since $last_mcp_tag"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Bump Helm chart (scope: helm)
|
# Bump Helm chart (scope: helm OR when MCP appVersion changes)
|
||||||
echo "Checking Helm chart for version bump..."
|
echo "Checking Helm chart for version bump..."
|
||||||
|
HELM_HAS_COMMITS=false
|
||||||
if has_commits_since_tag "nextcloud-mcp-server-" "(feat|fix|docs|refactor|perf|test|build|ci|chore)\(helm\)(!)?:"; then
|
if has_commits_since_tag "nextcloud-mcp-server-" "(feat|fix|docs|refactor|perf|test|build|ci|chore)\(helm\)(!)?:"; then
|
||||||
echo "Bumping Helm chart version..."
|
HELM_HAS_COMMITS=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$HELM_HAS_COMMITS" = true ]; then
|
||||||
|
echo "Bumping Helm chart version (helm-scoped commits)..."
|
||||||
./scripts/bump-helm.sh
|
./scripts/bump-helm.sh
|
||||||
BUMPED_COMPONENTS="$BUMPED_COMPONENTS helm"
|
BUMPED_COMPONENTS="$BUMPED_COMPONENTS helm"
|
||||||
|
elif [ "$MCP_BUMPED" = true ]; then
|
||||||
|
echo "Bumping Helm chart version (appVersion changed)..."
|
||||||
|
./scripts/bump-helm.sh --increment PATCH
|
||||||
|
BUMPED_COMPONENTS="$BUMPED_COMPONENTS helm"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Bump Astrolabe (scope: astrolabe)
|
# Bump Astrolabe (scope: astrolabe)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run Claude Code Review
|
- name: Run Claude Code Review
|
||||||
id: claude-review
|
id: claude-review
|
||||||
uses: anthropics/claude-code-action@7145c3e0510bcdbdd29f67cc4a8c1958f1acfa2f # v1
|
uses: anthropics/claude-code-action@1b8ee3b94104046d71fde52ec3557651ad8c0d71 # v1.0.29
|
||||||
with:
|
with:
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
prompt: |
|
prompt: |
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run Claude Code
|
- name: Run Claude Code
|
||||||
id: claude
|
id: claude
|
||||||
uses: anthropics/claude-code-action@7145c3e0510bcdbdd29f67cc4a8c1958f1acfa2f # v1
|
uses: anthropics/claude-code-action@1b8ee3b94104046d71fde52ec3557651ad8c0d71 # v1.0.29
|
||||||
with:
|
with:
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ jobs:
|
|||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- name: Run docker compose with vector sync
|
- name: Run docker compose with vector sync
|
||||||
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
|
uses: hoverkraft-tech/compose-action@05da55b2bb8a5a759d1c4732095044bd9018c050 # v2.4.3
|
||||||
with:
|
with:
|
||||||
compose-file: |
|
compose-file: |
|
||||||
./docker-compose.yml
|
./docker-compose.yml
|
||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
||||||
|
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||||
|
|
||||||
- name: Wait for Nextcloud to be ready
|
- name: Wait for Nextcloud to be ready
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||||
- name: Install Python 3.11
|
- name: Install Python 3.11
|
||||||
run: uv python install 3.11
|
run: uv python install 3.11
|
||||||
- name: Build
|
- name: Build
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||||
- name: Check format
|
- name: Check format
|
||||||
run: |
|
run: |
|
||||||
uv run --frozen ruff format --diff
|
uv run --frozen ruff format --diff
|
||||||
@@ -66,14 +66,14 @@ jobs:
|
|||||||
|
|
||||||
|
|
||||||
- name: Run docker compose
|
- name: Run docker compose
|
||||||
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
|
uses: hoverkraft-tech/compose-action@05da55b2bb8a5a759d1c4732095044bd9018c050 # v2.4.3
|
||||||
with:
|
with:
|
||||||
compose-file: "./docker-compose.yml"
|
compose-file: "./docker-compose.yml"
|
||||||
#compose-flags: "--profile qdrant"
|
#compose-flags: "--profile qdrant"
|
||||||
up-flags: "--build"
|
up-flags: "--build"
|
||||||
|
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||||
|
|
||||||
- name: Install Playwright dependencies
|
- name: Install Playwright dependencies
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -5,6 +5,78 @@ All notable changes to the Nextcloud MCP Server will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
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)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **astrolabe**: address review feedback for Vue 3 bindings
|
||||||
|
- **astrolabe**: update Vue component bindings for Vue 3 compatibility
|
||||||
|
|
||||||
|
## v0.61.2 (2026-01-15)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **ci**: bump helm chart version when MCP appVersion changes
|
||||||
|
|
||||||
|
## v0.61.1 (2026-01-15)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **astrolabe**: define appName and appVersion for @nextcloud/vue
|
||||||
|
|
||||||
|
## v0.61.0 (2026-01-14)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- Add rate limiting and extract helpers for app password endpoints
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Add missing annotations for deck remove/unassign operations
|
||||||
|
- **auth**: Store app passwords locally for multi-user BasicAuth background sync
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- Use get_settings() for vector sync enabled check
|
||||||
|
- Extract storage helper and improve PHP error handling
|
||||||
|
|
||||||
|
## v0.60.4 (2026-01-12)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **deck**: use correct endpoint for reorder_card to fix cross-stack moves
|
||||||
|
|
||||||
|
## v0.60.3 (2025-12-31)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **deck**: Always preserve fields in update_card for partial updates
|
||||||
|
- **astrolabe**: Fix CSS loading for Nextcloud apps
|
||||||
|
- **astrolabe**: Fix revoke access button HTTP method mismatch
|
||||||
|
|
||||||
## v0.60.2 (2025-12-29)
|
## v0.60.2 (2025-12-29)
|
||||||
|
|
||||||
### Fix
|
### Fix
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
|
FROM docker.io/library/python:3.12-slim-trixie@sha256:d75c4b6cdd039ae966a34cd3ccab9e0e5f7299280ad76fe1744882d86eedce0b
|
||||||
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 /uv /uvx /bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.9.26@sha256:9a23023be68b2ed09750ae636228e903a54a05ea56ed03a934d00fe9fbeded4b /uv /uvx /bin/
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
# 1. git (required for caldav dependency from git)
|
# 1. git (required for caldav dependency from git)
|
||||||
|
|||||||
+2
-2
@@ -12,12 +12,12 @@
|
|||||||
# - Per-session app password authentication
|
# - Per-session app password authentication
|
||||||
# - Multi-user support via Smithery session config
|
# - Multi-user support via Smithery session config
|
||||||
|
|
||||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
|
FROM docker.io/library/python:3.12-slim-trixie@sha256:d75c4b6cdd039ae966a34cd3ccab9e0e5f7299280ad76fe1744882d86eedce0b
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install uv for fast dependency management
|
# Install uv for fast dependency management
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 /uv /uvx /bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.9.26@sha256:9a23023be68b2ed09750ae636228e903a54a05ea56ed03a934d00fe9fbeded4b /uv /uvx /bin/
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
# 1. git (required for caldav dependency from git)
|
# 1. git (required for caldav dependency from git)
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Configure MCP server URL for Astrolabe background sync
|
||||||
|
# This URL is used by Astrolabe to send app passwords to the MCP server
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# The MCP multi-user BasicAuth service runs on port 8000 inside the container
|
||||||
|
# From Nextcloud's perspective (inside Docker network), we reach it via service name
|
||||||
|
MCP_SERVER_URL="${MCP_SERVER_URL:-http://mcp-multi-user-basic:8000}"
|
||||||
|
|
||||||
|
echo "Configuring MCP server URL: $MCP_SERVER_URL"
|
||||||
|
|
||||||
|
# Set the mcp_server_url in config.php via occ
|
||||||
|
php occ config:system:set mcp_server_url --value="$MCP_SERVER_URL"
|
||||||
|
|
||||||
|
echo "MCP server URL configured successfully"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[tool.commitizen]
|
[tool.commitizen]
|
||||||
name = "cz_conventional_commits"
|
name = "cz_conventional_commits"
|
||||||
version = "0.56.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,69 @@ 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)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **astrolabe**: address review feedback for Vue 3 bindings
|
||||||
|
- **astrolabe**: update Vue component bindings for Vue 3 compatibility
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.1 (2026-01-15)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **ci**: bump helm chart version when MCP appVersion changes
|
||||||
|
- **astrolabe**: define appName and appVersion for @nextcloud/vue
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.0 (2026-01-15)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- Add rate limiting and extract helpers for app password endpoints
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Add missing annotations for deck remove/unassign operations
|
||||||
|
- **auth**: Store app passwords locally for multi-user BasicAuth background sync
|
||||||
|
- **deck**: use correct endpoint for reorder_card to fix cross-stack moves
|
||||||
|
- **deck**: Always preserve fields in update_card for partial updates
|
||||||
|
- **astrolabe**: Fix CSS loading for Nextcloud apps
|
||||||
|
- **astrolabe**: Fix revoke access button HTTP method mismatch
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- Use get_settings() for vector sync enabled check
|
||||||
|
- Extract storage helper and improve PHP error handling
|
||||||
|
|
||||||
## nextcloud-mcp-server-0.56.2 (2025-12-29)
|
## nextcloud-mcp-server-0.56.2 (2025-12-29)
|
||||||
|
|
||||||
### Fix
|
### Fix
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ dependencies:
|
|||||||
version: 1.16.3
|
version: 1.16.3
|
||||||
- name: ollama
|
- name: ollama
|
||||||
repository: https://otwld.github.io/ollama-helm
|
repository: https://otwld.github.io/ollama-helm
|
||||||
version: 1.36.0
|
version: 1.37.0
|
||||||
digest: sha256:7f0979ec4110ff41ebeb55bf586b41366a350cc39fe65a2da7d2da03f723fe9b
|
digest: sha256:0ce3bb4b5e95a3b8fde3f5f374d7b62aeafcb0dcf8a60b9d95978530b6c05b68
|
||||||
generated: "2025-12-22T11:09:39.166328543Z"
|
generated: "2026-01-08T11:11:12.857375888Z"
|
||||||
|
|||||||
@@ -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.56.2
|
version: 0.57.7
|
||||||
appVersion: "0.60.2"
|
appVersion: "0.61.5"
|
||||||
keywords:
|
keywords:
|
||||||
- nextcloud
|
- nextcloud
|
||||||
- mcp
|
- mcp
|
||||||
@@ -31,6 +31,6 @@ dependencies:
|
|||||||
repository: https://qdrant.github.io/qdrant-helm
|
repository: https://qdrant.github.io/qdrant-helm
|
||||||
condition: qdrant.networkMode.deploySubchart
|
condition: qdrant.networkMode.deploySubchart
|
||||||
- name: ollama
|
- name: ollama
|
||||||
version: "1.36.0"
|
version: "1.37.0"
|
||||||
repository: https://otwld.github.io/ollama-helm
|
repository: https://otwld.github.io/ollama-helm
|
||||||
condition: ollama.enabled
|
condition: ollama.enabled
|
||||||
|
|||||||
+11
-15
@@ -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:53231a9fb9233af2c15bfe70fc03ebe639fd53243fa42a9369884b1e0008deae
|
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
|
||||||
@@ -54,14 +54,14 @@ services:
|
|||||||
retries: 30
|
retries: 30
|
||||||
|
|
||||||
recipes:
|
recipes:
|
||||||
image: docker.io/library/nginx:alpine@sha256:052b75ab72f690f33debaa51c7e08d9b969a0447a133eb2b99cc905d9188cb2b
|
image: docker.io/library/nginx:alpine@sha256:66d420cc54ef85bcc1d72220e83d7aaa6c4850bd2904794e3a56f09fd4ccb66e
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
|
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
|
||||||
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
|
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
|
||||||
unstructured:
|
unstructured:
|
||||||
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:54282d3a25f33fd6cf69bc45b3d37770f213593f58b6dfe5e85fe546376b2807
|
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:db5fcc831eb673ec835c41e8d47f993fdde276562285d6837cebb03f958536a2
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:8002:8000
|
- 127.0.0.1:8002:8000
|
||||||
@@ -88,8 +88,8 @@ services:
|
|||||||
- NEXTCLOUD_PASSWORD=admin
|
- NEXTCLOUD_PASSWORD=admin
|
||||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||||
|
|
||||||
# Vector sync configuration (ADR-007)
|
# Semantic search configuration (ADR-007, ADR-021)
|
||||||
#- VECTOR_SYNC_ENABLED=true
|
#- ENABLE_SEMANTIC_SEARCH=true
|
||||||
- VECTOR_SYNC_SCAN_INTERVAL=60
|
- VECTOR_SYNC_SCAN_INTERVAL=60
|
||||||
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
||||||
|
|
||||||
@@ -140,14 +140,13 @@ services:
|
|||||||
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8003
|
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8003
|
||||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||||
- ENABLE_MULTI_USER_BASIC_AUTH=true
|
- ENABLE_MULTI_USER_BASIC_AUTH=true
|
||||||
#- ENABLE_OFFLINE_ACCESS=true
|
|
||||||
- ENABLE_BACKGROUND_OPERATIONS=true
|
- ENABLE_BACKGROUND_OPERATIONS=true
|
||||||
|
|
||||||
# Token storage (required for middleware initialization)
|
# Token storage (required for middleware initialization)
|
||||||
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
||||||
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
|
||||||
- VECTOR_SYNC_ENABLED=true
|
- ENABLE_SEMANTIC_SEARCH=true
|
||||||
- VECTOR_SYNC_SCAN_INTERVAL=60
|
- VECTOR_SYNC_SCAN_INTERVAL=60
|
||||||
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
||||||
|
|
||||||
@@ -180,7 +179,6 @@ services:
|
|||||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write
|
- NEXTCLOUD_OIDC_SCOPES=openid profile email notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write
|
||||||
|
|
||||||
# Refresh token storage (ADR-002 Tier 1)
|
# Refresh token storage (ADR-002 Tier 1)
|
||||||
#- ENABLE_OFFLINE_ACCESS=true
|
|
||||||
- ENABLE_BACKGROUND_OPERATIONS=true
|
- ENABLE_BACKGROUND_OPERATIONS=true
|
||||||
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
||||||
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
@@ -189,9 +187,8 @@ services:
|
|||||||
# Tokens must contain BOTH MCP and Nextcloud audiences
|
# Tokens must contain BOTH MCP and Nextcloud audiences
|
||||||
# No token exchange needed - tokens work for both MCP auth and Nextcloud APIs
|
# No token exchange needed - tokens work for both MCP auth and Nextcloud APIs
|
||||||
|
|
||||||
# Vector sync configuration (ADR-007)
|
# Semantic search configuration (ADR-007, ADR-021)
|
||||||
- ENABLE_SEMANTIC_SEARCH=true
|
- ENABLE_SEMANTIC_SEARCH=true
|
||||||
#- VECTOR_SYNC_ENABLED=true
|
|
||||||
- VECTOR_SYNC_SCAN_INTERVAL=60
|
- VECTOR_SYNC_SCAN_INTERVAL=60
|
||||||
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
||||||
|
|
||||||
@@ -211,7 +208,7 @@ services:
|
|||||||
- oauth-tokens:/app/data
|
- oauth-tokens:/app/data
|
||||||
|
|
||||||
keycloak:
|
keycloak:
|
||||||
image: quay.io/keycloak/keycloak:26.4.7@sha256:9409c59bdfb65dbffa20b11e6f18b8abb9281d480c7ca402f51ed3d5977e6007
|
image: quay.io/keycloak/keycloak:26.5.1@sha256:b80a48090594367bd8cf6fe2019466ac4ea49de4d0830fb2a43256eda37b18f5
|
||||||
command:
|
command:
|
||||||
- "start-dev"
|
- "start-dev"
|
||||||
- "--import-realm"
|
- "--import-realm"
|
||||||
@@ -259,7 +256,6 @@ services:
|
|||||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8888/realms/nextcloud-mcp
|
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8888/realms/nextcloud-mcp
|
||||||
|
|
||||||
# Refresh token storage (ADR-002 Tier 1 & 2)
|
# Refresh token storage (ADR-002 Tier 1 & 2)
|
||||||
#- ENABLE_OFFLINE_ACCESS=true
|
|
||||||
- ENABLE_BACKGROUND_OPERATIONS=true
|
- ENABLE_BACKGROUND_OPERATIONS=true
|
||||||
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
||||||
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
@@ -293,13 +289,13 @@ services:
|
|||||||
- 127.0.0.1:8081:8081
|
- 127.0.0.1:8081:8081
|
||||||
environment:
|
environment:
|
||||||
- SMITHERY_DEPLOYMENT=true
|
- SMITHERY_DEPLOYMENT=true
|
||||||
- VECTOR_SYNC_ENABLED=false
|
- ENABLE_SEMANTIC_SEARCH=false
|
||||||
- PORT=8081
|
- PORT=8081
|
||||||
profiles:
|
profiles:
|
||||||
- smithery
|
- smithery
|
||||||
|
|
||||||
qdrant:
|
qdrant:
|
||||||
image: qdrant/qdrant:v1.16.2@sha256:dab6de32f7b2cc599985a7c764db3e8b062f70508fb85ca074aa856f829bf335
|
image: docker.io/qdrant/qdrant:v1.16.3@sha256:0425e3e03e7fd9b3dc95c4214546afe19de2eb2e28ca621441a56663ac6e1f46
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:6333:6333 # REST API
|
- 127.0.0.1:6333:6333 # REST API
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
# Webhook Management Guide
|
||||||
|
|
||||||
|
This guide explains how to enable and disable webhooks for vector sync in each MCP server deployment mode. Webhooks enable near-real-time synchronization of content changes to the vector database, complementing the default polling-based sync.
|
||||||
|
|
||||||
|
**Related ADRs:**
|
||||||
|
- ADR-010: Webhook-Based Vector Sync
|
||||||
|
- ADR-020: Deployment Modes and Configuration Validation
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before enabling webhooks, ensure:
|
||||||
|
|
||||||
|
1. **Nextcloud 30+** with `webhook_listeners` app enabled
|
||||||
|
2. **Astrolabe app** installed in Nextcloud (provides settings UI and credentials API)
|
||||||
|
3. **MCP server** accessible from Nextcloud via HTTP(S)
|
||||||
|
4. **Vector sync enabled** on the MCP server
|
||||||
|
|
||||||
|
## Webhook Architecture Overview
|
||||||
|
|
||||||
|
The webhook system has two components:
|
||||||
|
|
||||||
|
1. **Webhook Registration** - Configuring Nextcloud to send change notifications to the MCP server
|
||||||
|
2. **Background Sync Credentials** - Allowing the MCP server to access Nextcloud APIs on behalf of users
|
||||||
|
|
||||||
|
Both must be configured for webhooks to function properly.
|
||||||
|
|
||||||
|
## Deployment Mode Specifics
|
||||||
|
|
||||||
|
### 1. Single-User BasicAuth
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=http://localhost:8080
|
||||||
|
NEXTCLOUD_USERNAME=admin
|
||||||
|
NEXTCLOUD_PASSWORD=password
|
||||||
|
VECTOR_SYNC_ENABLED=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Enable Webhooks:**
|
||||||
|
1. Register webhooks using occ commands (requires Nextcloud admin):
|
||||||
|
```bash
|
||||||
|
# Enable webhook_listeners app
|
||||||
|
php occ app:enable webhook_listeners
|
||||||
|
|
||||||
|
# Register webhooks for vector sync
|
||||||
|
php occ webhook_listeners:add \
|
||||||
|
--event "OCP\Files\Events\Node\NodeCreatedEvent" \
|
||||||
|
--uri "http://mcp-server:8000/webhooks/nextcloud" \
|
||||||
|
--method POST
|
||||||
|
|
||||||
|
# Repeat for other events (see Event Types below)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Optionally reduce polling frequency:
|
||||||
|
```bash
|
||||||
|
VECTOR_SYNC_SCAN_INTERVAL=86400 # 24 hours
|
||||||
|
```
|
||||||
|
|
||||||
|
**Disable Webhooks:**
|
||||||
|
```bash
|
||||||
|
# List registered webhooks
|
||||||
|
php occ webhook_listeners:list
|
||||||
|
|
||||||
|
# Remove specific webhook by ID
|
||||||
|
php occ webhook_listeners:remove <webhook-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Simplest mode - admin credentials used for all operations
|
||||||
|
- No per-user provisioning required
|
||||||
|
- Background sync runs as the configured admin user
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Multi-User BasicAuth Pass-Through
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||||
|
ENABLE_MULTI_USER_BASIC_AUTH=true
|
||||||
|
ENABLE_BACKGROUND_OPERATIONS=true
|
||||||
|
TOKEN_ENCRYPTION_KEY=<key>
|
||||||
|
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
VECTOR_SYNC_ENABLED=true
|
||||||
|
# OAuth client for Astrolabe API access
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Credential Architecture:**
|
||||||
|
This mode uses **two separate credential mechanisms**:
|
||||||
|
|
||||||
|
1. **OAuth Session** (for management API access, including webhooks):
|
||||||
|
- Obtained via browser OAuth flow (`/oauth/login`)
|
||||||
|
- Stores refresh token in MCP server's `tokens.db`
|
||||||
|
- Used for webhook registration/management APIs
|
||||||
|
|
||||||
|
2. **App Password** (for background sync):
|
||||||
|
- Generated in Nextcloud Security settings
|
||||||
|
- Stored encrypted in Nextcloud's `oc_preferences` via Astrolabe
|
||||||
|
- Used by background scanners to access Nextcloud APIs
|
||||||
|
|
||||||
|
**Enable Webhooks:**
|
||||||
|
|
||||||
|
#### Step 1: Complete OAuth Login (for Management API)
|
||||||
|
Users must authorize the MCP server to access their Nextcloud:
|
||||||
|
|
||||||
|
1. Navigate to **Nextcloud Settings → Astrolabe** (Personal settings)
|
||||||
|
2. Click **"Authorize via OAuth"** under "Option 1"
|
||||||
|
3. Complete OAuth consent flow
|
||||||
|
4. Verify the page shows "Background Sync Access: Active"
|
||||||
|
|
||||||
|
#### Step 2: Configure App Password (for Background Sync)
|
||||||
|
Since OAuth refresh tokens have short expiry, users should also configure an app password:
|
||||||
|
|
||||||
|
1. Navigate to **Nextcloud Settings → Security**
|
||||||
|
2. Generate a new app password (name it "Astrolabe" or "MCP Server")
|
||||||
|
3. Return to **Nextcloud Settings → Astrolabe**
|
||||||
|
4. Under "Option 2: App Password", paste the app password
|
||||||
|
5. Click **Save**
|
||||||
|
|
||||||
|
#### Step 3: Register Webhooks (Admin)
|
||||||
|
Same as Single-User BasicAuth:
|
||||||
|
```bash
|
||||||
|
php occ webhook_listeners:add \
|
||||||
|
--event "OCP\Files\Events\Node\NodeCreatedEvent" \
|
||||||
|
--uri "http://mcp-server:8003/webhooks/nextcloud" \
|
||||||
|
--method POST
|
||||||
|
```
|
||||||
|
|
||||||
|
**Disable Webhooks:**
|
||||||
|
|
||||||
|
*Per-User:*
|
||||||
|
1. Navigate to **Nextcloud Settings → Astrolabe**
|
||||||
|
2. Click **"Revoke Access"** (for OAuth tokens) or **"Revoke Access"** (for app password)
|
||||||
|
|
||||||
|
*System-Wide:*
|
||||||
|
```bash
|
||||||
|
php occ webhook_listeners:remove <webhook-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Troubleshooting:**
|
||||||
|
|
||||||
|
If OAuth login fails with "Access forbidden - Your client is not authorized":
|
||||||
|
1. Check if OAuth client is registered:
|
||||||
|
```sql
|
||||||
|
SELECT id, name, client_identifier FROM oc_oidc_clients
|
||||||
|
WHERE dcr = 1 ORDER BY id DESC LIMIT 5;
|
||||||
|
```
|
||||||
|
2. Restart MCP server to trigger DCR re-registration
|
||||||
|
3. Verify `NEXTCLOUD_OIDC_CLIENT_ID` and `NEXTCLOUD_OIDC_CLIENT_SECRET` are set
|
||||||
|
|
||||||
|
If background sync fails with "User no longer provisioned":
|
||||||
|
1. Verify app password is stored:
|
||||||
|
```sql
|
||||||
|
SELECT userid, configkey FROM oc_preferences
|
||||||
|
WHERE appid = 'astrolabe' AND userid = 'username';
|
||||||
|
```
|
||||||
|
2. Ensure user completed **both** OAuth login AND app password setup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. OAuth Single-Audience (Default OAuth Mode)
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||||
|
# No NEXTCLOUD_USERNAME/PASSWORD
|
||||||
|
ENABLE_BACKGROUND_OPERATIONS=true
|
||||||
|
TOKEN_ENCRYPTION_KEY=<key>
|
||||||
|
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
VECTOR_SYNC_ENABLED=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Enable Webhooks:**
|
||||||
|
|
||||||
|
#### Step 1: User Provisioning
|
||||||
|
Users authorize via OAuth with `offline_access` scope:
|
||||||
|
|
||||||
|
1. MCP client initiates OAuth flow
|
||||||
|
2. User consents to requested scopes including `offline_access`
|
||||||
|
3. MCP server stores refresh token for background operations
|
||||||
|
|
||||||
|
Alternatively, via Astrolabe UI:
|
||||||
|
1. Navigate to **Nextcloud Settings → Astrolabe**
|
||||||
|
2. Click **"Authorize via OAuth"**
|
||||||
|
3. Complete consent flow
|
||||||
|
|
||||||
|
#### Step 2: Register Webhooks (Admin)
|
||||||
|
```bash
|
||||||
|
php occ webhook_listeners:add \
|
||||||
|
--event "OCP\Files\Events\Node\NodeCreatedEvent" \
|
||||||
|
--uri "http://mcp-server:8001/webhooks/nextcloud" \
|
||||||
|
--method POST
|
||||||
|
```
|
||||||
|
|
||||||
|
**Disable Webhooks:**
|
||||||
|
|
||||||
|
*Per-User:*
|
||||||
|
- Via Astrolabe UI: Click "Disable Indexing" or "Disconnect"
|
||||||
|
- Via MCP tool: Use `revoke_nextcloud_access` if available
|
||||||
|
|
||||||
|
*System-Wide:*
|
||||||
|
```bash
|
||||||
|
php occ webhook_listeners:remove <webhook-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. OAuth Token Exchange (RFC 8693)
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||||
|
ENABLE_TOKEN_EXCHANGE=true
|
||||||
|
ENABLE_BACKGROUND_OPERATIONS=true
|
||||||
|
TOKEN_ENCRYPTION_KEY=<key>
|
||||||
|
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
VECTOR_SYNC_ENABLED=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Enable/Disable Webhooks:**
|
||||||
|
Same process as OAuth Single-Audience. The token exchange happens transparently when the MCP server accesses Nextcloud APIs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Smithery Stateless
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
- Configuration from session URL params
|
||||||
|
- `VECTOR_SYNC_ENABLED=false` (required)
|
||||||
|
|
||||||
|
**Webhooks:**
|
||||||
|
**Not supported.** This mode is stateless with no persistent storage or background operations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Webhook Event Types
|
||||||
|
|
||||||
|
Register these webhook events for full vector sync coverage:
|
||||||
|
|
||||||
|
### File/Note Events
|
||||||
|
```bash
|
||||||
|
# Use BeforeNodeDeletedEvent for deletions (includes node.id)
|
||||||
|
php occ webhook_listeners:add --event "OCP\Files\Events\Node\NodeCreatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||||
|
php occ webhook_listeners:add --event "OCP\Files\Events\Node\NodeWrittenEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||||
|
php occ webhook_listeners:add --event "OCP\Files\Events\Node\BeforeNodeDeletedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Calendar Events
|
||||||
|
```bash
|
||||||
|
php occ webhook_listeners:add --event "OCP\Calendar\Events\CalendarObjectCreatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||||
|
php occ webhook_listeners:add --event "OCP\Calendar\Events\CalendarObjectUpdatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||||
|
php occ webhook_listeners:add --event "OCP\Calendar\Events\CalendarObjectDeletedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tables Events
|
||||||
|
```bash
|
||||||
|
php occ webhook_listeners:add --event "OCA\Tables\Event\RowAddedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||||
|
php occ webhook_listeners:add --event "OCA\Tables\Event\RowUpdatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||||
|
php occ webhook_listeners:add --event "OCA\Tables\Event\RowDeletedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Webhook Presets (via Astrolabe UI)
|
||||||
|
|
||||||
|
The Astrolabe app provides preset webhook configurations that can be enabled/disabled via the Admin settings UI:
|
||||||
|
|
||||||
|
| Preset | Events Covered |
|
||||||
|
|--------|----------------|
|
||||||
|
| `notes_sync` | File create/update/delete for .md files |
|
||||||
|
| `calendar_sync` | Calendar object events |
|
||||||
|
| `tables_sync` | Tables row events |
|
||||||
|
| `forms_sync` | Forms submission events |
|
||||||
|
| `files_sync` | All file events (optional, high volume) |
|
||||||
|
|
||||||
|
**Enable Presets:**
|
||||||
|
1. Navigate to **Nextcloud Settings → Astrolabe** (Admin settings)
|
||||||
|
2. Toggle desired presets in "Webhook Configuration"
|
||||||
|
|
||||||
|
**Note:** Presets require the MCP server's management API to be accessible. The API uses OAuth bearer tokens from the user's session.
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Webhook Authentication
|
||||||
|
Configure `WEBHOOK_SECRET` to require authentication for incoming webhooks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# MCP Server
|
||||||
|
WEBHOOK_SECRET=<generate-random-secret>
|
||||||
|
|
||||||
|
# Nextcloud webhook registration
|
||||||
|
php occ webhook_listeners:add \
|
||||||
|
--event "..." \
|
||||||
|
--uri "$MCP_URL/webhooks/nextcloud" \
|
||||||
|
--header "Authorization: Bearer <secret>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Token Storage
|
||||||
|
- Refresh tokens and app passwords are encrypted using `TOKEN_ENCRYPTION_KEY`
|
||||||
|
- Store the key securely (environment variable, secrets manager)
|
||||||
|
- Different users have isolated credential storage
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### MCP Server Logs
|
||||||
|
```bash
|
||||||
|
# Docker
|
||||||
|
docker compose logs mcp-multi-user-basic | grep -i webhook
|
||||||
|
|
||||||
|
# Key log messages
|
||||||
|
# - "Queued document from webhook: ..." - Success
|
||||||
|
# - "Webhook authentication failed" - Auth error
|
||||||
|
# - "User X no longer provisioned" - Missing credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nextcloud Logs
|
||||||
|
```bash
|
||||||
|
docker compose exec app cat /var/www/html/data/nextcloud.log | \
|
||||||
|
jq 'select(.message | contains("webhook"))' | tail
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Checks
|
||||||
|
```sql
|
||||||
|
-- Check registered webhooks
|
||||||
|
SELECT * FROM oc_webhook_listeners;
|
||||||
|
|
||||||
|
-- Check OAuth clients
|
||||||
|
SELECT id, name, token_type FROM oc_oidc_clients WHERE dcr = 1;
|
||||||
|
|
||||||
|
-- Check user credentials in Astrolabe
|
||||||
|
SELECT userid, configkey FROM oc_preferences WHERE appid = 'astrolabe';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### "Access forbidden - Your client is not authorized to connect"
|
||||||
|
**Cause:** OAuth client registration expired or not present in Nextcloud
|
||||||
|
**Fix:** Restart MCP server to trigger DCR re-registration
|
||||||
|
|
||||||
|
### "User X no longer provisioned, stopping scanner"
|
||||||
|
**Cause:** Background sync credentials missing or expired
|
||||||
|
**Fix:** User must complete credential provisioning (see mode-specific steps)
|
||||||
|
|
||||||
|
### "Failed to fetch" in browser console during OAuth
|
||||||
|
**Cause:** Network issue between browser and MCP server callback endpoint
|
||||||
|
**Fix:** Verify MCP server is accessible at the configured `NEXTCLOUD_MCP_SERVER_URL`
|
||||||
|
|
||||||
|
### Webhooks not firing
|
||||||
|
**Causes:**
|
||||||
|
1. `webhook_listeners` app not enabled
|
||||||
|
2. Webhook not registered for the event type
|
||||||
|
3. Background job workers not running
|
||||||
|
**Fix:**
|
||||||
|
```bash
|
||||||
|
php occ app:enable webhook_listeners
|
||||||
|
php occ background:cron # or configure systemd cron
|
||||||
|
```
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Add app_passwords table for multi-user BasicAuth mode
|
||||||
|
|
||||||
|
This migration adds support for storing app passwords that are provisioned
|
||||||
|
via Astrolabe's personal settings. This enables background sync in
|
||||||
|
multi-user BasicAuth mode without requiring OAuth.
|
||||||
|
|
||||||
|
Revision ID: 002
|
||||||
|
Revises: 001
|
||||||
|
Create Date: 2026-01-13 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "002"
|
||||||
|
down_revision = "001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Add app_passwords table for multi-user BasicAuth mode."""
|
||||||
|
|
||||||
|
# App passwords table for multi-user BasicAuth background sync
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS app_passwords (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
encrypted_password BLOB NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Index for efficient user lookups
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_app_passwords_updated
|
||||||
|
ON app_passwords(updated_at)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Drop app_passwords table."""
|
||||||
|
|
||||||
|
op.execute("DROP INDEX IF EXISTS idx_app_passwords_updated")
|
||||||
|
op.execute("DROP TABLE IF EXISTS app_passwords")
|
||||||
@@ -10,12 +10,18 @@ All endpoints use OAuth bearer token authentication via UnifiedTokenVerifier.
|
|||||||
The PHP app obtains tokens through PKCE flow and uses them to access these endpoints.
|
The PHP app obtains tokens through PKCE flow and uses them to access these endpoints.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
from importlib.metadata import version
|
from importlib.metadata import version
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import JSONResponse
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
@@ -25,6 +31,23 @@ logger = logging.getLogger(__name__)
|
|||||||
# Get package version from metadata
|
# Get package version from metadata
|
||||||
__version__ = version("nextcloud-mcp-server")
|
__version__ = version("nextcloud-mcp-server")
|
||||||
|
|
||||||
|
# App password format regex (Nextcloud format: xxxxx-xxxxx-xxxxx-xxxxx-xxxxx)
|
||||||
|
APP_PASSWORD_PATTERN = re.compile(
|
||||||
|
r"^[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}$"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timeout for Nextcloud API validation requests (seconds)
|
||||||
|
NEXTCLOUD_VALIDATION_TIMEOUT = 10.0
|
||||||
|
|
||||||
|
# Rate limiting configuration for app password provisioning
|
||||||
|
# Limits: 5 attempts per user per hour
|
||||||
|
RATE_LIMIT_MAX_ATTEMPTS = 5
|
||||||
|
RATE_LIMIT_WINDOW_SECONDS = 3600 # 1 hour
|
||||||
|
|
||||||
|
# In-memory rate limiter storage
|
||||||
|
# Structure: {user_id: [(timestamp, success), ...]}
|
||||||
|
_rate_limit_attempts: dict[str, list[tuple[float, bool]]] = defaultdict(list)
|
||||||
|
|
||||||
# Track server start time for uptime calculation
|
# Track server start time for uptime calculation
|
||||||
_server_start_time = time.time()
|
_server_start_time = time.time()
|
||||||
|
|
||||||
@@ -181,6 +204,141 @@ def _validate_query_string(query: str, max_length: int = 10000) -> None:
|
|||||||
raise ValueError(f"Query too long: maximum {max_length} characters")
|
raise ValueError(f"Query too long: maximum {max_length} characters")
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_app_password_storage(request: Request) -> "RefreshTokenStorage":
|
||||||
|
"""Get or initialize RefreshTokenStorage for app password operations.
|
||||||
|
|
||||||
|
Checks app.state.storage first, then falls back to creating from environment.
|
||||||
|
This helper avoids repeated storage initialization logic across endpoints.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Starlette request with app state
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Initialized RefreshTokenStorage instance
|
||||||
|
"""
|
||||||
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
|
||||||
|
storage = getattr(request.app.state, "storage", None)
|
||||||
|
|
||||||
|
if not storage:
|
||||||
|
# Multi-user BasicAuth mode may not have oauth_context
|
||||||
|
# Initialize storage from environment
|
||||||
|
storage = RefreshTokenStorage.from_env()
|
||||||
|
await storage.initialize()
|
||||||
|
|
||||||
|
return storage
|
||||||
|
|
||||||
|
|
||||||
|
def _check_rate_limit(user_id: str) -> tuple[bool, int]:
|
||||||
|
"""Check if user is rate limited for app password operations.
|
||||||
|
|
||||||
|
Implements a sliding window rate limiter to prevent brute-force attacks
|
||||||
|
on the app password provisioning endpoint.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User identifier to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_allowed, seconds_until_retry)
|
||||||
|
- is_allowed: True if request should be allowed
|
||||||
|
- seconds_until_retry: Seconds to wait if rate limited (0 if allowed)
|
||||||
|
"""
|
||||||
|
current_time = time.time()
|
||||||
|
window_start = current_time - RATE_LIMIT_WINDOW_SECONDS
|
||||||
|
|
||||||
|
# Clean up old attempts outside the window
|
||||||
|
_rate_limit_attempts[user_id] = [
|
||||||
|
(ts, success)
|
||||||
|
for ts, success in _rate_limit_attempts[user_id]
|
||||||
|
if ts > window_start
|
||||||
|
]
|
||||||
|
|
||||||
|
# Count recent attempts (both successful and failed)
|
||||||
|
recent_attempts = len(_rate_limit_attempts[user_id])
|
||||||
|
|
||||||
|
if recent_attempts >= RATE_LIMIT_MAX_ATTEMPTS:
|
||||||
|
# Find when the oldest attempt in the window will expire
|
||||||
|
oldest_attempt = min(ts for ts, _ in _rate_limit_attempts[user_id])
|
||||||
|
seconds_until_retry = int(
|
||||||
|
oldest_attempt + RATE_LIMIT_WINDOW_SECONDS - current_time
|
||||||
|
)
|
||||||
|
return False, max(1, seconds_until_retry)
|
||||||
|
|
||||||
|
return True, 0
|
||||||
|
|
||||||
|
|
||||||
|
def _record_rate_limit_attempt(user_id: str, success: bool) -> None:
|
||||||
|
"""Record an app password provisioning attempt for rate limiting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User identifier
|
||||||
|
success: Whether the attempt was successful
|
||||||
|
"""
|
||||||
|
_rate_limit_attempts[user_id].append((time.time(), success))
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_basic_auth(
|
||||||
|
request: Request, path_user_id: str
|
||||||
|
) -> tuple[str, str, JSONResponse | None]:
|
||||||
|
"""Extract and validate BasicAuth credentials from request.
|
||||||
|
|
||||||
|
Validates:
|
||||||
|
1. Authorization header is present and valid BasicAuth format
|
||||||
|
2. Username in credentials matches the path user_id
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Starlette request with Authorization header
|
||||||
|
path_user_id: User ID from the URL path to verify against
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (username, password, error_response)
|
||||||
|
- If successful: (username, password, None)
|
||||||
|
- If failed: ("", "", JSONResponse with error)
|
||||||
|
"""
|
||||||
|
auth_header = request.headers.get("Authorization")
|
||||||
|
|
||||||
|
if not auth_header or not auth_header.startswith("Basic "):
|
||||||
|
return (
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
JSONResponse(
|
||||||
|
{"success": False, "error": "Missing BasicAuth credentials"},
|
||||||
|
status_code=401,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Decode BasicAuth
|
||||||
|
encoded = auth_header.split(" ", 1)[1]
|
||||||
|
decoded = base64.b64decode(encoded).decode("utf-8")
|
||||||
|
username, password = decoded.split(":", 1)
|
||||||
|
except Exception:
|
||||||
|
return (
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
JSONResponse(
|
||||||
|
{"success": False, "error": "Invalid BasicAuth format"},
|
||||||
|
status_code=401,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify username matches path user_id
|
||||||
|
if username != path_user_id:
|
||||||
|
logger.warning(
|
||||||
|
f"Username mismatch in app password operation for path user {path_user_id}"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
JSONResponse(
|
||||||
|
{"success": False, "error": "Username does not match path user_id"},
|
||||||
|
status_code=403,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return username, password, None
|
||||||
|
|
||||||
|
|
||||||
async def get_server_status(request: Request) -> JSONResponse:
|
async def get_server_status(request: Request) -> JSONResponse:
|
||||||
"""GET /api/v1/status - Server status and version.
|
"""GET /api/v1/status - Server status and version.
|
||||||
|
|
||||||
@@ -229,8 +387,13 @@ async def get_server_status(request: Request) -> JSONResponse:
|
|||||||
if mode == AuthMode.MULTI_USER_BASIC:
|
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 = {}
|
||||||
|
|
||||||
@@ -510,6 +673,254 @@ async def revoke_user_access(request: Request) -> JSONResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def provision_app_password(request: Request) -> JSONResponse:
|
||||||
|
"""POST /api/v1/users/{user_id}/app-password - Store app password for background sync.
|
||||||
|
|
||||||
|
This endpoint is used by Astrolabe (Nextcloud PHP app) to provision app passwords
|
||||||
|
for multi-user BasicAuth mode background sync.
|
||||||
|
|
||||||
|
The request must include BasicAuth credentials where:
|
||||||
|
- username: Nextcloud user ID (must match path user_id)
|
||||||
|
- password: The app password being provisioned
|
||||||
|
|
||||||
|
The MCP server validates the app password against Nextcloud before storing it.
|
||||||
|
This proves the user owns the password and has access to Nextcloud.
|
||||||
|
|
||||||
|
Security model:
|
||||||
|
- User identity is verified via BasicAuth against Nextcloud
|
||||||
|
- App password is encrypted before storage
|
||||||
|
- Only the user who owns the password can provision it
|
||||||
|
- Rate limited to prevent brute-force attacks
|
||||||
|
"""
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
# Get user_id from path
|
||||||
|
path_user_id = request.path_params.get("user_id")
|
||||||
|
if not path_user_id:
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Missing user_id in path"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check rate limit before processing
|
||||||
|
is_allowed, retry_after = _check_rate_limit(path_user_id)
|
||||||
|
if not is_allowed:
|
||||||
|
logger.warning(
|
||||||
|
f"Rate limit exceeded for app password provisioning: {path_user_id}"
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": f"Rate limit exceeded. Try again in {retry_after} seconds.",
|
||||||
|
},
|
||||||
|
status_code=429,
|
||||||
|
headers={"Retry-After": str(retry_after)},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract and validate BasicAuth credentials
|
||||||
|
username, app_password, error_response = _extract_basic_auth(request, path_user_id)
|
||||||
|
if error_response is not None:
|
||||||
|
_record_rate_limit_attempt(path_user_id, success=False)
|
||||||
|
return error_response
|
||||||
|
|
||||||
|
# Validate app password format
|
||||||
|
if not APP_PASSWORD_PATTERN.match(app_password):
|
||||||
|
_record_rate_limit_attempt(path_user_id, success=False)
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Invalid app password format"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get Nextcloud host from settings
|
||||||
|
settings = get_settings()
|
||||||
|
nextcloud_host = settings.nextcloud_host
|
||||||
|
|
||||||
|
if not nextcloud_host:
|
||||||
|
logger.error("NEXTCLOUD_HOST not configured")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Server not configured"},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate app password against Nextcloud
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=NEXTCLOUD_VALIDATION_TIMEOUT) as client:
|
||||||
|
# Use OCS API to verify credentials
|
||||||
|
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
|
||||||
|
response = await client.get(
|
||||||
|
test_url,
|
||||||
|
auth=(username, app_password),
|
||||||
|
params={"format": "json"},
|
||||||
|
headers={"OCS-APIRequest": "true"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning(
|
||||||
|
f"App password validation failed for user: HTTP {response.status_code}"
|
||||||
|
)
|
||||||
|
_record_rate_limit_attempt(path_user_id, success=False)
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Invalid app password"},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the user ID from response matches
|
||||||
|
data = response.json()
|
||||||
|
ocs_user_id = data.get("ocs", {}).get("data", {}).get("id")
|
||||||
|
if ocs_user_id != username:
|
||||||
|
logger.warning("User ID mismatch in OCS response")
|
||||||
|
_record_rate_limit_attempt(path_user_id, success=False)
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "User ID mismatch"},
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"Failed to validate app password: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Failed to validate credentials"},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store the validated app password
|
||||||
|
try:
|
||||||
|
storage = await _get_app_password_storage(request)
|
||||||
|
await storage.store_app_password(username, app_password)
|
||||||
|
|
||||||
|
_record_rate_limit_attempt(path_user_id, success=True)
|
||||||
|
logger.info(f"Provisioned app password for user: {username}")
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": f"App password stored for {username}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = _sanitize_error_for_client(e, "provision_app_password")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": error_msg},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_app_password_status(request: Request) -> JSONResponse:
|
||||||
|
"""GET /api/v1/users/{user_id}/app-password - Check if user has provisioned app password.
|
||||||
|
|
||||||
|
Returns status of background sync access for multi-user BasicAuth mode.
|
||||||
|
|
||||||
|
Requires BasicAuth with the user's app password for authentication.
|
||||||
|
"""
|
||||||
|
# Get user_id from path
|
||||||
|
path_user_id = request.path_params.get("user_id")
|
||||||
|
if not path_user_id:
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Missing user_id in path"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract and validate BasicAuth credentials
|
||||||
|
username, _, error_response = _extract_basic_auth(request, path_user_id)
|
||||||
|
if error_response is not None:
|
||||||
|
return error_response
|
||||||
|
|
||||||
|
try:
|
||||||
|
storage = await _get_app_password_storage(request)
|
||||||
|
app_password = await storage.get_app_password(username)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"user_id": username,
|
||||||
|
"has_app_password": app_password is not None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = _sanitize_error_for_client(e, "get_app_password_status")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": error_msg},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_app_password(request: Request) -> JSONResponse:
|
||||||
|
"""DELETE /api/v1/users/{user_id}/app-password - Delete stored app password.
|
||||||
|
|
||||||
|
Removes the user's app password from MCP server storage.
|
||||||
|
|
||||||
|
Requires BasicAuth with the user's credentials.
|
||||||
|
"""
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
# Get user_id from path
|
||||||
|
path_user_id = request.path_params.get("user_id")
|
||||||
|
if not path_user_id:
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Missing user_id in path"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract and validate BasicAuth credentials
|
||||||
|
username, password, error_response = _extract_basic_auth(request, path_user_id)
|
||||||
|
if error_response is not None:
|
||||||
|
return error_response
|
||||||
|
|
||||||
|
# Validate credentials against Nextcloud
|
||||||
|
settings = get_settings()
|
||||||
|
nextcloud_host = settings.nextcloud_host
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=NEXTCLOUD_VALIDATION_TIMEOUT) as client:
|
||||||
|
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
|
||||||
|
response = await client.get(
|
||||||
|
test_url,
|
||||||
|
auth=(username, password),
|
||||||
|
params={"format": "json"},
|
||||||
|
headers={"OCS-APIRequest": "true"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Invalid credentials"},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"Failed to validate credentials: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Failed to validate credentials"},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
storage = await _get_app_password_storage(request)
|
||||||
|
deleted = await storage.delete_app_password(username)
|
||||||
|
|
||||||
|
if deleted:
|
||||||
|
logger.info(f"Deleted app password for user: {username}")
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": f"App password deleted for {username}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": "No app password found to delete",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = _sanitize_error_for_client(e, "delete_app_password")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": error_msg},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_installed_apps(request: Request) -> JSONResponse:
|
async def get_installed_apps(request: Request) -> JSONResponse:
|
||||||
"""GET /api/v1/apps - Get list of installed Nextcloud apps.
|
"""GET /api/v1/apps - Get list of installed Nextcloud apps.
|
||||||
|
|
||||||
|
|||||||
@@ -2012,7 +2012,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
checks["auth_mode"] = "multi_user_basic"
|
checks["auth_mode"] = "multi_user_basic"
|
||||||
checks["auth_configured"] = "ok"
|
checks["auth_configured"] = "ok"
|
||||||
# Indicate if app passwords are supported (when offline_access enabled)
|
# Indicate if app passwords are supported (when offline_access enabled)
|
||||||
checks["supports_app_passwords"] = settings.enable_offline_access
|
checks["supports_app_passwords"] = get_settings().enable_offline_access
|
||||||
elif mode == AuthMode.SINGLE_USER_BASIC:
|
elif mode == AuthMode.SINGLE_USER_BASIC:
|
||||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||||
password = os.getenv("NEXTCLOUD_PASSWORD")
|
password = os.getenv("NEXTCLOUD_PASSWORD")
|
||||||
@@ -2029,9 +2029,9 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
|
|
||||||
# Check Qdrant status if using network mode (external Qdrant service)
|
# Check Qdrant status if using network mode (external Qdrant service)
|
||||||
# In-memory and persistent modes use embedded Qdrant, no external service to check
|
# In-memory and persistent modes use embedded Qdrant, no external service to check
|
||||||
vector_sync_enabled = (
|
# Note: get_settings() supports both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED
|
||||||
os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
|
settings = get_settings()
|
||||||
)
|
vector_sync_enabled = settings.vector_sync_enabled
|
||||||
qdrant_url = os.getenv("QDRANT_URL") # Only set in network mode
|
qdrant_url = os.getenv("QDRANT_URL") # Only set in network mode
|
||||||
|
|
||||||
if vector_sync_enabled and qdrant_url:
|
if vector_sync_enabled and qdrant_url:
|
||||||
@@ -2114,13 +2114,16 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
if enable_management_apis:
|
if enable_management_apis:
|
||||||
from nextcloud_mcp_server.api.management import (
|
from nextcloud_mcp_server.api.management import (
|
||||||
create_webhook,
|
create_webhook,
|
||||||
|
delete_app_password,
|
||||||
delete_webhook,
|
delete_webhook,
|
||||||
|
get_app_password_status,
|
||||||
get_chunk_context,
|
get_chunk_context,
|
||||||
get_installed_apps,
|
get_installed_apps,
|
||||||
get_server_status,
|
get_server_status,
|
||||||
get_user_session,
|
get_user_session,
|
||||||
get_vector_sync_status,
|
get_vector_sync_status,
|
||||||
list_webhooks,
|
list_webhooks,
|
||||||
|
provision_app_password,
|
||||||
revoke_user_access,
|
revoke_user_access,
|
||||||
unified_search,
|
unified_search,
|
||||||
vector_search,
|
vector_search,
|
||||||
@@ -2148,6 +2151,28 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
methods=["POST"],
|
methods=["POST"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# App password endpoints for multi-user BasicAuth mode
|
||||||
|
routes.append(
|
||||||
|
Route(
|
||||||
|
"/api/v1/users/{user_id}/app-password",
|
||||||
|
provision_app_password,
|
||||||
|
methods=["POST"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
routes.append(
|
||||||
|
Route(
|
||||||
|
"/api/v1/users/{user_id}/app-password",
|
||||||
|
get_app_password_status,
|
||||||
|
methods=["GET"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
routes.append(
|
||||||
|
Route(
|
||||||
|
"/api/v1/users/{user_id}/app-password",
|
||||||
|
delete_app_password,
|
||||||
|
methods=["DELETE"],
|
||||||
|
)
|
||||||
|
)
|
||||||
routes.append(
|
routes.append(
|
||||||
Route("/api/v1/vector-viz/search", vector_search, methods=["POST"])
|
Route("/api/v1/vector-viz/search", vector_search, methods=["POST"])
|
||||||
)
|
)
|
||||||
@@ -2166,6 +2191,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
logger.info(
|
logger.info(
|
||||||
"Management API endpoints enabled: /api/v1/status, /api/v1/vector-sync/status, "
|
"Management API endpoints enabled: /api/v1/status, /api/v1/vector-sync/status, "
|
||||||
"/api/v1/users/{user_id}/session, /api/v1/users/{user_id}/revoke, "
|
"/api/v1/users/{user_id}/session, /api/v1/users/{user_id}/revoke, "
|
||||||
|
"/api/v1/users/{user_id}/app-password, "
|
||||||
"/api/v1/vector-viz/search, /api/v1/search, /api/v1/apps, "
|
"/api/v1/vector-viz/search, /api/v1/search, /api/v1/apps, "
|
||||||
"/api/v1/webhooks"
|
"/api/v1/webhooks"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1240,6 +1240,180 @@ class RefreshTokenStorage:
|
|||||||
|
|
||||||
return deleted
|
return deleted
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# App Password Storage (multi-user BasicAuth mode)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def store_app_password(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
app_password: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Store encrypted app password for background sync (multi-user BasicAuth mode).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Nextcloud user ID
|
||||||
|
app_password: Nextcloud app password to store
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
if not self.cipher:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Encryption key not configured. "
|
||||||
|
"Set TOKEN_ENCRYPTION_KEY for app password storage."
|
||||||
|
)
|
||||||
|
|
||||||
|
encrypted_password = self.cipher.encrypt(app_password.encode())
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO app_passwords
|
||||||
|
(user_id, encrypted_password, created_at, updated_at)
|
||||||
|
VALUES (
|
||||||
|
?,
|
||||||
|
?,
|
||||||
|
COALESCE((SELECT created_at FROM app_passwords WHERE user_id = ?), ?),
|
||||||
|
?
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(user_id, encrypted_password, user_id, now, now),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "insert", duration, "success")
|
||||||
|
logger.info(f"Stored app password for user {user_id}")
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "insert", duration, "error")
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
await self._audit_log(
|
||||||
|
event="store_app_password",
|
||||||
|
user_id=user_id,
|
||||||
|
auth_method="app_password",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_app_password(self, user_id: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Retrieve and decrypt app password for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Nextcloud user ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decrypted app password, or None if not found
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
if not self.cipher:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Encryption key not configured. "
|
||||||
|
"Set TOKEN_ENCRYPTION_KEY for app password retrieval."
|
||||||
|
)
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
async with db.execute(
|
||||||
|
"SELECT encrypted_password FROM app_passwords WHERE user_id = ?",
|
||||||
|
(user_id,),
|
||||||
|
) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
logger.debug(f"No app password found for user {user_id}")
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "select", duration, "success")
|
||||||
|
return None
|
||||||
|
|
||||||
|
encrypted_password = row[0]
|
||||||
|
decrypted_password = self.cipher.decrypt(encrypted_password).decode()
|
||||||
|
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "select", duration, "success")
|
||||||
|
logger.debug(f"Retrieved app password for user {user_id}")
|
||||||
|
|
||||||
|
return decrypted_password
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "select", duration, "error")
|
||||||
|
logger.error(f"Failed to decrypt app password for user {user_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def delete_app_password(self, user_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Delete app password for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Nextcloud user ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if password was deleted, False if not found
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"DELETE FROM app_passwords WHERE user_id = ?",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
deleted = cursor.rowcount > 0
|
||||||
|
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "delete", duration, "success")
|
||||||
|
|
||||||
|
if deleted:
|
||||||
|
logger.info(f"Deleted app password for user {user_id}")
|
||||||
|
await self._audit_log(
|
||||||
|
event="delete_app_password",
|
||||||
|
user_id=user_id,
|
||||||
|
auth_method="app_password",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(f"No app password to delete for user {user_id}")
|
||||||
|
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "delete", duration, "error")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get_all_app_password_user_ids(self) -> list[str]:
|
||||||
|
"""
|
||||||
|
Get list of all user IDs with stored app passwords.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of user IDs
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
async with db.execute(
|
||||||
|
"SELECT user_id FROM app_passwords ORDER BY updated_at DESC"
|
||||||
|
) as cursor:
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
|
||||||
|
user_ids = [row[0] for row in rows]
|
||||||
|
logger.debug(f"Found {len(user_ids)} users with app passwords")
|
||||||
|
return user_ids
|
||||||
|
|
||||||
|
|
||||||
async def generate_encryption_key() -> str:
|
async def generate_encryption_key() -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from starlette.requests import Request
|
|||||||
from starlette.responses import HTMLResponse, JSONResponse
|
from starlette.responses import HTMLResponse, JSONResponse
|
||||||
|
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -106,9 +107,9 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None:
|
|||||||
"status": str, # "syncing" or "idle"
|
"status": str, # "syncing" or "idle"
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
# Check if vector sync is enabled
|
# Check if vector sync is enabled (supports both old and new env var names)
|
||||||
vector_sync_enabled = os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
|
settings = get_settings()
|
||||||
if not vector_sync_enabled:
|
if not settings.vector_sync_enabled:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -127,10 +128,8 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None:
|
|||||||
# Get Qdrant client and query indexed count
|
# Get Qdrant client and query indexed count
|
||||||
indexed_count = 0
|
indexed_count = 0
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
qdrant_client = await get_qdrant_client()
|
qdrant_client = await get_qdrant_client()
|
||||||
|
|
||||||
# Count documents in collection
|
# Count documents in collection
|
||||||
@@ -634,7 +633,9 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Check if vector sync is enabled (needed for Welcome tab)
|
# Check if vector sync is enabled (needed for Welcome tab)
|
||||||
vector_sync_enabled = os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
|
# Note: get_settings() supports both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED
|
||||||
|
settings = get_settings()
|
||||||
|
vector_sync_enabled = settings.vector_sync_enabled
|
||||||
|
|
||||||
# Render template
|
# Render template
|
||||||
template = _jinja_env.get_template("user_info.html")
|
template = _jinja_env.get_template("user_info.html")
|
||||||
|
|||||||
@@ -386,11 +386,17 @@ class DeckClient(BaseNextcloudClient):
|
|||||||
order: int,
|
order: int,
|
||||||
target_stack_id: int,
|
target_stack_id: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
# Use the non-API route /cards/{cardId}/reorder which correctly reads
|
||||||
|
# stackId from the body. The API route /api/.../stacks/{stackId}/cards/...
|
||||||
|
# has a parameter conflict where URL stackId overrides body stackId.
|
||||||
|
# See: https://github.com/cbcoutinho/nextcloud-mcp-server/issues/469
|
||||||
json_data = {"order": order, "stackId": target_stack_id}
|
json_data = {"order": order, "stackId": target_stack_id}
|
||||||
|
headers = self._get_deck_headers()
|
||||||
await self._make_request(
|
await self._make_request(
|
||||||
"PUT",
|
"PUT",
|
||||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/reorder",
|
f"/apps/deck/cards/{card_id}/reorder",
|
||||||
json=json_data,
|
json=json_data,
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Labels
|
# Labels
|
||||||
|
|||||||
@@ -637,7 +637,9 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
title="Remove Label from Deck Card",
|
title="Remove Label from Deck Card",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
@@ -692,7 +694,9 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
title="Unassign User from Deck Card",
|
title="Unassign User from Deck Card",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Semantic search MCP tools using vector database."""
|
"""Semantic search MCP tools using vector database."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
from httpx import RequestError
|
from httpx import RequestError
|
||||||
@@ -658,12 +657,11 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
after creating or updating content across all indexed apps.
|
after creating or updating content across all indexed apps.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Check if vector sync is enabled
|
# Check if vector sync is enabled (supports both old and new env var names)
|
||||||
vector_sync_enabled = (
|
from nextcloud_mcp_server.config import get_settings
|
||||||
os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not vector_sync_enabled:
|
settings = get_settings()
|
||||||
|
if not settings.vector_sync_enabled:
|
||||||
return VectorSyncStatusResponse(
|
return VectorSyncStatusResponse(
|
||||||
indexed_count=0,
|
indexed_count=0,
|
||||||
pending_count=0,
|
pending_count=0,
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ Manages background vector sync for multi-user deployments:
|
|||||||
Authentication strategies are mutually exclusive by deployment mode:
|
Authentication strategies are mutually exclusive by deployment mode:
|
||||||
|
|
||||||
Multi-user BasicAuth mode (ENABLE_MULTI_USER_BASIC_AUTH=true):
|
Multi-user BasicAuth mode (ENABLE_MULTI_USER_BASIC_AUTH=true):
|
||||||
- Uses app passwords obtained via Astrolabe Management API
|
- Uses app passwords stored locally in MCP server's database
|
||||||
- Users provision via Astrolabe personal settings
|
- Users provision via Astrolabe personal settings, which sends to MCP API
|
||||||
- OAuth is NOT used
|
- OAuth is NOT used
|
||||||
|
|
||||||
OAuth mode (with external IdP like Keycloak):
|
OAuth mode (with external IdP like Keycloak):
|
||||||
@@ -33,7 +33,6 @@ from anyio.streams.memory import (
|
|||||||
)
|
)
|
||||||
from httpx import BasicAuth
|
from httpx import BasicAuth
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
|
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
from nextcloud_mcp_server.vector.scanner import DocumentTask, scan_user_documents
|
from nextcloud_mcp_server.vector.scanner import DocumentTask, scan_user_documents
|
||||||
@@ -71,15 +70,18 @@ class UserSyncState:
|
|||||||
async def get_user_client_basic_auth(
|
async def get_user_client_basic_auth(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
nextcloud_host: str,
|
nextcloud_host: str,
|
||||||
|
storage: "RefreshTokenStorage | None" = None,
|
||||||
) -> NextcloudClient:
|
) -> NextcloudClient:
|
||||||
"""Get an authenticated NextcloudClient using app password (BasicAuth mode).
|
"""Get an authenticated NextcloudClient using app password (BasicAuth mode).
|
||||||
|
|
||||||
For multi-user BasicAuth deployments where users provision app passwords
|
For multi-user BasicAuth deployments where users provision app passwords
|
||||||
via Astrolabe personal settings. OAuth is NOT used in this mode.
|
via Astrolabe personal settings. The app password is stored locally in the
|
||||||
|
MCP server's database after being provisioned through the management API.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_id: User identifier
|
user_id: User identifier
|
||||||
nextcloud_host: Nextcloud base URL
|
nextcloud_host: Nextcloud base URL
|
||||||
|
storage: Optional RefreshTokenStorage instance (created from env if not provided)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Authenticated NextcloudClient with BasicAuth
|
Authenticated NextcloudClient with BasicAuth
|
||||||
@@ -87,21 +89,15 @@ async def get_user_client_basic_auth(
|
|||||||
Raises:
|
Raises:
|
||||||
NotProvisionedError: If user has not provisioned an app password
|
NotProvisionedError: If user has not provisioned an app password
|
||||||
"""
|
"""
|
||||||
settings = get_settings()
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
|
||||||
if not settings.oidc_client_id or not settings.oidc_client_secret:
|
# Get or create storage instance
|
||||||
raise NotProvisionedError(
|
if storage is None:
|
||||||
"Astrolabe client credentials not configured. "
|
storage = RefreshTokenStorage.from_env()
|
||||||
"Set OIDC_CLIENT_ID and OIDC_CLIENT_SECRET for app password retrieval."
|
await storage.initialize()
|
||||||
)
|
|
||||||
|
|
||||||
astrolabe = AstrolabeClient(
|
# Retrieve app password from local storage
|
||||||
nextcloud_host=nextcloud_host,
|
app_password = await storage.get_app_password(user_id)
|
||||||
client_id=settings.oidc_client_id,
|
|
||||||
client_secret=settings.oidc_client_secret,
|
|
||||||
)
|
|
||||||
|
|
||||||
app_password = await astrolabe.get_user_app_password(user_id)
|
|
||||||
|
|
||||||
if not app_password:
|
if not app_password:
|
||||||
raise NotProvisionedError(
|
raise NotProvisionedError(
|
||||||
@@ -419,8 +415,15 @@ async def user_manager_task(
|
|||||||
|
|
||||||
while not shutdown_event.is_set():
|
while not shutdown_event.is_set():
|
||||||
try:
|
try:
|
||||||
# Get current provisioned users
|
# Get current provisioned users based on mode
|
||||||
provisioned_users = set(await refresh_token_storage.get_all_user_ids())
|
if use_basic_auth:
|
||||||
|
# BasicAuth mode: query app_passwords table
|
||||||
|
provisioned_users = set(
|
||||||
|
await refresh_token_storage.get_all_app_password_user_ids()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# OAuth mode: query refresh_tokens table
|
||||||
|
provisioned_users = set(await refresh_token_storage.get_all_user_ids())
|
||||||
active_users = set(user_states.keys())
|
active_users = set(user_states.keys())
|
||||||
|
|
||||||
# Start scanners for new users
|
# Start scanners for new users
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.60.2"
|
version = "0.61.5"
|
||||||
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
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"}
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
"""Integration tests for app password provisioning via Astrolabe.
|
"""Integration tests for app password provisioning via management API.
|
||||||
|
|
||||||
Tests the complete flow for multi-user BasicAuth mode:
|
Tests the complete flow for multi-user BasicAuth mode:
|
||||||
1. User stores app password via Astrolabe API
|
1. User stores app password via management API endpoint
|
||||||
2. MCP server retrieves it via OAuth client credentials
|
2. MCP server stores it locally (encrypted)
|
||||||
3. Background sync uses it to access Nextcloud (NOT OAuth refresh tokens)
|
3. Background sync uses locally stored password to access Nextcloud
|
||||||
|
|
||||||
These tests verify that BasicAuth and OAuth are completely separate concerns
|
These tests verify that BasicAuth and OAuth are completely separate concerns
|
||||||
with no fallback between them.
|
with no fallback between them.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
|
import pytest
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
from nextcloud_mcp_server.vector.oauth_sync import (
|
from nextcloud_mcp_server.vector.oauth_sync import (
|
||||||
NotProvisionedError,
|
NotProvisionedError,
|
||||||
get_user_client,
|
get_user_client,
|
||||||
@@ -21,140 +24,60 @@ from nextcloud_mcp_server.vector.oauth_sync import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.fixture
|
||||||
async def test_astrolabe_client_initialization():
|
def encryption_key():
|
||||||
"""Test AstrolabeClient can be instantiated."""
|
"""Generate a test encryption key."""
|
||||||
client = AstrolabeClient(
|
return Fernet.generate_key().decode()
|
||||||
nextcloud_host="http://localhost:8080",
|
|
||||||
client_id="test-client",
|
|
||||||
client_secret="test-secret",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert client is not None
|
|
||||||
assert client.nextcloud_host == "http://localhost:8080"
|
@pytest.fixture
|
||||||
assert client.client_id == "test-client"
|
async def temp_storage(encryption_key):
|
||||||
assert client.client_secret == "test-secret"
|
"""Create temporary storage instance with encryption for testing."""
|
||||||
assert client._token_cache is None
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
db_path = Path(tmpdir) / "test_provisioning.db"
|
||||||
|
storage = RefreshTokenStorage(
|
||||||
|
db_path=str(db_path), encryption_key=encryption_key
|
||||||
|
)
|
||||||
|
await storage.initialize()
|
||||||
|
yield storage
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
async def test_astrolabe_client_get_access_token_requires_oidc():
|
async def test_basic_auth_mode_uses_local_storage(temp_storage, mocker):
|
||||||
"""Test that getting access token requires OIDC discovery endpoint."""
|
"""Test that BasicAuth mode uses locally stored app passwords.
|
||||||
client = AstrolabeClient(
|
|
||||||
nextcloud_host="http://localhost:8080",
|
|
||||||
client_id="test-client",
|
|
||||||
client_secret="test-secret",
|
|
||||||
)
|
|
||||||
|
|
||||||
# This will fail without proper OIDC setup, which is expected
|
In multi-user BasicAuth mode, app passwords are stored locally
|
||||||
# The test verifies the client follows the OAuth client credentials flow
|
in the MCP server's database after being provisioned via the API.
|
||||||
try:
|
|
||||||
token = await client.get_access_token()
|
|
||||||
# If we get here, OIDC is configured
|
|
||||||
assert token is not None
|
|
||||||
except Exception as e:
|
|
||||||
# Expected if OIDC not fully configured for test client
|
|
||||||
# 400/401/403/404 all indicate the flow is working but credentials are invalid
|
|
||||||
assert any(code in str(e) for code in ["400", "401", "403", "404"])
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
async def test_get_user_app_password_returns_none_for_unconfigured_user():
|
|
||||||
"""Test that get_user_app_password returns None for users without app passwords."""
|
|
||||||
# This requires valid OAuth client credentials
|
|
||||||
settings = get_settings()
|
|
||||||
|
|
||||||
if not settings.oidc_client_id or not settings.oidc_client_secret:
|
|
||||||
pytest.skip("OAuth client credentials not configured")
|
|
||||||
|
|
||||||
client = AstrolabeClient(
|
|
||||||
nextcloud_host=settings.nextcloud_host or "http://localhost:8080",
|
|
||||||
client_id=settings.oidc_client_id,
|
|
||||||
client_secret=settings.oidc_client_secret,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try to get app password for a user that hasn't provisioned one
|
|
||||||
try:
|
|
||||||
app_password = await client.get_user_app_password("nonexistent_user")
|
|
||||||
# Should return None for unconfigured user (404 response)
|
|
||||||
assert app_password is None
|
|
||||||
except Exception as e:
|
|
||||||
# May fail with auth error if OAuth not fully configured
|
|
||||||
assert any(code in str(e) for code in ["400", "401", "403", "404"])
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
async def test_basic_auth_mode_uses_app_password_only(mocker):
|
|
||||||
"""Test that BasicAuth mode uses ONLY app passwords, NOT OAuth tokens.
|
|
||||||
|
|
||||||
In multi-user BasicAuth mode, OAuth refresh tokens are NOT used.
|
|
||||||
This is a complete separation of concerns.
|
|
||||||
"""
|
"""
|
||||||
# Mock settings to have client credentials
|
# Store an app password in local storage
|
||||||
mock_settings = mocker.MagicMock()
|
await temp_storage.store_app_password("test_user", "JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB")
|
||||||
mock_settings.oidc_client_id = "test-client-id"
|
|
||||||
mock_settings.oidc_client_secret = "test-client-secret"
|
|
||||||
mocker.patch(
|
|
||||||
"nextcloud_mcp_server.vector.oauth_sync.get_settings",
|
|
||||||
return_value=mock_settings,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mock AstrolabeClient to return an app password
|
# Call get_user_client_basic_auth with local storage
|
||||||
mock_astrolabe = mocker.AsyncMock()
|
client = await get_user_client_basic_auth(
|
||||||
mock_astrolabe.get_user_app_password.return_value = "test-app-password-12345"
|
|
||||||
|
|
||||||
mocker.patch(
|
|
||||||
"nextcloud_mcp_server.vector.oauth_sync.AstrolabeClient",
|
|
||||||
return_value=mock_astrolabe,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Call get_user_client in BasicAuth mode
|
|
||||||
_client = await get_user_client(
|
|
||||||
user_id="test_user",
|
user_id="test_user",
|
||||||
token_broker=None, # No token broker needed for BasicAuth mode
|
|
||||||
nextcloud_host="http://localhost:8080",
|
nextcloud_host="http://localhost:8080",
|
||||||
use_basic_auth=True,
|
storage=temp_storage,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify app password was requested
|
# Verify client was created with correct credentials
|
||||||
mock_astrolabe.get_user_app_password.assert_called_once_with("test_user")
|
assert client is not None
|
||||||
|
assert client.username == "test_user"
|
||||||
# Verify client was created successfully with correct username
|
|
||||||
assert _client is not None
|
|
||||||
assert _client.username == "test_user"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
async def test_basic_auth_mode_raises_error_without_app_password(mocker):
|
async def test_basic_auth_mode_raises_error_without_app_password(temp_storage):
|
||||||
"""Test that BasicAuth mode raises NotProvisionedError if no app password.
|
"""Test that BasicAuth mode raises NotProvisionedError if no app password.
|
||||||
|
|
||||||
There is NO fallback to OAuth - if no app password, user must provision one.
|
There is NO fallback to OAuth - if no app password, user must provision one.
|
||||||
"""
|
"""
|
||||||
# Mock settings to have client credentials
|
# Don't store any app password
|
||||||
mock_settings = mocker.MagicMock()
|
|
||||||
mock_settings.oidc_client_id = "test-client-id"
|
|
||||||
mock_settings.oidc_client_secret = "test-client-secret"
|
|
||||||
mocker.patch(
|
|
||||||
"nextcloud_mcp_server.vector.oauth_sync.get_settings",
|
|
||||||
return_value=mock_settings,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mock AstrolabeClient to return None (no app password)
|
# Call get_user_client_basic_auth - should raise NotProvisionedError
|
||||||
mock_astrolabe = mocker.AsyncMock()
|
|
||||||
mock_astrolabe.get_user_app_password.return_value = None
|
|
||||||
|
|
||||||
mocker.patch(
|
|
||||||
"nextcloud_mcp_server.vector.oauth_sync.AstrolabeClient",
|
|
||||||
return_value=mock_astrolabe,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Call get_user_client in BasicAuth mode - should raise NotProvisionedError
|
|
||||||
with pytest.raises(NotProvisionedError) as exc_info:
|
with pytest.raises(NotProvisionedError) as exc_info:
|
||||||
await get_user_client(
|
await get_user_client_basic_auth(
|
||||||
user_id="test_user",
|
user_id="test_user",
|
||||||
token_broker=None,
|
|
||||||
nextcloud_host="http://localhost:8080",
|
nextcloud_host="http://localhost:8080",
|
||||||
use_basic_auth=True,
|
storage=temp_storage,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify error message mentions app password provisioning
|
# Verify error message mentions app password provisioning
|
||||||
@@ -162,6 +85,33 @@ async def test_basic_auth_mode_raises_error_without_app_password(mocker):
|
|||||||
assert "test_user" in str(exc_info.value)
|
assert "test_user" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_get_user_client_dispatches_to_basic_auth(temp_storage, mocker):
|
||||||
|
"""Test that get_user_client dispatches to BasicAuth mode correctly."""
|
||||||
|
# Store an app password
|
||||||
|
await temp_storage.store_app_password("alice", "aaaaa-bbbbb-ccccc-ddddd-eeeee")
|
||||||
|
|
||||||
|
# Mock RefreshTokenStorage.from_env at the source module
|
||||||
|
mocker.patch(
|
||||||
|
"nextcloud_mcp_server.auth.storage.RefreshTokenStorage.from_env",
|
||||||
|
return_value=temp_storage,
|
||||||
|
)
|
||||||
|
# Also mock initialize since from_env returns an uninitialized instance
|
||||||
|
mocker.patch.object(temp_storage, "initialize", return_value=None)
|
||||||
|
|
||||||
|
# Call get_user_client in BasicAuth mode
|
||||||
|
client = await get_user_client(
|
||||||
|
user_id="alice",
|
||||||
|
token_broker=None, # No token broker needed for BasicAuth mode
|
||||||
|
nextcloud_host="http://localhost:8080",
|
||||||
|
use_basic_auth=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify client was created successfully
|
||||||
|
assert client is not None
|
||||||
|
assert client.username == "alice"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
async def test_oauth_mode_uses_refresh_token_only(mocker):
|
async def test_oauth_mode_uses_refresh_token_only(mocker):
|
||||||
"""Test that OAuth mode uses ONLY refresh tokens, NOT app passwords.
|
"""Test that OAuth mode uses ONLY refresh tokens, NOT app passwords.
|
||||||
@@ -183,7 +133,7 @@ async def test_oauth_mode_uses_refresh_token_only(mocker):
|
|||||||
use_basic_auth=False, # OAuth mode
|
use_basic_auth=False, # OAuth mode
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify token broker was called (NOT Astrolabe)
|
# Verify token broker was called
|
||||||
mock_token_broker.get_background_token.assert_called_once()
|
mock_token_broker.get_background_token.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@@ -213,38 +163,6 @@ async def test_oauth_mode_raises_error_without_token(mocker):
|
|||||||
assert "test_user" in str(exc_info.value)
|
assert "test_user" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
async def test_get_user_client_basic_auth_function(mocker):
|
|
||||||
"""Test the dedicated get_user_client_basic_auth function."""
|
|
||||||
# Mock settings to have client credentials
|
|
||||||
mock_settings = mocker.MagicMock()
|
|
||||||
mock_settings.oidc_client_id = "test-client-id"
|
|
||||||
mock_settings.oidc_client_secret = "test-client-secret"
|
|
||||||
mocker.patch(
|
|
||||||
"nextcloud_mcp_server.vector.oauth_sync.get_settings",
|
|
||||||
return_value=mock_settings,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mock AstrolabeClient
|
|
||||||
mock_astrolabe = mocker.AsyncMock()
|
|
||||||
mock_astrolabe.get_user_app_password.return_value = "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
|
|
||||||
|
|
||||||
mocker.patch(
|
|
||||||
"nextcloud_mcp_server.vector.oauth_sync.AstrolabeClient",
|
|
||||||
return_value=mock_astrolabe,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Call dedicated function
|
|
||||||
client = await get_user_client_basic_auth(
|
|
||||||
user_id="alice",
|
|
||||||
nextcloud_host="http://localhost:8080",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert client is not None
|
|
||||||
assert client.username == "alice"
|
|
||||||
mock_astrolabe.get_user_app_password.assert_called_once_with("alice")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
async def test_get_user_client_oauth_function(mocker):
|
async def test_get_user_client_oauth_function(mocker):
|
||||||
"""Test the dedicated get_user_client_oauth function."""
|
"""Test the dedicated get_user_client_oauth function."""
|
||||||
@@ -276,3 +194,69 @@ async def test_oauth_mode_requires_token_broker():
|
|||||||
nextcloud_host="http://localhost:8080",
|
nextcloud_host="http://localhost:8080",
|
||||||
use_basic_auth=False, # OAuth mode
|
use_basic_auth=False, # OAuth mode
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_multiple_users_basic_auth_mode(temp_storage, mocker):
|
||||||
|
"""Test that multiple users can be provisioned independently."""
|
||||||
|
# Store app passwords for multiple users
|
||||||
|
users = {
|
||||||
|
"alice": "aaaaa-aaaaa-aaaaa-aaaaa-aaaaa",
|
||||||
|
"bob": "bbbbb-bbbbb-bbbbb-bbbbb-bbbbb",
|
||||||
|
"charlie": "ccccc-ccccc-ccccc-ccccc-ccccc",
|
||||||
|
}
|
||||||
|
|
||||||
|
for user_id, password in users.items():
|
||||||
|
await temp_storage.store_app_password(user_id, password)
|
||||||
|
|
||||||
|
# Verify each user can get a client
|
||||||
|
for user_id in users.keys():
|
||||||
|
client = await get_user_client_basic_auth(
|
||||||
|
user_id=user_id,
|
||||||
|
nextcloud_host="http://localhost:8080",
|
||||||
|
storage=temp_storage,
|
||||||
|
)
|
||||||
|
assert client is not None
|
||||||
|
assert client.username == user_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_get_all_provisioned_users(temp_storage):
|
||||||
|
"""Test that we can list all provisioned users for BasicAuth mode."""
|
||||||
|
# Store app passwords for multiple users
|
||||||
|
await temp_storage.store_app_password("alice", "aaaaa-aaaaa-aaaaa-aaaaa-aaaaa")
|
||||||
|
await temp_storage.store_app_password("bob", "bbbbb-bbbbb-bbbbb-bbbbb-bbbbb")
|
||||||
|
|
||||||
|
# Get all provisioned users
|
||||||
|
user_ids = await temp_storage.get_all_app_password_user_ids()
|
||||||
|
|
||||||
|
assert len(user_ids) == 2
|
||||||
|
assert "alice" in user_ids
|
||||||
|
assert "bob" in user_ids
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_revoke_app_password(temp_storage):
|
||||||
|
"""Test that deleting app password revokes background access."""
|
||||||
|
# Provision user
|
||||||
|
await temp_storage.store_app_password("alice", "aaaaa-aaaaa-aaaaa-aaaaa-aaaaa")
|
||||||
|
|
||||||
|
# Verify user is provisioned
|
||||||
|
user_ids = await temp_storage.get_all_app_password_user_ids()
|
||||||
|
assert "alice" in user_ids
|
||||||
|
|
||||||
|
# Revoke access
|
||||||
|
deleted = await temp_storage.delete_app_password("alice")
|
||||||
|
assert deleted is True
|
||||||
|
|
||||||
|
# Verify user is no longer provisioned
|
||||||
|
user_ids = await temp_storage.get_all_app_password_user_ids()
|
||||||
|
assert "alice" not in user_ids
|
||||||
|
|
||||||
|
# Verify get_user_client now raises NotProvisionedError
|
||||||
|
with pytest.raises(NotProvisionedError):
|
||||||
|
await get_user_client_basic_auth(
|
||||||
|
user_id="alice",
|
||||||
|
nextcloud_host="http://localhost:8080",
|
||||||
|
storage=temp_storage,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,695 @@
|
|||||||
|
"""Integration tests for Astrolabe token refresh flow.
|
||||||
|
|
||||||
|
Tests the token refresh mechanism between Astrolabe (Nextcloud app)
|
||||||
|
and the MCP server backend in a multi-user basic auth deployment.
|
||||||
|
|
||||||
|
This test verifies:
|
||||||
|
1. User provisions access via Astrolabe personal settings
|
||||||
|
2. Token is stored encrypted in Nextcloud database
|
||||||
|
3. Token expires (simulated via database manipulation)
|
||||||
|
4. MCP server requests new token via refresh
|
||||||
|
5. Astrolabe refreshes token with IdP
|
||||||
|
6. New token is stored and used successfully
|
||||||
|
|
||||||
|
Note: The mcp-multi-user-basic deployment uses "hybrid mode" which requires
|
||||||
|
BOTH OAuth authorization AND app password for full configuration. These tests
|
||||||
|
focus on the app password/credential storage aspects and verify database state
|
||||||
|
directly rather than relying on UI elements that require both steps.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
import pytest
|
||||||
|
from playwright.async_api import Page
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def login_to_nextcloud(page: Page, username: str, password: str):
|
||||||
|
"""Helper function to login to Nextcloud via Playwright.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: Playwright page instance
|
||||||
|
username: Nextcloud username
|
||||||
|
password: Nextcloud password
|
||||||
|
"""
|
||||||
|
nextcloud_url = "http://localhost:8080"
|
||||||
|
|
||||||
|
logger.info(f"Logging in to Nextcloud as {username}...")
|
||||||
|
await page.goto(f"{nextcloud_url}/login", wait_until="networkidle")
|
||||||
|
|
||||||
|
# Fill in login form
|
||||||
|
await page.wait_for_selector('input[name="user"]', timeout=10000)
|
||||||
|
await page.fill('input[name="user"]', username)
|
||||||
|
await page.fill('input[name="password"]', password)
|
||||||
|
|
||||||
|
# Submit form
|
||||||
|
await page.click('button[type="submit"]')
|
||||||
|
await page.wait_for_load_state("networkidle", timeout=30000)
|
||||||
|
|
||||||
|
# Verify logged in (should redirect away from login page)
|
||||||
|
current_url = page.url
|
||||||
|
assert "/login" not in current_url, (
|
||||||
|
f"Login failed for {username}, still on login page"
|
||||||
|
)
|
||||||
|
logger.info(f"✓ Successfully logged in as {username}")
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_app_password(
|
||||||
|
page: Page, username: str, app_name: str = "Astrolabe Test"
|
||||||
|
) -> str:
|
||||||
|
"""Generate an app password in Nextcloud Security settings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: Playwright page instance (must be authenticated)
|
||||||
|
username: Username (for logging)
|
||||||
|
app_name: Name for the app password
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The generated app password string
|
||||||
|
"""
|
||||||
|
logger.info(f"Generating app password for {username}...")
|
||||||
|
|
||||||
|
nextcloud_url = "http://localhost:8080"
|
||||||
|
|
||||||
|
# Navigate to Security settings
|
||||||
|
await page.goto(f"{nextcloud_url}/settings/user/security", wait_until="networkidle")
|
||||||
|
logger.info("Navigated to Security settings")
|
||||||
|
|
||||||
|
# Fill the app password input field
|
||||||
|
app_password_input = page.locator('input[placeholder="App name"]')
|
||||||
|
await app_password_input.fill(app_name)
|
||||||
|
logger.info(f"Entered app name: {app_name}")
|
||||||
|
|
||||||
|
# Wait for Vue.js to react and enable the button
|
||||||
|
await anyio.sleep(1.0)
|
||||||
|
|
||||||
|
# Click the create button
|
||||||
|
create_button = page.locator(
|
||||||
|
'button[type="submit"]:has-text("Create new app password")'
|
||||||
|
)
|
||||||
|
await create_button.click()
|
||||||
|
logger.info("Clicked create app password button")
|
||||||
|
|
||||||
|
# Wait for app password to be generated
|
||||||
|
await anyio.sleep(3)
|
||||||
|
|
||||||
|
# Find the generated app password
|
||||||
|
app_password = None
|
||||||
|
try:
|
||||||
|
await page.wait_for_selector('text="New app password"', timeout=10000)
|
||||||
|
logger.info("App password dialog appeared")
|
||||||
|
|
||||||
|
all_inputs = await page.locator('input[type="text"]').all()
|
||||||
|
for idx, input_elem in enumerate(all_inputs):
|
||||||
|
try:
|
||||||
|
value = await input_elem.input_value()
|
||||||
|
if value and "-" in value and len(value) > 20:
|
||||||
|
app_password = value.strip()
|
||||||
|
logger.info(f"Found app password in input {idx}")
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to find app password dialog: {e}")
|
||||||
|
|
||||||
|
if not app_password:
|
||||||
|
screenshot_path = f"/tmp/app_password_generation_{username}.png"
|
||||||
|
await page.screenshot(path=screenshot_path)
|
||||||
|
raise ValueError(
|
||||||
|
f"Could not find generated app password. Screenshot: {screenshot_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate password format
|
||||||
|
if not re.match(
|
||||||
|
r"^[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}$",
|
||||||
|
app_password,
|
||||||
|
):
|
||||||
|
raise ValueError(f"App password format validation failed: {app_password}")
|
||||||
|
|
||||||
|
logger.info(f"✓ Generated app password for {username}")
|
||||||
|
|
||||||
|
# Close the dialog
|
||||||
|
close_button = page.get_by_role("button", name="Close")
|
||||||
|
await close_button.click()
|
||||||
|
await anyio.sleep(0.5)
|
||||||
|
|
||||||
|
return app_password
|
||||||
|
|
||||||
|
|
||||||
|
async def save_app_password_in_astrolabe(
|
||||||
|
page: Page, username: str, app_password: str
|
||||||
|
) -> bool:
|
||||||
|
"""Save app password in Astrolabe settings (Step 2 of hybrid mode).
|
||||||
|
|
||||||
|
This function only saves the app password - it does NOT verify the "Active"
|
||||||
|
badge since that requires both OAuth and app password in hybrid mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: Playwright page instance
|
||||||
|
username: Username (for logging)
|
||||||
|
app_password: App password to enter
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the password was saved successfully (based on network response)
|
||||||
|
"""
|
||||||
|
logger.info(f"Saving app password in Astrolabe for {username}...")
|
||||||
|
|
||||||
|
nextcloud_url = "http://localhost:8080"
|
||||||
|
|
||||||
|
# Track network responses
|
||||||
|
credentials_response_status = None
|
||||||
|
|
||||||
|
def capture_response(resp):
|
||||||
|
nonlocal credentials_response_status
|
||||||
|
if "background-sync/credentials" in resp.url or "storeAppPassword" in resp.url:
|
||||||
|
credentials_response_status = resp.status
|
||||||
|
logger.info(f"Credentials endpoint response: {resp.status} {resp.url}")
|
||||||
|
|
||||||
|
page.on("response", capture_response)
|
||||||
|
|
||||||
|
# Navigate to Astrolabe settings
|
||||||
|
await page.goto(
|
||||||
|
f"{nextcloud_url}/settings/user/astrolabe", wait_until="networkidle"
|
||||||
|
)
|
||||||
|
await anyio.sleep(1)
|
||||||
|
|
||||||
|
# Check if Step 2 already shows "Complete"
|
||||||
|
try:
|
||||||
|
complete_badge = page.locator('text="Complete"').first
|
||||||
|
if await complete_badge.is_visible(timeout=2000):
|
||||||
|
logger.info(f"✓ App password already configured for {username}")
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Find the app password input field
|
||||||
|
app_password_input = page.get_by_placeholder("xxxxx-xxxxx-xxxxx-xxxxx-xxxxx")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await app_password_input.wait_for(timeout=5000, state="visible")
|
||||||
|
logger.info("Found app password input field")
|
||||||
|
except Exception:
|
||||||
|
screenshot_path = f"/tmp/astrolabe_no_password_field_{username}.png"
|
||||||
|
await page.screenshot(path=screenshot_path)
|
||||||
|
raise ValueError(
|
||||||
|
f"Could not find app password input field. Screenshot: {screenshot_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enter the app password
|
||||||
|
await app_password_input.fill(app_password)
|
||||||
|
logger.info(f"Entered app password for {username}")
|
||||||
|
|
||||||
|
await anyio.sleep(0.5)
|
||||||
|
|
||||||
|
# Click Save button
|
||||||
|
save_button = page.get_by_role("button", name="Save")
|
||||||
|
await save_button.click()
|
||||||
|
logger.info("Clicked Save button")
|
||||||
|
|
||||||
|
# Wait for the request to complete and page to reload
|
||||||
|
await page.wait_for_load_state("networkidle", timeout=15000)
|
||||||
|
await anyio.sleep(2)
|
||||||
|
|
||||||
|
# Verify the save was successful by checking network response
|
||||||
|
if credentials_response_status == 200:
|
||||||
|
logger.info(f"✓ App password saved successfully for {username}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
f"App password save failed for {username}, status: {credentials_response_status}"
|
||||||
|
)
|
||||||
|
screenshot_path = f"/tmp/astrolabe_save_failed_{username}.png"
|
||||||
|
await page.screenshot(path=screenshot_path)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_background_sync_credentials(username: str) -> dict | None:
|
||||||
|
"""Get background sync credentials for a user from the database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username: Nextcloud username
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with credential details, or None if not found
|
||||||
|
"""
|
||||||
|
query = f"""
|
||||||
|
SELECT configkey, configvalue
|
||||||
|
FROM oc_preferences
|
||||||
|
WHERE userid = '{username}'
|
||||||
|
AND appid = 'astrolabe'
|
||||||
|
AND configkey IN ('background_sync_password', 'background_sync_type', 'background_sync_provisioned_at')
|
||||||
|
ORDER BY configkey;
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"compose",
|
||||||
|
"exec",
|
||||||
|
"-T",
|
||||||
|
"db",
|
||||||
|
"mariadb",
|
||||||
|
"-u",
|
||||||
|
"root",
|
||||||
|
"-ppassword",
|
||||||
|
"nextcloud",
|
||||||
|
"-e",
|
||||||
|
query,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
output = result.stdout
|
||||||
|
if "background_sync_type" in output:
|
||||||
|
return {
|
||||||
|
"has_password": "background_sync_password" in output,
|
||||||
|
"has_type": "background_sync_type" in output,
|
||||||
|
"has_timestamp": "background_sync_provisioned_at" in output,
|
||||||
|
"is_app_password": "app_password" in output,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting credentials for {username}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def delete_user_credentials(username: str) -> bool:
|
||||||
|
"""Delete all stored credentials for a user (for cleanup).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username: Nextcloud username
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
"""
|
||||||
|
query = f"""
|
||||||
|
DELETE FROM oc_preferences
|
||||||
|
WHERE userid = '{username}'
|
||||||
|
AND appid = 'astrolabe'
|
||||||
|
AND configkey IN ('oauth_tokens', 'background_sync_password', 'background_sync_type', 'background_sync_provisioned_at');
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"compose",
|
||||||
|
"exec",
|
||||||
|
"-T",
|
||||||
|
"db",
|
||||||
|
"mariadb",
|
||||||
|
"-u",
|
||||||
|
"root",
|
||||||
|
"-ppassword",
|
||||||
|
"nextcloud",
|
||||||
|
"-e",
|
||||||
|
query,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Deleted credentials for {username}")
|
||||||
|
return result.returncode == 0
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting credentials for {username}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.oauth
|
||||||
|
async def test_app_password_storage_and_cleanup(
|
||||||
|
browser,
|
||||||
|
nc_client,
|
||||||
|
test_users_setup,
|
||||||
|
configure_astrolabe_for_mcp_server,
|
||||||
|
):
|
||||||
|
"""Test that app passwords are stored and cleaned up correctly.
|
||||||
|
|
||||||
|
This test verifies:
|
||||||
|
1. User can save app password in Astrolabe settings
|
||||||
|
2. Password is stored encrypted in the database
|
||||||
|
3. Credentials can be revoked and are deleted from database
|
||||||
|
|
||||||
|
Note: In hybrid mode (mcp-multi-user-basic), this only tests Step 2
|
||||||
|
(app password storage). The "Active" badge requires both OAuth and
|
||||||
|
app password, which is tested separately.
|
||||||
|
"""
|
||||||
|
# Configure Astrolabe for mcp-multi-user-basic
|
||||||
|
logger.info("Configuring Astrolabe for mcp-multi-user-basic server...")
|
||||||
|
await configure_astrolabe_for_mcp_server(
|
||||||
|
mcp_server_internal_url="http://mcp-multi-user-basic:8000",
|
||||||
|
mcp_server_public_url="http://localhost:8003",
|
||||||
|
)
|
||||||
|
|
||||||
|
username = "alice"
|
||||||
|
user_config = test_users_setup[username]
|
||||||
|
password = user_config["password"]
|
||||||
|
|
||||||
|
# Cleanup any existing credentials
|
||||||
|
delete_user_credentials(username)
|
||||||
|
|
||||||
|
context = await browser.new_context(ignore_https_errors=True)
|
||||||
|
page = await context.new_page()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Step 1: Login
|
||||||
|
await login_to_nextcloud(page, username, password)
|
||||||
|
|
||||||
|
# Step 2: Verify no credentials exist initially
|
||||||
|
initial_creds = get_background_sync_credentials(username)
|
||||||
|
assert initial_creds is None, f"Expected no credentials, found: {initial_creds}"
|
||||||
|
logger.info("✓ Verified no initial credentials")
|
||||||
|
|
||||||
|
# Step 3: Generate app password
|
||||||
|
app_password = await generate_app_password(page, username)
|
||||||
|
assert app_password, "Failed to generate app password"
|
||||||
|
|
||||||
|
# Step 4: Save app password in Astrolabe
|
||||||
|
save_success = await save_app_password_in_astrolabe(
|
||||||
|
page, username, app_password
|
||||||
|
)
|
||||||
|
assert save_success, "Failed to save app password"
|
||||||
|
|
||||||
|
# Step 5: Verify credentials are stored in database
|
||||||
|
stored_creds = get_background_sync_credentials(username)
|
||||||
|
assert stored_creds is not None, "Expected credentials to be stored"
|
||||||
|
assert stored_creds["has_password"], "Expected password to be stored"
|
||||||
|
assert stored_creds["has_type"], "Expected type to be stored"
|
||||||
|
assert stored_creds["is_app_password"], "Expected type to be 'app_password'"
|
||||||
|
logger.info("✓ Verified credentials stored in database")
|
||||||
|
|
||||||
|
# Step 6: Verify password is encrypted (not plaintext)
|
||||||
|
query = f"""
|
||||||
|
SELECT configvalue
|
||||||
|
FROM oc_preferences
|
||||||
|
WHERE userid = '{username}'
|
||||||
|
AND appid = 'astrolabe'
|
||||||
|
AND configkey = 'background_sync_password';
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"compose",
|
||||||
|
"exec",
|
||||||
|
"-T",
|
||||||
|
"db",
|
||||||
|
"mariadb",
|
||||||
|
"-u",
|
||||||
|
"root",
|
||||||
|
"-ppassword",
|
||||||
|
"nextcloud",
|
||||||
|
"-N",
|
||||||
|
"-e",
|
||||||
|
query,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
encrypted_value = result.stdout.strip()
|
||||||
|
assert app_password not in encrypted_value, "Password appears in plaintext!"
|
||||||
|
assert len(encrypted_value) > len(app_password), (
|
||||||
|
"Encrypted value should be longer"
|
||||||
|
)
|
||||||
|
logger.info("✓ Verified password is encrypted")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await context.close()
|
||||||
|
# Cleanup
|
||||||
|
delete_user_credentials(username)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.oauth
|
||||||
|
async def test_credential_isolation_between_users(
|
||||||
|
browser,
|
||||||
|
nc_client,
|
||||||
|
test_users_setup,
|
||||||
|
configure_astrolabe_for_mcp_server,
|
||||||
|
):
|
||||||
|
"""Test that credentials are properly isolated between users.
|
||||||
|
|
||||||
|
This test verifies:
|
||||||
|
1. Multiple users can provision credentials independently
|
||||||
|
2. Each user's encrypted credentials are unique
|
||||||
|
3. Deleting one user's credentials doesn't affect others
|
||||||
|
"""
|
||||||
|
await configure_astrolabe_for_mcp_server(
|
||||||
|
mcp_server_internal_url="http://mcp-multi-user-basic:8000",
|
||||||
|
mcp_server_public_url="http://localhost:8003",
|
||||||
|
)
|
||||||
|
|
||||||
|
test_users = ["alice", "bob"]
|
||||||
|
user_passwords = {}
|
||||||
|
|
||||||
|
# Cleanup all users first
|
||||||
|
for username in test_users:
|
||||||
|
delete_user_credentials(username)
|
||||||
|
|
||||||
|
# Provision each user
|
||||||
|
for username in test_users:
|
||||||
|
user_config = test_users_setup[username]
|
||||||
|
password = user_config["password"]
|
||||||
|
|
||||||
|
context = await browser.new_context(ignore_https_errors=True)
|
||||||
|
page = await context.new_page()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await login_to_nextcloud(page, username, password)
|
||||||
|
app_password = await generate_app_password(
|
||||||
|
page, username, f"Test {username}"
|
||||||
|
)
|
||||||
|
save_success = await save_app_password_in_astrolabe(
|
||||||
|
page, username, app_password
|
||||||
|
)
|
||||||
|
|
||||||
|
assert save_success, f"Failed to save app password for {username}"
|
||||||
|
user_passwords[username] = app_password
|
||||||
|
|
||||||
|
# Verify stored
|
||||||
|
creds = get_background_sync_credentials(username)
|
||||||
|
assert creds is not None, f"Credentials not stored for {username}"
|
||||||
|
logger.info(f"✓ Credentials provisioned for {username}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await context.close()
|
||||||
|
|
||||||
|
# Verify isolation - get encrypted values
|
||||||
|
encrypted_values = {}
|
||||||
|
for username in test_users:
|
||||||
|
query = f"""
|
||||||
|
SELECT configvalue
|
||||||
|
FROM oc_preferences
|
||||||
|
WHERE userid = '{username}'
|
||||||
|
AND appid = 'astrolabe'
|
||||||
|
AND configkey = 'background_sync_password';
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"compose",
|
||||||
|
"exec",
|
||||||
|
"-T",
|
||||||
|
"db",
|
||||||
|
"mariadb",
|
||||||
|
"-u",
|
||||||
|
"root",
|
||||||
|
"-ppassword",
|
||||||
|
"nextcloud",
|
||||||
|
"-N",
|
||||||
|
"-e",
|
||||||
|
query,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
encrypted_values[username] = result.stdout.strip()
|
||||||
|
|
||||||
|
# Different users should have different encrypted values
|
||||||
|
assert encrypted_values["alice"] != encrypted_values["bob"], (
|
||||||
|
"Different users should have different encrypted values"
|
||||||
|
)
|
||||||
|
logger.info("✓ Verified credentials are unique per user")
|
||||||
|
|
||||||
|
# Delete alice's credentials and verify bob's are unaffected
|
||||||
|
delete_user_credentials("alice")
|
||||||
|
|
||||||
|
alice_creds = get_background_sync_credentials("alice")
|
||||||
|
bob_creds = get_background_sync_credentials("bob")
|
||||||
|
|
||||||
|
assert alice_creds is None, "Alice's credentials should be deleted"
|
||||||
|
assert bob_creds is not None, "Bob's credentials should still exist"
|
||||||
|
logger.info("✓ Verified credential deletion is isolated")
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
for username in test_users:
|
||||||
|
delete_user_credentials(username)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.oauth
|
||||||
|
async def test_credential_revoke_and_reprovision(
|
||||||
|
browser,
|
||||||
|
nc_client,
|
||||||
|
test_users_setup,
|
||||||
|
configure_astrolabe_for_mcp_server,
|
||||||
|
):
|
||||||
|
"""Test that credentials can be revoked and reprovisioned.
|
||||||
|
|
||||||
|
This test verifies:
|
||||||
|
1. User provisions credentials
|
||||||
|
2. User revokes credentials (deletes from database)
|
||||||
|
3. User provisions again with new app password
|
||||||
|
4. New credentials are stored correctly
|
||||||
|
|
||||||
|
Note: The UI prevents overwriting credentials directly - users must
|
||||||
|
revoke first before provisioning new credentials.
|
||||||
|
"""
|
||||||
|
await configure_astrolabe_for_mcp_server(
|
||||||
|
mcp_server_internal_url="http://mcp-multi-user-basic:8000",
|
||||||
|
mcp_server_public_url="http://localhost:8003",
|
||||||
|
)
|
||||||
|
|
||||||
|
username = "alice"
|
||||||
|
user_config = test_users_setup[username]
|
||||||
|
password = user_config["password"]
|
||||||
|
|
||||||
|
delete_user_credentials(username)
|
||||||
|
|
||||||
|
context = await browser.new_context(ignore_https_errors=True)
|
||||||
|
page = await context.new_page()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await login_to_nextcloud(page, username, password)
|
||||||
|
|
||||||
|
# First provisioning
|
||||||
|
app_password_1 = await generate_app_password(page, username, "First Password")
|
||||||
|
await save_app_password_in_astrolabe(page, username, app_password_1)
|
||||||
|
|
||||||
|
# Get first encrypted value
|
||||||
|
query = f"""
|
||||||
|
SELECT configvalue
|
||||||
|
FROM oc_preferences
|
||||||
|
WHERE userid = '{username}'
|
||||||
|
AND appid = 'astrolabe'
|
||||||
|
AND configkey = 'background_sync_password';
|
||||||
|
"""
|
||||||
|
|
||||||
|
result1 = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"compose",
|
||||||
|
"exec",
|
||||||
|
"-T",
|
||||||
|
"db",
|
||||||
|
"mariadb",
|
||||||
|
"-u",
|
||||||
|
"root",
|
||||||
|
"-ppassword",
|
||||||
|
"nextcloud",
|
||||||
|
"-N",
|
||||||
|
"-e",
|
||||||
|
query,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
first_encrypted = result1.stdout.strip()
|
||||||
|
assert first_encrypted, "First credential should be stored"
|
||||||
|
logger.info("✓ First credential stored")
|
||||||
|
|
||||||
|
# Revoke credentials (simulating user clicking "Revoke Access")
|
||||||
|
delete_user_credentials(username)
|
||||||
|
logger.info("✓ Credentials revoked")
|
||||||
|
|
||||||
|
# Verify credentials are gone
|
||||||
|
creds_after_revoke = get_background_sync_credentials(username)
|
||||||
|
assert creds_after_revoke is None, "Credentials should be deleted after revoke"
|
||||||
|
|
||||||
|
# Second provisioning with different password
|
||||||
|
app_password_2 = await generate_app_password(page, username, "Second Password")
|
||||||
|
await save_app_password_in_astrolabe(page, username, app_password_2)
|
||||||
|
|
||||||
|
result2 = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"compose",
|
||||||
|
"exec",
|
||||||
|
"-T",
|
||||||
|
"db",
|
||||||
|
"mariadb",
|
||||||
|
"-u",
|
||||||
|
"root",
|
||||||
|
"-ppassword",
|
||||||
|
"nextcloud",
|
||||||
|
"-N",
|
||||||
|
"-e",
|
||||||
|
query,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
second_encrypted = result2.stdout.strip()
|
||||||
|
assert second_encrypted, "Second credential should be stored"
|
||||||
|
logger.info("✓ Second credential stored")
|
||||||
|
|
||||||
|
# Verify the encrypted values are different (different passwords)
|
||||||
|
assert first_encrypted != second_encrypted, (
|
||||||
|
"Different passwords should produce different encrypted values"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify only one row exists
|
||||||
|
count_query = f"""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM oc_preferences
|
||||||
|
WHERE userid = '{username}'
|
||||||
|
AND appid = 'astrolabe'
|
||||||
|
AND configkey = 'background_sync_password';
|
||||||
|
"""
|
||||||
|
|
||||||
|
count_result = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"compose",
|
||||||
|
"exec",
|
||||||
|
"-T",
|
||||||
|
"db",
|
||||||
|
"mariadb",
|
||||||
|
"-u",
|
||||||
|
"root",
|
||||||
|
"-ppassword",
|
||||||
|
"nextcloud",
|
||||||
|
"-N",
|
||||||
|
"-e",
|
||||||
|
count_query,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
count = int(count_result.stdout.strip())
|
||||||
|
assert count == 1, f"Expected 1 credential row, found {count}"
|
||||||
|
logger.info("✓ Verified clean reprovision after revoke")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await context.close()
|
||||||
|
delete_user_credentials(username)
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""Integration tests for Deck card reorder functionality.
|
||||||
|
|
||||||
|
Tests issue #469: Moving Deck card from one column (stack) to another not working.
|
||||||
|
https://github.com/cbcoutinho/nextcloud-mcp-server/issues/469
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def board_with_two_stacks(nc_client: NextcloudClient):
|
||||||
|
"""Create a temporary board with two stacks for testing card movement.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
tuple: (board_data, source_stack_data, target_stack_data)
|
||||||
|
"""
|
||||||
|
unique_suffix = uuid.uuid4().hex[:8]
|
||||||
|
board_title = f"Reorder Test Board {unique_suffix}"
|
||||||
|
board = None
|
||||||
|
|
||||||
|
logger.info(f"Creating board with two stacks: {board_title}")
|
||||||
|
try:
|
||||||
|
board = await nc_client.deck.create_board(board_title, "0000FF")
|
||||||
|
board_id = board.id
|
||||||
|
|
||||||
|
# Create source stack (stack 1)
|
||||||
|
source_stack = await nc_client.deck.create_stack(
|
||||||
|
board_id, f"Source Stack {unique_suffix}", order=1
|
||||||
|
)
|
||||||
|
source_stack_data = {
|
||||||
|
"id": source_stack.id,
|
||||||
|
"title": source_stack.title,
|
||||||
|
"order": source_stack.order,
|
||||||
|
}
|
||||||
|
logger.info(f"Created source stack with ID: {source_stack.id}")
|
||||||
|
|
||||||
|
# Create target stack (stack 2)
|
||||||
|
target_stack = await nc_client.deck.create_stack(
|
||||||
|
board_id, f"Target Stack {unique_suffix}", order=2
|
||||||
|
)
|
||||||
|
target_stack_data = {
|
||||||
|
"id": target_stack.id,
|
||||||
|
"title": target_stack.title,
|
||||||
|
"order": target_stack.order,
|
||||||
|
}
|
||||||
|
logger.info(f"Created target stack with ID: {target_stack.id}")
|
||||||
|
|
||||||
|
board_data = {
|
||||||
|
"id": board_id,
|
||||||
|
"title": board.title,
|
||||||
|
"color": board.color,
|
||||||
|
}
|
||||||
|
|
||||||
|
yield (board_data, source_stack_data, target_stack_data)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if board:
|
||||||
|
logger.info(f"Cleaning up board ID: {board.id}")
|
||||||
|
try:
|
||||||
|
await nc_client.deck.delete_board(board.id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error cleaning up board: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reorder_card_move_to_different_stack(
|
||||||
|
nc_client: NextcloudClient, board_with_two_stacks: tuple
|
||||||
|
):
|
||||||
|
"""Test moving a card from one stack to another (issue #469).
|
||||||
|
|
||||||
|
This test reproduces the bug where the reorder_card API reports success
|
||||||
|
but the card doesn't actually move to the target stack.
|
||||||
|
"""
|
||||||
|
board_data, source_stack_data, target_stack_data = board_with_two_stacks
|
||||||
|
board_id = board_data["id"]
|
||||||
|
source_stack_id = source_stack_data["id"]
|
||||||
|
target_stack_id = target_stack_data["id"]
|
||||||
|
|
||||||
|
# Create a card in the source stack
|
||||||
|
unique_suffix = uuid.uuid4().hex[:8]
|
||||||
|
card_title = f"Test Card {unique_suffix}"
|
||||||
|
card = await nc_client.deck.create_card(
|
||||||
|
board_id, source_stack_id, card_title, description="Card to be moved"
|
||||||
|
)
|
||||||
|
card_id = card.id
|
||||||
|
logger.info(f"Created card ID: {card_id} in source stack ID: {source_stack_id}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Verify card is in source stack
|
||||||
|
card_before = await nc_client.deck.get_card(board_id, source_stack_id, card_id)
|
||||||
|
assert card_before.stackId == source_stack_id, (
|
||||||
|
f"Card should start in source stack {source_stack_id}, "
|
||||||
|
f"but is in {card_before.stackId}"
|
||||||
|
)
|
||||||
|
logger.info(f"Verified card is in source stack: {source_stack_id}")
|
||||||
|
|
||||||
|
# Move card to target stack
|
||||||
|
logger.info(
|
||||||
|
f"Moving card {card_id} from stack {source_stack_id} "
|
||||||
|
f"to stack {target_stack_id}"
|
||||||
|
)
|
||||||
|
await nc_client.deck.reorder_card(
|
||||||
|
board_id=board_id,
|
||||||
|
stack_id=source_stack_id,
|
||||||
|
card_id=card_id,
|
||||||
|
order=0,
|
||||||
|
target_stack_id=target_stack_id,
|
||||||
|
)
|
||||||
|
logger.info("reorder_card API call completed")
|
||||||
|
|
||||||
|
# Verify card moved to target stack
|
||||||
|
# Note: After moving, the card should be accessible from the target stack
|
||||||
|
card_after = await nc_client.deck.get_card(board_id, target_stack_id, card_id)
|
||||||
|
assert card_after.stackId == target_stack_id, (
|
||||||
|
f"Card should have moved to target stack {target_stack_id}, "
|
||||||
|
f"but is in {card_after.stackId}"
|
||||||
|
)
|
||||||
|
logger.info(f"SUCCESS: Card moved to target stack {target_stack_id}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clean up - try to delete from target stack first, then source
|
||||||
|
try:
|
||||||
|
await nc_client.deck.delete_card(board_id, target_stack_id, card_id)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
await nc_client.deck.delete_card(board_id, source_stack_id, card_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error cleaning up card: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reorder_card_within_same_stack(
|
||||||
|
nc_client: NextcloudClient, board_with_two_stacks: tuple
|
||||||
|
):
|
||||||
|
"""Test reordering a card within the same stack (should work)."""
|
||||||
|
board_data, source_stack_data, _ = board_with_two_stacks
|
||||||
|
board_id = board_data["id"]
|
||||||
|
source_stack_id = source_stack_data["id"]
|
||||||
|
|
||||||
|
# Create two cards in the source stack
|
||||||
|
unique_suffix = uuid.uuid4().hex[:8]
|
||||||
|
card1 = await nc_client.deck.create_card(
|
||||||
|
board_id, source_stack_id, f"Card 1 {unique_suffix}", order=0
|
||||||
|
)
|
||||||
|
card2 = await nc_client.deck.create_card(
|
||||||
|
board_id, source_stack_id, f"Card 2 {unique_suffix}", order=1
|
||||||
|
)
|
||||||
|
logger.info(f"Created cards {card1.id} (order 0) and {card2.id} (order 1)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Reorder card1 to position after card2
|
||||||
|
await nc_client.deck.reorder_card(
|
||||||
|
board_id=board_id,
|
||||||
|
stack_id=source_stack_id,
|
||||||
|
card_id=card1.id,
|
||||||
|
order=2, # Move to position 2
|
||||||
|
target_stack_id=source_stack_id, # Same stack
|
||||||
|
)
|
||||||
|
logger.info(f"Reordered card {card1.id} to order 2")
|
||||||
|
|
||||||
|
# Verify card is still in the same stack
|
||||||
|
card_after = await nc_client.deck.get_card(board_id, source_stack_id, card1.id)
|
||||||
|
assert card_after.stackId == source_stack_id
|
||||||
|
logger.info("Card reorder within same stack succeeded")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
await nc_client.deck.delete_card(board_id, source_stack_id, card1.id)
|
||||||
|
await nc_client.deck.delete_card(board_id, source_stack_id, card2.id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error cleaning up cards: {e}")
|
||||||
@@ -89,8 +89,13 @@ async def test_create_operations_not_idempotent(nc_mcp_client: ClientSession):
|
|||||||
"""Verify create operations are marked as non-idempotent."""
|
"""Verify create operations are marked as non-idempotent."""
|
||||||
tools = await nc_mcp_client.list_tools()
|
tools = await nc_mcp_client.list_tools()
|
||||||
|
|
||||||
|
# Exceptions: operations that are actually idempotent
|
||||||
|
# - calendar_create_meeting: creates or returns existing meeting
|
||||||
|
# - nc_webdav_create_directory: MKCOL returns 405 if exists (same end state)
|
||||||
|
idempotent_exceptions = {"calendar_create_meeting", "nc_webdav_create_directory"}
|
||||||
|
|
||||||
for tool in tools.tools:
|
for tool in tools.tools:
|
||||||
if "create" in tool.name.lower() and "calendar_create_meeting" not in tool.name:
|
if "create" in tool.name.lower() and tool.name not in idempotent_exceptions:
|
||||||
assert tool.annotations is not None, f"Tool {tool.name} missing annotations"
|
assert tool.annotations is not None, f"Tool {tool.name} missing annotations"
|
||||||
assert tool.annotations.idempotentHint is not True, (
|
assert tool.annotations.idempotentHint is not True, (
|
||||||
f"Create tool {tool.name} should not be idempotent (creates new resources)"
|
f"Create tool {tool.name} should not be idempotent (creates new resources)"
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for App Password Storage functionality.
|
||||||
|
|
||||||
|
Tests the app password methods in RefreshTokenStorage for multi-user
|
||||||
|
BasicAuth mode background sync.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def encryption_key():
|
||||||
|
"""Generate a test encryption key."""
|
||||||
|
return Fernet.generate_key().decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def temp_storage(encryption_key):
|
||||||
|
"""Create temporary storage instance with encryption for testing."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
db_path = Path(tmpdir) / "test_app_passwords.db"
|
||||||
|
storage = RefreshTokenStorage(
|
||||||
|
db_path=str(db_path), encryption_key=encryption_key
|
||||||
|
)
|
||||||
|
await storage.initialize()
|
||||||
|
yield storage
|
||||||
|
|
||||||
|
|
||||||
|
async def test_store_app_password(temp_storage):
|
||||||
|
"""Test storing an app password."""
|
||||||
|
await temp_storage.store_app_password(
|
||||||
|
user_id="testuser",
|
||||||
|
app_password="JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify it can be retrieved
|
||||||
|
retrieved = await temp_storage.get_app_password("testuser")
|
||||||
|
assert retrieved == "JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_store_app_password_replaces_existing(temp_storage):
|
||||||
|
"""Test that storing a new app password replaces the existing one."""
|
||||||
|
await temp_storage.store_app_password(
|
||||||
|
user_id="testuser",
|
||||||
|
app_password="aaaaa-bbbbb-ccccc-ddddd-eeeee",
|
||||||
|
)
|
||||||
|
await temp_storage.store_app_password(
|
||||||
|
user_id="testuser",
|
||||||
|
app_password="fffff-ggggg-hhhhh-iiiii-jjjjj",
|
||||||
|
)
|
||||||
|
|
||||||
|
retrieved = await temp_storage.get_app_password("testuser")
|
||||||
|
assert retrieved == "fffff-ggggg-hhhhh-iiiii-jjjjj"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_app_password_nonexistent(temp_storage):
|
||||||
|
"""Test retrieving app password for non-existent user."""
|
||||||
|
retrieved = await temp_storage.get_app_password("nonexistent")
|
||||||
|
assert retrieved is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_app_password(temp_storage):
|
||||||
|
"""Test deleting an app password."""
|
||||||
|
await temp_storage.store_app_password(
|
||||||
|
user_id="testuser",
|
||||||
|
app_password="JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB",
|
||||||
|
)
|
||||||
|
|
||||||
|
deleted = await temp_storage.delete_app_password("testuser")
|
||||||
|
assert deleted is True
|
||||||
|
|
||||||
|
# Verify it's gone
|
||||||
|
retrieved = await temp_storage.get_app_password("testuser")
|
||||||
|
assert retrieved is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_app_password_nonexistent(temp_storage):
|
||||||
|
"""Test deleting non-existent app password."""
|
||||||
|
deleted = await temp_storage.delete_app_password("nonexistent")
|
||||||
|
assert deleted is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_all_app_password_user_ids(temp_storage):
|
||||||
|
"""Test listing all users with app passwords."""
|
||||||
|
await temp_storage.store_app_password("alice", "aaaaa-aaaaa-aaaaa-aaaaa-aaaaa")
|
||||||
|
await temp_storage.store_app_password("bob", "bbbbb-bbbbb-bbbbb-bbbbb-bbbbb")
|
||||||
|
await temp_storage.store_app_password("charlie", "ccccc-ccccc-ccccc-ccccc-ccccc")
|
||||||
|
|
||||||
|
user_ids = await temp_storage.get_all_app_password_user_ids()
|
||||||
|
assert len(user_ids) == 3
|
||||||
|
assert "alice" in user_ids
|
||||||
|
assert "bob" in user_ids
|
||||||
|
assert "charlie" in user_ids
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_all_app_password_user_ids_empty(temp_storage):
|
||||||
|
"""Test listing users when none have app passwords."""
|
||||||
|
user_ids = await temp_storage.get_all_app_password_user_ids()
|
||||||
|
assert len(user_ids) == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_app_password_encryption(encryption_key):
|
||||||
|
"""Test that app passwords are encrypted at rest."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
db_path = Path(tmpdir) / "test_encryption.db"
|
||||||
|
storage = RefreshTokenStorage(
|
||||||
|
db_path=str(db_path), encryption_key=encryption_key
|
||||||
|
)
|
||||||
|
await storage.initialize()
|
||||||
|
|
||||||
|
# Store a password
|
||||||
|
test_password = "JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB"
|
||||||
|
await storage.store_app_password("testuser", test_password)
|
||||||
|
|
||||||
|
# Read directly from database to verify encryption
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
async with aiosqlite.connect(str(db_path)) as db:
|
||||||
|
async with db.execute(
|
||||||
|
"SELECT encrypted_password FROM app_passwords WHERE user_id = ?",
|
||||||
|
("testuser",),
|
||||||
|
) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
|
||||||
|
# The stored value should be encrypted (not plain text)
|
||||||
|
encrypted_bytes = row[0]
|
||||||
|
assert encrypted_bytes != test_password.encode()
|
||||||
|
# Encrypted data should be longer due to Fernet overhead
|
||||||
|
assert len(encrypted_bytes) > len(test_password)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_app_password_requires_encryption_key():
|
||||||
|
"""Test that app password operations require encryption key."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
db_path = Path(tmpdir) / "test_no_key.db"
|
||||||
|
storage = RefreshTokenStorage(db_path=str(db_path), encryption_key=None)
|
||||||
|
await storage.initialize()
|
||||||
|
|
||||||
|
# Storing should fail without encryption key
|
||||||
|
with pytest.raises(RuntimeError, match="Encryption key not configured"):
|
||||||
|
await storage.store_app_password(
|
||||||
|
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Getting should also fail without encryption key
|
||||||
|
with pytest.raises(RuntimeError, match="Encryption key not configured"):
|
||||||
|
await storage.get_app_password("testuser")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_multiple_users_independence(temp_storage):
|
||||||
|
"""Test that different users maintain independent app passwords."""
|
||||||
|
users = ["alice", "bob", "charlie", "diana"]
|
||||||
|
|
||||||
|
# Store unique passwords for each user
|
||||||
|
for i, user in enumerate(users):
|
||||||
|
password = (
|
||||||
|
f"{user[0]}{user[0]}{user[0]}{user[0]}{user[0]}-" * 4
|
||||||
|
+ f"{user[0]}{user[0]}{user[0]}{user[0]}{user[0]}"
|
||||||
|
)
|
||||||
|
await temp_storage.store_app_password(user, password)
|
||||||
|
|
||||||
|
# Verify each user has their correct password
|
||||||
|
for user in users:
|
||||||
|
expected = (
|
||||||
|
f"{user[0]}{user[0]}{user[0]}{user[0]}{user[0]}-" * 4
|
||||||
|
+ f"{user[0]}{user[0]}{user[0]}{user[0]}{user[0]}"
|
||||||
|
)
|
||||||
|
retrieved = await temp_storage.get_app_password(user)
|
||||||
|
assert retrieved == expected
|
||||||
|
|
||||||
|
# Delete one user's password
|
||||||
|
await temp_storage.delete_app_password("bob")
|
||||||
|
|
||||||
|
# Verify other users unchanged
|
||||||
|
for user in ["alice", "charlie", "diana"]:
|
||||||
|
retrieved = await temp_storage.get_app_password(user)
|
||||||
|
assert retrieved is not None
|
||||||
|
|
||||||
|
# Verify bob's password is gone
|
||||||
|
assert await temp_storage.get_app_password("bob") is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_app_password_with_special_characters(temp_storage):
|
||||||
|
"""Test storing passwords with various alphanumeric patterns."""
|
||||||
|
# Nextcloud app passwords use alphanumeric characters
|
||||||
|
passwords = [
|
||||||
|
"AAAAA-BBBBB-CCCCC-DDDDD-EEEEE", # uppercase
|
||||||
|
"aaaaa-bbbbb-ccccc-ddddd-eeeee", # lowercase
|
||||||
|
"12345-67890-12345-67890-12345", # numbers
|
||||||
|
"aB1cD-eF2gH-iJ3kL-mN4oP-qR5sT", # mixed
|
||||||
|
]
|
||||||
|
|
||||||
|
for i, password in enumerate(passwords):
|
||||||
|
user = f"user{i}"
|
||||||
|
await temp_storage.store_app_password(user, password)
|
||||||
|
retrieved = await temp_storage.get_app_password(user)
|
||||||
|
assert retrieved == password
|
||||||
|
|
||||||
|
|
||||||
|
async def test_decryption_with_wrong_key(encryption_key):
|
||||||
|
"""Test that decryption fails with wrong key."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
db_path = Path(tmpdir) / "test_wrong_key.db"
|
||||||
|
|
||||||
|
# Store with original key
|
||||||
|
storage1 = RefreshTokenStorage(
|
||||||
|
db_path=str(db_path), encryption_key=encryption_key
|
||||||
|
)
|
||||||
|
await storage1.initialize()
|
||||||
|
await storage1.store_app_password("testuser", "JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB")
|
||||||
|
|
||||||
|
# Try to read with different key
|
||||||
|
wrong_key = Fernet.generate_key().decode()
|
||||||
|
storage2 = RefreshTokenStorage(db_path=str(db_path), encryption_key=wrong_key)
|
||||||
|
await storage2.initialize()
|
||||||
|
|
||||||
|
# Decryption should fail and return None (graceful handling)
|
||||||
|
retrieved = await storage2.get_app_password("testuser")
|
||||||
|
assert retrieved is None
|
||||||
@@ -0,0 +1,624 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for Management API app password endpoints.
|
||||||
|
|
||||||
|
Tests the REST API endpoints for multi-user BasicAuth mode app password management:
|
||||||
|
- POST /api/v1/users/{user_id}/app-password - Provision app password
|
||||||
|
- GET /api/v1/users/{user_id}/app-password - Check status
|
||||||
|
- DELETE /api/v1/users/{user_id}/app-password - Delete app password
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from starlette.applications import Starlette
|
||||||
|
from starlette.routing import Route
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.api import management
|
||||||
|
from nextcloud_mcp_server.api.management import (
|
||||||
|
delete_app_password,
|
||||||
|
get_app_password_status,
|
||||||
|
provision_app_password,
|
||||||
|
)
|
||||||
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_rate_limit():
|
||||||
|
"""Clear rate limit state before each test."""
|
||||||
|
management._rate_limit_attempts.clear()
|
||||||
|
yield
|
||||||
|
management._rate_limit_attempts.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def encryption_key():
|
||||||
|
"""Generate a test encryption key."""
|
||||||
|
return Fernet.generate_key().decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def temp_storage(encryption_key):
|
||||||
|
"""Create temporary storage instance with encryption for testing."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
db_path = Path(tmpdir) / "test_management.db"
|
||||||
|
storage = RefreshTokenStorage(
|
||||||
|
db_path=str(db_path), encryption_key=encryption_key
|
||||||
|
)
|
||||||
|
await storage.initialize()
|
||||||
|
yield storage
|
||||||
|
|
||||||
|
|
||||||
|
def create_basic_auth_header(username: str, password: str) -> str:
|
||||||
|
"""Create BasicAuth header value."""
|
||||||
|
credentials = f"{username}:{password}"
|
||||||
|
encoded = base64.b64encode(credentials.encode()).decode()
|
||||||
|
return f"Basic {encoded}"
|
||||||
|
|
||||||
|
|
||||||
|
def create_test_app(storage):
|
||||||
|
"""Create a test Starlette app with the management endpoints."""
|
||||||
|
app = Starlette(
|
||||||
|
routes=[
|
||||||
|
Route(
|
||||||
|
"/api/v1/users/{user_id}/app-password",
|
||||||
|
provision_app_password,
|
||||||
|
methods=["POST"],
|
||||||
|
),
|
||||||
|
Route(
|
||||||
|
"/api/v1/users/{user_id}/app-password",
|
||||||
|
get_app_password_status,
|
||||||
|
methods=["GET"],
|
||||||
|
),
|
||||||
|
Route(
|
||||||
|
"/api/v1/users/{user_id}/app-password",
|
||||||
|
delete_app_password,
|
||||||
|
methods=["DELETE"],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
app.state.storage = storage
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
async def test_provision_app_password_missing_auth():
|
||||||
|
"""Test that missing auth returns 401."""
|
||||||
|
app = Starlette(
|
||||||
|
routes=[
|
||||||
|
Route(
|
||||||
|
"/api/v1/users/{user_id}/app-password",
|
||||||
|
provision_app_password,
|
||||||
|
methods=["POST"],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.post("/api/v1/users/testuser/app-password")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "Missing BasicAuth" in response.json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_provision_app_password_invalid_auth_format():
|
||||||
|
"""Test that invalid auth format returns 401."""
|
||||||
|
app = Starlette(
|
||||||
|
routes=[
|
||||||
|
Route(
|
||||||
|
"/api/v1/users/{user_id}/app-password",
|
||||||
|
provision_app_password,
|
||||||
|
methods=["POST"],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/users/testuser/app-password",
|
||||||
|
headers={"Authorization": "Basic invalid-not-base64!!!"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "Invalid BasicAuth" in response.json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_provision_app_password_username_mismatch():
|
||||||
|
"""Test that username mismatch returns 403."""
|
||||||
|
app = Starlette(
|
||||||
|
routes=[
|
||||||
|
Route(
|
||||||
|
"/api/v1/users/{user_id}/app-password",
|
||||||
|
provision_app_password,
|
||||||
|
methods=["POST"],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
# Try to provision for "testuser" but auth as "otheruser"
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/users/testuser/app-password",
|
||||||
|
headers={
|
||||||
|
"Authorization": create_basic_auth_header(
|
||||||
|
"otheruser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "does not match" in response.json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_provision_app_password_invalid_format():
|
||||||
|
"""Test that invalid app password format returns 400."""
|
||||||
|
app = Starlette(
|
||||||
|
routes=[
|
||||||
|
Route(
|
||||||
|
"/api/v1/users/{user_id}/app-password",
|
||||||
|
provision_app_password,
|
||||||
|
methods=["POST"],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
# Use invalid password format (not xxxxx-xxxxx-xxxxx-xxxxx-xxxxx)
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/users/testuser/app-password",
|
||||||
|
headers={
|
||||||
|
"Authorization": create_basic_auth_header("testuser", "invalid-password")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "Invalid app password format" in response.json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_provision_app_password_success(temp_storage, mocker):
|
||||||
|
"""Test successful app password provisioning."""
|
||||||
|
# Mock settings (imported locally in the function)
|
||||||
|
mocker.patch(
|
||||||
|
"nextcloud_mcp_server.config.get_settings",
|
||||||
|
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock httpx client for Nextcloud validation
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = {"ocs": {"data": {"id": "testuser"}}}
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock()
|
||||||
|
|
||||||
|
mocker.patch(
|
||||||
|
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
|
||||||
|
return_value=mock_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create app with storage
|
||||||
|
app = create_test_app(temp_storage)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/users/testuser/app-password",
|
||||||
|
headers={
|
||||||
|
"Authorization": create_basic_auth_header(
|
||||||
|
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert "stored" in data["message"].lower()
|
||||||
|
|
||||||
|
# Verify password was stored
|
||||||
|
stored_password = await temp_storage.get_app_password("testuser")
|
||||||
|
assert stored_password == "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_provision_app_password_nextcloud_validation_fails(mocker):
|
||||||
|
"""Test that failed Nextcloud validation returns 401."""
|
||||||
|
mocker.patch(
|
||||||
|
"nextcloud_mcp_server.config.get_settings",
|
||||||
|
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock httpx client to return 401
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 401
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock()
|
||||||
|
|
||||||
|
mocker.patch(
|
||||||
|
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
|
||||||
|
return_value=mock_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
app = Starlette(
|
||||||
|
routes=[
|
||||||
|
Route(
|
||||||
|
"/api/v1/users/{user_id}/app-password",
|
||||||
|
provision_app_password,
|
||||||
|
methods=["POST"],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/users/testuser/app-password",
|
||||||
|
headers={
|
||||||
|
"Authorization": create_basic_auth_header(
|
||||||
|
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "Invalid app password" in response.json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_app_password_status_provisioned(temp_storage, mocker):
|
||||||
|
"""Test checking status when app password is provisioned."""
|
||||||
|
# Store an app password
|
||||||
|
await temp_storage.store_app_password("testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee")
|
||||||
|
|
||||||
|
app = create_test_app(temp_storage)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/users/testuser/app-password",
|
||||||
|
headers={
|
||||||
|
"Authorization": create_basic_auth_header(
|
||||||
|
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert data["user_id"] == "testuser"
|
||||||
|
assert data["has_app_password"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_app_password_status_not_provisioned(temp_storage, mocker):
|
||||||
|
"""Test checking status when app password is not provisioned."""
|
||||||
|
app = create_test_app(temp_storage)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/users/testuser/app-password",
|
||||||
|
headers={
|
||||||
|
"Authorization": create_basic_auth_header(
|
||||||
|
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert data["user_id"] == "testuser"
|
||||||
|
assert data["has_app_password"] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_app_password_status_username_mismatch():
|
||||||
|
"""Test that username mismatch returns 403 for status check."""
|
||||||
|
app = Starlette(
|
||||||
|
routes=[
|
||||||
|
Route(
|
||||||
|
"/api/v1/users/{user_id}/app-password",
|
||||||
|
get_app_password_status,
|
||||||
|
methods=["GET"],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/users/testuser/app-password",
|
||||||
|
headers={
|
||||||
|
"Authorization": create_basic_auth_header(
|
||||||
|
"otheruser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_app_password_success(temp_storage, mocker):
|
||||||
|
"""Test successful app password deletion."""
|
||||||
|
# Store an app password
|
||||||
|
await temp_storage.store_app_password("testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee")
|
||||||
|
|
||||||
|
# Mock settings (imported locally in the function)
|
||||||
|
mocker.patch(
|
||||||
|
"nextcloud_mcp_server.config.get_settings",
|
||||||
|
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock httpx client for Nextcloud validation
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock()
|
||||||
|
|
||||||
|
mocker.patch(
|
||||||
|
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
|
||||||
|
return_value=mock_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
app = create_test_app(temp_storage)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.delete(
|
||||||
|
"/api/v1/users/testuser/app-password",
|
||||||
|
headers={
|
||||||
|
"Authorization": create_basic_auth_header(
|
||||||
|
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert "deleted" in data["message"].lower()
|
||||||
|
|
||||||
|
# Verify password was removed
|
||||||
|
stored_password = await temp_storage.get_app_password("testuser")
|
||||||
|
assert stored_password is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_app_password_not_found(temp_storage, mocker):
|
||||||
|
"""Test deleting non-existent app password."""
|
||||||
|
# Mock settings (imported locally in the function)
|
||||||
|
mocker.patch(
|
||||||
|
"nextcloud_mcp_server.config.get_settings",
|
||||||
|
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock httpx client for Nextcloud validation
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock()
|
||||||
|
|
||||||
|
mocker.patch(
|
||||||
|
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
|
||||||
|
return_value=mock_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
app = create_test_app(temp_storage)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.delete(
|
||||||
|
"/api/v1/users/testuser/app-password",
|
||||||
|
headers={
|
||||||
|
"Authorization": create_basic_auth_header(
|
||||||
|
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert "no app password found" in data["message"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_app_password_invalid_credentials(mocker):
|
||||||
|
"""Test that invalid credentials returns 401 for deletion."""
|
||||||
|
mocker.patch(
|
||||||
|
"nextcloud_mcp_server.config.get_settings",
|
||||||
|
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock httpx client to return 401
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 401
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock()
|
||||||
|
|
||||||
|
mocker.patch(
|
||||||
|
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
|
||||||
|
return_value=mock_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
app = Starlette(
|
||||||
|
routes=[
|
||||||
|
Route(
|
||||||
|
"/api/v1/users/{user_id}/app-password",
|
||||||
|
delete_app_password,
|
||||||
|
methods=["DELETE"],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.delete(
|
||||||
|
"/api/v1/users/testuser/app-password",
|
||||||
|
headers={
|
||||||
|
"Authorization": create_basic_auth_header(
|
||||||
|
"testuser", "wrong-password-xxxxx"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "Invalid credentials" in response.json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_app_password_username_mismatch():
|
||||||
|
"""Test that username mismatch returns 403 for deletion."""
|
||||||
|
app = Starlette(
|
||||||
|
routes=[
|
||||||
|
Route(
|
||||||
|
"/api/v1/users/{user_id}/app-password",
|
||||||
|
delete_app_password,
|
||||||
|
methods=["DELETE"],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.delete(
|
||||||
|
"/api/v1/users/testuser/app-password",
|
||||||
|
headers={
|
||||||
|
"Authorization": create_basic_auth_header(
|
||||||
|
"otheruser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
async def test_provision_app_password_rate_limiting(mocker):
|
||||||
|
"""Test that rate limiting blocks excessive provisioning attempts."""
|
||||||
|
mocker.patch(
|
||||||
|
"nextcloud_mcp_server.config.get_settings",
|
||||||
|
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock httpx client to return 401 (failed validation)
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 401
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock()
|
||||||
|
|
||||||
|
mocker.patch(
|
||||||
|
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
|
||||||
|
return_value=mock_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
app = Starlette(
|
||||||
|
routes=[
|
||||||
|
Route(
|
||||||
|
"/api/v1/users/{user_id}/app-password",
|
||||||
|
provision_app_password,
|
||||||
|
methods=["POST"],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
# Make 5 failed attempts (should all return 401)
|
||||||
|
for i in range(5):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/users/testuser/app-password",
|
||||||
|
headers={
|
||||||
|
"Authorization": create_basic_auth_header(
|
||||||
|
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401, f"Attempt {i + 1} should return 401"
|
||||||
|
|
||||||
|
# 6th attempt should be rate limited (429)
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/users/testuser/app-password",
|
||||||
|
headers={
|
||||||
|
"Authorization": create_basic_auth_header(
|
||||||
|
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 429
|
||||||
|
assert "Rate limit exceeded" in response.json()["error"]
|
||||||
|
assert "Retry-After" in response.headers
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rate_limiting_is_per_user(mocker):
|
||||||
|
"""Test that rate limiting is applied per user, not globally."""
|
||||||
|
mocker.patch(
|
||||||
|
"nextcloud_mcp_server.config.get_settings",
|
||||||
|
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock httpx client to return 401
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 401
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock()
|
||||||
|
|
||||||
|
mocker.patch(
|
||||||
|
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
|
||||||
|
return_value=mock_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
app = Starlette(
|
||||||
|
routes=[
|
||||||
|
Route(
|
||||||
|
"/api/v1/users/{user_id}/app-password",
|
||||||
|
provision_app_password,
|
||||||
|
methods=["POST"],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
# Make 5 failed attempts for user1 (hits rate limit)
|
||||||
|
for _ in range(5):
|
||||||
|
client.post(
|
||||||
|
"/api/v1/users/user1/app-password",
|
||||||
|
headers={
|
||||||
|
"Authorization": create_basic_auth_header(
|
||||||
|
"user1", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# user1 should be rate limited
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/users/user1/app-password",
|
||||||
|
headers={
|
||||||
|
"Authorization": create_basic_auth_header(
|
||||||
|
"user1", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 429
|
||||||
|
|
||||||
|
# user2 should NOT be rate limited (different user)
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/users/user2/app-password",
|
||||||
|
headers={
|
||||||
|
"Authorization": create_basic_auth_header(
|
||||||
|
"user2", "bbbbb-ccccc-ddddd-eeeee-fffff"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401 # Fails validation, but not rate limited
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for Management API status endpoint.
|
||||||
|
|
||||||
|
Tests the /api/v1/status endpoint focusing on:
|
||||||
|
- OIDC config availability in different auth modes
|
||||||
|
- Hybrid mode (multi_user_basic + enable_offline_access) returning OIDC config
|
||||||
|
- OAuth mode returning OIDC config
|
||||||
|
- Non-OAuth modes NOT returning OIDC config
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from starlette.applications import Starlette
|
||||||
|
from starlette.routing import Route
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.api.management import get_server_status
|
||||||
|
from nextcloud_mcp_server.config_validators import AuthMode
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
|
def create_test_app():
|
||||||
|
"""Create a test Starlette app with the status endpoint."""
|
||||||
|
return Starlette(
|
||||||
|
routes=[
|
||||||
|
Route("/api/v1/status", get_server_status, methods=["GET"]),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_mock_settings(
|
||||||
|
enable_multi_user_basic: bool = False,
|
||||||
|
enable_offline_access: bool = False,
|
||||||
|
oidc_discovery_url: str | None = None,
|
||||||
|
oidc_issuer: str | None = None,
|
||||||
|
vector_sync_enabled: bool = False,
|
||||||
|
nextcloud_url: str = "http://localhost",
|
||||||
|
enable_token_exchange: bool = False,
|
||||||
|
mcp_client_id: str | None = None,
|
||||||
|
mcp_client_secret: str | None = None,
|
||||||
|
):
|
||||||
|
"""Create mock settings with specified auth configuration."""
|
||||||
|
settings = MagicMock()
|
||||||
|
settings.enable_multi_user_basic_auth = enable_multi_user_basic
|
||||||
|
settings.enable_offline_access = enable_offline_access
|
||||||
|
settings.oidc_discovery_url = oidc_discovery_url
|
||||||
|
settings.oidc_issuer = oidc_issuer
|
||||||
|
settings.vector_sync_enabled = vector_sync_enabled
|
||||||
|
settings.nextcloud_url = nextcloud_url
|
||||||
|
settings.enable_token_exchange = enable_token_exchange
|
||||||
|
settings.mcp_client_id = mcp_client_id
|
||||||
|
settings.mcp_client_secret = mcp_client_secret
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatusEndpointOidcConfig:
|
||||||
|
"""Tests for OIDC configuration in status endpoint."""
|
||||||
|
|
||||||
|
def test_hybrid_mode_returns_oidc_config(self):
|
||||||
|
"""Test that hybrid mode (multi_user_basic + offline_access) returns OIDC config."""
|
||||||
|
mock_settings = create_mock_settings(
|
||||||
|
enable_multi_user_basic=True,
|
||||||
|
enable_offline_access=True,
|
||||||
|
oidc_discovery_url="http://keycloak/.well-known/openid-configuration",
|
||||||
|
oidc_issuer="http://keycloak/realms/test",
|
||||||
|
)
|
||||||
|
|
||||||
|
# get_settings and detect_auth_mode are imported inside the function
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||||
|
return_value=AuthMode.MULTI_USER_BASIC,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/api/v1/status")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Verify auth mode
|
||||||
|
assert data["auth_mode"] == "multi_user_basic"
|
||||||
|
assert data["supports_app_passwords"] is True
|
||||||
|
|
||||||
|
# Verify OIDC config is present (key feature for hybrid mode)
|
||||||
|
assert "oidc" in data
|
||||||
|
assert (
|
||||||
|
data["oidc"]["discovery_url"]
|
||||||
|
== "http://keycloak/.well-known/openid-configuration"
|
||||||
|
)
|
||||||
|
assert data["oidc"]["issuer"] == "http://keycloak/realms/test"
|
||||||
|
|
||||||
|
def test_hybrid_mode_without_oidc_settings_no_oidc_key(self):
|
||||||
|
"""Test that hybrid mode without OIDC settings doesn't include empty oidc key."""
|
||||||
|
mock_settings = create_mock_settings(
|
||||||
|
enable_multi_user_basic=True,
|
||||||
|
enable_offline_access=True,
|
||||||
|
oidc_discovery_url=None,
|
||||||
|
oidc_issuer=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||||
|
return_value=AuthMode.MULTI_USER_BASIC,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/api/v1/status")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# OIDC key should NOT be present if no OIDC settings configured
|
||||||
|
assert "oidc" not in data
|
||||||
|
|
||||||
|
def test_multi_user_basic_without_offline_access_no_oidc(self):
|
||||||
|
"""Test that multi_user_basic WITHOUT offline_access doesn't return OIDC config."""
|
||||||
|
mock_settings = create_mock_settings(
|
||||||
|
enable_multi_user_basic=True,
|
||||||
|
enable_offline_access=False, # Key difference: no offline access
|
||||||
|
oidc_discovery_url="http://keycloak/.well-known/openid-configuration",
|
||||||
|
oidc_issuer="http://keycloak/realms/test",
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||||
|
return_value=AuthMode.MULTI_USER_BASIC,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/api/v1/status")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Verify auth mode
|
||||||
|
assert data["auth_mode"] == "multi_user_basic"
|
||||||
|
assert data["supports_app_passwords"] is False
|
||||||
|
|
||||||
|
# OIDC config should NOT be present (not hybrid mode)
|
||||||
|
assert "oidc" not in data
|
||||||
|
|
||||||
|
def test_oauth_mode_returns_oidc_config(self):
|
||||||
|
"""Test that OAuth mode returns OIDC config."""
|
||||||
|
mock_settings = create_mock_settings(
|
||||||
|
enable_multi_user_basic=False,
|
||||||
|
enable_offline_access=True,
|
||||||
|
oidc_discovery_url="http://nextcloud/.well-known/openid-configuration",
|
||||||
|
oidc_issuer="http://nextcloud",
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||||
|
return_value=AuthMode.OAUTH_SINGLE_AUDIENCE,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/api/v1/status")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Verify auth mode
|
||||||
|
assert data["auth_mode"] == "oauth"
|
||||||
|
|
||||||
|
# Verify OIDC config is present
|
||||||
|
assert "oidc" in data
|
||||||
|
assert (
|
||||||
|
data["oidc"]["discovery_url"]
|
||||||
|
== "http://nextcloud/.well-known/openid-configuration"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_single_user_basic_no_oidc(self):
|
||||||
|
"""Test that single-user BasicAuth mode doesn't return OIDC config."""
|
||||||
|
mock_settings = create_mock_settings(
|
||||||
|
enable_multi_user_basic=False,
|
||||||
|
enable_offline_access=False,
|
||||||
|
oidc_discovery_url="http://keycloak/.well-known/openid-configuration",
|
||||||
|
oidc_issuer="http://keycloak/realms/test",
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||||
|
return_value=AuthMode.SINGLE_USER_BASIC,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/api/v1/status")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Verify auth mode
|
||||||
|
assert data["auth_mode"] == "basic"
|
||||||
|
|
||||||
|
# OIDC config should NOT be present
|
||||||
|
assert "oidc" not in data
|
||||||
|
# supports_app_passwords should NOT be present (only for multi_user_basic)
|
||||||
|
assert "supports_app_passwords" not in data
|
||||||
|
|
||||||
|
def test_oidc_partial_config_only_discovery_url(self):
|
||||||
|
"""Test OIDC config with only discovery URL set."""
|
||||||
|
mock_settings = create_mock_settings(
|
||||||
|
enable_multi_user_basic=True,
|
||||||
|
enable_offline_access=True,
|
||||||
|
oidc_discovery_url="http://keycloak/.well-known/openid-configuration",
|
||||||
|
oidc_issuer=None, # Only discovery URL
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||||
|
return_value=AuthMode.MULTI_USER_BASIC,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/api/v1/status")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert "oidc" in data
|
||||||
|
assert (
|
||||||
|
data["oidc"]["discovery_url"]
|
||||||
|
== "http://keycloak/.well-known/openid-configuration"
|
||||||
|
)
|
||||||
|
assert "issuer" not in data["oidc"]
|
||||||
|
|
||||||
|
def test_oidc_partial_config_only_issuer(self):
|
||||||
|
"""Test OIDC config with only issuer set."""
|
||||||
|
mock_settings = create_mock_settings(
|
||||||
|
enable_multi_user_basic=True,
|
||||||
|
enable_offline_access=True,
|
||||||
|
oidc_discovery_url=None, # Only issuer
|
||||||
|
oidc_issuer="http://keycloak/realms/test",
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||||
|
return_value=AuthMode.MULTI_USER_BASIC,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/api/v1/status")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert "oidc" in data
|
||||||
|
assert "discovery_url" not in data["oidc"]
|
||||||
|
assert data["oidc"]["issuer"] == "http://keycloak/realms/test"
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatusEndpointBasicResponse:
|
||||||
|
"""Tests for basic status endpoint response fields."""
|
||||||
|
|
||||||
|
def test_status_includes_version(self):
|
||||||
|
"""Test that status endpoint includes version."""
|
||||||
|
mock_settings = create_mock_settings()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||||
|
return_value=AuthMode.SINGLE_USER_BASIC,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/api/v1/status")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert "version" in data
|
||||||
|
assert "uptime_seconds" in data
|
||||||
|
assert "management_api_version" in data
|
||||||
|
assert data["management_api_version"] == "1.0"
|
||||||
|
|
||||||
|
def test_status_includes_vector_sync_enabled(self):
|
||||||
|
"""Test that status endpoint includes vector_sync_enabled."""
|
||||||
|
mock_settings = create_mock_settings(vector_sync_enabled=True)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||||
|
return_value=AuthMode.SINGLE_USER_BASIC,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
app = create_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/api/v1/status")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert data["vector_sync_enabled"] is True
|
||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[tool.commitizen]
|
[tool.commitizen]
|
||||||
name = "cz_conventional_commits"
|
name = "cz_conventional_commits"
|
||||||
version = "0.7.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@c2bf575a3516752db5ce2915499d3f694885e2c7 # v1.0.0
|
|
||||||
|
|
||||||
php-lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: matrix
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
php-versions: ${{fromJson(needs.matrix.outputs.php-versions)}}
|
|
||||||
|
|
||||||
name: php-lint
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Set up php ${{ matrix.php-versions }}
|
|
||||||
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
|
|
||||||
with:
|
|
||||||
php-version: ${{ matrix.php-versions }}
|
|
||||||
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite
|
|
||||||
coverage: none
|
|
||||||
ini-file: development
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
run: composer run lint
|
|
||||||
|
|
||||||
summary:
|
|
||||||
permissions:
|
|
||||||
contents: none
|
|
||||||
runs-on: ubuntu-latest-low
|
|
||||||
needs: php-lint
|
|
||||||
|
|
||||||
if: always()
|
|
||||||
|
|
||||||
name: php-lint-summary
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Summary status
|
|
||||||
run: if ${{ needs.php-lint.result != 'success' && needs.php-lint.result != 'skipped' }}; then exit 1; fi
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# This workflow is provided via the organization template repository
|
|
||||||
#
|
|
||||||
# https://github.com/nextcloud/.github
|
|
||||||
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
|
|
||||||
#
|
|
||||||
# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
|
|
||||||
# SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
name: Lint stylelint
|
|
||||||
|
|
||||||
on: pull_request
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: lint-stylelint-${{ github.head_ref || github.run_id }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
name: stylelint
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Read package.json node and npm engines version
|
|
||||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
|
||||||
id: versions
|
|
||||||
with:
|
|
||||||
fallbackNode: '^20'
|
|
||||||
fallbackNpm: '^10'
|
|
||||||
|
|
||||||
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
|
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
|
||||||
with:
|
|
||||||
node-version: ${{ steps.versions.outputs.nodeVersion }}
|
|
||||||
|
|
||||||
- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
|
|
||||||
run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
env:
|
|
||||||
CYPRESS_INSTALL_BINARY: 0
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
run: npm run stylelint
|
|
||||||
-107
@@ -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
+50
@@ -25,6 +25,56 @@ 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)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- Add rate limiting and extract helpers for app password endpoints
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **astrolabe**: define appName and appVersion for @nextcloud/vue
|
||||||
|
- Add missing annotations for deck remove/unassign operations
|
||||||
|
- **auth**: Store app passwords locally for multi-user BasicAuth background sync
|
||||||
|
- **deck**: use correct endpoint for reorder_card to fix cross-stack moves
|
||||||
|
- **deck**: Always preserve fields in update_card for partial updates
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- Use get_settings() for vector sync enabled check
|
||||||
|
- Extract storage helper and improve PHP error handling
|
||||||
|
|
||||||
|
## astrolabe-v0.7.2 (2025-12-30)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **astrolabe**: Fix CSS loading for Nextcloud apps
|
||||||
|
|
||||||
|
## astrolabe-v0.7.1 (2025-12-30)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **astrolabe**: Fix revoke access button HTTP method mismatch
|
||||||
|
- **oauth**: Enable browser OAuth routes for Management API in hybrid mode
|
||||||
|
- **mcp**: Move all imports to the top of modules
|
||||||
|
|
||||||
## astrolabe-v0.7.0 (2025-12-26)
|
## astrolabe-v0.7.0 (2025-12-26)
|
||||||
|
|
||||||
### 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.7.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,
|
||||||
|
|||||||
+82
-16
@@ -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,
|
||||||
@@ -94,24 +94,90 @@ class CredentialsController extends Controller {
|
|||||||
], Http::STATUS_UNAUTHORIZED);
|
], Http::STATUS_UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store encrypted app password
|
// Store encrypted app password locally in Nextcloud
|
||||||
try {
|
try {
|
||||||
$this->tokenStorage->storeBackgroundSyncPassword($userId, $appPassword);
|
$this->tokenStorage->storeBackgroundSyncPassword($userId, $appPassword);
|
||||||
$this->logger->info("Successfully stored app password for user: $userId");
|
$this->logger->info("Stored app password locally for user: $userId");
|
||||||
|
|
||||||
return new JSONResponse([
|
|
||||||
'success' => true,
|
|
||||||
'message' => 'App password saved successfully'
|
|
||||||
], Http::STATUS_OK);
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->logger->error("Failed to store app password for user $userId", [
|
$this->logger->error("Failed to store app password locally for user $userId", [
|
||||||
'error' => $e->getMessage()
|
'error' => $e->getMessage()
|
||||||
]);
|
]);
|
||||||
return new JSONResponse([
|
return new JSONResponse([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => 'Failed to save app password'
|
'error' => 'Failed to save app password locally'
|
||||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send app password to MCP server for background sync
|
||||||
|
// Get MCP server URL from system config (set in config.php)
|
||||||
|
$mcpServerUrl = $this->config->getSystemValue('mcp_server_url', '');
|
||||||
|
if (empty($mcpServerUrl)) {
|
||||||
|
$this->logger->warning('MCP server URL not configured, app password stored locally only');
|
||||||
|
return new JSONResponse([
|
||||||
|
'success' => true,
|
||||||
|
'partial_success' => true,
|
||||||
|
'local_storage' => true,
|
||||||
|
'mcp_sync' => false,
|
||||||
|
'message' => 'App password saved locally (MCP server not configured)'
|
||||||
|
], Http::STATUS_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$httpClient = $this->httpClientService->newClient();
|
||||||
|
|
||||||
|
// Send to MCP server with BasicAuth (user proves ownership of password)
|
||||||
|
$mcpEndpoint = rtrim($mcpServerUrl, '/') . '/api/v1/users/' . urlencode($userId) . '/app-password';
|
||||||
|
|
||||||
|
$this->logger->debug("Sending app password to MCP server: $mcpEndpoint");
|
||||||
|
|
||||||
|
$response = $httpClient->post($mcpEndpoint, [
|
||||||
|
'auth' => [$userId, $appPassword],
|
||||||
|
'headers' => [
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
],
|
||||||
|
'timeout' => 10,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$statusCode = $response->getStatusCode();
|
||||||
|
$body = json_decode($response->getBody(), true);
|
||||||
|
|
||||||
|
if ($statusCode === 200 && ($body['success'] ?? false)) {
|
||||||
|
$this->logger->info("Successfully provisioned app password to MCP server for user: $userId");
|
||||||
|
return new JSONResponse([
|
||||||
|
'success' => true,
|
||||||
|
'partial_success' => false,
|
||||||
|
'local_storage' => true,
|
||||||
|
'mcp_sync' => true,
|
||||||
|
'message' => 'App password saved successfully'
|
||||||
|
], Http::STATUS_OK);
|
||||||
|
} else {
|
||||||
|
$error = $body['error'] ?? 'Unknown error';
|
||||||
|
$this->logger->error("MCP server rejected app password for user $userId: $error");
|
||||||
|
// Return partial success since it was stored locally but MCP sync failed
|
||||||
|
return new JSONResponse([
|
||||||
|
'success' => true,
|
||||||
|
'partial_success' => true,
|
||||||
|
'local_storage' => true,
|
||||||
|
'mcp_sync' => false,
|
||||||
|
'message' => 'App password saved locally (MCP server sync failed)',
|
||||||
|
'mcp_error' => $error
|
||||||
|
], Http::STATUS_OK);
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->logger->error("Failed to send app password to MCP server for user $userId", [
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
]);
|
||||||
|
// Return partial success since it was stored locally but MCP was unreachable
|
||||||
|
return new JSONResponse([
|
||||||
|
'success' => true,
|
||||||
|
'partial_success' => true,
|
||||||
|
'local_storage' => true,
|
||||||
|
'mcp_sync' => false,
|
||||||
|
'message' => 'App password saved locally (MCP server unreachable)',
|
||||||
|
'mcp_error' => $e->getMessage()
|
||||||
|
], Http::STATUS_OK);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+10
-9
@@ -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.7.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
+15
-17
@@ -48,22 +48,21 @@
|
|||||||
<div class="mcp-search-card">
|
<div class="mcp-search-card">
|
||||||
<div class="mcp-search-row">
|
<div class="mcp-search-row">
|
||||||
<NcTextField
|
<NcTextField
|
||||||
:value="query"
|
v-model="query"
|
||||||
:label="t('astrolabe', 'Search query')"
|
:label="t('astrolabe', 'Search query')"
|
||||||
:placeholder="t('astrolabe', 'Enter your search query...')"
|
:placeholder="t('astrolabe', 'Enter your search query...')"
|
||||||
class="mcp-search-input"
|
class="mcp-search-input"
|
||||||
@update:value="query = $event"
|
|
||||||
@keyup.enter="performSearch" />
|
@keyup.enter="performSearch" />
|
||||||
|
|
||||||
<NcSelect
|
<NcSelect
|
||||||
v-model="selectedAlgorithmOption"
|
:model-value="selectedAlgorithmOption"
|
||||||
:options="algorithmOptions"
|
:options="algorithmOptions"
|
||||||
:placeholder="t('astrolabe', 'Algorithm')"
|
:placeholder="t('astrolabe', 'Algorithm')"
|
||||||
class="mcp-algorithm-select"
|
class="mcp-algorithm-select"
|
||||||
@input="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>
|
||||||
@@ -75,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>
|
||||||
@@ -94,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>
|
||||||
@@ -105,11 +104,10 @@
|
|||||||
<div class="mcp-option-group">
|
<div class="mcp-option-group">
|
||||||
<label>{{ t('astrolabe', 'Result Limit') }}</label>
|
<label>{{ t('astrolabe', 'Result Limit') }}</label>
|
||||||
<NcTextField
|
<NcTextField
|
||||||
:value="limit"
|
v-model="limit"
|
||||||
type="number"
|
type="number"
|
||||||
:min="1"
|
:min="1"
|
||||||
:max="100"
|
:max="100" />
|
||||||
@update:value="limit = Number($event)" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mcp-option-group">
|
<div class="mcp-option-group">
|
||||||
@@ -154,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>
|
||||||
@@ -175,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>
|
||||||
@@ -282,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>
|
||||||
@@ -307,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>
|
||||||
@@ -445,7 +443,7 @@ export default {
|
|||||||
algorithm: 'hybrid',
|
algorithm: 'hybrid',
|
||||||
showAdvanced: false,
|
showAdvanced: false,
|
||||||
selectedDocTypes: [],
|
selectedDocTypes: [],
|
||||||
limit: '20',
|
limit: 20,
|
||||||
scoreThreshold: 0,
|
scoreThreshold: 0,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|||||||
+24
-14
@@ -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')) }}
|
||||||
@@ -152,19 +152,21 @@
|
|||||||
|
|
||||||
<div class="settings-form">
|
<div class="settings-form">
|
||||||
<NcSelect
|
<NcSelect
|
||||||
v-model="settings.algorithm"
|
: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'" />
|
||||||
<p class="help-text">
|
<p class="help-text">
|
||||||
{{ t('astrolabe', 'Hybrid combines semantic understanding with keyword matching. Semantic finds conceptually similar content. BM25 matches exact keywords.') }}
|
{{ t('astrolabe', 'Hybrid combines semantic understanding with keyword matching. Semantic finds conceptually similar content. BM25 matches exact keywords.') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<NcSelect
|
<NcSelect
|
||||||
v-model="settings.fusion"
|
: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'" />
|
||||||
<p class="help-text">
|
<p class="help-text">
|
||||||
{{ t('astrolabe', 'Only applies to hybrid search. RRF balances results well for most queries. DBSF may work better when keyword matches are over/under-weighted.') }}
|
{{ t('astrolabe', 'Only applies to hybrid search. RRF balances results well for most queries. DBSF may work better when keyword matches are over/under-weighted.') }}
|
||||||
</p>
|
</p>
|
||||||
@@ -184,20 +186,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NcTextField
|
<NcTextField
|
||||||
:value="settings.limit"
|
v-model="settings.limit"
|
||||||
:label="t('astrolabe', 'Maximum Results')"
|
:label="t('astrolabe', 'Maximum Results')"
|
||||||
type="number"
|
type="number"
|
||||||
:min="5"
|
:min="5"
|
||||||
:max="100"
|
:max="100"
|
||||||
:step="5"
|
:step="5"
|
||||||
class="form-field"
|
class="form-field" />
|
||||||
@update:value="settings.limit = Number($event)" />
|
|
||||||
<p class="help-text">
|
<p class="help-text">
|
||||||
{{ t('astrolabe', 'Maximum number of results to return per search query (5-100).') }}
|
{{ t('astrolabe', 'Maximum number of results to return per search query (5-100).') }}
|
||||||
</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>
|
||||||
@@ -276,6 +277,15 @@ const fusionOptions = computed(() => [
|
|||||||
{ id: 'dbsf', label: t('astrolabe', 'DBSF - Distribution-Based Score Fusion') },
|
{ id: 'dbsf', label: t('astrolabe', 'DBSF - Distribution-Based Score Fusion') },
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// Computed properties for NcSelect (converts between stored ID and option object)
|
||||||
|
const selectedAlgorithmOption = computed(() =>
|
||||||
|
algorithmOptions.value.find(opt => opt.id === settings.value.algorithm) || algorithmOptions.value[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedFusionOption = computed(() =>
|
||||||
|
fusionOptions.value.find(opt => opt.id === settings.value.fusion) || fusionOptions.value[0],
|
||||||
|
)
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
async function loadServerStatus() {
|
async function loadServerStatus() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|||||||
+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>
|
||||||
Vendored
+15
-3
@@ -1,15 +1,26 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import { resolve } from 'path'
|
import { resolve } from 'path'
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
|
||||||
|
// Read app info from info.xml for @nextcloud/vue
|
||||||
|
const infoXml = readFileSync(resolve(__dirname, 'appinfo/info.xml'), 'utf-8')
|
||||||
|
const appName = infoXml.match(/<id>([^<]+)<\/id>/)?.[1] || 'astrolabe'
|
||||||
|
const appVersion = infoXml.match(/<version>([^<]+)<\/version>/)?.[1] || ''
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
|
define: {
|
||||||
|
appName: JSON.stringify(appName),
|
||||||
|
appVersion: JSON.stringify(appVersion),
|
||||||
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: '.',
|
outDir: '.',
|
||||||
emptyOutDir: false,
|
emptyOutDir: false,
|
||||||
|
cssCodeSplit: false, // Bundle all CSS into entry points (Nextcloud doesn't load CSS chunks)
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
main: resolve(__dirname, 'src/main.js'),
|
'astrolabe-main': resolve(__dirname, 'src/main.js'),
|
||||||
'astrolabe-adminSettings': resolve(__dirname, 'src/adminSettings.js'),
|
'astrolabe-adminSettings': resolve(__dirname, 'src/adminSettings.js'),
|
||||||
'astrolabe-personalSettings': resolve(__dirname, 'src/personalSettings.js'),
|
'astrolabe-personalSettings': resolve(__dirname, 'src/personalSettings.js'),
|
||||||
},
|
},
|
||||||
@@ -17,9 +28,10 @@ export default defineConfig({
|
|||||||
entryFileNames: 'js/[name].mjs',
|
entryFileNames: 'js/[name].mjs',
|
||||||
chunkFileNames: 'js/[name]-[hash].chunk.mjs',
|
chunkFileNames: 'js/[name]-[hash].chunk.mjs',
|
||||||
assetFileNames: (assetInfo) => {
|
assetFileNames: (assetInfo) => {
|
||||||
// Output CSS to css/ directory, JS/other assets to js/
|
// With cssCodeSplit:false, all CSS goes to a single file
|
||||||
|
// Name it astrolabe-main.css to match Nextcloud's Util::addStyle expectation
|
||||||
if (assetInfo.name && assetInfo.name.endsWith('.css')) {
|
if (assetInfo.name && assetInfo.name.endsWith('.css')) {
|
||||||
return 'css/[name][extname]';
|
return 'css/astrolabe-main.css';
|
||||||
}
|
}
|
||||||
return 'js/[name][extname]';
|
return 'js/[name][extname]';
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1988,7 +1988,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.60.2"
|
version = "0.61.5"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
|
|||||||
Reference in New Issue
Block a user