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 8e01839ac6..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,80 +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 .env
- LATEST_TAG=$(curl -fsSL -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" https://api.github.com/repos/appwrite/appwrite/releases/latest | jq -r .tag_name)
- echo "Latest release tag: $LATEST_TAG"
- curl -fsSL "https://raw.githubusercontent.com/appwrite/appwrite/${LATEST_TAG}/docker-compose.yml" -o docker-compose.yml
- curl -fsSL "https://raw.githubusercontent.com/appwrite/appwrite/${LATEST_TAG}/.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/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 50b0cb3dfc..33c7ffc9de 100644
--- a/app/config/roles.php
+++ b/app/config/roles.php
@@ -55,6 +55,9 @@ $admins = [
'tables.write',
'platforms.read',
'platforms.write',
+ 'mocks.read',
+ 'mocks.write',
+ 'policies.read',
'policies.write',
'templates.read',
'templates.write',
diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php
index c5fba3ed2b..592e032ba1 100644
--- a/app/config/scopes/project.php
+++ b/app/config/scopes/project.php
@@ -204,6 +204,18 @@ 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",
diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php
index bd5d0504cf..cf920b695f 100644
--- a/app/controllers/api/projects.php
+++ b/app/controllers/api/projects.php
@@ -1,14 +1,10 @@
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'])
@@ -130,63 +109,11 @@ Http::patch('/v1/projects/:projectId/oauth2')
$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'] ?? '';
-
- 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);
- });
-
+// 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')
@@ -216,92 +143,6 @@ 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);
- });
-
// Backwards compatibility
Http::delete('/v1/projects/:projectId/templates/email')
->alias('/v1/projects/:projectId/templates/email/:type/:locale')
diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php
index 8b8c7ee066..7c2f527ccf 100644
--- a/app/controllers/shared/api.php
+++ b/app/controllers/shared/api.php
@@ -44,7 +44,7 @@ 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) {
$find = $matches[0][$pos];
@@ -59,6 +59,7 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar
$params = match ($namespace) {
'user' => (array) $user,
+ 'project' => $project->getArrayCopy(),
'request' => $requestParams,
default => $responsePayload,
};
@@ -903,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;
}
@@ -976,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/models.php b/app/init/models.php
index f654c10121..b713d61cd2 100644
--- a/app/init/models.php
+++ b/app/init/models.php
@@ -112,6 +112,16 @@ use Appwrite\Utopia\Response\Model\PlatformLinux;
use Appwrite\Utopia\Response\Model\PlatformList;
use Appwrite\Utopia\Response\Model\PlatformWeb;
use Appwrite\Utopia\Response\Model\PlatformWindows;
+use Appwrite\Utopia\Response\Model\PolicyList;
+use Appwrite\Utopia\Response\Model\PolicyMembershipPrivacy;
+use Appwrite\Utopia\Response\Model\PolicyPasswordDictionary;
+use Appwrite\Utopia\Response\Model\PolicyPasswordHistory;
+use Appwrite\Utopia\Response\Model\PolicyPasswordPersonalData;
+use Appwrite\Utopia\Response\Model\PolicySessionAlert;
+use Appwrite\Utopia\Response\Model\PolicySessionDuration;
+use Appwrite\Utopia\Response\Model\PolicySessionInvalidation;
+use Appwrite\Utopia\Response\Model\PolicySessionLimit;
+use Appwrite\Utopia\Response\Model\PolicyUserLimit;
use Appwrite\Utopia\Response\Model\Preferences;
use Appwrite\Utopia\Response\Model\Project;
use Appwrite\Utopia\Response\Model\Provider;
@@ -210,6 +220,9 @@ Response::setModel(new BaseList('Currencies List', Response::MODEL_CURRENCY_LIST
Response::setModel(new BaseList('Phones List', Response::MODEL_PHONE_LIST, 'phones', Response::MODEL_PHONE));
Response::setModel(new BaseList('Metric List', Response::MODEL_METRIC_LIST, 'metrics', Response::MODEL_METRIC, true, false));
Response::setModel(new BaseList('Variables List', Response::MODEL_VARIABLE_LIST, 'variables', Response::MODEL_VARIABLE));
+Response::setModel(new BaseList('Mock Numbers List', Response::MODEL_MOCK_NUMBER_LIST, 'mockNumbers', Response::MODEL_MOCK_NUMBER));
+Response::setModel(new PolicyList());
+Response::setModel(new BaseList('Email Templates List', Response::MODEL_EMAIL_TEMPLATE_LIST, 'templates', Response::MODEL_EMAIL_TEMPLATE));
Response::setModel(new BaseList('Status List', Response::MODEL_HEALTH_STATUS_LIST, 'statuses', Response::MODEL_HEALTH_STATUS));
Response::setModel(new BaseList('Rule List', Response::MODEL_PROXY_RULE_LIST, 'rules', Response::MODEL_PROXY_RULE));
Response::setModel(new BaseList('Schedules List', Response::MODEL_SCHEDULE_LIST, 'schedules', Response::MODEL_SCHEDULE));
@@ -337,6 +350,15 @@ Response::setModel(new Webhook());
Response::setModel(new Key());
Response::setModel(new DevKey());
Response::setModel(new MockNumber());
+Response::setModel(new PolicyPasswordDictionary());
+Response::setModel(new PolicyPasswordHistory());
+Response::setModel(new PolicyPasswordPersonalData());
+Response::setModel(new PolicySessionAlert());
+Response::setModel(new PolicySessionDuration());
+Response::setModel(new PolicySessionInvalidation());
+Response::setModel(new PolicySessionLimit());
+Response::setModel(new PolicyUserLimit());
+Response::setModel(new PolicyMembershipPrivacy());
Response::setModel(new AuthProvider());
Response::setModel(new PlatformWeb());
Response::setModel(new PlatformApple());
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/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/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/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/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/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/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/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 331ad9482e..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,6 +29,7 @@ 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;
@@ -31,12 +39,14 @@ use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionDuration\Upda
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;
@@ -54,6 +64,7 @@ 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());
@@ -63,6 +74,7 @@ class Http extends Service
$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());
@@ -95,7 +107,16 @@ class Http extends Service
$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());
@@ -105,5 +126,8 @@ class Http extends Service
$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/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/Teams/Http/Memberships/Get.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php
index d146684a20..ef8d130855 100644
--- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php
+++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php
@@ -70,12 +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'] ?? false,
- 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? false,
- 'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? false,
- 'userId' => $project->getAttribute('auths', [])['membershipsUserId'] ?? false,
- 'userPhone' => $project->getAttribute('auths', [])['membershipsUserPhone'] ?? false,
+ '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();
diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php
index 70b78e02c6..7835c8051f 100644
--- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php
+++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php
@@ -123,12 +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'] ?? false,
- 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? false,
- 'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? false,
- 'userId' => $project->getAttribute('auths', [])['membershipsUserId'] ?? false,
- 'userPhone' => $project->getAttribute('auths', [])['membershipsUserPhone'] ?? false,
+ '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();
diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php
index 52ad64d975..cfe8d2d567 100644
--- a/src/Appwrite/Platform/Workers/Migrations.php
+++ b/src/Appwrite/Platform/Workers/Migrations.php
@@ -391,6 +391,9 @@ class Migrations extends Action
'keys.write',
'platforms.read',
'platforms.write',
+ 'mocks.read',
+ 'mocks.write',
+ 'policies.read',
'policies.write',
'templates.read',
'templates.write',
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/Filters/V20.php b/src/Appwrite/Utopia/Request/Filters/V20.php
index a290656b6e..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
{
@@ -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
index b10c26c449..e509900417 100644
--- a/src/Appwrite/Utopia/Request/Filters/V23.php
+++ b/src/Appwrite/Utopia/Request/Filters/V23.php
@@ -32,6 +32,9 @@ class V23 extends Filter
case 'project.updateSessionLimitPolicy':
$content = $this->parseLimitToTotal($content);
break;
+ case 'project.updateAuthMethod':
+ $content = $this->parseUpdateAuthMethod($content);
+ break;
}
return $content;
@@ -60,6 +63,21 @@ class V23 extends Filter
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'])) {
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/V23.php b/src/Appwrite/Utopia/Response/Filters/V23.php
index 51d223de37..cd8ce44c0a 100644
--- a/src/Appwrite/Utopia/Response/Filters/V23.php
+++ b/src/Appwrite/Utopia/Response/Filters/V23.php
@@ -16,10 +16,24 @@ class V23 extends Filter
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']);
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/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/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php
index 86d7de8849..f531ed774d 100644
--- a/tests/e2e/Scopes/ProjectCustom.php
+++ b/tests/e2e/Scopes/ProjectCustom.php
@@ -169,6 +169,9 @@ 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/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php
index c96676b598..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,
@@ -3695,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/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/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
index 84f5938d3e..04906c6c2b 100644
--- a/tests/e2e/Services/Project/PoliciesBase.php
+++ b/tests/e2e/Services/Project/PoliciesBase.php
@@ -3,9 +3,264 @@
namespace Tests\E2E\Services\Project;
use Tests\E2E\Client;
+use Utopia\Database\Query;
trait PoliciesBase
{
+ // =========================================================================
+ // Get Policy
+ // =========================================================================
+
+ public function testGetPolicy(): void
+ {
+ $expectedFields = [
+ 'password-dictionary' => ['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
// =========================================================================
@@ -842,6 +1097,26 @@ trait PoliciesBase
]);
}
+ 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), [
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
index 4bdf073e19..748fb3502b 100644
--- a/tests/e2e/Services/Project/SMTPBase.php
+++ b/tests/e2e/Services/Project/SMTPBase.php
@@ -2,11 +2,31 @@
namespace Tests\E2E\Services\Project;
+use PHPUnit\Framework\Attributes\Before;
use Tests\E2E\Client;
use Utopia\Database\Helpers\ID;
trait SMTPBase
{
+ // The ProjectCustom trait reuses the same project across tests in a class.
+ // Since the SMTP PATCH endpoint is additive (unset fields are preserved),
+ // state leaks across tests. Reset to a known-good, maildev-compatible
+ // configuration before each test so tests that don't specify credentials
+ // still connect cleanly.
+ #[Before(priority: -1)]
+ protected function resetProjectSMTP(): void
+ {
+ $this->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
diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php
index d415c3267d..b240c945b3 100644
--- a/tests/e2e/Services/Project/TemplatesBase.php
+++ b/tests/e2e/Services/Project/TemplatesBase.php
@@ -4,6 +4,7 @@ namespace Tests\E2E\Services\Project;
use Tests\E2E\Client;
use Utopia\Database\Helpers\ID;
+use Utopia\Database\Query;
trait TemplatesBase
{
@@ -579,6 +580,295 @@ trait TemplatesBase
}
}
+ // 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
@@ -804,6 +1094,28 @@ trait TemplatesBase
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,
diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php
index ed72d9375c..f88db41e8c 100644
--- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php
+++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php
@@ -1764,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,
]);
@@ -1860,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,
]);
@@ -2634,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([
@@ -4053,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/Sites/SitesCustomServerTest.php b/tests/e2e/Services/Sites/SitesCustomServerTest.php
index 59727b8d22..71f6675561 100644
--- a/tests/e2e/Services/Sites/SitesCustomServerTest.php
+++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php
@@ -866,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([
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();