diff --git a/.github/workflows/benchmark-comment.js b/.github/workflows/benchmark-comment.js new file mode 100644 index 0000000000..f25116c4f2 --- /dev/null +++ b/.github/workflows/benchmark-comment.js @@ -0,0 +1,349 @@ +const fs = require('fs'); + +const marker = ''; +const serviceLabels = ['Account', 'TablesDB', 'Storage', 'Functions']; + +module.exports = async ({ github, context, core }) => { + const body = buildComment(core); + fs.writeFileSync('benchmark-comment.txt', body); + + const pullRequest = context.payload.pull_request; + if (!pullRequest || pullRequest.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`) { + return; + } + + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + per_page: 100, + }); + + const existing = comments.find((comment) => { + return comment.user?.type === 'Bot' && comment.body?.includes(marker); + }) || comments.find((comment) => { + return comment.user?.type === 'Bot' && comment.body?.includes('Benchmark results'); + }); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + return; + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + body, + }); +}; + +function buildComment(core) { + const before = readSummary('benchmark-before-summary.json', core); + const after = readSummary('benchmark-after-summary.json', core); + const beforeSamples = readSamples('benchmark-before-samples.json', core); + const afterSamples = readSamples('benchmark-after-samples.json', core); + const baseRef = markdownText(process.env.BENCHMARK_BASE_REF || 'base'); + const headRef = markdownText(process.env.BENCHMARK_HEAD_REF || 'head'); + const rows = benchmarkRows(before, after, beforeSamples, afterSamples); + const topWaits = topSamples(afterSamples, 'appwrite_api_waiting', 3); + const lines = [ + marker, + '## :sparkles: Benchmark results', + '', + `Comparing ${baseRef} (before) to ${headRef} (after).`, + '', + ]; + + if (before === null) { + lines.push('> Before benchmark did not complete; showing current branch metrics only.', ''); + } + if (after === null) { + lines.push('> Current branch benchmark did not complete; showing available metrics only.', ''); + } + + lines.push( + '**Before**', + '', + metricTable(rows, 'before'), + '', + '**After**', + '', + metricTable(rows, 'after'), + '', + '**Delta**', + '', + '| Scenario | P95 delta (ms) |', + '| --- | ---: |', + ...rows.map(deltaRow), + '', + '
', + 'Top API waits', + '', + '
', + '', + '| API request | Max wait (ms) |', + '| --- | ---: |', + ...topWaitRows(topWaits), + '', + '
', + ); + + return `${lines.join('\n')}\n`; +} + +function readSummary(path, core) { + if (!fs.existsSync(path)) { + return null; + } + + try { + return JSON.parse(fs.readFileSync(path, 'utf8')); + } catch (error) { + core?.warning(`Invalid benchmark summary ${path}: ${error.message}`); + return null; + } +} + +function readSamples(path, core) { + if (!fs.existsSync(path)) { + return []; + } + + const contents = fs.readFileSync(path, 'utf8').trim(); + if (contents === '') { + return []; + } + + return contents + .split('\n') + .filter(Boolean) + .flatMap((line) => { + try { + return [JSON.parse(line)]; + } catch (error) { + core?.warning(`Invalid benchmark sample in ${path}: ${error.message}`); + return []; + } + }); +} + +function benchmarkRows(before, after, beforeSamples, afterSamples) { + const beforeServices = serviceStats(beforeSamples); + const afterServices = serviceStats(afterSamples); + return [ + { + label: 'API total', + before: apiSampleStats(beforeSamples) || summaryStats(before, 'appwrite_api_duration'), + after: apiSampleStats(afterSamples) || summaryStats(after, 'appwrite_api_duration'), + }, + ...serviceLabels.map((label) => ({ + label, + before: beforeServices.get(label) || null, + after: afterServices.get(label) || null, + })), + ]; +} + +function summaryStats(summary, durationMetric, iterationsMetric = null, rpsMetric = null) { + const values = metricValues(summary, durationMetric); + if (!values) { + return null; + } + + return { + p50: values.med ?? null, + p95: values['p(95)'] ?? null, + iterations: iterationsMetric ? metricValue(summary, iterationsMetric, 'count') : values.count ?? null, + rps: rpsMetric ? metricValue(summary, rpsMetric, 'rate') : null, + }; +} + +function serviceStats(samples) { + const apiSamples = samples.filter((sample) => { + return sample.metric === 'appwrite_api_duration' && typeof sample.data?.value === 'number'; + }); + const groups = new Map(); + + for (const sample of apiSamples) { + const service = serviceFromName(sample.data.tags?.name || ''); + if (!service) { + continue; + } + + const serviceSamples = groups.get(service) || []; + serviceSamples.push(sample); + groups.set(service, serviceSamples); + } + + return new Map([...groups.entries()].map(([service, serviceSamples]) => { + const values = serviceSamples.map((sample) => sample.data.value); + const durationSeconds = sampleWindowSeconds(serviceSamples); + return [service, { + p50: percentile(values, 50), + p95: percentile(values, 95), + iterations: values.length, + rps: durationSeconds ? values.length / durationSeconds : null, + }]; + })); +} + +function apiSampleStats(samples) { + const apiSamples = samples.filter((sample) => { + return sample.metric === 'appwrite_api_duration' && typeof sample.data?.value === 'number'; + }); + const values = apiSamples.map((sample) => sample.data.value); + if (values.length === 0) { + return null; + } + + const durationSeconds = sampleWindowSeconds(apiSamples); + return { + p50: percentile(values, 50), + p95: percentile(values, 95), + iterations: values.length, + rps: durationSeconds ? values.length / durationSeconds : null, + }; +} + +function serviceFromName(name) { + if (name.startsWith('account.')) { + return 'Account'; + } + if (name.startsWith('tablesdb.')) { + return 'TablesDB'; + } + if (name.startsWith('storage.') || name.startsWith('tokens.')) { + return 'Storage'; + } + if (name.startsWith('functions.')) { + return 'Functions'; + } + return null; +} + +function sampleWindowSeconds(samples) { + const times = samples + .map((sample) => Date.parse(sample.data?.time)) + .filter((value) => !Number.isNaN(value)); + if (times.length < 2) { + return null; + } + + return Math.max((Math.max(...times) - Math.min(...times)) / 1000, 1); +} + +function percentile(values, percentileValue) { + if (values.length === 0) { + return null; + } + + const sorted = [...values].sort((left, right) => left - right); + const index = Math.ceil((percentileValue / 100) * sorted.length) - 1; + return sorted[Math.max(0, Math.min(index, sorted.length - 1))]; +} + +function metricValues(data, metric) { + return data?.metrics?.[metric]?.values ?? null; +} + +function metricValue(data, metric, stat) { + return metricValues(data, metric)?.[stat] ?? null; +} + +function metricTable(rows, side) { + return [ + '| Scenario | P50 (ms) | P95 (ms) | Requests | RPS |', + '| --- | ---: | ---: | ---: | ---: |', + ...rows.map((row) => metricRow(row, side)), + ].join('\n'); +} + +function metricRow(row, side) { + const values = row[side]; + return `| ${row.label} | ${formatMs(values?.p50)} | ${formatMs(values?.p95)} | ${formatCount(values?.iterations)} | ${formatRate(values?.rps)} |`; +} + +function deltaRow(row) { + return `| ${row.label} | ${formatDelta(row.before?.p95, row.after?.p95)} |`; +} + +function topSamples(samples, metric, limit) { + const byName = samples.reduce((result, sample) => { + if (sample.metric !== metric || typeof sample.data?.value !== 'number') { + return result; + } + + const name = sample.data.tags?.name || 'unknown'; + const current = result.get(name); + if (!current || sample.data.value > current.value) { + result.set(name, { name, value: sample.data.value }); + } + + return result; + }, new Map()); + + return [...byName.values()] + .sort((left, right) => right.value - left.value) + .slice(0, limit); +} + +function topWaitRows(samples) { + if (samples.length === 0) { + return ['| n/a | n/a |']; + } + + return samples.map((sample) => { + return `| ${markdownText(sample.name).replace(/\|/g, '\\|')} | ${formatMs(sample.value)} |`; + }); +} + +function markdownText(value) { + return String(value || '').replace(/[\r\n]/g, ' ').replace(/[&<>"']/g, (char) => { + return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]; + }); +} + +function formatMs(value) { + return formatNumber(value, 2); +} + +function formatRate(value) { + return formatNumber(value, 2); +} + +function formatCount(value) { + if (value === null || value === undefined || Number.isNaN(value)) { + return 'n/a'; + } + + return `${Math.round(value)}`; +} + +function formatDelta(before, after) { + if (before === null || before === undefined || after === null || after === undefined || Number.isNaN(before) || Number.isNaN(after)) { + return 'n/a'; + } + + const difference = Number((after - before).toFixed(2)); + return `${difference > 0 ? '+' : ''}${trimNumber(difference)}`; +} + +function formatNumber(value, decimals) { + if (value === null || value === undefined || Number.isNaN(value)) { + return 'n/a'; + } + + return trimNumber(Number(value).toFixed(decimals)); +} + +function trimNumber(value) { + const text = String(value); + const trimmed = text.includes('.') ? text.replace(/\.?0+$/, '') : text; + return trimmed === '' ? '0' : trimmed; +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b02d021f1a..a056ff8510 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ concurrency: env: COMPOSE_FILE: docker-compose.yml IMAGE: appwrite-dev + K6_VERSION: '0.53.0' on: pull_request: @@ -429,6 +430,8 @@ jobs: include: - service: Databases runner: blacksmith-4vcpu-ubuntu-2404 + paratest_processes: 3 + timeout_minutes: 30 - service: Sites runner: blacksmith-4vcpu-ubuntu-2404 - service: Functions @@ -439,6 +442,10 @@ jobs: runner: blacksmith-4vcpu-ubuntu-2404 - service: TablesDB runner: blacksmith-4vcpu-ubuntu-2404 + paratest_processes: 3 + timeout_minutes: 30 + - service: Migrations + paratest_processes: 1 steps: - name: Checkout repository uses: actions/checkout@v6 @@ -499,7 +506,7 @@ jobs: with: max_attempts: 2 retry_wait_seconds: 60 - timeout_minutes: 20 + timeout_minutes: ${{ matrix.timeout_minutes || 20 }} job_id: ${{ job.check_run_id }} github_token: ${{ secrets.GITHUB_TOKEN }} test_dir: tests/e2e/Services/${{ matrix.service }} @@ -512,9 +519,14 @@ jobs: Databases|TablesDB|Functions|Realtime|GraphQL|ProjectWebhooks) FUNCTIONAL_FLAG="" ;; esac + PARATEST_PROCESSES="${{ matrix.paratest_processes }}" + if [ -z "$PARATEST_PROCESSES" ]; then + PARATEST_PROCESSES="$(nproc)" + fi + docker compose exec -T \ -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \ - appwrite vendor/bin/paratest --processes $(nproc) $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml + appwrite vendor/bin/paratest --processes "$PARATEST_PROCESSES" $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml - name: Failure Logs if: failure() @@ -536,6 +548,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v6 + with: + fetch-depth: 1 - name: Download Docker Image uses: actions/download-artifact@v7 @@ -649,13 +663,19 @@ jobs: benchmark: name: Benchmark + if: github.event_name == 'pull_request' runs-on: ubuntu-latest needs: build permissions: + actions: read + contents: read + issues: write pull-requests: write steps: - name: Checkout repository uses: actions/checkout@v6 + with: + fetch-depth: 1 - name: Download Docker Image uses: actions/download-artifact@v7 @@ -669,79 +689,145 @@ jobs: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Load and Start Appwrite + - name: Load Appwrite image run: | - sed -i 's/traefik/localhost/g' .env docker load --input /tmp/${{ env.IMAGE }}.tar - docker compose up -d - sleep 10 + docker tag ${{ env.IMAGE }} ${{ env.IMAGE }}:after - - name: Install Oha + - name: Setup k6 + uses: grafana/setup-k6-action@ffe7d7290dfa715e48c2ccc924d068444c94bde2 + with: + k6-version: ${{ env.K6_VERSION }} + + - name: Prepare benchmark before + id: benchmark_before_prepare + continue-on-error: true run: | - echo "deb [signed-by=/usr/share/keyrings/azlux-archive-keyring.gpg] http://packages.azlux.fr/debian/ stable main" | sudo tee /etc/apt/sources.list.d/azlux.list - sudo wget -O /usr/share/keyrings/azlux-archive-keyring.gpg https://azlux.fr/repo.gpg - sudo apt update - sudo apt install oha - oha --version + git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }} + git worktree add --detach /tmp/appwrite-benchmark-before ${{ github.event.pull_request.base.sha }} + docker build \ + --cache-from ${{ env.IMAGE }}:after \ + --target development \ + --build-arg DEBUG=false \ + --build-arg TESTING=true \ + --build-arg VERSION=dev \ + --tag ${{ env.IMAGE }}:before \ + /tmp/appwrite-benchmark-before - - name: Benchmark PR - run: 'oha -z 180s http://localhost/v1/health/version --output-format json > benchmark.json' - - - name: Cleaning - run: docker compose down -v - - - name: Installing latest version + - name: Start before Appwrite + id: benchmark_before_start + if: steps.benchmark_before_prepare.outcome == 'success' + continue-on-error: true + working-directory: /tmp/appwrite-benchmark-before + env: + _APP_DOMAIN: localhost + _APP_CONSOLE_DOMAIN: localhost + _APP_DOMAIN_FUNCTIONS: functions.localhost + _APP_OPTIONS_ABUSE: disabled run: | - rm docker-compose.yml - rm .env - curl https://appwrite.io/install/compose -o docker-compose.yml - curl https://appwrite.io/install/env -o .env - sed -i 's/_APP_OPTIONS_ABUSE=enabled/_APP_OPTIONS_ABUSE=disabled/g' .env - docker compose up -d - sleep 10 + docker tag ${{ env.IMAGE }}:before ${{ env.IMAGE }} + docker compose up -d --wait --no-build - - name: Benchmark Latest - run: oha -z 180s http://localhost/v1/health/version --output-format json > benchmark-latest.json + - name: Prepare benchmark files + run: rm -f benchmark-before-summary.json benchmark-after-summary.json benchmark-before-samples.json benchmark-after-samples.json - - name: Prepare comment + - name: Benchmark before + if: steps.benchmark_before_start.outcome == 'success' + continue-on-error: true + uses: grafana/run-k6-action@a15e2072ede004e8d46141e33d7f7dad8ad08d9d + env: + APPWRITE_ENDPOINT: 'http://localhost/v1' + APPWRITE_BENCHMARK_ITERATIONS: '5' + APPWRITE_BENCHMARK_VUS: '1' + APPWRITE_WORKER_TIMEOUT_MS: '120000' + APPWRITE_BENCHMARK_SUMMARY_PATH: 'benchmark-before-summary.json' + with: + path: tests/benchmarks/http.js + flags: --quiet --out json=benchmark-before-samples.json + cloud-comment-on-pr: false + debug: true + + - name: Stop before Appwrite + if: always() run: | - echo '## :sparkles: Benchmark results' > benchmark.txt - echo ' ' >> benchmark.txt - echo "- Requests per second: $(jq -r '.summary.requestsPerSec|tonumber?|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json)" >> benchmark.txt - echo "- Requests with 200 status code: $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json)" >> benchmark.txt - echo "- P99 latency: $(jq -r '.latencyPercentiles.p99' benchmark.json )" >> benchmark.txt - echo " " >> benchmark.txt - echo " " >> benchmark.txt - echo "## :zap: Benchmark Comparison" >> benchmark.txt - echo " " >> benchmark.txt - echo "| Metric | This PR | Latest version | " >> benchmark.txt - echo "| --- | --- | --- | " >> benchmark.txt - echo "| RPS | $(jq -r '.summary.requestsPerSec|tonumber?|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json) | $(jq -r '.summary.requestsPerSec|tonumber|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark-latest.json) | " >> benchmark.txt - echo "| 200 | $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json) | $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark-latest.json) | " >> benchmark.txt - echo "| P99 | $(jq -r '.latencyPercentiles.p99' benchmark.json ) | $(jq -r '.latencyPercentiles.p99' benchmark-latest.json ) | " >> benchmark.txt + if [ -d /tmp/appwrite-benchmark-before ]; then + cd /tmp/appwrite-benchmark-before + docker compose down -v || true + fi + + - name: Wait for benchmark ports + if: always() + run: | + for port in 80 443 8080 9503; do + for attempt in $(seq 1 30); do + if ! ss -ltn | awk '{print $4}' | grep -Eq "[:.]${port}$"; then + break + fi + sleep 1 + done + + if ss -ltn | awk '{print $4}' | grep -Eq "[:.]${port}$"; then + echo "Port ${port} is still in use after stopping the before stack" + ss -ltn + exit 1 + fi + done + + - name: Start after Appwrite + env: + _APP_DOMAIN: localhost + _APP_CONSOLE_DOMAIN: localhost + _APP_DOMAIN_FUNCTIONS: functions.localhost + _APP_OPTIONS_ABUSE: disabled + run: | + docker tag ${{ env.IMAGE }}:after ${{ env.IMAGE }} + docker compose up -d --wait --no-build + + - name: Benchmark after + id: benchmark_after + continue-on-error: true + uses: grafana/run-k6-action@a15e2072ede004e8d46141e33d7f7dad8ad08d9d + env: + APPWRITE_ENDPOINT: 'http://localhost/v1' + APPWRITE_BENCHMARK_ITERATIONS: '5' + APPWRITE_BENCHMARK_VUS: '1' + APPWRITE_WORKER_TIMEOUT_MS: '120000' + APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH: '../../benchmark-before-summary.json' + APPWRITE_BENCHMARK_SUMMARY_PATH: 'benchmark-after-summary.json' + with: + path: tests/benchmarks/http.js + flags: --quiet --out json=benchmark-after-samples.json + cloud-comment-on-pr: false + debug: true + + - name: Stop after Appwrite + if: always() + run: docker compose down -v || true + + - name: Comment on PR + if: always() + uses: actions/github-script@v8 + env: + BENCHMARK_BASE_REF: ${{ github.event.pull_request.base.ref }} + BENCHMARK_HEAD_REF: ${{ github.event.pull_request.head.ref }} + with: + script: | + const comment = require('./.github/workflows/benchmark-comment.js'); + await comment({ github, context, core }); - name: Save results uses: actions/upload-artifact@v7 if: ${{ !cancelled() }} with: - name: benchmark.json - path: benchmark.json + name: benchmark-results + path: | + benchmark-comment.txt + benchmark-before-summary.json + benchmark-after-summary.json + benchmark-before-samples.json + benchmark-after-samples.json retention-days: 7 - - name: Find Comment - if: github.event.pull_request.head.repo.full_name == github.repository - uses: peter-evans/find-comment@v3 - id: fc - with: - issue-number: ${{ github.event.pull_request.number }} - comment-author: 'github-actions[bot]' - body-includes: Benchmark results - - - name: Comment on PR - if: github.event.pull_request.head.repo.full_name == github.repository - uses: peter-evans/create-or-update-comment@v4 - with: - comment-id: ${{ steps.fc.outputs.comment-id }} - issue-number: ${{ github.event.pull_request.number }} - body-path: benchmark.txt - edit-mode: replace + - name: Fail benchmark + if: always() && steps.benchmark_after.outcome != 'success' + run: exit 1 diff --git a/README-CN.md b/README-CN.md index 2c7402f1ef..212b5bb08d 100644 --- a/README-CN.md +++ b/README-CN.md @@ -72,7 +72,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.0 ``` ### Windows @@ -84,7 +84,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.0 ``` #### PowerShell @@ -94,7 +94,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.0 ``` 运行后,可以在浏览器上访问 http://localhost 找到 Appwrite 控制台。在非 Linux 的本机主机上完成安装后,服务器可能需要几分钟才能启动。 diff --git a/README.md b/README.md index 31076ffa31..88d527f060 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.0 ``` ### Windows @@ -88,7 +88,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.0 ``` #### PowerShell @@ -99,7 +99,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.0 ``` Once the Docker installation is complete, go to http://localhost to access the Appwrite console from your browser. Please note that on non-Linux native hosts, the server might take a few minutes to start after completing the installation. diff --git a/app/config/console.php b/app/config/console.php index 0b0d6c5881..b7a3f2195a 100644 --- a/app/config/console.php +++ b/app/config/console.php @@ -34,6 +34,11 @@ $console = [ 'legalAddress' => '', 'legalTaxId' => '', 'auths' => [ + 'membershipsUserName' => true, + 'membershipsUserEmail' => true, + 'membershipsMfa' => true, + 'membershipsUserId' => true, + 'membershipsUserPhone' => true, 'mockNumbers' => [], 'invites' => System::getEnv('_APP_CONSOLE_INVITES', 'enabled') === 'enabled', 'limit' => (System::getEnv('_APP_CONSOLE_WHITELIST_ROOT', 'enabled') === 'enabled') ? 1 : 0, // limit signup to 1 user diff --git a/app/config/errors.php b/app/config/errors.php index 4190c6e277..07b0cd59ed 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -1408,4 +1408,19 @@ return [ 'description' => 'When using project API key, make sure to pass x-appwrite-project header with your project ID.', 'code' => 403, ], + Exception::MOCK_NUMBER_ALREADY_EXISTS => [ + 'name' => Exception::MOCK_NUMBER_ALREADY_EXISTS, + 'description' => 'Mock number with the requested number already exists. Try again with a different number. or update OTP of existing mock number.', + 'code' => 409, + ], + Exception::MOCK_NUMBER_NOT_FOUND => [ + 'name' => Exception::MOCK_NUMBER_NOT_FOUND, + 'description' => 'Mock number with the requested number could not be found.', + 'code' => 404, + ], + Exception::MOCK_NUMBER_LIMIT_EXCEEDED => [ + 'name' => Exception::MOCK_NUMBER_LIMIT_EXCEEDED, + 'description' => 'The maximum number of mock phones for this project has been reached.', + 'code' => 400, + ], ]; diff --git a/app/config/roles.php b/app/config/roles.php index 116e8ac932..33c7ffc9de 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -55,6 +55,12 @@ $admins = [ 'tables.write', 'platforms.read', 'platforms.write', + 'mocks.read', + 'mocks.write', + 'policies.read', + 'policies.write', + 'templates.read', + 'templates.write', 'projects.write', 'keys.read', 'keys.write', diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 6c7f75c08e..592e032ba1 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -204,4 +204,28 @@ return [ // List of publicly visible scopes "description" => "Access to create, update, and delete project\'s platforms", ], + "mocks.read" => [ + "description" => + "Access to read project\'s mocks", + ], + "mocks.write" => [ + "description" => + "Access to create, update, and delete project\'s mocks", + ], + "policies.read" => [ + "description" => + "Access to read project\'s policies", + ], + "policies.write" => [ + "description" => + "Access to update project\'s policies", + ], + "templates.read" => [ + "description" => + "Access to read project\'s templates", + ], + "templates.write" => [ + "description" => + "Access to create, update, and delete project\'s templates", + ], ]; diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index ffe2b54c5b..c6a5fd6f97 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -133,9 +133,6 @@ $createSession = function (string $userId, string $secret, Request $request, Res }); $provider = match ($verifiedToken->getAttribute('type')) { - TOKEN_TYPE_VERIFICATION, - TOKEN_TYPE_RECOVERY, - TOKEN_TYPE_INVITE => SESSION_PROVIDER_EMAIL, TOKEN_TYPE_MAGIC_URL => SESSION_PROVIDER_MAGIC_URL, TOKEN_TYPE_PHONE => SESSION_PROVIDER_PHONE, TOKEN_TYPE_OAUTH2 => $oauthProvider, @@ -335,15 +332,15 @@ Http::post('/v1/account') throw new Exception(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { throw new Exception(Exception::USER_EMAIL_FREE); } @@ -453,7 +450,7 @@ Http::delete('/v1/account') ->groups(['api', 'account']) ->label('scope', 'account') ->label('audits.event', 'user.delete') - ->label('audits.resource', 'user/{response.$id}') + ->label('audits.resource', 'user/{user.$id}') ->label('sdk', new Method( namespace: 'account', group: 'account', @@ -837,7 +834,7 @@ Http::patch('/v1/account/sessions/:sessionId') throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED); } - if (!empty($provider) && $className !== null && \class_exists($className)) { + if (!empty($provider) && \class_exists($className)) { $appId = $project->getAttribute('oAuthProviders', [])[$provider . 'Appid'] ?? ''; $appSecret = $project->getAttribute('oAuthProviders', [])[$provider . 'Secret'] ?? '{}'; @@ -1604,7 +1601,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') } } - if ($user === false || $user->isEmpty()) { // No user logged in or with OAuth2 provider ID, create new one or connect with account with same email + if ($user->isEmpty()) { // No user logged in or with OAuth2 provider ID, create new one or connect with account with same email if (empty($email)) { $failureRedirect(Exception::USER_UNAUTHORIZED, 'OAuth provider failed to return email.'); } @@ -1621,7 +1618,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') } // If user is not found, check if there is a user with the same email - if ($user === false || $user->isEmpty()) { + if ($user->isEmpty()) { $userWithEmail = $dbForProject->findOne('users', [ Query::equal('email', [$email]), ]); @@ -1634,7 +1631,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') } // If user is not found, check if there is an identity with the same email - if ($user === false || $user->isEmpty()) { + if ($user->isEmpty()) { $identityWithMatchingEmail = $dbForProject->findOne('identities', [ Query::equal('providerEmail', [$email]), ]); @@ -1646,7 +1643,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') } } - if ($user === false || $user->isEmpty()) { // Last option -> create the user + if ($user->isEmpty()) { // Last option -> create the user $limit = $project->getAttribute('auths', [])['limit'] ?? 0; if ($limit !== 0) { @@ -1679,15 +1676,15 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') $failureRedirect(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { $failureRedirect(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { $failureRedirect(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { $failureRedirect(Exception::USER_EMAIL_FREE); } @@ -1820,15 +1817,15 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') $failureRedirect(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { $failureRedirect(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { $failureRedirect(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { $failureRedirect(Exception::USER_EMAIL_FREE); } @@ -1954,7 +1951,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite')); } - if (isset($sessionUpgrade) && $sessionUpgrade && isset($session)) { + if (isset($sessionUpgrade) && isset($session)) { foreach ($user->getAttribute('targets', []) as $target) { if ($target->getAttribute('providerType') !== MESSAGE_TYPE_PUSH) { continue; @@ -2178,15 +2175,15 @@ Http::post('/v1/account/tokens/magic-url') throw new Exception(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { throw new Exception(Exception::USER_EMAIL_FREE); } @@ -2305,8 +2302,8 @@ Http::post('/v1/account/tokens/magic-url') $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - - $replyTo = ""; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (!empty($smtp['senderEmail'])) { @@ -2315,8 +2312,13 @@ Http::post('/v1/account/tokens/magic-url') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + if (!empty($smtpReplyToEmail)) { + $replyToEmail = $smtpReplyToEmail; + } + if (!empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -2333,8 +2335,13 @@ Http::post('/v1/account/tokens/magic-url') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? ''; + if (!empty($customReplyToEmail)) { + $replyToEmail = $customReplyToEmail; + } + if (!empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -2342,7 +2349,8 @@ Http::post('/v1/account/tokens/magic-url') } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } @@ -2488,15 +2496,15 @@ Http::post('/v1/account/tokens/email') throw new Exception(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { throw new Exception(Exception::USER_EMAIL_FREE); } @@ -2623,7 +2631,8 @@ Http::post('/v1/account/tokens/email') $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyTo = ""; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (!empty($smtp['senderEmail'])) { @@ -2632,8 +2641,13 @@ Http::post('/v1/account/tokens/email') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + if (!empty($smtpReplyToEmail)) { + $replyToEmail = $smtpReplyToEmail; + } + if (!empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -2650,8 +2664,13 @@ Http::post('/v1/account/tokens/email') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? ''; + if (!empty($customReplyToEmail)) { + $replyToEmail = $customReplyToEmail; + } + if (!empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -2659,7 +2678,8 @@ Http::post('/v1/account/tokens/email') } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } @@ -3274,7 +3294,7 @@ Http::patch('/v1/account/password') } $history[] = $newPassword; - $history = array_slice($history, (count($history) - $historyLimit), $historyLimit); + $history = array_slice($history, -$historyLimit); } if ($project->getAttribute('auths', [])['personalDataCheck'] ?? false) { @@ -3397,15 +3417,15 @@ Http::patch('/v1/account/email') throw new Exception(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { throw new Exception(Exception::USER_EMAIL_FREE); } @@ -3437,9 +3457,6 @@ Http::patch('/v1/account/email') try { $user = $dbForProject->updateDocument('users', $user->getId(), $user); - /** - * @var Document $oldTarget - */ $oldTarget = $user->find('identifier', $oldEmail, 'targets'); if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) { @@ -3526,9 +3543,6 @@ Http::patch('/v1/account/phone') try { $user = $dbForProject->updateDocument('users', $user->getId(), $user); - /** - * @var Document $oldTarget - */ $oldTarget = $user->find('identifier', $oldPhone, 'targets'); if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) { @@ -3752,7 +3766,8 @@ Http::post('/v1/account/recovery') $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyTo = ""; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (!empty($smtp['senderEmail'])) { @@ -3761,8 +3776,13 @@ Http::post('/v1/account/recovery') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + if (!empty($smtpReplyToEmail)) { + $replyToEmail = $smtpReplyToEmail; + } + if (!empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -3779,8 +3799,13 @@ Http::post('/v1/account/recovery') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? ''; + if (!empty($customReplyToEmail)) { + $replyToEmail = $customReplyToEmail; + } + if (!empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -3788,7 +3813,8 @@ Http::post('/v1/account/recovery') } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } @@ -4071,7 +4097,8 @@ Http::post('/v1/account/verifications/email') $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyTo = ""; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (!empty($smtp['senderEmail'])) { @@ -4080,8 +4107,13 @@ Http::post('/v1/account/verifications/email') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + if (!empty($smtpReplyToEmail)) { + $replyToEmail = $smtpReplyToEmail; + } + if (!empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -4098,8 +4130,13 @@ Http::post('/v1/account/verifications/email') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? ''; + if (!empty($customReplyToEmail)) { + $replyToEmail = $customReplyToEmail; + } + if (!empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -4107,7 +4144,8 @@ Http::post('/v1/account/verifications/email') } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } @@ -4618,7 +4656,7 @@ Http::delete('/v1/account/targets/:targetId/push') ->groups(['api', 'account']) ->label('scope', 'targets.write') ->label('audits.event', 'target.delete') - ->label('audits.resource', 'target/response.$id') + ->label('audits.resource', 'target/{request.targetId}') ->label('event', 'users.[userId].targets.[targetId].delete') ->label('sdk', new Method( namespace: 'account', diff --git a/app/controllers/api/messaging.php b/app/controllers/api/messaging.php index 2a0012bd30..58c6a2c29e 100644 --- a/app/controllers/api/messaging.php +++ b/app/controllers/api/messaging.php @@ -482,7 +482,6 @@ Http::post('/v1/messaging/providers/msg91') $enabled === true && \array_key_exists('senderId', $credentials) && \array_key_exists('authKey', $credentials) - && \array_key_exists('from', $options) ) { $enabled = true; } else { @@ -3207,10 +3206,6 @@ Http::post('/v1/messaging/messages/email') throw new Exception(Exception::MESSAGE_MISSING_TARGET); } - if ($status === MessageStatus::SCHEDULED && \is_null($scheduledAt)) { - throw new Exception(Exception::MESSAGE_MISSING_SCHEDULE); - } - $mergedTargets = \array_merge($targets, $cc, $bcc); if (!empty($mergedTargets)) { @@ -3386,10 +3381,6 @@ Http::post('/v1/messaging/messages/sms') throw new Exception(Exception::MESSAGE_MISSING_TARGET); } - if ($status === MessageStatus::SCHEDULED && \is_null($scheduledAt)) { - throw new Exception(Exception::MESSAGE_MISSING_SCHEDULE); - } - if (!empty($targets)) { $foundTargets = $dbForProject->find('targets', [ Query::equal('$id', $targets), @@ -3527,10 +3518,6 @@ Http::post('/v1/messaging/messages/push') throw new Exception(Exception::MESSAGE_MISSING_TARGET); } - if ($status === MessageStatus::SCHEDULED && \is_null($scheduledAt)) { - throw new Exception(Exception::MESSAGE_MISSING_SCHEDULE); - } - if (!empty($targets)) { $foundTargets = $dbForProject->find('targets', [ Query::equal('$id', $targets), @@ -4660,7 +4647,7 @@ Http::delete('/v1/messaging/messages/:messageId') if (!empty($scheduleId)) { try { $dbForPlatform->deleteDocument('schedules', $scheduleId); - } catch (Exception) { + } catch (\Throwable) { // Ignore } } diff --git a/app/controllers/api/migrations.php b/app/controllers/api/migrations.php index 4c541d2817..7338197511 100644 --- a/app/controllers/api/migrations.php +++ b/app/controllers/api/migrations.php @@ -51,7 +51,8 @@ function getDatabaseTransferResourceServices(string $databaseType) DATABASE_TYPE_LEGACY, DATABASE_TYPE_TABLESDB => Transfer::GROUP_DATABASES_TABLES_DB, DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB, - DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB + DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB, + default => throw new \LogicException('Unknown database type: ' . $databaseType), }; } diff --git a/app/controllers/api/project.php b/app/controllers/api/project.php index 054a7c8f0d..544beade77 100644 --- a/app/controllers/api/project.php +++ b/app/controllers/api/project.php @@ -113,11 +113,12 @@ Http::get('/v1/project/usage') $factor = match ($period) { '1h' => 3600, '1d' => 86400, + default => throw new \LogicException('Unsupported period: ' . $period), }; $limit = match ($period) { '1h' => (new DateTime($startDate))->diff(new DateTime($endDate))->days * 24, - '1d' => (new DateTime($startDate))->diff(new DateTime($endDate))->days + '1d' => (new DateTime($startDate))->diff(new DateTime($endDate))->days, }; $format = match ($period) { diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 439692e1dd..cf920b695f 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -1,33 +1,20 @@ dynamic($project, Response::MODEL_PROJECT); }); -Http::patch('/v1/projects/:projectId/service/all') - ->desc('Update all service status') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->action(function () { - throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED, 'Bulk API no longer exists for services. Please change status individually.'); - }); - -Http::patch('/v1/projects/:projectId/api/all') - ->desc('Update all API status') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->action(function () { - throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED, 'Bulk API no longer exists for services. Please change status individually.'); - }); - Http::patch('/v1/projects/:projectId/oauth2') ->desc('Update project OAuth2') ->groups(['api', 'projects']) @@ -138,372 +109,11 @@ Http::patch('/v1/projects/:projectId/oauth2') $response->dynamic($project, Response::MODEL_PROJECT); }); -Http::patch('/v1/projects/:projectId/auth/session-alerts') - ->desc('Update project sessions emails') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateSessionAlerts', - description: '/docs/references/projects/update-session-alerts.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('alerts', false, new Boolean(true), 'Set to true to enable session emails.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $alerts, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['sessionAlerts'] = $alerts; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/memberships-privacy') - ->desc('Update project memberships privacy attributes') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateMembershipsPrivacy', - description: '/docs/references/projects/update-memberships-privacy.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('userName', true, new Boolean(true), 'Set to true to show userName to members of a team.') - ->param('userEmail', true, new Boolean(true), 'Set to true to show email to members of a team.') - ->param('mfa', true, new Boolean(true), 'Set to true to show mfa to members of a team.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $userName, bool $userEmail, bool $mfa, Response $response, Database $dbForPlatform) { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - - $auths['membershipsUserName'] = $userName; - $auths['membershipsUserEmail'] = $userEmail; - $auths['membershipsMfa'] = $mfa; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/limit') - ->desc('Update project users limit') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateAuthLimit', - description: '/docs/references/projects/update-auth-limit.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('limit', false, new Range(0, APP_LIMIT_USERS), 'Set the max number of users allowed in this project. Use 0 for unlimited.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, int $limit, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['limit'] = $limit; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/duration') - ->desc('Update project authentication duration') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateAuthDuration', - description: '/docs/references/projects/update-auth-duration.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('duration', 31536000, new Range(0, 31536000), 'Project session length in seconds. Max length: 31536000 seconds.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, int $duration, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['duration'] = $duration; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/:method') - ->desc('Update project auth method status. Use this endpoint to enable or disable a given auth method for this project.') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateAuthStatus', - description: '/docs/references/projects/update-auth-status.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('method', '', new WhiteList(\array_keys(Config::getParam('auth')), true), 'Auth Method. Possible values: ' . implode(',', \array_keys(Config::getParam('auth'))), false) - ->param('status', false, new Boolean(true), 'Set the status of this auth method.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $method, bool $status, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - $auth = Config::getParam('auth')[$method] ?? []; - $authKey = $auth['key'] ?? ''; - $status = ($status === '1' || $status === 'true' || $status === 1 || $status === true); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths[$authKey] = $status; - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/password-history') - ->desc('Update authentication password history. Use this endpoint to set the number of password history to save and 0 to disable password history.') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateAuthPasswordHistory', - description: '/docs/references/projects/update-auth-password-history.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('limit', 0, new Range(0, APP_LIMIT_USER_PASSWORD_HISTORY), 'Set the max number of passwords to store in user history. User can\'t choose a new password that is already stored in the password history list. Max number of passwords allowed in history is' . APP_LIMIT_USER_PASSWORD_HISTORY . '. Default value is 0') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, int $limit, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['passwordHistory'] = $limit; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/password-dictionary') - ->desc('Update authentication password dictionary status. Use this endpoint to enable or disable the dicitonary check for user password') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateAuthPasswordDictionary', - description: '/docs/references/projects/update-auth-password-dictionary.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('enabled', false, new Boolean(false), 'Set whether or not to enable checking user\'s password against most commonly used passwords. Default is false.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $enabled, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['passwordDictionary'] = $enabled; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/personal-data') - ->desc('Update personal data check') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updatePersonalDataCheck', - description: '/docs/references/projects/update-personal-data-check.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('enabled', false, new Boolean(false), 'Set whether or not to check a password for similarity with personal data. Default is false.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $enabled, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['personalDataCheck'] = $enabled; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/max-sessions') - ->desc('Update project user sessions limit') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateAuthSessionsLimit', - description: '/docs/references/projects/update-auth-sessions-limit.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('limit', false, new Range(1, APP_LIMIT_USER_SESSIONS_MAX), 'Set the max number of users allowed in this project. Value allowed is between 1-' . APP_LIMIT_USER_SESSIONS_MAX . '. Default is ' . APP_LIMIT_USER_SESSIONS_DEFAULT) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, int $limit, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['maxSessions'] = $limit; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - +// Backwards compatibility Http::patch('/v1/projects/:projectId/auth/mock-numbers') ->desc('Update the mock numbers for the project') ->groups(['api', 'projects']) ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateMockNumbers', - description: '/docs/references/projects/update-mock-numbers.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('numbers', '', new ArrayList(new MockNumber(), 10), 'An array of mock numbers and their corresponding verification codes (OTPs). Each number should be a valid E.164 formatted phone number. Maximum of 10 numbers are allowed.') ->inject('response') @@ -533,557 +143,29 @@ Http::patch('/v1/projects/:projectId/auth/mock-numbers') $response->dynamic($project, Response::MODEL_PROJECT); }); -Http::delete('/v1/projects/:projectId') - ->desc('Delete project') - ->groups(['api', 'projects']) - ->label('audits.event', 'projects.delete') - ->label('audits.resource', 'project/{request.projectId}') - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'projects', - name: 'delete', - description: '/docs/references/projects/delete.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ], - contentType: ContentType::NONE - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->inject('response') - ->inject('user') - ->inject('dbForPlatform') - ->inject('queueForDeletes') - ->action(function (string $projectId, Response $response, Document $user, Database $dbForPlatform, Delete $queueForDeletes) { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $queueForDeletes - ->setProject($project) - ->setType(DELETE_TYPE_DOCUMENT) - ->setDocument($project); - - if (!$dbForPlatform->deleteDocument('projects', $projectId)) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove project from DB'); - } - - $response->noContent(); - }); - -// JWT Keys - -Http::post('/v1/projects/:projectId/jwts') - ->groups(['api', 'projects']) - ->desc('Create JWT') - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'createJWT', - description: '/docs/references/projects/create-jwt.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_JWT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for JWT key. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') - ->param('duration', 900, new Range(0, 3600), 'Time in seconds before JWT expires. Default duration is 900 seconds, and maximum is 3600 seconds.', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, array $scopes, int $duration, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $duration, 0); - - $response - ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic(new Document(['jwt' => API_KEY_DYNAMIC . '_' . $jwt->encode([ - 'projectId' => $project->getId(), - 'scopes' => $scopes - ])]), Response::MODEL_JWT); - }); - -// CUSTOM SMTP and Templates -Http::patch('/v1/projects/:projectId/smtp') - ->desc('Update SMTP') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'templates', - name: 'updateSmtp', - description: '/docs/references/projects/update-smtp.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.updateSMTP', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'templates', - name: 'updateSMTP', - description: '/docs/references/projects/update-smtp.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('enabled', false, new Boolean(), 'Enable custom SMTP service') - ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true) - ->param('senderEmail', '', new Email(), 'Email of the sender', true) - ->param('replyTo', '', new Email(), 'Reply to email', true) - ->param('host', '', new HostName(), 'SMTP server host name', true) - ->param('port', 587, new Integer(), 'SMTP server port', true) - ->param('username', '', new Text(0, 0), 'SMTP server username', true) - ->param('password', '', new Text(0, 0), 'SMTP server password', true) - ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $enabled, string $senderName, string $senderEmail, string $replyTo, string $host, int $port, string $username, string $password, string $secure, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - // Ensure required params for when enabling SMTP - if ($enabled) { - if (empty($senderName)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Sender name is required when enabling SMTP.'); - } elseif (empty($senderEmail)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Sender email is required when enabling SMTP.'); - } elseif (empty($host)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Host is required when enabling SMTP.'); - } elseif (empty($port)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Port is required when enabling SMTP.'); - } - } - - // validate SMTP settings - if ($enabled) { - $mail = new PHPMailer(true); - $mail->isSMTP(); - $mail->SMTPAuth = (!empty($username) && !empty($password)); - $mail->Username = $username; - $mail->Password = $password; - $mail->Host = $host; - $mail->Port = $port; - $mail->SMTPSecure = $secure; - $mail->SMTPAutoTLS = false; - $mail->Timeout = 5; - - try { - $valid = $mail->SmtpConnect(); - - if (!$valid) { - throw new Exception('Connection is not valid.'); - } - } catch (Throwable $error) { - throw new Exception(Exception::PROJECT_SMTP_CONFIG_INVALID, $error->getMessage()); - } - } - - // Save SMTP settings - if ($enabled) { - $smtp = [ - 'enabled' => $enabled, - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'replyTo' => $replyTo, - 'host' => $host, - 'port' => $port, - 'username' => $username, - 'password' => $password, - 'secure' => $secure, - ]; - } else { - $smtp = [ - 'enabled' => false - ]; - } - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::post('/v1/projects/:projectId/smtp/tests') - ->desc('Create SMTP test') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'templates', - name: 'createSmtpTest', - description: '/docs/references/projects/create-smtp-test.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.createSMTPTest', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'templates', - name: 'createSMTPTest', - description: '/docs/references/projects/create-smtp-test.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('emails', [], new ArrayList(new Email(), 10), 'Array of emails to send test email to. Maximum of 10 emails are allowed.') - ->param('senderName', System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'), new Text(255, 0), 'Name of the email sender') - ->param('senderEmail', System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM), new Email(), 'Email of the sender') - ->param('replyTo', '', new Email(), 'Reply to email', true) - ->param('host', '', new HostName(), 'SMTP server host name') - ->param('port', 587, new Integer(), 'SMTP server port', true) - ->param('username', '', new Text(0, 0), 'SMTP server username', true) - ->param('password', '', new Text(0, 0), 'SMTP server password', true) - ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', true) - ->inject('response') - ->inject('dbForPlatform') - ->inject('queueForMails') - ->inject('plan') - ->action(function (string $projectId, array $emails, string $senderName, string $senderEmail, string $replyTo, string $host, int $port, string $username, string $password, string $secure, Response $response, Database $dbForPlatform, Mail $queueForMails, array $plan) { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $replyToEmail = !empty($replyTo) ? $replyTo : $senderEmail; - - $subject = 'Custom SMTP email sample'; - $template = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-smtp-test.tpl'); - $template - ->setParam('{{from}}', "{$senderName} ({$senderEmail})") - ->setParam('{{replyTo}}', "{$senderName} ({$replyToEmail})") - ->setParam('{{logoUrl}}', $plan['logoUrl'] ?? APP_EMAIL_LOGO_URL) - ->setParam('{{accentColor}}', $plan['accentColor'] ?? APP_EMAIL_ACCENT_COLOR) - ->setParam('{{twitterUrl}}', $plan['twitterUrl'] ?? APP_SOCIAL_TWITTER) - ->setParam('{{discordUrl}}', $plan['discordUrl'] ?? APP_SOCIAL_DISCORD) - ->setParam('{{githubUrl}}', $plan['githubUrl'] ?? APP_SOCIAL_GITHUB_APPWRITE) - ->setParam('{{termsUrl}}', $plan['termsUrl'] ?? APP_EMAIL_TERMS_URL) - ->setParam('{{privacyUrl}}', $plan['privacyUrl'] ?? APP_EMAIL_PRIVACY_URL); - - foreach ($emails as $email) { - $queueForMails - ->setSmtpHost($host) - ->setSmtpPort($port) - ->setSmtpUsername($username) - ->setSmtpPassword($password) - ->setSmtpSecure($secure) - ->setSmtpReplyTo($replyTo) - ->setSmtpSenderEmail($senderEmail) - ->setSmtpSenderName($senderName) - ->setRecipient($email) - ->setName('') - ->setBodyTemplate(__DIR__ . '/../../config/locale/templates/email-base-styled.tpl') - ->setBody($template->render()) - ->setVariables([]) - ->setSubject($subject) - ->trigger(); - } - - $response->noContent(); - }); - -Http::get('/v1/projects/:projectId/templates/email') - ->alias('/v1/projects/:projectId/templates/email/:type/:locale') - ->desc('Get custom email template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'getEmailTemplate', - description: '/docs/references/projects/get-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) - ->inject('response') - ->inject('dbForPlatform') - ->inject('locale') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform, Locale $localeObject) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; - - $localeObj = new Locale($locale); - $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); - - if (is_null($template)) { - /** - * different templates, different placeholders. - */ - $templateConfigs = [ - 'magicSession' => [ - 'file' => 'email-magic-url.tpl', - 'placeholders' => ['optionButton', 'buttonText', 'optionUrl', 'clientInfo', 'securityPhrase'] - ], - 'mfaChallenge' => [ - 'file' => 'email-mfa-challenge.tpl', - 'placeholders' => ['description', 'clientInfo'] - ], - 'otpSession' => [ - 'file' => 'email-otp.tpl', - 'placeholders' => ['description', 'clientInfo', 'securityPhrase'] - ], - 'sessionAlert' => [ - 'file' => 'email-session-alert.tpl', - 'placeholders' => ['body', 'listDevice', 'listIpAddress', 'listCountry', 'footer'] - ], - ]; - - // fallback to the base template. - $config = $templateConfigs[$type] ?? [ - 'file' => 'email-inner-base.tpl', - 'placeholders' => ['buttonText', 'body', 'footer'] - ]; - - $templateString = file_get_contents(__DIR__ . '/../../config/locale/templates/' . $config['file']); - - // We use `fromString` due to the replace above - $message = Template::fromString($templateString); - - // Set type-specific parameters - foreach ($config['placeholders'] as $param) { - $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']); - $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$type}.{$param}"), escapeHtml: $escapeHtml); - } - - $message - // common placeholders on all the templates - ->setParam('{{hello}}', $localeObj->getText("emails.{$type}.hello")) - ->setParam('{{thanks}}', $localeObj->getText("emails.{$type}.thanks")) - ->setParam('{{signature}}', $localeObj->getText("emails.{$type}.signature")); - - // `useContent: false` will strip new lines! - $message = $message->render(useContent: true); - - $template = [ - 'message' => $message, - 'subject' => $localeObj->getText('emails.' . $type . '.subject'), - 'senderEmail' => '', - 'senderName' => '' - ]; - } - - $template['type'] = $type; - $template['locale'] = $locale; - - $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); - }); - -Http::patch('/v1/projects/:projectId/templates/email') - ->alias('/v1/projects/:projectId/templates/email/:type/:locale') - ->desc('Update custom email templates') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'updateEmailTemplate', - description: '/docs/references/projects/update-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) - ->param('subject', '', new Text(255), 'Email Subject') - ->param('message', '', new Text(0), 'Template message') - ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true) - ->param('senderEmail', '', new Email(), 'Email of the sender', true) - ->param('replyTo', '', new Email(), 'Reply to email', true) - ->inject('response') - ->inject('dbForPlatform') - ->inject('locale') - ->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform, Locale $localeObject) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $templates['email.' . $type . '-' . $locale] = [ - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'subject' => $subject, - 'replyTo' => $replyTo, - 'message' => $message - ]; - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); - - $response->dynamic(new Document([ - 'type' => $type, - 'locale' => $locale, - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'subject' => $subject, - 'replyTo' => $replyTo, - 'message' => $message - ]), Response::MODEL_EMAIL_TEMPLATE); - }); - +// Backwards compatibility Http::delete('/v1/projects/:projectId/templates/email') ->alias('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Delete custom email template') ->groups(['api', 'projects']) ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'deleteEmailTemplate', - description: '/docs/references/projects/delete-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ], - contentType: ContentType::JSON - )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) ->inject('response') ->inject('dbForPlatform') - ->inject('locale') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform, Locale $localeObject) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { + $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); $project = $dbForPlatform->getDocument('projects', $projectId); - if ($project->isEmpty()) { throw new Exception(Exception::PROJECT_NOT_FOUND); } $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; - - if (is_null($template)) { - throw new Exception(Exception::PROJECT_TEMPLATE_DEFAULT_DELETION); - } - unset($templates['email.' . $type . '-' . $locale]); $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); - $response->dynamic(new Document([ - 'type' => $type, - 'locale' => $locale, - 'senderName' => $template['senderName'], - 'senderEmail' => $template['senderEmail'], - 'subject' => $template['subject'], - 'replyTo' => $template['replyTo'], - 'message' => $template['message'] - ]), Response::MODEL_EMAIL_TEMPLATE); - }); - -Http::patch('/v1/projects/:projectId/auth/session-invalidation') - ->desc('Update invalidate session option of the project') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateSessionInvalidation', - description: '/docs/references/projects/update-session-invalidation.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('enabled', false, new Boolean(), 'Update authentication session invalidation status. Use this endpoint to enable or disable session invalidation on password change') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $enabled, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['invalidateSessions'] = $enabled; - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); + $response->noContent(); }); diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index a8875fc442..1346812668 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -1535,7 +1535,7 @@ Http::patch('/v1/users/:userId/email') Query::equal('identifier', [$email]), ]); - if ($target instanceof Document && !$target->isEmpty()) { + if (!$target->isEmpty()) { throw new Exception(Exception::USER_TARGET_ALREADY_EXISTS); } } @@ -1595,9 +1595,6 @@ Http::patch('/v1/users/:userId/email') 'emailIsDisposable' => $user->getAttribute('emailIsDisposable'), 'emailIsFree' => $user->getAttribute('emailIsFree'), ])); - /** - * @var Document $oldTarget - */ $oldTarget = $user->find('identifier', $oldEmail, 'targets'); if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) { @@ -1681,7 +1678,7 @@ Http::patch('/v1/users/:userId/phone') Query::equal('identifier', [$number]), ]); - if ($target instanceof Document && !$target->isEmpty()) { + if (!$target->isEmpty()) { throw new Exception(Exception::USER_TARGET_ALREADY_EXISTS); } } @@ -1691,9 +1688,6 @@ Http::patch('/v1/users/:userId/phone') 'phone' => $phoneValue, 'phoneVerification' => $user->getAttribute('phoneVerification'), ])); - /** - * @var Document $oldTarget - */ $oldTarget = $user->find('identifier', $oldPhone, 'targets'); if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) { @@ -2252,8 +2246,8 @@ Http::delete('/v1/users/:userId/mfa/authenticators/:type') ->label('event', 'users.[userId].delete.mfa') ->label('scope', 'users.write') ->label('audits.event', 'user.update') - ->label('audits.resource', 'user/{response.$id}') - ->label('audits.userId', '{response.$id}') + ->label('audits.resource', 'user/{request.userId}') + ->label('audits.userId', '{request.userId}') ->label('usage.metric', 'users.{scope}.requests.update') ->label('sdk', [ new Method( @@ -2842,6 +2836,7 @@ Http::get('/v1/users/usage') $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new \LogicException('Unsupported period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/app/controllers/general.php b/app/controllers/general.php index b4f4a5c1d1..2cec14cc1d 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -26,6 +26,7 @@ use Appwrite\Utopia\Request\Filters\V19 as RequestV19; use Appwrite\Utopia\Request\Filters\V20 as RequestV20; use Appwrite\Utopia\Request\Filters\V21 as RequestV21; use Appwrite\Utopia\Request\Filters\V22 as RequestV22; +use Appwrite\Utopia\Request\Filters\V23 as RequestV23; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Filters\V16 as ResponseV16; use Appwrite\Utopia\Response\Filters\V17 as ResponseV17; @@ -34,6 +35,7 @@ use Appwrite\Utopia\Response\Filters\V19 as ResponseV19; use Appwrite\Utopia\Response\Filters\V20 as ResponseV20; use Appwrite\Utopia\Response\Filters\V21 as ResponseV21; use Appwrite\Utopia\Response\Filters\V22 as ResponseV22; +use Appwrite\Utopia\Response\Filters\V23 as ResponseV23; use Appwrite\Utopia\View; use Executor\Executor; use MaxMind\Db\Reader; @@ -67,7 +69,7 @@ Config::setParam('cookieSamesite', Response::COOKIE_SAMESITE_NONE); function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Authorization $authorization, ?Key $apiKey, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { - $host = $request->getHostname() ?? ''; + $host = $request->getHostname(); if (!empty($previewHostname)) { $host = $previewHostname; } @@ -200,12 +202,6 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S $deployment = $authorization->skip(fn () => $dbForProject->getDocument('deployments', $activeDeploymentId)); } - if ($deployment->getAttribute('resourceType', '') === 'functions') { - $type = 'function'; - } elseif ($deployment->getAttribute('resourceType', '') === 'sites') { - $type = 'site'; - } - if ($deployment->isEmpty()) { $resourceType = $rule->getAttribute('deploymentResourceType', ''); $resourceId = $rule->getAttribute('deploymentResourceId', ''); @@ -215,6 +211,14 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S throw $exception; } + if ($deployment->getAttribute('resourceType', '') === 'functions') { + $type = 'function'; + } elseif ($deployment->getAttribute('resourceType', '') === 'sites') { + $type = 'site'; + } else { + throw new AppwriteException(AppwriteException::GENERAL_SERVER_ERROR, 'Unknown deployment resource type', view: $errorView); + } + $resource = $type === 'function' ? $authorization->skip(fn () => $dbForProject->getDocument('functions', $deployment->getAttribute('resourceId', ''))) : $authorization->skip(fn () => $dbForProject->getDocument('sites', $deployment->getAttribute('resourceId', ''))); @@ -302,13 +306,13 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S } } - $body = $swooleRequest->getContent() ?? ''; + $body = $swooleRequest->getContent() ?: ''; $method = $swooleRequest->server['request_method']; $requestHeaders = $request->getHeaders(); if ($resource->isEmpty() || !$resource->getAttribute('enabled')) { - if ($type === 'functions') { + if ($type === 'function') { throw new AppwriteException(AppwriteException::FUNCTION_NOT_FOUND, view: $errorView); } else { throw new AppwriteException(AppwriteException::SITE_NOT_FOUND, view: $errorView); @@ -330,7 +334,6 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S $runtime = match ($type) { 'function' => $runtimes[$resource->getAttribute('runtime')] ?? null, 'site' => $runtimes[$resource->getAttribute('buildRuntime')] ?? null, - default => null }; // Static site enforced runtime @@ -459,10 +462,10 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S // V2 vars if ($version === 'v2') { $vars = \array_merge($vars, [ - 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'] ?? '', + 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'], 'APPWRITE_FUNCTION_DATA' => $body, - 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'] ?? '', - 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ?? '' + 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'], + 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ]); } @@ -678,9 +681,8 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S if (\is_string($logs) && \strlen($logs) > $maxLogLength) { $warningMessage = "[WARNING] Logs truncated. The output exceeded {$maxLogLength} characters.\n"; - $warningLength = \strlen($warningMessage); - $maxContentLength = max(0, $maxLogLength - $warningLength); - $logs = $warningMessage . ($maxContentLength > 0 ? \substr($logs, -$maxContentLength) : ''); + $maxContentLength = $maxLogLength - \strlen($warningMessage); + $logs = $warningMessage . \substr($logs, -$maxContentLength); } // Truncate errors if they exceed the limit @@ -689,9 +691,8 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S if (\is_string($errors) && \strlen($errors) > $maxErrorLength) { $warningMessage = "[WARNING] Errors truncated. The output exceeded {$maxErrorLength} characters.\n"; - $warningLength = \strlen($warningMessage); - $maxContentLength = max(0, $maxErrorLength - $warningLength); - $errors = $warningMessage . ($maxContentLength > 0 ? \substr($errors, -$maxContentLength) : ''); + $maxContentLength = $maxErrorLength - \strlen($warningMessage); + $errors = $warningMessage . \substr($errors, -$maxContentLength); } /** Update execution status */ $status = $executionResponse['statusCode'] >= 500 ? 'failed' : 'completed'; @@ -719,14 +720,12 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S throw $th; } } finally { - if ($type === 'function' || $type === 'site') { - $bus->dispatch(new ExecutionCompleted( - execution: $execution->getArrayCopy(), - project: $project->getArrayCopy(), - spec: $spec, - resource: $resource->getArrayCopy(), - )); - } + $bus->dispatch(new ExecutionCompleted( + execution: $execution->getArrayCopy(), + project: $project->getArrayCopy(), + spec: $spec, + resource: $resource->getArrayCopy(), + )); } $execution->setAttribute('logs', ''); @@ -852,7 +851,7 @@ Http::init() /* * Appwrite Router */ - $hostname = $request->getHostname() ?? ''; + $hostname = $request->getHostname(); $platformHostnames = $platform['hostnames'] ?? []; // Only run Router when external domain if (!\in_array($hostname, $platformHostnames) || !empty($previewHostname)) { @@ -897,6 +896,9 @@ Http::init() if (version_compare($requestFormat, '1.9.1', '<')) { $request->addFilter(new RequestV22()); } + if (version_compare($requestFormat, '1.9.2', '<')) { + $request->addFilter(new RequestV23()); + } } $localeParam = (string) $request->getParam('locale', $request->getHeader('x-appwrite-locale', '')); @@ -921,6 +923,9 @@ Http::init() */ $responseFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', '')); if ($responseFormat) { + if (version_compare($responseFormat, '1.9.2', '<')) { + $response->addFilter(new ResponseV23()); + } if (version_compare($responseFormat, '1.9.1', '<')) { $response->addFilter(new ResponseV22()); } @@ -1499,9 +1504,9 @@ Http::error() ->setParam('development', Http::isDevelopment()) ->setParam('projectName', $project->getAttribute('name')) ->setParam('projectURL', $project->getAttribute('url')) - ->setParam('message', $output['message'] ?? '') - ->setParam('type', $output['type'] ?? '') - ->setParam('code', $output['code'] ?? '') + ->setParam('message', $output['message']) + ->setParam('type', $output['type']) + ->setParam('code', $output['code']) ->setParam('trace', $output['trace'] ?? []) ->setParam('exception', $error); @@ -1616,7 +1621,7 @@ Http::get('/.well-known/acme-challenge/*') throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND, 'Unknown path'); } - if (!\substr($absolute, 0, \strlen($base)) === $base) { + if (\substr($absolute, 0, \strlen($base)) !== $base) { throw new AppwriteException(AppwriteException::GENERAL_UNAUTHORIZED_SCOPE, 'Invalid path'); } @@ -1695,7 +1700,7 @@ Http::get('/_appwrite/authorize') ->inject('previewHostname') ->action(function (Request $request, Response $response, string $previewHostname) { - $host = $request->getHostname() ?? ''; + $host = $request->getHostname(); if (!empty($previewHostname)) { $host = $previewHostname; } diff --git a/app/controllers/mock.php b/app/controllers/mock.php index 99713af430..4e92b3482d 100644 --- a/app/controllers/mock.php +++ b/app/controllers/mock.php @@ -251,7 +251,7 @@ Http::get('/v1/mock/github/callback') $privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY'); $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID'); $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId); - $owner = $github->getOwnerName($providerInstallationId) ?? ''; + $owner = $github->getOwnerName($providerInstallationId); $projectInternalId = $project->getSequence(); diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index bba00bede1..7c2f527ccf 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -44,9 +44,9 @@ use Utopia\System\System; use Utopia\Telemetry\Adapter as Telemetry; use Utopia\Validator\WhiteList; -$parseLabel = function (string $label, array $responsePayload, array $requestParams, User $user) { +$parseLabel = function (string $label, array $responsePayload, array $requestParams, User $user, Document $project) { preg_match_all('/{(.*?)}/', $label, $matches); - foreach ($matches[1] ?? [] as $pos => $match) { + foreach ($matches[1] as $pos => $match) { $find = $matches[0][$pos]; $parts = explode('.', $match); @@ -54,11 +54,12 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar throw new Exception(Exception::GENERAL_SERVER_ERROR, "The server encountered an error while parsing the label: $label. Please create an issue on GitHub to allow us to investigate further https://github.com/appwrite/appwrite/issues/new/choose"); } - $namespace = $parts[0] ?? ''; - $replace = $parts[1] ?? ''; + $namespace = $parts[0]; + $replace = $parts[1]; $params = match ($namespace) { 'user' => (array) $user, + 'project' => $project->getArrayCopy(), 'request' => $requestParams, default => $responsePayload, }; @@ -263,8 +264,7 @@ Http::init() $userClone->setAttribute('type', match ($apiKey->getType()) { API_KEY_STANDARD => ACTIVITY_TYPE_KEY_PROJECT, API_KEY_ACCOUNT => ACTIVITY_TYPE_KEY_ACCOUNT, - API_KEY_ORGANIZATION => ACTIVITY_TYPE_KEY_ORGANIZATION, - default => ACTIVITY_TYPE_KEY_PROJECT, + default => ACTIVITY_TYPE_KEY_ORGANIZATION, }); $auditContext->user = $userClone; } @@ -385,7 +385,7 @@ Http::init() } // Step 6: Update project and user last activity - if (! $project->isEmpty() && $project->getId() !== 'console') { + if ($project->getId() !== 'console') { $accessedAt = $project->getAttribute('accessedAt', 0); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $accessedAt) { $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ @@ -415,9 +415,6 @@ Http::init() } // Steps 7-9: Access Control - Method, Namespace and Scope Validation - /** - * @var ?Method $method - */ $method = $route->getLabel('sdk', false); // Take the first method if there's more than one, @@ -646,7 +643,7 @@ Http::init() if (! empty($data) && ! $cacheLog->isEmpty()) { $parts = explode('/', $cacheLog->getAttribute('resourceType', '')); - $type = $parts[0] ?? null; + $type = $parts[0]; if ($type === 'bucket' && (! $isImageTransformation || ! $isDisabled)) { $bucketId = $parts[1] ?? null; @@ -757,7 +754,12 @@ Http::shutdown() ->inject('project') ->inject('dbForProject') ->action(function (Http $utopia, Request $request, Response $response, Document $project, Database $dbForProject) { - $sessionLimit = $project->getAttribute('auths', [])['maxSessions'] ?? APP_LIMIT_USER_SESSIONS_DEFAULT; + $sessionLimit = $project->getAttribute('auths', [])['maxSessions'] ?? 0; + + if ($sessionLimit === 0) { + return; + } + $session = $response->getPayload(); $userId = $session['userId'] ?? ''; if (empty($userId)) { @@ -902,7 +904,7 @@ Http::shutdown() */ $pattern = $route->getLabel('audits.resource', null); if (! empty($pattern)) { - $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user); + $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user, $project); if (! empty($resource) && $resource !== $pattern) { $auditContext->resource = $resource; } @@ -937,7 +939,7 @@ Http::shutdown() } $auditUser = $auditContext->user; - if (! empty($auditContext->resource) && ! \is_null($auditUser) && ! $auditUser->isEmpty()) { + if (! empty($auditContext->resource) && ! $auditUser->isEmpty()) { /** * audits.payload is switched to default true * in order to auto audit payload for all endpoints @@ -975,12 +977,12 @@ Http::shutdown() if (! empty($data['payload']) && $statusCode >= 200 && $statusCode < 300) { $pattern = $route->getLabel('cache.resource', null); if (! empty($pattern)) { - $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user); + $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user, $project); } $pattern = $route->getLabel('cache.resourceType', null); if (! empty($pattern)) { - $resourceType = $parseLabel($pattern, $responsePayload, $requestParams, $user); + $resourceType = $parseLabel($pattern, $responsePayload, $requestParams, $user, $project); } $cache = new Cache( diff --git a/app/init/constants.php b/app/init/constants.php index f2127cd666..8eacf2fe12 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -1,6 +1,7 @@ findOne('rules', [ Query::equal('domain', [$domain]), - ]) ?? new Document(); + ]); }); $permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence(); @@ -139,7 +139,7 @@ return function (Container $container): void { $sdkValidator = new WhiteList($servers, true); $sdk = \strtolower($request->getHeader('x-sdk-name', 'UNKNOWN')); - if ($sdk !== 'UNKNOWN' && $sdkValidator->isValid($sdk)) { + if ($sdk !== 'unknown' && $sdkValidator->isValid($sdk)) { $sdks = $key->getAttribute('sdks', []); if (!\in_array($sdk, $sdks, true)) { diff --git a/app/init/registers.php b/app/init/registers.php index c07bc9da8b..54c0053a33 100644 --- a/app/init/registers.php +++ b/app/init/registers.php @@ -71,7 +71,7 @@ $register->set('logger', function () { $providerConfig = match ($providerName) { 'sentry' => [ 'key' => $configChunks[0], 'projectId' => $configChunks[1] ?? '', 'host' => '',], - 'logowl' => ['ticket' => $configChunks[0] ?? '', 'host' => ''], + 'logowl' => ['ticket' => $configChunks[0], 'host' => ''], default => ['key' => $providerConfig], }; } @@ -249,11 +249,11 @@ $register->set('pools', function () { $poolSize = max(1, (int)($instanceConnections / $workerCount)); foreach ($connections as $key => $connection) { - $type = $connection['type'] ?? ''; - $multiple = $connection['multiple'] ?? false; - $schemes = $connection['schemes'] ?? []; + $type = $connection['type']; + $multiple = $connection['multiple']; + $schemes = $connection['schemes']; $config = []; - $dsns = explode(',', $connection['dsns'] ?? ''); + $dsns = explode(',', $connection['dsns']); foreach ($dsns as &$dsn) { $dsn = explode('=', $dsn); $name = ($multiple) ? $key . '_' . $dsn[0] : $key; @@ -318,7 +318,7 @@ $register->set('pools', function () { )); }); }, - 'redis' => function () use ($dsnHost, $dsnPort, $dsnPass) { + default => function () use ($dsnHost, $dsnPort, $dsnPass) { $redis = new \Redis(); @$redis->pconnect($dsnHost, (int)$dsnPort); if ($dsnPass) { @@ -328,7 +328,6 @@ $register->set('pools', function () { return $redis; }, - default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Invalid scheme'), }; $poolAdapter = System::getEnv('_APP_POOL_ADAPTER', default: 'stack') === 'swoole' ? new SwoolePool() : new StackPool(); diff --git a/app/init/resources.php b/app/init/resources.php index d1bb7584bf..29506bfc9c 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -266,7 +266,7 @@ function getDevice(string $root, string $connection = ''): Device return new Local($root); } } else { - switch (strtolower(System::getEnv('_APP_STORAGE_DEVICE', Storage::DEVICE_LOCAL) ?? '')) { + switch (strtolower(System::getEnv('_APP_STORAGE_DEVICE', Storage::DEVICE_LOCAL))) { case Storage::DEVICE_LOCAL: default: return new Local($root); diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 3f6196c460..7d1731b80d 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -375,7 +375,7 @@ return function (Container $container): void { return $dbForPlatform->findOne('rules', [ Query::equal('domain', [$domain]), - ]) ?? new Document(); + ]); }); $permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence(); @@ -478,14 +478,10 @@ return function (Container $container): void { } // Get fallback session from old clients (no SameSite support) or clients who block 3rd-party cookies - if ($response) { // if in http context - add debug header - $response->addHeader('X-Debug-Fallback', 'false'); - } + $response->addHeader('X-Debug-Fallback', 'false'); if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) { - if ($response) { - $response->addHeader('X-Debug-Fallback', 'true'); - } + $response->addHeader('X-Debug-Fallback', 'true'); $fallback = $request->getHeader('x-fallback-cookies', ''); $fallback = \json_decode($fallback, true); $store->decode(((is_array($fallback) && isset($fallback[$store->getKey()])) ? $fallback[$store->getKey()] : '')); @@ -1084,7 +1080,7 @@ return function (Container $container): void { $sdkValidator = new WhiteList($servers, true); $sdk = \strtolower($request->getHeader('x-sdk-name', 'UNKNOWN')); - if ($sdk !== 'UNKNOWN' && $sdkValidator->isValid($sdk)) { + if ($sdk !== 'unknown' && $sdkValidator->isValid($sdk)) { $sdks = $key->getAttribute('sdks', []); if (! in_array($sdk, $sdks)) { diff --git a/app/init/worker/message.php b/app/init/worker/message.php index c505d4cb3a..dfe6af9bd9 100644 --- a/app/init/worker/message.php +++ b/app/init/worker/message.php @@ -368,7 +368,7 @@ return function (Container $container): void { $log->addTag('code', $error->getCode()); $log->addTag('verboseType', \get_class($error)); - $log->addTag('projectId', $project->getId() ?? ''); + $log->addTag('projectId', $project->getId()); $log->addExtra('file', $error->getFile()); $log->addExtra('line', $error->getLine()); diff --git a/app/realtime.php b/app/realtime.php index 5631a7f860..0e7388b83f 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -38,12 +38,14 @@ use Utopia\DSN\DSN; use Utopia\Logger\Log; use Utopia\Pools\Group; use Utopia\Registry\Registry; +use Utopia\Span\Span; use Utopia\System\System; use Utopia\Telemetry\Adapter\None as NoTelemetry; use Utopia\WebSocket\Adapter; use Utopia\WebSocket\Server; require_once __DIR__ . '/init.php'; +require_once __DIR__ . '/init/span.php'; /** @var Registry $register */ $register = $GLOBALS['register'] ?? throw new \RuntimeException('Registry not initialized'); @@ -262,7 +264,9 @@ $stats->create(); $containerId = uniqid(); $statsDocument = null; -$workerNumber = intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6)); + +$workerNumber = intval(System::getEnv('_APP_WORKERS_NUM', 0)) + ?: intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6)); $adapter = new Adapter\Swoole(port: System::getEnv('PORT', 80)); $adapter @@ -394,15 +398,27 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, Console::success('Worker ' . $workerId . ' started successfully'); $telemetry = getTelemetry($workerId); + $realtimeDelayBuckets = [100, 250, 500, 750, 1000, 1500, 2000, 3000, 5000, 7500, 10000, 15000, 30000]; + $workerTelemetryAttributes = ['workerId' => (string) $workerId]; $register->set('telemetry', fn () => $telemetry); + $register->set('telemetry.workerAttributes', fn () => $workerTelemetryAttributes); + $register->set('telemetry.workerCounter', fn () => $telemetry->createUpDownCounter('realtime.server.active_workers')); + $register->set('telemetry.workerClientCounter', fn () => $telemetry->createUpDownCounter('realtime.server.worker_clients')); + $register->set('telemetry.workerSubscriptionCounter', fn () => $telemetry->createUpDownCounter('realtime.server.worker_subscriptions')); $register->set('telemetry.connectionCounter', fn () => $telemetry->createUpDownCounter('realtime.server.open_connections')); $register->set('telemetry.connectionCreatedCounter', fn () => $telemetry->createCounter('realtime.server.connection.created')); $register->set('telemetry.messageSentCounter', fn () => $telemetry->createCounter('realtime.server.message.sent')); $register->set('telemetry.deliveryDelayHistogram', fn () => $telemetry->createHistogram( name: 'realtime.server.delivery_delay', unit: 'ms', - advisory: ['ExplicitBucketBoundaries' => [100, 250, 500, 750, 1000, 1500, 2000, 3000, 5000, 7500, 10000, 15000, 30000]], + advisory: ['ExplicitBucketBoundaries' => $realtimeDelayBuckets], )); + $register->set('telemetry.arrivalDelayHistogram', fn () => $telemetry->createHistogram( + name: 'realtime.server.arrival_delay', + unit: 'ms', + advisory: ['ExplicitBucketBoundaries' => $realtimeDelayBuckets], + )); + $register->get('telemetry.workerCounter')->add(1); $attempts = 0; $start = time(); @@ -519,12 +535,28 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $pubsub->subscribe(['realtime'], function (mixed $redis, string $channel, string $payload) use ($server, $workerId, $stats, $register, $realtime) { $event = json_decode($payload, true); + $eventTimestamp = $event['data']['timestamp'] ?? null; + if (\is_string($eventTimestamp)) { + try { + $eventDate = new \DateTimeImmutable($eventTimestamp, new \DateTimeZone('UTC')); + $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + $eventTimestampMs = (float) $eventDate->format('U.u') * 1000; + $nowTimestampMs = (float) $now->format('U.u') * 1000; + $arrivalDelayMs = (int) \max(0, $nowTimestampMs - $eventTimestampMs); + + $register->get('telemetry.arrivalDelayHistogram')->record($arrivalDelayMs); + } catch (\Throwable) { + // Ignore invalid timestamp payloads. + } + } + if ($event['permissionsChanged'] && isset($event['userId'])) { $projectId = $event['project']; $userId = $event['userId']; if ($realtime->hasSubscriber($projectId, 'user:' . $userId)) { $connection = array_key_first(reset($realtime->subscriptions[$projectId]['user:' . $userId])); + $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection)); $consoleDatabase = getConsoleDB(); $project = $consoleDatabase->getAuthorization()->skip(fn () => $consoleDatabase->getDocument('projects', $projectId)); $database = getProjectDB($project); @@ -555,6 +587,12 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, if ($authorization !== null) { $realtime->connections[$connection]['authorization'] = $authorization; } + + $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection)); + $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore; + if ($subscriptionDelta !== 0) { + $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes')); + } } } @@ -600,7 +638,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $updatedAt = $event['data']['payload']['$updatedAt'] ?? null; if (\is_string($updatedAt)) { try { - $updatedAtDate = new \DateTimeImmutable($updatedAt); + $updatedAtDate = new \DateTimeImmutable($updatedAt, new \DateTimeZone('UTC')); $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); $updatedAtTimestampMs = (float) $updatedAtDate->format('U.u') * 1000; $nowTimestampMs = (float) $now->format('U.u') * 1000; @@ -640,6 +678,16 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, Console::error('Failed to restart pub/sub...'); }); +$server->onWorkerStop(function (int $workerId) use ($register) { + Console::warning('Worker ' . $workerId . ' stopping'); + + try { + $register->get('telemetry.workerCounter')->add(-1); + } catch (\Throwable $th) { + Console::error('Realtime onWorkerStop telemetry error: ' . $th->getMessage()); + } +}); + $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $register, $stats, &$realtime, $registerConnectionResources) { global $container; $request = new Request($request); @@ -655,6 +703,20 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $project = null; $logUser = null; $authorization = null; + $rawSize = $request->getSize(); + $channelCount = 0; + $subscriptionCount = 0; + $outboundBytes = 0; + $responseCode = 200; + $subscriptionMode = 'message'; + $success = false; + + Span::init('realtime.open'); + Span::add('realtime.connectionId', $connection); + Span::add('realtime.inboundBytes', $rawSize); + if (!empty($request->getOrigin())) { + Span::add('realtime.origin', $request->getOrigin()); + } try { /** @var Document $project */ @@ -704,8 +766,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, throw new Exception(Exception::REALTIME_TOO_MANY_MESSAGES, 'Too many requests'); } - $rawSize = $request->getSize(); - triggerStats([ METRIC_REALTIME_INBOUND => $rawSize, ], $project->getId()); @@ -725,9 +785,11 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $roles = $user->getRoles($authorization); $channels = Realtime::convertChannels($request->getQuery('channels', []), $user->getId()); + $channelCount = \count($channels); $updateStats = static function (string $projectId, ?string $teamId, string $payloadJson) use ($register, $stats): void { $register->get('telemetry.connectionCounter')->add(1); + $register->get('telemetry.workerClientCounter')->add(1, $register->get('telemetry.workerAttributes')); $register->get('telemetry.connectionCreatedCounter')->add(1); $stats->set($projectId, [ @@ -761,11 +823,15 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $realtime->subscribe($project->getId(), $connection, '', $roles, [], [], $user->getId()); $realtime->connections[$connection]['authorization'] = $authorization; $server->send([$connection], $connectedPayloadJson); + $outboundBytes += \strlen($connectedPayloadJson); $updateStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson); + $subscriptionMode = 'message'; + $success = true; return; } $names = array_keys($channels); + $subscriptionMode = 'url'; try { $subscriptions = Realtime::constructSubscriptions( @@ -792,6 +858,10 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $mapping[$index] = $subscriptionId; } + $subscriptionCount = \count($subscriptions); + if (!empty($subscriptions)) { + $register->get('telemetry.workerSubscriptionCounter')->add(\count($subscriptions), $register->get('telemetry.workerAttributes')); + } $realtime->connections[$connection]['authorization'] = $authorization; @@ -807,8 +877,9 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, ]); $server->send([$connection], $connectedPayloadJson); + $outboundBytes += \strlen($connectedPayloadJson); $updateStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson); - + $success = true; } catch (Throwable $th) { logError($th, 'realtime', project: $project, user: $logUser, authorization: $authorization); @@ -818,6 +889,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, if (!\is_int($code)) { $code = 500; } + $responseCode = $code; $message = $th->getMessage(); @@ -835,7 +907,9 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, ] ]; - $server->send([$connection], json_encode($response)); + $responsePayloadJson = json_encode($response); + $server->send([$connection], $responsePayloadJson); + $outboundBytes += \strlen($responsePayloadJson); $server->close($connection, $code); if (System::getEnv('_APP_ENV', 'production') === 'development') { @@ -843,16 +917,44 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, Console::error('[Error] Code: ' . $response['data']['code']); Console::error('[Error] Message: ' . $response['data']['message']); } + Span::error($th); + } finally { + Span::add('realtime.success', $success); + Span::add('realtime.responseCode', $responseCode); + Span::add('realtime.subscriptionMode', $subscriptionMode); + Span::add('realtime.channelCount', $channelCount); + Span::add('realtime.subscriptionCount', $subscriptionCount); + Span::add('realtime.outboundBytes', $outboundBytes); + if (!empty($project?->getId())) { + Span::add('realtime.projectId', $project->getId()); + } + if (!empty($logUser?->getId())) { + Span::add('realtime.userId', $logUser->getId()); + } + Span::current()?->finish(); } }); -$server->onMessage(function (int $connection, string $message) use ($server, $realtime, $containerId) { +$server->onMessage(function (int $connection, string $message) use ($server, $realtime, $containerId, $register) { $project = null; $authorization = null; + $projectId = $realtime->connections[$connection]['projectId'] ?? null; + $rawSize = \strlen($message); + $messageType = 'invalid'; + $subscriptionDelta = 0; + $subscriptionsRequested = 0; + $subscriptionsRemoved = 0; + $outboundBytes = 0; + $responseCode = 200; + $success = false; + + Span::init('realtime.message'); + Span::add('realtime.connectionId', $connection); + Span::add('realtime.inboundBytes', $rawSize); + Span::add('realtime.containerId', $containerId); + try { - $rawSize = \strlen($message); $response = new Response(new SwooleResponse()); - $projectId = $realtime->connections[$connection]['projectId'] ?? null; // Get authorization from connection (stored during onOpen) $authorization = $realtime->connections[$connection]['authorization'] ?? null; @@ -902,6 +1004,12 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message format is not valid.'); } + $messageType = $message['type'] ?? 'invalid'; + + if (!\is_scalar($messageType)) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.'); + } + // Ping does not require project context; other messages do (e.g. after unsubscribe during auth) if (empty($projectId) && ($message['type'] ?? '') !== 'ping') { throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Missing project context. Reconnect to the project first.'); @@ -914,6 +1022,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re ]); $server->send([$connection], $pongPayloadJson); + $outboundBytes += \strlen($pongPayloadJson); if ($project !== null && !$project->isEmpty()) { $pongOutboundBytes = \strlen($pongPayloadJson); @@ -960,6 +1069,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $authorization = $realtime->connections[$connection]['authorization'] ?? null; $projectId = $realtime->connections[$connection]['projectId'] ?? null; + $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection)); $meta = $realtime->getSubscriptionMetadata($connection); $realtime->unsubscribe($connection); @@ -984,6 +1094,12 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $realtime->connections[$connection]['authorization'] = $authorization; } + $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection)); + $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore; + if ($subscriptionDelta !== 0) { + $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes')); + } + $user = $response->output($user, Response::MODEL_ACCOUNT); $authResponsePayloadJson = json_encode([ @@ -996,6 +1112,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re ]); $server->send([$connection], $authResponsePayloadJson); + $outboundBytes += \strlen($authResponsePayloadJson); if ($project !== null && !$project->isEmpty()) { $authOutboundBytes = \strlen($authResponsePayloadJson); @@ -1028,6 +1145,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re // bulk validation + parsing before subscribing $parsedPayloads = []; + $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection)); foreach ($message['data'] as $payload) { if (!\is_array($payload)) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Each subscribe payload must be an object.'); @@ -1056,22 +1174,28 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Invalid query: ' . $e->getMessage()); } + $convertedChannels = \array_keys(Realtime::convertChannels($payload['channels'], $userId)); + $parsedPayloads[] = [ 'subscriptionId' => $subscriptionId, 'channels' => $payload['channels'], + 'convertedChannels' => $convertedChannels, 'queries' => $convertedQueries, ]; } foreach ($parsedPayloads as $parsedPayload) { $subscriptionId = $parsedPayload['subscriptionId']; - $channels = \array_keys(Realtime::convertChannels($parsedPayload['channels'], $userId)); + $channels = $parsedPayload['convertedChannels']; $queries = $parsedPayload['queries']; $realtime->subscribe($projectId, $connection, $subscriptionId, $roles, $channels, $queries); } - - // subscribe() overwrites the connection entry; restore auth so later onMessage uses the same context. - $realtime->connections[$connection]['authorization'] = $authorization; + $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection)); + $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore; + $subscriptionsRequested = \count($parsedPayloads); + if ($subscriptionDelta !== 0) { + $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes')); + } $responsePayload = json_encode([ 'type' => 'response', @@ -1081,7 +1205,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re 'subscriptions' => \array_map(function (array $parsedPayload) { return [ 'subscriptionId' => $parsedPayload['subscriptionId'], - 'channels' => $parsedPayload['channels'], + 'channels' => $parsedPayload['convertedChannels'], 'queries' => \array_map(fn ($q) => $q->toString(), $parsedPayload['queries']), ]; }, $parsedPayloads), @@ -1089,6 +1213,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re ]); $server->send([$connection], $responsePayload); + $outboundBytes += \strlen($responsePayload); if ($project !== null && !$project->isEmpty()) { $subscribeOutboundBytes = \strlen($responsePayload); @@ -1102,15 +1227,79 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re break; + case 'unsubscribe': + if (!\is_array($message['data']) || !\array_is_list($message['data'])) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Payload is not valid.'); + } + + $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection)); + + // Validate every payload before executing any removal so an invalid entry + // later in the batch does not leave earlier entries half-applied on the server. + $validatedIds = []; + foreach ($message['data'] as $payload) { + if ( + !\is_array($payload) + || !\array_key_exists('subscriptionId', $payload) + || !\is_string($payload['subscriptionId']) + || $payload['subscriptionId'] === '' + ) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Each unsubscribe payload must include a non-empty subscriptionId.'); + } + $validatedIds[] = $payload['subscriptionId']; + } + + $unsubscribeResults = []; + foreach ($validatedIds as $subscriptionId) { + $wasRemoved = $realtime->unsubscribeSubscription($connection, $subscriptionId); + $unsubscribeResults[] = [ + 'subscriptionId' => $subscriptionId, + 'removed' => $wasRemoved, + ]; + } + $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection)); + $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore; + $subscriptionsRequested = \count($validatedIds); + $subscriptionsRemoved = \count(\array_filter($unsubscribeResults, fn (array $item) => $item['removed'])); + if ($subscriptionDelta !== 0) { + $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes')); + } + + $unsubscribeResponsePayload = json_encode([ + 'type' => 'response', + 'data' => [ + 'to' => 'unsubscribe', + 'success' => true, + 'subscriptions' => $unsubscribeResults, + ], + ]); + + $server->send([$connection], $unsubscribeResponsePayload); + $outboundBytes += \strlen($unsubscribeResponsePayload); + + if ($project !== null && !$project->isEmpty()) { + $unsubscribeOutboundBytes = \strlen($unsubscribeResponsePayload); + + if ($unsubscribeOutboundBytes > 0) { + triggerStats([ + METRIC_REALTIME_OUTBOUND => $unsubscribeOutboundBytes, + ], $project->getId()); + } + } + + break; + default: throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.'); } + $success = true; } catch (Throwable $th) { logError($th, 'realtimeMessage', project: $project, authorization: $authorization); $code = $th->getCode(); if (!is_int($code)) { $code = 500; } + $responseCode = $code; $message = $th->getMessage(); @@ -1127,19 +1316,52 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re ] ]; - $server->send([$connection], json_encode($response)); + $responsePayloadJson = json_encode($response); + $server->send([$connection], $responsePayloadJson); + $outboundBytes += \strlen($responsePayloadJson); if ($th->getCode() === 1008) { $server->close($connection, $th->getCode()); } + Span::error($th); + } finally { + Span::add('realtime.success', $success); + Span::add('realtime.responseCode', $responseCode); + Span::add('realtime.subscriptionDelta', $subscriptionDelta); + Span::add('realtime.subscriptionsRequested', $subscriptionsRequested); + Span::add('realtime.subscriptionsRemoved', $subscriptionsRemoved); + Span::add('realtime.subscribe.subscriptionsCount', $subscriptionsRequested); + Span::add('realtime.outboundBytes', $outboundBytes); + Span::add('realtime.projectId', $project?->getId() ?? $projectId); + Span::add('realtime.userId', $realtime->connections[$connection]['userId'] ?? null); + Span::add('realtime.messageType', $messageType); + Span::current()?->finish(); } }); $server->onClose(function (int $connection) use ($realtime, $stats, $register) { + $projectId = null; + $userId = null; + $subscriptionsBeforeClose = 0; + $success = false; + + Span::init('realtime.close'); + Span::add('realtime.connectionId', $connection); + + if (array_key_exists($connection, $realtime->connections)) { + $projectId = $realtime->connections[$connection]['projectId'] ?? null; + $userId = $realtime->connections[$connection]['userId'] ?? null; + } + try { if (array_key_exists($connection, $realtime->connections)) { $stats->decr($realtime->connections[$connection]['projectId'], 'connectionsTotal'); $register->get('telemetry.connectionCounter')->add(-1); + $register->get('telemetry.workerClientCounter')->add(-1, $register->get('telemetry.workerAttributes')); + $subscriptionsBeforeClose = \count($realtime->getSubscriptionMetadata($connection)); + if ($subscriptionsBeforeClose > 0) { + $register->get('telemetry.workerSubscriptionCounter')->add(-$subscriptionsBeforeClose, $register->get('telemetry.workerAttributes')); + } $projectId = $realtime->connections[$connection]['projectId']; @@ -1147,12 +1369,30 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) { METRIC_REALTIME_CONNECTIONS => -1, ], $projectId); } + $success = true; } catch (\Throwable $th) { // Log only; do not rethrow. If we let this bubble, Swoole dumps full coroutine // backtraces and unsubscribe() below would never run (connection cleanup would fail). Console::error('Realtime onClose error: ' . $th->getMessage()); + Span::error($th); + } finally { + try { + $realtime->unsubscribe($connection); + } catch (\Throwable $th) { + Console::error('Realtime onClose unsubscribe error: ' . $th->getMessage()); + Span::error($th); + } + + Span::add('realtime.success', $success); + if (!empty($projectId)) { + Span::add('realtime.projectId', $projectId); + } + if (!empty($userId)) { + Span::add('realtime.userId', $userId); + } + Span::add('realtime.subscriptionsBeforeClose', $subscriptionsBeforeClose); + Span::current()?->finish(); } - $realtime->unsubscribe($connection); Console::info('Connection close: ' . $connection); }); diff --git a/app/worker.php b/app/worker.php index 7cc34f397c..12b822c4eb 100644 --- a/app/worker.php +++ b/app/worker.php @@ -129,7 +129,7 @@ $worker $log->setAction('appwrite-queue-' . $queueName); $log->addTag('verboseType', get_class($error)); $log->addTag('code', $error->getCode()); - $log->addTag('projectId', $project->getId() ?? 'n/a'); + $log->addTag('projectId', $project->getId()); $log->addExtra('file', $error->getFile()); $log->addExtra('line', $error->getLine()); $log->addExtra('trace', $error->getTraceAsString()); diff --git a/composer.lock b/composer.lock index d0d69bd0c5..02590020e0 100644 --- a/composer.lock +++ b/composer.lock @@ -3850,16 +3850,16 @@ }, { "name": "utopia-php/database", - "version": "5.3.21", + "version": "5.3.22", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "ee2d7d4c87b3a3fae954089ad7494ceb454f619d" + "reference": "d765945da6b3141852014b2f96ecf1fe7e3d6ba7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/ee2d7d4c87b3a3fae954089ad7494ceb454f619d", - "reference": "ee2d7d4c87b3a3fae954089ad7494ceb454f619d", + "url": "https://api.github.com/repos/utopia-php/database/zipball/d765945da6b3141852014b2f96ecf1fe7e3d6ba7", + "reference": "d765945da6b3141852014b2f96ecf1fe7e3d6ba7", "shasum": "" }, "require": { @@ -3903,9 +3903,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/5.3.21" + "source": "https://github.com/utopia-php/database/tree/5.3.22" }, - "time": "2026-04-10T12:38:57+00:00" + "time": "2026-04-20T07:12:46+00:00" }, { "name": "utopia-php/detector", diff --git a/docker-compose.yml b/docker-compose.yml index 2e53b67901..7d53d2965d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -253,7 +253,7 @@ services: appwrite-console: <<: *x-logging container_name: appwrite-console - image: appwrite/console:7.8.26 + image: appwrite/console:7.8.45 restart: unless-stopped networks: - appwrite diff --git a/docs/references/account/create-2fa-challenge.md b/docs/references/account/create-2fa-challenge.md deleted file mode 100644 index ee6ef2f2ac..0000000000 --- a/docs/references/account/create-2fa-challenge.md +++ /dev/null @@ -1 +0,0 @@ -Initialize an MFA challenge of the specified factor. The factor must be available on the account. \ No newline at end of file diff --git a/docs/references/account/delete-session-current.md b/docs/references/account/delete-session-current.md deleted file mode 100644 index d38520f479..0000000000 --- a/docs/references/account/delete-session-current.md +++ /dev/null @@ -1 +0,0 @@ -Use this endpoint to log out the currently logged in user from their account. When successful this endpoint will delete the user session and remove the session secret cookie from the user client. \ No newline at end of file diff --git a/docs/references/documentsdb/get-collection-logs.md b/docs/references/documentsdb/get-collection-logs.md deleted file mode 100644 index 8578cef03c..0000000000 --- a/docs/references/documentsdb/get-collection-logs.md +++ /dev/null @@ -1 +0,0 @@ -Get the collection activity logs list by its unique ID. \ No newline at end of file diff --git a/docs/references/documentsdb/get-document-logs.md b/docs/references/documentsdb/get-document-logs.md deleted file mode 100644 index 9b96df5ad4..0000000000 --- a/docs/references/documentsdb/get-document-logs.md +++ /dev/null @@ -1 +0,0 @@ -Get the document activity logs list by its unique ID. \ No newline at end of file diff --git a/docs/references/documentsdb/list-attributes.md b/docs/references/documentsdb/list-attributes.md deleted file mode 100644 index 72ad6d727f..0000000000 --- a/docs/references/documentsdb/list-attributes.md +++ /dev/null @@ -1 +0,0 @@ -List attributes in the collection. \ No newline at end of file diff --git a/docs/references/functions/create-build.md b/docs/references/functions/create-build.md deleted file mode 100644 index 160a04c291..0000000000 --- a/docs/references/functions/create-build.md +++ /dev/null @@ -1 +0,0 @@ -Create a new build for an existing function deployment. This endpoint allows you to rebuild a deployment with the updated function configuration, including its entrypoint and build commands if they have been modified. The build process will be queued and executed asynchronously. The original deployment's code will be preserved and used for the new build. \ No newline at end of file diff --git a/docs/references/functions/create-deployment.md b/docs/references/functions/create-deployment.md deleted file mode 100644 index 3bbdbfc848..0000000000 --- a/docs/references/functions/create-deployment.md +++ /dev/null @@ -1,5 +0,0 @@ -Create a new function code deployment. Use this endpoint to upload a new version of your code function. To execute your newly uploaded code, you'll need to update the function's deployment to use your new deployment UID. - -This endpoint accepts a tar.gz file compressed with your code. Make sure to include any dependencies your code has within the compressed file. You can learn more about code packaging in the [Appwrite Cloud Functions tutorial](https://appwrite.io/docs/functions). - -Use the "command" param to set the entrypoint used to execute your code. \ No newline at end of file diff --git a/docs/references/functions/create-execution.md b/docs/references/functions/create-execution.md deleted file mode 100644 index 6089c4ff01..0000000000 --- a/docs/references/functions/create-execution.md +++ /dev/null @@ -1 +0,0 @@ -Trigger a function execution. The returned object will return you the current execution status. You can ping the `Get Execution` endpoint to get updates on the current execution status. Once this endpoint is called, your function execution process will start asynchronously. \ No newline at end of file diff --git a/docs/references/functions/create-function.md b/docs/references/functions/create-function.md deleted file mode 100644 index 1ac9143f45..0000000000 --- a/docs/references/functions/create-function.md +++ /dev/null @@ -1 +0,0 @@ -Create a new function. You can pass a list of [permissions](https://appwrite.io/docs/permissions) to allow different project users or team with access to execute the function using the client API. \ No newline at end of file diff --git a/docs/references/functions/create-variable.md b/docs/references/functions/create-variable.md deleted file mode 100644 index 40fabd75a8..0000000000 --- a/docs/references/functions/create-variable.md +++ /dev/null @@ -1 +0,0 @@ -Create a new function environment variable. These variables can be accessed in the function at runtime as environment variables. \ No newline at end of file diff --git a/docs/references/functions/delete-deployment.md b/docs/references/functions/delete-deployment.md deleted file mode 100644 index 19c74965bd..0000000000 --- a/docs/references/functions/delete-deployment.md +++ /dev/null @@ -1 +0,0 @@ -Delete a code deployment by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/delete-execution.md b/docs/references/functions/delete-execution.md deleted file mode 100644 index d7cad98ac1..0000000000 --- a/docs/references/functions/delete-execution.md +++ /dev/null @@ -1 +0,0 @@ -Delete a function execution by its unique ID. diff --git a/docs/references/functions/delete-function.md b/docs/references/functions/delete-function.md deleted file mode 100644 index 92835e3c82..0000000000 --- a/docs/references/functions/delete-function.md +++ /dev/null @@ -1 +0,0 @@ -Delete a function by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/delete-variable.md b/docs/references/functions/delete-variable.md deleted file mode 100644 index 9b1326d96f..0000000000 --- a/docs/references/functions/delete-variable.md +++ /dev/null @@ -1 +0,0 @@ -Delete a variable by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/get-deployment-download.md b/docs/references/functions/get-deployment-download.md deleted file mode 100644 index e662ae2733..0000000000 --- a/docs/references/functions/get-deployment-download.md +++ /dev/null @@ -1 +0,0 @@ -Get a Deployment's contents by its unique ID. This endpoint supports range requests for partial or streaming file download. \ No newline at end of file diff --git a/docs/references/functions/get-deployment.md b/docs/references/functions/get-deployment.md deleted file mode 100644 index 6d73976eb1..0000000000 --- a/docs/references/functions/get-deployment.md +++ /dev/null @@ -1 +0,0 @@ -Get a code deployment by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/get-execution.md b/docs/references/functions/get-execution.md deleted file mode 100644 index fc38260bdb..0000000000 --- a/docs/references/functions/get-execution.md +++ /dev/null @@ -1 +0,0 @@ -Get a function execution log by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/get-function-usage.md b/docs/references/functions/get-function-usage.md deleted file mode 100644 index 4498abb05b..0000000000 --- a/docs/references/functions/get-function-usage.md +++ /dev/null @@ -1 +0,0 @@ -Get usage metrics and statistics for a for a specific function. View statistics including total deployments, builds, executions, storage usage, and compute time. The response includes both current totals and historical data for each metric. Use the optional range parameter to specify the time window for historical data: 24h (last 24 hours), 30d (last 30 days), or 90d (last 90 days). If not specified, defaults to 30 days. \ No newline at end of file diff --git a/docs/references/functions/get-function.md b/docs/references/functions/get-function.md deleted file mode 100644 index 557ec316ba..0000000000 --- a/docs/references/functions/get-function.md +++ /dev/null @@ -1 +0,0 @@ -Get a function by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/get-functions-usage.md b/docs/references/functions/get-functions-usage.md deleted file mode 100644 index 14427d335d..0000000000 --- a/docs/references/functions/get-functions-usage.md +++ /dev/null @@ -1 +0,0 @@ -Get usage metrics and statistics for a for all functions. View statistics including total functions, deployments, builds, executions, storage usage, and compute time. The response includes both current totals and historical data for each metric. Use the optional range parameter to specify the time window for historical data: 24h (last 24 hours), 30d (last 30 days), or 90d (last 90 days). If not specified, defaults to 30 days. \ No newline at end of file diff --git a/docs/references/functions/get-template.md b/docs/references/functions/get-template.md deleted file mode 100644 index ccdcce7352..0000000000 --- a/docs/references/functions/get-template.md +++ /dev/null @@ -1 +0,0 @@ -Get a function template using ID. You can use template details in [createFunction](/docs/references/cloud/server-nodejs/functions#create) method. \ No newline at end of file diff --git a/docs/references/functions/get-variable.md b/docs/references/functions/get-variable.md deleted file mode 100644 index f0fa853655..0000000000 --- a/docs/references/functions/get-variable.md +++ /dev/null @@ -1 +0,0 @@ -Get a variable by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/list-deployments.md b/docs/references/functions/list-deployments.md deleted file mode 100644 index 80bbba1bf6..0000000000 --- a/docs/references/functions/list-deployments.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all the function's code deployments. You can use the query params to filter your results. \ No newline at end of file diff --git a/docs/references/functions/list-executions.md b/docs/references/functions/list-executions.md deleted file mode 100644 index 168c795b20..0000000000 --- a/docs/references/functions/list-executions.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all the current user function execution logs. You can use the query params to filter your results. \ No newline at end of file diff --git a/docs/references/functions/list-functions.md b/docs/references/functions/list-functions.md deleted file mode 100644 index 9ad432fdc0..0000000000 --- a/docs/references/functions/list-functions.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all the project's functions. You can use the query params to filter your results. \ No newline at end of file diff --git a/docs/references/functions/list-runtimes.md b/docs/references/functions/list-runtimes.md deleted file mode 100644 index d4d3d23b18..0000000000 --- a/docs/references/functions/list-runtimes.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all runtimes that are currently active on your instance. \ No newline at end of file diff --git a/docs/references/functions/list-specifications.md b/docs/references/functions/list-specifications.md deleted file mode 100644 index d65a215827..0000000000 --- a/docs/references/functions/list-specifications.md +++ /dev/null @@ -1 +0,0 @@ -List allowed function specifications for this instance. diff --git a/docs/references/functions/list-templates.md b/docs/references/functions/list-templates.md deleted file mode 100644 index ed43b9cbf4..0000000000 --- a/docs/references/functions/list-templates.md +++ /dev/null @@ -1 +0,0 @@ -List available function templates. You can use template details in [createFunction](/docs/references/cloud/server-nodejs/functions#create) method. \ No newline at end of file diff --git a/docs/references/functions/list-variables.md b/docs/references/functions/list-variables.md deleted file mode 100644 index 68bd5e17e1..0000000000 --- a/docs/references/functions/list-variables.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all variables of a specific function. \ No newline at end of file diff --git a/docs/references/functions/update-deployment-build.md b/docs/references/functions/update-deployment-build.md deleted file mode 100644 index d047990adf..0000000000 --- a/docs/references/functions/update-deployment-build.md +++ /dev/null @@ -1 +0,0 @@ -Cancel an ongoing function deployment build. If the build is already in progress, it will be stopped and marked as canceled. If the build hasn't started yet, it will be marked as canceled without executing. You cannot cancel builds that have already completed (status 'ready') or failed. The response includes the final build status and details. \ No newline at end of file diff --git a/docs/references/functions/update-function-deployment.md b/docs/references/functions/update-function-deployment.md deleted file mode 100644 index 7a85188842..0000000000 --- a/docs/references/functions/update-function-deployment.md +++ /dev/null @@ -1 +0,0 @@ -Update the function code deployment ID using the unique function ID. Use this endpoint to switch the code deployment that should be executed by the execution endpoint. \ No newline at end of file diff --git a/docs/references/functions/update-function.md b/docs/references/functions/update-function.md deleted file mode 100644 index 5a9a84ad94..0000000000 --- a/docs/references/functions/update-function.md +++ /dev/null @@ -1 +0,0 @@ -Update function by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/update-variable.md b/docs/references/functions/update-variable.md deleted file mode 100644 index af2c38aea2..0000000000 --- a/docs/references/functions/update-variable.md +++ /dev/null @@ -1 +0,0 @@ -Update variable by its unique ID. \ No newline at end of file diff --git a/docs/references/health/get-queue-stats-usage-dump.md b/docs/references/health/get-queue-stats-usage-dump.md deleted file mode 100644 index 3c95da1b8a..0000000000 --- a/docs/references/health/get-queue-stats-usage-dump.md +++ /dev/null @@ -1 +0,0 @@ -Get the number of projects containing metrics that are waiting to be processed in the Appwrite internal queue server. \ No newline at end of file diff --git a/docs/references/health/get-queue-tasks.md b/docs/references/health/get-queue-tasks.md deleted file mode 100644 index ea6fa22087..0000000000 --- a/docs/references/health/get-queue-tasks.md +++ /dev/null @@ -1 +0,0 @@ -Get the number of tasks that are waiting to be processed in the Appwrite internal queue server. \ No newline at end of file diff --git a/docs/references/health/get-queue.md b/docs/references/health/get-queue.md deleted file mode 100644 index e4558f941f..0000000000 --- a/docs/references/health/get-queue.md +++ /dev/null @@ -1 +0,0 @@ -Check the Appwrite queue messaging servers are up and connection is successful. \ No newline at end of file diff --git a/docs/references/messaging/delete.md b/docs/references/messaging/delete.md deleted file mode 100644 index b07d020900..0000000000 --- a/docs/references/messaging/delete.md +++ /dev/null @@ -1 +0,0 @@ -Delete a message by its unique ID. \ No newline at end of file diff --git a/docs/references/project/create-variable.md b/docs/references/project/create-variable.md deleted file mode 100644 index 2bbee5bf99..0000000000 --- a/docs/references/project/create-variable.md +++ /dev/null @@ -1 +0,0 @@ -Create a new project variable. This variable will be accessible in all Appwrite Functions at runtime. \ No newline at end of file diff --git a/docs/references/project/delete-variable.md b/docs/references/project/delete-variable.md deleted file mode 100644 index 9be15f83ca..0000000000 --- a/docs/references/project/delete-variable.md +++ /dev/null @@ -1 +0,0 @@ -Delete a project variable by its unique ID. \ No newline at end of file diff --git a/docs/references/project/get-variable.md b/docs/references/project/get-variable.md deleted file mode 100644 index 8636768434..0000000000 --- a/docs/references/project/get-variable.md +++ /dev/null @@ -1 +0,0 @@ -Get a project variable by its unique ID. \ No newline at end of file diff --git a/docs/references/project/list-variables.md b/docs/references/project/list-variables.md deleted file mode 100644 index fbe191178a..0000000000 --- a/docs/references/project/list-variables.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all project variables. These variables will be accessible in all Appwrite Functions at runtime. \ No newline at end of file diff --git a/docs/references/project/update-variable.md b/docs/references/project/update-variable.md deleted file mode 100644 index 603622b2c7..0000000000 --- a/docs/references/project/update-variable.md +++ /dev/null @@ -1 +0,0 @@ -Update project variable by its unique ID. This variable will be accessible in all Appwrite Functions at runtime. \ No newline at end of file diff --git a/docs/references/projects/create-key.md b/docs/references/projects/create-key.md deleted file mode 100644 index d6633d936d..0000000000 --- a/docs/references/projects/create-key.md +++ /dev/null @@ -1 +0,0 @@ -Create a new API key. It's recommended to have multiple API keys with strict scopes for separate functions within your project. \ No newline at end of file diff --git a/docs/references/projects/create-platform.md b/docs/references/projects/create-platform.md deleted file mode 100644 index b5d8be0ff9..0000000000 --- a/docs/references/projects/create-platform.md +++ /dev/null @@ -1 +0,0 @@ -Create a new platform for your project. Use this endpoint to register a new platform where your users will run your application which will interact with the Appwrite API. \ No newline at end of file diff --git a/docs/references/projects/create-webhook.md b/docs/references/projects/create-webhook.md deleted file mode 100644 index cd0e93332b..0000000000 --- a/docs/references/projects/create-webhook.md +++ /dev/null @@ -1 +0,0 @@ -Create a new webhook. Use this endpoint to configure a URL that will receive events from Appwrite when specific events occur. \ No newline at end of file diff --git a/docs/references/projects/delete-key.md b/docs/references/projects/delete-key.md deleted file mode 100644 index 9f3774b419..0000000000 --- a/docs/references/projects/delete-key.md +++ /dev/null @@ -1 +0,0 @@ -Delete a key by its unique ID. Once deleted, the key can no longer be used to authenticate API calls. \ No newline at end of file diff --git a/docs/references/projects/delete-platform.md b/docs/references/projects/delete-platform.md deleted file mode 100644 index 7d538cac26..0000000000 --- a/docs/references/projects/delete-platform.md +++ /dev/null @@ -1 +0,0 @@ -Delete a platform by its unique ID. This endpoint removes the platform and all its configurations from the project. \ No newline at end of file diff --git a/docs/references/projects/delete-webhook.md b/docs/references/projects/delete-webhook.md deleted file mode 100644 index 74fee2bcec..0000000000 --- a/docs/references/projects/delete-webhook.md +++ /dev/null @@ -1 +0,0 @@ -Delete a webhook by its unique ID. Once deleted, the webhook will no longer receive project events. \ No newline at end of file diff --git a/docs/references/projects/get-key.md b/docs/references/projects/get-key.md deleted file mode 100644 index bd6351f420..0000000000 --- a/docs/references/projects/get-key.md +++ /dev/null @@ -1 +0,0 @@ -Get a key by its unique ID. This endpoint returns details about a specific API key in your project including it's scopes. \ No newline at end of file diff --git a/docs/references/projects/get-platform.md b/docs/references/projects/get-platform.md deleted file mode 100644 index 87129b829d..0000000000 --- a/docs/references/projects/get-platform.md +++ /dev/null @@ -1 +0,0 @@ -Get a platform by its unique ID. This endpoint returns the platform's details, including its name, type, and key configurations. \ No newline at end of file diff --git a/docs/references/projects/get-webhook.md b/docs/references/projects/get-webhook.md deleted file mode 100644 index 559c73c748..0000000000 --- a/docs/references/projects/get-webhook.md +++ /dev/null @@ -1 +0,0 @@ -Get a webhook by its unique ID. This endpoint returns details about a specific webhook configured for a project. \ No newline at end of file diff --git a/docs/references/projects/list-keys.md b/docs/references/projects/list-keys.md deleted file mode 100644 index a7b701b0d7..0000000000 --- a/docs/references/projects/list-keys.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all API keys from the current project. \ No newline at end of file diff --git a/docs/references/projects/list-platforms.md b/docs/references/projects/list-platforms.md deleted file mode 100644 index ed9ade0852..0000000000 --- a/docs/references/projects/list-platforms.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all platforms in the project. This endpoint returns an array of all platforms and their configurations. \ No newline at end of file diff --git a/docs/references/projects/list-webhooks.md b/docs/references/projects/list-webhooks.md deleted file mode 100644 index bbbf4c7376..0000000000 --- a/docs/references/projects/list-webhooks.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all webhooks belonging to the project. You can use the query params to filter your results. \ No newline at end of file diff --git a/docs/references/projects/update-api-status-all.md b/docs/references/projects/update-api-status-all.md deleted file mode 100644 index 654070759f..0000000000 --- a/docs/references/projects/update-api-status-all.md +++ /dev/null @@ -1 +0,0 @@ -Update the status of all API types. Use this endpoint to enable or disable API types such as REST, GraphQL and Realtime all at once. \ No newline at end of file diff --git a/docs/references/projects/update-api-status.md b/docs/references/projects/update-api-status.md deleted file mode 100644 index af10a0d4f4..0000000000 --- a/docs/references/projects/update-api-status.md +++ /dev/null @@ -1 +0,0 @@ -Update the status of a specific API type. Use this endpoint to enable or disable API types such as REST, GraphQL and Realtime. \ No newline at end of file diff --git a/docs/references/projects/update-auth-duration.md b/docs/references/projects/update-auth-duration.md deleted file mode 100644 index bdc75fa6f0..0000000000 --- a/docs/references/projects/update-auth-duration.md +++ /dev/null @@ -1 +0,0 @@ -Update how long sessions created within a project should stay active for. \ No newline at end of file diff --git a/docs/references/projects/update-auth-limit.md b/docs/references/projects/update-auth-limit.md deleted file mode 100644 index c8faa3fe37..0000000000 --- a/docs/references/projects/update-auth-limit.md +++ /dev/null @@ -1 +0,0 @@ -Update the maximum number of users allowed in this project. Set to 0 for unlimited users. \ No newline at end of file diff --git a/docs/references/projects/update-auth-password-dictionary.md b/docs/references/projects/update-auth-password-dictionary.md deleted file mode 100644 index 1d47d30bb5..0000000000 --- a/docs/references/projects/update-auth-password-dictionary.md +++ /dev/null @@ -1 +0,0 @@ -Enable or disable checking user passwords against common passwords dictionary. This helps ensure users don't use common and insecure passwords. \ No newline at end of file diff --git a/docs/references/projects/update-auth-password-history.md b/docs/references/projects/update-auth-password-history.md deleted file mode 100644 index 3a892915d5..0000000000 --- a/docs/references/projects/update-auth-password-history.md +++ /dev/null @@ -1 +0,0 @@ -Update the authentication password history requirement. Use this endpoint to require new passwords to be different than the last X amount of previously used ones. \ No newline at end of file diff --git a/docs/references/projects/update-auth-sessions-limit.md b/docs/references/projects/update-auth-sessions-limit.md deleted file mode 100644 index 7d5fdffae7..0000000000 --- a/docs/references/projects/update-auth-sessions-limit.md +++ /dev/null @@ -1 +0,0 @@ -Update the maximum number of sessions allowed per user within the project, if the limit is hit the oldest session will be deleted to make room for new sessions. \ No newline at end of file diff --git a/docs/references/projects/update-key.md b/docs/references/projects/update-key.md deleted file mode 100644 index 4934a51497..0000000000 --- a/docs/references/projects/update-key.md +++ /dev/null @@ -1 +0,0 @@ -Update a key by its unique ID. Use this endpoint to update the name, scopes, or expiration time of an API key. \ No newline at end of file diff --git a/docs/references/projects/update-memberships-privacy.md b/docs/references/projects/update-memberships-privacy.md deleted file mode 100644 index a1affc1166..0000000000 --- a/docs/references/projects/update-memberships-privacy.md +++ /dev/null @@ -1 +0,0 @@ -Update project membership privacy settings. Use this endpoint to control what user information is visible to other team members, such as user name, email, and MFA status. \ No newline at end of file diff --git a/docs/references/projects/update-personal-data-check.md b/docs/references/projects/update-personal-data-check.md deleted file mode 100644 index 42847fdbfc..0000000000 --- a/docs/references/projects/update-personal-data-check.md +++ /dev/null @@ -1 +0,0 @@ -Enable or disable checking user passwords against their personal data. This helps prevent users from using personal information in their passwords. \ No newline at end of file diff --git a/docs/references/projects/update-platform.md b/docs/references/projects/update-platform.md deleted file mode 100644 index d04b07bafd..0000000000 --- a/docs/references/projects/update-platform.md +++ /dev/null @@ -1 +0,0 @@ -Update a platform by its unique ID. Use this endpoint to update the platform's name, key, platform store ID, or hostname. \ No newline at end of file diff --git a/docs/references/projects/update-service-status-all.md b/docs/references/projects/update-service-status-all.md deleted file mode 100644 index f05e7d8c5c..0000000000 --- a/docs/references/projects/update-service-status-all.md +++ /dev/null @@ -1 +0,0 @@ -Update the status of all services. Use this endpoint to enable or disable all optional services at once. \ No newline at end of file diff --git a/docs/references/projects/update-service-status.md b/docs/references/projects/update-service-status.md deleted file mode 100644 index 9d3b0743a8..0000000000 --- a/docs/references/projects/update-service-status.md +++ /dev/null @@ -1 +0,0 @@ -Update the status of a specific service. Use this endpoint to enable or disable a service in your project. \ No newline at end of file diff --git a/docs/references/projects/update-session-alerts.md b/docs/references/projects/update-session-alerts.md deleted file mode 100644 index 36859e0c1e..0000000000 --- a/docs/references/projects/update-session-alerts.md +++ /dev/null @@ -1 +0,0 @@ -Enable or disable session email alerts. When enabled, users will receive email notifications when new sessions are created. \ No newline at end of file diff --git a/docs/references/projects/update-session-invalidation.md b/docs/references/projects/update-session-invalidation.md deleted file mode 100644 index cbaf378624..0000000000 --- a/docs/references/projects/update-session-invalidation.md +++ /dev/null @@ -1 +0,0 @@ -Invalidate all existing sessions. An optional auth security setting for projects, and enabled by default for console project. \ No newline at end of file diff --git a/docs/references/projects/update-webhook-signature.md b/docs/references/projects/update-webhook-signature.md deleted file mode 100644 index 8525a05777..0000000000 --- a/docs/references/projects/update-webhook-signature.md +++ /dev/null @@ -1 +0,0 @@ -Update the webhook signature key. This endpoint can be used to regenerate the signature key used to sign and validate payload deliveries for a specific webhook. \ No newline at end of file diff --git a/docs/references/projects/update-webhook.md b/docs/references/projects/update-webhook.md deleted file mode 100644 index 745e4aebe1..0000000000 --- a/docs/references/projects/update-webhook.md +++ /dev/null @@ -1 +0,0 @@ -Update a webhook by its unique ID. Use this endpoint to update the URL, events, or status of an existing webhook. \ No newline at end of file diff --git a/docs/references/tablesdb/get-database.md b/docs/references/tablesdb/get-database.md deleted file mode 100644 index 24183f6f6b..0000000000 --- a/docs/references/tablesdb/get-database.md +++ /dev/null @@ -1 +0,0 @@ -Get a database by its unique ID. This endpoint response returns a JSON object with the database metadata. \ No newline at end of file diff --git a/docs/references/vectorsdb/decrement-document-attribute.md b/docs/references/vectorsdb/decrement-document-attribute.md deleted file mode 100644 index b7b32d6148..0000000000 --- a/docs/references/vectorsdb/decrement-document-attribute.md +++ /dev/null @@ -1 +0,0 @@ -Decrement a specific column of a row by a given value. \ No newline at end of file diff --git a/docs/references/vectorsdb/get-collection-logs.md b/docs/references/vectorsdb/get-collection-logs.md deleted file mode 100644 index 8578cef03c..0000000000 --- a/docs/references/vectorsdb/get-collection-logs.md +++ /dev/null @@ -1 +0,0 @@ -Get the collection activity logs list by its unique ID. \ No newline at end of file diff --git a/docs/references/vectorsdb/get-document-logs.md b/docs/references/vectorsdb/get-document-logs.md deleted file mode 100644 index 9b96df5ad4..0000000000 --- a/docs/references/vectorsdb/get-document-logs.md +++ /dev/null @@ -1 +0,0 @@ -Get the document activity logs list by its unique ID. \ No newline at end of file diff --git a/docs/references/vectorsdb/increment-document-attribute.md b/docs/references/vectorsdb/increment-document-attribute.md deleted file mode 100644 index 7a19b3fbc7..0000000000 --- a/docs/references/vectorsdb/increment-document-attribute.md +++ /dev/null @@ -1 +0,0 @@ -Increment a specific column of a row by a given value. \ No newline at end of file diff --git a/docs/references/vectorsdb/list-attributes.md b/docs/references/vectorsdb/list-attributes.md deleted file mode 100644 index 72ad6d727f..0000000000 --- a/docs/references/vectorsdb/list-attributes.md +++ /dev/null @@ -1 +0,0 @@ -List attributes in the collection. \ No newline at end of file diff --git a/phpstan.neon b/phpstan.neon index 85d18fd44d..0b8761c19e 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,5 @@ parameters: - level: 3 + level: 4 tmpDir: .phpstan-cache paths: - src diff --git a/src/Appwrite/Auth/OAuth2/Apple.php b/src/Appwrite/Auth/OAuth2/Apple.php index 0b4ec50881..bae3446fcb 100644 --- a/src/Appwrite/Auth/OAuth2/Apple.php +++ b/src/Appwrite/Auth/OAuth2/Apple.php @@ -165,9 +165,9 @@ class Apple extends OAuth2 protected function getAppSecret(): string { - try { - $secret = \json_decode($this->appSecret, true); - } catch (\Throwable $th) { + $secret = \json_decode($this->appSecret, true); + + if (!\is_array($secret)) { throw new Exception('Invalid secret'); } diff --git a/src/Appwrite/Auth/OAuth2/Etsy.php b/src/Appwrite/Auth/OAuth2/Etsy.php index 7ff16fcb78..6e0da14437 100644 --- a/src/Appwrite/Auth/OAuth2/Etsy.php +++ b/src/Appwrite/Auth/OAuth2/Etsy.php @@ -11,11 +11,6 @@ class Etsy extends OAuth2 */ private string $endpoint = 'https://api.etsy.com/v3/public'; - /** - * @var string - */ - private string $version = '2022-07-14'; - /** * @var array */ diff --git a/src/Appwrite/Auth/OAuth2/Podio.php b/src/Appwrite/Auth/OAuth2/Podio.php index 0b1f35414b..6a977da854 100644 --- a/src/Appwrite/Auth/OAuth2/Podio.php +++ b/src/Appwrite/Auth/OAuth2/Podio.php @@ -121,7 +121,7 @@ class Podio extends OAuth2 { $user = $this->getUser($accessToken); - return \strval($user['user_id']) ?? ''; + return \strval($user['user_id']); } /** diff --git a/src/Appwrite/Auth/OAuth2/Zoom.php b/src/Appwrite/Auth/OAuth2/Zoom.php index 9dad22212a..a4967741a9 100644 --- a/src/Appwrite/Auth/OAuth2/Zoom.php +++ b/src/Appwrite/Auth/OAuth2/Zoom.php @@ -11,11 +11,6 @@ class Zoom extends OAuth2 */ private string $endpoint = 'https://zoom.us'; - /** - * @var string - */ - private string $version = '2022-03-26'; - /** * @var array */ diff --git a/src/Appwrite/Auth/Validator/PersonalData.php b/src/Appwrite/Auth/Validator/PersonalData.php index 3b09839bd1..b047e5dd2f 100644 --- a/src/Appwrite/Auth/Validator/PersonalData.php +++ b/src/Appwrite/Auth/Validator/PersonalData.php @@ -59,7 +59,7 @@ class PersonalData extends Password return false; } - if ($this->email && strpos($password, explode('@', $this->email)[0] ?? '') !== false) { + if ($this->email && strpos($password, explode('@', $this->email)[0]) !== false) { return false; } diff --git a/src/Appwrite/Bus/Listeners/Mails.php b/src/Appwrite/Bus/Listeners/Mails.php index 3d31101d2b..9b3d68519f 100644 --- a/src/Appwrite/Bus/Listeners/Mails.php +++ b/src/Appwrite/Bus/Listeners/Mails.php @@ -133,7 +133,8 @@ class Mails extends Listener ->setSmtpUsername($smtp['username'] ?? '') ->setSmtpPassword($smtp['password'] ?? '') ->setSmtpSecure($smtp['secure'] ?? '') - ->setSmtpReplyTo($customTemplate['replyTo'] ?? $smtp['replyTo'] ?? '') + ->setSmtpReplyToEmail($customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? '') // Includes backwards compatibility + ->setSmtpReplyToName($customTemplate['replyToName'] ?? $smtp['replyToName'] ?? '') ->setSmtpSenderEmail($customTemplate['senderEmail'] ?? $smtp['senderEmail'] ?? System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM)) ->setSmtpSenderName($customTemplate['senderName'] ?? $smtp['senderName'] ?? System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server')); } diff --git a/src/Appwrite/Docker/Compose/Service.php b/src/Appwrite/Docker/Compose/Service.php index 87699aaeba..e7993d6927 100644 --- a/src/Appwrite/Docker/Compose/Service.php +++ b/src/Appwrite/Docker/Compose/Service.php @@ -21,7 +21,7 @@ class Service array_walk($ports, function (&$value, $key) { $split = explode(':', $value); $this->service['ports'][ - (isset($split[0])) ? $split[0] : '' + $split[0] ] = (isset($split[1])) ? $split[1] : ''; }); diff --git a/src/Appwrite/Docker/Env.php b/src/Appwrite/Docker/Env.php index af5e4f11e2..7e44a6c5cf 100644 --- a/src/Appwrite/Docker/Env.php +++ b/src/Appwrite/Docker/Env.php @@ -15,7 +15,7 @@ class Env foreach ($data as &$row) { $row = explode('=', $row, 2); - $key = (isset($row[0])) ? trim($row[0]) : null; + $key = trim($row[0]); $value = (isset($row[1])) ? (function (string $v): string { $v = trim($v); if ( diff --git a/src/Appwrite/Event/Event.php b/src/Appwrite/Event/Event.php index fae2d0e843..357442a07c 100644 --- a/src/Appwrite/Event/Event.php +++ b/src/Appwrite/Event/Event.php @@ -459,7 +459,7 @@ class Event /** * Identify all sections of the pattern. */ - $type = $parts[0] ?? false; + $type = $parts[0]; $resource = $parts[1] ?? false; $hasSubResource = $count > 3 && \str_starts_with($parts[3], '['); $hasSubSubResource = $count > 5 && \str_starts_with($parts[5], '[') && $hasSubResource; diff --git a/src/Appwrite/Event/Mail.php b/src/Appwrite/Event/Mail.php index d8f25489c6..0685586c60 100644 --- a/src/Appwrite/Event/Mail.php +++ b/src/Appwrite/Event/Mail.php @@ -251,14 +251,26 @@ class Mail extends Event } /** - * Set SMTP reply to + * Set SMTP reply-to email * - * @param string $replyTo + * @param string $email * @return self */ - public function setSmtpReplyTo(string $replyTo): self + public function setSmtpReplyToEmail(string $email): self { - $this->smtp['replyTo'] = $replyTo; + $this->smtp['replyToEmail'] = $email; + return $this; + } + + /** + * Set SMTP reply-to name + * + * @param string $name + * @return self + */ + public function setSmtpReplyToName(string $name): self + { + $this->smtp['replyToName'] = $name; return $this; } @@ -333,13 +345,23 @@ class Mail extends Event } /** - * Get SMTP reply to + * Get SMTP reply-to email * * @return string */ - public function getSmtpReplyTo(): string + public function getSmtpReplyToEmail(): string { - return $this->smtp['replyTo'] ?? ''; + return $this->smtp['replyToEmail'] ?? ''; + } + + /** + * Get SMTP reply-to name + * + * @return string + */ + public function getSmtpReplyToName(): string + { + return $this->smtp['replyToName'] ?? ''; } /** diff --git a/src/Appwrite/Event/Validator/Event.php b/src/Appwrite/Event/Validator/Event.php index a3605e4df5..7a4f4fbcf8 100644 --- a/src/Appwrite/Event/Validator/Event.php +++ b/src/Appwrite/Event/Validator/Event.php @@ -44,7 +44,7 @@ class Event extends Validator /** * Identify all sections of the pattern. */ - $type = $parts[0] ?? false; + $type = $parts[0]; $resource = $parts[1] ?? false; $hasSubResource = $count > 3 && ($events[$type]['$resource'] ?? false) && ($events[$type][$parts[2]]['$resource'] ?? false); $hasSubSubResource = $count > 5 && $hasSubResource && ($events[$type][$parts[2]][$parts[4]]['$resource'] ?? false); @@ -61,9 +61,6 @@ class Event extends Validator if ($hasSubSubResource) { $subSubType = $parts[4]; $subSubResource = $parts[5]; - if ($count === 8) { - $attribute = $parts[7]; - } } if ($hasSubResource && !$hasSubSubResource) { diff --git a/src/Appwrite/Event/Webhook.php b/src/Appwrite/Event/Webhook.php index f6d16c8b14..5cd773a18f 100644 --- a/src/Appwrite/Event/Webhook.php +++ b/src/Appwrite/Event/Webhook.php @@ -24,7 +24,7 @@ class Webhook extends Event public function trimPayload(): array { $trimmed = parent::trimPayload(); - if (isset($this->context)) { + if (!empty($this->context)) { $trimmed['context'] = []; } return $trimmed; diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 58a21b5517..6fc3e88635 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -384,6 +384,11 @@ class Exception extends \Exception public const string MESSAGE_TARGET_NOT_PUSH = 'message_target_not_push'; public const string MESSAGE_MISSING_SCHEDULE = 'message_missing_schedule'; + /** Mocks */ + public const string MOCK_NUMBER_ALREADY_EXISTS = 'mock_number_already_exists'; + public const string MOCK_NUMBER_NOT_FOUND = 'mock_number_not_found'; + public const string MOCK_NUMBER_LIMIT_EXCEEDED = 'mock_number_limit_exceeded'; + /** Targets */ public const string TARGET_PROVIDER_INVALID_TYPE = 'target_provider_invalid_type'; diff --git a/src/Appwrite/GraphQL/Types/Mapper.php b/src/Appwrite/GraphQL/Types/Mapper.php index 53474b855a..55810fd74e 100644 --- a/src/Appwrite/GraphQL/Types/Mapper.php +++ b/src/Appwrite/GraphQL/Types/Mapper.php @@ -91,26 +91,20 @@ class Mapper } } - $responses = $method->getResponses() ?? []; + $responses = $method->getResponses(); - // If responses is an array, map each response to its model - if (\is_array($responses)) { - $models = []; - foreach ($responses as $response) { - $modelName = $response->getModel(); + // Map each response to its model + $models = []; + foreach ($responses as $response) { + $modelName = $response->getModel(); - if (\is_array($modelName)) { - foreach ($modelName as $name) { - $models[] = self::$models[$name]; - } - } else { - $models[] = self::$models[$modelName]; + if (\is_array($modelName)) { + foreach ($modelName as $name) { + $models[] = self::$models[$name]; } + } else { + $models[] = self::$models[$modelName]; } - } else { - // If single response, get its model and wrap in array - $modelName = $responses->getModel(); - $models = [self::$models[$modelName]]; } foreach ($models as $model) { diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index f1d806bcc5..8fe7342ec2 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -114,14 +114,24 @@ class Realtime extends MessagingAdapter } } - // Keep userId from onOpen/authentication when provided. - // Fallback to existing stored value for subsequent subscribe upserts. - $this->connections[$identifier] = [ + // Union channels/roles across all subscriptions on the connection; overwriting would + // leave getSubscriptionMetadata and full unsubscribe operating on stale state. + $existing = $this->connections[$identifier] ?? []; + $existingChannels = $existing['channels'] ?? []; + $existingRoles = $existing['roles'] ?? []; + + $entry = [ 'projectId' => $projectId, - 'roles' => $roles, - 'userId' => $userId ?? ($this->connections[$identifier]['userId'] ?? ''), - 'channels' => $channels + 'roles' => \array_values(\array_unique(\array_merge($existingRoles, $roles))), + 'userId' => $userId ?? ($existing['userId'] ?? ''), + 'channels' => \array_values(\array_unique(\array_merge($existingChannels, $channels))), ]; + + if (\array_key_exists('authorization', $existing)) { + $entry['authorization'] = $existing['authorization']; + } + + $this->connections[$identifier] = $entry; } /** @@ -206,6 +216,87 @@ class Realtime extends MessagingAdapter } } + /** + * Removes a single subscription from a connection, keeping the connection alive so + * the client can resubscribe. Idempotent — returns true only when something was removed. + * + * @param mixed $connection + * @param string $subscriptionId + * @return bool + */ + public function unsubscribeSubscription(mixed $connection, string $subscriptionId): bool + { + $projectId = $this->connections[$connection]['projectId'] ?? ''; + if ($projectId === '' || !isset($this->subscriptions[$projectId])) { + return false; + } + + $removed = false; + + foreach ($this->subscriptions[$projectId] as $role => $byChannel) { + foreach ($byChannel as $channel => $byConnection) { + if (!isset($byConnection[$connection][$subscriptionId])) { + continue; + } + + unset($this->subscriptions[$projectId][$role][$channel][$connection][$subscriptionId]); + $removed = true; + + if (empty($this->subscriptions[$projectId][$role][$channel][$connection])) { + unset($this->subscriptions[$projectId][$role][$channel][$connection]); + } + if (empty($this->subscriptions[$projectId][$role][$channel])) { + unset($this->subscriptions[$projectId][$role][$channel]); + } + } + if (empty($this->subscriptions[$projectId][$role])) { + unset($this->subscriptions[$projectId][$role]); + } + } + + if (empty($this->subscriptions[$projectId])) { + unset($this->subscriptions[$projectId]); + } + + if ($removed) { + $this->recomputeConnectionState($connection); + } + + return $removed; + } + + /** + * Recomputes the cached channels on the connection entry from the subscriptions tree. + * Called after per-subscription removal so stale channel entries do not linger for later reads. + * + * Roles are deliberately NOT recomputed here. They represent the connection's authorization + * context (set at onOpen, replaced on `authentication` / permission-change) and must survive + * per-subscription removal — otherwise a client that unsubscribes every subscription and then + * resubscribes would subscribe with an empty roles array and silently receive nothing. + * + * @param mixed $connection + * @return void + */ + private function recomputeConnectionState(mixed $connection): void + { + if (!isset($this->connections[$connection])) { + return; + } + + $projectId = $this->connections[$connection]['projectId'] ?? ''; + $channels = []; + + foreach ($this->subscriptions[$projectId] ?? [] as $byChannel) { + foreach ($byChannel as $channel => $byConnection) { + if (isset($byConnection[$connection])) { + $channels[$channel] = true; + } + } + } + + $this->connections[$connection]['channels'] = \array_keys($channels); + } + /** * Checks if Channel has a subscriber. * @param string $projectId @@ -361,6 +452,7 @@ class Realtime extends MessagingAdapter * Reserved channel params with expected type * If matched the expected type then skip the query parsing like in project */ + /** @var array $reservedParamExpectedTypes */ $reservedParamExpectedTypes = [ 'project' => 'string', ]; @@ -374,7 +466,6 @@ class Realtime extends MessagingAdapter $isExpectedType = match ($expectedType) { 'array' => \is_array($params), 'string' => \is_string($params), - default => false, }; // If the value matches the expected type dont use it the queries diff --git a/src/Appwrite/Migration/Migration.php b/src/Appwrite/Migration/Migration.php index a01031de9b..ef0dd9f8b5 100644 --- a/src/Appwrite/Migration/Migration.php +++ b/src/Appwrite/Migration/Migration.php @@ -94,6 +94,7 @@ abstract class Migration '1.8.1' => 'V23', '1.9.0' => 'V24', '1.9.1' => 'V24', + '1.9.2' => 'V24', ]; /** diff --git a/src/Appwrite/Migration/Version/V17.php b/src/Appwrite/Migration/Version/V17.php index 3297206ccd..862ab7f26c 100644 --- a/src/Appwrite/Migration/Version/V17.php +++ b/src/Appwrite/Migration/Version/V17.php @@ -262,7 +262,7 @@ class V17 extends Migration * Set default maxSessions */ $document->setAttribute('auths', array_merge($document->getAttribute('auths', []), [ - 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT + 'maxSessions' => 10 ])); break; case 'users': diff --git a/src/Appwrite/OpenSSL/OpenSSL.php b/src/Appwrite/OpenSSL/OpenSSL.php index 787feb0904..89c52f069e 100644 --- a/src/Appwrite/OpenSSL/OpenSSL.php +++ b/src/Appwrite/OpenSSL/OpenSSL.php @@ -16,7 +16,7 @@ class OpenSSL * @param string $aad * @param int $tag_length * - * @return string + * @return string|false */ public static function encrypt($data, $method, $key, $options = 0, $iv = '', ?string &$tag = null, $aad = '', $tag_length = 16) { diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Install.php b/src/Appwrite/Platform/Installer/Http/Installer/Install.php index 8aaaf621bb..e7e9008e3b 100644 --- a/src/Appwrite/Platform/Installer/Http/Installer/Install.php +++ b/src/Appwrite/Platform/Installer/Http/Installer/Install.php @@ -240,9 +240,7 @@ class Install extends Action $inputValue = trim($inputValue); } if ($storedValue !== $inputValue) { - if ($installId !== '') { - $state->updateGlobalLock($installId, Server::STATUS_ERROR); - } + $state->updateGlobalLock($installId, Server::STATUS_ERROR); $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Installation payload mismatch'); return; } @@ -262,16 +260,12 @@ class Install extends Action $incomingHash = $state->hashSensitiveValue($incomingValue); if (isset($stored[$hashField])) { if (!hash_equals((string) $stored[$hashField], $incomingHash)) { - if ($installId !== '') { - $state->updateGlobalLock($installId, Server::STATUS_ERROR); - } + $state->updateGlobalLock($installId, Server::STATUS_ERROR); $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Installation payload mismatch'); return; } } elseif (isset($stored[$field]) && $incomingValue !== '' && (string) $stored[$field] !== $incomingValue) { - if ($installId !== '') { - $state->updateGlobalLock($installId, Server::STATUS_ERROR); - } + $state->updateGlobalLock($installId, Server::STATUS_ERROR); $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Installation payload mismatch'); return; } @@ -430,7 +424,7 @@ class Install extends Action private function deriveNameFromEmail(string $email): string { $parts = explode('@', $email); - $username = $parts[0] ?? ''; + $username = $parts[0]; $cleaned = preg_replace('/[^a-zA-Z0-9]/', '', $username); return ucfirst($cleaned); } diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Status.php b/src/Appwrite/Platform/Installer/Http/Installer/Status.php index d6ffa64c8f..204ace077c 100644 --- a/src/Appwrite/Platform/Installer/Http/Installer/Status.php +++ b/src/Appwrite/Platform/Installer/Http/Installer/Status.php @@ -45,7 +45,7 @@ class Status extends Action } $data = $state->readProgressFile($installId); - if (is_array($data) && isset($data['payload']) && is_array($data['payload'])) { + if (isset($data['payload']) && is_array($data['payload'])) { unset( $data['payload']['opensslKey'], $data['payload']['assistantOpenAIKey'], @@ -54,7 +54,7 @@ class Status extends Action ); } // Strip sensitive data from step details - if (is_array($data) && isset($data['details']) && is_array($data['details'])) { + if (isset($data['details']) && is_array($data['details'])) { foreach ($data['details'] as $stepKey => &$stepDetails) { if (is_array($stepDetails)) { unset($stepDetails['sessionSecret'], $stepDetails['trace']); diff --git a/src/Appwrite/Platform/Installer/Runtime/Config.php b/src/Appwrite/Platform/Installer/Runtime/Config.php index 99db12dfed..6142e47152 100644 --- a/src/Appwrite/Platform/Installer/Runtime/Config.php +++ b/src/Appwrite/Platform/Installer/Runtime/Config.php @@ -218,7 +218,7 @@ final class Config } /** - * @param string[] $value + * @param array $value */ public function setEnabledDatabases(array $value): void { diff --git a/src/Appwrite/Platform/Installer/Runtime/State.php b/src/Appwrite/Platform/Installer/Runtime/State.php index 75efd7027c..3cbcc51fa6 100644 --- a/src/Appwrite/Platform/Installer/Runtime/State.php +++ b/src/Appwrite/Platform/Installer/Runtime/State.php @@ -19,13 +19,11 @@ class State private const int PORT_MIN = 1; private const int PORT_MAX = 65535; - private array $paths; private bool $bootstrapped = false; private int $lastStaleLockClearAt = 0; - public function __construct(array $paths) + public function __construct() { - $this->paths = $paths; } public function buildConfig(array $overrides = [], bool $useEnv = true): Config @@ -180,7 +178,7 @@ class State if (!preg_match(self::PATTERN_IPV6_WITH_PORT, $value, $matches)) { return false; } - $host = $matches[1] ?? ''; + $host = $matches[1]; $port = $matches[2] ?? null; } else { $parts = explode(':', $value); diff --git a/src/Appwrite/Platform/Installer/Server.php b/src/Appwrite/Platform/Installer/Server.php index 99ec9e65d2..38d61b7d24 100644 --- a/src/Appwrite/Platform/Installer/Server.php +++ b/src/Appwrite/Platform/Installer/Server.php @@ -60,7 +60,7 @@ class Server { $this->initPaths(); - $this->state = new State($this->paths); + $this->state = new State(); if (PHP_SAPI === 'cli') { $this->runCli(); diff --git a/src/Appwrite/Platform/Installer/Validator/AppDomain.php b/src/Appwrite/Platform/Installer/Validator/AppDomain.php index f631015654..5d18b5214a 100644 --- a/src/Appwrite/Platform/Installer/Validator/AppDomain.php +++ b/src/Appwrite/Platform/Installer/Validator/AppDomain.php @@ -47,7 +47,7 @@ class AppDomain extends Validator if (!preg_match(self::PATTERN_IPV6_WITH_PORT, $value, $matches)) { return false; } - $host = $matches[1] ?? ''; + $host = $matches[1]; $port = $matches[2] ?? null; } else { $parts = explode(':', $value); diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php index 754255be15..5765c5bf6e 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php @@ -37,8 +37,8 @@ class Delete extends Action ->label('event', 'users.[userId].delete.mfa') ->label('scope', 'account') ->label('audits.event', 'user.update') - ->label('audits.resource', 'user/{response.$id}') - ->label('audits.userId', '{response.$id}') + ->label('audits.resource', 'user/{user.$id}') + ->label('audits.userId', '{user.$id}') ->label('sdk', [ new Method( namespace: 'account', diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php index 14dc4e3237..7bcc78e974 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php @@ -250,7 +250,8 @@ class Create extends Action $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyTo = ""; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (!empty($smtp['senderEmail'])) { @@ -259,8 +260,13 @@ class Create extends Action if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + if (!empty($smtpReplyToEmail)) { + $replyToEmail = $smtpReplyToEmail; + } + if (!empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -277,8 +283,13 @@ class Create extends Action if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? ''; + if (!empty($customReplyToEmail)) { + $replyToEmail = $customReplyToEmail; + } + if (!empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -286,7 +297,8 @@ class Create extends Action } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Front/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Front/Get.php index f8e7a35b05..d0c600192b 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Front/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Front/Get.php @@ -86,10 +86,10 @@ class Get extends Action } if (!$isEmployee && !empty($githubName)) { - $employeeGitHub = \array_search(\strtolower($githubName), \array_map(fn ($employee) => \strtolower($employee['gitHub']) ?? '', $employees)); + $employeeGitHub = \array_search(\strtolower($githubName), \array_map(fn ($employee) => \strtolower($employee['gitHub'] ?? ''), $employees)); if (!empty($employeeGitHub)) { $isEmployee = true; - $employeeNumber = $isEmployee ? $employees[$employeeGitHub]['spot'] : ''; + $employeeNumber = $employees[$employeeGitHub]['spot']; $createdAt = new \DateTime($employees[$employeeGitHub]['memberSince'] ?? ''); } } diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/OG/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/OG/Get.php index 37776a3466..ad74d6c192 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/OG/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/OG/Get.php @@ -90,10 +90,10 @@ class Get extends Action } if (!$isEmployee && !empty($githubName)) { - $employeeGitHub = \array_search(\strtolower($githubName), \array_map(fn ($employee) => \strtolower($employee['gitHub']) ?? '', $employees)); + $employeeGitHub = \array_search(\strtolower($githubName), \array_map(fn ($employee) => \strtolower($employee['gitHub'] ?? ''), $employees)); if (!empty($employeeGitHub)) { $isEmployee = true; - $employeeNumber = $isEmployee ? $employees[$employeeGitHub]['spot'] : ''; + $employeeNumber = $employees[$employeeGitHub]['spot']; $createdAt = new \DateTime($employees[$employeeGitHub]['memberSince'] ?? ''); } } diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php index b6cc408dde..a41d0f81da 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php @@ -98,7 +98,7 @@ class Get extends Action $doc->strictErrorChecking = false; @$doc->loadHTML($res->getBody()); - $links = $doc->getElementsByTagName('link') ?? []; + $links = $doc->getElementsByTagName('link'); $outputHref = ''; $outputExt = ''; $space = 0; @@ -128,7 +128,7 @@ class Get extends Action case 'jpeg': $size = \explode('x', \strtolower($sizes)); - $sizeWidth = (int) ($size[0] ?? 0); + $sizeWidth = (int) $size[0]; $sizeHeight = (int) ($size[1] ?? 0); if (($sizeWidth * $sizeHeight) >= $space) { diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/QR/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/QR/Get.php index 27fd8708d9..f3448f5264 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/QR/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/QR/Get.php @@ -60,7 +60,6 @@ class Get extends Action public function action(string $text, int $size, int $margin, bool $download, Response $response) { - $download = ($download === '1' || $download === 'true' || $download === 1 || $download === true); $options = new QROptions([ 'addQuietzone' => true, 'quietzoneSize' => $margin, diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php index 2df12b17d1..c43c0fc4bf 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php @@ -105,7 +105,7 @@ class Get extends Action $client->addHeader('content-type', Client::CONTENT_TYPE_APPLICATION_JSON); // Convert indexed array to empty array (should not happen due to Assoc validator) - if (is_array($headers) && count($headers) > 0 && array_keys($headers) === range(0, count($headers) - 1)) { + if (count($headers) > 0 && array_keys($headers) === range(0, count($headers) - 1)) { $headers = []; } diff --git a/src/Appwrite/Platform/Modules/Compute/Base.php b/src/Appwrite/Platform/Modules/Compute/Base.php index f388e46f83..85dfec3cfd 100644 --- a/src/Appwrite/Platform/Modules/Compute/Base.php +++ b/src/Appwrite/Platform/Modules/Compute/Base.php @@ -68,7 +68,7 @@ class Base extends Action $owner = $github->getOwnerName($providerInstallationId); $providerRepositoryId = $function->getAttribute('providerRepositoryId', ''); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $github->getRepositoryName($providerRepositoryId); if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } @@ -169,7 +169,7 @@ class Base extends Action $owner = $github->getOwnerName($providerInstallationId); $providerRepositoryId = $site->getAttribute('providerRepositoryId', ''); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $github->getRepositoryName($providerRepositoryId); if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php index 4afab449c0..1f730fa543 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php @@ -12,7 +12,7 @@ abstract class Action extends DatabasesAction /** * The current API context (either 'table' or 'collection'). */ - private ?string $context = COLLECTIONS; + private string $context = COLLECTIONS; /** * Get the response model used in the SDK and HTTP responses. diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Action.php index 0d562a2894..1606c7ab40 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Action.php @@ -26,9 +26,9 @@ use Utopia\Validator\Range; abstract class Action extends UtopiaAction { /** - * @var string|null The current context (either 'column' or 'attribute') + * @var string The current context (either 'column' or 'attribute') */ - private ?string $context = ATTRIBUTES; + private string $context = ATTRIBUTES; /** * Get the correct response model. diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php index 91dd9c603c..8100a2c51b 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php @@ -14,10 +14,10 @@ use Utopia\Database\Validator\Authorization; abstract class Action extends DatabasesAction { /** - * @var string|null The current context (either 'row' or 'document') + * @var string The current context (either 'row' or 'document') */ - private ?string $context = DOCUMENTS; - private ?string $databaseType = DATABASE_TYPE_LEGACY; + private string $context = DOCUMENTS; + private string $databaseType = DATABASE_TYPE_LEGACY; /** * Get the response model used in the SDK and HTTP responses. diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php index 24cba578a9..633a2bbc86 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php @@ -293,16 +293,6 @@ class Create extends Action throw new Exception(Exception::USER_UNAUTHORIZED, $authorization->getDescription()); } - if ($permission === Database::PERMISSION_UPDATE) { - $validDocument = $authorization->isValid( - new Input($permission, $document->getUpdate()) - ); - $valid = $validCollection || $validDocument; - if ($documentSecurity && !$valid) { - throw new Exception(Exception::USER_UNAUTHORIZED, $authorization->getDescription()); - } - } - $relationships = \array_filter( $collection->getAttribute('attributes', []), fn ($attribute) => $attribute->getAttribute('type') === Database::VAR_RELATIONSHIP diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php index b48df136ee..06f0e9cf1c 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php @@ -100,7 +100,7 @@ class Get extends Action } try { - $selects = Query::groupByType($queries)['selections'] ?? []; + $selects = Query::groupByType($queries)['selections']; $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php index ef89b80e97..fb3d414097 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php @@ -353,12 +353,7 @@ class Upsert extends Action $collectionsCache = []; if (empty($upserted[0])) { - if ($transactionId !== null) { - // For transactions, get the document with transaction changes applied - $upserted[0] = $transactionState->getDocument($database, $collectionTableId, $documentId, $transactionId); - } else { - $upserted[0] = $dbForDatabases->getDocument($collectionTableId, $documentId); - } + $upserted[0] = $dbForDatabases->getDocument($collectionTableId, $documentId); } $document = $upserted[0]; diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index aeee280615..3a49d6c665 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -22,6 +22,7 @@ use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\UID; use Utopia\Http\Adapter\Swoole\Response as SwooleResponse; +use Utopia\Http\Http; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; use Utopia\Validator\Nullable; @@ -80,10 +81,11 @@ class XList extends Action ->inject('usage') ->inject('transactionState') ->inject('authorization') + ->inject('utopia') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, User $user, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, User $user, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization, ?Http $utopia = null): void { $isAPIKey = $user->isApp($authorization->getRoles()); $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); @@ -126,8 +128,10 @@ class XList extends Action $cursor->setValue($cursorDocument); } + $dbStart = \microtime(true); + try { - $hasSelects = ! empty(Query::groupByType($queries)['selections'] ?? []); + $hasSelects = ! empty(Query::groupByType($queries)['selections']); $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); // When there are no select queries, relationship loading is skipped on the // underlying find() to avoid pulling related documents the caller did not ask for. @@ -178,7 +182,7 @@ class XList extends Action $cachedTotal = null; } if ($cachedTotal !== null && $cachedTotal !== false) { - $total = $cachedTotal; + $total = (int) $cachedTotal; } else { $total = $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT); try { @@ -206,6 +210,8 @@ class XList extends Action throw new Exception(Exception::DATABASE_TIMEOUT); } + $dbDurationMs = (\microtime(true) - $dbStart) * 1000; + $operations = 0; $collectionsCache = []; foreach ($documents as $document) { @@ -229,5 +235,20 @@ class XList extends Action // rows or documents $this->getSDKGroup() => $documents, ]), $this->getResponseModel()); + + try { + $this->afterQuery($dbDurationMs, $database, $collection, $queries, $utopia); + } catch (\Throwable) { + // Observers must never break the response. + } + } + + /** + * After query hook. + * + * @param array $queries + */ + protected function afterQuery(float $dbDurationMs, Document $database, Document $collection, array $queries, ?Http $utopia): void + { } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php index 400d716e41..251e493cb6 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php @@ -10,7 +10,7 @@ abstract class Action extends UtopiaAction /** * The current API context (either 'columnIndex' or 'index'). */ - private ?string $context = INDEX; + private string $context = INDEX; /** * Get the response model used in the SDK and HTTP responses. diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php index 37213f1061..bea367af36 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php @@ -119,6 +119,7 @@ class Get extends Action $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new \LogicException('Unexpected period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php index 1ed7e6a63f..a13c6c4903 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Logs; +use Appwrite\Detector\Detector; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -9,7 +10,6 @@ use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response as UtopiaResponse; -use DeviceDetector\DeviceDetector as Detector; use MaxMind\Db\Reader; use Utopia\Audit\Audit; use Utopia\Database\Database; @@ -103,9 +103,9 @@ class XList extends Action $os = $detector->getOS(); $client = $detector->getClient(); $device = $detector->getDevice(); - $deviceName = \is_array($device) ? ($device['deviceName'] ?? '') : ''; - $deviceBrand = \is_array($device) ? ($device['deviceBrand'] ?? '') : ''; - $deviceModel = \is_array($device) ? ($device['deviceModel'] ?? '') : ''; + $deviceName = $device['deviceName'] ?? ''; + $deviceBrand = $device['deviceBrand'] ?? ''; + $deviceModel = $device['deviceModel'] ?? ''; $output[$i] = new Document([ 'event' => $log['event'], diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php index 91bc1a3ccf..ccf9632fef 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php @@ -9,8 +9,8 @@ abstract class Action extends DatabasesAction /** * The current API context (either 'table' or 'collection'). */ - private ?string $context = COLLECTIONS; - private ?string $databaseType = LEGACY; + private string $context = COLLECTIONS; + private string $databaseType = LEGACY; public function getDatabaseType(): string { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php index 18e6fd7a8b..240e7d400c 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php @@ -144,6 +144,7 @@ class Get extends Action $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new \LogicException('Unexpected period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php index b8cb774a3e..db73954e7f 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php @@ -133,6 +133,7 @@ class XList extends Action $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new \LogicException('Unexpected period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php index 9e0d0b10d9..51c0d67e8a 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php @@ -63,6 +63,7 @@ class XList extends DocumentXList ->inject('usage') ->inject('transactionState') ->inject('authorization') + ->inject('utopia') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php index 81822df208..ccb421b36d 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php @@ -2,13 +2,13 @@ namespace Appwrite\Platform\Modules\Databases\Http\TablesDB\Logs; +use Appwrite\Detector\Detector; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response as UtopiaResponse; -use DeviceDetector\DeviceDetector as Detector; use MaxMind\Db\Reader; use Utopia\Audit\Audit; use Utopia\Database\Database; @@ -97,9 +97,9 @@ class XList extends Action $os = $detector->getOS(); $client = $detector->getClient(); $device = $detector->getDevice(); - $deviceName = \is_array($device) ? ($device['deviceName'] ?? '') : ''; - $deviceBrand = \is_array($device) ? ($device['deviceBrand'] ?? '') : ''; - $deviceModel = \is_array($device) ? ($device['deviceModel'] ?? '') : ''; + $deviceName = $device['deviceName'] ?? ''; + $deviceBrand = $device['deviceBrand'] ?? ''; + $deviceModel = $device['deviceModel'] ?? ''; $output[$i] = new Document([ 'event' => $log['event'], diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php index 91c62aea05..87e276719e 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php @@ -65,6 +65,7 @@ class XList extends DocumentXList ->inject('usage') ->inject('transactionState') ->inject('authorization') + ->inject('utopia') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php index d9b378774b..8a7137e38b 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php @@ -98,7 +98,7 @@ class Create extends CreateDocumentAction $error = ''; try { $embedResult = $embeddingAgent->embed($text); - $embedding = $embedResult['embedding'] ?? []; + $embedding = $embedResult['embedding']; $totalDuration += $embedResult['totalDuration'] ?? 0; $totalTokens += $embedResult['tokensProcessed'] ?? 0; } catch (\Exception $e) { diff --git a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php index a50e8f8bdf..39902aea53 100644 --- a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php +++ b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php @@ -54,7 +54,7 @@ class Databases extends Action */ public function action(Message $message, Document $project, Database $dbForPlatform, Database $dbForProject, callable $getDatabasesDB, Realtime $queueForRealtime, Log $log): void { - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new Exception('Missing payload'); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php index 65b6ffd5bb..11736c8ca5 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php @@ -206,6 +206,12 @@ class Create extends Action if ($chunk === -1) { $chunk = $chunks; } + } else { + // Guard against manually setting range header for single chunk upload + if ($chunks === -1) { + $chunks = 1; + $chunk = 1; + } } $chunksUploaded = $deviceForFunctions->upload($fileTmpName, $path, $chunk, $chunks, $metadata); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/XList.php index fef0708931..e8e9ea9a18 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/XList.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/XList.php @@ -116,7 +116,7 @@ class XList extends Base $grouped = Query::groupByType($queries); $filterQueries = $grouped['filters']; - $selectQueries = $grouped['selections'] ?? []; + $selectQueries = $grouped['selections']; try { $results = $dbForProject->find('deployments', $queries); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index 72474b03f9..5b2f4ff297 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -145,21 +145,8 @@ class Create extends Base } } - /** - * @var array $headers - */ - $assocParams = ['headers']; - foreach ($assocParams as $assocParam) { - if (!empty('headers') && !is_array($$assocParam)) { - $$assocParam = \json_decode($$assocParam, true); - } - } - - $booleanParams = ['async']; - foreach ($booleanParams as $booleamParam) { - if (!empty($$booleamParam) && !is_bool($$booleamParam)) { - $$booleamParam = $$booleamParam === "true" ? true : false; - } + if (!is_array($headers)) { + $headers = \json_decode($headers, true); } // 'headers' validator @@ -370,10 +357,10 @@ class Create extends Base // V2 vars if ($version === 'v2') { $vars = \array_merge($vars, [ - 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'] ?? '', + 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'], 'APPWRITE_FUNCTION_DATA' => $body, - 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'] ?? '', - 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ?? '' + 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'], + 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ]); } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php index 8d4ad5d403..7b294f3f90 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php @@ -375,7 +375,7 @@ class Create extends Base } $functionsDomain = $platform['functionsDomain']; - if (!empty($functionsDomain)) { + if (!empty($functionsDomain) && isset($deployment) && !$deployment->isEmpty()) { $routeSubdomain = ID::unique(); $domain = "{$routeSubdomain}.{$functionsDomain}"; // TODO: (@Meldiron) Remove after 1.7.x migration @@ -391,8 +391,8 @@ class Create extends Base 'status' => 'verified', 'type' => 'deployment', 'trigger' => 'manual', - 'deploymentId' => !isset($deployment) || $deployment->isEmpty() ? '' : $deployment->getId(), - 'deploymentInternalId' => !isset($deployment) || $deployment->isEmpty() ? '' : $deployment->getSequence(), + 'deploymentId' => $deployment->getId(), + 'deploymentInternalId' => $deployment->getSequence(), 'deploymentResourceType' => 'function', 'deploymentResourceId' => $function->getId(), 'deploymentResourceInternalId' => $function->getSequence(), diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php index 71fc99a30e..7d6572d336 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php @@ -162,10 +162,6 @@ class Update extends Base throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'When connecting to VCS (Version Control System), you need to provide "installationId" and "providerBranch".'); } - if ($function->isEmpty()) { - throw new Exception(Exception::FUNCTION_NOT_FOUND); - } - if (empty($runtime)) { $runtime = $function->getAttribute('runtime'); } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Usage/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Usage/Get.php index 19476329bf..7016d600cb 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Usage/Get.php @@ -112,6 +112,7 @@ class Get extends Base $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period "' . $days['period'] . '".'), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Usage/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Usage/XList.php index 38a95d4469..70b7b8e058 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Usage/XList.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Usage/XList.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Functions\Http\Usage; +use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -104,6 +105,7 @@ class XList extends Base $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period "' . $days['period'] . '".'), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php index 5648596826..f6d77c2a0d 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php @@ -77,11 +77,7 @@ class Delete extends Base } $variable = $dbForProject->getDocument('variables', $variableId); - if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getSequence() || $variable->getAttribute('resourceType') !== 'function') { - throw new Exception(Exception::VARIABLE_NOT_FOUND); - } - - if ($variable === false || $variable->isEmpty()) { + if ($variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getSequence() || $variable->getAttribute('resourceType') !== 'function') { throw new Exception(Exception::VARIABLE_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Get.php index 19c345fbc2..13ce73e751 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Get.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Get.php @@ -66,7 +66,6 @@ class Get extends Base $variable = $dbForProject->getDocument('variables', $variableId); if ( - $variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getSequence() || $variable->getAttribute('resourceType') !== 'function' @@ -74,10 +73,6 @@ class Get extends Base throw new Exception(Exception::VARIABLE_NOT_FOUND); } - if ($variable === false || $variable->isEmpty()) { - throw new Exception(Exception::VARIABLE_NOT_FOUND); - } - $response->dynamic($variable, Response::MODEL_VARIABLE); } } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php index acb066ca9c..54d7a647a3 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php @@ -85,7 +85,7 @@ class Update extends Base } $variable = $dbForProject->getDocument('variables', $variableId); - if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getSequence() || $variable->getAttribute('resourceType') !== 'function') { + if ($variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getSequence() || $variable->getAttribute('resourceType') !== 'function') { throw new Exception(Exception::VARIABLE_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index 87e936a965..286f1c55ee 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -102,7 +102,7 @@ class Builds extends Action ): void { Console::log('Build action started'); - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new \Exception('Missing payload'); @@ -206,7 +206,7 @@ class Builds extends Action throw new \Exception('Resource not found'); } - if ($isResourceBlocked($project, $resourceKey === 'functions' ? RESOURCE_TYPE_FUNCTIONS : RESOURCE_TYPE_SITES, $resource->getId())) { + if ($isResourceBlocked($project, $resource->getCollection() === 'functions' ? RESOURCE_TYPE_FUNCTIONS : RESOURCE_TYPE_SITES, $resource->getId())) { throw new \Exception('Resource is blocked'); } @@ -226,10 +226,6 @@ class Builds extends Action $spec = Config::getParam('specifications')[$resource->getAttribute('buildSpecification', APP_COMPUTE_SPECIFICATION_DEFAULT)]; - if ($resource->getCollection() === 'functions' && \is_null($runtime)) { - throw new \Exception('Runtime "' . $resource->getAttribute('runtime', '') . '" is not supported'); - } - // Realtime preparation $event = "{$resource->getCollection()}.[{$resourceKey}].deployments.[deploymentId].update"; $queueForRealtime @@ -829,7 +825,8 @@ class Builds extends Action Console::log('Runtime creation finished'); - if ($dbForProject->getDocument('deployments', $deploymentId)->getAttribute('status') === 'canceled') { + $latestDeployment = $dbForProject->getDocument('deployments', $deploymentId); + if ($latestDeployment->getAttribute('status') === 'canceled') { $this->cancelDeployment($deployment->getId(), $dbForProject, $queueForRealtime); return; @@ -1259,21 +1256,6 @@ class Builds extends Action */ protected function afterBuildSuccess(Realtime $queueForRealtime, Database $dbForProject, Document &$deployment, array $runtime, ?string $adapter): void { - if (! ($queueForRealtime instanceof Realtime)) { - throw new Exception('queueForRealtime must be an instance of Realtime'); - } - if (! ($dbForProject instanceof Database)) { - throw new Exception('dbForProject must be an instance of Database'); - } - if (! ($deployment instanceof Document)) { - throw new Exception('deployment must be an instance of Document'); - } - if (! is_array($runtime)) { - throw new Exception('runtime must be an array'); - } - if (! is_string($adapter) && ! is_null($adapter)) { - throw new Exception('adapter must be a string or null'); - } } /** @@ -1283,13 +1265,6 @@ class Builds extends Action Document $project, Document $deployment, ): void { - if (! ($project instanceof Document)) { - throw new Exception('project must be an instance of Document'); - } - - if (! ($deployment instanceof Document)) { - throw new Exception('deployment must be an instance of Document'); - } } protected function getRuntime(Document $resource, string $version): array @@ -1313,6 +1288,7 @@ class Builds extends Action return match ($resource->getCollection()) { 'functions' => $resource->getAttribute('version', 'v2'), 'sites' => 'v5', + default => throw new \Exception('Unsupported resource type "' . $resource->getCollection() . '".'), }; } @@ -1445,11 +1421,10 @@ class Builds extends Action ]); $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; - $previewUrl = match ($resource->getCollection()) { - 'functions' => '', - 'sites' => !$rule->isEmpty() ? ("{$protocol}://" . $rule->getAttribute('domain', '')) : '', - default => throw new \Exception('Invalid resource type') - }; + $previewUrl = ''; + if ($resource->getCollection() === 'sites' && !$rule->isEmpty()) { + $previewUrl = "{$protocol}://" . $rule->getAttribute('domain', ''); + } $comment = new Comment($platform); $comment->parseComment($github->getComment($owner, $repositoryName, $commentId)); diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php index 423bf0bd41..a6f1ca1b03 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php @@ -20,6 +20,8 @@ use Utopia\Platform\Action; use Utopia\Queue\Message; use Utopia\Storage\Device; use Utopia\System\System; +use Utopia\Telemetry\Adapter as Telemetry; +use Utopia\Telemetry\Counter; use function Swoole\Coroutine\batch; @@ -44,6 +46,7 @@ class Screenshots extends Action ->inject('dbForProject') ->inject('project') ->inject('deviceForFiles') + ->inject('telemetry') ->callback($this->action(...)); } @@ -53,17 +56,19 @@ class Screenshots extends Action Database $dbForPlatform, Database $dbForProject, Document $project, - Device $deviceForFiles + Device $deviceForFiles, + Telemetry $telemetry ): void { Console::log('Screenshot action started'); - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new \Exception('Missing payload'); } $screenshotMessage = Screenshot::fromArray($payload); + $counter = $telemetry->createCounter('worker.screenshots.capture'); Console::log('Site screenshot started'); @@ -162,7 +167,7 @@ class Screenshots extends Action try { $config = $configs[$key]; - $config['headers'] = \array_merge($config['headers'] ?? [], [ + $config['headers'] = \array_merge($config['headers'], [ 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey ]); $config['sleep'] = 3000; @@ -268,8 +273,24 @@ class Screenshots extends Action $date = \date('H:i:s'); $this->appendToLogs($dbForProject, $deployment->getId(), $queueForRealtime, "[$date] [appwrite] Screenshot capturing failed. Deployment will continue. \n"); + $this->recordTelemetry($counter, 'failure'); + throw $th; } + + $this->recordTelemetry($counter, 'success'); + } + + protected function recordTelemetry(Counter $counter, string $result): void + { + try { + $counter->add(1, [ + 'resourceType' => RESOURCE_TYPE_SITES, + 'result' => $result, + ]); + } catch (\Throwable) { + // Telemetry should never affect screenshot processing. + } } protected function appendToLogs(Database $dbForProject, string $deploymentId, Realtime $queueForRealtime, string $logs) diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php index 60cf5d00d4..728ffb8b71 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php @@ -82,7 +82,7 @@ class Get extends Action } $certificatePayload = @openssl_x509_parse($peerCertificate); - if ($certificatePayload === false || !\is_array($certificatePayload)) { + if ($certificatePayload === false) { throw new Exception(Exception::HEALTH_INVALID_HOST, 'Failed to parse peer certificate for ' . $domain); } diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php index 6d77cc6e16..7602de45d3 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php @@ -16,6 +16,7 @@ use Appwrite\Event\Publisher\Screenshot; use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher; use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Event\Webhook; +use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -123,6 +124,7 @@ class Get extends Base System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME) => $publisherForScreenshots, System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME) => $queueForMessaging, System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME) => $publisherForMigrations, + default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unknown queue name: ' . $name), }; $failed = $queue->getSize(failed: true); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/AuthMethods/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/AuthMethods/Update.php new file mode 100644 index 0000000000..0d1cd83203 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/AuthMethods/Update.php @@ -0,0 +1,89 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/auth-methods/:methodId') + ->httpAlias('/v1/projects/:projectId/auth/:methodId') + ->desc('Update project auth method status. Use this endpoint to enable or disable a given auth method for this project.') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'authMethod.[methodId].update') + ->label('audits.event', 'project.authMethods.[methodId].update') + ->label('audits.resource', 'project.authMethods/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: null, + name: 'updateAuthMethod', + description: <<param('methodId', '', new WhiteList(\array_keys(Config::getParam('auth')), true), 'Auth Method ID. Possible values: ' . implode(',', \array_keys(Config::getParam('auth'))), false) + ->param('enabled', null, new Boolean(), 'Auth method status.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $methodId, + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents + ): void { + $auth = Config::getParam('auth')[$methodId] ?? []; + $authKey = $auth['key'] ?? ''; + + $auths = $project->getAttribute('auths', []); + $auths[$authKey] = $enabled; + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ + 'auths' => $auths, + ]))); + + $queueForEvents->setParam('methodId', $methodId); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Delete.php new file mode 100644 index 0000000000..0a60e4ce4d --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Delete.php @@ -0,0 +1,81 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/project') + ->httpAlias('/v1/projects/:projectId') + ->desc('Delete project') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'project.delete') + ->label('audits.event', 'project.delete') + ->label('audits.resource', 'project/{project.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: null, + name: 'delete', + description: <<inject('response') + ->inject('dbForPlatform') + ->inject('queueForDeletes') + ->inject('authorization') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + Response $response, + Database $dbForPlatform, + DeleteQueue $queueForDeletes, + Authorization $authorization, + Document $project, + ) { + $queueForDeletes + ->setProject($project) + ->setType(DELETE_TYPE_DOCUMENT) + ->setDocument($project); + + if (!$authorization->skip(fn () => $dbForPlatform->deleteDocument('projects', $project->getId()))) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove project from DB'); + } + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php index 24d1c48cf1..8a3506eb13 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php @@ -9,6 +9,7 @@ use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Validator\Authorization; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\ArrayList; use Utopia\Validator\Text; @@ -31,7 +32,7 @@ class Update extends Action ->desc('Update project labels') ->groups(['api', 'project']) ->label('scope', 'project.write') - ->label('event', 'labels.*.update') + // ->label('event', 'project.labels.update') ->label('audits.event', 'project.labels.update') ->label('audits.resource', 'project.labels/{response.$id}') ->label('sdk', new Method( @@ -53,6 +54,7 @@ class Update extends Action ->inject('response') ->inject('dbForPlatform') ->inject('project') + ->inject('authorization') ->callback($this->action(...)); } @@ -63,11 +65,12 @@ class Update extends Action array $labels, Response $response, Database $dbForPlatform, - Document $project + Document $project, + Authorization $authorization ): void { $labels = (array) \array_values(\array_unique($labels)); - $project = $dbForPlatform->updateDocument('projects', $project->getId(), new Document(['labels' => $labels])); + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document(['labels' => $labels]))); $response->dynamic($project, Response::MODEL_PROJECT); } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Create.php new file mode 100644 index 0000000000..f4002c60ef --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Create.php @@ -0,0 +1,111 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/project/mock-phones') + ->desc('Create project mock phone') + ->groups(['api', 'project']) + ->label('scope', 'mocks.write') + ->label('event', 'mock-phones.[number].create') + ->label('audits.event', 'project.mock-phone.create') + ->label('audits.resource', 'project.mock-phone/{response.number}') + ->label('sdk', new Method( + namespace: 'project', + group: 'mocks', + name: 'createMockPhone', + description: <<param('number', null, new Phone(), 'Phone number to associate with the mock phone. Must be a valid E.164 formatted phone number.') + ->param('otp', '', new Text(6, 6, Text::NUMBERS), 'One-time password (OTP) to associate with the mock phone. Must be a 6-digit numeric code.') + ->inject('response') + ->inject('queueForEvents') + ->inject('project') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $number, + string $otp, + Response $response, + QueueEvent $queueForEvents, + Document $project, + Database $dbForPlatform, + Authorization $authorization, + ) { + $auths = $project->getAttribute('auths', []); + + $mockNumbers = $auths['mockNumbers'] ?? []; + + if (\count($mockNumbers) >= APP_LIMIT_COUNT) { + throw new Exception(Exception::MOCK_NUMBER_LIMIT_EXCEEDED); + } + + foreach ($mockNumbers as $mockNumber) { + if ($mockNumber['phone'] === $number) { + throw new Exception(Exception::MOCK_NUMBER_ALREADY_EXISTS); + } + } + + // Set to now date + $mockNumber = [ + 'phone' => $number, + 'otp' => $otp, + '$createdAt' => DateTime::now(), + '$updatedAt' => DateTime::now(), + ]; + + $mockNumbers[] = $mockNumber; + $auths['mockNumbers'] = $mockNumbers; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents->setParam('number', $number); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic(new Document($mockNumber), Response::MODEL_MOCK_NUMBER); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Delete.php new file mode 100644 index 0000000000..0fb23e1764 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Delete.php @@ -0,0 +1,103 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/project/mock-phones/:number') + ->desc('Delete project mock phone') + ->groups(['api', 'project']) + ->label('scope', 'mocks.write') + ->label('event', 'mock-phones.[number].delete') + ->label('audits.event', 'project.mock-phone.delete') + ->label('audits.resource', 'project.mock-phone/{request.number}') + ->label('sdk', new Method( + namespace: 'project', + group: 'mocks', + name: 'deleteMockPhone', + description: <<param('number', null, new Phone(), 'Phone number associated with the mock phone. Must be a valid E.164 formatted phone number.') + ->inject('response') + ->inject('queueForEvents') + ->inject('project') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $number, + Response $response, + QueueEvent $queueForEvents, + Document $project, + Database $dbForPlatform, + Authorization $authorization, + ) { + $auths = $project->getAttribute('auths', []); + + $mockNumbers = $auths['mockNumbers'] ?? []; + + $mockNumberIndex = null; + foreach ($mockNumbers as $index => $mock) { + if ($mock['phone'] === $number) { + $mockNumberIndex = $index; + break; + } + } + + if (\is_null($mockNumberIndex)) { + throw new Exception(Exception::MOCK_NUMBER_NOT_FOUND); + } + + unset($mockNumbers[$mockNumberIndex]); + $mockNumbers = array_values($mockNumbers); + + $auths['mockNumbers'] = $mockNumbers; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents->setParam('number', $number); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Get.php new file mode 100644 index 0000000000..a51095b368 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Get.php @@ -0,0 +1,78 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/mock-phones/:number') + ->desc('Get project mock phone') + ->groups(['api', 'project']) + ->label('scope', 'mocks.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'mocks', + name: 'getMockPhone', + description: <<param('number', null, new Phone(), 'Phone number associated with the mock phone. Must be a valid E.164 formatted phone number.') + ->inject('response') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $number, + Response $response, + Document $project + ) { + $auths = $project->getAttribute('auths', []); + + $mockNumbers = $auths['mockNumbers'] ?? []; + + $mockNumberIndex = null; + foreach ($mockNumbers as $index => $mock) { + if ($mock['phone'] === $number) { + $mockNumberIndex = $index; + break; + } + } + + if (\is_null($mockNumberIndex)) { + throw new Exception(Exception::MOCK_NUMBER_NOT_FOUND); + } + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic(new Document($mockNumbers[$mockNumberIndex]), Response::MODEL_MOCK_NUMBER); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Update.php new file mode 100644 index 0000000000..48b90a1b97 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Update.php @@ -0,0 +1,107 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/project/mock-phones/:number') + ->desc('Update project mock phone') + ->groups(['api', 'project']) + ->label('scope', 'mocks.write') + ->label('event', 'mock-phones.[number].update') + ->label('audits.event', 'project.mock-phone.update') + ->label('audits.resource', 'project.mock-phone/{response.number}') + ->label('sdk', new Method( + namespace: 'project', + group: 'mocks', + name: 'updateMockPhone', + description: <<param('number', null, new Phone(), 'Phone number associated with the mock phone. Must be a valid E.164 formatted phone number.') + ->param('otp', '', new Text(6, 6, Text::NUMBERS), 'One-time password (OTP) to associate with the mock phone. Must be a 6-digit numeric code.') + ->inject('response') + ->inject('queueForEvents') + ->inject('project') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $number, + string $otp, + Response $response, + QueueEvent $queueForEvents, + Document $project, + Database $dbForPlatform, + Authorization $authorization, + ) { + $auths = $project->getAttribute('auths', []); + + $mockNumbers = $auths['mockNumbers'] ?? []; + + $mockNumberIndex = null; + foreach ($mockNumbers as $index => $mock) { + if ($mock['phone'] === $number) { + $mockNumberIndex = $index; + break; + } + } + + if (\is_null($mockNumberIndex)) { + throw new Exception(Exception::MOCK_NUMBER_NOT_FOUND); + } + + $mockNumbers[$mockNumberIndex]['otp'] = $otp; + $mockNumbers[$mockNumberIndex]['$updatedAt'] = DateTime::now(); + + $auths['mockNumbers'] = $mockNumbers; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents->setParam('number', $number); + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic(new Document($mockNumbers[$mockNumberIndex]), Response::MODEL_MOCK_NUMBER); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/XList.php new file mode 100644 index 0000000000..82aa7f1446 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/XList.php @@ -0,0 +1,87 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/mock-phones') + ->desc('List project mock phones') + ->groups(['api', 'project']) + ->label('scope', 'mocks.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'mocks', + name: 'listMockPhones', + description: <<param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + array $queries, + bool $includeTotal, + Response $response, + Document $project, + ) { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $auths = $project->getAttribute('auths', []); + $mockNumbers = $auths['mockNumbers'] ?? []; + $grouped = Query::groupByType($queries); + $limit = $grouped['limit'] ?? null; + $offset = $grouped['offset'] ?? 0; + + $total = $includeTotal ? \count($mockNumbers) : 0; + $mockNumbers = \array_slice($mockNumbers, $offset, $limit); + + $mockNumbers = \array_map(fn ($mockNumber) => new Document($mockNumber), $mockNumbers); + + $response->dynamic(new Document([ + 'mockNumbers' => $mockNumbers, + 'total' => $total, + ]), Response::MODEL_MOCK_NUMBER_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php index 4b58766751..24669b02b2 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php @@ -36,7 +36,7 @@ class Delete extends Action ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].delete') ->label('audits.event', 'project.platform.delete') - ->label('audits.resource', 'project.platform/{response.$id}') + ->label('audits.resource', 'project.platform/{request.platformId}') ->label('sdk', new Method( namespace: 'project', group: 'platforms', diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php index 2fca0ace6c..6c07727150 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php @@ -139,7 +139,7 @@ class Create extends Action if (empty($key) && empty($type)) { // Modern request, validate hostname if (empty($hostname)) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Param "hostname" is not optional.'); + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Param "hostname" is not optional.'); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/Get.php new file mode 100644 index 0000000000..3ffe30f1fa --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/Get.php @@ -0,0 +1,152 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/policies/:policyId') + ->desc('Get project policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'getPolicy', + description: <<param('policyId', '', new WhiteList([ + 'password-dictionary', + 'password-history', + 'password-personal-data', + 'session-alert', + 'session-duration', + 'session-invalidation', + 'session-limit', + 'user-limit', + 'membership-privacy', + ], true), 'Policy ID. Can be one of: password-dictionary, password-history, password-personal-data, session-alert, session-duration, session-invalidation, session-limit, user-limit, membership-privacy.') + ->inject('response') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $policyId, + Response $response, + Document $project, + ): void { + $auths = $project->getAttribute('auths', []); + + [$policy, $model] = match ($policyId) { + 'password-dictionary' => [ + new Document([ + '$id' => 'password-dictionary', + 'enabled' => $auths['passwordDictionary'] ?? false, + ]), + Response::MODEL_POLICY_PASSWORD_DICTIONARY, + ], + 'password-history' => [ + new Document([ + '$id' => 'password-history', + 'total' => $auths['passwordHistory'] ?? 0, + ]), + Response::MODEL_POLICY_PASSWORD_HISTORY, + ], + 'password-personal-data' => [ + new Document([ + '$id' => 'password-personal-data', + 'enabled' => $auths['personalDataCheck'] ?? false, + ]), + Response::MODEL_POLICY_PASSWORD_PERSONAL_DATA, + ], + 'session-alert' => [ + new Document([ + '$id' => 'session-alert', + 'enabled' => $auths['sessionAlerts'] ?? false, + ]), + Response::MODEL_POLICY_SESSION_ALERT, + ], + 'session-duration' => [ + new Document([ + '$id' => 'session-duration', + 'duration' => $auths['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG, + ]), + Response::MODEL_POLICY_SESSION_DURATION, + ], + 'session-invalidation' => [ + new Document([ + '$id' => 'session-invalidation', + 'enabled' => $auths['invalidateSessions'] ?? true, + ]), + Response::MODEL_POLICY_SESSION_INVALIDATION, + ], + 'session-limit' => [ + new Document([ + '$id' => 'session-limit', + 'total' => $auths['maxSessions'] ?? 0, + ]), + Response::MODEL_POLICY_SESSION_LIMIT, + ], + 'user-limit' => [ + new Document([ + '$id' => 'user-limit', + 'total' => $auths['limit'] ?? 0, + ]), + Response::MODEL_POLICY_USER_LIMIT, + ], + 'membership-privacy' => [ + new Document([ + '$id' => 'membership-privacy', + 'userId' => $auths['membershipsUserId'] ?? false, + 'userEmail' => $auths['membershipsUserEmail'] ?? false, + 'userPhone' => $auths['membershipsUserPhone'] ?? false, + 'userName' => $auths['membershipsUserName'] ?? false, + 'userMFA' => $auths['membershipsMfa'] ?? false, + ]), + Response::MODEL_POLICY_MEMBERSHIP_PRIVACY, + ], + default => throw new \LogicException('Unknown policy ID: ' . $policyId), + }; + + $response->dynamic($policy, $model); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php new file mode 100644 index 0000000000..c947ff225a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php @@ -0,0 +1,108 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/membership-privacy') + ->httpAlias('/v1/projects/:projectId/auth/memberships-privacy') + ->desc('Update membership privacy policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateMembershipPrivacyPolicy', + description: <<param('userId', null, new Boolean(), 'Set to true if you want make user ID visible to all team members, or false to hide it.', optional: true) + ->param('userEmail', null, new Boolean(), 'Set to true if you want make user email visible to all team members, or false to hide it.', optional: true) + ->param('userPhone', null, new Boolean(), 'Set to true if you want make user phone number visible to all team members, or false to hide it.', optional: true) + ->param('userName', null, new Boolean(), 'Set to true if you want make user name visible to all team members, or false to hide it.', optional: true) + ->param('userMFA', null, new Boolean(), 'Set to true if you want make user MFA status visible to all team members, or false to hide it.', optional: true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + ?bool $userId, + ?bool $userEmail, + ?bool $userPhone, + ?bool $userName, + ?bool $userMFA, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents, + ): void { + $auths = $project->getAttribute('auths', []); + + if ($userId !== null) { + $auths['membershipsUserId'] = $userId; + } + if ($userEmail !== null) { + $auths['membershipsUserEmail'] = $userEmail; + } + if ($userPhone !== null) { + $auths['membershipsUserPhone'] = $userPhone; + } + if ($userName !== null) { + $auths['membershipsUserName'] = $userName; + } + if ($userMFA !== null) { + $auths['membershipsMfa'] = $userMFA; + } + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'membership-privacy'); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php new file mode 100644 index 0000000000..e2c678abb6 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php @@ -0,0 +1,85 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/password-dictionary') + ->httpAlias('/v1/projects/:projectId/auth/password-dictionary') + ->desc('Update password dictionary policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updatePasswordDictionaryPolicy', + description: <<param('enabled', null, new Boolean(), 'Toggle password dictionary policy. Set to true if you want password change to block passwords in the dictionary, or false to allow them. When changing this policy, existing passwords remain valid.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['passwordDictionary'] = $enabled; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'password-dictionary'); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php new file mode 100644 index 0000000000..a8ae81caff --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php @@ -0,0 +1,93 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/password-history') + ->httpAlias('/v1/projects/:projectId/auth/password-history') + ->desc('Update password history policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updatePasswordHistoryPolicy', + description: <<param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT)), 'Set the password history length per user. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + ?int $total, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents, + ): void { + $auths = $project->getAttribute('auths', []); + + if (\is_null($total)) { + $auths['passwordHistory'] = 0; + } else { + $auths['passwordHistory'] = $total; + } + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'password-history'); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php new file mode 100644 index 0000000000..9db7cf0549 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php @@ -0,0 +1,86 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/password-personal-data') + ->httpAlias('/v1/projects/:projectId/auth/personal-data') + ->desc('Update password personal data policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updatePasswordPersonalDataPolicy', + description: <<param('enabled', null, new Boolean(), 'Toggle password personal data policy. Set to true if you want to block passwords including user\'s personal data, or false to allow it. When changing this policy, existing passwords remain valid.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['personalDataCheck'] = $enabled; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'password-personal-data'); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php new file mode 100644 index 0000000000..22b7a44b04 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php @@ -0,0 +1,85 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/session-alert') + ->httpAlias('/v1/projects/:projectId/auth/session-alerts') + ->desc('Update session alert policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateSessionAlertPolicy', + description: <<param('enabled', null, new Boolean(), 'Toggle session alert policy. Set to true if you want users to receive email notifications when a sessions are created for their users, or false to not send email alerts.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['sessionAlerts'] = $enabled; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'session-alert'); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php new file mode 100644 index 0000000000..ba72c93a6f --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php @@ -0,0 +1,85 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/session-duration') + ->httpAlias('/v1/projects/:projectId/auth/duration') + ->desc('Update session duration policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateSessionDurationPolicy', + description: <<param('duration', null, new Range(5, 31536000), 'Maximum session length in seconds. Minium allowed value is 5 second, and maximum is 1 year, which is 31536000 seconds.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + int $duration, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['duration'] = $duration; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'session-duration'); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php new file mode 100644 index 0000000000..8f8a959959 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php @@ -0,0 +1,85 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/session-invalidation') + ->httpAlias('/v1/projects/:projectId/auth/session-invalidation') + ->desc('Update session invalidation policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateSessionInvalidationPolicy', + description: <<param('enabled', null, new Boolean(), 'Toggle session invalidation policy. Set to true if you want password change to invalidate all sessions of an user, or false to keep sessions active.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['invalidateSessions'] = $enabled; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'session-invalidation'); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php new file mode 100644 index 0000000000..382ed6f0d9 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php @@ -0,0 +1,91 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/session-limit') + ->httpAlias('/v1/projects/:projectId/auth/max-sessions') + ->desc('Update session limit policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateSessionLimitPolicy', + description: <<param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT)), 'Set the maximum number of sessions allowed per user. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + ?int $total, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents, + ): void { + $auths = $project->getAttribute('auths', []); + + if (\is_null($total)) { + $auths['maxSessions'] = 0; + } else { + $auths['maxSessions'] = $total; + } + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'session-limit'); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php new file mode 100644 index 0000000000..9129b81250 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php @@ -0,0 +1,91 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/user-limit') + ->httpAlias('/v1/projects/:projectId/auth/limit') + ->desc('Update user limit policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateUserLimitPolicy', + description: <<param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT)), 'Set the maximum number of users allowed in the project. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + ?int $total, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents, + ): void { + $auths = $project->getAttribute('auths', []); + + if (\is_null($total)) { + $auths['limit'] = 0; + } else { + $auths['limit'] = $total; + } + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'user-limit'); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/XList.php new file mode 100644 index 0000000000..893b28fef2 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/XList.php @@ -0,0 +1,132 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/policies') + ->desc('List project policies') + ->groups(['api', 'project']) + ->label('scope', 'policies.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'listPolicies', + description: <<param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('project') + ->callback($this->action(...)); + } + + /** + * @param array $queries + */ + public function action( + array $queries, + bool $includeTotal, + Response $response, + Document $project, + ) { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $auths = $project->getAttribute('auths', []); + + $policies = [ + new Document([ + '$id' => 'password-dictionary', + 'enabled' => $auths['passwordDictionary'] ?? false, + ]), + new Document([ + '$id' => 'password-history', + 'total' => $auths['passwordHistory'] ?? 0, + ]), + new Document([ + '$id' => 'password-personal-data', + 'enabled' => $auths['personalDataCheck'] ?? false, + ]), + new Document([ + '$id' => 'session-alert', + 'enabled' => $auths['sessionAlerts'] ?? false, + ]), + new Document([ + '$id' => 'session-duration', + 'duration' => $auths['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG, + ]), + new Document([ + '$id' => 'session-invalidation', + 'enabled' => $auths['invalidateSessions'] ?? true, + ]), + new Document([ + '$id' => 'session-limit', + 'total' => $auths['maxSessions'] ?? 0, + ]), + new Document([ + '$id' => 'user-limit', + 'total' => $auths['limit'] ?? 0, + ]), + new Document([ + '$id' => 'membership-privacy', + 'userId' => $auths['membershipsUserId'] ?? false, + 'userEmail' => $auths['membershipsUserEmail'] ?? false, + 'userPhone' => $auths['membershipsUserPhone'] ?? false, + 'userName' => $auths['membershipsUserName'] ?? false, + 'userMFA' => $auths['membershipsMfa'] ?? false, + ]), + ]; + + $total = $includeTotal ? \count($policies) : 0; + + $grouped = Query::groupByType($queries); + $offset = $grouped['offset'] ?? 0; + $limit = $grouped['limit'] ?? null; + + $policies = \array_slice($policies, $offset, $limit); + + $response->dynamic(new Document([ + 'policies' => $policies, + 'total' => $total, + ]), Response::MODEL_POLICY_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php new file mode 100644 index 0000000000..7095c2d2d0 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php @@ -0,0 +1,177 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/project/smtp/tests') + ->httpAlias('/v1/projects/:projectId/smtp/tests') + ->desc('Create project SMTP test') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('sdk', new Method( + namespace: 'project', + group: 'smtp', + name: 'createSMTPTest', + description: <<param('emails', [], new ArrayList(new Email(), 10), 'Array of emails to send test email to. Maximum of 10 emails are allowed.') + ->param('senderName', '', new Text(256), 'Name of the email sender', optional: true, deprecated: true) // Backwards compatibility + ->param('senderEmail', '', new Email(), 'Email of the sender', optional: true, deprecated: true) // Backwards compatibility + ->param('replyTo', '', new Email(), 'Reply to email', optional: true, deprecated: true) // Backwards compatibility + ->param('host', '', new Hostname(), 'SMTP server host name', optional: true, deprecated: true) // Backwards compatibility + ->param('port', null, new Integer(), 'SMTP server port', optional: true, deprecated: true) // Backwards compatibility + ->param('username', '', new Text(256), 'SMTP server username', optional: true, deprecated: true) // Backwards compatibility + ->param('password', '', new Text(256), 'SMTP server password', optional: true, deprecated: true) // Backwards compatibility + ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', optional: true, deprecated: true) // Backwards compatibility + ->inject('response') + ->inject('project') + ->inject('queueForMails') + ->inject('plan') + ->callback($this->action(...)); + } + + /** + * @param array $emails + */ + public function action( + array $emails, + string $paramSenderName, // Backwards compatibility + string $paramSenderEmail, // Backwards compatibility + string $paramReplyTo, // Backwards compatibility + string $paramHost, // Backwards compatibility + ?int $paramPort, // Backwards compatibility + string $paramUsername, // Backwards compatibility + string $paramPassword, // Backwards compatibility + string $paramSecure, // Backwards compatibility + Response $response, + Document $project, + Mail $queueForMails, + array $plan + ): void { + // Backwards compatibility: use inline params if provided, otherwise fall back to project SMTP config. + // When inline params are provided they are treated as self-contained — project config is ignored + // so legacy (1.9.1) callers do not get project state (e.g. replyToName) leaked into their request. + $hasInlineParams = !empty($paramHost); + + $smtp = $project->getAttribute('smtp', []); + + if (!$hasInlineParams && ($smtp['enabled'] ?? false) !== true) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP must be enabled on the project to send a test email.'); + } + + if ($hasInlineParams) { + $senderName = $paramSenderName; + $senderEmail = $paramSenderEmail; + $replyToEmail = $paramReplyTo; + $replyToName = ''; // 1.9.1 inline params did not include replyToName + $host = $paramHost; + $port = $paramPort ?? 0; + $username = $paramUsername; + $password = $paramPassword; + $secure = $paramSecure; + } else { + $senderName = $smtp['senderName'] ?? ''; + $senderEmail = $smtp['senderEmail'] ?? ''; + $replyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; // Includes backwards compatibility + $replyToName = $smtp['replyToName'] ?? ''; + $host = $smtp['host'] ?? ''; + $port = $smtp['port'] ?? 0; + $username = $smtp['username'] ?? ''; + $password = $smtp['password'] ?? ''; + $secure = $smtp['secure'] ?? ''; + } + + if (empty($senderEmail)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP sender email must be configured on the project to send a test email.'); + } + + if (empty($host)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP host must be configured on the project to send a test email.'); + } + + if (empty($port)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP port must be configured on the project to send a test email.'); + } + + // Fallback to sender details when reply-to is not explicitly configured + $replyToEmailDisplay = !empty($replyToEmail) ? $replyToEmail : $senderEmail; + $replyToNameDisplay = !empty($replyToName) ? $replyToName : $senderName; + + $subject = 'Custom SMTP email sample'; + $template = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/email-smtp-test.tpl'); + $template + ->setParam('{{from}}', "{$senderName} ({$senderEmail})") + ->setParam('{{replyTo}}', "{$replyToNameDisplay} ({$replyToEmailDisplay})") + ->setParam('{{logoUrl}}', $plan['logoUrl'] ?? APP_EMAIL_LOGO_URL) + ->setParam('{{accentColor}}', $plan['accentColor'] ?? APP_EMAIL_ACCENT_COLOR) + ->setParam('{{twitterUrl}}', $plan['twitterUrl'] ?? APP_SOCIAL_TWITTER) + ->setParam('{{discordUrl}}', $plan['discordUrl'] ?? APP_SOCIAL_DISCORD) + ->setParam('{{githubUrl}}', $plan['githubUrl'] ?? APP_SOCIAL_GITHUB_APPWRITE) + ->setParam('{{termsUrl}}', $plan['termsUrl'] ?? APP_EMAIL_TERMS_URL) + ->setParam('{{privacyUrl}}', $plan['privacyUrl'] ?? APP_EMAIL_PRIVACY_URL); + + foreach ($emails as $email) { + $queueForMails + ->setSmtpHost($host) + ->setSmtpPort($port) + ->setSmtpUsername($username) + ->setSmtpPassword($password) + ->setSmtpSecure($secure) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) + ->setSmtpSenderEmail($senderEmail) + ->setSmtpSenderName($senderName) + ->setRecipient($email) + ->setName('') + ->setBodyTemplate(APP_CE_CONFIG_DIR . '/locale/templates/email-base-styled.tpl') + ->setBody($template->render()) + ->setVariables([]) + ->setSubject($subject) + ->trigger(); + } + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php new file mode 100644 index 0000000000..97e723f52c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -0,0 +1,173 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/smtp') + ->httpAlias('/v1/projects/:projectId/smtp') + ->desc('Update project SMTP configuration') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + // ->label('event', 'project.smtp.update') + ->label('audits.event', 'project.smtp.update') + ->label('audits.resource', 'project.smtp/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'smtp', + name: 'updateSMTP', + description: <<param('host', null, new Nullable(new Hostname()), 'SMTP server hostname (domain)', optional: true) + ->param('port', null, new Nullable(new Integer()), 'SMTP server port', optional: true) + ->param('username', null, new Nullable(new Text(256)), 'SMTP server username. Leave empty for no authorization.', optional: true) + ->param('password', null, new Nullable(new Text(256)), 'SMTP server password. Leave empty for no authorization. This property is stored securely and cannot be read in future (write-only).', optional: true) + ->param('senderEmail', null, new Nullable(new Email()), 'Email address shown in inbox as the sender of the email.', optional: true) + ->param('senderName', null, new Nullable(new Text(256)), 'Name shown in inbox as the sender of the email.', optional: true) + ->param('replyToEmail', null, new Nullable(new Email()), 'Email used when user replies to the email.', optional: true) + ->param('replyToName', null, new Nullable(new Text(256)), 'Name used when user replies to the email.', optional: true) + ->param('secure', null, new Nullable(new WhiteList(['tls', 'ssl'], true)), 'Configures if communication with SMTP server is encrypted. Allowed values are: tls, ssl. Leave empty for no encryption.', optional: true) + ->param('enabled', null, new Nullable(new Boolean()), 'Enable or disable custom SMTP. Custom SMTP is useful for branding purposes, but also allows use of custom email templates.', optional: true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + + public function action( + ?string $host, + ?int $port, + ?string $username, + ?string $password, + ?string $senderEmail, + ?string $senderName, + ?string $replyToEmail, + ?string $replyToName, + ?string $secure, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization + ): void { + // Fetch current configuration + $smtp = $project->getAttribute('smtp', []); + + // Apply changes + $keys = ['host', 'port', 'username', 'password', 'senderEmail', 'senderName', 'replyToEmail', 'replyToName', 'secure', 'enabled']; + foreach ($keys as $key) { + if (!\is_null(${$key})) { + $smtp[$key] = ${$key}; + } + } + + // Backwards compatibility + $smtp['replyToEmail'] = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + + if (($smtp['enabled'] ?? false) === true) { + // Ensure required fields are set + $requiredKeys = ['host', 'port', 'senderEmail']; + foreach ($requiredKeys as $key) { + if (empty($smtp[$key])) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Param "' . $key . '" is not optional.'); + } + } + } + + // Validate SMTP credentials + // Validate when the caller is explicitly enabling or hasn't expressed a preference + // (so a credentials-only PATCH can auto-enable). Skip only when the caller is + // explicitly keeping/turning SMTP off. + if (\is_null($enabled) || $enabled === true) { + $mail = new PHPMailer(true); + $mail->isSMTP(); + + $mail->Host = $smtp['host'] ?? ''; + $mail->Port = $smtp['port'] ?? ''; + $mail->SMTPSecure = $smtp['secure'] ?? ''; + $mail->setFrom($smtp['senderEmail'], $smtp['senderName'] ?? ''); + + if (!empty($smtp['username'] ?? '')) { + $mail->SMTPAuth = true; + $mail->Username = $smtp['username']; + $mail->Password = $smtp['password'] ?? ''; + } + + if (!empty($smtp['replyToEmail'] ?? '')) { + $mail->addReplyTo($smtp['replyToEmail'], $smtp['replyToName'] ?? ''); + } + + $mail->SMTPAutoTLS = false; + $mail->Timeout = 5; + + try { + $valid = $mail->SmtpConnect(); + + if (!$valid) { + throw new \Exception('Connection is not valid.'); + } + + // Auto-enable if configuration is valid + // Dont do this if specifically request to mark disabled + if (\is_null($enabled)) { + $smtp['enabled'] = true; + } + } catch (Throwable $error) { + if (($smtp['enabled'] ?? null) === true) { + throw new Exception(Exception::PROJECT_SMTP_CONFIG_INVALID, $error->getMessage()); + } + } + } + + // Save configuration + $updates = new Document([ + 'smtp' => $smtp, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php new file mode 100644 index 0000000000..02ba431775 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -0,0 +1,142 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/templates/email/:templateId') + ->httpAlias('/v1/projects/:projectId/templates/email/:templateId/:locale') + ->desc('Get project email template') + ->groups(['api', 'project']) + ->label('scope', 'templates.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'templates', + name: 'getEmailTemplate', + description: <<param('templateId', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale. If left empty, the fallback locale (en) will be used.', optional: true, injections: ['localeCodes']) + ->inject('response') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $templateId, + string $locale, + Response $response, + Document $project, + ) { + $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); + + // Get custom template if available + $templates = $project->getAttribute('templates', []); + $template = $templates['email.' . $templateId . '-' . $locale] ?? []; + + // Enforced params + $template['templateId'] = $templateId; + $template['locale'] = $locale; + + // Prepare default tempaltes + $localeObj = new Locale($locale); + $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); + + $defaultSubject = $localeObj->getText('emails.' . $templateId . '.subject'); + $defaultMessage = $this->getDefaultMessage($templateId, $localeObj); + + // Apply defaults if needed + if (\is_null($template['message'] ?? null)) { + $template['message'] = $defaultMessage; + } + + if (\is_null($template['subject'] ?? null)) { + $template['subject'] = $defaultSubject; + } + + // Backwards compatibility + if (!\is_null($template['replyTo'] ?? null)) { + $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? ''; + } + + $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); + } + + protected function getDefaultMessage(string $templateId, Locale $localeObj): string + { + $templateConfigs = [ + 'magicSession' => [ + 'file' => 'email-magic-url.tpl', + 'placeholders' => ['optionButton', 'buttonText', 'optionUrl', 'clientInfo', 'securityPhrase'] + ], + 'mfaChallenge' => [ + 'file' => 'email-mfa-challenge.tpl', + 'placeholders' => ['description', 'clientInfo'] + ], + 'otpSession' => [ + 'file' => 'email-otp.tpl', + 'placeholders' => ['description', 'clientInfo', 'securityPhrase'] + ], + 'sessionAlert' => [ + 'file' => 'email-session-alert.tpl', + 'placeholders' => ['body', 'listDevice', 'listIpAddress', 'listCountry', 'footer'] + ], + ]; + + // fallback to the base template. + $config = $templateConfigs[$templateId] ?? [ + 'file' => 'email-inner-base.tpl', + 'placeholders' => ['buttonText', 'body', 'footer'] + ]; + + $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/locale/templates/' . $config['file']); + $message = Template::fromString($templateString); + + // Set type-specific parameters + foreach ($config['placeholders'] as $param) { + $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']); + $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$templateId}.{$param}"), escapeHtml: $escapeHtml); + } + + $message + ->setParam('{{hello}}', $localeObj->getText("emails.{$templateId}.hello")) + ->setParam('{{thanks}}', $localeObj->getText("emails.{$templateId}.thanks")) + ->setParam('{{signature}}', $localeObj->getText("emails.{$templateId}.signature")); + + $message = $message->render(useContent: true); + + return $message; + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php new file mode 100644 index 0000000000..ef93abf683 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -0,0 +1,144 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email/:templateId/:locale') + ->desc('Update project email template') + ->groups(['api', 'project']) + ->label('scope', 'templates.write') + ->label('event', 'templates.[templateId].update') + ->label('audits.event', 'project.template.update') + ->label('audits.resource', 'project.template/{response.templateId}') + ->label('sdk', new Method( + namespace: 'project', + group: 'templates', + name: 'updateEmailTemplate', + description: <<param('templateId', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale. If left empty, the fallback locale (en) will be used.', optional: true, injections: ['localeCodes']) + ->param('subject', null, new Nullable(new Text(255)), 'Subject of the email template. Can be up to 255 characters.', optional: true) + ->param('message', null, new Nullable(new Text(10485760)), 'Plain or HTML body of the email template message. Can be up to 10MB of content.', optional: true) + ->param('senderName', null, new Nullable(new Text(255, 0)), 'Name of the email sender.', optional: true) + ->param('senderEmail', null, new Nullable(new Email()), 'Email of the sender.', optional: true) + ->param('replyToEmail', null, new Nullable(new Email()), 'Reply to email.', optional: true) + ->param('replyToName', null, new Nullable(new Text(255, 0)), 'Reply to name.', optional: true) + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $templateId, + string $locale, + ?string $subject, + ?string $message, + ?string $senderName, + ?string $senderEmail, + ?string $replyToEmail, + ?string $replyToName, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + ) { + $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); + + // Prevent template update if custom SMTP is not configured + $smtp = $project->getAttribute('smtp', []); + if (($smtp['enabled'] ?? false) !== true) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP must be enabled on the project to configure custom email templates.'); + } + + // Fetch current configuration + $templates = $project->getAttribute('templates', []); + $template = $templates['email.' . $templateId . '-' . $locale] ?? []; + + // Apply changes + $keys = ['senderName', 'senderEmail', 'replyToEmail', 'replyToName', 'message', 'subject']; + foreach ($keys as $key) { + if (!\is_null(${$key})) { + $template[$key] = ${$key}; + } + } + + // Backwards compatibility + if (!\is_null($template['replyTo'] ?? null)) { + $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? ''; + } + + // Ensure required fields are set + $requiredKeys = ['subject', 'message']; + foreach ($requiredKeys as $key) { + if (empty($template[$key])) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Param "' . $key . '" is not optional.'); + } + } + + // Save configuration + $templates['email.' . $templateId . '-' . $locale] = $template; + $updates = new Document([ + 'templates' => $templates, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents->setParam('templateId', $templateId); + + $response->dynamic(new Document([ + 'templateId' => $templateId, + 'locale' => $locale, + 'subject' => $template['subject'], + 'message' => $template['message'], + 'senderName' => $template['senderName'] ?? '', + 'senderEmail' => $template['senderEmail'] ?? '', + 'replyToEmail' => $template['replyToEmail'] ?? '', + 'replyToName' => $template['replyToName'] ?? '', + ]), Response::MODEL_EMAIL_TEMPLATE); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php new file mode 100644 index 0000000000..d15f2f856c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php @@ -0,0 +1,114 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/templates/email') + ->desc('List project email templates') + ->groups(['api', 'project']) + ->label('scope', 'templates.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'templates', + name: 'listEmailTemplates', + description: <<param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('project') + ->callback($this->action(...)); + } + + /** + * @param array $queries + */ + public function action( + array $queries, + bool $includeTotal, + Response $response, + Document $project, + ) { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $templates = $project->getAttribute('templates', []); + + $emailTemplates = []; + foreach ($templates as $key => $template) { + if (!\str_starts_with($key, 'email.')) { + continue; + } + + $suffix = \substr($key, \strlen('email.')); + $parts = \explode('-', $suffix, 2); + if (\count($parts) !== 2) { + continue; + } + + [$templateId, $locale] = $parts; + + $template['templateId'] = $templateId; + $template['locale'] = $locale; + + // Backwards compatibility + if (!\is_null($template['replyTo'] ?? null)) { + $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? ''; + } + + $emailTemplates[] = new Document($template); + } + + $total = $includeTotal ? \count($emailTemplates) : 0; + + $grouped = Query::groupByType($queries); + $offset = $grouped['offset'] ?? 0; + $limit = $grouped['limit'] ?? null; + + $emailTemplates = \array_slice($emailTemplates, $offset, $limit); + + $response->dynamic(new Document([ + 'templates' => $emailTemplates, + 'total' => $total, + ]), Response::MODEL_EMAIL_TEMPLATE_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index bcab75a8c5..64dad109f8 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -3,12 +3,19 @@ namespace Appwrite\Platform\Modules\Project\Services; use Appwrite\Platform\Modules\Project\Http\Init; +use Appwrite\Platform\Modules\Project\Http\Project\AuthMethods\Update as UpdateAuthMethod; +use Appwrite\Platform\Modules\Project\Http\Project\Delete as DeleteProject; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Create as CreateKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Delete as DeleteKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Get as GetKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Update as UpdateKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\XList as ListKeys; use Appwrite\Platform\Modules\Project\Http\Project\Labels\Update as UpdateProjectLabels; +use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Create as CreateMockPhone; +use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Delete as DeleteMockPhone; +use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Get as GetMockPhone; +use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Update as UpdateMockPhone; +use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\XList as ListMockPhones; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Android\Create as CreateAndroidPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Android\Update as UpdateAndroidPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Apple\Create as CreateApplePlatform; @@ -22,8 +29,24 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Web\Update as Updat use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Create as CreateWindowsPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as UpdateWindowsPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\Get as GetPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\MembershipPrivacy\Update as UpdateMembershipPrivacyPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordDictionary\Update as UpdatePasswordDictionaryPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordHistory\Update as UpdatePasswordHistoryPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordPersonalData\Update as UpdatePasswordPersonalDataPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionAlert\Update as UpdateSessionAlertPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionDuration\Update as UpdateSessionDurationPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionInvalidation\Update as UpdateSessionInvalidationPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionLimit\Update as UpdateSessionLimitPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\UserLimit\Update as UpdateUserLimitPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\XList as ListPolicies; use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Update as UpdateProjectProtocol; use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProjectService; +use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Tests\Create as CreateSMTPTest; +use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Update as UpdateSMTP; +use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Get as GetTemplate; +use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Update as UpdateTemplate; +use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\XList as ListTemplates; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Get as GetVariable; @@ -41,10 +64,20 @@ class Http extends Service $this->addAction(Init::getName(), new Init()); // Project + $this->addAction(DeleteProject::getName(), new DeleteProject()); $this->addAction(UpdateProjectLabels::getName(), new UpdateProjectLabels()); $this->addAction(UpdateProjectProtocol::getName(), new UpdateProjectProtocol()); $this->addAction(UpdateProjectService::getName(), new UpdateProjectService()); + // SMTP + $this->addAction(UpdateSMTP::getName(), new UpdateSMTP()); + $this->addAction(CreateSMTPTest::getName(), new CreateSMTPTest()); + + // Templates + $this->addAction(ListTemplates::getName(), new ListTemplates()); + $this->addAction(GetTemplate::getName(), new GetTemplate()); + $this->addAction(UpdateTemplate::getName(), new UpdateTemplate()); + // Variables $this->addAction(CreateVariable::getName(), new CreateVariable()); $this->addAction(ListVariables::getName(), new ListVariables()); @@ -73,5 +106,28 @@ class Http extends Service $this->addAction(CreateLinuxPlatform::getName(), new CreateLinuxPlatform()); $this->addAction(GetPlatform::getName(), new GetPlatform()); $this->addAction(ListPlatforms::getName(), new ListPlatforms()); + + // Mock Phones + $this->addAction(CreateMockPhone::getName(), new CreateMockPhone()); + $this->addAction(ListMockPhones::getName(), new ListMockPhones()); + $this->addAction(GetMockPhone::getName(), new GetMockPhone()); + $this->addAction(UpdateMockPhone::getName(), new UpdateMockPhone()); + $this->addAction(DeleteMockPhone::getName(), new DeleteMockPhone()); + + // Policies + $this->addAction(ListPolicies::getName(), new ListPolicies()); + $this->addAction(GetPolicy::getName(), new GetPolicy()); + $this->addAction(UpdateMembershipPrivacyPolicy::getName(), new UpdateMembershipPrivacyPolicy()); + $this->addAction(UpdatePasswordDictionaryPolicy::getName(), new UpdatePasswordDictionaryPolicy()); + $this->addAction(UpdatePasswordHistoryPolicy::getName(), new UpdatePasswordHistoryPolicy()); + $this->addAction(UpdatePasswordPersonalDataPolicy::getName(), new UpdatePasswordPersonalDataPolicy()); + $this->addAction(UpdateSessionAlertPolicy::getName(), new UpdateSessionAlertPolicy()); + $this->addAction(UpdateSessionDurationPolicy::getName(), new UpdateSessionDurationPolicy()); + $this->addAction(UpdateSessionInvalidationPolicy::getName(), new UpdateSessionInvalidationPolicy()); + $this->addAction(UpdateSessionLimitPolicy::getName(), new UpdateSessionLimitPolicy()); + $this->addAction(UpdateUserLimitPolicy::getName(), new UpdateUserLimitPolicy()); + + // Auth Methods + $this->addAction(UpdateAuthMethod::getName(), new UpdateAuthMethod()); } } diff --git a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Delete.php b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Delete.php index 5329585be3..76df8c2b45 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Delete.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Delete.php @@ -63,7 +63,7 @@ class Delete extends Action $key = $dbForPlatform->getDocument('devKeys', $keyId); - if ($key === false || $key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) { + if ($key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) { throw new Exception(Exception::KEY_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Get.php b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Get.php index 5cb3b0545f..ff4e348c8e 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Get.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Get.php @@ -63,7 +63,7 @@ class Get extends Action $key = $dbForPlatform->getDocument('devKeys', $keyId); - if ($key === false || $key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) { + if ($key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) { throw new Exception(Exception::KEY_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Update.php b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Update.php index f3e47f80ba..9704740bc4 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Update.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Update.php @@ -66,7 +66,7 @@ class Update extends Action $key = $dbForPlatform->getDocument('devKeys', $keyId); - if ($key === false || $key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) { + if ($key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) { throw new Exception(Exception::KEY_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php index c509a565cd..363c99dc1f 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php @@ -107,7 +107,7 @@ class Create extends Action $auth = Config::getParam('auth', []); $auths = [ 'limit' => 0, - 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT, + 'maxSessions' => 0, 'passwordHistory' => 0, 'passwordDictionary' => false, 'duration' => TOKEN_EXPIRATION_LOGIN_LONG, @@ -120,6 +120,8 @@ class Create extends Action 'membershipsUserName' => false, 'membershipsUserEmail' => false, 'membershipsMfa' => false, + 'membershipsUserId' => false, + 'membershipsUserPhone' => false, 'invalidateSessions' => true ]; diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php index 8e420e87f2..0d2a951388 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php @@ -109,7 +109,7 @@ class XList extends Action } try { - $selectQueries = Query::groupByType($queries)['selections'] ?? []; + $selectQueries = Query::groupByType($queries)['selections']; $filterQueries = Query::groupByType($queries)['filters']; $projects = $this->find($dbForPlatform, $queries, $selectQueries); diff --git a/src/Appwrite/Platform/Modules/Proxy/Action.php b/src/Appwrite/Platform/Modules/Proxy/Action.php index 30ad140530..8baf54c790 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Action.php +++ b/src/Appwrite/Platform/Modules/Proxy/Action.php @@ -164,9 +164,7 @@ class Action extends PlatformAction $validator = new AnyOf($cnameValidators); $validators[] = $validator; - if (\is_null($mainValidator)) { - $mainValidator = $validator; - } + $mainValidator = $validator; } // Ensure at least one of CNAME/A/AAAA record points to our servers properly diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php index 8a265ba5bb..5964a20772 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php @@ -84,7 +84,8 @@ class Create extends Action $collection = match ($resourceType) { 'site' => 'sites', - 'function' => 'functions' + 'function' => 'functions', + default => throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Invalid resource type: ' . $resourceType), }; $resource = $dbForProject->getDocument($collection, $resourceId); if ($resource->isEmpty()) { diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php index 8a6964209f..0b8ca24aaa 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php @@ -208,6 +208,12 @@ class Create extends Action if ($chunk === -1) { $chunk = $chunks; } + } else { + // Guard against manually setting range header for single chunk upload + if ($chunks === -1) { + $chunks = 1; + $chunk = 1; + } } $chunksUploaded = $deviceForSites->upload($fileTmpName, $path, $chunk, $chunks, $metadata); diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/XList.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/XList.php index a9198f937b..3dccd687ea 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/XList.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/XList.php @@ -116,7 +116,7 @@ class XList extends Base $grouped = Query::groupByType($queries); $filterQueries = $grouped['filters']; - $selectQueries = $grouped['selections'] ?? []; + $selectQueries = $grouped['selections']; try { $results = $dbForProject->find('deployments', $queries); diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php index dd9bedffb5..3c0d090b7b 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php @@ -164,10 +164,6 @@ class Update extends Base throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'When connecting to VCS (Version Control System), you need to provide "installationId" and "providerBranch".'); } - if ($site->isEmpty()) { - throw new Exception(Exception::SITE_NOT_FOUND); - } - if (empty($framework)) { $framework = $site->getAttribute('framework'); } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php b/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php index a6768462d1..85968c7550 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php @@ -121,6 +121,7 @@ class Get extends Base $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php b/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php index a90cb0cab9..636889f6c0 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Sites\Http\Usage; +use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -107,6 +108,7 @@ class XList extends Base $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php index 703806f1aa..d61c9892cf 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php @@ -67,11 +67,7 @@ class Delete extends Base } $variable = $dbForProject->getDocument('variables', $variableId); - if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $site->getSequence() || $variable->getAttribute('resourceType') !== 'site') { - throw new Exception(Exception::VARIABLE_NOT_FOUND); - } - - if ($variable === false || $variable->isEmpty()) { + if ($variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $site->getSequence() || $variable->getAttribute('resourceType') !== 'site') { throw new Exception(Exception::VARIABLE_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Get.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Get.php index 54522c0ec7..2fcb051996 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Get.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Get.php @@ -66,7 +66,6 @@ class Get extends Base $variable = $dbForProject->getDocument('variables', $variableId); if ( - $variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $site->getSequence() || $variable->getAttribute('resourceType') !== 'site' @@ -74,10 +73,6 @@ class Get extends Base throw new Exception(Exception::VARIABLE_NOT_FOUND); } - if ($variable === false || $variable->isEmpty()) { - throw new Exception(Exception::VARIABLE_NOT_FOUND); - } - $response->dynamic($variable, Response::MODEL_VARIABLE); } } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php index 99f68a45df..08cdd4ac38 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php @@ -79,7 +79,7 @@ class Update extends Base } $variable = $dbForProject->getDocument('variables', $variableId); - if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $site->getSequence() || $variable->getAttribute('resourceType') !== 'site') { + if ($variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $site->getSequence() || $variable->getAttribute('resourceType') !== 'site') { throw new Exception(Exception::VARIABLE_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php index c5f4f3dccd..befc02a1df 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php @@ -384,14 +384,11 @@ class Create extends Action ->setAttribute('chunksUploaded', $chunksUploaded); /** - * Validate create permission and skip authorization in updateDocument - * Without this, the file creation will fail when user doesn't have update permission + * Skip authorization in updateDocument. + * Without this, the file creation will fail when user doesn't have update permission. * However as with chunk upload even if we are updating, we are essentially creating a file - * adding it's new chunk so we validate create permission instead of update + * adding it's new chunk so we rely on the create-permission check performed earlier. */ - if (!$authorization->isValid(new Input(Database::PERMISSION_CREATE, $bucket->getCreate()))) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } $file = $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file)); } @@ -431,15 +428,11 @@ class Create extends Action ->setAttribute('metadata', $metadata); /** - * Validate create permission and skip authorization in updateDocument - * Without this, the file creation will fail when user doesn't have update permission + * Skip authorization in updateDocument. + * Without this, the file creation will fail when user doesn't have update permission. * However as with chunk upload even if we are updating, we are essentially creating a file - * adding it's new chunk so we validate create permission instead of update + * adding it's new chunk so we rely on the create-permission check performed earlier. */ - if (!$authorization->isValid(new Input(Database::PERMISSION_CREATE, $bucket->getCreate()))) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - try { $file = $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file)); } catch (NotFoundException) { @@ -468,8 +461,5 @@ class Create extends Action */ protected function afterCreateSuccess(Document $file) { - if (!($file instanceof Document)) { - throw new Exception('file must be an instance of document'); - } } } diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php index f6b6eb25da..4fa5006db8 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php @@ -200,7 +200,7 @@ class Get extends Action // when file extension is not provided and the mime type is not one of our supported outputs // we fallback to `jpg` output format - $output = empty($type) ? (array_search($mime, $outputs) ?? 'jpg') : $type; + $output = empty($type) ? (array_search($mime, $outputs) ?: 'jpg') : $type; } $startTime = \microtime(true); @@ -243,7 +243,7 @@ class Get extends Action $image->crop((int) $width, (int) $height, $gravity); - if (!empty($opacity) || $opacity === 0) { + if (!empty($opacity)) { $image->setOpacity($opacity); } diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php index 8e69468170..407f3766df 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php @@ -130,7 +130,7 @@ class Update extends Action } if (\is_null($permissions)) { - $permissions = $file->getPermissions() ?? []; + $permissions = $file->getPermissions(); } $file->setAttribute('$permissions', $permissions); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/XList.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/XList.php index 8f2cd9bbac..d8e5cd5ad2 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/XList.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/XList.php @@ -143,11 +143,12 @@ class XList extends Action }); foreach ($stats as $stat) { - $bucket = $bucketByStatsId[$stat->getId()]; - - if ($bucket) { - $bucket->setAttribute('totalSize', $stat->getAttribute('value', 0)); + if (!isset($bucketByStatsId[$stat->getId()])) { + continue; } + + $bucket = $bucketByStatsId[$stat->getId()]; + $bucket->setAttribute('totalSize', $stat->getAttribute('value', 0)); } } catch (\Throwable) { // Stats may not be available, default to 0 diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php index a7bda355da..10a603f5df 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php @@ -109,6 +109,7 @@ class Get extends Action $format = match ($days['period']) { '1h' => 'Y-m-d\\TH:00:00.000P', '1d' => 'Y-m-d\\T00:00:00.000P', + default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Usage/XList.php b/src/Appwrite/Platform/Modules/Storage/Http/Usage/XList.php index 44fdd54e8c..04eac21754 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Usage/XList.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Usage/XList.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Storage\Http\Usage; +use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -92,6 +93,7 @@ class XList extends Action $format = match ($days['period']) { '1h' => 'Y-m-d\\TH:00:00.000P', '1d' => 'Y-m-d\\T00:00:00.000P', + default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php index aa4ee2c66c..e174029031 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php @@ -343,7 +343,8 @@ class Create extends Action $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyTo = ''; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (! empty($smtp['senderEmail'])) { @@ -352,8 +353,13 @@ class Create extends Action if (! empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (! empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + if (! empty($smtpReplyToEmail)) { + $replyToEmail = $smtpReplyToEmail; + } + if (! empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -370,8 +376,13 @@ class Create extends Action if (! empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (! empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? ''; + if (! empty($customReplyToEmail)) { + $replyToEmail = $customReplyToEmail; + } + if (! empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -379,7 +390,8 @@ class Create extends Action } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php index f3fd9a4bb9..ef8d130855 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php @@ -70,10 +70,13 @@ class Get extends Action throw new Exception(Exception::MEMBERSHIP_NOT_FOUND); } + // Default should be "false", but existing projects already rely on this being "true" $membershipsPrivacy = [ 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true, 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true, 'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? true, + 'userId' => $project->getAttribute('auths', [])['membershipsUserId'] ?? true, + 'userPhone' => $project->getAttribute('auths', [])['membershipsUserPhone'] ?? true, ]; $roles = $authorization->getRoles(); @@ -113,6 +116,16 @@ class Get extends Action $membership->setAttribute('userEmail', $memberUser->getAttribute('email')); } + if ($membershipsPrivacy['userId']) { + $membership->setAttribute('userId', $memberUser->getId()); + } else { + $membership->removeAttribute('userId'); + } + + if ($membershipsPrivacy['userPhone']) { + $membership->setAttribute('userPhone', $memberUser->getAttribute('phone')); + } + $membership->setAttribute('teamName', $team->getAttribute('name')); $response->dynamic($membership, Response::MODEL_MEMBERSHIP); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php index 364f92e1c5..7835c8051f 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php @@ -123,10 +123,13 @@ class XList extends Action $memberships = array_filter($memberships, fn (Document $membership) => !empty($membership->getAttribute('userId'))); + // Default should be "false", but existing projects already rely on this being "true" $membershipsPrivacy = [ 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true, 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true, 'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? true, + 'userId' => $project->getAttribute('auths', [])['membershipsUserId'] ?? true, + 'userPhone' => $project->getAttribute('auths', [])['membershipsUserPhone'] ?? true, ]; $roles = $authorization->getRoles(); @@ -167,6 +170,16 @@ class XList extends Action $membership->setAttribute('userEmail', $memberUser->getAttribute('email')); } + if ($membershipsPrivacy['userId']) { + $membership->setAttribute('userId', $memberUser->getId()); + } else { + $membership->removeAttribute('userId'); + } + + if ($membershipsPrivacy['userPhone']) { + $membership->setAttribute('userPhone', $memberUser->getAttribute('phone')); + } + $membership->setAttribute('teamName', $team->getAttribute('name')); return $membership; diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php index 69da270e19..c5a8d8f43f 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php @@ -104,7 +104,7 @@ class Get extends Action $privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY'); $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID'); $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId); - $owner = $github->getOwnerName($providerInstallationId) ?? ''; + $owner = $github->getOwnerName($providerInstallationId); $projectInternalId = $project->getSequence(); @@ -121,11 +121,11 @@ class Get extends Action if (!empty($code)) { $oauth2 = new OAuth2Github(System::getEnv('_APP_VCS_GITHUB_CLIENT_ID', ''), System::getEnv('_APP_VCS_GITHUB_CLIENT_SECRET', ''), ""); - $accessToken = $oauth2->getAccessToken($code) ?? ''; - $refreshToken = $oauth2->getRefreshToken($code) ?? ''; + $accessToken = $oauth2->getAccessToken($code); + $refreshToken = $oauth2->getRefreshToken($code); $accessTokenExpiry = DateTime::addSeconds(new \DateTime(), \intval($oauth2->getAccessTokenExpiry($code))); - $personalSlug = $oauth2->getUserSlug($accessToken) ?? ''; + $personalSlug = $oauth2->getUserSlug($accessToken); $personal = $personalSlug === $owner; } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php index c30f960262..6c59d3c80a 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php @@ -114,7 +114,7 @@ trait Deployment $activate = true; } - $owner = $github->getOwnerName($providerInstallationId) ?? ''; + $owner = $github->getOwnerName($providerInstallationId); try { $repositoryName = $github->getRepositoryName($providerRepositoryId); } catch (RepositoryNotFound $e) { diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php index 7bb2dedaf5..4e7b80f5b2 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php @@ -59,7 +59,7 @@ class Get extends Action ) { $installation = $dbForPlatform->getDocument('installations', $installationId); - if ($installation === false || $installation->isEmpty()) { + if ($installation->isEmpty()) { throw new Exception(Exception::INSTALLATION_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php index 4ed4241d25..8ead94b7cb 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php @@ -73,9 +73,9 @@ class XList extends Action $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID'); $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId); - $owner = $github->getOwnerName($providerInstallationId) ?? ''; + $owner = $github->getOwnerName($providerInstallationId); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $github->getRepositoryName($providerRepositoryId); if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } @@ -83,7 +83,7 @@ class XList extends Action throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } - $branches = $github->listBranches($owner, $repositoryName) ?? []; + $branches = $github->listBranches($owner, $repositoryName); $response->dynamic(new Document([ 'branches' => \array_map(function ($branch) { diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Contents/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Contents/Get.php index a0dcec8590..89b38e7b79 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Contents/Get.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Contents/Get.php @@ -79,7 +79,7 @@ class Get extends Action $owner = $github->getOwnerName($providerInstallationId); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $github->getRepositoryName($providerRepositoryId); if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php index 04003812f8..1918e454a4 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php @@ -152,7 +152,7 @@ class Create extends Action throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Provider Error: ' . $repository['message']); } - $repository['id'] = \strval($repository['id']) ?? ''; + $repository['id'] = \strval($repository['id']); $repository['pushedAt'] = $repository['pushed_at'] ?? ''; $repository['organization'] = $installation->getAttribute('organization', ''); $repository['provider'] = $installation->getAttribute('provider', ''); diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php index 6295fcd03b..aa7d7ae95c 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php @@ -121,7 +121,7 @@ class Create extends Action $owner = $github->getOwnerName($providerInstallationId); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $github->getRepositoryName($providerRepositoryId); if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php index 52b94cd525..ec135dc96e 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php @@ -73,9 +73,9 @@ class Get extends Action $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID'); $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId); - $owner = $github->getOwnerName($providerInstallationId) ?? ''; + $owner = $github->getOwnerName($providerInstallationId); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $github->getRepositoryName($providerRepositoryId); if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } @@ -97,7 +97,7 @@ class Get extends Action } } - $repository['id'] = \strval($repository['id']) ?? ''; + $repository['id'] = \strval($repository['id']); $repository['pushedAt'] = $repository['pushed_at'] ?? ''; $repository['organization'] = $installation->getAttribute('organization', ''); $repository['provider'] = $installation->getAttribute('provider', ''); diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index dd7bed0137..3e11a4060c 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -109,7 +109,7 @@ class Install extends Action file_put_contents($this->path . '/' . $composeFileName . '.' . $time . '.backup', $data); $compose = new Compose($data); $appwrite = $compose->getService('appwrite'); - $oldVersion = $appwrite?->getImageVersion(); + $oldVersion = $appwrite->getImageVersion(); try { $ports = $compose->getService('traefik')->getPorts(); } catch (\Throwable $th) { @@ -122,10 +122,6 @@ class Install extends Action if ($oldVersion) { foreach ($compose->getServices() as $service) { - if (!$service) { - continue; - } - $env = $service->getEnvironment()->list(); foreach ($env as $key => $value) { @@ -177,9 +173,6 @@ class Install extends Action // can be detected by the DB service name or _APP_DB_HOST. $existingDatabase = null; foreach ($compose->getServices() as $service) { - if (!$service) { - continue; - } $svcEnv = $service->getEnvironment()->list(); if (isset($svcEnv['_APP_DB_ADAPTER'])) { $existingDatabase = $svcEnv['_APP_DB_ADAPTER']; @@ -229,8 +222,8 @@ class Install extends Action $assistantExistsInOldCompose = false; if ($existingInstallation) { try { - $assistantService = $compose->getService('appwrite-assistant'); - $assistantExistsInOldCompose = $assistantService !== null; + $compose->getService('appwrite-assistant'); + $assistantExistsInOldCompose = true; } catch (\Throwable) { /* ignore */ } @@ -290,7 +283,7 @@ class Install extends Action continue; } - if ($var['name'] === '_APP_DB_ADAPTER' && $data !== false) { + if ($var['name'] === '_APP_DB_ADAPTER' && $data !== '') { $userInput[$var['name']] = $database; continue; } @@ -334,7 +327,7 @@ class Install extends Action @unlink(InstallerServer::INSTALLER_COMPLETE_FILE); - $state = new State([]); + $state = new State(); $state->clearStaleLock(); $installerConfig = $this->readInstallerConfig(); @@ -608,7 +601,7 @@ class Install extends Action $this->copyMongoEntrypointIfNeeded(); } - if (!$noStart && $startIndex <= 2) { + if (!$noStart) { $currentStep = InstallerServer::STEP_DOCKER_CONTAINERS; $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_IN_PROGRESS, $messages); $this->runDockerCompose($input, $isLocalInstall, $useExistingConfig, $isCLI, $progress, $isUpgrade); @@ -838,7 +831,7 @@ class Install extends Action 'email' => $email, 'domain' => $domain, 'database' => $database, - 'ip' => ($hostIp !== false && $hostIp !== $domain) ? $hostIp : null, + 'ip' => ($hostIp !== $domain) ? $hostIp : null, 'os' => php_uname('s') . ' ' . php_uname('r'), 'arch' => php_uname('m'), 'cpus' => ((int) trim((string) \shell_exec('nproc'))) ?: null, @@ -1365,9 +1358,6 @@ class Install extends Action } foreach ($compose->getServices() as $service) { - if (!$service) { - continue; - } $env = $service->getEnvironment()->list(); $host = $env['_APP_DB_HOST'] ?? null; if ($host !== null && in_array($host, $dbServices, true)) { diff --git a/src/Appwrite/Platform/Tasks/Interval.php b/src/Appwrite/Platform/Tasks/Interval.php index f5502a5986..7308dc003f 100644 --- a/src/Appwrite/Platform/Tasks/Interval.php +++ b/src/Appwrite/Platform/Tasks/Interval.php @@ -75,7 +75,6 @@ class Interval extends Action protected function getTasks(): array { $intervalDomainVerification = (int) System::getEnv('_APP_INTERVAL_DOMAIN_VERIFICATION', '120'); // 2 minutes - $intervalCleanupStaleExecutions = (int) System::getEnv('_APP_INTERVAL_CLEANUP_STALE_EXECUTIONS', '300'); // 5 minutes return [ [ @@ -135,50 +134,4 @@ class Interval extends Action Span::add("interval.domainVerification.processed", $processed); Span::add("interval.domainVerification.failed", $failed); } - - private function cleanupStaleExecutions(Database $dbForPlatform, callable $getProjectDB): void - { - $staleThreshold = DatabaseDateTime::addSeconds(new DateTime(), -1200); // 20 minutes ago - - $scanned = 0; - $processed = 0; - $failed = 0; - - $dbForPlatform->foreach( - 'projects', - function (Document $project) use ($getProjectDB, $staleThreshold, &$scanned, &$processed, &$failed) { - try { - $dbForProject = $getProjectDB($project); - - $staleExecutions = $dbForProject->find('executions', [ - Query::equal('status', ['processing']), - Query::lessThan('$createdAt', $staleThreshold), - Query::limit(100), - ]); - - $scanned += \count($staleExecutions); - - if (\count($staleExecutions) === 0) { - return; - } - - foreach ($staleExecutions as $execution) { - $dbForProject->updateDocument('executions', $execution->getId(), new Document(['status' => 'failed', 'errors' => 'Execution timed out'])); - } - - $processed++; - } catch (\Throwable $th) { - $failed++; - } - }, - [ - Query::equal('region', [System::getEnv('_APP_REGION', 'default')]), - Query::limit(100), - ] - ); - - Span::add("interval.cleanupStaleExecutions.scanned", $scanned); - Span::add("interval.cleanupStaleExecutions.processed", $processed); - Span::add("interval.cleanupStaleExecutions.failed", $failed); - } } diff --git a/src/Appwrite/Platform/Tasks/SDKs.php b/src/Appwrite/Platform/Tasks/SDKs.php index aac738915d..b1580f0e68 100644 --- a/src/Appwrite/Platform/Tasks/SDKs.php +++ b/src/Appwrite/Platform/Tasks/SDKs.php @@ -182,7 +182,7 @@ class SDKs extends Action Console::log(''); - if ($createRelease && ! $examplesOnly) { + if ($createRelease) { Console::info("━━━ {$language['name']} SDK ({$platform['name']}, {$language['version']}) ━━━"); $changelog = $language['changelog'] ?? ''; $changelog = ($changelog) ? \file_get_contents($changelog) : '# Change Log'; @@ -1150,7 +1150,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND if (! empty($prListOutput[0])) { $parts = \explode(' ', trim($prListOutput[0]), 2); - $prNumber = $parts[0] ?? ''; + $prNumber = $parts[0]; $prUrl = $parts[1] ?? ''; } } diff --git a/src/Appwrite/Platform/Tasks/ScheduleBase.php b/src/Appwrite/Platform/Tasks/ScheduleBase.php index c55e3d4a6a..1213f78924 100644 --- a/src/Appwrite/Platform/Tasks/ScheduleBase.php +++ b/src/Appwrite/Platform/Tasks/ScheduleBase.php @@ -73,7 +73,7 @@ abstract class ScheduleBase extends Action * 2. Create timer that sync all changes from 'schedules' collection to local copy. Only reading changes thanks to 'resourceUpdatedAt' attribute * 3. Create timer that prepares coroutines for soon-to-execute schedules. When it's ready, coroutine sleeps until exact time before sending request to worker. */ - public function action(BrokerPool $publisher, BrokerPool $publisherMigrations, BrokerPool $publisherFunctions, BrokerPool $publisherMessaging, callable $isResourceBlocked, Database $dbForPlatform, callable $getProjectDB, Telemetry $telemetry): void + public function action(BrokerPool $publisher, BrokerPool $publisherMigrations, BrokerPool $publisherFunctions, BrokerPool $publisherMessaging, callable $isResourceBlocked, Database $dbForPlatform, callable $getProjectDB, Telemetry $telemetry): never { Console::title(\ucfirst(static::getSupportedResource()) . ' scheduler V1'); Console::success(APP_NAME . ' ' . \ucfirst(static::getSupportedResource()) . ' scheduler v1 has started'); diff --git a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php index f867884801..75908c99c7 100644 --- a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php +++ b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php @@ -21,8 +21,6 @@ class ScheduleFunctions extends ScheduleBase public const UPDATE_TIMER = 10; // seconds public const ENQUEUE_TIMER = 60; // seconds - private ?float $lastEnqueueUpdate = null; - public static function getName(): string { return 'schedule-functions'; @@ -43,7 +41,10 @@ class ScheduleFunctions extends ScheduleBase $timerStart = \microtime(true); $time = DateTime::now(); - $enqueueDiff = $this->lastEnqueueUpdate === null ? 0 : $timerStart - $this->lastEnqueueUpdate; + // TODO: Track the last enqueue timestamp to subtract ENQUEUE_TIMER drift from + // the time frame. Previously this used $this->lastEnqueueUpdate as a property + // but enabling the assignment broke scheduling, so the diff stays 0. + $enqueueDiff = 0; $timeFrame = DateTime::addSeconds(new \DateTime(), static::ENQUEUE_TIMER - $enqueueDiff); Console::log("Enqueue tick: started at: $time (with diff $enqueueDiff)"); @@ -128,9 +129,6 @@ class ScheduleFunctions extends ScheduleBase $timerEnd = \microtime(true); - // TODO: This was a bug before because it wasn't passed by reference, enabling it breaks scheduling - //$this->lastEnqueueUpdate = $timerStart; - Console::log("Enqueue tick: {$total} executions were enqueued in " . ($timerEnd - $timerStart) . " seconds"); } } diff --git a/src/Appwrite/Platform/Tasks/Screenshot.php b/src/Appwrite/Platform/Tasks/Screenshot.php index 59e0b11c89..3b50ed7e00 100644 --- a/src/Appwrite/Platform/Tasks/Screenshot.php +++ b/src/Appwrite/Platform/Tasks/Screenshot.php @@ -40,9 +40,6 @@ class Screenshot extends Action throw new \Exception('Invalid JSON in --variables flag'); } } - if ($variables === null) { - throw new \Exception('Invalid JSON in --variables flag'); - } $templates = Config::getParam('templates-site', []); diff --git a/src/Appwrite/Platform/Tasks/Specs.php b/src/Appwrite/Platform/Tasks/Specs.php index 2c03ad3108..82020b05b1 100644 --- a/src/Appwrite/Platform/Tasks/Specs.php +++ b/src/Appwrite/Platform/Tasks/Specs.php @@ -297,6 +297,150 @@ class Specs extends Action ]; } + protected function verifyParsedSpec(array $spec): void + { + $services = []; + foreach ($spec['tags'] ?? [] as $tag) { + if (!\is_array($tag)) { + continue; + } + + $service = $tag['name'] ?? null; + if (!\is_string($service) || $service === '') { + continue; + } + + $services[$this->normalizeSdkName($service)] = $service; + } + + if (empty($services)) { + return; + } + + $enums = []; + $this->collectSpecEnumNames($spec, $enums); + + if (empty($enums)) { + return; + } + + $overlaps = []; + foreach ($services as $normalized => $service) { + if (!isset($enums[$normalized])) { + continue; + } + + foreach ($enums[$normalized] as $enum) { + $overlaps[] = "service '{$service}' with enum '{$enum}'"; + } + } + + if (!empty($overlaps)) { + throw new \RuntimeException( + 'Spec service names must not overlap enum names. Overlaps: ' + . \implode(', ', \array_unique($overlaps)) + ); + } + } + + private function collectSpecEnumNames(array $node, array &$enums, ?string $fallbackName = null, bool $skipCurrentEnum = false): void + { + if (!$skipCurrentEnum && isset($node['enum']) && \is_array($node['enum'])) { + $enumName = $this->getExplicitSpecEnumName($node) + ?? $this->getFallbackSpecEnumName($node, $fallbackName); + + if (!\is_null($enumName)) { + $this->addSpecEnumName($enums, $enumName); + } + } + + $itemsEnumHandled = false; + if ( + isset($node['items']) + && \is_array($node['items']) + && isset($node['items']['enum']) + && \is_array($node['items']['enum']) + ) { + $enumName = $this->getExplicitSpecEnumName($node['items']) + ?? $this->getExplicitSpecEnumName($node) + ?? $this->getFallbackSpecEnumName($node, $fallbackName); + + if (!\is_null($enumName)) { + $this->addSpecEnumName($enums, $enumName); + } + + $itemsEnumHandled = true; + } + + $explicitEnumName = $this->getExplicitSpecEnumName($node); + if (!\is_null($explicitEnumName) && !isset($node['enum']) && !$itemsEnumHandled) { + $this->addSpecEnumName($enums, $explicitEnumName); + } + + foreach ($node as $key => $value) { + if (!\is_array($value)) { + continue; + } + + $this->collectSpecEnumNames( + $value, + $enums, + $this->getChildSpecEnumFallbackName($node, $key, $value, $fallbackName), + $key === 'items' && $itemsEnumHandled + ); + } + } + + private function addSpecEnumName(array &$enums, string $name): void + { + $enums[$this->normalizeSdkName($name)][] = $this->formatSdkName($name); + } + + private function getExplicitSpecEnumName(array $node): ?string + { + $enumName = $node['x-enum-name'] ?? null; + + return \is_string($enumName) && $enumName !== '' ? $enumName : null; + } + + private function getFallbackSpecEnumName(array $node, ?string $fallbackName): ?string + { + $name = $node['name'] ?? $fallbackName; + + return \is_string($name) && $name !== '' ? $name : null; + } + + private function getChildSpecEnumFallbackName( + array $parent, + int|string $key, + array $child, + ?string $fallbackName + ): ?string { + if (isset($child['name']) && \is_string($child['name']) && $child['name'] !== '') { + return $child['name']; + } + + if ($key === 'schema' || $key === 'items') { + return $this->getFallbackSpecEnumName($parent, $fallbackName); + } + + if (\is_string($key) && !\in_array($key, ['components', 'content', 'definitions', 'delete', 'get', 'head', 'options', 'parameters', 'patch', 'paths', 'post', 'properties', 'put', 'responses'], true)) { + return $key; + } + + return $fallbackName; + } + + private function formatSdkName(string $name): string + { + return \str_replace(' ', '', \ucwords(\str_replace(['-', '_', '/'], ' ', $name))); + } + + private function normalizeSdkName(string $name): string + { + return \strtolower((string) \preg_replace('/[^a-z0-9]/i', '', $name)); + } + public function getSDKPlatformsForRouteSecurity(array $routeSecurity): array { $sdkPlatforms = []; @@ -483,6 +627,7 @@ class Specs extends Action try { $parsedSpecs = $specs->parse(); + $this->verifyParsedSpec($parsedSpecs); } catch (\RuntimeException $e) { throw new \RuntimeException("Spec generation failed for {$platform} ({$format}): " . $e->getMessage(), 0, $e); } diff --git a/src/Appwrite/Platform/Tasks/Upgrade.php b/src/Appwrite/Platform/Tasks/Upgrade.php index f49674896e..bde73fd05c 100644 --- a/src/Appwrite/Platform/Tasks/Upgrade.php +++ b/src/Appwrite/Platform/Tasks/Upgrade.php @@ -65,9 +65,6 @@ class Upgrade extends Install $database = null; $compose = new Compose($data); foreach ($compose->getServices() as $service) { - if (!$service) { - continue; - } $env = $service->getEnvironment()->list(); if (isset($env['_APP_DB_ADAPTER'])) { $database = $env['_APP_DB_ADAPTER']; diff --git a/src/Appwrite/Platform/Workers/Audits.php b/src/Appwrite/Platform/Workers/Audits.php index e5a7950945..f6b0345381 100644 --- a/src/Appwrite/Platform/Workers/Audits.php +++ b/src/Appwrite/Platform/Workers/Audits.php @@ -58,7 +58,7 @@ class Audits extends Action */ public function action(Message $message, callable $getAudit): Commit|NoCommit { - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new Exception('Missing payload'); diff --git a/src/Appwrite/Platform/Workers/Certificates.php b/src/Appwrite/Platform/Workers/Certificates.php index 34234971d9..4d04a3c92c 100644 --- a/src/Appwrite/Platform/Workers/Certificates.php +++ b/src/Appwrite/Platform/Workers/Certificates.php @@ -94,7 +94,7 @@ class Certificates extends Action array $plan, ValidatorAuthorization $authorization, ): void { - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new Exception('Missing payload'); diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 6801d12b77..8f5397f630 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -96,7 +96,7 @@ class Deletes extends Action DeleteEvent $queueForDeletes, callable $getAudit, ): void { - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new Exception('Missing payload'); @@ -304,7 +304,8 @@ class Deletes extends Action $collectionId = match ($document->getAttribute('resourceType')) { 'function' => 'functions', 'execution' => 'executions', - 'message' => 'messages' + 'message' => 'messages', + default => throw new \Exception('Unknown resource type: ' . $document->getAttribute('resourceType')), }; try { diff --git a/src/Appwrite/Platform/Workers/Executions.php b/src/Appwrite/Platform/Workers/Executions.php index 99e20be035..404b04ce76 100644 --- a/src/Appwrite/Platform/Workers/Executions.php +++ b/src/Appwrite/Platform/Workers/Executions.php @@ -34,7 +34,7 @@ class Executions extends Action Message $message, Database $dbForProject, ): void { - $executionMessage = Execution::fromArray($message->getPayload() ?? []); + $executionMessage = Execution::fromArray($message->getPayload()); $execution = $executionMessage->execution; if ($execution->isEmpty()) { diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index 0899fbacb4..28c298b050 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -68,7 +68,7 @@ class Functions extends Action Executor $executor, callable $isResourceBlocked ): void { - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new AppwriteException( @@ -258,7 +258,7 @@ class Functions extends Action jwt: $jwt, event: null, eventData: null, - executionId: $execution->getId() ?? null + executionId: $execution->getId() ); break; } @@ -437,7 +437,7 @@ class Functions extends Action $headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $apiKey; $headers['x-appwrite-trigger'] = $trigger; $headers['x-appwrite-event'] = $event ?? ''; - $headers['x-appwrite-user-id'] = $user->getId() ?? ''; + $headers['x-appwrite-user-id'] = $user->getId(); $headers['x-appwrite-user-jwt'] = $jwt ?? ''; $headers['x-appwrite-country-code'] = ''; $headers['x-appwrite-continent-code'] = ''; @@ -488,12 +488,12 @@ class Functions extends Action // V2 vars if ($version === 'v2') { $vars = \array_merge($vars, [ - 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'] ?? '', + 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'], 'APPWRITE_FUNCTION_DATA' => $body, 'APPWRITE_FUNCTION_EVENT_DATA' => $body, - 'APPWRITE_FUNCTION_EVENT' => $headers['x-appwrite-event'] ?? '', - 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'] ?? '', - 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ?? '' + 'APPWRITE_FUNCTION_EVENT' => $headers['x-appwrite-event'], + 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'], + 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ]); } @@ -688,7 +688,7 @@ class Functions extends Action if (!empty($error)) { throw new AppwriteException( AppwriteException::GENERAL_SERVER_ERROR, - 'Function execution failed: ' . ($error ?: 'No error message provided'), + 'Function execution failed: ' . $error, $errorCode ); } diff --git a/src/Appwrite/Platform/Workers/Mails.php b/src/Appwrite/Platform/Workers/Mails.php index 32de1e50d6..5cd4639988 100644 --- a/src/Appwrite/Platform/Workers/Mails.php +++ b/src/Appwrite/Platform/Workers/Mails.php @@ -61,7 +61,7 @@ class Mails extends Action public function action(Message $message, Document $project, Registry $register, Log $log): void { Runtime::setHookFlags(SWOOLE_HOOK_ALL ^ SWOOLE_HOOK_TCP); - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new Exception('Missing payload'); @@ -173,8 +173,10 @@ class Mails extends Action $replyTo = $customMailOptions['replyToEmail'] ?? $replyTo; $replyToName = $customMailOptions['replyToName'] ?? $replyToName; } elseif (!empty($smtp)) { - $replyTo = !empty($smtp['replyTo']) ? $smtp['replyTo'] : ($smtp['senderEmail'] ?? $replyTo); - $replyToName = $smtp['senderName'] ?? $replyToName; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + $replyTo = !empty($smtpReplyToEmail) ? $smtpReplyToEmail : ($smtp['senderEmail'] ?? $replyTo); + $replyToName = !empty($smtp['replyToName']) ? $smtp['replyToName'] : ($smtp['senderName'] ?? $replyToName); } $attachments = null; diff --git a/src/Appwrite/Platform/Workers/Messaging.php b/src/Appwrite/Platform/Workers/Messaging.php index ff5eb2417a..03adebc4b5 100644 --- a/src/Appwrite/Platform/Workers/Messaging.php +++ b/src/Appwrite/Platform/Workers/Messaging.php @@ -96,7 +96,7 @@ class Messaging extends Action UsagePublisher $publisherForUsage ): void { Runtime::setHookFlags(SWOOLE_HOOK_ALL ^ SWOOLE_HOOK_TCP); - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new \Exception('Missing payload'); @@ -257,7 +257,9 @@ class Messaging extends Action $identifiersForProvider = $identifiers[$providerId]; - $adapter = match ($provider->getAttribute('type')) { + $providerType = $provider->getAttribute('type'); + + $adapter = match ($providerType) { MESSAGE_TYPE_SMS => $this->getSmsAdapter($provider), MESSAGE_TYPE_PUSH => $this->getPushAdapter($provider), MESSAGE_TYPE_EMAIL => $this->getEmailAdapter($provider), @@ -269,18 +271,17 @@ class Messaging extends Action $adapter->getMaxMessagesPerRequest() ); - return batch(\array_map(function ($batch) use ($message, $provider, $adapter, $dbForProject, $deviceForFiles, $project, $publisherForUsage) { - return function () use ($batch, $message, $provider, $adapter, $dbForProject, $deviceForFiles, $project, $publisherForUsage) { + return batch(\array_map(function ($batch) use ($message, $provider, $providerType, $adapter, $dbForProject, $deviceForFiles, $project, $publisherForUsage) { + return function () use ($batch, $message, $provider, $providerType, $adapter, $dbForProject, $deviceForFiles, $project, $publisherForUsage) { $deliveredTotal = 0; $deliveryErrors = []; $messageData = clone $message; $messageData->setAttribute('to', $batch); - $data = match ($provider->getAttribute('type')) { + $data = match ($providerType) { MESSAGE_TYPE_SMS => $this->buildSmsMessage($messageData, $provider), MESSAGE_TYPE_PUSH => $this->buildPushMessage($messageData), MESSAGE_TYPE_EMAIL => $this->buildEmailMessage($dbForProject, $messageData, $provider, $deviceForFiles, $project), - default => throw new \Exception('Provider with the requested ID is of the incorrect type') }; try { diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 118ff7acf9..cfe8d2d567 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -56,7 +56,7 @@ class Migrations extends Action protected ?Device $deviceForFiles; protected ?Document $project; - protected Document $sourceProject; + protected ?Document $sourceProject = null; /** * @var callable @@ -74,7 +74,6 @@ class Migrations extends Action */ protected array $sourceReport = []; - private string $source; /** * @var callable|null */ @@ -130,7 +129,7 @@ class Migrations extends Action array $plan, Authorization $authorization, ): void { - $migrationMessage = Migration::fromArray($message->getPayload() ?? []); + $migrationMessage = Migration::fromArray($message->getPayload()); $this->getDatabasesDB = $getDatabasesDB; $this->getProjectDB = $getProjectDB; @@ -195,9 +194,25 @@ class Migrations extends Action $migrationOptions = $migration->getAttribute('options'); /** @var Database|null $projectDB */ $projectDB = null; - if ($credentials['projectId']) { + $useAppwriteApiSource = false; + if ($source === SourceAppwrite::getName() && empty($credentials['projectId'])) { + throw new \Exception('Source projectId is required for Appwrite migrations'); + } + + if (! empty($credentials['projectId'])) { $this->sourceProject = $this->dbForPlatform->getDocument('projects', $credentials['projectId']); - $projectDB = call_user_func($this->getProjectDB, $this->sourceProject); + if ($this->sourceProject->isEmpty()) { + throw new \Exception('Source project not found for provided projectId'); + } + + $sourceRegion = $this->sourceProject->getAttribute('region', 'default'); + $destinationRegion = $this->project->getAttribute('region', 'default'); + $useAppwriteApiSource = $source === SourceAppwrite::getName() + && $destination === DestinationAppwrite::getName() + && $sourceRegion !== $destinationRegion; + if (! $useAppwriteApiSource) { + $projectDB = call_user_func($this->getProjectDB, $this->sourceProject); + } } $getDatabasesDB = fn (Document $database): Database => $this->getDatabasesDBForProject($database); @@ -233,7 +248,7 @@ class Migrations extends Action $credentials['endpoint'], $credentials['apiKey'], $getDatabasesDB, - SourceAppwrite::SOURCE_DATABASE, + $useAppwriteApiSource ? SourceAppwrite::SOURCE_API : SourceAppwrite::SOURCE_DATABASE, $projectDB, $queries ), @@ -376,6 +391,12 @@ class Migrations extends Action 'keys.write', 'platforms.read', 'platforms.write', + 'mocks.read', + 'mocks.write', + 'policies.read', + 'policies.write', + 'templates.read', + 'templates.write', ] ]); @@ -578,9 +599,10 @@ class Migrations extends Action protected function getDatabasesDBForProject(Document $database) { - if ($this->sourceProject) { + if (isset($this->sourceProject) && ! $this->sourceProject->isEmpty()) { return ($this->getDatabasesDB)($database, $this->sourceProject); } + return ($this->getDatabasesDB)($database); } diff --git a/src/Appwrite/Platform/Workers/StatsResources.php b/src/Appwrite/Platform/Workers/StatsResources.php index db214f5d32..2706d33e2a 100644 --- a/src/Appwrite/Platform/Workers/StatsResources.php +++ b/src/Appwrite/Platform/Workers/StatsResources.php @@ -68,7 +68,7 @@ class StatsResources extends Action { $this->logError = $logError; - $statsResources = StatsResourcesMessage::fromArray($message->getPayload() ?? []); + $statsResources = StatsResourcesMessage::fromArray($message->getPayload()); if ($statsResources->project->isEmpty()) { throw new Exception('Missing payload'); } diff --git a/src/Appwrite/Platform/Workers/StatsUsage.php b/src/Appwrite/Platform/Workers/StatsUsage.php index 144c429629..dad444b381 100644 --- a/src/Appwrite/Platform/Workers/StatsUsage.php +++ b/src/Appwrite/Platform/Workers/StatsUsage.php @@ -151,7 +151,7 @@ class StatsUsage extends Action { $this->getLogsDB = $getLogsDB; $this->register = $register; - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new Exception('Missing payload'); } diff --git a/src/Appwrite/Platform/Workers/Webhooks.php b/src/Appwrite/Platform/Workers/Webhooks.php index 509f0a6313..5b0497dbea 100644 --- a/src/Appwrite/Platform/Workers/Webhooks.php +++ b/src/Appwrite/Platform/Workers/Webhooks.php @@ -57,7 +57,7 @@ class Webhooks extends Action public function action(Message $message, Document $project, Database $dbForPlatform, Mail $queueForMails, UsagePublisher $publisherForUsage, Log $log, array $plan): void { $this->errors = []; - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index e68e9438ca..30df5acf52 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -40,6 +40,9 @@ abstract class Format 'license.url' => '', ]; + /** + * @var list, parameter: string, excludeKeys?: list, exclude?: bool}> + */ private const array OAUTH_PROVIDER_BLACKLIST = [ [ 'namespace' => 'account', @@ -67,6 +70,9 @@ abstract class Format ], ]; + /** + * @var list, parameter: string, excludeKeys?: list, exclude?: bool}> + */ private const array PROVIDER_USAGE_BLACKLIST = [ [ 'namespace' => 'users', @@ -78,6 +84,9 @@ abstract class Format ], ]; + /** + * @var list, parameter: string, required?: bool, nullable?: bool}> + */ private const array REQUEST_PARAMETER_OVERRIDES = [ [ 'namespace' => 'project', @@ -109,24 +118,7 @@ abstract class Format { $blacklist = []; - foreach (self::OAUTH_PROVIDER_BLACKLIST as $config) { - foreach ($config['methods'] as $method) { - $entry = [ - 'namespace' => $config['namespace'], - 'method' => $method, - 'parameter' => $config['parameter'], - ]; - if (isset($config['excludeKeys'])) { - $entry['excludeKeys'] = $config['excludeKeys']; - } - if (isset($config['exclude'])) { - $entry['exclude'] = $config['exclude']; - } - $blacklist[] = $entry; - } - } - - foreach (self::PROVIDER_USAGE_BLACKLIST as $config) { + foreach ([...self::OAUTH_PROVIDER_BLACKLIST, ...self::PROVIDER_USAGE_BLACKLIST] as $config) { foreach ($config['methods'] as $method) { $entry = [ 'namespace' => $config['namespace'], @@ -751,6 +743,15 @@ abstract class Format break; case 'project': switch ($method) { + case 'getEmailTemplate': + case 'updateEmailTemplate': + switch ($param) { + case 'templateId': + return 'EmailTemplateType'; + case 'locale': + return 'EmailTemplateLocale'; + } + break; case 'getUsage': switch ($param) { case 'period': @@ -763,7 +764,6 @@ abstract class Format switch ($method) { case 'getEmailTemplate': case 'updateEmailTemplate': - case 'deleteEmailTemplate': switch ($param) { case 'type': return 'EmailTemplateType'; @@ -959,7 +959,7 @@ abstract class Format 'nullable' => $nullable, ]; - foreach (self::REQUEST_PARAMETER_OVERRIDES as $override) { + foreach ($this->getRequestParameterOverrides() as $override) { if ( $override['namespace'] !== $service || !\in_array($method, $override['methods'], true) @@ -968,8 +968,12 @@ abstract class Format continue; } - $config['required'] = $override['required'] ?? $config['required']; - $config['nullable'] = $override['nullable'] ?? $config['nullable']; + if (isset($override['required'])) { + $config['required'] = $override['required']; + } + if (isset($override['nullable'])) { + $config['nullable'] = $override['nullable']; + } break; } @@ -978,6 +982,14 @@ abstract class Format return $config; } + /** + * @return list, parameter: string, required?: bool, nullable?: bool}> + */ + private function getRequestParameterOverrides(): array + { + return self::REQUEST_PARAMETER_OVERRIDES; + } + public function getResponseEnumName(string $model, string $param): ?string { if ($param === 'type' && \str_starts_with($model, 'platform') && $model !== 'platformList') { diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index fcff6ac2f4..66c2cd7c1c 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -114,16 +114,16 @@ class OpenAPI3 extends Format */ $consumes = [$sdk->getRequestType()->value]; - $methodName = $sdk->getMethodName() ?? \uniqid(); + $methodName = $sdk->getMethodName(); $desc = $sdk->getDescriptionFilePath() ?: $sdk->getDescription(); $produces = ($sdk->getContentType())->value; - $routeSecurity = $sdk->getAuth() ?? []; + $routeSecurity = $sdk->getAuth(); $specs = new Specs(); $sdkPlatforms = $specs->getSDKPlatformsForRouteSecurity($routeSecurity); - $namespace = $sdk->getNamespace() ?? 'default'; + $namespace = $sdk->getNamespace(); $descContents = $this->getDescriptionContents($desc); @@ -185,7 +185,7 @@ class OpenAPI3 extends Format $additionalMethod = [ 'name' => $methodObj->getMethodName(), 'namespace' => $methodObj->getNamespace(), - 'desc' => $methodObj->getDesc() ?? '', + 'desc' => $methodObj->getDesc(), 'auth' => \array_slice($methodSecurities, 0, $this->authCount), 'parameters' => [], 'required' => [], @@ -291,7 +291,7 @@ class OpenAPI3 extends Format } if (!(\is_array($model)) && $model->isNone()) { - $temp['responses'][(string)$response->getCode() ?? '500'] = [ + $temp['responses'][(string)$response->getCode()] = [ 'description' => in_array($produces, [ 'image/*', 'image/jpeg', @@ -312,7 +312,7 @@ class OpenAPI3 extends Format $usedModels[] = $m->getType(); } - $temp['responses'][(string)$response->getCode() ?? '500'] = [ + $temp['responses'][(string)$response->getCode()] = [ 'description' => $modelDescription, 'content' => [ $produces => [ @@ -326,7 +326,7 @@ class OpenAPI3 extends Format } else { // Response definition using one type $usedModels[] = $model->getType(); - $temp['responses'][(string)$response->getCode() ?? '500'] = [ + $temp['responses'][(string)$response->getCode()] = [ 'description' => $model->getName(), 'content' => [ $produces => [ @@ -339,9 +339,9 @@ class OpenAPI3 extends Format } } - if (($response->getCode() ?? 500) === 204) { - $temp['responses'][(string)$response->getCode() ?? '500']['description'] = 'No content'; - unset($temp['responses'][(string)$response->getCode() ?? '500']['content']); + if ($response->getCode() === 204) { + $temp['responses'][(string)$response->getCode()]['description'] = 'No content'; + unset($temp['responses'][(string)$response->getCode()]['content']); } } @@ -385,7 +385,7 @@ class OpenAPI3 extends Format $isNullable = $validator instanceof Nullable; $parameter = $this->getRequestParameterConfig( - $sdk->getNamespace() ?? '', + $sdk->getNamespace(), $methodName, $name, $param['optional'], @@ -404,13 +404,9 @@ class OpenAPI3 extends Format $validator = $validator->getValidator(); } - $class = $validator instanceof Validator - ? \get_class($validator) - : ''; + $class = \get_class($validator); - $base = !empty($class) - ? \get_parent_class($class) - : ''; + $base = \get_parent_class($class); switch ($base) { case \Appwrite\Utopia\Database\Validator\Queries\Base::class: @@ -469,6 +465,7 @@ class OpenAPI3 extends Format Database::VAR_POINT => '[1, 2]', Database::VAR_LINESTRING => '[[1, 2], [3, 4], [5, 6]]', Database::VAR_POLYGON => '[[[1, 2], [3, 4], [5, 6], [1, 2]]]', + default => '', }; break; case \Utopia\Emails\Validator\Email::class: @@ -619,7 +616,7 @@ class OpenAPI3 extends Format } if ($allowed && $validator->getType() === 'string') { $allValues = \array_values($validator->getList()); - $allKeys = $this->getRequestEnumKeys($sdk->getNamespace() ?? '', $methodName, $name); + $allKeys = $this->getRequestEnumKeys($sdk->getNamespace(), $methodName, $name); if ($excludeKeys !== null) { $keepIndices = []; @@ -635,7 +632,7 @@ class OpenAPI3 extends Format $enumValues = $allValues; } $node['schema']['items']['enum'] = $enumValues; - $node['schema']['items']['x-enum-name'] = $this->getRequestEnumName($sdk->getNamespace() ?? '', $methodName, $name); + $node['schema']['items']['x-enum-name'] = $this->getRequestEnumName($sdk->getNamespace(), $methodName, $name); $node['schema']['items']['x-enum-keys'] = $enumKeys; if (!empty($excludeKeys)) { @@ -643,7 +640,7 @@ class OpenAPI3 extends Format } } if ($validator->getType() === 'integer') { - $node['schema']['items']['format'] = $validator->getFormat() ?? 'int32'; + $node['schema']['items']['format'] = $validator->getFormat(); } } else { $node['schema']['type'] = $validator->getType(); @@ -673,7 +670,7 @@ class OpenAPI3 extends Format } if ($allowed && $validator->getType() === 'string') { $allValues = \array_values($validator->getList()); - $allKeys = $this->getRequestEnumKeys($sdk->getNamespace() ?? '', $methodName, $name); + $allKeys = $this->getRequestEnumKeys($sdk->getNamespace(), $methodName, $name); if ($excludeKeys !== null) { $keepIndices = []; @@ -689,7 +686,7 @@ class OpenAPI3 extends Format $enumValues = $allValues; } $node['schema']['enum'] = $enumValues; - $node['schema']['x-enum-name'] = $this->getRequestEnumName($sdk->getNamespace() ?? '', $methodName, $name); + $node['schema']['x-enum-name'] = $this->getRequestEnumName($sdk->getNamespace(), $methodName, $name); $node['schema']['x-enum-keys'] = $enumKeys; if (!empty($excludeKeys)) { @@ -697,7 +694,7 @@ class OpenAPI3 extends Format } } if ($validator->getType() === 'integer') { - $node['schema']['format'] = $validator->getFormat() ?? 'int32'; + $node['schema']['format'] = $validator->getFormat(); } } break; @@ -774,25 +771,17 @@ class OpenAPI3 extends Format /// If the enum flag is Set, add the enum values to the body $body['content'][$consumes[0]]['schema']['properties'][$name]['enum'] = $node['schema']['enum']; $body['content'][$consumes[0]]['schema']['properties'][$name]['x-enum-name'] = $node['schema']['x-enum-name'] ?? null; - $body['content'][$consumes[0]]['schema']['properties'][$name]['x-enum-keys'] = $node['schema']['x-enum-keys'] ?? null; + $body['content'][$consumes[0]]['schema']['properties'][$name]['x-enum-keys'] = $node['schema']['x-enum-keys']; } if ($node['schema']['x-upload-id'] ?? false) { $body['content'][$consumes[0]]['schema']['properties'][$name]['x-upload-id'] = $node['schema']['x-upload-id']; } - if (isset($node['default'])) { - $body['content'][$consumes[0]]['schema']['properties'][$name]['default'] = $node['default']; - } - if (\array_key_exists('items', $node['schema'])) { $body['content'][$consumes[0]]['schema']['properties'][$name]['items'] = $node['schema']['items']; } - if ($node['x-global'] ?? false) { - $body['content'][$consumes[0]]['schema']['properties'][$name]['x-global'] = true; - } - if ($parameter['nullable']) { $body['content'][$consumes[0]]['schema']['properties'][$name]['x-nullable'] = true; } diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index 8d47766117..d07d957577 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -114,17 +114,17 @@ class Swagger2 extends Format $consumes = [$sdk->getRequestType()->value]; } - $methodName = $sdk->getMethodName() ?? \uniqid(); + $methodName = $sdk->getMethodName(); $desc = $sdk->getDescriptionFilePath() ?: $sdk->getDescription(); $produces = ($sdk->getContentType())->value; - $routeSecurity = $sdk->getAuth() ?? []; + $routeSecurity = $sdk->getAuth(); $specs = new Specs(); $sdkPlatforms = $specs->getSDKPlatformsForRouteSecurity($routeSecurity); $sdkPlatforms = array_values(array_unique($sdkPlatforms)); - $namespace = $sdk->getNamespace() ?? 'default'; + $namespace = $sdk->getNamespace(); $descContents = $this->getDescriptionContents($desc); @@ -193,7 +193,7 @@ class Swagger2 extends Format $additionalMethod = [ 'name' => $methodObj->getMethodName(), 'namespace' => $methodObj->getNamespace(), - 'desc' => $methodObj->getDesc() ?? '', + 'desc' => $methodObj->getDesc(), 'auth' => \array_slice($methodSecurities, 0, $this->authCount), 'parameters' => [], 'required' => [], @@ -298,7 +298,7 @@ class Swagger2 extends Format } if (!(\is_array($model)) && $model->isNone()) { - $temp['responses'][(string)$response->getCode() ?? '500'] = [ + $temp['responses'][(string)$response->getCode()] = [ 'description' => in_array($produces, [ 'image/*', 'image/jpeg', @@ -320,7 +320,7 @@ class Swagger2 extends Format foreach ($model as $m) { $usedModels[] = $m->getType(); } - $temp['responses'][(string)$response->getCode() ?? '500'] = [ + $temp['responses'][(string)$response->getCode()] = [ 'description' => $modelDescription, 'schema' => \array_filter([ 'x-oneOf' => \array_map(function ($m) { @@ -332,7 +332,7 @@ class Swagger2 extends Format } else { // Response definition using one type $usedModels[] = $model->getType(); - $temp['responses'][(string)$response->getCode() ?? '500'] = [ + $temp['responses'][(string)$response->getCode()] = [ 'description' => $model->getName(), 'schema' => [ '$ref' => '#/definitions/' . $model->getType(), @@ -341,9 +341,9 @@ class Swagger2 extends Format } } - if (in_array($response->getCode() ?? 500, [204, 301, 302, 308], true)) { - $temp['responses'][(string)$response->getCode() ?? '500']['description'] = 'No content'; - unset($temp['responses'][(string)$response->getCode() ?? '500']['schema']); + if (in_array($response->getCode(), [204, 301, 302, 308], true)) { + $temp['responses'][(string)$response->getCode()]['description'] = 'No content'; + unset($temp['responses'][(string)$response->getCode()]['schema']); } } @@ -387,7 +387,7 @@ class Swagger2 extends Format $isNullable = $validator instanceof Nullable; $parameter = $this->getRequestParameterConfig( - $sdk->getNamespace() ?? '', + $sdk->getNamespace(), $methodName, $name, $param['optional'], @@ -406,13 +406,9 @@ class Swagger2 extends Format $validator = $validator->getValidator(); } - $class = $validator instanceof Validator - ? \get_class($validator) - : ''; + $class = \get_class($validator); - $base = !empty($class) - ? \get_parent_class($class) - : ''; + $base = \get_parent_class($class); switch ($base) { case \Appwrite\Utopia\Database\Validator\Queries\Base::class: @@ -471,6 +467,7 @@ class Swagger2 extends Format Database::VAR_POINT => '[1, 2]', Database::VAR_LINESTRING => '[[1, 2], [3, 4], [5, 6]]', Database::VAR_POLYGON => '[[[1, 2], [3, 4], [5, 6], [1, 2]]]', + default => '', }; break; case \Utopia\Emails\Validator\Email::class: @@ -624,7 +621,7 @@ class Swagger2 extends Format } } if ($validator->getType() === 'integer') { - $node['items']['format'] = $validator->getFormat() ?? 'int32'; + $node['items']['format'] = $validator->getFormat(); } } else { $node['type'] = $validator->getType(); @@ -672,7 +669,7 @@ class Swagger2 extends Format } } if ($validator->getType() === 'integer') { - $node['format'] = $validator->getFormat() ?? 'int32'; + $node['format'] = $validator->getFormat(); } } break; @@ -758,11 +755,7 @@ class Swagger2 extends Format /// If the enum flag is Set, add the enum values to the body $body['schema']['properties'][$name]['enum'] = $node['enum']; $body['schema']['properties'][$name]['x-enum-name'] = $node['x-enum-name'] ?? null; - $body['schema']['properties'][$name]['x-enum-keys'] = $node['x-enum-keys'] ?? null; - } - - if ($node['x-global'] ?? false) { - $body['schema']['properties'][$name]['x-global'] = true; + $body['schema']['properties'][$name]['x-enum-keys'] = $node['x-enum-keys']; } if ($parameter['nullable']) { diff --git a/src/Appwrite/Utopia/Database/Validator/Attributes.php b/src/Appwrite/Utopia/Database/Validator/Attributes.php index f8bdd01103..16bf0909d2 100644 --- a/src/Appwrite/Utopia/Database/Validator/Attributes.php +++ b/src/Appwrite/Utopia/Database/Validator/Attributes.php @@ -188,13 +188,13 @@ class Attributes extends Validator } // Validate required and default conflict - if (isset($attribute['required']) && $attribute['required'] === true && isset($attribute['default']) && $attribute['default'] !== null) { + if (isset($attribute['required']) && $attribute['required'] === true && isset($attribute['default'])) { $this->message = "Attribute '" . $attribute['key'] . "' cannot have a default value when required is true"; return false; } // Validate array and default conflict - if (isset($attribute['array']) && $attribute['array'] === true && isset($attribute['default']) && $attribute['default'] !== null) { + if (isset($attribute['array']) && $attribute['array'] === true && isset($attribute['default'])) { $this->message = "Attribute '" . $attribute['key'] . "' cannot have a default value when array is true"; return false; } @@ -331,7 +331,7 @@ class Attributes extends Validator } // Validate default exists in elements - if (isset($attribute['default']) && $attribute['default'] !== null) { + if (isset($attribute['default'])) { if (!in_array($attribute['default'], $attribute['elements'], true)) { $this->message = "Default value for enum attribute '" . $attribute['key'] . "' must be one of the provided elements"; return false; diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php b/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php index 07e27f06cb..587ad58ea4 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php @@ -51,18 +51,25 @@ class Webhooks extends Base */ public function isValid($value): bool { - if (\is_array($value)) { - foreach ($value as &$queryString) { - if (!\is_string($queryString)) { - continue; - } - foreach (self::ATTRIBUTE_ALIASES as $alias => $dbName) { - $queryString = \str_replace('"' . $alias . '"', '"' . $dbName . '"', $queryString); - } - } - unset($queryString); + return parent::isValid($this->normalizeAliases($value)); + } + + private function normalizeAliases(mixed $value): mixed + { + if (!\is_array($value)) { + return $value; } - return parent::isValid($value); + foreach ($value as &$queryString) { + if (!\is_string($queryString)) { + continue; + } + foreach (self::ATTRIBUTE_ALIASES as $alias => $dbName) { + $queryString = \str_replace('"' . $alias . '"', '"' . $dbName . '"', $queryString); + } + } + unset($queryString); + + return $value; } } diff --git a/src/Appwrite/Utopia/Fetch/BodyMultipart.php b/src/Appwrite/Utopia/Fetch/BodyMultipart.php index ee482a7d9e..90732eb7a1 100644 --- a/src/Appwrite/Utopia/Fetch/BodyMultipart.php +++ b/src/Appwrite/Utopia/Fetch/BodyMultipart.php @@ -64,7 +64,7 @@ class BodyMultipart $partHeaderArray = \explode(':', $partHeader, 2); - $partHeaderName = \strtolower($partHeaderArray[0] ?? ''); + $partHeaderName = \strtolower($partHeaderArray[0]); $partHeaderValue = $partHeaderArray[1] ?? ''; if ($partHeaderName == "content-disposition") { $dispositionChunks = \explode("; ", $partHeaderValue); @@ -92,7 +92,7 @@ class BodyMultipart */ public function getParts(): array { - return $this->parts ?? []; + return $this->parts; } public function getPart(string $key, mixed $default = ''): mixed diff --git a/src/Appwrite/Utopia/Request.php b/src/Appwrite/Utopia/Request.php index 32f0fa89a9..66ac4ca932 100644 --- a/src/Appwrite/Utopia/Request.php +++ b/src/Appwrite/Utopia/Request.php @@ -18,6 +18,7 @@ class Request extends UtopiaRequest */ private array $filters = []; private ?Route $route = null; + private ?array $filteredParams = null; public function __construct(SwooleRequest $request) { @@ -32,6 +33,10 @@ class Request extends UtopiaRequest */ public function getParams(): array { + if ($this->filteredParams !== null) { + return $this->filteredParams; + } + $parameters = parent::getParams(); if (!$this->hasFilters() || !$this->hasRoute()) { @@ -49,6 +54,7 @@ class Request extends UtopiaRequest foreach ($this->getFilters() as $filter) { $parameters = $filter->parse($parameters, $id); } + $this->filteredParams = $parameters; return $parameters; } @@ -79,6 +85,7 @@ class Request extends UtopiaRequest $parameters = $filter->parse($parameters, $id); } + $this->filteredParams = $parameters; return $parameters; } @@ -92,6 +99,7 @@ class Request extends UtopiaRequest public function addFilter(Filter $filter): void { $this->filters[] = $filter; + $this->filteredParams = null; } /** @@ -112,6 +120,7 @@ class Request extends UtopiaRequest public function resetFilters(): void { $this->filters = []; + $this->filteredParams = null; } /** @@ -134,6 +143,7 @@ class Request extends UtopiaRequest public function setRoute(?Route $route): void { $this->route = $route; + $this->filteredParams = null; } /** diff --git a/src/Appwrite/Utopia/Request/Filter.php b/src/Appwrite/Utopia/Request/Filter.php index 4bd9b394a0..638d6f993a 100644 --- a/src/Appwrite/Utopia/Request/Filter.php +++ b/src/Appwrite/Utopia/Request/Filter.php @@ -45,12 +45,6 @@ abstract class Filter */ public function getParamValue(string $key, mixed $default = ''): mixed { - try { - $value = $this->params[$key] ?? $default; - } catch (\Exception $e) { - $value = $default; - } - - return $value; + return $this->params[$key] ?? $default; } } diff --git a/src/Appwrite/Utopia/Request/Filters/V20.php b/src/Appwrite/Utopia/Request/Filters/V20.php index e3d5fe2f79..6b1da2709a 100644 --- a/src/Appwrite/Utopia/Request/Filters/V20.php +++ b/src/Appwrite/Utopia/Request/Filters/V20.php @@ -10,6 +10,18 @@ use Utopia\Database\Query; class V20 extends Filter { + /** + * Per-instance (request-scoped) memo of the `attributes` array for a given + * `(databaseNamespace, collectionId)`. Avoids re-fetching the same collection + * document when multiple relationships in the same schema point at it, and + * when `parse()` is re-entered before `Request::getParams()` memoization warms. + * + * A `null` value means we already tried and the collection was missing or errored. + * + * @var array>|null> + */ + private array $collectionAttributesCache = []; + // Convert 1.7 params to 1.8 public function parse(array $content, string $model): array { @@ -58,7 +70,7 @@ class V20 extends Filter throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } - $selections = Query::groupByType($parsed)['selections'] ?? []; + $selections = Query::groupByType($parsed)['selections']; // Check if we need to add wildcard + relationships // This happens when: @@ -106,36 +118,21 @@ class V20 extends Filter * Recursively includes nested relationships up to 3 levels deep. * Prevents infinite loops by tracking all visited collections in the current path. */ - private function getRelatedCollectionKeys( - ?string $databaseId = null, - ?string $collectionId = null, - ?string $prefix = null, - int $depth = 1, - array $visited = [] - ): array { - $databaseId ??= $this->getParamValue('databaseId'); - $collectionId ??= $this->getParamValue('collectionId'); + private function getRelatedCollectionKeys(): array + { + $databaseId = $this->getParamValue('databaseId'); + $collectionId = $this->getParamValue('collectionId'); - if ( - empty($databaseId) || - empty($collectionId) || - $depth > Database::RELATION_MAX_DEPTH - ) { + if (empty($databaseId) || empty($collectionId)) { return []; } - // Check if we've already visited this collection in the current path to prevent cycles - if (in_array($collectionId, $visited)) { - return []; - } - - $visited[] = $collectionId; - $dbForProject = $this->getDbForProject(); if ($dbForProject === null) { return []; } + // Resolve the database namespace once, outside the recursion. try { $database = $dbForProject->getAuthorization()->skip(fn () => $dbForProject->getDocument( 'databases', @@ -148,19 +145,42 @@ class V20 extends Filter return []; } - try { - $collection = $database = $dbForProject->getAuthorization()->skip(fn () => $dbForProject->getDocument( - 'database_' . $database->getSequence(), - $collectionId - )); - if ($collection->isEmpty()) { - return []; - } - } catch (\Throwable) { + $databaseNamespace = 'database_' . $database->getSequence(); + + return $this->walkRelatedCollectionKeys( + $dbForProject, + $databaseNamespace, + $collectionId, + null, + 1, + [] + ); + } + + private function walkRelatedCollectionKeys( + Database $dbForProject, + string $databaseNamespace, + string $collectionId, + ?string $prefix, + int $depth, + array $visited + ): array { + if ($depth > Database::RELATION_MAX_DEPTH) { return []; } - $attributes = $collection->getAttribute('attributes', []); + // Check if we've already visited this collection in the current path to prevent cycles + if (in_array($collectionId, $visited, true)) { + return []; + } + + $attributes = $this->getCollectionAttributes($dbForProject, $databaseNamespace, $collectionId); + if ($attributes === null) { + return []; + } + + $visited[] = $collectionId; + $relationshipKeys = []; foreach ($attributes as $attr) { @@ -176,27 +196,54 @@ class V20 extends Filter $relatedCollectionId = $attr['relatedCollection'] ?? null; // Skip this relationship entirely if it points to an already visited collection - if ($relatedCollectionId && in_array($relatedCollectionId, $visited)) { + if ($relatedCollectionId && in_array($relatedCollectionId, $visited, true)) { continue; } - // Add the wildcard select for this relationship $relationshipKeys[] = $fullKey . '.*'; - // Continue recursively if we have a related collection if ($relatedCollectionId) { - $nestedKeys = $this->getRelatedCollectionKeys( - $databaseId, + $nestedKeys = $this->walkRelatedCollectionKeys( + $dbForProject, + $databaseNamespace, $relatedCollectionId, $fullKey, $depth + 1, $visited ); - $relationshipKeys = \array_merge($relationshipKeys, $nestedKeys); } } return \array_values(\array_unique($relationshipKeys)); } + + /** + * @return array>|null + */ + private function getCollectionAttributes( + Database $dbForProject, + string $databaseNamespace, + string $collectionId + ): ?array { + $cacheKey = $databaseNamespace . ':' . $collectionId; + if (\array_key_exists($cacheKey, $this->collectionAttributesCache)) { + return $this->collectionAttributesCache[$cacheKey]; + } + + try { + $collection = $dbForProject->getAuthorization()->skip(fn () => $dbForProject->getDocument( + $databaseNamespace, + $collectionId + )); + } catch (\Throwable) { + return $this->collectionAttributesCache[$cacheKey] = null; + } + + if ($collection->isEmpty()) { + return $this->collectionAttributesCache[$cacheKey] = null; + } + + return $this->collectionAttributesCache[$cacheKey] = $collection->getAttribute('attributes', []); + } } diff --git a/src/Appwrite/Utopia/Request/Filters/V23.php b/src/Appwrite/Utopia/Request/Filters/V23.php new file mode 100644 index 0000000000..e509900417 --- /dev/null +++ b/src/Appwrite/Utopia/Request/Filters/V23.php @@ -0,0 +1,110 @@ +parseEmailTemplate($content); + break; + case 'project.updateEmailTemplate': + $content = $this->parseEmailTemplate($content); + $content = $this->parseReplyTo($content); + break; + case 'project.updateSMTP': + $content = $this->parseReplyTo($content); + break; + case 'project.updateMembershipPrivacyPolicy': + $content = $this->parseUpdateMembershipPrivacyPolicy($content); + break; + case 'project.updateSessionAlertPolicy': + $content = $this->parseUpdateSessionAlertPolicy($content); + break; + case 'project.updateUserLimitPolicy': + case 'project.updatePasswordHistoryPolicy': + case 'project.updateSessionLimitPolicy': + $content = $this->parseLimitToTotal($content); + break; + case 'project.updateAuthMethod': + $content = $this->parseUpdateAuthMethod($content); + break; + } + + return $content; + } + + protected function parseUpdateMembershipPrivacyPolicy(array $content): array + { + $content['userId'] = false; + $content['userPhone'] = false; + + if (isset($content['mfa'])) { + $content['userMFA'] = $content['mfa']; + unset($content['mfa']); + } + + return $content; + } + + protected function parseUpdateSessionAlertPolicy(array $content): array + { + if (isset($content['alerts'])) { + $content['enabled'] = $content['alerts']; + unset($content['alerts']); + } + + return $content; + } + + protected function parseUpdateAuthMethod(array $content): array + { + if (isset($content['status'])) { + $content['enabled'] = $content['status']; + unset($content['status']); + } + + if (isset($content['method'])) { + $content['methodId'] = $content['method']; + unset($content['method']); + } + + return $content; + } + + protected function parseLimitToTotal(array $content): array + { + if (isset($content['limit'])) { + $content['total'] = $content['limit'] === 0 ? null : $content['limit']; + unset($content['limit']); + } + + return $content; + } + + protected function parseEmailTemplate(array $content): array + { + if (isset($content['type'])) { + $content['templateId'] = $content['type']; + unset($content['type']); + } + + return $content; + } + + protected function parseReplyTo(array $content): array + { + if (isset($content['replyTo'])) { + $content['replyToEmail'] = $content['replyTo']; + unset($content['replyTo']); + } + + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index d747373b59..c4e616ea12 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -254,6 +254,17 @@ class Response extends SwooleResponse public const MODEL_DEV_KEY = 'devKey'; public const MODEL_DEV_KEY_LIST = 'devKeyList'; public const MODEL_MOCK_NUMBER = 'mockNumber'; + public const MODEL_MOCK_NUMBER_LIST = 'mockNumberList'; + public const MODEL_POLICY_LIST = 'policyList'; + public const MODEL_POLICY_PASSWORD_DICTIONARY = 'policyPasswordDictionary'; + public const MODEL_POLICY_PASSWORD_HISTORY = 'policyPasswordHistory'; + public const MODEL_POLICY_PASSWORD_PERSONAL_DATA = 'policyPasswordPersonalData'; + public const MODEL_POLICY_SESSION_ALERT = 'policySessionAlert'; + public const MODEL_POLICY_SESSION_DURATION = 'policySessionDuration'; + public const MODEL_POLICY_SESSION_INVALIDATION = 'policySessionInvalidation'; + public const MODEL_POLICY_SESSION_LIMIT = 'policySessionLimit'; + public const MODEL_POLICY_USER_LIMIT = 'policyUserLimit'; + public const MODEL_POLICY_MEMBERSHIP_PRIVACY = 'policyMembershipPrivacy'; public const MODEL_AUTH_PROVIDER = 'authProvider'; public const MODEL_AUTH_PROVIDER_LIST = 'authProviderList'; public const MODEL_PLATFORM_APPLE = 'platformApple'; @@ -266,6 +277,7 @@ class Response extends SwooleResponse public const MODEL_VARIABLE_LIST = 'variableList'; public const MODEL_VCS = 'vcs'; public const MODEL_EMAIL_TEMPLATE = 'emailTemplate'; + public const MODEL_EMAIL_TEMPLATE_LIST = 'emailTemplateList'; // Health public const MODEL_HEALTH_STATUS = 'healthStatus'; diff --git a/src/Appwrite/Utopia/Response/Filters/V16.php b/src/Appwrite/Utopia/Response/Filters/V16.php index 7eb3ec6eb3..74bae97abb 100644 --- a/src/Appwrite/Utopia/Response/Filters/V16.php +++ b/src/Appwrite/Utopia/Response/Filters/V16.php @@ -40,7 +40,7 @@ class V16 extends Filter } if (isset($content['buildSize'])) { - $content['size'] += + $content['buildSize'] ?? 0; + $content['size'] += +$content['buildSize']; unset($content['buildSize']); } diff --git a/src/Appwrite/Utopia/Response/Filters/V23.php b/src/Appwrite/Utopia/Response/Filters/V23.php new file mode 100644 index 0000000000..cd8ce44c0a --- /dev/null +++ b/src/Appwrite/Utopia/Response/Filters/V23.php @@ -0,0 +1,76 @@ + $this->parseMembership($content), + Response::MODEL_MEMBERSHIP_LIST => $this->handleList($content, 'memberships', fn ($item) => $this->parseMembership($item)), + Response::MODEL_PROJECT => $this->parseProject($content), + Response::MODEL_PROJECT_LIST => $this->handleList($content, 'projects', fn ($item) => $this->parseProject($item)), + Response::MODEL_EMAIL_TEMPLATE => $this->parseEmailTemplate($content), + Response::MODEL_MOCK_NUMBER => $this->parseMockNumber($content), + default => $content, + }; + } + + private function parseMockNumber(array $content): array + { + unset($content['$createdAt']); + unset($content['$updatedAt']); + + if (isset($content['number'])) { + $content['phone'] = $content['number']; + unset($content['number']); + } + + return $content; + } + + private function parseMembership(array $content): array + { + unset($content['userPhone']); + + return $content; + } + + private function parseEmailTemplate(array $content): array + { + if (isset($content['templateId'])) { + $content['type'] = $content['templateId']; + unset($content['templateId']); + } + + if (isset($content['replyToEmail'])) { + $content['replyTo'] = $content['replyToEmail']; + unset($content['replyToEmail']); + } + + unset($content['replyToName']); + unset($content['custom']); + + return $content; + } + + private function parseProject(array $content): array + { + unset($content['authMembershipsUserId']); + unset($content['authMembershipsUserPhone']); + + if (isset($content['smtpReplyToEmail'])) { + $content['smtpReplyTo'] = $content['smtpReplyToEmail']; + unset($content['smtpReplyToEmail']); + } + + unset($content['smtpReplyToName']); + + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/Membership.php b/src/Appwrite/Utopia/Response/Model/Membership.php index 46153842bc..9be7102145 100644 --- a/src/Appwrite/Utopia/Response/Model/Membership.php +++ b/src/Appwrite/Utopia/Response/Model/Membership.php @@ -46,6 +46,12 @@ class Membership extends Model 'default' => '', 'example' => 'john@appwrite.io', ]) + ->addRule('userPhone', [ + 'type' => self::TYPE_STRING, + 'description' => 'User phone number. Hide this attribute by toggling membership privacy in the Console.', + 'default' => '', + 'example' => '+1 555 555 5555', + ]) ->addRule('teamId', [ 'type' => self::TYPE_STRING, 'description' => 'Team ID.', diff --git a/src/Appwrite/Utopia/Response/Model/MockNumber.php b/src/Appwrite/Utopia/Response/Model/MockNumber.php index 14ce747da6..507700bc5b 100644 --- a/src/Appwrite/Utopia/Response/Model/MockNumber.php +++ b/src/Appwrite/Utopia/Response/Model/MockNumber.php @@ -4,13 +4,14 @@ namespace Appwrite\Utopia\Response\Model; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Model; +use Utopia\Database\Document; class MockNumber extends Model { public function __construct() { $this - ->addRule('phone', [ + ->addRule('number', [ 'type' => self::TYPE_STRING, 'description' => 'Mock phone number for testing phone authentication. Useful for testing phone authentication without sending an SMS.', 'default' => '', @@ -22,9 +23,31 @@ class MockNumber extends Model 'default' => '', 'example' => '123456', ]) + ->addRule('$createdAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Attribute creation date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('$updatedAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Attribute update date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]); ; } + public function filter(Document $document): Document + { + if ($document->isSet('phone')) { + $document->setAttribute('number', $document->getAttribute('phone')); + $document->removeAttribute('phone'); + } + + return $document; + } + /** * Get Name * diff --git a/src/Appwrite/Utopia/Response/Model/PolicyBase.php b/src/Appwrite/Utopia/Response/Model/PolicyBase.php new file mode 100644 index 0000000000..04a44d9ffd --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicyBase.php @@ -0,0 +1,19 @@ +addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'Policy ID.', + 'default' => '', + 'example' => 'password-dictionary', + ]); + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicyList.php b/src/Appwrite/Utopia/Response/Model/PolicyList.php new file mode 100644 index 0000000000..09548fedcf --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicyList.php @@ -0,0 +1,46 @@ +addRule('total', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of policies in the given project.', + 'default' => 0, + 'example' => 9, + ]) + ->addRule('policies', [ + 'type' => [ + Response::MODEL_POLICY_PASSWORD_DICTIONARY, + Response::MODEL_POLICY_PASSWORD_HISTORY, + Response::MODEL_POLICY_PASSWORD_PERSONAL_DATA, + Response::MODEL_POLICY_SESSION_ALERT, + Response::MODEL_POLICY_SESSION_DURATION, + Response::MODEL_POLICY_SESSION_INVALIDATION, + Response::MODEL_POLICY_SESSION_LIMIT, + Response::MODEL_POLICY_USER_LIMIT, + Response::MODEL_POLICY_MEMBERSHIP_PRIVACY, + ], + 'description' => 'List of policies.', + 'default' => [], + 'array' => true, + ]); + } + + public function getName(): string + { + return 'Policies List'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_LIST; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicyMembershipPrivacy.php b/src/Appwrite/Utopia/Response/Model/PolicyMembershipPrivacy.php new file mode 100644 index 0000000000..fe2851d35b --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicyMembershipPrivacy.php @@ -0,0 +1,59 @@ + 'membership-privacy', + ]; + + public function __construct() + { + parent::__construct(); + + $this + ->addRule('userId', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether user ID is visible in memberships.', + 'default' => false, + 'example' => true, + ]) + ->addRule('userEmail', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether user email is visible in memberships.', + 'default' => false, + 'example' => true, + ]) + ->addRule('userPhone', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether user phone is visible in memberships.', + 'default' => false, + 'example' => true, + ]) + ->addRule('userName', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether user name is visible in memberships.', + 'default' => false, + 'example' => true, + ]) + ->addRule('userMFA', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether user MFA status is visible in memberships.', + 'default' => false, + 'example' => true, + ]); + } + + public function getName(): string + { + return 'Policy Membership Privacy'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_MEMBERSHIP_PRIVACY; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicyPasswordDictionary.php b/src/Appwrite/Utopia/Response/Model/PolicyPasswordDictionary.php new file mode 100644 index 0000000000..78cd284332 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicyPasswordDictionary.php @@ -0,0 +1,34 @@ + 'password-dictionary', + ]; + + public function __construct() + { + parent::__construct(); + + $this->addRule('enabled', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether password dictionary policy is enabled.', + 'default' => false, + 'example' => true, + ]); + } + + public function getName(): string + { + return 'Policy Password Dictionary'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_PASSWORD_DICTIONARY; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicyPasswordHistory.php b/src/Appwrite/Utopia/Response/Model/PolicyPasswordHistory.php new file mode 100644 index 0000000000..a9b5951db6 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicyPasswordHistory.php @@ -0,0 +1,34 @@ + 'password-history', + ]; + + public function __construct() + { + parent::__construct(); + + $this->addRule('total', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Password history length. A value of 0 means the policy is disabled.', + 'default' => 0, + 'example' => 5, + ]); + } + + public function getName(): string + { + return 'Policy Password History'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_PASSWORD_HISTORY; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicyPasswordPersonalData.php b/src/Appwrite/Utopia/Response/Model/PolicyPasswordPersonalData.php new file mode 100644 index 0000000000..feffd95f1b --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicyPasswordPersonalData.php @@ -0,0 +1,34 @@ + 'password-personal-data', + ]; + + public function __construct() + { + parent::__construct(); + + $this->addRule('enabled', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether password personal data policy is enabled.', + 'default' => false, + 'example' => true, + ]); + } + + public function getName(): string + { + return 'Policy Password Personal Data'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_PASSWORD_PERSONAL_DATA; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicySessionAlert.php b/src/Appwrite/Utopia/Response/Model/PolicySessionAlert.php new file mode 100644 index 0000000000..4f1a66c65c --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicySessionAlert.php @@ -0,0 +1,34 @@ + 'session-alert', + ]; + + public function __construct() + { + parent::__construct(); + + $this->addRule('enabled', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether session alert policy is enabled.', + 'default' => false, + 'example' => true, + ]); + } + + public function getName(): string + { + return 'Policy Session Alert'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_SESSION_ALERT; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicySessionDuration.php b/src/Appwrite/Utopia/Response/Model/PolicySessionDuration.php new file mode 100644 index 0000000000..1242802c42 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicySessionDuration.php @@ -0,0 +1,34 @@ + 'session-duration', + ]; + + public function __construct() + { + parent::__construct(); + + $this->addRule('duration', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Session duration in seconds.', + 'default' => TOKEN_EXPIRATION_LOGIN_LONG, + 'example' => 3600, + ]); + } + + public function getName(): string + { + return 'Policy Session Duration'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_SESSION_DURATION; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicySessionInvalidation.php b/src/Appwrite/Utopia/Response/Model/PolicySessionInvalidation.php new file mode 100644 index 0000000000..12cbe10851 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicySessionInvalidation.php @@ -0,0 +1,34 @@ + 'session-invalidation', + ]; + + public function __construct() + { + parent::__construct(); + + $this->addRule('enabled', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether session invalidation policy is enabled.', + 'default' => true, + 'example' => true, + ]); + } + + public function getName(): string + { + return 'Policy Session Invalidation'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_SESSION_INVALIDATION; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicySessionLimit.php b/src/Appwrite/Utopia/Response/Model/PolicySessionLimit.php new file mode 100644 index 0000000000..2f187ef1f9 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicySessionLimit.php @@ -0,0 +1,34 @@ + 'session-limit', + ]; + + public function __construct() + { + parent::__construct(); + + $this->addRule('total', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Maximum number of sessions allowed per user. A value of 0 means the policy is disabled.', + 'default' => 0, + 'example' => 10, + ]); + } + + public function getName(): string + { + return 'Policy Session Limit'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_SESSION_LIMIT; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicyUserLimit.php b/src/Appwrite/Utopia/Response/Model/PolicyUserLimit.php new file mode 100644 index 0000000000..0ae80445ea --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicyUserLimit.php @@ -0,0 +1,34 @@ + 'user-limit', + ]; + + public function __construct() + { + parent::__construct(); + + $this->addRule('total', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Maximum number of users allowed in the project. A value of 0 means the policy is disabled.', + 'default' => 0, + 'example' => 100, + ]); + } + + public function getName(): string + { + return 'Policy User Limit'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_USER_LIMIT; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index 4cb038fc37..97b58d8a51 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -181,6 +181,18 @@ class Project extends Model 'default' => false, 'example' => true, ]) + ->addRule('authMembershipsUserId', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether or not to show user IDs in the teams membership response.', + 'default' => false, + 'example' => true, + ]) + ->addRule('authMembershipsUserPhone', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether or not to show user phone numbers in the teams membership response.', + 'default' => false, + 'example' => true, + ]) ->addRule('authInvalidateSessions', [ 'type' => self::TYPE_BOOLEAN, 'description' => 'Whether or not all existing sessions should be invalidated on password change', @@ -247,7 +259,13 @@ class Project extends Model 'default' => '', 'example' => 'john@appwrite.io', ]) - ->addRule('smtpReplyTo', [ + ->addRule('smtpReplyToName', [ + 'type' => self::TYPE_STRING, + 'description' => 'SMTP reply to name', + 'default' => '', + 'example' => 'Support Team', + ]) + ->addRule('smtpReplyToEmail', [ 'type' => self::TYPE_STRING, 'description' => 'SMTP reply to email', 'default' => '', @@ -273,9 +291,9 @@ class Project extends Model ]) ->addRule('smtpPassword', [ 'type' => self::TYPE_STRING, - 'description' => 'SMTP server password', + 'description' => 'SMTP server password. This property is write-only and always returned empty.', 'default' => '', - 'example' => 'securepassword', + 'example' => '', ]) ->addRule('smtpSecure', [ 'type' => self::TYPE_STRING, @@ -409,11 +427,12 @@ class Project extends Model $document->setAttribute('smtpEnabled', $smtp['enabled'] ?? false); $document->setAttribute('smtpSenderEmail', $smtp['senderEmail'] ?? ''); $document->setAttribute('smtpSenderName', $smtp['senderName'] ?? ''); - $document->setAttribute('smtpReplyTo', $smtp['replyTo'] ?? ''); + $document->setAttribute('smtpReplyToEmail', $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''); // Includes backwards compatibility + $document->setAttribute('smtpReplyToName', $smtp['replyToName'] ?? ''); $document->setAttribute('smtpHost', $smtp['host'] ?? ''); $document->setAttribute('smtpPort', $smtp['port'] ?? ''); $document->setAttribute('smtpUsername', $smtp['username'] ?? ''); - $document->setAttribute('smtpPassword', $smtp['password'] ?? ''); + $document->setAttribute('smtpPassword', ''); // Write-only: never expose the stored value $document->setAttribute('smtpSecure', $smtp['secure'] ?? ''); } @@ -463,7 +482,7 @@ class Project extends Model $document->setAttribute('authLimit', $authValues['limit'] ?? 0); $document->setAttribute('authDuration', $authValues['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG); - $document->setAttribute('authSessionsLimit', $authValues['maxSessions'] ?? APP_LIMIT_USER_SESSIONS_DEFAULT); + $document->setAttribute('authSessionsLimit', $authValues['maxSessions'] ?? 0); $document->setAttribute('authPasswordHistory', $authValues['passwordHistory'] ?? 0); $document->setAttribute('authPasswordDictionary', $authValues['passwordDictionary'] ?? false); $document->setAttribute('authPersonalDataCheck', $authValues['personalDataCheck'] ?? false); @@ -472,9 +491,11 @@ class Project extends Model $document->setAttribute('authFreeEmails', $authValues['freeEmails'] ?? false); $document->setAttribute('authMockNumbers', $authValues['mockNumbers'] ?? []); $document->setAttribute('authSessionAlerts', $authValues['sessionAlerts'] ?? false); - $document->setAttribute('authMembershipsUserName', $authValues['membershipsUserName'] ?? true); - $document->setAttribute('authMembershipsUserEmail', $authValues['membershipsUserEmail'] ?? true); - $document->setAttribute('authMembershipsMfa', $authValues['membershipsMfa'] ?? true); + $document->setAttribute('authMembershipsUserName', $authValues['membershipsUserName'] ?? false); + $document->setAttribute('authMembershipsUserEmail', $authValues['membershipsUserEmail'] ?? false); + $document->setAttribute('authMembershipsMfa', $authValues['membershipsMfa'] ?? false); + $document->setAttribute('authMembershipsUserId', $authValues['membershipsUserId'] ?? false); + $document->setAttribute('authMembershipsUserPhone', $authValues['membershipsUserPhone'] ?? false); $document->setAttribute('authInvalidateSessions', $authValues['invalidateSessions'] ?? false); foreach ($auth as $method) { diff --git a/src/Appwrite/Utopia/Response/Model/Template.php b/src/Appwrite/Utopia/Response/Model/Template.php deleted file mode 100644 index 3ce9cacdb3..0000000000 --- a/src/Appwrite/Utopia/Response/Model/Template.php +++ /dev/null @@ -1,32 +0,0 @@ -addRule('type', [ - 'type' => self::TYPE_STRING, - 'description' => 'Template type', - 'default' => '', - 'example' => 'verification', - ]) - ->addRule('locale', [ - 'type' => self::TYPE_STRING, - 'description' => 'Template locale', - 'default' => '', - 'example' => 'en_us', - ]) - ->addRule('message', [ - 'type' => self::TYPE_STRING, - 'description' => 'Template message', - 'default' => '', - 'example' => 'Click on the link to verify your account.', - ]) - ; - } -} diff --git a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php index ecdf89e774..833de90065 100644 --- a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php +++ b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php @@ -3,13 +3,31 @@ namespace Appwrite\Utopia\Response\Model; use Appwrite\Utopia\Response; +use Appwrite\Utopia\Response\Model; -class TemplateEmail extends Template +class TemplateEmail extends Model { public function __construct() { - parent::__construct(); $this + ->addRule('templateId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Template type', + 'default' => '', + 'example' => 'verification', + ]) + ->addRule('locale', [ + 'type' => self::TYPE_STRING, + 'description' => 'Template locale', + 'default' => '', + 'example' => 'en_us', + ]) + ->addRule('message', [ + 'type' => self::TYPE_STRING, + 'description' => 'Template message', + 'default' => '', + 'example' => 'Click on the link to verify your account.', + ]) ->addRule('senderName', [ 'type' => self::TYPE_STRING, 'description' => 'Name of the sender', @@ -22,12 +40,18 @@ class TemplateEmail extends Template 'default' => '', 'example' => 'mail@appwrite.io', ]) - ->addRule('replyTo', [ + ->addRule('replyToEmail', [ 'type' => self::TYPE_STRING, 'description' => 'Reply to email address', 'default' => '', 'example' => 'emails@appwrite.io', ]) + ->addRule('replyToName', [ + 'type' => self::TYPE_STRING, + 'description' => 'Reply to name', + 'default' => '', + 'example' => 'Support Team', + ]) ->addRule('subject', [ 'type' => self::TYPE_STRING, 'description' => 'Email subject', diff --git a/src/Appwrite/Vcs/Comment.php b/src/Appwrite/Vcs/Comment.php index 148b29c1d1..6214bb1f29 100644 --- a/src/Appwrite/Vcs/Comment.php +++ b/src/Appwrite/Vcs/Comment.php @@ -148,6 +148,7 @@ class Comment 'building' => $this->generatImage($pathLight, $pathDark, 'Building', 85) . ' _Building_', 'ready' => $this->generatImage($pathLight, $pathDark, 'Ready', 85) . ' _Ready_', 'failed' => $this->generatImage($pathLight, $pathDark, 'Failed', 85) . ' _Failed_', + default => '', }; if ($site['action']['type'] === 'logs') { @@ -195,6 +196,7 @@ class Comment 'building' => $this->generatImage($pathLight, $pathDark, 'Building', 85) . ' _Building_', 'ready' => $this->generatImage($pathLight, $pathDark, 'Ready', 85) . ' _Ready_', 'failed' => $this->generatImage($pathLight, $pathDark, 'Failed', 85) . ' _Failed_', + default => '', }; if ($function['action']['type'] === 'logs') { @@ -245,7 +247,7 @@ class Comment public function parseComment(string $comment): self { - $state = \explode("\n", $comment)[0] ?? ''; + $state = \explode("\n", $comment)[0]; $state = substr($state, strlen($this->statePrefix)); $json = \base64_decode($state); diff --git a/src/Executor/Executor.php b/src/Executor/Executor.php index f899f06bad..a4f1ae44cd 100644 --- a/src/Executor/Executor.php +++ b/src/Executor/Executor.php @@ -297,10 +297,10 @@ class Executor * @param array $params * @param array $headers * @param bool $decode - * @return array|string + * @return array * @throws Exception */ - private function call(string $endpoint, string $method, string $path = '', array $headers = [], array $params = [], bool $decode = true, int $timeout = 15, ?callable $callback = null) + private function call(string $endpoint, string $method, string $path = '', array $headers = [], array $params = [], bool $decode = true, int $timeout = 15, ?callable $callback = null): array { $headers = array_merge($this->headers, $headers); $ch = curl_init($endpoint . $path . (($method == self::METHOD_GET && !empty($params)) ? '?' . http_build_query($params) : '')); @@ -392,7 +392,7 @@ class Executor $strpos = \is_bool($strpos) ? \strlen($responseType) : $strpos; switch (substr($responseType, 0, $strpos)) { case 'multipart/form-data': - $boundary = \explode('boundary=', $responseHeaders['content-type'] ?? '')[1] ?? ''; + $boundary = \explode('boundary=', $responseHeaders['content-type'])[1] ?? ''; $multipartResponse = new BodyMultipart($boundary); $multipartResponse->load(\is_bool($responseBody) ? '' : $responseBody); diff --git a/tests/benchmarks/http-local.sh b/tests/benchmarks/http-local.sh new file mode 100755 index 0000000000..734c825fda --- /dev/null +++ b/tests/benchmarks/http-local.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +export K6_WEB_DASHBOARD="${K6_WEB_DASHBOARD:-true}" +export K6_WEB_DASHBOARD_HOST="${K6_WEB_DASHBOARD_HOST:-127.0.0.1}" +export K6_WEB_DASHBOARD_PORT="${K6_WEB_DASHBOARD_PORT:-5665}" +export K6_WEB_DASHBOARD_EXPORT="${K6_WEB_DASHBOARD_EXPORT:-/tmp/appwrite-k6-report.html}" +export APPWRITE_ENDPOINT="${APPWRITE_ENDPOINT:-http://localhost/v1}" +export APPWRITE_WORKER_TIMEOUT_MS="${APPWRITE_WORKER_TIMEOUT_MS:-120000}" +export APPWRITE_BENCHMARK_SUMMARY_PATH="${APPWRITE_BENCHMARK_SUMMARY_PATH:-/tmp/appwrite-k6-summary.json}" + +samples_path="${APPWRITE_BENCHMARK_SAMPLES_PATH:-/tmp/appwrite-k6-samples.json}" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "${script_dir}/../.." && pwd)" + +exec k6 run --out "json=${samples_path}" "$@" "${repo_root}/tests/benchmarks/http.js" diff --git a/tests/benchmarks/http.js b/tests/benchmarks/http.js index 799c8fb23c..4009024069 100644 --- a/tests/benchmarks/http.js +++ b/tests/benchmarks/http.js @@ -1,34 +1,625 @@ +/* + * Run locally: + * Requires k6 and a running Appwrite instance. + * + * tests/benchmarks/http-local.sh + * + * Open http://127.0.0.1:5665 while the benchmark is running. + */ import http from 'k6/http'; -import { check } from 'k6'; -import { Counter } from 'k6/metrics'; +import { check, group, sleep } from 'k6'; +import encoding from 'k6/encoding'; +import { Counter, Trend } from 'k6/metrics'; -// A simple counter for http requests -export const requests = new Counter('http_reqs'); +const ENDPOINT = (__ENV.APPWRITE_ENDPOINT || 'http://localhost/v1').replace(/\/+$/, ''); +const CONSOLE_PROJECT = __ENV.APPWRITE_CONSOLE_PROJECT || 'console'; +const REGION = __ENV.APPWRITE_REGION || 'default'; +const REDIRECT_URL = __ENV.APPWRITE_BENCHMARK_REDIRECT_URL || 'http://localhost'; +const PASSWORD = __ENV.APPWRITE_BENCHMARK_PASSWORD || 'Password123!'; +const WORKER_TIMEOUT_MS = Number(__ENV.APPWRITE_WORKER_TIMEOUT_MS || 120000); +const ITERATIONS = Number(__ENV.APPWRITE_BENCHMARK_ITERATIONS || 1); +const VUS = Number(__ENV.APPWRITE_BENCHMARK_VUS || 1); +const SUMMARY_PATH = __ENV.APPWRITE_BENCHMARK_SUMMARY_PATH || '/tmp/appwrite-k6-summary.json'; +const PREVIOUS_SUMMARY_PATH = __ENV.APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH || ''; +const PREVIOUS_SUMMARY = PREVIOUS_SUMMARY_PATH ? loadPreviousSummary(PREVIOUS_SUMMARY_PATH) : null; -// you can specify stages of your test (ramp up/down patterns) through the options object -// target is the number of VUs you are aiming for +export const httpWaiting = new Trend('appwrite_http_waiting', true); +export const apiDuration = new Trend('appwrite_api_duration', true); +export const apiWaiting = new Trend('appwrite_api_waiting', true); +export const flowFailures = new Counter('appwrite_benchmark_flow_failures'); export const options = { - stages: [ - { target: 50, duration: '1m' }, - // { target: 15, duration: '1m' }, - // { target: 0, duration: '1m' }, - ], + scenarios: { + curated_flows: { + executor: 'shared-iterations', + exec: 'curatedFlows', + vus: VUS, + iterations: ITERATIONS, + maxDuration: __ENV.APPWRITE_BENCHMARK_MAX_DURATION || '30m', + }, + }, thresholds: { - requests: ['count < 100'], + http_req_failed: ['rate<0.05'], + appwrite_api_duration: ['p(95)<2000'], + appwrite_benchmark_flow_failures: ['count<1'], }, }; -export default function () { - const config = { - headers: { - 'X-Appwrite-Key': '24356eb021863f81eb7dd77c7750304d0464e141cad6e9a8befa1f7d2b066fde190df3dab1e8d2639dbb82ee848da30501424923f4cd80d887ee40ad77ded62763ee489448523f6e39667f290f9a54b2ab8fad131a0bc985e6c0f760015f7f3411e40626c75646bb19d2bb2f7bf2f63130918220a206758cbc48845fd725a695', - 'X-Appwrite-Project': '60479fe35d95d' - }} +const API_SCOPES = [ + 'sessions.write', + 'users.read', + 'users.write', + 'teams.read', + 'teams.write', + 'databases.read', + 'databases.write', + 'collections.read', + 'collections.write', + 'tables.read', + 'tables.write', + 'attributes.read', + 'attributes.write', + 'columns.read', + 'columns.write', + 'indexes.read', + 'indexes.write', + 'documents.read', + 'documents.write', + 'rows.read', + 'rows.write', + 'files.read', + 'files.write', + 'buckets.read', + 'buckets.write', + 'functions.read', + 'functions.write', + 'log.read', + 'log.write', + 'execution.read', + 'execution.write', + 'locale.read', + 'avatars.read', + 'rules.read', + 'rules.write', + 'migrations.read', + 'migrations.write', + 'vcs.read', + 'vcs.write', + 'assistant.read', + 'tokens.read', + 'tokens.write', + 'platforms.read', + 'platforms.write', +]; - const resDb = http.get('http://localhost:9501/', config); +const BASE_PERMISSIONS = [ + 'read("any")', + 'create("any")', + 'update("any")', + 'delete("any")', +]; - check(resDb, { - 'status is 200': (r) => r.status === 200, +const ITEM_PERMISSIONS = [ + 'read("any")', + 'update("any")', + 'delete("any")', +]; + +export function setup() { + const runId = unique('run'); + const consoleEmail = __ENV.APPWRITE_ADMIN_EMAIL || `bench-admin-${runId}@example.com`; + const consolePassword = __ENV.APPWRITE_ADMIN_PASSWORD || PASSWORD; + + const consoleHeaders = { + 'Content-Type': 'application/json', + 'X-Appwrite-Project': CONSOLE_PROJECT, + }; + + const account = rawRequest('POST', '/account', { + userId: unique('admin'), + email: consoleEmail, + password: consolePassword, + name: 'Benchmark Admin', + }, consoleHeaders, 'setup.account.create'); + + if (![201, 409].includes(account.status)) { + failResponse(account, 'Unable to create or reuse the benchmark console account'); + } + + const session = rawRequest('POST', '/account/sessions/email', { + email: consoleEmail, + password: consolePassword, + }, consoleHeaders, 'setup.account.session'); + + assertStatus(session, [201], 'console session created'); + + const consoleSessionHeaders = { + ...consoleHeaders, + Cookie: cookieHeader(session), + }; + + const team = setupApi('POST', '/teams', { + teamId: unique('team'), + name: `Benchmark Team ${runId}`, + }, consoleSessionHeaders, [201], 'setup.teams.create'); + + const teamId = team.json('$id'); + const project = setupApi('POST', '/projects', { + projectId: unique('project'), + name: `Benchmark Project ${runId}`, + teamId, + region: REGION, + }, consoleSessionHeaders, [201], 'setup.projects.create'); + + const projectId = project.json('$id'); + const key = setupApi('POST', `/projects/${projectId}/keys`, { + keyId: unique('key'), + name: 'Benchmark API key', + scopes: API_SCOPES, + }, consoleSessionHeaders, [201], 'setup.projects.keys.create'); + + const apiHeaders = { + 'Content-Type': 'application/json', + 'X-Appwrite-Project': projectId, + 'X-Appwrite-Key': key.json('secret'), + }; + + const platform = setupApi('POST', '/project/platforms/web', { + platformId: unique('web'), + name: 'Benchmark web', + hostname: hostnameFromUrl(REDIRECT_URL), + }, apiHeaders, [201, 409], 'setup.project.platforms.web.create'); + + const tablesDb = setupTablesDb(apiHeaders); + + return { + runId, + teamId, + projectId, + databaseId: tablesDb.databaseId, + tableId: tablesDb.tableId, + consoleSessionHeaders, + apiHeaders, + platformStatus: platform.status, + }; +} + +function setupTablesDb(apiHeaders) { + const databaseId = unique('tdb'); + const tableId = unique('tbl'); + + setupApi('POST', '/tablesdb', { databaseId, name: 'Benchmark TablesDB' }, apiHeaders, [201], 'setup.tablesdb.create'); + setupApi('POST', `/tablesdb/${databaseId}/tables`, { + tableId, + name: 'Benchmark Table', + permissions: BASE_PERMISSIONS, + rowSecurity: false, + }, apiHeaders, [201], 'setup.tablesdb.tables.create'); + + const columns = [ + ['string', 'title', { size: 128 }], + ['integer', 'quantity', { min: 0, max: 100000 }], + ['email', 'email', {}], + ['boolean', 'active', {}], + ]; + + for (const [type, key, extra] of columns) { + setupApi('POST', `/tablesdb/${databaseId}/tables/${tableId}/columns/${type}`, { + key, + required: false, + array: false, + ...extra, + }, apiHeaders, [202], `setup.tablesdb.columns.${type}.create`); + waitForStatus(`/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, apiHeaders, 'available', WORKER_TIMEOUT_MS, `setup.tablesdb.columns.${type}.wait`); + } + + return { databaseId, tableId }; +} + +export function curatedFlows(data) { + const ctx = { ...data }; + + try { + group('account flow', () => accountFlow(ctx)); + group('tablesdb rows flow', () => tablesDbFlow(ctx)); + group('storage files and tokens flow', () => storageFlow(ctx)); + group('functions control-plane flow', () => computeFlow(ctx)); + } catch (error) { + flowFailures.add(1); + throw error; + } +} + +export function teardown(data) { + if (data && data.projectId && data.consoleSessionHeaders) { + rawRequest('DELETE', `/projects/${data.projectId}`, null, data.consoleSessionHeaders, 'teardown.projects.delete'); + } + + if (data && data.teamId && data.consoleSessionHeaders) { + rawRequest('DELETE', `/teams/${data.teamId}`, null, data.consoleSessionHeaders, 'teardown.teams.delete'); + } +} + +function accountFlow(ctx) { + const userId = unique('user'); + const email = `bench-user-${unique('mail')}@example.com`; + const headers = projectHeaders(ctx.projectId); + + api('POST', '/account', { + userId, + email, + password: PASSWORD, + name: 'Benchmark User', + }, headers, [201], 'account.create'); + + const session = api('POST', '/account/sessions/email', { + email, + password: PASSWORD, + }, headers, [201], 'account.sessions.email.create'); + + const sessionHeaders = { + ...headers, + Cookie: cookieHeader(session), + }; + + ctx.userId = userId; + ctx.userEmail = email; + ctx.sessionHeaders = sessionHeaders; + + api('GET', '/account', null, sessionHeaders, [200], 'account.get'); + api('GET', '/account/logs', null, sessionHeaders, [200], 'account.logs.list'); + api('PATCH', '/account/prefs', { prefs: { benchmark: true, runId: ctx.runId } }, sessionHeaders, [200], 'account.prefs.update'); + api('PATCH', '/account/name', { name: 'Benchmark User Updated' }, sessionHeaders, [200], 'account.name.update'); + api('PATCH', '/account/password', { password: `${PASSWORD}2`, oldPassword: PASSWORD }, sessionHeaders, [200], 'account.password.update'); +} + +function tablesDbFlow(ctx) { + requireSession(ctx, 'tablesDbFlow'); + + const databaseId = ctx.databaseId; + const tableId = ctx.tableId; + const rowId = unique('row'); + + api('POST', `/tablesdb/${databaseId}/tables/${tableId}/rows`, { + rowId, + data: tablePayload(), + permissions: ITEM_PERMISSIONS, + }, ctx.sessionHeaders, [201], 'tablesdb.rows.create'); + api('GET', `/tablesdb/${databaseId}/tables/${tableId}/rows`, null, ctx.sessionHeaders, [200], 'tablesdb.rows.list'); + api('GET', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`, null, ctx.sessionHeaders, [200], 'tablesdb.rows.get'); + api('PATCH', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`, { + data: { title: 'Benchmark Row Updated' }, + }, ctx.sessionHeaders, [200], 'tablesdb.rows.update'); + api('PATCH', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}/quantity/increment`, { + value: 1, + }, ctx.sessionHeaders, [200], 'tablesdb.rows.increment'); + api('PATCH', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}/quantity/decrement`, { + value: 1, + }, ctx.sessionHeaders, [200], 'tablesdb.rows.decrement'); + api('DELETE', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`, null, ctx.sessionHeaders, [204], 'tablesdb.rows.delete'); +} + +function storageFlow(ctx) { + requireSession(ctx, 'storageFlow'); + + const bucketId = unique('bucket'); + const fileId = unique('file'); + + api('POST', '/storage/buckets', { + bucketId, + name: 'Benchmark Bucket', + permissions: BASE_PERMISSIONS, + fileSecurity: false, + enabled: true, + maximumFileSize: 30000000, + allowedFileExtensions: [], + compression: 'none', + encryption: false, + antivirus: false, + }, ctx.apiHeaders, [201], 'storage.buckets.create'); + + const multipartHeaders = { ...ctx.sessionHeaders }; + delete multipartHeaders['Content-Type']; + + const upload = http.post(`${ENDPOINT}/storage/buckets/${bucketId}/files`, { + fileId, + file: http.file(onePixelPng(), 'benchmark.png', 'image/png'), + ...flattenMultipartArray('permissions', ITEM_PERMISSIONS), + }, { + headers: multipartHeaders, + tags: { name: 'storage.files.create' }, }); -} \ No newline at end of file + + httpWaiting.add(upload.timings.waiting, { name: 'storage.files.create' }); + apiDuration.add(upload.timings.duration, { name: 'storage.files.create' }); + apiWaiting.add(upload.timings.waiting, { name: 'storage.files.create' }); + assertStatus(upload, [201], 'storage file created'); + + api('GET', `/storage/buckets/${bucketId}/files`, null, ctx.sessionHeaders, [200], 'storage.files.list'); + api('GET', `/storage/buckets/${bucketId}/files/${fileId}`, null, ctx.sessionHeaders, [200], 'storage.files.get'); + api('GET', `/storage/buckets/${bucketId}/files/${fileId}/view`, null, ctx.sessionHeaders, [200], 'storage.files.view'); + api('GET', `/storage/buckets/${bucketId}/files/${fileId}/download`, null, ctx.sessionHeaders, [200], 'storage.files.download'); + api('GET', `/storage/buckets/${bucketId}/files/${fileId}/preview`, null, ctx.sessionHeaders, [200], 'storage.files.preview'); + api('PUT', `/storage/buckets/${bucketId}/files/${fileId}`, { + name: 'benchmark-renamed.png', + permissions: ITEM_PERMISSIONS, + }, ctx.sessionHeaders, [200], 'storage.files.update'); + + const token = api('POST', `/tokens/buckets/${bucketId}/files/${fileId}`, {}, ctx.apiHeaders, [201], 'tokens.files.create'); + api('GET', `/tokens/buckets/${bucketId}/files/${fileId}`, null, ctx.apiHeaders, [200], 'tokens.files.list'); + api('GET', `/tokens/${token.json('$id')}`, null, ctx.apiHeaders, [200], 'tokens.get'); + api('PATCH', `/tokens/${token.json('$id')}`, { expire: null }, ctx.apiHeaders, [200], 'tokens.update'); + api('DELETE', `/tokens/${token.json('$id')}`, null, ctx.apiHeaders, [204], 'tokens.delete'); + + api('DELETE', `/storage/buckets/${bucketId}/files/${fileId}`, null, ctx.sessionHeaders, [204], 'storage.files.delete'); + api('DELETE', `/storage/buckets/${bucketId}`, null, ctx.apiHeaders, [204], 'storage.buckets.delete'); +} + +function computeFlow(ctx) { + requireSession(ctx, 'computeFlow'); + + const functionId = unique('fn'); + let functionVariableId; + + api('POST', '/functions', { + functionId, + name: 'Benchmark Function', + runtime: __ENV.APPWRITE_BENCHMARK_RUNTIME || 'node-22', + execute: ['any'], + events: [], + schedule: '', + timeout: 15, + enabled: true, + logging: true, + entrypoint: 'index.js', + commands: 'npm install', + scopes: ['users.read'], + }, ctx.apiHeaders, [201], 'functions.create'); + api('GET', '/functions/runtimes', null, ctx.sessionHeaders, [200], 'functions.runtimes.list'); + api('GET', '/functions/specifications', null, ctx.apiHeaders, [200], 'functions.specifications.list'); + const functionVariable = api('POST', `/functions/${functionId}/variables`, { + key: 'BENCHMARK', + value: 'true', + secret: false, + }, ctx.apiHeaders, [201], 'functions.variables.create'); + functionVariableId = functionVariable.json('$id'); + + api('PUT', `/functions/${functionId}/variables/${functionVariableId}`, { + key: 'BENCHMARK', + value: 'updated', + secret: false, + }, ctx.apiHeaders, [200], 'functions.variables.update'); + api('GET', `/functions/${functionId}/variables/${functionVariableId}`, null, ctx.apiHeaders, [200], 'functions.variables.get'); + api('DELETE', `/functions/${functionId}/variables/${functionVariableId}`, null, ctx.apiHeaders, [204], 'functions.variables.delete'); + api('DELETE', `/functions/${functionId}`, null, ctx.apiHeaders, [204], 'functions.delete'); +} + +function api(method, path, body, headers, expected, name) { + const response = rawRequest(method, path, body, headers, name); + apiDuration.add(response.timings.duration, { name }); + apiWaiting.add(response.timings.waiting, { name }); + assertStatus(response, expected, name); + return response; +} + +function setupApi(method, path, body, headers, expected, name) { + const response = rawRequest(method, path, body, headers, name); + assertStatus(response, expected, name); + return response; +} + +function rawRequest(method, path, body, headers, name) { + const params = { + headers, + tags: { name }, + }; + const payload = body === null || body === undefined ? null : JSON.stringify(body); + const response = http.request(method, `${ENDPOINT}${path}`, payload, params); + httpWaiting.add(response.timings.waiting, { name }); + + return response; +} + +function waitForStatus(path, headers, wantedStatus, timeoutMs, name) { + const started = Date.now(); + + while (Date.now() - started < timeoutMs) { + const response = rawRequest('GET', path, null, headers, name); + if (response.status === 200) { + const status = response.json('status'); + if (status === wantedStatus) { + return response; + } + if (status === 'failed') { + throw new Error(`${path} failed while waiting for ${wantedStatus}`); + } + } + sleep(0.5); + } + + throw new Error(`Timed out waiting for ${path} to become ${wantedStatus}`); +} + +function assertStatus(response, expected, name) { + const ok = check(response, { + [`${name} status ${expected.join('|')}`]: (r) => expected.includes(r.status), + }); + + if (!ok) { + failResponse(response, `${name} returned an unexpected status`); + } +} + +function failResponse(response, message) { + throw new Error(`${message}. Status: ${response.status}. Body: ${response.body}`); +} + +function cookieHeader(response) { + return response.headers['Set-Cookie'] || response.headers['set-cookie'] || ''; +} + +function projectHeaders(projectId) { + return { + 'Content-Type': 'application/json', + 'X-Appwrite-Project': projectId, + }; +} + +function requireSession(ctx, flow) { + if (!ctx.sessionHeaders || typeof ctx.sessionHeaders !== 'object') { + throw new Error(`accountFlow must run before ${flow}`); + } +} + +function tablePayload() { + return { + title: 'Benchmark Row', + quantity: 1, + email: 'row@example.com', + active: true, + }; +} + +function onePixelPng() { + return encoding.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII=', 'std', 'b'); +} + +function flattenMultipartArray(key, values) { + const output = {}; + values.forEach((value, index) => { + output[`${key}[${index}]`] = value; + }); + return output; +} + +function unique(prefix) { + return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .slice(0, 36); +} + +function hostnameFromUrl(value) { + return value.replace(/^https?:\/\//, '').split('/')[0].split(':')[0]; +} + +export function handleSummary(data) { + const lines = [ + 'Appwrite curated benchmark review', + '', + 'Before', + '', + summaryTable(PREVIOUS_SUMMARY), + '', + 'After', + '', + summaryTable(data), + '', + 'Delta', + '', + deltaTable(PREVIOUS_SUMMARY, data), + '', + ]; + + return { + stdout: `${lines.join('\n')}\n`, + [SUMMARY_PATH]: JSON.stringify(data, null, 2), + }; +} + +function summaryTable(data) { + return [ + '| Scenario | P50 (ms) | P95 (ms) | Requests | RPS |', + '| --- | ---: | ---: | ---: | ---: |', + summaryRow(data, 'API total', 'appwrite_api_duration'), + ].join('\n'); +} + +function summaryRow(data, label, metric, iterationsMetric = null, rpsMetric = null) { + const values = data && data.metrics[metric] && data.metrics[metric].values; + if (!values || values.count === 0) { + return `| ${label} | n/a | n/a | n/a | n/a |`; + } + + const iterations = iterationsMetric + ? trendMetric(data, iterationsMetric, 'count') + : values.count; + const rps = rpsMetric ? trendMetric(data, rpsMetric, 'rate') : null; + + return `| ${label} | ${formatDetailValue(values.med)} | ${formatDetailValue(values['p(95)'])} | ${formatCount(iterations)} | ${formatRate(rps)} |`; +} + +function loadPreviousSummary(path) { + let contents; + try { + contents = open(path); + } catch (error) { + console.warn(`Missing benchmark summary at ${path}: ${error.message}`); + return null; + } + + try { + return JSON.parse(contents); + } catch (error) { + console.warn(`Invalid benchmark summary at ${path}: ${error.message}`); + return null; + } +} + +function deltaTable(before, after) { + return [ + '| Scenario | P95 delta (ms) |', + '| --- | ---: |', + ...[ + ['API total', 'appwrite_api_duration'], + ].map(([label, metric]) => { + const beforeP95 = trendMetric(before, metric, 'p(95)'); + const afterP95 = trendMetric(after, metric, 'p(95)'); + return `| ${label} | ${formatDelta(beforeP95, afterP95)} |`; + }), + ].join('\n'); +} + +function trendMetric(data, metric, stat) { + return data && data.metrics[metric] && data.metrics[metric].values + ? data.metrics[metric].values[stat] + : null; +} + +function formatDetailValue(value) { + if (value === null || value === undefined || Number.isNaN(value)) { + return 'n/a'; + } + + return `${Number(value).toFixed(2)}`; +} + +function formatDelta(before, after) { + if (before === null || before === undefined || after === null || after === undefined || Number.isNaN(before) || Number.isNaN(after)) { + return 'n/a'; + } + + const delta = round(after - before); + const sign = delta > 0 ? '+' : ''; + return `${sign}${delta}`; +} + +function formatCount(value) { + if (value === null || value === undefined || Number.isNaN(value)) { + return 'n/a'; + } + + return `${Math.round(value)}`; +} + +function formatRate(value) { + if (value === null || value === undefined || Number.isNaN(value)) { + return 'n/a'; + } + + return `${Number(value).toFixed(2)}`; +} + +function round(value) { + return Math.round((value || 0) * 100) / 100; +} diff --git a/tests/e2e/Client.php b/tests/e2e/Client.php index d170d56fe4..4358058fe3 100644 --- a/tests/e2e/Client.php +++ b/tests/e2e/Client.php @@ -264,7 +264,7 @@ class Client $strpos = \is_bool($strpos) ? \strlen($responseType) : $strpos; switch (substr($responseType, 0, $strpos)) { case 'multipart/form-data': - $boundary = \explode('boundary=', $responseHeaders['content-type'] ?? '')[1] ?? ''; + $boundary = \explode('boundary=', $responseHeaders['content-type'])[1] ?? ''; $multipartResponse = new BodyMultipart($boundary); $multipartResponse->load(\is_bool($responseBody) ? '' : $responseBody); diff --git a/tests/e2e/General/UsageTest.php b/tests/e2e/General/UsageTest.php index f6eb963967..4f557e8959 100644 --- a/tests/e2e/General/UsageTest.php +++ b/tests/e2e/General/UsageTest.php @@ -1605,8 +1605,6 @@ class UsageTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $deployment = $this->createDeploymentSite($siteId, [ 'siteId' => $siteId, 'code' => $this->packageSite('static'), diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index a62a1e8ba3..f531ed774d 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -169,6 +169,12 @@ trait ProjectCustom 'keys.write', 'platforms.read', 'platforms.write', + 'mocks.read', + 'mocks.write', + 'policies.read', + 'policies.write', + 'templates.read', + 'templates.write', ], ]); diff --git a/tests/e2e/Services/Account/AccountBase.php b/tests/e2e/Services/Account/AccountBase.php index a81da60968..8b4dfd4e3e 100644 --- a/tests/e2e/Services/Account/AccountBase.php +++ b/tests/e2e/Services/Account/AccountBase.php @@ -175,7 +175,7 @@ trait AccountBase // FInd 6 concurrent digits in email text - OTP preg_match_all("/\b\d{6}\b/", $lastEmail['text'], $matches); - $code = ($matches[0] ?? [])[0] ?? ''; + $code = $matches[0][0] ?? ''; $this->assertNotEmpty($code); $this->assertStringContainsStringIgnoringCase('Use OTP ' . $code . ' to sign in to '. $this->getProject()['name'] . '. Expires in 15 minutes.', $lastEmail['text']); diff --git a/tests/e2e/Services/Account/AccountConsoleClientTest.php b/tests/e2e/Services/Account/AccountConsoleClientTest.php index 9f825c3c89..cd2c43381c 100644 --- a/tests/e2e/Services/Account/AccountConsoleClientTest.php +++ b/tests/e2e/Services/Account/AccountConsoleClientTest.php @@ -203,7 +203,7 @@ class AccountConsoleClientTest extends Scope // Find 6 concurrent digits in email text - OTP preg_match_all("/\b\d{6}\b/", $lastEmail['text'], $matches); - $code = ($matches[0] ?? [])[0] ?? ''; + $code = $matches[0][0] ?? ''; $this->assertNotEmpty($code); diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 49f0c4c245..da788c3caa 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -772,6 +772,7 @@ class AccountCustomClientTest extends Scope 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => 'console', + 'x-appwrite-response-format' => '1.9.1', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'status' => true, @@ -2050,6 +2051,7 @@ class AccountCustomClientTest extends Scope 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => 'console', + 'x-appwrite-response-format' => '1.9.1', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'alerts' => true, @@ -2135,7 +2137,7 @@ class AccountCustomClientTest extends Scope // Find 6 concurrent digits in email text - OTP preg_match_all("/\b\d{6}\b/", $lastEmail['text'], $matches); - $code = ($matches[0] ?? [])[0] ?? ''; + $code = $matches[0][0] ?? ''; $this->assertNotEmpty($code); @@ -3363,7 +3365,7 @@ class AccountCustomClientTest extends Scope { $data = $this->setupPhoneAccount(); $id = $data['id']; - $token = explode(" ", $data['token'])[0] ?? ''; + $token = explode(" ", $data['token'])[0]; $number = $data['number']; /** @@ -3694,6 +3696,7 @@ class AccountCustomClientTest extends Scope 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => 'console', + 'x-appwrite-response-format' => '1.9.1', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'status' => false, diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index f5f1d1864c..e3efe3bbd9 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -936,7 +936,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } // Use dedicated collections for this test to avoid conflicts with setupAttributes() $data = $this->setupDatabase(); @@ -1189,7 +1188,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $data = $this->setupAttributes(); $databaseId = $data['databaseId']; @@ -1221,7 +1219,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $data = $this->setupDatabase(); $databaseId = $data['databaseId']; @@ -1290,7 +1287,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $database = $this->client->call(Client::METHOD_POST, $this->getApiBasePath(), [ 'content-type' => 'application/json', @@ -1351,7 +1347,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $data = $this->setupAttributes(); $databaseId = $data['databaseId']; @@ -3324,7 +3319,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $data = $this->setupDocuments(); $databaseId = $data['databaseId']; @@ -3368,7 +3362,7 @@ trait DatabasesBase ]); $this->assertEquals(200, $documents2['headers']['status-code']); - $this->assertEquals(3, $documents2['body']['total']); + $this->assertSame(3, $documents2['body']['total']); $this->assertCount(3, $documents2['body'][$this->getRecordResource()]); $this->assertEquals($documents1['body'][$this->getRecordResource()][0]['$id'], $documents2['body'][$this->getRecordResource()][0]['$id']); $this->assertEquals($documents1['body'][$this->getRecordResource()][0]['title'], $documents2['body'][$this->getRecordResource()][0]['title']); @@ -3458,7 +3452,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $data = $this->setupDocuments(); $databaseId = $data['databaseId']; @@ -3531,7 +3524,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $data = $this->setupDocuments(); $databaseId = $data['databaseId']; @@ -3578,7 +3570,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $data = $this->setupDocuments(); $databaseId = $data['databaseId']; @@ -4929,7 +4920,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $database = $this->client->call(Client::METHOD_POST, $this->getApiBasePath(), array_merge([ 'content-type' => 'application/json', diff --git a/tests/e2e/Services/Databases/Transactions/ACIDBase.php b/tests/e2e/Services/Databases/Transactions/ACIDBase.php index 1a6ee83b33..11b6de3b70 100644 --- a/tests/e2e/Services/Databases/Transactions/ACIDBase.php +++ b/tests/e2e/Services/Databases/Transactions/ACIDBase.php @@ -178,7 +178,6 @@ trait ACIDBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('This adapter does not support attributes; schema constraint consistency cannot be tested.'); - return; } // Create database diff --git a/tests/e2e/Services/Databases/VectorsDBCustomClientTest.php b/tests/e2e/Services/Databases/VectorsDBCustomClientTest.php index 7add5c7f71..632b1a62de 100644 --- a/tests/e2e/Services/Databases/VectorsDBCustomClientTest.php +++ b/tests/e2e/Services/Databases/VectorsDBCustomClientTest.php @@ -3,6 +3,7 @@ namespace Tests\E2E\Services\Databases; use Tests\E2E\Client; +use Tests\E2E\Scopes\ApiVectorsDB; use Tests\E2E\Scopes\ProjectCustom; use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\SideClient; @@ -16,6 +17,7 @@ class VectorsDBCustomClientTest extends Scope use DatabasesBase; use ProjectCustom; use SideClient; + use ApiVectorsDB; public function testAllowedPermissions(): void { diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index ba518ee0b6..4255774f18 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -567,6 +567,44 @@ class FunctionsCustomServerTest extends Scope }, 120000, 500); } + public function testCreateDeploymentWithSingleContentRangeChunk(): void + { + $functionId = $this->setupFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test Single Chunk Range', + 'execute' => [Role::user($this->getUser()['$id'])->toString()], + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'timeout' => 10, + ]); + + $code = $this->packageFunction('basic'); + $size = \filesize($code->getFilename()); + + $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'content-range' => 'bytes 0-' . ($size - 1) . '/' . $size, + ], $this->getHeaders()), [ + 'code' => $code, + 'activate' => true, + ]); + + $this->assertEquals(202, $deployment['headers']['status-code']); + $this->assertNotEmpty($deployment['body']['$id']); + + $deploymentId = $deployment['body']['$id']; + + $this->assertEventually(function () use ($functionId, $deploymentId) { + $deployment = $this->getDeployment($functionId, $deploymentId); + + $this->assertEquals(200, $deployment['headers']['status-code']); + $this->assertEquals('ready', $deployment['body']['status']); + }, 120000, 500); + + $this->cleanupFunction($functionId); + } + public function testCreateFunctionAndDeploymentFromTemplate() { diff --git a/tests/e2e/Services/GraphQL/FunctionsClientTest.php b/tests/e2e/Services/GraphQL/FunctionsClientTest.php index 8dc2fe337f..ed436ad075 100644 --- a/tests/e2e/Services/GraphQL/FunctionsClientTest.php +++ b/tests/e2e/Services/GraphQL/FunctionsClientTest.php @@ -184,7 +184,7 @@ class FunctionsClientTest extends Scope public function testCreateFunction(): void { $function = $this->setupFunction(); - $this->assertIsArray($function); + $this->assertNotEmpty($function); } /** @@ -194,7 +194,7 @@ class FunctionsClientTest extends Scope public function testCreateDeployment(): void { $deployment = $this->setupDeployment(); - $this->assertIsArray($deployment); + $this->assertNotEmpty($deployment); } /** @@ -204,7 +204,7 @@ class FunctionsClientTest extends Scope public function testCreateExecution(): void { $execution = $this->setupExecution(); - $this->assertIsArray($execution); + $this->assertNotEmpty($execution); } /** diff --git a/tests/e2e/Services/GraphQL/FunctionsServerTest.php b/tests/e2e/Services/GraphQL/FunctionsServerTest.php index 8e1c7ac7e7..572fde49bf 100644 --- a/tests/e2e/Services/GraphQL/FunctionsServerTest.php +++ b/tests/e2e/Services/GraphQL/FunctionsServerTest.php @@ -186,7 +186,7 @@ class FunctionsServerTest extends Scope public function testCreateFunction(): void { $function = $this->setupFunction(); - $this->assertIsArray($function); + $this->assertNotEmpty($function); } /** @@ -196,7 +196,7 @@ class FunctionsServerTest extends Scope public function testCreateDeployment(): void { $deployment = $this->setupDeployment(); - $this->assertIsArray($deployment); + $this->assertNotEmpty($deployment); } /** @@ -206,7 +206,7 @@ class FunctionsServerTest extends Scope public function testCreateExecution(): void { $execution = $this->setupExecution(); - $this->assertIsArray($execution); + $this->assertNotEmpty($execution); } /** diff --git a/tests/e2e/Services/GraphQL/Legacy/AuthTest.php b/tests/e2e/Services/GraphQL/Legacy/AuthTest.php index 4a3e49cc60..d3c6d01ffa 100644 --- a/tests/e2e/Services/GraphQL/Legacy/AuthTest.php +++ b/tests/e2e/Services/GraphQL/Legacy/AuthTest.php @@ -18,7 +18,6 @@ class AuthTest extends Scope use Base; private array $account1; - private array $account2; private string $token1; private string $token2; diff --git a/tests/e2e/Services/GraphQL/StorageClientTest.php b/tests/e2e/Services/GraphQL/StorageClientTest.php index 25041e843b..dd89819c34 100644 --- a/tests/e2e/Services/GraphQL/StorageClientTest.php +++ b/tests/e2e/Services/GraphQL/StorageClientTest.php @@ -112,7 +112,7 @@ class StorageClientTest extends Scope public function testCreateFile(): void { $file = $this->setupFile(); - $this->assertIsArray($file); + $this->assertNotEmpty($file); } /** diff --git a/tests/e2e/Services/GraphQL/StorageServerTest.php b/tests/e2e/Services/GraphQL/StorageServerTest.php index cc4c8ecec3..1377ef9207 100644 --- a/tests/e2e/Services/GraphQL/StorageServerTest.php +++ b/tests/e2e/Services/GraphQL/StorageServerTest.php @@ -111,7 +111,7 @@ class StorageServerTest extends Scope public function testCreateFile(): void { $file = $this->setupFile(); - $this->assertIsArray($file); + $this->assertNotEmpty($file); } public function testGetBuckets(): array diff --git a/tests/e2e/Services/GraphQL/TablesDB/AuthTest.php b/tests/e2e/Services/GraphQL/TablesDB/AuthTest.php index 9c6910fb30..13f083f0eb 100644 --- a/tests/e2e/Services/GraphQL/TablesDB/AuthTest.php +++ b/tests/e2e/Services/GraphQL/TablesDB/AuthTest.php @@ -18,7 +18,6 @@ class AuthTest extends Scope use Base; private array $account1; - private array $account2; private string $token1; private string $token2; diff --git a/tests/e2e/Services/GraphQL/TeamsServerTest.php b/tests/e2e/Services/GraphQL/TeamsServerTest.php index ff6e8e3c6f..dd546119e2 100644 --- a/tests/e2e/Services/GraphQL/TeamsServerTest.php +++ b/tests/e2e/Services/GraphQL/TeamsServerTest.php @@ -199,7 +199,7 @@ class TeamsServerTest extends Scope public function testUpdateTeamPrefs() { $team = $this->setupTeamWithPrefs(); - $this->assertIsArray($team); + $this->assertNotEmpty($team); } public function testGetTeamPreferences() diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index 9e9ce2fbcd..069dc9cfbb 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -4207,7 +4207,9 @@ trait MigrationsBase }, 30_000, 500); // Check that email was sent with download link - $lastEmail = $this->getLastEmail(); + $lastEmail = $this->getLastEmail(probe: function ($email) { + $this->assertEquals('Your JSON export is ready', $email['subject']); + }); $this->assertNotEmpty($lastEmail); $this->assertEquals('Your JSON export is ready', $lastEmail['subject']); $this->assertStringContainsStringIgnoringCase('Your data export has been completed successfully', $lastEmail['text']); diff --git a/tests/e2e/Services/Project/AuthMethodsBase.php b/tests/e2e/Services/Project/AuthMethodsBase.php new file mode 100644 index 0000000000..afa58a3640 --- /dev/null +++ b/tests/e2e/Services/Project/AuthMethodsBase.php @@ -0,0 +1,337 @@ + response field name exposed by the Project model. + */ + protected static array $authMethods = [ + 'email-password' => 'authEmailPassword', + 'magic-url' => 'authUsersAuthMagicURL', + 'email-otp' => 'authEmailOtp', + 'anonymous' => 'authAnonymous', + 'invites' => 'authInvites', + 'jwt' => 'authJWT', + 'phone' => 'authPhone', + ]; + + // Success flow + + public function testDisableAuthMethod(): void + { + foreach (self::$authMethods as $methodId => $responseKey) { + $response = $this->updateAuthMethod($methodId, false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(false, $response['body'][$responseKey]); + } + + // Cleanup + foreach (self::$authMethods as $methodId => $responseKey) { + $this->updateAuthMethod($methodId, true); + } + } + + public function testEnableAuthMethod(): void + { + // Disable first + foreach (self::$authMethods as $methodId => $responseKey) { + $this->updateAuthMethod($methodId, false); + } + + // Re-enable + foreach (self::$authMethods as $methodId => $responseKey) { + $response = $this->updateAuthMethod($methodId, true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body'][$responseKey]); + } + } + + public function testDisableAuthMethodIdempotent(): void + { + $first = $this->updateAuthMethod('email-password', false); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(false, $first['body']['authEmailPassword']); + + $second = $this->updateAuthMethod('email-password', false); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(false, $second['body']['authEmailPassword']); + + // Cleanup + $this->updateAuthMethod('email-password', true); + } + + public function testEnableAuthMethodIdempotent(): void + { + $first = $this->updateAuthMethod('email-password', true); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(true, $first['body']['authEmailPassword']); + + $second = $this->updateAuthMethod('email-password', true); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(true, $second['body']['authEmailPassword']); + } + + public function testDisableOneMethodDoesNotAffectOther(): void + { + // Ensure both start enabled + $this->updateAuthMethod('email-password', true); + $this->updateAuthMethod('magic-url', true); + + $response = $this->updateAuthMethod('email-password', false); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authEmailPassword']); + $this->assertSame(true, $response['body']['authUsersAuthMagicURL']); + + // Cleanup + $this->updateAuthMethod('email-password', true); + } + + public function testDisabledEmailPasswordBlocksSessionCreation(): void + { + $this->updateAuthMethod('email-password', false); + + // Unauthenticated account creation would normally be permitted; with the + // method disabled we expect the shared auth filter to reject it. + $response = $this->client->call(Client::METHOD_POST, '/account', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'userId' => 'unique()', + 'email' => 'disabled-method-' . \uniqid() . '@appwrite.io', + 'password' => 'password123', + ]); + + $this->assertSame(501, $response['headers']['status-code']); + $this->assertSame('user_auth_method_unsupported', $response['body']['type']); + + // Cleanup + $this->updateAuthMethod('email-password', true); + } + + public function testEnabledEmailPasswordAllowsSessionCreation(): void + { + $this->updateAuthMethod('email-password', false); + $this->updateAuthMethod('email-password', true); + + $response = $this->client->call(Client::METHOD_POST, '/account', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'userId' => 'unique()', + 'email' => 'enabled-method-' . \uniqid() . '@appwrite.io', + 'password' => 'password123', + ]); + + $this->assertNotSame(501, $response['headers']['status-code']); + $this->assertNotSame('user_auth_method_unsupported', $response['body']['type'] ?? ''); + } + + public function testDisabledAnonymousBlocksSessionCreation(): void + { + $this->updateAuthMethod('anonymous', false); + + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/anonymous', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertSame(501, $response['headers']['status-code']); + $this->assertSame('user_auth_method_unsupported', $response['body']['type']); + + // Cleanup + $this->updateAuthMethod('anonymous', true); + } + + public function testResponseModel(): void + { + $response = $this->updateAuthMethod('email-password', false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('name', $response['body']); + foreach (self::$authMethods as $methodId => $responseKey) { + $this->assertArrayHasKey($responseKey, $response['body']); + } + + // Cleanup + $this->updateAuthMethod('email-password', true); + } + + // Failure flow + + public function testUpdateAuthMethodWithoutAuthentication(): void + { + $response = $this->updateAuthMethod('email-password', false, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdateAuthMethodInvalidMethodId(): void + { + $response = $this->updateAuthMethod('invalid-method', false); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateAuthMethodEmptyMethodId(): void + { + $response = $this->updateAuthMethod('', false); + + $this->assertSame(404, $response['headers']['status-code']); + } + + public function testUpdateAuthMethodMissingEnabled(): void + { + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()); + + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/auth-methods/email-password', + $headers, + [] + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + // Backwards compatibility + + public function testUpdateAuthMethodLegacyAliasPath(): void + { + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()); + + $projectId = $this->getProject()['$id']; + + // Disable via the legacy `/v1/projects/:projectId/auth/:methodId` alias + $response = $this->client->call( + Client::METHOD_PATCH, + '/projects/' . $projectId . '/auth/email-password', + $headers, + [ + 'enabled' => false, + ] + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(false, $response['body']['authEmailPassword']); + + // Re-enable via the legacy alias + $response = $this->client->call( + Client::METHOD_PATCH, + '/projects/' . $projectId . '/auth/email-password', + $headers, + [ + 'enabled' => true, + ] + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['authEmailPassword']); + } + + public function testUpdateAuthMethodLegacyStatusParam(): void + { + // Old SDK passed `status` in the body. The V23 request filter (triggered + // via `x-appwrite-response-format: 1.9.1`) must rename it to `enabled`. + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + $projectId = $this->getProject()['$id']; + + $response = $this->client->call( + Client::METHOD_PATCH, + '/projects/' . $projectId . '/auth/email-password', + $headers, + [ + 'status' => false, + ] + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authEmailPassword']); + + $response = $this->client->call( + Client::METHOD_PATCH, + '/projects/' . $projectId . '/auth/email-password', + $headers, + [ + 'status' => true, + ] + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['authEmailPassword']); + } + + public function testUpdateAuthMethodLegacyMethodParam(): void + { + // Old SDK also had `method` as a path identifier; the V23 filter renames + // a stray `method` body field to `methodId`. The URL path parameter of + // the alias already binds to `:methodId`, so supplying `method` in the + // body is tolerated. + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + $projectId = $this->getProject()['$id']; + + $response = $this->client->call( + Client::METHOD_PATCH, + '/projects/' . $projectId . '/auth/email-password', + $headers, + [ + 'method' => 'email-password', + 'status' => false, + ] + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authEmailPassword']); + + // Cleanup + $this->updateAuthMethod('email-password', true); + } + + // Helpers + + protected function updateAuthMethod(string $methodId, bool $enabled, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + return $this->client->call( + Client::METHOD_PATCH, + '/project/auth-methods/' . $methodId, + $headers, + [ + 'enabled' => $enabled, + ] + ); + } +} diff --git a/tests/e2e/Services/Project/AuthMethodsConsoleClientTest.php b/tests/e2e/Services/Project/AuthMethodsConsoleClientTest.php new file mode 100644 index 0000000000..e1ae5de357 --- /dev/null +++ b/tests/e2e/Services/Project/AuthMethodsConsoleClientTest.php @@ -0,0 +1,14 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + // Public headers carry no session / api key — this forces the shared + // auth init to actually evaluate the auth-method gate (it is bypassed + // for privileged / app users). + $publicHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + $setAuthMethod = function (string $methodId, bool $enabled) use ($serverHeaders): void { + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/auth-methods/' . $methodId, + $serverHeaders, + ['enabled' => $enabled] + ); + $this->assertSame(200, $response['headers']['status-code'], 'Failed to toggle ' . $methodId); + }; + + $methods = ['email-password', 'magic-url', 'email-otp', 'anonymous', 'invites', 'jwt', 'phone']; + + // Step 1 — Disable every auth method up front. + foreach ($methods as $methodId) { + $setAuthMethod($methodId, false); + } + + $assertBlocked = function (array $response, string $context): void { + $this->assertSame(501, $response['headers']['status-code'], $context . ' should be blocked with 501'); + $this->assertSame('user_auth_method_unsupported', $response['body']['type'] ?? '', $context . ' should return user_auth_method_unsupported'); + }; + + $assertNotBlocked = function (array $response, string $context): void { + $this->assertNotSame(501, $response['headers']['status-code'], $context . ' should not be blocked after enabling'); + $this->assertNotSame('user_auth_method_unsupported', $response['body']['type'] ?? '', $context . ' should not return user_auth_method_unsupported after enabling'); + }; + + $email = 'auth_methods_' . \uniqid() . '@localhost.test'; + $password = 'password1234'; + + // Step 2 — anonymous session creation. + $anonymousAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/sessions/anonymous', $publicHeaders); + + $assertBlocked($anonymousAttempt(), 'Anonymous session (disabled)'); + $setAuthMethod('anonymous', true); + $response = $anonymousAttempt(); + $assertNotBlocked($response, 'Anonymous session (enabled)'); + $this->assertSame(201, $response['headers']['status-code']); + + // Step 3 — email/password account creation. + $createAccount = fn () => $this->client->call(Client::METHOD_POST, '/account', $publicHeaders, [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => 'Auth Methods User', + ]); + + $assertBlocked($createAccount(), 'Account creation (email-password disabled)'); + $setAuthMethod('email-password', true); + $response = $createAccount(); + $assertNotBlocked($response, 'Account creation (email-password enabled)'); + $this->assertSame(201, $response['headers']['status-code']); + $userId = $response['body']['$id']; + + // Step 4 — email/password session creation (still gated by email-password). + // Disable momentarily to prove the session endpoint is gated too. + $setAuthMethod('email-password', false); + $emailSessionAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [ + 'email' => $email, + 'password' => $password, + ]); + + $assertBlocked($emailSessionAttempt(), 'Email/password session (disabled)'); + $setAuthMethod('email-password', true); + $response = $emailSessionAttempt(); + $assertNotBlocked($response, 'Email/password session (enabled)'); + $this->assertSame(201, $response['headers']['status-code']); + $sessionSecret = $response['cookies']['a_session_' . $projectId] ?? ''; + $this->assertNotEmpty($sessionSecret, 'Expected a session cookie after email/password login'); + + // Step 5 — email OTP token. + $emailOtpAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/tokens/email', $publicHeaders, [ + 'userId' => $userId, + 'email' => $email, + ]); + + $assertBlocked($emailOtpAttempt(), 'Email OTP (disabled)'); + $setAuthMethod('email-otp', true); + $response = $emailOtpAttempt(); + $assertNotBlocked($response, 'Email OTP (enabled)'); + $this->assertSame(201, $response['headers']['status-code']); + + // Step 6 — magic URL token. + $magicUrlAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', $publicHeaders, [ + 'userId' => ID::unique(), + 'email' => 'magic_' . \uniqid() . '@localhost.test', + ]); + + $assertBlocked($magicUrlAttempt(), 'Magic URL (disabled)'); + $setAuthMethod('magic-url', true); + $response = $magicUrlAttempt(); + $assertNotBlocked($response, 'Magic URL (enabled)'); + $this->assertSame(201, $response['headers']['status-code']); + + // Step 7 — phone token. After enabling the auth method the endpoint may + // still fail for provider reasons — we only assert that the auth-method + // gate stops fighting us. + $phoneAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/tokens/phone', $publicHeaders, [ + 'userId' => ID::unique(), + 'phone' => '+14155550199', + ]); + + $assertBlocked($phoneAttempt(), 'Phone token (disabled)'); + $setAuthMethod('phone', true); + $assertNotBlocked($phoneAttempt(), 'Phone token (enabled)'); + + // Step 8 — team invites. Needs an existing team; the session user + // isn't a team owner, so we don't assert on 201 here — the gate itself + // is what's under test and any non-501 proves it was lifted. + $teamResponse = $this->client->call(Client::METHOD_POST, '/teams', $serverHeaders, [ + 'teamId' => ID::unique(), + 'name' => 'Auth Methods Team', + ]); + $this->assertSame(201, $teamResponse['headers']['status-code']); + $teamId = $teamResponse['body']['$id']; + + $inviteHeaders = \array_merge($publicHeaders, [ + 'cookie' => 'a_session_' . $projectId . '=' . $sessionSecret, + ]); + $inviteAttempt = fn () => $this->client->call(Client::METHOD_POST, '/teams/' . $teamId . '/memberships', $inviteHeaders, [ + 'email' => 'invitee_' . \uniqid() . '@localhost.test', + 'roles' => ['developer'], + 'url' => 'http://localhost/join', + ]); + + $assertBlocked($inviteAttempt(), 'Team invite (disabled)'); + $setAuthMethod('invites', true); + $assertNotBlocked($inviteAttempt(), 'Team invite (enabled)'); + + // Step 9 — JWT creation. Requires an active session. + $sessionHeaders = \array_merge($publicHeaders, [ + 'cookie' => 'a_session_' . $projectId . '=' . $sessionSecret, + ]); + $jwtAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/jwts', $sessionHeaders); + + $assertBlocked($jwtAttempt(), 'JWT (disabled)'); + $setAuthMethod('jwt', true); + $response = $jwtAttempt(); + $assertNotBlocked($response, 'JWT (enabled)'); + $this->assertSame(201, $response['headers']['status-code']); + + // Step 10 — End goal: GET /v1/account returns 200 using the session we + // built via the (now enabled) email-password flow. + $response = $this->client->call(Client::METHOD_GET, '/account', $sessionHeaders); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($userId, $response['body']['$id']); + $this->assertSame($email, $response['body']['email']); + } +} diff --git a/tests/e2e/Services/Project/MockPhonesBase.php b/tests/e2e/Services/Project/MockPhonesBase.php new file mode 100644 index 0000000000..e41a8901bf --- /dev/null +++ b/tests/e2e/Services/Project/MockPhonesBase.php @@ -0,0 +1,550 @@ +uniquePhoneNumber(); + + $response = $this->createMockPhone($number, '123456'); + + $this->assertSame(201, $response['headers']['status-code']); + $this->assertSame($number, $response['body']['number']); + $this->assertSame('123456', $response['body']['otp']); + + $dateValidator = new DatetimeValidator(); + $this->assertTrue($dateValidator->isValid($response['body']['$createdAt'])); + $this->assertTrue($dateValidator->isValid($response['body']['$updatedAt'])); + + // Verify via GET + $get = $this->getMockPhone($number); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($number, $get['body']['number']); + $this->assertSame('123456', $get['body']['otp']); + + // Verify via LIST + $list = $this->listMockPhones(); + $this->assertSame(200, $list['headers']['status-code']); + $numbers = \array_column($list['body']['mockNumbers'], 'number'); + $this->assertContains($number, $numbers); + + // Cleanup + $this->deleteMockPhone($number); + } + + public function testCreateMockPhoneAlreadyExists(): void + { + $number = $this->uniquePhoneNumber(); + + $first = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $first['headers']['status-code']); + + $duplicate = $this->createMockPhone($number, '654321'); + $this->assertSame(409, $duplicate['headers']['status-code']); + $this->assertSame('mock_number_already_exists', $duplicate['body']['type']); + + // Original OTP must remain unchanged + $get = $this->getMockPhone($number); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('123456', $get['body']['otp']); + + // Cleanup + $this->deleteMockPhone($number); + } + + public function testCreateMockPhoneInvalidNumber(): void + { + // Missing `+` prefix — Phone validator rejects. + $response = $this->createMockPhone('16555551234', '123456'); + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateMockPhoneNumberTooLong(): void + { + // 16 digits exceeds the E.164 15-digit maximum. + $response = $this->createMockPhone('+1234567890987654', '123456'); + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateMockPhoneInvalidOtpTooShort(): void + { + $response = $this->createMockPhone($this->uniquePhoneNumber(), '123'); + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateMockPhoneInvalidOtpTooLong(): void + { + $response = $this->createMockPhone($this->uniquePhoneNumber(), '1234567'); + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateMockPhoneInvalidOtpNonNumeric(): void + { + $response = $this->createMockPhone($this->uniquePhoneNumber(), 'abc123'); + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateMockPhoneMissingNumber(): void + { + $response = $this->createMockPhone(null, '123456'); + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateMockPhoneMissingOtp(): void + { + $response = $this->createMockPhone($this->uniquePhoneNumber(), null); + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateMockPhoneWithoutAuthentication(): void + { + $response = $this->createMockPhone($this->uniquePhoneNumber(), '123456', authenticated: false); + $this->assertSame(401, $response['headers']['status-code']); + } + + // Get mock phone tests + + public function testGetMockPhone(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '987654'); + $this->assertSame(201, $create['headers']['status-code']); + + $response = $this->getMockPhone($number); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($number, $response['body']['number']); + $this->assertSame('987654', $response['body']['otp']); + + $dateValidator = new DatetimeValidator(); + $this->assertTrue($dateValidator->isValid($response['body']['$createdAt'])); + $this->assertTrue($dateValidator->isValid($response['body']['$updatedAt'])); + + // Cleanup + $this->deleteMockPhone($number); + } + + public function testGetMockPhoneNotFound(): void + { + $response = $this->getMockPhone($this->uniquePhoneNumber()); + + $this->assertSame(404, $response['headers']['status-code']); + $this->assertSame('mock_number_not_found', $response['body']['type']); + } + + public function testGetMockPhoneInvalidNumber(): void + { + // Path param is still validated with the Phone validator. + $response = $this->getMockPhone('not-a-phone'); + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testGetMockPhoneWithoutAuthentication(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $create['headers']['status-code']); + + $response = $this->getMockPhone($number, authenticated: false); + $this->assertSame(401, $response['headers']['status-code']); + + // Cleanup + $this->deleteMockPhone($number); + } + + // Update mock phone tests + + public function testUpdateMockPhone(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '111111'); + $this->assertSame(201, $create['headers']['status-code']); + + $createdAt = $create['body']['$createdAt']; + + // Sleep a bit so $updatedAt shifts noticeably — makes the assertion below meaningful. + \sleep(1); + + $update = $this->updateMockPhone($number, '222222'); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertSame($number, $update['body']['number']); + $this->assertSame('222222', $update['body']['otp']); + $this->assertSame($createdAt, $update['body']['$createdAt']); + $this->assertNotSame($createdAt, $update['body']['$updatedAt']); + + // Verify persistence via GET + $get = $this->getMockPhone($number); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('222222', $get['body']['otp']); + + // Cleanup + $this->deleteMockPhone($number); + } + + public function testUpdateMockPhoneNotFound(): void + { + $response = $this->updateMockPhone($this->uniquePhoneNumber(), '123456'); + + $this->assertSame(404, $response['headers']['status-code']); + $this->assertSame('mock_number_not_found', $response['body']['type']); + } + + public function testUpdateMockPhoneInvalidOtp(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $create['headers']['status-code']); + + $response = $this->updateMockPhone($number, 'abc123'); + $this->assertSame(400, $response['headers']['status-code']); + + // Original OTP must remain unchanged + $get = $this->getMockPhone($number); + $this->assertSame('123456', $get['body']['otp']); + + // Cleanup + $this->deleteMockPhone($number); + } + + public function testUpdateMockPhoneMissingOtp(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $create['headers']['status-code']); + + $response = $this->updateMockPhone($number, null); + $this->assertSame(400, $response['headers']['status-code']); + + // Cleanup + $this->deleteMockPhone($number); + } + + public function testUpdateMockPhoneWithoutAuthentication(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $create['headers']['status-code']); + + $response = $this->updateMockPhone($number, '654321', authenticated: false); + $this->assertSame(401, $response['headers']['status-code']); + + // Verify it's unchanged + $get = $this->getMockPhone($number); + $this->assertSame('123456', $get['body']['otp']); + + // Cleanup + $this->deleteMockPhone($number); + } + + // List mock phones tests + + public function testListMockPhones(): void + { + $number1 = $this->uniquePhoneNumber(); + $number2 = $this->uniquePhoneNumber(); + $number3 = $this->uniquePhoneNumber(); + + $this->assertSame(201, $this->createMockPhone($number1, '111111')['headers']['status-code']); + $this->assertSame(201, $this->createMockPhone($number2, '222222')['headers']['status-code']); + $this->assertSame(201, $this->createMockPhone($number3, '333333')['headers']['status-code']); + + $response = $this->listMockPhones(); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('mockNumbers', $response['body']); + $this->assertArrayHasKey('total', $response['body']); + $this->assertIsArray($response['body']['mockNumbers']); + $this->assertIsInt($response['body']['total']); + $this->assertGreaterThanOrEqual(3, $response['body']['total']); + $this->assertGreaterThanOrEqual(3, \count($response['body']['mockNumbers'])); + + // Verify shape of each entry + foreach ($response['body']['mockNumbers'] as $entry) { + $this->assertArrayHasKey('number', $entry); + $this->assertArrayHasKey('otp', $entry); + $this->assertArrayHasKey('$createdAt', $entry); + $this->assertArrayHasKey('$updatedAt', $entry); + } + + // All three seeded phones must be in the list + $numbers = \array_column($response['body']['mockNumbers'], 'number'); + $this->assertContains($number1, $numbers); + $this->assertContains($number2, $numbers); + $this->assertContains($number3, $numbers); + + // Cleanup + $this->deleteMockPhone($number1); + $this->deleteMockPhone($number2); + $this->deleteMockPhone($number3); + } + + public function testListMockPhonesTotalFalse(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $create['headers']['status-code']); + + $response = $this->listMockPhones(total: false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['total']); + $this->assertGreaterThanOrEqual(1, \count($response['body']['mockNumbers'])); + + // Cleanup + $this->deleteMockPhone($number); + } + + public function testListMockPhonesTotalMatchesCount(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $create['headers']['status-code']); + + $response = $this->listMockPhones(); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(\count($response['body']['mockNumbers']), $response['body']['total']); + + // Cleanup + $this->deleteMockPhone($number); + } + + public function testListMockPhonesWithLimit(): void + { + $number1 = $this->uniquePhoneNumber(); + $number2 = $this->uniquePhoneNumber(); + + $this->assertSame(201, $this->createMockPhone($number1, '111111')['headers']['status-code']); + $this->assertSame(201, $this->createMockPhone($number2, '222222')['headers']['status-code']); + + $response = $this->listMockPhones([ + Query::limit(1)->toString(), + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body']['mockNumbers']); + $this->assertGreaterThanOrEqual(2, $response['body']['total']); + + // Cleanup + $this->deleteMockPhone($number1); + $this->deleteMockPhone($number2); + } + + public function testListMockPhonesWithOffset(): void + { + $number1 = $this->uniquePhoneNumber(); + $number2 = $this->uniquePhoneNumber(); + + $this->assertSame(201, $this->createMockPhone($number1, '111111')['headers']['status-code']); + $this->assertSame(201, $this->createMockPhone($number2, '222222')['headers']['status-code']); + + $listAll = $this->listMockPhones(); + $this->assertSame(200, $listAll['headers']['status-code']); + $totalAll = \count($listAll['body']['mockNumbers']); + + $listOffset = $this->listMockPhones([ + Query::offset(1)->toString(), + ]); + + $this->assertSame(200, $listOffset['headers']['status-code']); + $this->assertCount($totalAll - 1, $listOffset['body']['mockNumbers']); + $this->assertSame($listAll['body']['total'], $listOffset['body']['total']); + + // Cleanup + $this->deleteMockPhone($number1); + $this->deleteMockPhone($number2); + } + + public function testListMockPhonesWithoutAuthentication(): void + { + $response = $this->listMockPhones(authenticated: false); + $this->assertSame(401, $response['headers']['status-code']); + } + + // Delete mock phone tests + + public function testDeleteMockPhone(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $create['headers']['status-code']); + + // Confirm it exists + $this->assertSame(200, $this->getMockPhone($number)['headers']['status-code']); + + $response = $this->deleteMockPhone($number); + $this->assertSame(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); + + // Confirm it is gone + $get = $this->getMockPhone($number); + $this->assertSame(404, $get['headers']['status-code']); + $this->assertSame('mock_number_not_found', $get['body']['type']); + } + + public function testDeleteMockPhoneNotFound(): void + { + $response = $this->deleteMockPhone($this->uniquePhoneNumber()); + + $this->assertSame(404, $response['headers']['status-code']); + $this->assertSame('mock_number_not_found', $response['body']['type']); + } + + public function testDeleteMockPhoneDoubleDelete(): void + { + $number = $this->uniquePhoneNumber(); + $this->assertSame(201, $this->createMockPhone($number, '123456')['headers']['status-code']); + + $first = $this->deleteMockPhone($number); + $this->assertSame(204, $first['headers']['status-code']); + + $second = $this->deleteMockPhone($number); + $this->assertSame(404, $second['headers']['status-code']); + $this->assertSame('mock_number_not_found', $second['body']['type']); + } + + public function testDeleteMockPhoneRemovedFromList(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $create['headers']['status-code']); + + $before = $this->listMockPhones(); + $this->assertSame(200, $before['headers']['status-code']); + $this->assertContains($number, \array_column($before['body']['mockNumbers'], 'number')); + $countBefore = $before['body']['total']; + + $delete = $this->deleteMockPhone($number); + $this->assertSame(204, $delete['headers']['status-code']); + + $after = $this->listMockPhones(); + $this->assertSame(200, $after['headers']['status-code']); + $this->assertSame($countBefore - 1, $after['body']['total']); + $this->assertNotContains($number, \array_column($after['body']['mockNumbers'], 'number')); + } + + public function testDeleteMockPhoneWithoutAuthentication(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $create['headers']['status-code']); + + $response = $this->deleteMockPhone($number, authenticated: false); + $this->assertSame(401, $response['headers']['status-code']); + + // Still present + $this->assertSame(200, $this->getMockPhone($number)['headers']['status-code']); + + // Cleanup + $this->deleteMockPhone($number); + } + + // Helpers + + protected function createMockPhone(?string $number, ?string $otp, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + $params = []; + if ($number !== null) { + $params['number'] = $number; + } + if ($otp !== null) { + $params['otp'] = $otp; + } + + return $this->client->call(Client::METHOD_POST, '/project/mock-phones', $headers, $params); + } + + protected function getMockPhone(string $number, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_GET, '/project/mock-phones/' . $number, $headers); + } + + protected function updateMockPhone(string $number, ?string $otp, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + $params = []; + if ($otp !== null) { + $params['otp'] = $otp; + } + + return $this->client->call(Client::METHOD_PUT, '/project/mock-phones/' . $number, $headers, $params); + } + + protected function listMockPhones(?array $queries = null, ?bool $total = null, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + $params = []; + if ($queries !== null) { + $params['queries'] = $queries; + } + if ($total !== null) { + $params['total'] = $total; + } + + return $this->client->call(Client::METHOD_GET, '/project/mock-phones', $headers, $params); + } + + protected function deleteMockPhone(string $number, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_DELETE, '/project/mock-phones/' . $number, $headers); + } + + protected function uniquePhoneNumber(): string + { + // E.164: leading '+', first digit 1-9, 10 more digits. Randomised to avoid + // collisions between interleaved tests that all live in the same project. + return '+1' . \random_int(2000000000, 9999999999); + } +} diff --git a/tests/e2e/Services/Project/MockPhonesConsoleClientTest.php b/tests/e2e/Services/Project/MockPhonesConsoleClientTest.php new file mode 100644 index 0000000000..c4819774bf --- /dev/null +++ b/tests/e2e/Services/Project/MockPhonesConsoleClientTest.php @@ -0,0 +1,14 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $clientHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + // Step 1: Configure two mock phones with distinct OTPs. + $phoneA = '+1' . \random_int(2000000000, 9999999999); + $phoneB = '+1' . \random_int(2000000000, 9999999999); + $otpA = '111111'; + $otpB = '222222'; + + $mockA = $this->client->call(Client::METHOD_POST, '/project/mock-phones', $serverHeaders, [ + 'number' => $phoneA, + 'otp' => $otpA, + ]); + $this->assertSame(201, $mockA['headers']['status-code']); + $this->assertSame($phoneA, $mockA['body']['number']); + $this->assertSame($otpA, $mockA['body']['otp']); + + $mockB = $this->client->call(Client::METHOD_POST, '/project/mock-phones', $serverHeaders, [ + 'number' => $phoneB, + 'otp' => $otpB, + ]); + $this->assertSame(201, $mockB['headers']['status-code']); + $this->assertSame($phoneB, $mockB['body']['number']); + $this->assertSame($otpB, $mockB['body']['otp']); + + // Step 2 (Phone A): sign-in flow that also creates the user (userId = unique()). + $tokenA = $this->client->call(Client::METHOD_POST, '/account/tokens/phone', $clientHeaders, [ + 'userId' => ID::unique(), + 'phone' => $phoneA, + ]); + $this->assertSame(201, $tokenA['headers']['status-code']); + $userIdA = $tokenA['body']['userId']; + $this->assertNotEmpty($userIdA); + + // Arbitrary wrong OTP must be rejected. + $wrongA = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', $clientHeaders, [ + 'userId' => $userIdA, + 'secret' => '999999', + ]); + $this->assertSame(401, $wrongA['headers']['status-code']); + + // Phone B's OTP must not unlock Phone A's user — proves OTPs are scoped to the mock record. + $crossA = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', $clientHeaders, [ + 'userId' => $userIdA, + 'secret' => $otpB, + ]); + $this->assertSame(401, $crossA['headers']['status-code']); + + // Correct mock OTP establishes the session. + $sessionA = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', $clientHeaders, [ + 'userId' => $userIdA, + 'secret' => $otpA, + ]); + $this->assertSame(201, $sessionA['headers']['status-code']); + $this->assertNotEmpty($sessionA['cookies']['a_session_' . $projectId] ?? null); + $cookieA = $sessionA['cookies']['a_session_' . $projectId]; + + // GET /account using the session confirms identity. + $accountA = $this->client->call(Client::METHOD_GET, '/account', \array_merge($clientHeaders, [ + 'cookie' => 'a_session_' . $projectId . '=' . $cookieA, + ])); + $this->assertSame(200, $accountA['headers']['status-code']); + $this->assertSame($userIdA, $accountA['body']['$id']); + $this->assertSame($phoneA, $accountA['body']['phone']); + $this->assertTrue($accountA['body']['phoneVerification']); + + // Step 3 (Phone B): pre-create the user server-side, then sign in with the mock OTP. + $precreated = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'phone' => $phoneB, + ]); + $this->assertSame(201, $precreated['headers']['status-code']); + $userIdB = $precreated['body']['$id']; + $this->assertSame($phoneB, $precreated['body']['phone']); + + $tokenB = $this->client->call(Client::METHOD_POST, '/account/tokens/phone', $clientHeaders, [ + 'userId' => $userIdB, + 'phone' => $phoneB, + ]); + $this->assertSame(201, $tokenB['headers']['status-code']); + $this->assertSame($userIdB, $tokenB['body']['userId']); + + // Arbitrary wrong OTP must be rejected. + $wrongB = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', $clientHeaders, [ + 'userId' => $userIdB, + 'secret' => '000000', + ]); + $this->assertSame(401, $wrongB['headers']['status-code']); + + // Phone A's OTP must not unlock Phone B's user. + $crossB = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', $clientHeaders, [ + 'userId' => $userIdB, + 'secret' => $otpA, + ]); + $this->assertSame(401, $crossB['headers']['status-code']); + + // Correct mock OTP establishes the session. + $sessionB = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', $clientHeaders, [ + 'userId' => $userIdB, + 'secret' => $otpB, + ]); + $this->assertSame(201, $sessionB['headers']['status-code']); + $this->assertNotEmpty($sessionB['cookies']['a_session_' . $projectId] ?? null); + $cookieB = $sessionB['cookies']['a_session_' . $projectId]; + + // GET /account using the session confirms identity. + $accountB = $this->client->call(Client::METHOD_GET, '/account', \array_merge($clientHeaders, [ + 'cookie' => 'a_session_' . $projectId . '=' . $cookieB, + ])); + $this->assertSame(200, $accountB['headers']['status-code']); + $this->assertSame($userIdB, $accountB['body']['$id']); + $this->assertSame($phoneB, $accountB['body']['phone']); + $this->assertTrue($accountB['body']['phoneVerification']); + + // Cross-check: the two flows produced distinct users. + $this->assertNotSame($userIdA, $userIdB); + $this->assertNotSame($accountA['body']['phone'], $accountB['body']['phone']); + + // Cleanup mock phone config to avoid polluting project state for later tests. + $this->client->call(Client::METHOD_DELETE, '/project/mock-phones/' . \urlencode($phoneA), $serverHeaders); + $this->client->call(Client::METHOD_DELETE, '/project/mock-phones/' . \urlencode($phoneB), $serverHeaders); + } +} diff --git a/tests/e2e/Services/Project/PoliciesBase.php b/tests/e2e/Services/Project/PoliciesBase.php new file mode 100644 index 0000000000..04906c6c2b --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesBase.php @@ -0,0 +1,1183 @@ + ['enabled'], + 'password-history' => ['total'], + 'password-personal-data' => ['enabled'], + 'session-alert' => ['enabled'], + 'session-duration' => ['duration'], + 'session-invalidation' => ['enabled'], + 'session-limit' => ['total'], + 'user-limit' => ['total'], + 'membership-privacy' => ['userId', 'userEmail', 'userPhone', 'userName', 'userMFA'], + ]; + + foreach ($expectedFields as $policyId => $fields) { + $response = $this->getPolicy($policyId); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($policyId, $response['body']['$id']); + + foreach ($fields as $field) { + $this->assertArrayHasKey($field, $response['body']); + } + } + } + + public function testGetPolicyMatchesListPolicies(): void + { + $list = $this->listPolicies(); + + $this->assertSame(200, $list['headers']['status-code']); + + $byId = []; + foreach ($list['body']['policies'] as $policy) { + $byId[$policy['$id']] = $policy; + } + + foreach (\array_keys($byId) as $policyId) { + $response = $this->getPolicy($policyId); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($byId[$policyId], $response['body']); + } + } + + public function testGetPolicyReflectsUpdates(): void + { + $this->updatePasswordDictionaryPolicy(true); + $this->updatePasswordHistoryPolicy(5); + $this->updateSessionDurationPolicy(3600); + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => false, + 'userName' => true, + 'userMFA' => true, + ]); + + $passwordDictionary = $this->getPolicy('password-dictionary'); + $passwordHistory = $this->getPolicy('password-history'); + $sessionDuration = $this->getPolicy('session-duration'); + $membershipPrivacy = $this->getPolicy('membership-privacy'); + + $this->assertSame(200, $passwordDictionary['headers']['status-code']); + $this->assertSame(true, $passwordDictionary['body']['enabled']); + + $this->assertSame(200, $passwordHistory['headers']['status-code']); + $this->assertSame(5, $passwordHistory['body']['total']); + + $this->assertSame(200, $sessionDuration['headers']['status-code']); + $this->assertSame(3600, $sessionDuration['body']['duration']); + + $this->assertSame(200, $membershipPrivacy['headers']['status-code']); + $this->assertSame(true, $membershipPrivacy['body']['userId']); + $this->assertSame(true, $membershipPrivacy['body']['userEmail']); + $this->assertSame(false, $membershipPrivacy['body']['userPhone']); + $this->assertSame(true, $membershipPrivacy['body']['userName']); + $this->assertSame(true, $membershipPrivacy['body']['userMFA']); + + // Cleanup + $this->updatePasswordDictionaryPolicy(false); + $this->updatePasswordHistoryPolicy(null); + $this->updateSessionDurationPolicy(31536000); + $this->updateMembershipPrivacyPolicy([ + 'userId' => false, + 'userEmail' => false, + 'userPhone' => false, + 'userName' => false, + 'userMFA' => false, + ]); + } + + public function testGetPolicyWithoutAuthentication(): void + { + $response = $this->getPolicy('password-dictionary', authenticated: false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testGetPolicyInvalidPolicyId(): void + { + $response = $this->getPolicy('invalid-policy'); + + $this->assertSame(400, $response['headers']['status-code']); + } + + // ========================================================================= + // List Policies + // ========================================================================= + + public function testListPolicies(): void + { + $response = $this->listPolicies(); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('policies', $response['body']); + $this->assertArrayHasKey('total', $response['body']); + $this->assertIsArray($response['body']['policies']); + $this->assertIsInt($response['body']['total']); + $this->assertSame(9, $response['body']['total']); + $this->assertCount(9, $response['body']['policies']); + + $policyIds = \array_column($response['body']['policies'], '$id'); + + $this->assertContains('password-dictionary', $policyIds); + $this->assertContains('password-history', $policyIds); + $this->assertContains('password-personal-data', $policyIds); + $this->assertContains('session-alert', $policyIds); + $this->assertContains('session-duration', $policyIds); + $this->assertContains('session-invalidation', $policyIds); + $this->assertContains('session-limit', $policyIds); + $this->assertContains('user-limit', $policyIds); + $this->assertContains('membership-privacy', $policyIds); + } + + public function testListPoliciesResponseModel(): void + { + $response = $this->listPolicies(); + + $this->assertSame(200, $response['headers']['status-code']); + + foreach ($response['body']['policies'] as $policy) { + $this->assertArrayHasKey('$id', $policy); + } + + $byId = []; + foreach ($response['body']['policies'] as $policy) { + $byId[$policy['$id']] = $policy; + } + + $this->assertArrayHasKey('enabled', $byId['password-dictionary']); + $this->assertArrayHasKey('total', $byId['password-history']); + $this->assertArrayHasKey('enabled', $byId['password-personal-data']); + $this->assertArrayHasKey('enabled', $byId['session-alert']); + $this->assertArrayHasKey('duration', $byId['session-duration']); + $this->assertArrayHasKey('enabled', $byId['session-invalidation']); + $this->assertArrayHasKey('total', $byId['session-limit']); + $this->assertArrayHasKey('total', $byId['user-limit']); + $this->assertArrayHasKey('userId', $byId['membership-privacy']); + $this->assertArrayHasKey('userEmail', $byId['membership-privacy']); + $this->assertArrayHasKey('userPhone', $byId['membership-privacy']); + $this->assertArrayHasKey('userName', $byId['membership-privacy']); + $this->assertArrayHasKey('userMFA', $byId['membership-privacy']); + } + + public function testListPoliciesReflectsUpdates(): void + { + $this->updatePasswordDictionaryPolicy(true); + $this->updatePasswordHistoryPolicy(5); + $this->updateSessionDurationPolicy(3600); + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => false, + 'userName' => true, + 'userMFA' => true, + ]); + + $response = $this->listPolicies(); + + $this->assertSame(200, $response['headers']['status-code']); + + $byId = []; + foreach ($response['body']['policies'] as $policy) { + $byId[$policy['$id']] = $policy; + } + + $this->assertSame(true, $byId['password-dictionary']['enabled']); + $this->assertSame(5, $byId['password-history']['total']); + $this->assertSame(3600, $byId['session-duration']['duration']); + $this->assertSame(true, $byId['membership-privacy']['userId']); + $this->assertSame(true, $byId['membership-privacy']['userEmail']); + $this->assertSame(false, $byId['membership-privacy']['userPhone']); + $this->assertSame(true, $byId['membership-privacy']['userName']); + $this->assertSame(true, $byId['membership-privacy']['userMFA']); + + // Cleanup + $this->updatePasswordDictionaryPolicy(false); + $this->updatePasswordHistoryPolicy(null); + $this->updateSessionDurationPolicy(31536000); + $this->updateMembershipPrivacyPolicy([ + 'userId' => false, + 'userEmail' => false, + 'userPhone' => false, + 'userName' => false, + 'userMFA' => false, + ]); + } + + public function testListPoliciesTotalFalse(): void + { + $response = $this->listPolicies(total: false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['total']); + $this->assertCount(9, $response['body']['policies']); + } + + public function testListPoliciesWithLimit(): void + { + $response = $this->listPolicies([ + Query::limit(1)->toString(), + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body']['policies']); + $this->assertSame(9, $response['body']['total']); + } + + public function testListPoliciesWithOffset(): void + { + $listAll = $this->listPolicies(); + $this->assertSame(200, $listAll['headers']['status-code']); + + $listOffset = $this->listPolicies([ + Query::offset(1)->toString(), + ]); + + $this->assertSame(200, $listOffset['headers']['status-code']); + $this->assertCount(\count($listAll['body']['policies']) - 1, $listOffset['body']['policies']); + $this->assertSame($listAll['body']['total'], $listOffset['body']['total']); + } + + public function testListPoliciesWithoutAuthentication(): void + { + $response = $this->listPolicies(authenticated: false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Password Dictionary Policy + // ========================================================================= + + public function testUpdatePasswordDictionaryPolicyEnable(): void + { + $response = $this->updatePasswordDictionaryPolicy(true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['authPasswordDictionary']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(true, $project['body']['authPasswordDictionary']); + + // Cleanup + $this->updatePasswordDictionaryPolicy(false); + } + + public function testUpdatePasswordDictionaryPolicyDisable(): void + { + $this->updatePasswordDictionaryPolicy(true); + + $response = $this->updatePasswordDictionaryPolicy(false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authPasswordDictionary']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(false, $project['body']['authPasswordDictionary']); + } + + public function testUpdatePasswordDictionaryPolicyIdempotent(): void + { + $first = $this->updatePasswordDictionaryPolicy(true); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(true, $first['body']['authPasswordDictionary']); + + $second = $this->updatePasswordDictionaryPolicy(true); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(true, $second['body']['authPasswordDictionary']); + + // Cleanup + $this->updatePasswordDictionaryPolicy(false); + } + + public function testUpdatePasswordDictionaryPolicyWithoutAuth(): void + { + $response = $this->updatePasswordDictionaryPolicy(true, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdatePasswordDictionaryPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $this->buildHeaders(), [ + 'enabled' => 'not-a-boolean', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordDictionaryPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + // ========================================================================= + // Password History Policy + // ========================================================================= + + public function testUpdatePasswordHistoryPolicyEnable(): void + { + $response = $this->updatePasswordHistoryPolicy(5); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(5, $response['body']['authPasswordHistory']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(5, $project['body']['authPasswordHistory']); + + // Cleanup (disable by setting total to null which maps to 0) + $this->updatePasswordHistoryPolicy(null); + } + + public function testUpdatePasswordHistoryPolicyMin(): void + { + $response = $this->updatePasswordHistoryPolicy(1); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(1, $response['body']['authPasswordHistory']); + + // Cleanup + $this->updatePasswordHistoryPolicy(null); + } + + public function testUpdatePasswordHistoryPolicyMax(): void + { + $response = $this->updatePasswordHistoryPolicy(5000); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(5000, $response['body']['authPasswordHistory']); + + // Cleanup + $this->updatePasswordHistoryPolicy(null); + } + + public function testUpdatePasswordHistoryPolicyDisable(): void + { + $this->updatePasswordHistoryPolicy(5); + + $response = $this->updatePasswordHistoryPolicy(null); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['authPasswordHistory']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(0, $project['body']['authPasswordHistory']); + } + + public function testUpdatePasswordHistoryPolicyBelowMin(): void + { + $response = $this->updatePasswordHistoryPolicy(0); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordHistoryPolicyAboveMax(): void + { + $response = $this->updatePasswordHistoryPolicy(5001); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordHistoryPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $this->buildHeaders(), [ + 'total' => 'not-a-number', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordHistoryPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordHistoryPolicyWithoutAuth(): void + { + $response = $this->updatePasswordHistoryPolicy(5, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Password Personal Data Policy + // ========================================================================= + + public function testUpdatePasswordPersonalDataPolicyEnable(): void + { + $response = $this->updatePasswordPersonalDataPolicy(true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['authPersonalDataCheck']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(true, $project['body']['authPersonalDataCheck']); + + // Cleanup + $this->updatePasswordPersonalDataPolicy(false); + } + + public function testUpdatePasswordPersonalDataPolicyDisable(): void + { + $this->updatePasswordPersonalDataPolicy(true); + + $response = $this->updatePasswordPersonalDataPolicy(false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authPersonalDataCheck']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(false, $project['body']['authPersonalDataCheck']); + } + + public function testUpdatePasswordPersonalDataPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', $this->buildHeaders(), [ + 'enabled' => 'not-a-boolean', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordPersonalDataPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordPersonalDataPolicyWithoutAuth(): void + { + $response = $this->updatePasswordPersonalDataPolicy(true, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Session Alert Policy + // ========================================================================= + + public function testUpdateSessionAlertPolicyEnable(): void + { + $response = $this->updateSessionAlertPolicy(true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['authSessionAlerts']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(true, $project['body']['authSessionAlerts']); + + // Cleanup + $this->updateSessionAlertPolicy(false); + } + + public function testUpdateSessionAlertPolicyDisable(): void + { + $this->updateSessionAlertPolicy(true); + + $response = $this->updateSessionAlertPolicy(false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authSessionAlerts']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(false, $project['body']['authSessionAlerts']); + } + + public function testUpdateSessionAlertPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', $this->buildHeaders(), [ + 'enabled' => 'not-a-boolean', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionAlertPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionAlertPolicyWithoutAuth(): void + { + $response = $this->updateSessionAlertPolicy(true, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Session Duration Policy + // ========================================================================= + + public function testUpdateSessionDurationPolicy(): void + { + $response = $this->updateSessionDurationPolicy(3600); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(3600, $response['body']['authDuration']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(3600, $project['body']['authDuration']); + + // Cleanup (reset to default 1 year) + $this->updateSessionDurationPolicy(31536000); + } + + public function testUpdateSessionDurationPolicyMin(): void + { + $response = $this->updateSessionDurationPolicy(5); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(5, $response['body']['authDuration']); + + // Cleanup + $this->updateSessionDurationPolicy(31536000); + } + + public function testUpdateSessionDurationPolicyMax(): void + { + $response = $this->updateSessionDurationPolicy(31536000); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(31536000, $response['body']['authDuration']); + } + + public function testUpdateSessionDurationPolicyBelowMin(): void + { + $response = $this->updateSessionDurationPolicy(4); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionDurationPolicyAboveMax(): void + { + $response = $this->updateSessionDurationPolicy(31536001); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionDurationPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', $this->buildHeaders(), [ + 'duration' => 'not-a-number', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionDurationPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionDurationPolicyWithoutAuth(): void + { + $response = $this->updateSessionDurationPolicy(3600, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Session Invalidation Policy + // ========================================================================= + + public function testUpdateSessionInvalidationPolicyEnable(): void + { + $response = $this->updateSessionInvalidationPolicy(true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['authInvalidateSessions']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(true, $project['body']['authInvalidateSessions']); + + // Cleanup + $this->updateSessionInvalidationPolicy(false); + } + + public function testUpdateSessionInvalidationPolicyDisable(): void + { + $this->updateSessionInvalidationPolicy(true); + + $response = $this->updateSessionInvalidationPolicy(false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authInvalidateSessions']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(false, $project['body']['authInvalidateSessions']); + } + + public function testUpdateSessionInvalidationPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', $this->buildHeaders(), [ + 'enabled' => 'not-a-boolean', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionInvalidationPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionInvalidationPolicyWithoutAuth(): void + { + $response = $this->updateSessionInvalidationPolicy(true, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Session Limit Policy + // ========================================================================= + + public function testUpdateSessionLimitPolicy(): void + { + $response = $this->updateSessionLimitPolicy(5); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(5, $response['body']['authSessionsLimit']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(5, $project['body']['authSessionsLimit']); + + // Cleanup (reset to default) + $this->updateSessionLimitPolicy(10); + } + + public function testUpdateSessionLimitPolicyMin(): void + { + $response = $this->updateSessionLimitPolicy(1); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(1, $response['body']['authSessionsLimit']); + + // Cleanup + $this->updateSessionLimitPolicy(10); + } + + public function testUpdateSessionLimitPolicyMax(): void + { + $response = $this->updateSessionLimitPolicy(5000); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(5000, $response['body']['authSessionsLimit']); + + // Cleanup + $this->updateSessionLimitPolicy(10); + } + + public function testUpdateSessionLimitPolicyDisable(): void + { + $response = $this->updateSessionLimitPolicy(null); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['authSessionsLimit']); + + // Cleanup + $this->updateSessionLimitPolicy(10); + } + + public function testUpdateSessionLimitPolicyBelowMin(): void + { + $response = $this->updateSessionLimitPolicy(0); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionLimitPolicyAboveMax(): void + { + $response = $this->updateSessionLimitPolicy(5001); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionLimitPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', $this->buildHeaders(), [ + 'total' => 'not-a-number', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionLimitPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionLimitPolicyWithoutAuth(): void + { + $response = $this->updateSessionLimitPolicy(5, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // User Limit Policy + // ========================================================================= + + public function testUpdateUserLimitPolicy(): void + { + $response = $this->updateUserLimitPolicy(100); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(100, $response['body']['authLimit']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(100, $project['body']['authLimit']); + + // Cleanup + $this->updateUserLimitPolicy(null); + } + + public function testUpdateUserLimitPolicyMin(): void + { + $response = $this->updateUserLimitPolicy(1); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(1, $response['body']['authLimit']); + + // Cleanup + $this->updateUserLimitPolicy(null); + } + + public function testUpdateUserLimitPolicyMax(): void + { + $response = $this->updateUserLimitPolicy(5000); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(5000, $response['body']['authLimit']); + + // Cleanup + $this->updateUserLimitPolicy(null); + } + + public function testUpdateUserLimitPolicyDisable(): void + { + $this->updateUserLimitPolicy(100); + + $response = $this->updateUserLimitPolicy(null); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['authLimit']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(0, $project['body']['authLimit']); + } + + public function testUpdateUserLimitPolicyBelowMin(): void + { + $response = $this->updateUserLimitPolicy(0); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateUserLimitPolicyAboveMax(): void + { + $response = $this->updateUserLimitPolicy(5001); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateUserLimitPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $this->buildHeaders(), [ + 'total' => 'not-a-number', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateUserLimitPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateUserLimitPolicyWithoutAuth(): void + { + $response = $this->updateUserLimitPolicy(100, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Membership Privacy Policy + // ========================================================================= + + public function testUpdateMembershipPrivacyPolicyAllEnabled(): void + { + $response = $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['authMembershipsUserId']); + $this->assertSame(true, $response['body']['authMembershipsUserEmail']); + $this->assertSame(true, $response['body']['authMembershipsUserPhone']); + $this->assertSame(true, $response['body']['authMembershipsUserName']); + $this->assertSame(true, $response['body']['authMembershipsMfa']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(true, $project['body']['authMembershipsUserId']); + $this->assertSame(true, $project['body']['authMembershipsUserEmail']); + $this->assertSame(true, $project['body']['authMembershipsUserPhone']); + $this->assertSame(true, $project['body']['authMembershipsUserName']); + $this->assertSame(true, $project['body']['authMembershipsMfa']); + } + + public function testUpdateMembershipPrivacyPolicyAllDisabled(): void + { + $response = $this->updateMembershipPrivacyPolicy([ + 'userId' => false, + 'userEmail' => false, + 'userPhone' => false, + 'userName' => false, + 'userMFA' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authMembershipsUserId']); + $this->assertSame(false, $response['body']['authMembershipsUserEmail']); + $this->assertSame(false, $response['body']['authMembershipsUserPhone']); + $this->assertSame(false, $response['body']['authMembershipsUserName']); + $this->assertSame(false, $response['body']['authMembershipsMfa']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(false, $project['body']['authMembershipsUserId']); + $this->assertSame(false, $project['body']['authMembershipsUserEmail']); + $this->assertSame(false, $project['body']['authMembershipsUserPhone']); + $this->assertSame(false, $project['body']['authMembershipsUserName']); + $this->assertSame(false, $project['body']['authMembershipsMfa']); + + // Cleanup (restore defaults) + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + } + + public function testUpdateMembershipPrivacyPolicyMixed(): void + { + $response = $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => false, + 'userPhone' => true, + 'userName' => false, + 'userMFA' => true, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['authMembershipsUserId']); + $this->assertSame(false, $response['body']['authMembershipsUserEmail']); + $this->assertSame(true, $response['body']['authMembershipsUserPhone']); + $this->assertSame(false, $response['body']['authMembershipsUserName']); + $this->assertSame(true, $response['body']['authMembershipsMfa']); + + // Cleanup + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + } + + public function testUpdateMembershipPrivacyPolicyIndividualFields(): void + { + // Start from a known baseline where every field is enabled + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + + $fields = [ + 'userId' => 'authMembershipsUserId', + 'userEmail' => 'authMembershipsUserEmail', + 'userPhone' => 'authMembershipsUserPhone', + 'userName' => 'authMembershipsUserName', + 'userMFA' => 'authMembershipsMfa', + ]; + + // Each field can be toggled individually without clobbering the others + foreach ($fields as $param => $attribute) { + $response = $this->updateMembershipPrivacyPolicy([$param => false]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body'][$attribute]); + + foreach ($fields as $otherParam => $otherAttribute) { + if ($otherParam === $param) { + continue; + } + $this->assertSame(true, $response['body'][$otherAttribute], $otherAttribute . ' should be untouched while only ' . $param . ' was updated'); + } + + // Restore the field before the next iteration + $restore = $this->updateMembershipPrivacyPolicy([$param => true]); + $this->assertSame(200, $restore['headers']['status-code']); + $this->assertSame(true, $restore['body'][$attribute]); + } + } + + public function testUpdateMembershipPrivacyPolicyMultipleFields(): void + { + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + + $response = $this->updateMembershipPrivacyPolicy([ + 'userId' => false, + 'userPhone' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authMembershipsUserId']); + $this->assertSame(false, $response['body']['authMembershipsUserPhone']); + $this->assertSame(true, $response['body']['authMembershipsUserEmail']); + $this->assertSame(true, $response['body']['authMembershipsUserName']); + $this->assertSame(true, $response['body']['authMembershipsMfa']); + + // Cleanup + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + } + + public function testUpdateMembershipPrivacyPolicyEmptyBody(): void + { + // PATCH with no fields should be a no-op, leaving state unchanged + $this->updateMembershipPrivacyPolicy([ + 'userId' => false, + 'userEmail' => false, + 'userPhone' => false, + 'userName' => false, + 'userMFA' => false, + ]); + + $response = $this->updateMembershipPrivacyPolicy([]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authMembershipsUserId']); + $this->assertSame(false, $response['body']['authMembershipsUserEmail']); + $this->assertSame(false, $response['body']['authMembershipsUserPhone']); + $this->assertSame(false, $response['body']['authMembershipsUserName']); + $this->assertSame(false, $response['body']['authMembershipsMfa']); + + // Cleanup + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + } + + public function testUpdateMembershipPrivacyPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $this->buildHeaders(), [ + 'userId' => 'not-a-boolean', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateMembershipPrivacyPolicyWithoutAuth(): void + { + $response = $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ], false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + protected function buildHeaders(bool $authenticated = true): array + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $headers; + } + + protected function getProjectDocument(): array + { + return $this->client->call(Client::METHOD_GET, '/projects/' . $this->getProject()['$id'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => 'console', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]); + } + + protected function listPolicies(?array $queries = null, ?bool $total = null, bool $authenticated = true): mixed + { + $params = []; + + if ($queries !== null) { + $params['queries'] = $queries; + } + + if ($total !== null) { + $params['total'] = $total; + } + + return $this->client->call(Client::METHOD_GET, '/project/policies', $this->buildHeaders($authenticated), $params); + } + + protected function getPolicy(string $policyId, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_GET, '/project/policies/' . $policyId, $this->buildHeaders($authenticated)); + } + + protected function updatePasswordDictionaryPolicy(bool $enabled, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $this->buildHeaders($authenticated), [ + 'enabled' => $enabled, + ]); + } + + protected function updatePasswordHistoryPolicy(?int $total, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $this->buildHeaders($authenticated), [ + 'total' => $total, + ]); + } + + protected function updatePasswordPersonalDataPolicy(bool $enabled, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', $this->buildHeaders($authenticated), [ + 'enabled' => $enabled, + ]); + } + + protected function updateSessionAlertPolicy(bool $enabled, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', $this->buildHeaders($authenticated), [ + 'enabled' => $enabled, + ]); + } + + protected function updateSessionDurationPolicy(int $duration, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', $this->buildHeaders($authenticated), [ + 'duration' => $duration, + ]); + } + + protected function updateSessionInvalidationPolicy(bool $enabled, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', $this->buildHeaders($authenticated), [ + 'enabled' => $enabled, + ]); + } + + protected function updateSessionLimitPolicy(?int $total, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', $this->buildHeaders($authenticated), [ + 'total' => $total, + ]); + } + + protected function updateUserLimitPolicy(?int $total, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $this->buildHeaders($authenticated), [ + 'total' => $total, + ]); + } + + /** + * @param array $params + */ + protected function updateMembershipPrivacyPolicy(array $params, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $this->buildHeaders($authenticated), $params); + } +} diff --git a/tests/e2e/Services/Project/PoliciesConsoleClientTest.php b/tests/e2e/Services/Project/PoliciesConsoleClientTest.php new file mode 100644 index 0000000000..2db8e57a35 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesConsoleClientTest.php @@ -0,0 +1,14 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + // Step 1: Configure privacy to false + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $serverHeaders, [ + 'userId' => false, + 'userEmail' => false, + 'userPhone' => false, + 'userName' => false, + 'userMFA' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertFalse($response['body']['authMembershipsUserId']); + $this->assertFalse($response['body']['authMembershipsUserEmail']); + $this->assertFalse($response['body']['authMembershipsUserPhone']); + $this->assertFalse($response['body']['authMembershipsUserName']); + $this->assertFalse($response['body']['authMembershipsMfa']); + + // Step 2: Setup two users + $user1Email = 'user1_' . uniqid() . '@localhost.test'; + $user1Name = 'Alice Anderson'; + $user1Phone = '+12025550101'; + $password = 'password1234'; + + $user1 = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $user1Email, + 'password' => $password, + 'name' => $user1Name, + ]); + $this->assertSame(201, $user1['headers']['status-code']); + $user1Id = $user1['body']['$id']; + + $response = $this->client->call(Client::METHOD_PATCH, '/users/' . $user1Id . '/phone', $serverHeaders, [ + 'number' => $user1Phone, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + $user2Email = 'user2_' . uniqid() . '@localhost.test'; + $user2Name = 'Bob Baker'; + $user2Phone = '+12025550102'; + + $user2 = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $user2Email, + 'password' => $password, + 'name' => $user2Name, + ]); + $this->assertSame(201, $user2['headers']['status-code']); + $user2Id = $user2['body']['$id']; + + $response = $this->client->call(Client::METHOD_PATCH, '/users/' . $user2Id . '/phone', $serverHeaders, [ + 'number' => $user2Phone, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + // Step 3: Create team and add both users as members + $team = $this->client->call(Client::METHOD_POST, '/teams', $serverHeaders, [ + 'teamId' => ID::unique(), + 'name' => 'Privacy Team', + 'roles' => ['member'], + ]); + $this->assertSame(201, $team['headers']['status-code']); + $teamId = $team['body']['$id']; + + $membership1 = $this->client->call(Client::METHOD_POST, '/teams/' . $teamId . '/memberships', $serverHeaders, [ + 'userId' => $user1Id, + 'roles' => ['member'], + ]); + $this->assertSame(201, $membership1['headers']['status-code']); + $this->assertTrue($membership1['body']['confirm']); + + $membership2 = $this->client->call(Client::METHOD_POST, '/teams/' . $teamId . '/memberships', $serverHeaders, [ + 'userId' => $user2Id, + 'roles' => ['member'], + ]); + $this->assertSame(201, $membership2['headers']['status-code']); + $this->assertTrue($membership2['body']['confirm']); + + // Step 4: Sign in as user1 and list memberships with privacy disabled + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $user1Email, + 'password' => $password, + ]); + $this->assertSame(201, $session['headers']['status-code']); + $user1Session = $session['cookies']['a_session_' . $projectId]; + + $clientHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $user1Session, + ]; + + $response = $this->client->call(Client::METHOD_GET, '/teams/' . $teamId . '/memberships', $clientHeaders); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(2, $response['body']['total']); + $this->assertCount(2, $response['body']['memberships']); + + foreach ($response['body']['memberships'] as $membership) { + $this->assertSame('', $membership['userName']); + $this->assertSame('', $membership['userEmail']); + $this->assertSame('', $membership['userPhone']); + $this->assertSame('', $membership['userId']); + $this->assertFalse($membership['mfa']); + } + + // Step 5: Update privacy to true + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $serverHeaders, [ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertTrue($response['body']['authMembershipsUserId']); + $this->assertTrue($response['body']['authMembershipsUserEmail']); + $this->assertTrue($response['body']['authMembershipsUserPhone']); + $this->assertTrue($response['body']['authMembershipsUserName']); + $this->assertTrue($response['body']['authMembershipsMfa']); + + // Step 6: List memberships with privacy enabled - user details exposed + $response = $this->client->call(Client::METHOD_GET, '/teams/' . $teamId . '/memberships', $clientHeaders); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(2, $response['body']['total']); + $this->assertCount(2, $response['body']['memberships']); + + $membershipsByUser = []; + foreach ($response['body']['memberships'] as $membership) { + $membershipsByUser[$membership['userId']] = $membership; + } + + $this->assertArrayHasKey($user1Id, $membershipsByUser); + $this->assertSame($user1Id, $membershipsByUser[$user1Id]['userId']); + $this->assertSame($user1Name, $membershipsByUser[$user1Id]['userName']); + $this->assertSame($user1Email, $membershipsByUser[$user1Id]['userEmail']); + $this->assertSame($user1Phone, $membershipsByUser[$user1Id]['userPhone']); + $this->assertFalse($membershipsByUser[$user1Id]['mfa']); + + $this->assertArrayHasKey($user2Id, $membershipsByUser); + $this->assertSame($user2Id, $membershipsByUser[$user2Id]['userId']); + $this->assertSame($user2Name, $membershipsByUser[$user2Id]['userName']); + $this->assertSame($user2Email, $membershipsByUser[$user2Id]['userEmail']); + $this->assertSame($user2Phone, $membershipsByUser[$user2Id]['userPhone']); + $this->assertFalse($membershipsByUser[$user2Id]['mfa']); + } +} diff --git a/tests/e2e/Services/Project/PoliciesPasswordDictionaryIntegrationTest.php b/tests/e2e/Services/Project/PoliciesPasswordDictionaryIntegrationTest.php new file mode 100644 index 0000000000..2d0e15a70f --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesPasswordDictionaryIntegrationTest.php @@ -0,0 +1,68 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + // "password" is the top entry in the common-passwords dictionary and is 8 chars (min length). + $commonPassword = 'football'; + + // Step 1: Disable password dictionary policy + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $serverHeaders, [ + 'enabled' => false, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertFalse($response['body']['authPasswordDictionary']); + + // Step 2: Create user with common password - should succeed + $user1 = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => 'dict_off_' . uniqid() . '@localhost.test', + 'password' => $commonPassword, + 'name' => 'Dictionary Off User', + ]); + $this->assertSame(201, $user1['headers']['status-code']); + $this->assertNotEmpty($user1['body']['$id']); + + // Step 3: Enable password dictionary policy + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $serverHeaders, [ + 'enabled' => true, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertTrue($response['body']['authPasswordDictionary']); + + // Step 4: Creating another user with the common password must fail + $user2 = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => 'dict_on_' . uniqid() . '@localhost.test', + 'password' => $commonPassword, + 'name' => 'Dictionary On User', + ]); + $this->assertSame(400, $user2['headers']['status-code']); + + // Cleanup: disable policy + $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $serverHeaders, [ + 'enabled' => false, + ]); + } +} diff --git a/tests/e2e/Services/Project/PoliciesPasswordHistoryIntegrationTest.php b/tests/e2e/Services/Project/PoliciesPasswordHistoryIntegrationTest.php new file mode 100644 index 0000000000..c2dfd7be5e --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesPasswordHistoryIntegrationTest.php @@ -0,0 +1,152 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + // Step 1: Enable password history policy with limit 3 + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $serverHeaders, [ + 'total' => 3, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(3, $response['body']['authPasswordHistory']); + + $firstPassword = 'firstpassword'; + $secondPassword = 'secondpassword'; + $thirdPassword = 'thirdpassword'; + $fourthPassword = 'fourthpassword'; + + // Step 2: Sign up user with firstpassword (policy on, so signup populates history) + $email = 'history_' . uniqid() . '@localhost.test'; + $userId = ID::unique(); + + $account = $this->client->call(Client::METHOD_POST, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'userId' => $userId, + 'email' => $email, + 'password' => $firstPassword, + 'name' => 'History User', + ]); + $this->assertSame(201, $account['headers']['status-code']); + + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $email, + 'password' => $firstPassword, + ]); + $this->assertSame(201, $session['headers']['status-code']); + $sessionCookie = $session['cookies']['a_session_' . $projectId]; + + $clientHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $sessionCookie, + ]; + + // Change password: first -> second + $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [ + 'password' => $secondPassword, + 'oldPassword' => $firstPassword, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + // Change password: second -> third + $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [ + 'password' => $thirdPassword, + 'oldPassword' => $secondPassword, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + // Step 3: Attempt to reuse each of the 3 previous passwords - all should fail + foreach ([$firstPassword, $secondPassword, $thirdPassword] as $reused) { + $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [ + 'password' => $reused, + 'oldPassword' => $thirdPassword, + ]); + $this->assertSame(400, $response['headers']['status-code'], 'Reusing password "' . $reused . '" should be blocked by history policy'); + $this->assertSame('password_recently_used', $response['body']['type']); + } + + // Step 4: Setting fourthpassword succeeds + $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [ + 'password' => $fourthPassword, + 'oldPassword' => $thirdPassword, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + // Verify the new password works by signing in again + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $email, + 'password' => $fourthPassword, + ]); + $this->assertSame(201, $session['headers']['status-code']); + + // Step 5: Disable password history policy + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $serverHeaders, [ + 'total' => null, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['authPasswordHistory']); + + // Step 6: With policy off, reusing any previous password should succeed, as should setting a brand new one. + // oldPassword must match current password, so walk through each previous password sequentially. + $fifthPassword = 'fifthpassword'; + $chain = [ + [$fourthPassword, $firstPassword], + [$firstPassword, $secondPassword], + [$secondPassword, $thirdPassword], + [$thirdPassword, $fourthPassword], + [$fourthPassword, $fifthPassword], + ]; + + foreach ($chain as [$current, $next]) { + $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [ + 'password' => $next, + 'oldPassword' => $current, + ]); + $this->assertSame(200, $response['headers']['status-code'], 'Changing password from "' . $current . '" to "' . $next . '" should succeed with history policy disabled'); + } + + // Verify the final password works by signing in + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $email, + 'password' => $fifthPassword, + ]); + $this->assertSame(201, $session['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Project/PoliciesPasswordPersonalDataIntegrationTest.php b/tests/e2e/Services/Project/PoliciesPasswordPersonalDataIntegrationTest.php new file mode 100644 index 0000000000..3284fed16f --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesPasswordPersonalDataIntegrationTest.php @@ -0,0 +1,104 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $setPersonalData = function (bool $enabled) use ($serverHeaders): void { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', $serverHeaders, [ + 'enabled' => $enabled, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($enabled, $response['body']['authPersonalDataCheck']); + }; + + $buildCases = function (): array { + $suffix = \uniqid(); + $userId = 'personaluser' . $suffix; + $emailLocal = 'personalmail' . $suffix; + $email = $emailLocal . '@localhost.test'; + $name = 'Personalname' . $suffix; + $phone = '+12025550' . \str_pad((string) \rand(100, 999), 3, '0', STR_PAD_LEFT); + + return [ + 'userId' => [ + 'userId' => $userId, + 'email' => 'safe_' . $suffix . '@localhost.test', + 'phone' => '+12025559' . \str_pad((string) \rand(100, 999), 3, '0', STR_PAD_LEFT), + 'name' => 'Safe Name', + 'password' => $userId . 'extra', + ], + 'email' => [ + 'userId' => 'safeid' . $suffix, + 'email' => $email, + 'phone' => '+12025558' . \str_pad((string) \rand(100, 999), 3, '0', STR_PAD_LEFT), + 'name' => 'Safe Name', + 'password' => 'prefix_' . $emailLocal . '_suffix', + ], + 'name' => [ + 'userId' => 'safeid2' . $suffix, + 'email' => 'safename_' . $suffix . '@localhost.test', + 'phone' => '+12025557' . \str_pad((string) \rand(100, 999), 3, '0', STR_PAD_LEFT), + 'name' => $name, + 'password' => 'prefix' . $name . 'xyz', + ], + 'phone' => [ + 'userId' => 'safeid3' . $suffix, + 'email' => 'safephone_' . $suffix . '@localhost.test', + 'phone' => $phone, + 'name' => 'Safe Name', + 'password' => 'prefix' . \str_replace('+', '', $phone) . 'xyz', + ], + ]; + }; + + $createUser = function (array $params) use ($serverHeaders): array { + return $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => $params['userId'], + 'email' => $params['email'], + 'phone' => $params['phone'], + 'password' => $params['password'], + 'name' => $params['name'], + ]); + }; + + // Step 1: Enable password personal data policy + $setPersonalData(true); + + // Step 2: Each of the four personal-data fields in the password must block user creation + foreach ($buildCases() as $field => $params) { + $response = $createUser($params); + $this->assertSame(400, $response['headers']['status-code'], 'Password containing ' . $field . ' should be rejected'); + $this->assertSame('password_personal_data', $response['body']['type']); + } + + // Step 3: Disable password personal data policy + $setPersonalData(false); + + // Step 4: The same categories of passwords should now be accepted (fresh data to avoid uniqueness conflicts) + foreach ($buildCases() as $field => $params) { + $response = $createUser($params); + $this->assertSame(201, $response['headers']['status-code'], 'Password containing ' . $field . ' should be accepted with policy disabled'); + $this->assertSame($params['userId'], $response['body']['$id']); + } + } +} diff --git a/tests/e2e/Services/Project/PoliciesSessionAlertIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionAlertIntegrationTest.php new file mode 100644 index 0000000000..1500a1dcfa --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesSessionAlertIntegrationTest.php @@ -0,0 +1,121 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + $password = 'password1234'; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $publicHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + $setSessionAlert = function (bool $enabled) use ($serverHeaders): void { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', $serverHeaders, [ + 'enabled' => $enabled, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($enabled, $response['body']['authSessionAlerts']); + }; + + $createUser = function (string $email) use ($serverHeaders, $password): void { + $response = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => 'Alert User', + ]); + $this->assertSame(201, $response['headers']['status-code']); + }; + + $createSession = function (string $email) use ($publicHeaders, $password): void { + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $response['headers']['status-code']); + }; + + $countEmailsTo = function (string $address): int { + $emails = \json_decode(\file_get_contents('http://maildev:1080/email'), true) ?? []; + $count = 0; + foreach ($emails as $email) { + foreach ($email['to'] ?? [] as $recipient) { + if (($recipient['address'] ?? '') === $address) { + $count++; + } + } + } + return $count; + }; + + $assertEmailCountStays = function (string $address, int $expected, int $seconds) use ($countEmailsTo): void { + $deadline = \microtime(true) + $seconds; + while (\microtime(true) < $deadline) { + $this->assertSame($expected, $countEmailsTo($address), 'Unexpected email count for ' . $address); + \usleep(500_000); + } + }; + + // Step 1: Disable session alerts + $setSessionAlert(false); + + // Step 2: Create user1 and two sessions + $user1Email = 'alert1_' . uniqid() . '@localhost.test'; + $createUser($user1Email); + $createSession($user1Email); + $createSession($user1Email); + + // Step 3: No alert should arrive in the next 10 seconds + $assertEmailCountStays($user1Email, 0, 10); + + // Step 4: Enable session alerts + $setSessionAlert(true); + + // Step 5: Create user2 and one session + $user2Email = 'alert2_' . uniqid() . '@localhost.test'; + $createUser($user2Email); + $createSession($user2Email); + + // Step 6: First session never alerts, so nothing arrives in 10 seconds + $assertEmailCountStays($user2Email, 0, 10); + + // Step 7: Create the second session for user2 + $createSession($user2Email); + + // Step 8: Session alert email should eventually arrive + $this->assertEventually(function () use ($countEmailsTo, $user2Email) { + $this->assertSame(1, $countEmailsTo($user2Email)); + }, 15_000, 500); + + // Step 9: Disable session alerts + $setSessionAlert(false); + + // Step 10: Create the third session for user2 + $createSession($user2Email); + + // Step 11: No additional alert email should arrive in 10 seconds + $assertEmailCountStays($user2Email, 1, 10); + } +} diff --git a/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php new file mode 100644 index 0000000000..71562f52a5 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php @@ -0,0 +1,92 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $publicHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + $setDuration = function (int $seconds) use ($serverHeaders): void { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', $serverHeaders, [ + 'duration' => $seconds, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($seconds, $response['body']['authDuration']); + }; + + // Step 1: Set session duration to 5 seconds + $setDuration(5); + + // Step 2: Create user and a session + $email = 'duration_' . uniqid() . '@localhost.test'; + $password = 'password1234'; + + $user = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => 'Duration User', + ]); + $this->assertSame(201, $user['headers']['status-code']); + + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $session['headers']['status-code']); + $sessionCookie = $session['cookies']['a_session_' . $projectId]; + + $accountHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $sessionCookie, + ]; + + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(200, $response['headers']['status-code']); + + // Step 3: Poll until the 5s TTL elapses - session should expire + $this->assertEventually(function () use ($accountHeaders) { + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(401, $response['headers']['status-code']); + }, 15_000, 500); + + // Step 4: Raise duration to 10s - same session should still not be usable + $setDuration(10); + + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(401, $response['headers']['status-code']); + + // Step 5: Set duration to 1 year + $setDuration(31536000); + + // Step 6: Same session should still not be usable + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(401, $response['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Project/PoliciesSessionInvalidationIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionInvalidationIntegrationTest.php new file mode 100644 index 0000000000..c9de2be9a5 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesSessionInvalidationIntegrationTest.php @@ -0,0 +1,119 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $publicHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + $setInvalidation = function (bool $enabled) use ($serverHeaders): void { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', $serverHeaders, [ + 'enabled' => $enabled, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($enabled, $response['body']['authInvalidateSessions']); + }; + + $accountHeaders = function (string $sessionCookie) use ($projectId): array { + return [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $sessionCookie, + ]; + }; + + $getAccount = function (string $sessionCookie) use ($accountHeaders): array { + return $this->client->call(Client::METHOD_GET, '/account', $accountHeaders($sessionCookie)); + }; + + // Step 1: Disable session invalidation + $setInvalidation(false); + + // Step 2: Create user and two sessions + $email = 'invalidation_' . uniqid() . '@localhost.test'; + $firstPassword = 'firstpassword'; + + $user = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $firstPassword, + 'name' => 'Invalidation User', + ]); + $this->assertSame(201, $user['headers']['status-code']); + $userId = $user['body']['$id']; + + $login = function (string $password) use ($publicHeaders, $email, $projectId): string { + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $response['headers']['status-code']); + return $response['cookies']['a_session_' . $projectId]; + }; + + $session1 = $login($firstPassword); + $session2 = $login($firstPassword); + + $this->assertSame(200, $getAccount($session1)['headers']['status-code']); + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); + + // Step 3: Change password while invalidation is disabled - both sessions survive + $secondPassword = 'secondpassword'; + $response = $this->client->call(Client::METHOD_PATCH, '/users/' . $userId . '/password', $serverHeaders, [ + 'password' => $secondPassword, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + $this->assertEventually(function () use ($getAccount, $session1, $session2) { + $this->assertSame(200, $getAccount($session1)['headers']['status-code']); + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); + }, 15_000, 500); + + // Step 4: Enable session invalidation + $setInvalidation(true); + + // Step 5: Change password - both sessions should be invalidated + $thirdPassword = 'thirdpassword'; + $response = $this->client->call(Client::METHOD_PATCH, '/users/' . $userId . '/password', $serverHeaders, [ + 'password' => $thirdPassword, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + $this->assertEventually(function () use ($getAccount, $session1, $session2) { + $this->assertSame(401, $getAccount($session1)['headers']['status-code']); + $this->assertSame(401, $getAccount($session2)['headers']['status-code']); + }, 15_000, 500); + + // Step 6: Disable session invalidation again + $setInvalidation(false); + + // Step 7: Previously-invalidated sessions stay dead + $this->assertSame(401, $getAccount($session1)['headers']['status-code']); + $this->assertSame(401, $getAccount($session2)['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php new file mode 100644 index 0000000000..295418a974 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php @@ -0,0 +1,122 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $publicHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + $email = 'session_' . uniqid() . '@localhost.test'; + $password = 'password1234'; + + // Create user (via API key so signup rules don't interfere) + $response = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => 'Session User', + ]); + $this->assertSame(201, $response['headers']['status-code']); + + $login = function () use ($publicHeaders, $email, $password): string { + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $response['headers']['status-code']); + return $response['cookies']['a_session_' . $this->getProject()['$id']]; + }; + + $accountHeaders = function (string $sessionCookie) use ($projectId): array { + return [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $sessionCookie, + ]; + }; + + $getAccount = function (string $sessionCookie) use ($accountHeaders): array { + return $this->client->call(Client::METHOD_GET, '/account', $accountHeaders($sessionCookie)); + }; + + $setSessionLimit = function (?int $total) use ($serverHeaders): void { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', $serverHeaders, [ + 'total' => $total, + ]); + $this->assertSame(200, $response['headers']['status-code']); + }; + + // Step 1: Session limit = 1 + $setSessionLimit(1); + + $session1 = $login(); + $this->assertEventually(function () use ($getAccount, $session1) { + $response = $getAccount($session1); + $this->assertSame(200, $response['headers']['status-code']); + }, 15_000, 500); + + // New session pushes old one out + $session2 = $login(); + + \sleep(3); // Giving ::shutdown() hooks some time + + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); + $this->assertSame(401, $getAccount($session1)['headers']['status-code']); + + // Step 2: Session limit = 2 + $setSessionLimit(2); + + $session3 = $login(); + + \sleep(3); // Giving ::shutdown() hooks some time + + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); + $this->assertSame(200, $getAccount($session3)['headers']['status-code']); + + // Step 3: 4th session evicts session2 (oldest), session3 and session4 remain + $session4 = $login(); + + \sleep(3); // Giving ::shutdown() hooks some time + + $this->assertSame(200, $getAccount($session4)['headers']['status-code']); + $this->assertSame(200, $getAccount($session3)['headers']['status-code']); + $this->assertSame(401, $getAccount($session2)['headers']['status-code']); + + // Step 4: Disable session limit, create 5 new sessions, all should remain usable + $setSessionLimit(null); + + $newSessions = []; + for ($i = 0; $i < 5; $i++) { + $newSessions[] = $login(); + } + + foreach ($newSessions as $index => $sessionCookie) { + $this->assertSame(200, $getAccount($sessionCookie)['headers']['status-code'], 'Session #' . ($index + 1) . ' should remain valid when limit is disabled'); + } + } +} diff --git a/tests/e2e/Services/Project/PoliciesUserLimitIntegrationTest.php b/tests/e2e/Services/Project/PoliciesUserLimitIntegrationTest.php new file mode 100644 index 0000000000..5ddcd8aaa1 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesUserLimitIntegrationTest.php @@ -0,0 +1,87 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $signupHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + $signup = function () use ($signupHeaders): array { + return $this->client->call(Client::METHOD_POST, '/account', $signupHeaders, [ + 'userId' => ID::unique(), + 'email' => 'limit_' . uniqid() . '@localhost.test', + 'password' => 'password1234', + 'name' => 'Limit User', + ]); + }; + + // Step 1: Set user limit to 3 + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $serverHeaders, [ + 'total' => 3, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(3, $response['body']['authLimit']); + + // Create 3 users - all should succeed + for ($i = 1; $i <= 3; $i++) { + $response = $signup(); + $this->assertSame(201, $response['headers']['status-code'], 'User ' . $i . ' should be created under limit of 3'); + } + + // User 4 should be blocked + $response = $signup(); + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('user_count_exceeded', $response['body']['type']); + + // Step 2: Raise user limit to 4 + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $serverHeaders, [ + 'total' => 4, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(4, $response['body']['authLimit']); + + // User 4 now succeeds + $response = $signup(); + $this->assertSame(201, $response['headers']['status-code']); + + // User 5 should be blocked + $response = $signup(); + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('user_count_exceeded', $response['body']['type']); + + // Step 3: Remove user limit (null -> stored as 0 -> unlimited) + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $serverHeaders, [ + 'total' => null, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['authLimit']); + + // User 5 now succeeds + $response = $signup(); + $this->assertSame(201, $response['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Project/ProjectBase.php b/tests/e2e/Services/Project/ProjectBase.php new file mode 100644 index 0000000000..fa4d2ca7fa --- /dev/null +++ b/tests/e2e/Services/Project/ProjectBase.php @@ -0,0 +1,7 @@ +createTeam('Delete Project Team'); + $project = $this->createProject($team['body']['$id'], 'Delete Project'); + + $response = $this->client->call(Client::METHOD_DELETE, '/project', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project['body']['$id'], + ], $this->getHeaders())); + + $this->assertSame(204, $response['headers']['status-code']); + + $getProject = $this->getConsoleProject($project['body']['$id']); + + $this->assertSame(404, $getProject['headers']['status-code']); + } + + public function testDeleteProjectUsingKey(): void + { + $team = $this->createTeam('Delete Project Key Team'); + $project = $this->createProject($team['body']['$id'], 'Delete Project Using Key'); + $apiKey = $this->createProjectKey($project['body']['$id'], ['project.write']); + + $response = $this->client->call(Client::METHOD_DELETE, '/project', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project['body']['$id'], + 'x-appwrite-key' => $apiKey, + ]); + + $this->assertSame(204, $response['headers']['status-code']); + + $getProject = $this->getConsoleProject($project['body']['$id']); + + $this->assertSame(404, $getProject['headers']['status-code']); + } + + protected function createTeam(string $name): array + { + $response = $this->client->call(Client::METHOD_POST, '/teams', $this->getConsoleSessionHeaders(), [ + 'teamId' => ID::unique(), + 'name' => $name, + ]); + + $this->assertSame(201, $response['headers']['status-code']); + $this->assertSame($name, $response['body']['name']); + $this->assertNotEmpty($response['body']['$id']); + + return $response; + } + + protected function createProject(string $teamId, string $name): array + { + $response = $this->client->call(Client::METHOD_POST, '/projects', $this->getConsoleSessionHeaders(), [ + 'projectId' => ID::unique(), + 'region' => System::getEnv('_APP_REGION', 'default'), + 'name' => $name, + 'teamId' => $teamId, + ]); + + $this->assertSame(201, $response['headers']['status-code']); + $this->assertSame($name, $response['body']['name']); + $this->assertNotEmpty($response['body']['$id']); + + return $response; + } + + protected function createProjectKey(string $projectId, array $scopes): string + { + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $projectId . '/keys', $this->getConsoleSessionHeaders(), [ + 'keyId' => ID::unique(), + 'name' => 'Delete Project Key', + 'scopes' => $scopes, + ]); + + $this->assertSame(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['secret']); + + return $response['body']['secret']; + } + + protected function getConsoleProject(string $projectId): array + { + return $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, $this->getConsoleSessionHeaders()); + } + + protected function getConsoleSessionHeaders(): array + { + return [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-project' => 'console', + ]; + } +} diff --git a/tests/e2e/Services/Project/ProjectCustomServerTest.php b/tests/e2e/Services/Project/ProjectCustomServerTest.php new file mode 100644 index 0000000000..a719d4b372 --- /dev/null +++ b/tests/e2e/Services/Project/ProjectCustomServerTest.php @@ -0,0 +1,21 @@ +expectNotToPerformAssertions(); + } +} diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php new file mode 100644 index 0000000000..748fb3502b --- /dev/null +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -0,0 +1,1153 @@ +updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: 'user', + password: 'password', + enabled: false, + ); + } + + // Update SMTP status tests + + public function testUpdateSMTPStatusEnable(): void + { + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['smtpEnabled']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPStatusDisable(): void + { + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); + + $response = $this->updateSMTP(enabled: false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(false, $response['body']['smtpEnabled']); + } + + public function testUpdateSMTPStatusEnableIdempotent(): void + { + $first = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(true, $first['body']['smtpEnabled']); + + $second = $this->updateSMTP(enabled: true); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(true, $second['body']['smtpEnabled']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPStatusDisableIdempotent(): void + { + $first = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(false, $first['body']['smtpEnabled']); + + $second = $this->updateSMTP(enabled: false); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(false, $second['body']['smtpEnabled']); + } + + public function testUpdateSMTPStatusResponseModel(): void + { + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: 'user', + password: 'password', + enabled: true, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('name', $response['body']); + $this->assertArrayHasKey('smtpEnabled', $response['body']); + $this->assertArrayHasKey('smtpSenderName', $response['body']); + $this->assertArrayHasKey('smtpSenderEmail', $response['body']); + $this->assertArrayHasKey('smtpReplyToEmail', $response['body']); + $this->assertArrayHasKey('smtpReplyToName', $response['body']); + $this->assertArrayHasKey('smtpHost', $response['body']); + $this->assertArrayHasKey('smtpPort', $response['body']); + $this->assertArrayHasKey('smtpUsername', $response['body']); + $this->assertArrayHasKey('smtpPassword', $response['body']); + // smtpPassword is write-only: the stored password must never leak in responses + $this->assertSame('', $response['body']['smtpPassword']); + $this->assertArrayHasKey('smtpSecure', $response['body']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPStatusWithoutAuthentication(): void + { + $response = $this->updateSMTP(enabled: true, authenticated: false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // Update SMTP tests + + public function testUpdateSMTPCredentials(): void + { + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['smtpEnabled']); + $this->assertSame('Test Sender', $response['body']['smtpSenderName']); + $this->assertSame('sender@example.com', $response['body']['smtpSenderEmail']); + $this->assertSame('maildev', $response['body']['smtpHost']); + $this->assertSame(1025, $response['body']['smtpPort']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPWithOptionalReplyTo(): void + { + $response = $this->updateSMTP( + senderName: 'Full Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + replyToEmail: 'reply@example.com', + replyToName: 'Full Reply', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + $this->assertSame('Full Sender', $response['body']['smtpSenderName']); + $this->assertSame('sender@example.com', $response['body']['smtpSenderEmail']); + $this->assertSame('reply@example.com', $response['body']['smtpReplyToEmail']); + $this->assertSame('Full Reply', $response['body']['smtpReplyToName']); + $this->assertSame('maildev', $response['body']['smtpHost']); + $this->assertSame(1025, $response['body']['smtpPort']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPOverwritesPreviousSettings(): void + { + $this->updateSMTP( + senderName: 'First Sender', + senderEmail: 'first@example.com', + host: 'maildev', + port: 1025, + ); + + $response = $this->updateSMTP( + senderName: 'Second Sender', + senderEmail: 'second@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('Second Sender', $response['body']['smtpSenderName']); + $this->assertSame('second@example.com', $response['body']['smtpSenderEmail']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPEnablesSMTP(): void + { + // Ensure SMTP is disabled + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPResponseModel(): void + { + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: 'user', + password: 'password', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('name', $response['body']); + $this->assertArrayHasKey('smtpEnabled', $response['body']); + $this->assertArrayHasKey('smtpSenderName', $response['body']); + $this->assertArrayHasKey('smtpSenderEmail', $response['body']); + $this->assertArrayHasKey('smtpReplyToEmail', $response['body']); + $this->assertArrayHasKey('smtpReplyToName', $response['body']); + $this->assertArrayHasKey('smtpHost', $response['body']); + $this->assertArrayHasKey('smtpPort', $response['body']); + $this->assertArrayHasKey('smtpUsername', $response['body']); + $this->assertArrayHasKey('smtpPassword', $response['body']); + // smtpPassword is write-only: the stored password must never leak in responses + $this->assertSame('', $response['body']['smtpPassword']); + $this->assertArrayHasKey('smtpSecure', $response['body']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPWithoutAuthentication(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + authenticated: false, + ); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdateSMTPInvalidSenderEmail(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'not-an-email', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPEmptySenderName(): void + { + $response = $this->updateSMTP( + senderName: '', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPEmptySenderEmail(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: '', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPEmptyHost(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: '', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPInvalidHost(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'https://myhost.com/v1', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPInvalidReplyToEmail(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + replyToEmail: 'not-an-email', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPInvalidSecure(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + secure: 'invalid', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPSenderNameMinLength(): void + { + $response = $this->updateSMTP( + senderName: 'A', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('A', $response['body']['smtpSenderName']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPSenderNameMaxLength(): void + { + $name = str_repeat('a', 256); + $response = $this->updateSMTP( + senderName: $name, + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($name, $response['body']['smtpSenderName']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPSenderNameTooLong(): void + { + $response = $this->updateSMTP( + senderName: str_repeat('a', 257), + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPUsernameMinLength(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: 'u', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('u', $response['body']['smtpUsername']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPUsernameMaxLength(): void + { + $username = str_repeat('a', 256); + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: $username, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($username, $response['body']['smtpUsername']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPUsernameTooLong(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: str_repeat('a', 257), + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPUsernameEmpty(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: '', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPPasswordMinLength(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + password: 'p', + ); + + $this->assertSame(200, $response['headers']['status-code']); + // smtpPassword is write-only: the accepted password must not be echoed back + $this->assertSame('', $response['body']['smtpPassword']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPPasswordMaxLength(): void + { + $password = str_repeat('a', 256); + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + password: $password, + ); + + $this->assertSame(200, $response['headers']['status-code']); + // smtpPassword is write-only: the accepted password must not be echoed back + $this->assertSame('', $response['body']['smtpPassword']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPPasswordTooLong(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + password: str_repeat('a', 257), + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPPasswordEmpty(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + password: '', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPWithoutSecure(): void + { + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['smtpSecure']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPInvalidConnectionEnabled(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'localhost', + port: 12345, + enabled: true, + ); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('project_smtp_config_invalid', $response['body']['type']); + } + + public function testUpdateSMTPInvalidConnectionDisabled(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'localhost', + port: 12345, + enabled: false, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['smtpEnabled']); + $this->assertSame('Test', $response['body']['smtpSenderName']); + $this->assertSame('sender@example.com', $response['body']['smtpSenderEmail']); + $this->assertSame('localhost', $response['body']['smtpHost']); + $this->assertSame(12345, $response['body']['smtpPort']); + } + + public function testUpdateSMTPLegacyReplyToAndResponseFormat(): void + { + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + // Legacy client sends `replyTo` (not `replyToEmail`). Request filter maps it. + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/smtp', + $headers, + [ + 'enabled' => true, + 'senderName' => 'Legacy Sender', + 'senderEmail' => 'legacy-sender@example.com', + 'host' => 'maildev', + 'port' => 1025, + 'replyTo' => 'legacy-reply@example.com', + ], + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + $this->assertSame('Legacy Sender', $response['body']['smtpSenderName']); + $this->assertSame('legacy-sender@example.com', $response['body']['smtpSenderEmail']); + + // Response filter must expose smtpReplyTo and strip smtpReplyToEmail / smtpReplyToName. + $this->assertArrayHasKey('smtpReplyTo', $response['body']); + $this->assertArrayNotHasKey('smtpReplyToEmail', $response['body']); + $this->assertArrayNotHasKey('smtpReplyToName', $response['body']); + $this->assertSame('legacy-reply@example.com', $response['body']['smtpReplyTo']); + + // Sanity-check: a modern (non-legacy) read sees the new field names. + $modern = $this->updateSMTP(enabled: true); + $this->assertArrayHasKey('smtpReplyToEmail', $modern['body']); + $this->assertSame('legacy-reply@example.com', $modern['body']['smtpReplyToEmail']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testCreateSMTPTestLegacyInlineParams(): void + { + // Seed the project with a distinct SMTP config so we can prove the + // inline (1.9.1-style) params take precedence over project config. + $this->updateSMTP( + senderName: 'Project Sender', + senderEmail: 'project-sender@example.com', + host: 'maildev', + port: 1025, + replyToEmail: 'project-reply@example.com', + replyToName: 'Project Reply', + enabled: false, + ); + + $recipient = 'legacy-smtp-' . \uniqid() . '@appwrite.io'; + + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + $response = $this->client->call( + Client::METHOD_POST, + '/project/smtp/tests', + $headers, + [ + 'emails' => [$recipient], + 'senderName' => 'Inline Legacy Sender', + 'senderEmail' => 'inline-legacy@appwrite.io', + 'replyTo' => 'inline-legacy-reply@appwrite.io', + 'host' => 'maildev', + 'port' => 1025, + 'username' => 'user', + 'password' => 'password', + ], + ); + + $this->assertSame(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); + + // Verify the email was sent using the inline params (not project SMTP). + $email = $this->getLastEmailByAddress($recipient, function ($email) { + $this->assertSame('Custom SMTP email sample', $email['subject']); + }); + + $this->assertSame('inline-legacy@appwrite.io', $email['from'][0]['address']); + $this->assertSame('Inline Legacy Sender', $email['from'][0]['name']); + $this->assertSame('inline-legacy-reply@appwrite.io', $email['replyTo'][0]['address']); + $this->assertSame('Inline Legacy Sender', $email['replyTo'][0]['name']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPBackwardsCompatibilityDisable(): void + { + // First enable SMTP + $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); + + // Use the deprecated enabled=false parameter to disable + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['smtpEnabled']); + } + + public function testUpdateSMTPRequiredFieldsOptionalAfterConfigured(): void + { + // Seed with a known configuration so required fields (host, port, senderEmail) are stored. + $this->updateSMTP( + senderName: 'Initial Sender', + senderEmail: 'initial@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); + + // Partial update: only update senderName, omitting host/port/senderEmail. + // Required fields should not be re-required because they are already stored. + $response = $this->updateSMTP(senderName: 'Updated Sender'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('Updated Sender', $response['body']['smtpSenderName']); + $this->assertSame('initial@example.com', $response['body']['smtpSenderEmail']); + $this->assertSame('maildev', $response['body']['smtpHost']); + $this->assertSame(1025, $response['body']['smtpPort']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPAllParamsOptionalAfterConfigured(): void + { + // Seed a configuration so all fields are stored. + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); + + // Issue a PATCH with no params at all. Once previously configured, this must succeed. + $response = $this->updateSMTP(); + + $this->assertSame(200, $response['headers']['status-code']); + // Previously-set values are preserved + $this->assertSame('Test Sender', $response['body']['smtpSenderName']); + $this->assertSame('sender@example.com', $response['body']['smtpSenderEmail']); + $this->assertSame('maildev', $response['body']['smtpHost']); + $this->assertSame(1025, $response['body']['smtpPort']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPEnabledTrueWithInvalidCredentials(): void + { + // Explicitly enabling SMTP with unreachable host/port must throw. + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'localhost', + port: 12345, + enabled: true, + ); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('project_smtp_config_invalid', $response['body']['type']); + } + + public function testUpdateSMTPEnabledFalseWithInvalidCredentials(): void + { + // enabled=false means SMTP is not in use, so invalid credentials must be accepted. + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'localhost', + port: 12345, + enabled: false, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['smtpEnabled']); + $this->assertSame('localhost', $response['body']['smtpHost']); + $this->assertSame(12345, $response['body']['smtpPort']); + + // Cleanup (restore valid disabled config) + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + } + + public function testUpdateSMTPEnabledNullWithInvalidCredentialsDoesNotThrow(): void + { + // Ensure SMTP is currently disabled so we aren't enforcing validation on an enabled config. + $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + + // With enabled omitted (null) and invalid credentials, the request must not throw. + // SMTP remains disabled because the credentials could not be validated. + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'localhost', + port: 12345, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['smtpEnabled']); + + // Cleanup (restore valid disabled config) + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + } + + public function testUpdateSMTPEnabledNullWithValidCredentialsAutoEnables(): void + { + // Start from a disabled state. + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + + // With enabled omitted (null) and valid credentials, SMTP must be auto-enabled. + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + // Create SMTP test tests + + public function testCreateSMTPTest(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $response = $this->createSMTPTest(['recipient@example.com']); + + $this->assertSame(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testCreateSMTPTestMultipleRecipients(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $response = $this->createSMTPTest([ + 'recipient1@example.com', + 'recipient2@example.com', + 'recipient3@example.com', + ]); + + $this->assertSame(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testCreateSMTPTestWhenSMTPDisabled(): void + { + // Ensure SMTP is disabled + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + + $response = $this->createSMTPTest(['recipient@example.com']); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateSMTPTestWithoutAuthentication(): void + { + $response = $this->createSMTPTest(['recipient@example.com'], false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testCreateSMTPTestEmptyEmails(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $response = $this->createSMTPTest([]); + + $this->assertSame(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testCreateSMTPTestInvalidEmail(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $response = $this->createSMTPTest(['not-an-email']); + + $this->assertSame(400, $response['headers']['status-code']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testCreateSMTPTestExceedsMaxEmails(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $emails = []; + for ($i = 1; $i <= 11; $i++) { + $emails[] = "recipient{$i}@example.com"; + } + + $response = $this->createSMTPTest($emails); + + $this->assertSame(400, $response['headers']['status-code']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testCreateSMTPTestMaxEmails(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $emails = []; + for ($i = 1; $i <= 10; $i++) { + $emails[] = "recipient{$i}@example.com"; + } + + $response = $this->createSMTPTest($emails); + + $this->assertSame(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + // Integration tests + + public function testCreateSMTPTestEmailDelivery(): void + { + $senderName = 'SMTP Test Sender'; + $senderEmail = 'smtptest@appwrite.io'; + $replyToEmail = 'smtpreply@appwrite.io'; + $replyToName = 'SMTP Reply Team'; + $recipientEmail = 'smtpdelivery-' . \uniqid() . '@appwrite.io'; + + // Configure SMTP with reply-to and auth credentials + $response = $this->updateSMTP( + senderName: $senderName, + senderEmail: $senderEmail, + host: 'maildev', + port: 1025, + replyToEmail: $replyToEmail, + replyToName: $replyToName, + username: 'user', + password: 'password', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + + // Trigger test email + $response = $this->createSMTPTest([$recipientEmail]); + + $this->assertSame(204, $response['headers']['status-code']); + + // Verify email arrived via maildev + $email = $this->getLastEmailByAddress($recipientEmail, function ($email) { + $this->assertSame('Custom SMTP email sample', $email['subject']); + }); + + $this->assertSame($senderEmail, $email['from'][0]['address']); + $this->assertSame($senderName, $email['from'][0]['name']); + $this->assertSame($replyToEmail, $email['replyTo'][0]['address']); + $this->assertSame($replyToName, $email['replyTo'][0]['name']); + $this->assertSame('Custom SMTP email sample', $email['subject']); + $this->assertStringContainsStringIgnoringCase('working correctly', $email['text']); + $this->assertStringContainsStringIgnoringCase('working correctly', $email['html']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testMagicURLLoginUsesCustomSMTP(): void + { + $senderName = 'Custom Auth Mailer'; + $senderEmail = 'authmailer@appwrite.io'; + $recipientEmail = 'magicurl-' . \uniqid() . '@appwrite.io'; + + // Configure custom SMTP with auth credentials + $response = $this->updateSMTP( + senderName: $senderName, + senderEmail: $senderEmail, + host: 'maildev', + port: 1025, + username: 'user', + password: 'password', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + + // Trigger MagicURL login as a client (no auth headers needed) + $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'userId' => ID::unique(), + 'email' => $recipientEmail, + ]); + + $this->assertSame(201, $response['headers']['status-code']); + + // Verify the email arrived with custom SMTP sender details + $email = $this->getLastEmailByAddress($recipientEmail, function ($email) { + $this->assertStringContainsString('Login', $email['subject']); + }); + + $this->assertSame($senderEmail, $email['from'][0]['address']); + $this->assertSame($senderName, $email['from'][0]['name']); + $this->assertSame($this->getProject()['name'] . ' Login', $email['subject']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + // Helpers + + protected function updateSMTP( + ?string $senderName = null, + ?string $senderEmail = null, + ?string $host = null, + ?int $port = null, + ?string $replyToEmail = null, + ?string $replyToName = null, + ?string $username = null, + ?string $password = null, + ?string $secure = null, + ?bool $enabled = null, + bool $authenticated = true, + ): mixed { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + $params = []; + + foreach (['senderName', 'senderEmail', 'host', 'port', 'replyToEmail', 'replyToName', 'username', 'password', 'secure', 'enabled'] as $key) { + if (!\is_null(${$key})) { + $params[$key] = ${$key}; + } + } + + return $this->client->call(Client::METHOD_PATCH, '/project/smtp', $headers, $params); + } + + /** + * @param array $emails + */ + protected function createSMTPTest(array $emails, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_POST, '/project/smtp/tests', $headers, [ + 'emails' => $emails, + ]); + } +} diff --git a/tests/e2e/Services/Project/SMTPConsoleClientTest.php b/tests/e2e/Services/Project/SMTPConsoleClientTest.php new file mode 100644 index 0000000000..e5962c0960 --- /dev/null +++ b/tests/e2e/Services/Project/SMTPConsoleClientTest.php @@ -0,0 +1,14 @@ +getEmailTemplate('verification', 'en'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('verification', $response['body']['templateId']); + $this->assertSame('en', $response['body']['locale']); + $this->assertNotEmpty($response['body']['subject']); + $this->assertNotEmpty($response['body']['message']); + } + + public function testGetEmailTemplateDefaultLocale(): void + { + // When locale is omitted, the fallback locale (en) is applied server-side. + $response = $this->getEmailTemplate('recovery'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('recovery', $response['body']['templateId']); + $this->assertSame('en', $response['body']['locale']); + $this->assertNotEmpty($response['body']['subject']); + $this->assertNotEmpty($response['body']['message']); + } + + public function testGetEmailTemplateAllSupportedTypes(): void + { + $types = [ + 'verification', + 'magicSession', + 'recovery', + 'invitation', + 'mfaChallenge', + 'sessionAlert', + 'otpSession', + ]; + + foreach ($types as $type) { + $response = $this->getEmailTemplate($type, 'en'); + + $this->assertSame(200, $response['headers']['status-code'], "type={$type}"); + $this->assertSame($type, $response['body']['templateId']); + $this->assertSame('en', $response['body']['locale']); + $this->assertNotEmpty($response['body']['subject'], "type={$type} must have default subject"); + $this->assertNotEmpty($response['body']['message'], "type={$type} must have default message"); + } + } + + public function testGetEmailTemplateNonDefaultLocale(): void + { + // Even a non-en locale that has no custom template must return defaults. + $response = $this->getEmailTemplate('verification', 'fr'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('verification', $response['body']['templateId']); + $this->assertSame('fr', $response['body']['locale']); + $this->assertNotEmpty($response['body']['subject']); + $this->assertNotEmpty($response['body']['message']); + } + + public function testGetEmailTemplateResponseModel(): void + { + $response = $this->getEmailTemplate('verification', 'en'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('templateId', $response['body']); + $this->assertArrayHasKey('locale', $response['body']); + $this->assertArrayHasKey('subject', $response['body']); + $this->assertArrayHasKey('message', $response['body']); + $this->assertArrayHasKey('senderName', $response['body']); + $this->assertArrayHasKey('senderEmail', $response['body']); + $this->assertArrayHasKey('replyToEmail', $response['body']); + $this->assertArrayHasKey('replyToName', $response['body']); + } + + public function testGetEmailTemplateInvalidType(): void + { + $response = $this->getEmailTemplate('notATemplate', 'en'); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testGetEmailTemplateInvalidLocale(): void + { + $response = $this->getEmailTemplate('verification', 'not-a-locale'); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testGetEmailTemplateWithoutAuthentication(): void + { + $response = $this->getEmailTemplate('verification', 'en', false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testGetEmailTemplateReturnsCustomValues(): void + { + $this->ensureSMTPEnabled(); + + $subject = 'Custom invitation subject ' . \uniqid(); + $message = 'Custom invitation body ' . \uniqid(); + + $update = $this->updateEmailTemplate( + templateId: 'invitation', + locale: 'en', + subject: $subject, + message: $message, + senderName: 'Invitation Sender', + senderEmail: 'invitation@appwrite.io', + replyToEmail: 'reply-invitation@appwrite.io', + replyToName: 'Invitation Reply', + ); + $this->assertSame(200, $update['headers']['status-code']); + + $get = $this->getEmailTemplate('invitation', 'en'); + + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('invitation', $get['body']['templateId']); + $this->assertSame('en', $get['body']['locale']); + $this->assertSame($subject, $get['body']['subject']); + $this->assertSame($message, $get['body']['message']); + $this->assertSame('Invitation Sender', $get['body']['senderName']); + $this->assertSame('invitation@appwrite.io', $get['body']['senderEmail']); + $this->assertSame('reply-invitation@appwrite.io', $get['body']['replyToEmail']); + $this->assertSame('Invitation Reply', $get['body']['replyToName']); + } + + public function testGetEmailTemplateCustomizationIsLocaleScoped(): void + { + $this->ensureSMTPEnabled(); + + $enSubject = 'EN only subject ' . \uniqid(); + $update = $this->updateEmailTemplate( + templateId: 'mfaChallenge', + locale: 'en', + subject: $enSubject, + message: 'EN only message', + ); + $this->assertSame(200, $update['headers']['status-code']); + + // Another locale must still return its defaults — not the en customization. + $other = $this->getEmailTemplate('mfaChallenge', 'de'); + $this->assertSame(200, $other['headers']['status-code']); + $this->assertSame('de', $other['body']['locale']); + $this->assertNotSame($enSubject, $other['body']['subject']); + } + + // Update email template tests + + public function testUpdateEmailTemplateRequiredFields(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Please verify your email', + message: 'Click here to verify: {{url}}', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('verification', $response['body']['templateId']); + $this->assertSame('en', $response['body']['locale']); + $this->assertSame('Please verify your email', $response['body']['subject']); + $this->assertSame('Click here to verify: {{url}}', $response['body']['message']); + } + + public function testUpdateEmailTemplateAllFields(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'recovery', + locale: 'en', + subject: 'Password reset', + message: 'Reset your password', + senderName: 'Security Team', + senderEmail: 'security@appwrite.io', + replyToEmail: 'noreply@appwrite.io', + replyToName: 'No Reply', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('Password reset', $response['body']['subject']); + $this->assertSame('Reset your password', $response['body']['message']); + $this->assertSame('Security Team', $response['body']['senderName']); + $this->assertSame('security@appwrite.io', $response['body']['senderEmail']); + $this->assertSame('noreply@appwrite.io', $response['body']['replyToEmail']); + $this->assertSame('No Reply', $response['body']['replyToName']); + } + + public function testUpdateEmailTemplateDefaultLocale(): void + { + $this->ensureSMTPEnabled(); + + // Omit locale entirely; server falls back to `en`. + $response = $this->updateEmailTemplate( + templateId: 'sessionAlert', + locale: null, + subject: 'Session alert', + message: 'Someone signed in', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('sessionAlert', $response['body']['templateId']); + $this->assertSame('en', $response['body']['locale']); + } + + public function testUpdateEmailTemplateOverwritesPrevious(): void + { + $this->ensureSMTPEnabled(); + + $first = $this->updateEmailTemplate( + templateId: 'otpSession', + locale: 'en', + subject: 'First subject', + message: 'First body', + ); + $this->assertSame(200, $first['headers']['status-code']); + + $second = $this->updateEmailTemplate( + templateId: 'otpSession', + locale: 'en', + subject: 'Second subject', + message: 'Second body', + ); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame('Second subject', $second['body']['subject']); + $this->assertSame('Second body', $second['body']['message']); + + $get = $this->getEmailTemplate('otpSession', 'en'); + $this->assertSame('Second subject', $get['body']['subject']); + $this->assertSame('Second body', $get['body']['message']); + } + + public function testUpdateEmailTemplatePartialAfterSeed(): void + { + $this->ensureSMTPEnabled(); + + // Seed a fully configured template. + $seed = $this->updateEmailTemplate( + templateId: 'magicSession', + locale: 'en', + subject: 'Magic subject', + message: 'Magic body', + senderName: 'Magic Sender', + senderEmail: 'magic@appwrite.io', + replyToEmail: 'magic-reply@appwrite.io', + replyToName: 'Magic Reply', + ); + $this->assertSame(200, $seed['headers']['status-code']); + + // Once seeded, sending just one field is fine: previous subject/message persist. + $response = $this->updateEmailTemplate( + templateId: 'magicSession', + locale: 'en', + senderName: 'Updated Sender', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('Updated Sender', $response['body']['senderName']); + $this->assertSame('Magic subject', $response['body']['subject']); + $this->assertSame('Magic body', $response['body']['message']); + $this->assertSame('magic@appwrite.io', $response['body']['senderEmail']); + $this->assertSame('magic-reply@appwrite.io', $response['body']['replyToEmail']); + $this->assertSame('Magic Reply', $response['body']['replyToName']); + } + + public function testUpdateEmailTemplateDifferentLocales(): void + { + $this->ensureSMTPEnabled(); + + $enUpdate = $this->updateEmailTemplate( + templateId: 'invitation', + locale: 'en', + subject: 'English subject', + message: 'English body', + ); + $this->assertSame(200, $enUpdate['headers']['status-code']); + $this->assertSame('en', $enUpdate['body']['locale']); + $this->assertSame('English subject', $enUpdate['body']['subject']); + + $frUpdate = $this->updateEmailTemplate( + templateId: 'invitation', + locale: 'fr', + subject: 'Sujet francais', + message: 'Corps francais', + ); + $this->assertSame(200, $frUpdate['headers']['status-code']); + $this->assertSame('fr', $frUpdate['body']['locale']); + $this->assertSame('Sujet francais', $frUpdate['body']['subject']); + + // Locales remain independent. + $enGet = $this->getEmailTemplate('invitation', 'en'); + $this->assertSame('English subject', $enGet['body']['subject']); + + $frGet = $this->getEmailTemplate('invitation', 'fr'); + $this->assertSame('Sujet francais', $frGet['body']['subject']); + } + + public function testUpdateEmailTemplateResponseModel(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Model check subject', + message: 'Model check body', + senderName: 'Sender', + senderEmail: 'sender@appwrite.io', + replyToEmail: 'reply@appwrite.io', + replyToName: 'Reply', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('templateId', $response['body']); + $this->assertArrayHasKey('locale', $response['body']); + $this->assertArrayHasKey('subject', $response['body']); + $this->assertArrayHasKey('message', $response['body']); + $this->assertArrayHasKey('senderName', $response['body']); + $this->assertArrayHasKey('senderEmail', $response['body']); + $this->assertArrayHasKey('replyToEmail', $response['body']); + $this->assertArrayHasKey('replyToName', $response['body']); + } + + public function testUpdateEmailTemplateSubjectMaxLength(): void + { + $this->ensureSMTPEnabled(); + + $subject = \str_repeat('a', 255); + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: $subject, + message: 'Body', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($subject, $response['body']['subject']); + } + + public function testUpdateEmailTemplateSubjectTooLong(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: \str_repeat('a', 256), + message: 'Body', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateSenderNameEmptyAllowed(): void + { + $this->ensureSMTPEnabled(); + + // senderName validator explicitly allows empty strings (Text(255, 0)). + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + senderName: '', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['senderName']); + } + + public function testUpdateEmailTemplateReplyToNameEmptyAllowed(): void + { + $this->ensureSMTPEnabled(); + + // replyToName validator explicitly allows empty strings (Text(255, 0)). + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + replyToName: '', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['replyToName']); + } + + public function testUpdateEmailTemplateSenderNameTooLong(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + senderName: \str_repeat('a', 256), + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateInvalidType(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'notATemplate', + locale: 'en', + subject: 'Subject', + message: 'Message', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateInvalidLocale(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'not-a-locale', + subject: 'Subject', + message: 'Message', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateMissingSubjectOnFirstWrite(): void + { + $this->ensureSMTPEnabled(); + + // 'recovery'/'de' was never customized, so there is no persisted subject + // to fall back on — the endpoint must reject the request. + $response = $this->updateEmailTemplate( + templateId: 'recovery', + locale: 'de', + subject: null, + message: 'Body only', + ); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateEmailTemplateMissingMessageOnFirstWrite(): void + { + $this->ensureSMTPEnabled(); + + // 'invitation'/'es' was never customized, so there is no persisted message + // to fall back on — the endpoint must reject the request. + $response = $this->updateEmailTemplate( + templateId: 'invitation', + locale: 'es', + subject: 'Subject only', + message: null, + ); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateEmailTemplateEmptySubject(): void + { + $this->ensureSMTPEnabled(); + + // Text(255) validator requires min length 1 — empty subject is rejected. + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: '', + message: 'Body', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateEmptyMessage(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: '', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateInvalidSenderEmail(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + senderEmail: 'not-an-email', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateInvalidReplyToEmail(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + replyToEmail: 'not-an-email', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateWithoutAuthentication(): void + { + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + authenticated: false, + ); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateBlockedWhenSMTPDisabled(): void + { + // Custom templates only make sense alongside a custom SMTP configuration. + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/smtp', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), + ['enabled' => false], + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['smtpEnabled']); + + try { + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Should be blocked', + message: 'Should be blocked', + ); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + $this->assertStringContainsStringIgnoringCase('SMTP', $response['body']['message']); + } finally { + $this->ensureSMTPEnabled(); + } + } + + // List email template tests + + public function testListEmailTemplatesReturnsSeededTemplate(): void + { + $this->ensureSMTPEnabled(); + + $subject = 'List subject ' . \uniqid(); + $seed = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: $subject, + message: 'List body', + ); + $this->assertSame(200, $seed['headers']['status-code']); + + $response = $this->listEmailTemplates(); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('templates', $response['body']); + $this->assertArrayHasKey('total', $response['body']); + $this->assertIsArray($response['body']['templates']); + $this->assertIsInt($response['body']['total']); + $this->assertGreaterThanOrEqual(1, $response['body']['total']); + + $found = null; + foreach ($response['body']['templates'] as $template) { + if ( + $template['templateId'] === 'verification' + && $template['locale'] === 'en' + && $template['subject'] === $subject + ) { + $found = $template; + break; + } + } + $this->assertNotNull($found, 'seeded verification/en template must appear in the list'); + } + + public function testListEmailTemplatesResponseModel(): void + { + $this->ensureSMTPEnabled(); + + $seed = $this->updateEmailTemplate( + templateId: 'invitation', + locale: 'en', + subject: 'Shape subject ' . \uniqid(), + message: 'Shape body', + senderName: 'Shape Sender', + senderEmail: 'shape@appwrite.io', + replyToEmail: 'shape-reply@appwrite.io', + replyToName: 'Shape Reply', + ); + $this->assertSame(200, $seed['headers']['status-code']); + + $response = $this->listEmailTemplates(); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['templates']); + + foreach ($response['body']['templates'] as $template) { + $this->assertArrayHasKey('templateId', $template); + $this->assertArrayHasKey('locale', $template); + $this->assertArrayHasKey('subject', $template); + $this->assertArrayHasKey('message', $template); + $this->assertArrayHasKey('senderName', $template); + $this->assertArrayHasKey('senderEmail', $template); + $this->assertArrayHasKey('replyToEmail', $template); + $this->assertArrayHasKey('replyToName', $template); + } + } + + public function testListEmailTemplatesSeparatesLocales(): void + { + $this->ensureSMTPEnabled(); + + $runId = \uniqid(); + $enSubject = "Multi-locale EN {$runId}"; + $frSubject = "Multi-locale FR {$runId}"; + + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'recovery', + locale: 'en', + subject: $enSubject, + message: 'EN body', + )['headers']['status-code']); + + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'recovery', + locale: 'fr', + subject: $frSubject, + message: 'FR body', + )['headers']['status-code']); + + $response = $this->listEmailTemplates(); + $this->assertSame(200, $response['headers']['status-code']); + + $foundEn = false; + $foundFr = false; + foreach ($response['body']['templates'] as $template) { + if ($template['templateId'] === 'recovery' && $template['locale'] === 'en' && $template['subject'] === $enSubject) { + $foundEn = true; + } + if ($template['templateId'] === 'recovery' && $template['locale'] === 'fr' && $template['subject'] === $frSubject) { + $foundFr = true; + } + } + + $this->assertTrue($foundEn, 'recovery/en must appear in the list'); + $this->assertTrue($foundFr, 'recovery/fr must appear in the list'); + } + + public function testListEmailTemplatesUpdateDoesNotDuplicate(): void + { + $this->ensureSMTPEnabled(); + + $runId = \uniqid(); + $firstSubject = "First {$runId}"; + $secondSubject = "Second {$runId}"; + + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'mfaChallenge', + locale: 'en', + subject: $firstSubject, + message: 'Body', + )['headers']['status-code']); + + $before = $this->listEmailTemplates(); + $this->assertSame(200, $before['headers']['status-code']); + $beforeTotal = $before['body']['total']; + + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'mfaChallenge', + locale: 'en', + subject: $secondSubject, + message: 'Body', + )['headers']['status-code']); + + $after = $this->listEmailTemplates(); + $this->assertSame(200, $after['headers']['status-code']); + + // Same templateId/locale must remain a single entry, not accumulate. + $this->assertSame($beforeTotal, $after['body']['total']); + + $matches = \array_values(\array_filter( + $after['body']['templates'], + fn ($t) => $t['templateId'] === 'mfaChallenge' && $t['locale'] === 'en', + )); + $this->assertCount(1, $matches); + $this->assertSame($secondSubject, $matches[0]['subject']); + } + + public function testListEmailTemplatesTotalFalse(): void + { + $this->ensureSMTPEnabled(); + + // Ensure at least one template exists so `templates` is non-empty. + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Total-false subject', + message: 'Body', + )['headers']['status-code']); + + $response = $this->listEmailTemplates(total: false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertIsInt($response['body']['total']); + $this->assertSame(0, $response['body']['total']); + $this->assertNotEmpty($response['body']['templates']); + } + + public function testListEmailTemplatesTotalMatchesCount(): void + { + $this->ensureSMTPEnabled(); + + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Match subject', + message: 'Body', + )['headers']['status-code']); + + $response = $this->listEmailTemplates(); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(\count($response['body']['templates']), $response['body']['total']); + } + + public function testListEmailTemplatesWithLimit(): void + { + $this->ensureSMTPEnabled(); + + $runId = \uniqid(); + + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: "Limit verification {$runId}", + message: 'Body', + )['headers']['status-code']); + + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'recovery', + locale: 'en', + subject: "Limit recovery {$runId}", + message: 'Body', + )['headers']['status-code']); + + $response = $this->listEmailTemplates([ + Query::limit(1)->toString(), + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body']['templates']); + $this->assertGreaterThanOrEqual(2, $response['body']['total']); + } + + public function testListEmailTemplatesWithOffset(): void + { + $this->ensureSMTPEnabled(); + + $runId = \uniqid(); + + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'magicSession', + locale: 'en', + subject: "Offset magic {$runId}", + message: 'Body', + )['headers']['status-code']); + + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'sessionAlert', + locale: 'en', + subject: "Offset session {$runId}", + message: 'Body', + )['headers']['status-code']); + + $listAll = $this->listEmailTemplates(); + $this->assertSame(200, $listAll['headers']['status-code']); + $totalAll = \count($listAll['body']['templates']); + + $listOffset = $this->listEmailTemplates([ + Query::offset(1)->toString(), + ]); + + $this->assertSame(200, $listOffset['headers']['status-code']); + $this->assertCount($totalAll - 1, $listOffset['body']['templates']); + $this->assertSame($listAll['body']['total'], $listOffset['body']['total']); + } + + public function testListEmailTemplatesOnlyReturnsCustomizedTemplates(): void + { + $this->ensureSMTPEnabled(); + + // Seed exactly one template so we have a stable marker to count against. + $marker = 'Customized-only ' . \uniqid(); + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'otpSession', + locale: 'en', + subject: $marker, + message: 'Body', + )['headers']['status-code']); + + $response = $this->listEmailTemplates(); + $this->assertSame(200, $response['headers']['status-code']); + + // Every returned entry must be a real stored template (has templateId+locale set, + // not a synthesized default row for every possible type). + foreach ($response['body']['templates'] as $template) { + $this->assertNotEmpty($template['templateId']); + $this->assertNotEmpty($template['locale']); + } + + // A `(templateId, locale)` pair that has never been customized in this test + // run must NOT show up. 'otpSession'/'pt-br' has no writer anywhere in the file. + $uncustomized = \array_filter( + $response['body']['templates'], + fn ($t) => $t['templateId'] === 'otpSession' && $t['locale'] === 'pt-br', + ); + $this->assertEmpty($uncustomized, 'uncustomized (templateId, locale) pairs must not appear'); + } + + public function testListEmailTemplatesWithoutAuthentication(): void + { + $response = $this->listEmailTemplates(authenticated: false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // Backwards compatibility (x-appwrite-response-format: 1.9.1) + + public function testGetEmailTemplateLegacyResponseFormat(): void + { + $response = $this->client->call( + Client::METHOD_GET, + '/project/templates/email/verification', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()), + ); + + $this->assertSame(200, $response['headers']['status-code']); + // The 1.9.1 response filter renames templateId -> type and strips replyToName. + $this->assertArrayHasKey('type', $response['body']); + $this->assertArrayNotHasKey('templateId', $response['body']); + $this->assertArrayNotHasKey('replyToName', $response['body']); + $this->assertSame('verification', $response['body']['type']); + $this->assertSame('en', $response['body']['locale']); + } + + public function testUpdateEmailTemplateLegacyRequestAndResponse(): void + { + $this->ensureSMTPEnabled(); + + // Legacy clients send `type` + `replyTo`; request filter maps both. + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/templates/email', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()), + [ + 'type' => 'magicSession', + 'locale' => 'en', + 'subject' => 'Legacy subject', + 'message' => 'Legacy body', + 'senderName' => 'Legacy Sender', + 'senderEmail' => 'legacy-sender@appwrite.io', + 'replyTo' => 'legacy-reply@appwrite.io', + ], + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('type', $response['body']); + $this->assertArrayNotHasKey('templateId', $response['body']); + $this->assertArrayHasKey('replyTo', $response['body']); + $this->assertArrayNotHasKey('replyToEmail', $response['body']); + $this->assertArrayNotHasKey('replyToName', $response['body']); + $this->assertSame('magicSession', $response['body']['type']); + $this->assertSame('Legacy subject', $response['body']['subject']); + $this->assertSame('Legacy body', $response['body']['message']); + $this->assertSame('Legacy Sender', $response['body']['senderName']); + $this->assertSame('legacy-sender@appwrite.io', $response['body']['senderEmail']); + $this->assertSame('legacy-reply@appwrite.io', $response['body']['replyTo']); + + // Modern clients see the new field names for the exact same record. + $modern = $this->getEmailTemplate('magicSession', 'en'); + $this->assertSame('magicSession', $modern['body']['templateId']); + $this->assertSame('legacy-reply@appwrite.io', $modern['body']['replyToEmail']); + } + + public function testUpdateEmailTemplateLegacyInvalidType(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/templates/email', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()), + [ + 'type' => 'notATemplate', + 'locale' => 'en', + 'subject' => 'Subject', + 'message' => 'Message', + ], + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + // Session alert integration + + public function testSessionAlertUsesCustomTemplatePerLocale(): void + { + $this->ensureSMTPEnabled(); + + // session-alerts lives under /projects (console scope), so it's driven with the + // root console session rather than the current test's project-scoped headers. + $alertsResponse = $this->client->call( + Client::METHOD_PATCH, + '/projects/' . $this->getProject()['$id'] . '/auth/session-alerts', + [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => 'console', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ], + ['enabled' => true], + ); + $this->assertSame(200, $alertsResponse['headers']['status-code'], 'failed to enable session alerts'); + + $runId = \uniqid(); + $enSubject = "EN alert subject {$runId}"; + $enMessage = "EN alert body marker {$runId}"; + $skSubject = "SK alert subject {$runId}"; + $skMessage = "SK alert body marker {$runId}"; + + // Configure custom EN template via the default-locale path (omit `locale`). + $enUpdate = $this->updateEmailTemplate( + templateId: 'sessionAlert', + locale: null, + subject: $enSubject, + message: $enMessage, + ); + $this->assertSame(200, $enUpdate['headers']['status-code']); + $this->assertSame('en', $enUpdate['body']['locale']); + + // Configure custom SK template explicitly. + $skUpdate = $this->updateEmailTemplate( + templateId: 'sessionAlert', + locale: 'sk', + subject: $skSubject, + message: $skMessage, + ); + $this->assertSame(200, $skUpdate['headers']['status-code']); + + // Matrix of request-time locales and the custom template each one must resolve to. + // `de` has no custom template stored, so it must fall back to the `en` custom template. + $cases = [ + ['requestLocale' => 'en', 'expectedSubject' => $enSubject, 'expectedMessageMarker' => $enMessage], + ['requestLocale' => null, 'expectedSubject' => $enSubject, 'expectedMessageMarker' => $enMessage], + ['requestLocale' => 'sk', 'expectedSubject' => $skSubject, 'expectedMessageMarker' => $skMessage], + ['requestLocale' => 'de', 'expectedSubject' => $enSubject, 'expectedMessageMarker' => $enMessage], + ]; + + foreach ($cases as $case) { + $localeLabel = $case['requestLocale'] ?? 'none'; + $email = "session-alert-{$runId}-{$localeLabel}@appwrite.io"; + $password = 'password123'; + + // Fresh user per case so the session count starts at zero. + $create = $this->client->call(Client::METHOD_POST, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-dev-key' => $this->getProject()['devKey'] ?? '', + ], [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => 'Session Alert ' . $localeLabel, + ]); + $this->assertSame(201, $create['headers']['status-code'], "create user ({$localeLabel})"); + + // First session must NOT trigger an alert (count === 1 returns early). + $first = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $first['headers']['status-code'], "first session ({$localeLabel})"); + + // Second session — this one triggers the alert, with the test's request locale. + $headers = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + if ($case['requestLocale'] !== null) { + $headers['x-appwrite-locale'] = $case['requestLocale']; + } + $second = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $headers, [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $second['headers']['status-code'], "second session ({$localeLabel})"); + + // The custom subject is uniquely tagged per run, so matching it proves both + // that an alert was sent and that the correct locale template was resolved. + $received = $this->getLastEmailByAddress($email, function ($mail) use ($case) { + $this->assertSame($case['expectedSubject'], $mail['subject']); + }); + + $this->assertSame($case['expectedSubject'], $received['subject'], "subject ({$localeLabel})"); + $this->assertStringContainsString( + $case['expectedMessageMarker'], + $received['text'] . $received['html'], + "message marker ({$localeLabel})", + ); + } + } + + // Helpers + + protected function getEmailTemplate(string $templateId, ?string $locale = null, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + $params = []; + if ($locale !== null) { + $params['locale'] = $locale; + } + + return $this->client->call(Client::METHOD_GET, '/project/templates/email/' . $templateId, $headers, $params); + } + + protected function listEmailTemplates(?array $queries = null, ?bool $total = null, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + $params = []; + if ($queries !== null) { + $params['queries'] = $queries; + } + if ($total !== null) { + $params['total'] = $total; + } + + return $this->client->call(Client::METHOD_GET, '/project/templates/email', $headers, $params); + } + + protected function updateEmailTemplate( + string $templateId, + ?string $locale = null, + ?string $subject = null, + ?string $message = null, + ?string $senderName = null, + ?string $senderEmail = null, + ?string $replyToEmail = null, + ?string $replyToName = null, + bool $authenticated = true, + ): mixed { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + $params = ['templateId' => $templateId]; + + foreach (['locale', 'subject', 'message', 'senderName', 'senderEmail', 'replyToEmail', 'replyToName'] as $key) { + if (!\is_null(${$key})) { + $params[$key] = ${$key}; + } + } + + return $this->client->call(Client::METHOD_PATCH, '/project/templates/email', $headers, $params); + } + + protected function ensureSMTPEnabled(): void + { + $this->client->call( + Client::METHOD_PATCH, + '/project/smtp', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), + [ + 'enabled' => true, + 'senderName' => 'Mailer', + 'senderEmail' => 'mailer@appwrite.io', + 'host' => 'maildev', + 'port' => 1025, + 'username' => 'user', + 'password' => 'password', + ], + ); + } +} diff --git a/tests/e2e/Services/Project/TemplatesConsoleClientTest.php b/tests/e2e/Services/Project/TemplatesConsoleClientTest.php new file mode 100644 index 0000000000..d5431074e3 --- /dev/null +++ b/tests/e2e/Services/Project/TemplatesConsoleClientTest.php @@ -0,0 +1,14 @@ +client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/limit', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 0, ]); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 59ff5e353c..f88db41e8c 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -6,7 +6,6 @@ use Appwrite\Extend\Exception; use Appwrite\Tests\Async; use PHPUnit\Framework\Attributes\Group; use Tests\E2E\Client; -use Tests\E2E\General\UsageTest; use Tests\E2E\Scopes\ProjectConsole; use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\SideClient; @@ -831,49 +830,6 @@ class ProjectsConsoleClientTest extends Scope $this->markTestIncomplete( 'This test is failing right now due to functions collection.' ); - /** - * Test for SUCCESS - */ - $response = $this->client->call(Client::METHOD_GET, '/project/usage', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'startDate' => UsageTest::getToday(), - 'endDate' => UsageTest::getTomorrow(), - ]); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(8, count($response['body'])); - $this->assertNotEmpty($response['body']); - $this->assertIsArray($response['body']['requests']); - $this->assertIsArray($response['body']['network']); - $this->assertIsNumeric($response['body']['executionsTotal']); - $this->assertIsNumeric($response['body']['rowsTotal']); - $this->assertIsNumeric($response['body']['databasesTotal']); - $this->assertIsNumeric($response['body']['bucketsTotal']); - $this->assertIsNumeric($response['body']['usersTotal']); - $this->assertIsNumeric($response['body']['filesStorageTotal']); - $this->assertIsNumeric($response['body']['deploymentStorageTotal']); - $this->assertIsNumeric($response['body']['authPhoneTotal']); - $this->assertIsNumeric($response['body']['authPhoneEstimate']); - - - /** - * Test for FAILURE - */ - $response = $this->client->call(Client::METHOD_GET, '/projects/empty', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(404, $response['headers']['status-code']); - - $response = $this->client->call(Client::METHOD_GET, '/projects/id-is-really-long-id-is-really-long-id-is-really-long-id-is-really-long', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(400, $response['headers']['status-code']); } public function testUpdateProject(): void @@ -971,7 +927,8 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals($smtpHost, $response['body']['smtpHost']); $this->assertEquals($smtpPort, $response['body']['smtpPort']); $this->assertEquals($smtpUsername, $response['body']['smtpUsername']); - $this->assertEquals($smtpPassword, $response['body']['smtpPassword']); + // smtpPassword is write-only: the stored password must never leak in responses + $this->assertEquals('', $response['body']['smtpPassword']); $this->assertEquals('', $response['body']['smtpSecure']); // Check the project @@ -987,7 +944,8 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals($smtpHost, $response['body']['smtpHost']); $this->assertEquals($smtpPort, $response['body']['smtpPort']); $this->assertEquals($smtpUsername, $response['body']['smtpUsername']); - $this->assertEquals($smtpPassword, $response['body']['smtpPassword']); + // smtpPassword is write-only: the stored password must never leak in responses + $this->assertEquals('', $response['body']['smtpPassword']); $this->assertEquals('', $response['body']['smtpSecure']); /** @@ -1121,6 +1079,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); @@ -1129,10 +1088,45 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('verification', $response['body']['type']); $this->assertEquals('en-us', $response['body']['locale']); + /** Update Email template, fail due to SMTP disabled */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()), [ + 'subject' => 'Please verify your email', + 'message' => 'Please verify your email {{url}}', + 'senderName' => 'Appwrite Custom', + 'senderEmail' => 'custom@appwrite.io', + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + /** Configure custom SMTP pointing to maildev, so changing template is allowed */ + $smtpHost = 'maildev'; + $smtpPort = 1025; + $smtpUsername = 'user'; + $smtpPassword = 'password'; + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/smtp', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()), [ + 'enabled' => true, + 'senderEmail' => 'mailer@appwrite.io', + 'senderName' => 'Mailer', + 'host' => $smtpHost, + 'port' => $smtpPort, + 'username' => $smtpUsername, + 'password' => $smtpPassword, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + /** Update Email template */ $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'subject' => 'Please verify your email', 'message' => 'Please verify your email {{url}}', @@ -1152,6 +1146,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); @@ -1219,6 +1214,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'type' => 'sessionAlert', // Intentionally no locale @@ -1237,6 +1233,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'type' => 'sessionAlert', 'locale' => 'sk', @@ -1255,6 +1252,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/auth/session-alerts', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'alerts' => true, ]); @@ -1397,6 +1395,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/duration', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'duration' => 10, // Set session duration to 10 seconds ]); @@ -1464,6 +1463,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/duration', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'duration' => 600, // seconds ]); @@ -1484,6 +1484,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/duration', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'duration' => TOKEN_EXPIRATION_LOGIN_LONG, ]); @@ -1540,6 +1541,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/session-invalidation', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => false, ]); @@ -1556,6 +1558,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/session-invalidation', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => true, ]); @@ -1761,6 +1764,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/' . $index, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'status' => false, ]); @@ -1857,6 +1861,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/' . $index, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'status' => true, ]); @@ -1874,6 +1879,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/limit', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 1, ]); @@ -1952,6 +1958,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/limit', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 0, ]); @@ -1983,24 +1990,13 @@ class ProjectsConsoleClientTest extends Scope 'region' => System::getEnv('_APP_REGION', 'default') ]); - /** - * Test for failure - */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'limit' => 0, - ]); - - $this->assertEquals(400, $response['headers']['status-code']); - /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 1, ]); @@ -2072,6 +2068,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 10, ]); @@ -2084,25 +2081,13 @@ class ProjectsConsoleClientTest extends Scope $data = $this->setupProjectWithAuthLimit(); $id = $data['projectId']; - /** - * Test for Failure - */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'limit' => 25, - ]); - - $this->assertEquals(400, $response['headers']['status-code']); - - /** * Test for Success */ $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 1, ]); @@ -2176,6 +2161,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 0, ]); @@ -2436,6 +2422,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-dictionary', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => true, ]); @@ -2493,6 +2480,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 0, ]); @@ -2506,6 +2494,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-dictionary', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => false, ]); @@ -2525,6 +2514,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/personal-data', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => true, ]); @@ -2637,6 +2627,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/personal-data', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => false, ]); @@ -2645,120 +2636,6 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(false, $response['body']['authPersonalDataCheck']); } - public function testUpdateProjectServicesAll(): void - { - $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ]), [ - 'teamId' => ID::unique(), - 'name' => 'Project Test', - ]); - - $this->assertEquals(201, $team['headers']['status-code']); - $this->assertNotEmpty($team['body']['$id']); - - $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ]), [ - 'projectId' => ID::unique(), - 'name' => 'Project Test', - 'teamId' => $team['body']['$id'], - 'region' => System::getEnv('_APP_REGION', 'default') - ]); - - $this->assertEquals(201, $project['headers']['status-code']); - $this->assertNotEmpty($project['body']['$id']); - - $id = $project['body']['$id']; - - // Bulk disable should no longer work - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/all', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.0', - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ]), [ - 'status' => false, - ]); - - $this->assertEquals(405, $response['headers']['status-code']); - $this->assertEquals('general_not_implemented', $response['body']['type']); - - // Bulk enable should no longer work - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/all', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.0', - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ]), [ - 'status' => true, - ]); - - $this->assertEquals(405, $response['headers']['status-code']); - $this->assertEquals('general_not_implemented', $response['body']['type']); - } - - public function testUpdateProjectApisAll(): void - { - $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ]), [ - 'teamId' => ID::unique(), - 'name' => 'Project Test', - ]); - - $this->assertEquals(201, $team['headers']['status-code']); - $this->assertNotEmpty($team['body']['$id']); - - $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ]), [ - 'projectId' => ID::unique(), - 'name' => 'Project Test', - 'teamId' => $team['body']['$id'], - 'region' => System::getEnv('_APP_REGION', 'default') - ]); - - $this->assertEquals(201, $project['headers']['status-code']); - $this->assertNotEmpty($project['body']['$id']); - - $id = $project['body']['$id']; - - // Bulk disable should no longer work - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api/all', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.0', - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ]), [ - 'status' => false, - ]); - - $this->assertEquals(405, $response['headers']['status-code']); - $this->assertEquals('general_not_implemented', $response['body']['type']); - - // Bulk enable should no longer work - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api/all', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.0', - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ]), [ - 'status' => true, - ]); - - $this->assertEquals(405, $response['headers']['status-code']); - $this->assertEquals('general_not_implemented', $response['body']['type']); - } - public function testUpdateProjectApiStatus(): void { $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ @@ -4064,58 +3941,6 @@ class ProjectsConsoleClientTest extends Scope $this->assertEmpty($response['body']); } - // JWT Keys - - public function testJWTKey(): void - { - $data = $this->setupProjectData(); - $id = $data['projectId']; - - // Create JWT key - $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/jwts', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'duration' => 5, - 'scopes' => ['users.read'], - ]); - - $this->assertEquals(201, $response['headers']['status-code']); - $this->assertNotEmpty($response['body']['jwt']); - - $jwt = $response['body']['jwt']; - - // Ensure JWT key works - $response = $this->client->call(Client::METHOD_GET, '/users', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $id, - 'x-appwrite-key' => $jwt, - ]); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertArrayHasKey('users', $response['body']); - - // Ensure JWT key respect scopes - $response = $this->client->call(Client::METHOD_GET, '/functions', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $id, - 'x-appwrite-key' => $jwt, - ]); - - $this->assertEquals(401, $response['headers']['status-code']); - - // Ensure JWT key expires - \sleep(10); - - $response = $this->client->call(Client::METHOD_GET, '/users', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $id, - 'x-appwrite-key' => $jwt, - ]); - - $this->assertEquals(401, $response['headers']['status-code']); - } - // Platforms public function testCreateProjectPlatform(): void diff --git a/tests/e2e/Services/Projects/ProjectsCustomServerTest.php b/tests/e2e/Services/Projects/ProjectsCustomServerTest.php index 313a4d53be..d87c2cbf78 100644 --- a/tests/e2e/Services/Projects/ProjectsCustomServerTest.php +++ b/tests/e2e/Services/Projects/ProjectsCustomServerTest.php @@ -10,6 +10,7 @@ use Utopia\System\System; class ProjectsCustomServerTest extends Scope { + use ProjectsBase; use ProjectCustom; use SideServer; diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php index edce428e0f..4d37a8944b 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php @@ -164,6 +164,20 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope return $response; } + /** + * @param array> $payloadEntries + * @return array + */ + private function sendUnsubscribeMessage(WebSocketClient $client, array $payloadEntries): array + { + $client->send(\json_encode([ + 'type' => 'unsubscribe', + 'data' => $payloadEntries, + ])); + + return \json_decode($client->receive(), true); + } + /** * subscriptionId: update with id from connected, create by omitting id, explicit new id, * duplicate id in one bulk (last wins), mixed bulk, idempotent repeat, empty queries → select-all. @@ -293,6 +307,282 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope $client->close(); } + /** + * Update a subscription's queries/channels by reusing its subscriptionId. + * Verifies the update takes effect on live event filtering (not just the response echo), + * sibling subscriptions are untouched, unknown ids upsert as new, empty queries fall + * back to select-all, and a removed id can be recreated by subscribing again. + */ + public function testUpdateSubscriptionAndEdgeCases(): void + { + $user = $this->getUser(); + $userId = $user['$id'] ?? ''; + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + $queryString = \http_build_query(['project' => $projectId]); + $client = new WebSocketClient( + 'ws://appwrite.test/v1/realtime?' . $queryString, + [ + 'headers' => $headers, + 'timeout' => 10, + ] + ); + $connected = \json_decode($client->receive(), true); + $this->assertEquals('connected', $connected['type'] ?? null); + + $triggerAccountEvent = function () use ($projectId, $session): void { + $this->client->call(Client::METHOD_PATCH, '/account/name', \array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]), ['name' => 'Update Sub Test ' . \uniqid()]); + }; + + // subA matches current user, subB never matches + $created = $this->sendSubscribeMessage($client, [ + [ + 'channels' => ['account'], + 'queries' => [Query::equal('$id', [$userId])->toString()], + ], + [ + 'channels' => ['account'], + 'queries' => [Query::equal('$id', ['no-match-initial'])->toString()], + ], + ]); + $subA = $created['data']['subscriptions'][0]['subscriptionId']; + $subB = $created['data']['subscriptions'][1]['subscriptionId']; + $this->assertNotSame($subA, $subB); + + $triggerAccountEvent(); + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertSame([$subA], $event['data']['subscriptions']); + + // Swap: A -> non-matching, B -> matching. Same ids returned, server-side filter swaps. + $swap = $this->sendSubscribeMessage($client, [ + [ + 'subscriptionId' => $subA, + 'channels' => ['account'], + 'queries' => [Query::equal('$id', ['no-match-swapped'])->toString()], + ], + [ + 'subscriptionId' => $subB, + 'channels' => ['account'], + 'queries' => [Query::equal('$id', [$userId])->toString()], + ], + ]); + $this->assertSame($subA, $swap['data']['subscriptions'][0]['subscriptionId']); + $this->assertSame($subB, $swap['data']['subscriptions'][1]['subscriptionId']); + + $triggerAccountEvent(); + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertSame([$subB], $event['data']['subscriptions']); + + // Sibling isolation: updating only subA must leave subB's matching filter intact. + $isolation = $this->sendSubscribeMessage($client, [[ + 'subscriptionId' => $subA, + 'channels' => ['account'], + 'queries' => [Query::equal('$id', [$userId])->toString()], + ]]); + $this->assertSame($subA, $isolation['data']['subscriptions'][0]['subscriptionId']); + + $triggerAccountEvent(); + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEqualsCanonicalizing([$subA, $subB], $event['data']['subscriptions']); + + // Empty queries on update -> select-all; subA still matches every event on the channel. + $empty = $this->sendSubscribeMessage($client, [[ + 'subscriptionId' => $subA, + 'channels' => ['account'], + 'queries' => [], + ]]); + $this->assertSame($subA, $empty['data']['subscriptions'][0]['subscriptionId']); + + $triggerAccountEvent(); + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEqualsCanonicalizing([$subA, $subB], $event['data']['subscriptions']); + + // Unknown subscriptionId upserts as a new subscription. + $ghostId = ID::unique(); + $ghost = $this->sendSubscribeMessage($client, [[ + 'subscriptionId' => $ghostId, + 'channels' => ['account'], + 'queries' => [Query::equal('$id', [$userId])->toString()], + ]]); + $this->assertSame($ghostId, $ghost['data']['subscriptions'][0]['subscriptionId']); + $this->assertNotSame($subA, $ghostId); + $this->assertNotSame($subB, $ghostId); + + $triggerAccountEvent(); + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEqualsCanonicalizing([$subA, $subB, $ghostId], $event['data']['subscriptions']); + + // Update after unsubscribe: subscribing with the removed id recreates it. + $unsub = $this->sendUnsubscribeMessage($client, [['subscriptionId' => $subA]]); + $this->assertTrue($unsub['data']['subscriptions'][0]['removed']); + + $triggerAccountEvent(); + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEqualsCanonicalizing([$subB, $ghostId], $event['data']['subscriptions']); + + $recreated = $this->sendSubscribeMessage($client, [[ + 'subscriptionId' => $subA, + 'channels' => ['account'], + 'queries' => [Query::equal('$id', [$userId])->toString()], + ]]); + $this->assertSame($subA, $recreated['data']['subscriptions'][0]['subscriptionId']); + + $triggerAccountEvent(); + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEqualsCanonicalizing([$subA, $subB, $ghostId], $event['data']['subscriptions']); + + $client->close(); + } + + public function testUnsubscribeRemovesOnlyMatchingSubscription(): void + { + $user = $this->getUser(); + $userId = $user['$id'] ?? ''; + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + $queryString = \http_build_query(['project' => $projectId]); + $client = new WebSocketClient( + 'ws://appwrite.test/v1/realtime?' . $queryString, + [ + 'headers' => $headers, + 'timeout' => 10, + ] + ); + + $connected = \json_decode($client->receive(), true); + $this->assertEquals('connected', $connected['type'] ?? null); + + // Two subscriptions on the `account` channel, both matching the current user + $r1 = $this->sendSubscribeMessage($client, [[ + 'channels' => ['account'], + 'queries' => [Query::equal('$id', [$userId])->toString()], + ]]); + $subA = $r1['data']['subscriptions'][0]['subscriptionId']; + + $r2 = $this->sendSubscribeMessage($client, [[ + 'channels' => ['account'], + 'queries' => [Query::select(['*'])->toString()], + ]]); + $subB = $r2['data']['subscriptions'][0]['subscriptionId']; + + $this->assertNotSame($subA, $subB); + + // Trigger an event -- both subscriptions should match + $name = 'Unsubscribe Test ' . \uniqid(); + $this->client->call(Client::METHOD_PATCH, '/account/name', \array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]), ['name' => $name]); + + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEqualsCanonicalizing([$subA, $subB], $event['data']['subscriptions']); + + // Unsubscribe subA only + $unsubA = $this->sendUnsubscribeMessage($client, [['subscriptionId' => $subA]]); + $this->assertEquals('response', $unsubA['type']); + $this->assertEquals('unsubscribe', $unsubA['data']['to']); + $this->assertTrue($unsubA['data']['success']); + $this->assertCount(1, $unsubA['data']['subscriptions']); + $this->assertSame($subA, $unsubA['data']['subscriptions'][0]['subscriptionId']); + $this->assertTrue($unsubA['data']['subscriptions'][0]['removed']); + + // Trigger another event -- only subB should match now + $name = 'Unsubscribe Test ' . \uniqid(); + $this->client->call(Client::METHOD_PATCH, '/account/name', \array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]), ['name' => $name]); + + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertSame([$subB], $event['data']['subscriptions']); + + // Idempotent: unsubscribing subA again reports removed=false + $unsubAgain = $this->sendUnsubscribeMessage($client, [['subscriptionId' => $subA]]); + $this->assertTrue($unsubAgain['data']['success']); + $this->assertFalse($unsubAgain['data']['subscriptions'][0]['removed']); + + // Connection is still alive -- ping still works + $client->send(\json_encode(['type' => 'ping'])); + $pong = \json_decode($client->receive(), true); + $this->assertEquals('pong', $pong['type']); + + // Invalid payloads are rejected + $errNonString = $this->sendUnsubscribeMessage($client, [['subscriptionId' => 123]]); + $this->assertEquals('error', $errNonString['type']); + $this->assertStringContainsString('subscriptionId', $errNonString['data']['message']); + + $errEmpty = $this->sendUnsubscribeMessage($client, [['subscriptionId' => '']]); + $this->assertEquals('error', $errEmpty['type']); + + $errMissing = $this->sendUnsubscribeMessage($client, [['channels' => ['foo']]]); + $this->assertEquals('error', $errMissing['type']); + + $errNonList = $this->sendUnsubscribeMessage($client, ['subscriptionId' => $subB]); + $this->assertEquals('error', $errNonList['type']); + + // A batch with a valid id followed by an invalid one must be rejected atomically: + // the valid id must remain subscribed, not be quietly removed before validation fails. + $partial = $this->sendUnsubscribeMessage($client, [ + ['subscriptionId' => $subB], + ['subscriptionId' => 999], + ]); + $this->assertEquals('error', $partial['type']); + + $name = 'Partial Rejection Test ' . \uniqid(); + $this->client->call(Client::METHOD_PATCH, '/account/name', \array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]), ['name' => $name]); + + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertSame([$subB], $event['data']['subscriptions']); + + // Bulk unsubscribe: remaining subB plus a never-existed id -- response mirrors input order + $bulk = $this->sendUnsubscribeMessage($client, [ + ['subscriptionId' => $subB], + ['subscriptionId' => 'does-not-exist'], + ]); + $this->assertTrue($bulk['data']['success']); + $this->assertCount(2, $bulk['data']['subscriptions']); + $this->assertSame($subB, $bulk['data']['subscriptions'][0]['subscriptionId']); + $this->assertTrue($bulk['data']['subscriptions'][0]['removed']); + $this->assertSame('does-not-exist', $bulk['data']['subscriptions'][1]['subscriptionId']); + $this->assertFalse($bulk['data']['subscriptions'][1]['removed']); + + $client->close(); + } + public function testInvalidQueryShouldNotSubscribe(): void { $user = $this->getUser(); @@ -513,7 +803,7 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope $client->receive(); $this->fail('Expected TimeoutException - event should be filtered by updated query'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index ca07d45f46..ef1c5fce7a 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -3828,7 +3828,7 @@ class RealtimeCustomClientTest extends Scope $this->fail('Should not receive any event after rollback'); } catch (TimeoutException $e) { // Expected - no event should be triggered - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -5655,7 +5655,7 @@ class RealtimeCustomClientTest extends Scope $client->receive(); $this->fail('Should not receive duplicate event'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // Test Document Decrement @@ -5686,7 +5686,7 @@ class RealtimeCustomClientTest extends Scope $client->receive(); $this->fail('Should not receive duplicate event'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); diff --git a/tests/e2e/Services/Realtime/RealtimeQueryBase.php b/tests/e2e/Services/Realtime/RealtimeQueryBase.php index 04ed56dae6..04b8400b57 100644 --- a/tests/e2e/Services/Realtime/RealtimeQueryBase.php +++ b/tests/e2e/Services/Realtime/RealtimeQueryBase.php @@ -101,7 +101,7 @@ trait RealtimeQueryBase $data = $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -206,7 +206,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -304,7 +304,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -398,7 +398,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -492,7 +492,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -604,7 +604,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -716,7 +716,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -810,7 +810,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -903,7 +903,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -1019,7 +1019,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // Create document with priority > 5 but status != 'active' - should NOT receive event @@ -1041,7 +1041,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -1157,7 +1157,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -1296,7 +1296,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // Create document with score >= 80 but category != 'premium' or 'vip' - should NOT receive event @@ -1318,7 +1318,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -1511,7 +1511,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered for scoped channel query'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -1583,7 +1583,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -1692,7 +1692,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered (neither query matches)'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // Create document with matching ID but wrong status - should NOT receive event (only one query matches) @@ -1713,7 +1713,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered (ID matches but status does not)'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -1870,7 +1870,7 @@ trait RealtimeQueryBase $clientQ2->receive(); $this->fail('Expected TimeoutException - event should be filtered for clientQ2 (active document)'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // clientComplex: should receive event, subscriptions should not be empty (query matched) @@ -1912,7 +1912,7 @@ trait RealtimeQueryBase $clientQ1->receive(); $this->fail('Expected TimeoutException - event should be filtered for clientQ1 (pending document)'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // clientQ2: should receive event, subscriptions should not be empty (query matched) @@ -1929,7 +1929,7 @@ trait RealtimeQueryBase $clientComplex->receive(); $this->fail('Expected TimeoutException - event should be filtered for complex subscription (pending document)'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $clientAll->close(); @@ -2043,7 +2043,7 @@ trait RealtimeQueryBase $clientQ2->receive(); $this->fail('Expected TimeoutException - clientQ2 should not receive active document'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // 2) pending document -> only queryStatusPending subscription should see it @@ -2073,7 +2073,7 @@ trait RealtimeQueryBase $clientQ1->receive(); $this->fail('Expected TimeoutException - clientQ1 should not receive pending document'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $clientQ1->close(); @@ -2252,7 +2252,7 @@ trait RealtimeQueryBase $data = $client->receive(); $this->fail('Expected TimeoutException - document does not match query after permission change'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // Create a NEW document with a different ID - should NOT receive event @@ -2279,7 +2279,7 @@ trait RealtimeQueryBase $data = $client->receive(); $this->fail('Expected TimeoutException - new document does not match original query after permission change'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // Create a document with the ORIGINAL matching ID - should receive event @@ -2439,7 +2439,7 @@ trait RealtimeQueryBase $clientWithNonMatchingQuery->receive(); $this->fail('Expected TimeoutException - client with non-matching query should not receive event'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $clientNoQuery->close(); diff --git a/tests/e2e/Services/Sites/SitesCustomServerTest.php b/tests/e2e/Services/Sites/SitesCustomServerTest.php index 69dbd7fdf0..71f6675561 100644 --- a/tests/e2e/Services/Sites/SitesCustomServerTest.php +++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php @@ -801,8 +801,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - /** * Test for SUCCESS */ @@ -868,6 +866,46 @@ class SitesCustomServerTest extends Scope // // TODO: Implement testCreateDeploymentFromCLI() later // } + public function testCreateDeploymentWithSingleContentRangeChunk(): void + { + $siteId = $this->setupSite([ + 'buildRuntime' => 'node-22', + 'fallbackFile' => '', + 'framework' => 'other', + 'name' => 'Test Site Single Chunk Range', + 'outputDirectory' => './', + 'providerBranch' => 'main', + 'providerRootDirectory' => './', + 'siteId' => ID::unique() + ]); + + $code = $this->packageSite('static-single-file'); + $size = \filesize($code->getFilename()); + + $deployment = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/deployments', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'content-range' => 'bytes 0-' . ($size - 1) . '/' . $size, + ], $this->getHeaders()), [ + 'code' => $code, + 'activate' => true, + ]); + + $this->assertEquals(202, $deployment['headers']['status-code']); + $this->assertNotEmpty($deployment['body']['$id']); + + $deploymentId = $deployment['body']['$id']; + + $this->assertEventually(function () use ($siteId, $deploymentId) { + $deployment = $this->getDeployment($siteId, $deploymentId); + + $this->assertEquals(200, $deployment['headers']['status-code']); + $this->assertEquals('ready', $deployment['body']['status']); + }, 120000, 500); + + $this->cleanupSite($siteId); + } + public function testCreateDeployment() { $siteId = $this->setupSite([ @@ -881,8 +919,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $deployment = $this->createDeployment($siteId, [ 'siteId' => $siteId, 'code' => $this->packageSite('static-single-file'), @@ -943,8 +979,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $deployment = $this->createDeployment($siteId, [ 'code' => $this->packageSite('static-single-file'), 'activate' => 'false' @@ -995,8 +1029,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $deployment = $this->createDeployment($siteId, [ 'code' => $this->packageSite('static-single-file'), 'activate' => 'false' @@ -1040,8 +1072,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $deployment = $this->createDeployment($siteId, [ 'code' => $this->packageSite('static-single-file'), 'activate' => 'false' @@ -1243,8 +1273,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $deployment = $this->createDeployment($siteId, [ 'code' => $this->packageSite('static-single-file'), 'activate' => 'false' @@ -1294,8 +1322,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - /** * Test for SUCCESS */ @@ -1383,8 +1409,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $deployment = $this->createDeployment($siteId, [ 'code' => $this->packageSite('static-single-file'), 'activate' => 'false' @@ -1427,8 +1451,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $site = $this->deleteSite($siteId); $this->assertEquals(204, $site['headers']['status-code']); diff --git a/tests/e2e/Services/Teams/TeamsBaseClient.php b/tests/e2e/Services/Teams/TeamsBaseClient.php index 80d73b3bc0..5b04108f71 100644 --- a/tests/e2e/Services/Teams/TeamsBaseClient.php +++ b/tests/e2e/Services/Teams/TeamsBaseClient.php @@ -254,7 +254,7 @@ trait TeamsBaseClient $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertFalse($response['body']['mfa']); - $this->assertNotEmpty($response['body']['userId']); + $this->assertArrayHasKey('userId', $response['body']); $this->assertArrayHasKey('userName', $response['body']); $this->assertArrayHasKey('userEmail', $response['body']); $this->assertNotEmpty($response['body']['teamId']); diff --git a/tests/e2e/Services/Teams/TeamsConsoleClientTest.php b/tests/e2e/Services/Teams/TeamsConsoleClientTest.php index 2a1367d749..da19a26c87 100644 --- a/tests/e2e/Services/Teams/TeamsConsoleClientTest.php +++ b/tests/e2e/Services/Teams/TeamsConsoleClientTest.php @@ -14,6 +14,65 @@ class TeamsConsoleClientTest extends Scope use ProjectConsole; use SideClient; + public function testConsoleMembershipPrivacyDefaults(): void + { + $teamData = $this->createTeamHelper(); + $membershipData = $this->createAndAcceptMembershipHelper($teamData['teamUid'], $teamData['teamName']); + + $teamUid = $teamData['teamUid']; + $projectId = $this->getProject()['$id']; + $owner = $this->getUser(); + $memberHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $membershipData['session'], + ]; + + $ownerMemberships = $this->client->call(Client::METHOD_GET, '/teams/' . $teamUid . '/memberships', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders())); + + $this->assertEquals(200, $ownerMemberships['headers']['status-code']); + $this->assertEquals(2, $ownerMemberships['body']['total']); + + $ownerMembershipsByUser = []; + foreach ($ownerMemberships['body']['memberships'] as $membership) { + $ownerMembershipsByUser[$membership['userId']] = $membership; + } + + $this->assertArrayHasKey($owner['$id'], $ownerMembershipsByUser); + $this->assertContains('owner', $ownerMembershipsByUser[$owner['$id']]['roles']); + + $this->assertArrayHasKey($membershipData['userUid'], $ownerMembershipsByUser); + $this->assertNotContains('owner', $ownerMembershipsByUser[$membershipData['userUid']]['roles']); + $this->assertSame($membershipData['userUid'], $ownerMembershipsByUser[$membershipData['userUid']]['userId']); + $this->assertSame($membershipData['name'], $ownerMembershipsByUser[$membershipData['userUid']]['userName']); + $this->assertSame($membershipData['email'], $ownerMembershipsByUser[$membershipData['userUid']]['userEmail']); + $this->assertFalse($ownerMembershipsByUser[$membershipData['userUid']]['mfa']); + + $memberMemberships = $this->client->call(Client::METHOD_GET, '/teams/' . $teamUid . '/memberships', $memberHeaders); + + $this->assertEquals(200, $memberMemberships['headers']['status-code']); + $this->assertEquals(2, $memberMemberships['body']['total']); + + $memberMembershipsByUser = []; + foreach ($memberMemberships['body']['memberships'] as $membership) { + $memberMembershipsByUser[$membership['userId']] = $membership; + } + + $this->assertArrayHasKey($owner['$id'], $memberMembershipsByUser); + $this->assertSame($owner['$id'], $memberMembershipsByUser[$owner['$id']]['userId']); + $this->assertSame($owner['name'], $memberMembershipsByUser[$owner['$id']]['userName']); + $this->assertSame($owner['email'], $memberMembershipsByUser[$owner['$id']]['userEmail']); + $this->assertFalse($memberMembershipsByUser[$owner['$id']]['mfa']); + $this->assertContains('owner', $memberMembershipsByUser[$owner['$id']]['roles']); + + $this->assertArrayHasKey($membershipData['userUid'], $memberMembershipsByUser); + $this->assertNotContains('owner', $memberMembershipsByUser[$membershipData['userUid']]['roles']); + } + public function testTeamCreateMembershipConsole(): void { $teamData = $this->createTeamHelper(); diff --git a/tests/e2e/Services/Tokens/TokensConsoleClientTest.php b/tests/e2e/Services/Tokens/TokensConsoleClientTest.php index 601bf1d2d0..80e406eac9 100644 --- a/tests/e2e/Services/Tokens/TokensConsoleClientTest.php +++ b/tests/e2e/Services/Tokens/TokensConsoleClientTest.php @@ -147,7 +147,6 @@ class TokensConsoleClientTest extends Scope $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 86400 * 365 * 10, 10); // 10 years maxAge try { $payload = $jwt->decode($token['body']['secret']); - $this->assertIsArray($payload, 'JWT payload should decode to an array'); $this->assertArrayHasKey('tokenId', $payload, 'JWT payload should contain tokenId'); $this->assertArrayHasKey('resourceId', $payload, 'JWT payload should contain resourceId'); $this->assertArrayHasKey('resourceType', $payload, 'JWT payload should contain resourceType'); @@ -204,7 +203,6 @@ class TokensConsoleClientTest extends Scope $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 86400 * 365 * 10, 10); // 10 years maxAge try { $payload = $jwt->decode($token['body']['secret']); - $this->assertIsArray($payload, 'JWT payload should decode to an array'); $this->assertArrayHasKey('exp', $payload, 'JWT payload should contain exp field'); $expectedExp = (new \DateTime($expiry))->getTimestamp(); @@ -226,7 +224,6 @@ class TokensConsoleClientTest extends Scope // Verify JWT does not contain exp for infinite expiry using native JWT decode try { $payload = $jwt->decode($token['body']['secret']); - $this->assertIsArray($payload, 'JWT payload should decode to an array'); $this->assertArrayNotHasKey('exp', $payload, 'JWT payload should not contain exp field for infinite expiry'); } catch (JWTException $e) { $this->fail('Failed to decode JWT: ' . $e->getMessage()); @@ -265,7 +262,6 @@ class TokensConsoleClientTest extends Scope // Verify the JWT token is valid and contains correct information try { $payload = $jwt->decode($token['secret']); - $this->assertIsArray($payload, 'JWT payload should decode to an array'); $this->assertArrayHasKey('tokenId', $payload, 'JWT payload should contain tokenId'); $this->assertArrayHasKey('resourceId', $payload, 'JWT payload should contain resourceId'); $this->assertArrayHasKey('resourceType', $payload, 'JWT payload should contain resourceType'); diff --git a/tests/e2e/Traits/DatabaseFixture.php b/tests/e2e/Traits/DatabaseFixture.php deleted file mode 100644 index f3ba10e765..0000000000 --- a/tests/e2e/Traits/DatabaseFixture.php +++ /dev/null @@ -1,239 +0,0 @@ -ensureFixturesCreated(); - return self::$fixtureDatabaseId; - } - - protected function getFixtureMoviesId(): string - { - $this->ensureFixturesCreated(); - return self::$fixtureMoviesId; - } - - protected function getFixtureActorsId(): string - { - $this->ensureFixturesCreated(); - return self::$fixtureActorsId; - } - - protected function getFixtureDocumentIds(): array - { - $this->ensureFixturesCreated(); - return self::$fixtureDocumentIds; - } - - protected function ensureFixturesCreated(): void - { - if (self::$fixturesInitialized) { - return; - } - - $this->createDatabaseFixtures(); - self::$fixturesInitialized = true; - } - - protected function createDatabaseFixtures(): void - { - $config = $this->getSchemaApiConfig(); - $isTablesDB = $config['basePath'] === '/tablesdb'; - - // Create database - $database = $this->client->call(Client::METHOD_POST, $config['basePath'], [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'databaseId' => ID::unique(), - 'name' => 'Fixture Database' - ]); - - self::$fixtureDatabaseId = $database['body']['$id']; - $databaseId = self::$fixtureDatabaseId; - - $collectionEndpoint = $config['basePath'] . '/' . $databaseId . '/' . $config['collectionPath']; - $collectionKey = $isTablesDB ? 'tableId' : 'collectionId'; - $docKey = $isTablesDB ? 'rowId' : 'documentId'; - $docEndpoint = $isTablesDB ? 'rows' : 'documents'; - - // Create Movies collection - $movies = $this->client->call(Client::METHOD_POST, $collectionEndpoint, [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - $collectionKey => ID::unique(), - 'name' => 'Movies', - ($isTablesDB ? 'rowSecurity' : 'documentSecurity') => true, - 'permissions' => [ - Permission::create(Role::users()), - Permission::read(Role::users()), - Permission::update(Role::users()), - Permission::delete(Role::users()), - ], - ]); - - self::$fixtureMoviesId = $movies['body']['$id']; - - // Create Actors collection - $actors = $this->client->call(Client::METHOD_POST, $collectionEndpoint, [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - $collectionKey => ID::unique(), - 'name' => 'Actors', - ($isTablesDB ? 'rowSecurity' : 'documentSecurity') => true, - 'permissions' => [ - Permission::create(Role::users()), - Permission::read(Role::users()), - Permission::update(Role::users()), - Permission::delete(Role::users()), - ], - ]); - - self::$fixtureActorsId = $actors['body']['$id']; - - // Create attributes on Movies - $attrEndpoint = $config['basePath'] . '/' . $databaseId . '/' . $config['collectionPath'] . '/' . self::$fixtureMoviesId . '/' . $config['attributePath']; - - $this->client->call(Client::METHOD_POST, $attrEndpoint . '/string', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'key' => 'title', - 'size' => 256, - 'required' => true, - ]); - - $this->client->call(Client::METHOD_POST, $attrEndpoint . '/string', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'key' => 'description', - 'size' => 512, - 'required' => false, - 'default' => '', - ]); - - $this->client->call(Client::METHOD_POST, $attrEndpoint . '/integer', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'key' => 'releaseYear', - 'required' => false, - 'default' => 0, - ]); - - $this->client->call(Client::METHOD_POST, $attrEndpoint . '/float', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'key' => 'rating', - 'required' => false, - 'default' => 0.0, - ]); - - $this->client->call(Client::METHOD_POST, $attrEndpoint . '/boolean', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'key' => 'active', - 'required' => false, - 'default' => true, - ]); - - // Create attributes on Actors - $actorAttrEndpoint = $config['basePath'] . '/' . $databaseId . '/' . $config['collectionPath'] . '/' . self::$fixtureActorsId . '/' . $config['attributePath']; - - $this->client->call(Client::METHOD_POST, $actorAttrEndpoint . '/string', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'key' => 'name', - 'size' => 256, - 'required' => true, - ]); - - $this->waitForAllAttributes($databaseId, self::$fixtureMoviesId); - $this->waitForAllAttributes($databaseId, self::$fixtureActorsId); - - // Create indexes - $indexEndpoint = $config['basePath'] . '/' . $databaseId . '/' . $config['collectionPath'] . '/' . self::$fixtureMoviesId . '/' . $config['indexPath']; - - $this->client->call(Client::METHOD_POST, $indexEndpoint, [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'key' => 'title_index', - 'type' => 'key', - 'attributes' => ['title'], - ]); - - $this->waitForAllIndexes($databaseId, self::$fixtureMoviesId); - - // Create sample documents - $docsEndpoint = $config['basePath'] . '/' . $databaseId . '/' . $config['collectionPath'] . '/' . self::$fixtureMoviesId . '/' . $docEndpoint; - - $sampleMovies = [ - ['title' => 'Inception', 'description' => 'A mind-bending thriller', 'releaseYear' => 2010, 'rating' => 8.8, 'active' => true], - ['title' => 'The Matrix', 'description' => 'A sci-fi classic', 'releaseYear' => 1999, 'rating' => 8.7, 'active' => true], - ['title' => 'Interstellar', 'description' => 'Space exploration epic', 'releaseYear' => 2014, 'rating' => 8.6, 'active' => true], - ]; - - foreach ($sampleMovies as $movie) { - $doc = $this->client->call(Client::METHOD_POST, $docsEndpoint, array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - $docKey => ID::unique(), - 'data' => $movie, - 'permissions' => [ - Permission::read(Role::users()), - Permission::update(Role::user($this->getUser()['$id'])), - Permission::delete(Role::user($this->getUser()['$id'])), - ], - ]); - - self::$fixtureDocumentIds[] = $doc['body']['$id']; - } - } - - public static function tearDownAfterClass(): void - { - self::$fixtureDatabaseId = null; - self::$fixtureMoviesId = null; - self::$fixtureActorsId = null; - self::$fixtureDocumentIds = []; - self::$fixturesInitialized = false; - - parent::tearDownAfterClass(); - } -} diff --git a/tests/extensions/Async/Eventually.php b/tests/extensions/Async/Eventually.php index 10f6b41eee..d8c9dc998d 100644 --- a/tests/extensions/Async/Eventually.php +++ b/tests/extensions/Async/Eventually.php @@ -11,7 +11,7 @@ final class Eventually extends Constraint { } - public function evaluate(mixed $probe, string $description = '', bool $returnResult = false): ?bool + public function evaluate(mixed $probe, string $description = '', bool $returnResult = false): bool { if (!is_callable($probe)) { throw new \Exception('Probe must be a callable'); diff --git a/tests/extensions/RetrySubscriber.php b/tests/extensions/RetrySubscriber.php index 08623dc261..ff09b187d4 100644 --- a/tests/extensions/RetrySubscriber.php +++ b/tests/extensions/RetrySubscriber.php @@ -16,13 +16,6 @@ class RetrySubscriber implements FailedSubscriber */ private static array $retryCounts = []; - /** - * Track tests that should be retried - * - * @var array - */ - private static array $pendingRetries = []; - public function notify(Failed $event): void { $this->handleTestFailure($event->test(), $event->throwable()->asString()); @@ -98,6 +91,5 @@ class RetrySubscriber implements FailedSubscriber public static function reset(): void { self::$retryCounts = []; - self::$pendingRetries = []; } } diff --git a/tests/unit/Messaging/MessagingChannelsTest.php b/tests/unit/Messaging/MessagingChannelsTest.php index fc2d839ca6..af6592ef92 100644 --- a/tests/unit/Messaging/MessagingChannelsTest.php +++ b/tests/unit/Messaging/MessagingChannelsTest.php @@ -203,7 +203,6 @@ class MessagingChannelsTest extends TestCase * Making sure the right clients receive the event. */ $this->assertStringEndsWith($index, $receiverId); - $this->assertIsArray($queryKeys); } } } @@ -240,7 +239,6 @@ class MessagingChannelsTest extends TestCase * Making sure the right clients receive the event. */ $this->assertStringEndsWith($index, $receiverId); - $this->assertIsArray($queryKeys); } } } diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index 4b2474c760..f48be46202 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -147,6 +147,193 @@ class MessagingTest extends TestCase $this->assertEmpty($realtime->subscriptions); } + public function testSubscribeUnionsChannelsAndRoles(): void + { + $realtime = new Realtime(); + + $realtime->subscribe( + '1', + 1, + 'sub-a', + [Role::user(ID::custom('123'))->toString()], + ['documents'], + ); + + $realtime->subscribe( + '1', + 1, + 'sub-b', + [Role::users()->toString()], + ['files'], + ); + + $connection = $realtime->connections[1]; + + $this->assertContains('documents', $connection['channels']); + $this->assertContains('files', $connection['channels']); + $this->assertContains(Role::user(ID::custom('123'))->toString(), $connection['roles']); + $this->assertContains(Role::users()->toString(), $connection['roles']); + $this->assertCount(2, $connection['channels']); + $this->assertCount(2, $connection['roles']); + } + + public function testUnsubscribeSubscriptionRemovesOnlyOneSubscription(): void + { + $realtime = new Realtime(); + + $realtime->subscribe( + '1', + 1, + 'sub-a', + [Role::user(ID::custom('123'))->toString()], + ['documents'], + ); + + $realtime->subscribe( + '1', + 1, + 'sub-b', + [Role::users()->toString()], + ['files'], + ); + + $removed = $realtime->unsubscribeSubscription(1, 'sub-a'); + + $this->assertTrue($removed); + $this->assertArrayHasKey(1, $realtime->connections); + + // sub-a is fully cleaned from the tree + $this->assertArrayNotHasKey( + Role::user(ID::custom('123'))->toString(), + $realtime->subscriptions['1'] + ); + + // sub-b still delivers + $event = [ + 'project' => '1', + 'roles' => [Role::users()->toString()], + 'data' => [ + 'channels' => ['files'], + ], + ]; + $receivers = array_keys($realtime->getSubscribers($event)); + $this->assertEquals([1], $receivers); + + // Channels recomputed: sub-a's channel is gone + $this->assertSame(['files'], $realtime->connections[1]['channels']); + + // Roles are connection-level auth context — union of both subscribe calls preserved + $this->assertContains(Role::user(ID::custom('123'))->toString(), $realtime->connections[1]['roles']); + $this->assertContains(Role::users()->toString(), $realtime->connections[1]['roles']); + } + + public function testUnsubscribeSubscriptionIsIdempotent(): void + { + $realtime = new Realtime(); + + $realtime->subscribe( + '1', + 1, + 'sub-a', + [Role::users()->toString()], + ['documents'], + ); + + $this->assertFalse($realtime->unsubscribeSubscription(1, 'does-not-exist')); + $this->assertFalse($realtime->unsubscribeSubscription(99, 'sub-a')); + + // Original sub is untouched + $event = [ + 'project' => '1', + 'roles' => [Role::users()->toString()], + 'data' => [ + 'channels' => ['documents'], + ], + ]; + $this->assertEquals([1], array_keys($realtime->getSubscribers($event))); + } + + public function testUnsubscribeSubscriptionKeepsConnectionWhenLastSubRemoved(): void + { + $realtime = new Realtime(); + + $realtime->subscribe( + '1', + 1, + 'sub-a', + [Role::users()->toString()], + ['documents'], + ); + + $this->assertTrue($realtime->unsubscribeSubscription(1, 'sub-a')); + + $this->assertArrayHasKey(1, $realtime->connections); + $this->assertSame([], $realtime->connections[1]['channels']); + // Roles preserved so a later resubscribe on the same connection still has auth context + $this->assertSame([Role::users()->toString()], $realtime->connections[1]['roles']); + $this->assertArrayNotHasKey('1', $realtime->subscriptions); + } + + public function testResubscribeAfterUnsubscribingLastSubDelivers(): void + { + $realtime = new Realtime(); + + $realtime->subscribe( + '1', + 1, + 'sub-a', + [Role::users()->toString()], + ['documents'], + ); + + $this->assertTrue($realtime->unsubscribeSubscription(1, 'sub-a')); + + // Simulate the message-based subscribe path reading stored roles + $storedRoles = $realtime->connections[1]['roles']; + $this->assertNotEmpty($storedRoles, 'connection roles must survive per-subscription removal'); + + $realtime->subscribe('1', 1, 'sub-b', $storedRoles, ['files']); + + $event = [ + 'project' => '1', + 'roles' => [Role::users()->toString()], + 'data' => [ + 'channels' => ['files'], + ], + ]; + $this->assertEquals([1], array_keys($realtime->getSubscribers($event))); + } + + public function testSubscribeAfterOnOpenEmptySentinelPreservesUnion(): void + { + $realtime = new Realtime(); + + // Mirrors the onOpen empty-channels path: subscribe with '' id, empty channels + $realtime->subscribe( + '1', + 1, + '', + [Role::users()->toString()], + [], + [], + 'user-123', + ); + + // Now a real subscription comes in via the subscribe message type + $realtime->subscribe( + '1', + 1, + 'sub-a', + [Role::user(ID::custom('user-123'))->toString()], + ['documents'], + ); + + $this->assertSame('user-123', $realtime->connections[1]['userId']); + $this->assertContains('documents', $realtime->connections[1]['channels']); + $this->assertContains(Role::users()->toString(), $realtime->connections[1]['roles']); + $this->assertContains(Role::user(ID::custom('user-123'))->toString(), $realtime->connections[1]['roles']); + } + public function testConvertChannelsGuest(): void { $user = new Document([ diff --git a/tests/unit/Network/Validators/DNSTest.php b/tests/unit/Network/Validators/DNSTest.php index 6e4a78022f..845d01e723 100644 --- a/tests/unit/Network/Validators/DNSTest.php +++ b/tests/unit/Network/Validators/DNSTest.php @@ -33,10 +33,7 @@ class DNSTest extends TestCase $result = $validator->isValid('nonexistent-domain-' . \uniqid() . '.com'); $this->assertEquals(false, $result); - $this->assertIsInt($validator->count); - $this->assertIsString($validator->value); - $this->assertIsArray($validator->records); - $this->assertIsString($validator->getDescription()); + $this->assertNotEmpty($validator->getDescription()); } public function testCoreDNSFailure(): void diff --git a/tests/unit/Platform/Modules/Installer/ModuleTest.php b/tests/unit/Platform/Modules/Installer/ModuleTest.php index 507a4e25f6..87babcfb16 100644 --- a/tests/unit/Platform/Modules/Installer/ModuleTest.php +++ b/tests/unit/Platform/Modules/Installer/ModuleTest.php @@ -157,7 +157,7 @@ class ModuleTest extends TestCase $platform->init(Service::TYPE_HTTP); // If we get here without exceptions, route registration succeeded - $this->assertTrue(true); + $this->addToAssertionCount(1); } public function testModuleHasNoTaskServices(): void @@ -267,14 +267,6 @@ class ModuleTest extends TestCase } } - public function testValidateClassHasCsrfMethod(): void - { - $this->assertTrue( - method_exists(Validate::class, 'validateCsrf'), - 'Validate class should expose validateCsrf method' - ); - } - private function getAction(string $name): Action { $services = $this->module->getServicesByType(Service::TYPE_HTTP); diff --git a/tests/unit/Platform/Modules/Installer/Runtime/StateTest.php b/tests/unit/Platform/Modules/Installer/Runtime/StateTest.php index 6c36e6d732..c8cfd6d884 100644 --- a/tests/unit/Platform/Modules/Installer/Runtime/StateTest.php +++ b/tests/unit/Platform/Modules/Installer/Runtime/StateTest.php @@ -19,14 +19,7 @@ class StateTest extends TestCase $this->tempDir = sys_get_temp_dir() . '/appwrite-installer-test-' . uniqid(); mkdir($this->tempDir, 0755, true); - $root = dirname(__DIR__, 6); - $this->state = new State([ - 'public' => $root . '/public', - 'init' => $root . '/app/init.php', - 'views' => $root . '/app/views/install', - 'vendor' => $root . '/vendor/autoload.php', - 'installPhp' => $root . '/src/Appwrite/Platform/Tasks/Install.php', - ]); + $this->state = new State(); // Preserve env state $env = getenv('APPWRITE_INSTALLER_CONFIG'); @@ -273,7 +266,6 @@ class StateTest extends TestCase public function testReadProgressFileReturnsDefaultForMissing(): void { $data = $this->state->readProgressFile('nonexistent-id-' . uniqid()); - $this->assertIsArray($data); $this->assertArrayHasKey('installId', $data); $this->assertArrayHasKey('steps', $data); $this->assertEmpty($data['steps']); @@ -291,7 +283,6 @@ class StateTest extends TestCase ]); $data = $this->state->readProgressFile($installId); - $this->assertIsArray($data); $this->assertArrayHasKey('steps', $data); $this->assertArrayHasKey(Server::STEP_ENV_VARS, $data['steps']); $this->assertEquals(Server::STATUS_IN_PROGRESS, $data['steps'][Server::STEP_ENV_VARS]['status']); @@ -604,7 +595,6 @@ class StateTest extends TestCase file_put_contents($path, 'not valid json {{{'); $data = $this->state->readProgressFile($installId); - $this->assertIsArray($data); $this->assertArrayHasKey('installId', $data); $this->assertArrayHasKey('steps', $data); $this->assertEmpty($data['steps']); @@ -618,7 +608,6 @@ class StateTest extends TestCase file_put_contents($path, ''); $data = $this->state->readProgressFile($installId); - $this->assertIsArray($data); $this->assertArrayHasKey('installId', $data); $this->assertEmpty($data['steps']); } @@ -631,7 +620,6 @@ class StateTest extends TestCase file_put_contents($path, '"just a string"'); $data = $this->state->readProgressFile($installId); - $this->assertIsArray($data); $this->assertEmpty($data['steps']); } diff --git a/tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php b/tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php index c453dcade4..0a360783ac 100644 --- a/tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php +++ b/tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php @@ -22,7 +22,6 @@ class AppDomainTest extends TestCase public function testDescription(): void { $this->assertNotEmpty($this->validator->getDescription()); - $this->assertIsString($this->validator->getDescription()); } public function testIsArray(): void diff --git a/tests/unit/URL/URLTest.php b/tests/unit/URL/URLTest.php index ceca1c6304..597d77f74c 100644 --- a/tests/unit/URL/URLTest.php +++ b/tests/unit/URL/URLTest.php @@ -11,7 +11,6 @@ class URLTest extends TestCase { $url = URL::parse('https://appwrite.io:8080/path?query=string¶m=value'); - $this->assertIsArray($url); $this->assertEquals('https', $url['scheme']); $this->assertEquals('appwrite.io', $url['host']); $this->assertEquals('8080', $url['port']); @@ -20,7 +19,6 @@ class URLTest extends TestCase $url = URL::parse('https://appwrite.io'); - $this->assertIsArray($url); $this->assertEquals('https', $url['scheme']); $this->assertEquals('appwrite.io', $url['host']); $this->assertEquals(null, $url['port']); @@ -29,7 +27,6 @@ class URLTest extends TestCase $url = URL::parse('appwrite-callback-project://'); - $this->assertIsArray($url); $this->assertEquals('appwrite-callback-project', $url['scheme']); $this->assertEquals('', $url['host']); $this->assertEquals(null, $url['port']); @@ -47,7 +44,6 @@ class URLTest extends TestCase 'query' => 'query=string¶m=value', ]); - $this->assertIsString($url); $this->assertEquals('https://appwrite.io:8080/path?query=string¶m=value', $url); $url = URL::unparse([ @@ -58,7 +54,6 @@ class URLTest extends TestCase 'query' => 'query=string¶m=value', ]); - $this->assertIsString($url); $this->assertEquals('https://appwrite.io/path?query=string¶m=value', $url); $url = URL::unparse([ @@ -69,7 +64,6 @@ class URLTest extends TestCase 'query' => '', ]); - $this->assertIsString($url); $this->assertEquals('https://appwrite.io/', $url); $url = URL::unparse([ @@ -80,7 +74,6 @@ class URLTest extends TestCase 'fragment' => 'bottom', ]); - $this->assertIsString($url); $this->assertEquals('https://appwrite.io/#bottom', $url); $url = URL::unparse([ @@ -93,7 +86,6 @@ class URLTest extends TestCase 'fragment' => 'bottom', ]); - $this->assertIsString($url); $this->assertEquals('https://eldad:fux@appwrite.io/#bottom', $url); $url = URL::unparse([ @@ -106,7 +98,6 @@ class URLTest extends TestCase 'fragment' => '', ]); - $this->assertIsString($url); $this->assertEquals('https://appwrite.io/#', $url); } @@ -114,7 +105,6 @@ class URLTest extends TestCase { $result = URL::parseQuery('param1=value1¶m2=value2'); - $this->assertIsArray($result); $this->assertEquals(['param1' => 'value1', 'param2' => 'value2'], $result); } @@ -122,7 +112,6 @@ class URLTest extends TestCase { $result = URL::unparseQuery(['param1' => 'value1', 'param2' => 'value2']); - $this->assertIsString($result); $this->assertEquals('param1=value1¶m2=value2', $result); } } diff --git a/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php b/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php index f7d73eb287..d5507327be 100644 --- a/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php +++ b/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php @@ -659,7 +659,7 @@ class RuntimeQueryTest extends TestCase $query = Query::select(['*']); // Should not throw RuntimeQuery::validateSelectQuery($query); - $this->assertTrue(true); + $this->addToAssertionCount(1); } public function testValidateSelectQueryWithSpecificFields(): void @@ -694,7 +694,7 @@ class RuntimeQueryTest extends TestCase $query = Query::equal('name', ['John']); // Should not throw for non-select queries RuntimeQuery::validateSelectQuery($query); - $this->assertTrue(true); + $this->addToAssertionCount(1); } // Filter tests with select("*") diff --git a/tests/unit/Utopia/RequestTest.php b/tests/unit/Utopia/RequestTest.php index d5cd5d800a..81e0ead4b3 100644 --- a/tests/unit/Utopia/RequestTest.php +++ b/tests/unit/Utopia/RequestTest.php @@ -23,7 +23,6 @@ class RequestTest extends TestCase public function testFilters(): void { $this->assertFalse($this->request->hasFilters()); - $this->assertIsArray($this->request->getFilters()); $this->assertEmpty($this->request->getFilters()); $this->request->addFilter(new First()); diff --git a/tests/unit/Utopia/ResponseTest.php b/tests/unit/Utopia/ResponseTest.php index be8cfdc216..f5a30a5500 100644 --- a/tests/unit/Utopia/ResponseTest.php +++ b/tests/unit/Utopia/ResponseTest.php @@ -26,7 +26,6 @@ class ResponseTest extends TestCase public function testFilters(): void { $this->assertFalse($this->response->hasFilters()); - $this->assertIsArray($this->response->getFilters()); $this->assertEmpty($this->response->getFilters()); $this->response->addFilter(new First());