Compare commits

..
20 Commits
Author SHA1 Message Date
semantic-release-bot d3ec2e2be7 chore(release): 6.5.2 [skip ci]
## [6.5.2](https://github.com/parse-community/parse-server/compare/6.5.1...6.5.2) (2024-03-02)

### Bug Fixes

* Security upgrade @parse/push-adapter from 5.1.0 to 5.1.1 ([#8975](https://github.com/parse-community/parse-server/issues/8975)) ([0fa0aab](https://github.com/parse-community/parse-server/commit/0fa0aabefe6bc9d356ee70be78dafc5fa22d4e17))
2024-03-02 21:48:11 +00:00
Parse Platform 0fa0aabefe fix: Security upgrade @parse/push-adapter from 5.1.0 to 5.1.1 (#8975) 2024-03-02 22:47:16 +01:00
semantic-release-bot 46761d3ae2 chore(release): 6.5.1 [skip ci]
## [6.5.1](https://github.com/parse-community/parse-server/compare/6.5.0...6.5.1) (2024-03-02)

### Bug Fixes

* Security upgrade @parse/push-adapter from 5.0.2 to 5.1.0 ([#8972](https://github.com/parse-community/parse-server/issues/8972)) ([bba24dd](https://github.com/parse-community/parse-server/commit/bba24dd8279ebb8e4084a5f00fbe3ab9fe6c01b4))
2024-03-02 20:43:02 +00:00
Parse Platform bba24dd827 fix: Security upgrade @parse/push-adapter from 5.0.2 to 5.1.0 (#8972) 2024-03-02 21:42:04 +01:00
Manuel 30258be121 docs: Remove incorrect change log entries (#8963) 2024-03-01 17:14:29 +01:00
semantic-release-bot 5f9a27fb8e chore(release): 6.5.0 [skip ci]
# [6.5.0](https://github.com/parse-community/parse-server/compare/6.4.0...6.5.0) (2024-03-01)

### Bug Fixes

* Context not passed to Cloud Code Trigger `beforeFind` when using `Parse.Query.include` ([#8765](https://github.com/parse-community/parse-server/issues/8765)) ([7d32d89](https://github.com/parse-community/parse-server/commit/7d32d8934f3ae7af7a7d8b9cc6a829c7d73973d3))
* Improve PostgreSQL injection detection; fixes security vulnerability [GHSA-6927-3vr9-fxf2](https://github.com/parse-community/parse-server/security/advisories/GHSA-6927-3vr9-fxf2) which affects Parse Server deployments using a Postgres database ([#8960](https://github.com/parse-community/parse-server/issues/8960)) ([a6e6549](https://github.com/parse-community/parse-server/commit/a6e654943536932904a69b51e513507fcf90a504))
* Parse Server option `fileUpload.fileExtensions` fails to determine file extension if filename contains multiple dots ([#8754](https://github.com/parse-community/parse-server/issues/8754)) ([3d6d50e](https://github.com/parse-community/parse-server/commit/3d6d50e0afff18b95fb906914e2cebd3839b517a))
* Security bump @babel/traverse from 7.20.5 to 7.23.2 ([#8777](https://github.com/parse-community/parse-server/issues/8777)) ([2d6b3d1](https://github.com/parse-community/parse-server/commit/2d6b3d18499179e99be116f25c0850d3f449509c))
* Security upgrade graphql from 16.6.0 to 16.8.1 ([#8758](https://github.com/parse-community/parse-server/issues/8758)) ([71dfd8a](https://github.com/parse-community/parse-server/commit/71dfd8a7ece8c0dd1a66d03bb9420cfd39f4f9b1))

### Features

* Add `$setOnInsert` operator to `Parse.Server.database.update` ([#8791](https://github.com/parse-community/parse-server/issues/8791)) ([f630a45](https://github.com/parse-community/parse-server/commit/f630a45aa5e87bc73a81fded061400c199b71a29))
* Add compatibility for MongoDB Atlas Serverless and AWS Amazon DocumentDB with collation options `enableCollationCaseComparison`, `transformEmailToLowercase`, `transformUsernameToLowercase` ([#8805](https://github.com/parse-community/parse-server/issues/8805)) ([09fbeeb](https://github.com/parse-community/parse-server/commit/09fbeebba8870e7cf371fb84371a254c7b368620))
* Add context to Cloud Code Triggers `beforeLogin` and `afterLogin` ([#8724](https://github.com/parse-community/parse-server/issues/8724)) ([a9c34ef](https://github.com/parse-community/parse-server/commit/a9c34ef1e2c78a42fb8b5fa8d569b7677c74919d))
* Allow setting `createdAt` and `updatedAt` during `Parse.Object` creation with maintenance key ([#8696](https://github.com/parse-community/parse-server/issues/8696)) ([77bbfb3](https://github.com/parse-community/parse-server/commit/77bbfb3f186f5651c33ba152f04cff95128eaf2d))
* Upgrade Parse Server Push Adapter to 5.0.2 ([#8813](https://github.com/parse-community/parse-server/issues/8813)) ([6ef1986](https://github.com/parse-community/parse-server/commit/6ef1986c03a1d84b7e11c05851e5bf9688d88740))

### Performance Improvements

* Improved IP validation performance for `masterKeyIPs`, `maintenanceKeyIPs` ([#8510](https://github.com/parse-community/parse-server/issues/8510)) ([b87daba](https://github.com/parse-community/parse-server/commit/b87daba0671a1b0b7b8d63bc671d665c91a04522))
2024-03-01 16:04:36 +00:00
Manuel 297faaece4 ci: Fix incorrect release branch config (#8962) 2024-03-01 17:03:43 +01:00
Manuel a6e6549435 fix: Improve PostgreSQL injection detection; fixes security vulnerability [GHSA-6927-3vr9-fxf2](https://github.com/parse-community/parse-server/security/advisories/GHSA-6927-3vr9-fxf2) which affects Parse Server deployments using a Postgres database (#8960) 2024-03-01 16:51:02 +01:00
Parse Platform 244e3431cf refactor: Upgrade redis from 4.6.12 to 4.6.13 (#8955) 2024-02-27 14:42:47 +01:00
Parse Platform 33c648dc81 refactor: Upgrade uuid from 9.0.0 to 9.0.1 (#8943) 2024-02-26 01:22:37 +01:00
Parse Platform 4524c35d51 refactor: Upgrade follow-redirects from 1.15.2 to 1.15.5 (#8931) 2024-02-24 17:30:05 +01:00
Parse Platform 70e0cb3744 refactor: Upgrade jwks-rsa from 2.1.5 to 3.1.0 (#8932) 2024-02-24 12:22:00 +01:00
Parse Platform 519dee9b89 refactor: Upgrade winston from 3.8.2 to 3.11.0 (#8933) 2024-02-23 18:18:24 +01:00
Parse Platform 897acb76a5 refactor: Upgrade semver from 7.5.2 to 7.5.4 (#8934) 2024-02-22 13:05:39 +01:00
Parse Platform e5de9daa18 refactor: Upgrade @parse/fs-files-adapter from 1.2.2 to 2.0.1 (#8930) 2024-02-20 18:17:11 +01:00
Parse Platform 223fde0f31 refactor: Upgrade pg-promise from 11.5.0 to 11.5.4 (#8924) 2024-02-17 00:41:11 +01:00
Parse Platform d0a5af33ca refactor: Upgrade otpauth from 9.1.2 to 9.2.2 (#8923) 2024-02-17 00:10:26 +01:00
Parse Platform 8fe0ae7a2c refactor: Upgrade ws from 8.13.0 to 8.16.0 (#8921) 2024-02-15 23:54:05 +01:00
Parse Platform 5179501885 refactor: Upgrade redis from 4.6.6 to 4.6.12 (#8922) 2024-02-15 19:35:22 +01:00
Parse Platform 933e8226df refactor: Upgrade lru-cache from 9.1.1 to 10.1.0 (#8870) 2024-01-08 12:15:33 +01:00
324 changed files with 29063 additions and 59479 deletions
+3 -3
View File
@@ -1,12 +1,12 @@
{
"plugins": [
"@babel/plugin-transform-flow-strip-types"
"@babel/plugin-transform-flow-strip-types",
"@babel/plugin-proposal-object-rest-spread"
],
"presets": [
"@babel/preset-typescript",
["@babel/preset-env", {
"targets": {
"node": "18"
"node": "14",
},
"exclude": ["proposal-dynamic-import"]
}]
+3
View File
@@ -0,0 +1,3 @@
lib
coverage
out
+32
View File
@@ -0,0 +1,32 @@
{
"root": true,
"extends": "eslint:recommended",
"env": {
"node": true,
"es6": true
},
"parser": "@babel/eslint-parser",
"plugins": [
"flowtype"
],
"parserOptions": {
"ecmaVersion": 6,
"sourceType": "module",
"requireConfigFile": false
},
"rules": {
"indent": ["error", 2, { "SwitchCase": 1 }],
"linebreak-style": ["error", "unix"],
"no-trailing-spaces": 2,
"eol-last": 2,
"space-in-parens": ["error", "never"],
"no-multiple-empty-lines": 1,
"prefer-const": "error",
"space-infix-ops": "error",
"no-useless-escape": "off",
"require-atomic-updates": "off"
},
"globals": {
"Parse": true
}
}
@@ -8,10 +8,16 @@ assignees: ''
---
### New Issue Checklist
<!--
Check every following box [x] before submitting your issue.
Click the "Preview" tab for better readability.
Thanks for contributing to Parse Platform!
-->
- Report security issues [confidentially](https://github.com/parse-community/parse-server/security/policy).
- Any contribution is under this [license](https://github.com/parse-community/parse-server/blob/alpha/LICENSE).
- Before posting search [existing issues](https://github.com/parse-community/parse-server/issues?q=is%3Aissue).
- [ ] I am not disclosing a [vulnerability](https://github.com/parse-community/parse-server/blob/master/SECURITY.md).
- [ ] I am not just asking a [question](https://github.com/parse-community/.github/blob/master/SUPPORT.md).
- [ ] I have searched through [existing issues](https://github.com/parse-community/parse-server/issues?q=is%3Aissue).
- [ ] I can reproduce the issue with the [latest version of Parse Server](https://github.com/parse-community/parse-server/releases). <!-- We don't investigate issues for outdated releases. -->
### Issue Description
<!-- What is the specific issue with Parse Server? -->
@@ -24,7 +30,6 @@ assignees: ''
### Expected Outcome
<!-- What outcome, for example query result, did you expect? -->
### Environment
<!-- Be specific with versions, don't use "latest" or semver ranges like "~x.y.z" or "^x.y.z". -->
@@ -8,10 +8,15 @@ assignees: ''
---
### New Feature / Enhancement Checklist
<!--
Check every following box [x] before submitting your issue.
Click the "Preview" tab for better readability.
Thanks for contributing to Parse Platform!
-->
- Report security issues [confidentially](https://github.com/parse-community/parse-server/security/policy).
- Any contribution is under this [license](https://github.com/parse-community/parse-server/blob/alpha/LICENSE).
- Before posting search [existing issues](https://github.com/parse-community/parse-server/issues?q=is%3Aissue).
- [ ] I am not disclosing a [vulnerability](https://github.com/parse-community/parse-server/blob/master/SECURITY.md).
- [ ] I am not just asking a [question](https://github.com/parse-community/.github/blob/master/SUPPORT.md).
- [ ] I have searched through [existing issues](https://github.com/parse-community/parse-server/issues?q=is%3Aissue).
### Current Limitation
<!-- Which current limitation is the feature or enhancement addressing? -->
-14
View File
@@ -1,14 +0,0 @@
# Dependabot dependency updates
# Docs: https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "npm"
# Location of package-lock.json
directory: "/"
# Check daily for updates
schedule:
interval: "daily"
commit-message:
# Set commit message prefix
prefix: "refactor"
+5 -2
View File
@@ -2,15 +2,18 @@
- Report security issues [confidentially](https://github.com/parse-community/parse-server/security/policy).
- Any contribution is under this [license](https://github.com/parse-community/parse-server/blob/alpha/LICENSE).
- Link this pull request to an [issue](https://github.com/parse-community/parse-server/issues?q=is%3Aissue).
## Issue
<!-- Describe or link the issue that this PR closes. -->
<!-- Add the link to the issue that this PR closes. -->
Closes: FILL_THIS_OUT
## Approach
<!-- Describe the changes in this PR. -->
## Tasks
<!-- Check completed tasks and delete tasks that don't apply. -->
<!-- Delete tasks that don't apply. -->
- [ ] Add tests
- [ ] Add changes to documentation (guides, repository pages, code comments)
@@ -13,12 +13,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout default branch
uses: actions/checkout@v4
uses: actions/checkout@v2
- name: Setup Node
uses: actions/setup-node@v2
with:
node-version: 20
cache: 'npm'
node-version: 14
- name: Cache Node.js modules
uses: actions/cache@v2
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: npm ci
- name: CI Environments Check
@@ -30,7 +36,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout default branch
uses: actions/checkout@v4
uses: actions/checkout@v2
- name: Compose branch name for PR
id: branch
run: echo "::set-output name=name::ci-bump-environment"
-274
View File
@@ -1,274 +0,0 @@
name: ci-performance
on:
pull_request:
branches:
- alpha
- beta
- release
- 'release-[0-9]+.x.x'
- next-major
paths-ignore:
- '**.md'
- 'docs/**'
env:
NODE_VERSION: 24.11.0
MONGODB_VERSION: 8.0.4
permissions:
contents: read
jobs:
performance-check:
name: Benchmarks
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout PR branch (for benchmark script)
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 1
- name: Save PR benchmark script
run: |
mkdir -p /tmp/pr-benchmark
cp -r benchmark /tmp/pr-benchmark/ || echo "No benchmark directory"
cp package.json /tmp/pr-benchmark/ || true
- name: Checkout base branch
uses: actions/checkout@v4
with:
ref: ${{ github.base_ref }}
fetch-depth: 1
clean: true
- name: Restore PR benchmark script
run: |
if [ -d "/tmp/pr-benchmark/benchmark" ]; then
rm -rf benchmark
cp -r /tmp/pr-benchmark/benchmark .
fi
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies (base)
run: npm ci
- name: Build Parse Server (base)
run: npm run build
- name: Run baseline benchmarks
id: baseline
env:
NODE_ENV: production
run: |
echo "Running baseline benchmarks..."
if [ ! -f "benchmark/performance.js" ]; then
echo "⚠️ Benchmark script not found - this is expected for new features"
echo "Skipping baseline benchmark"
echo '[]' > baseline.json
echo "Baseline: N/A (no benchmark script)" > baseline-output.txt
exit 0
fi
taskset -c 0 npm run benchmark > baseline-output.txt 2>&1 || npm run benchmark > baseline-output.txt 2>&1 || true
echo "Benchmark command completed with exit code: $?"
echo "Output file size: $(wc -c < baseline-output.txt) bytes"
echo "--- Begin baseline-output.txt ---"
cat baseline-output.txt
echo "--- End baseline-output.txt ---"
# Extract JSON from output (everything between first [ and last ])
sed -n '/^\[/,/^\]/p' baseline-output.txt > baseline.json || echo '[]' > baseline.json
echo "Extracted JSON size: $(wc -c < baseline.json) bytes"
echo "Baseline benchmark results:"
cat baseline.json
continue-on-error: true
- name: Save baseline results to temp location
run: |
mkdir -p /tmp/benchmark-results
cp baseline.json /tmp/benchmark-results/ || echo '[]' > /tmp/benchmark-results/baseline.json
cp baseline-output.txt /tmp/benchmark-results/ || echo 'No baseline output' > /tmp/benchmark-results/baseline-output.txt
- name: Upload baseline results
uses: actions/upload-artifact@v4
with:
name: baseline-benchmark
path: |
/tmp/benchmark-results/baseline.json
/tmp/benchmark-results/baseline-output.txt
retention-days: 7
- name: Checkout PR branch
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 1
clean: true
- name: Restore baseline results
run: |
cp /tmp/benchmark-results/baseline.json ./ || echo '[]' > baseline.json
cp /tmp/benchmark-results/baseline-output.txt ./ || echo 'No baseline output' > baseline-output.txt
- name: Setup Node.js (PR)
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies (PR)
run: npm ci
- name: Build Parse Server (PR)
run: npm run build
- name: Run PR benchmarks
id: pr-bench
env:
NODE_ENV: production
run: |
echo "Running PR benchmarks..."
taskset -c 0 npm run benchmark > pr-output.txt 2>&1 || npm run benchmark > pr-output.txt 2>&1 || true
echo "Benchmark command completed with exit code: $?"
echo "Output file size: $(wc -c < pr-output.txt) bytes"
echo "--- Begin pr-output.txt ---"
cat pr-output.txt
echo "--- End pr-output.txt ---"
# Extract JSON from output (everything between first [ and last ])
sed -n '/^\[/,/^\]/p' pr-output.txt > pr.json || echo '[]' > pr.json
echo "Extracted JSON size: $(wc -c < pr.json) bytes"
echo "PR benchmark results:"
cat pr.json
continue-on-error: true
- name: Upload PR results
uses: actions/upload-artifact@v4
with:
name: pr-benchmark
path: |
pr.json
pr-output.txt
retention-days: 7
- name: Verify benchmark files exist
run: |
echo "Checking for benchmark result files..."
if [ ! -f baseline.json ] || [ ! -s baseline.json ]; then
echo "⚠️ baseline.json is missing or empty, creating empty array"
echo '[]' > baseline.json
fi
if [ ! -f pr.json ] || [ ! -s pr.json ]; then
echo "⚠️ pr.json is missing or empty, creating empty array"
echo '[]' > pr.json
fi
echo "baseline.json size: $(wc -c < baseline.json) bytes"
echo "pr.json size: $(wc -c < pr.json) bytes"
- name: Compare benchmark results
id: compare
run: |
set -o pipefail
node -e "
const fs = require('fs');
let baseline, pr;
try {
baseline = JSON.parse(fs.readFileSync('baseline.json', 'utf8'));
pr = JSON.parse(fs.readFileSync('pr.json', 'utf8'));
} catch (e) {
console.log('⚠️ Could not parse benchmark results');
process.exit(0);
}
// Handle case where baseline doesn't exist (new feature)
if (!Array.isArray(baseline) || baseline.length === 0) {
if (!Array.isArray(pr) || pr.length === 0) {
console.log('⚠️ Benchmark results are empty or invalid');
process.exit(0);
}
console.log('# Performance Benchmark Results\n');
console.log('> ℹ️ Baseline not available - this appears to be a new feature\n');
console.log('| Benchmark | Value | Details |');
console.log('|-----------|-------|---------|');
pr.forEach(result => {
console.log(\`| \${result.name} | \${result.value.toFixed(2)} ms | \${result.extra} |\`);
});
console.log('');
console.log('✅ **New benchmarks established for this feature.**');
process.exit(0);
}
if (!Array.isArray(pr) || pr.length === 0) {
console.log('⚠️ PR benchmark results are empty or invalid');
process.exit(0);
}
console.log('# Performance Comparison\n');
console.log('| Benchmark | Baseline | PR | Change | Status |');
console.log('|-----------|----------|----|---------| ------ |');
let hasRegression = false;
let hasImprovement = false;
baseline.forEach(baseResult => {
const prResult = pr.find(p => p.name === baseResult.name);
if (!prResult) {
console.log(\`| \${baseResult.name} | \${baseResult.value.toFixed(2)} ms | N/A | - | ⚠️ Missing |\`);
return;
}
const baseValue = parseFloat(baseResult.value);
const prValue = parseFloat(prResult.value);
const change = ((prValue - baseValue) / baseValue * 100);
const changeStr = change > 0 ? \`+\${change.toFixed(1)}%\` : \`\${change.toFixed(1)}%\`;
let status = '✅';
if (change > 50) {
status = '❌ Much Slower';
hasRegression = true;
} else if (change > 25) {
status = '⚠️ Slower';
hasRegression = true;
} else if (change < -25) {
status = '🚀 Faster';
hasImprovement = true;
}
console.log(\`| \${baseResult.name} | \${baseValue.toFixed(2)} ms | \${prValue.toFixed(2)} ms | \${changeStr} | \${status} |\`);
});
console.log('');
if (hasRegression) {
console.log('⚠️ **Performance regressions detected.** Please review the changes.');
process.exitCode = 1;
} else if (hasImprovement) {
console.log('🚀 **Performance improvements detected!** Great work!');
} else {
console.log('✅ **No significant performance changes.**');
}
" | tee comparison.md
- name: Upload comparison
uses: actions/upload-artifact@v4
with:
name: benchmark-comparison
path: comparison.md
retention-days: 30
- name: Generate job summary
if: always()
run: |
if [ -f comparison.md ]; then
cat comparison.md >> $GITHUB_STEP_SUMMARY
else
echo "⚠️ Benchmark comparison not available" >> $GITHUB_STEP_SUMMARY
fi
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
+137 -148
View File
@@ -1,17 +1,15 @@
name: ci
on:
push:
branches: [release, alpha, beta, next-major, 'release-[0-9]+.x.x']
branches: [ release, alpha, beta, next-major, 'release-[0-9]+.x.x' ]
pull_request:
branches:
- '**'
paths-ignore:
- '**/**.md'
env:
NODE_VERSION: 24.11.0
NODE_VERSION: 19.3.0
PARSE_SERVER_TEST_TIMEOUT: 20000
permissions:
actions: write
jobs:
check-code-analysis:
name: Code Analysis
@@ -23,45 +21,29 @@ jobs:
strategy:
fail-fast: false
matrix:
language: ['javascript']
language: [ 'javascript' ]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
source-root: src
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
- name: Checkout repository
uses: actions/checkout@v3
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
source-root: src
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
check-ci:
name: Node Engine Check
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.NODE_VERSION }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Install prod dependencies
run: npm ci
- name: Remove dev dependencies
run: ./ci/uninstallDevDeps.sh @actions/core
- name: CI Node Engine Check
run: npm run ci:checkNodeEngine
check-lint:
name: Lint
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.NODE_VERSION }}
uses: actions/setup-node@v4
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- name: Cache Node.js modules
uses: actions/cache@v4
uses: actions/cache@v2
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ matrix.NODE_VERSION }}-${{ hashFiles('**/package-lock.json') }}
@@ -69,19 +51,40 @@ jobs:
${{ runner.os }}-node-${{ matrix.NODE_VERSION }}-
- name: Install dependencies
run: npm ci
- run: npm run lint
- name: CI Node Engine Check
run: npm run ci:checkNodeEngine
check-lint:
name: Lint
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.NODE_VERSION }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- name: Cache Node.js modules
uses: actions/cache@v2
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ matrix.NODE_VERSION }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-${{ matrix.NODE_VERSION }}-
- name: Install dependencies
run: npm ci
- run: npm run lint
check-definitions:
name: Check Definitions
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
name: Check Definitions
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.NODE_VERSION }}
uses: actions/setup-node@v4
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- name: Cache Node.js modules
uses: actions/cache@v4
uses: actions/cache@v2
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ matrix.NODE_VERSION }}-${{ hashFiles('**/package-lock.json') }}
@@ -92,120 +95,98 @@ jobs:
- name: CI Definitions Check
run: npm run ci:definitionsCheck
check-circular:
name: Circular Dependencies
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.NODE_VERSION }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Cache Node.js modules
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ matrix.NODE_VERSION }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-${{ matrix.NODE_VERSION }}-
- name: Install dependencies
run: npm ci
- run: npm run madge:circular
check-docs:
name: Docs
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.NODE_VERSION }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Cache Node.js modules
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ matrix.NODE_VERSION }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-${{ matrix.NODE_VERSION }}-
- name: Install dependencies
run: npm ci
- name: Build source
run: npm run build
- name: Generate docs
run: npm run docs
name: Circular Dependencies
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.NODE_VERSION }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- name: Cache Node.js modules
uses: actions/cache@v2
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ matrix.NODE_VERSION }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-${{ matrix.NODE_VERSION }}-
- name: Install dependencies
run: npm ci
- run: npm run madge:circular
check-docker:
name: Docker Build
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v2
- name: Set up QEMU
id: qemu
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v1
- name: Build docker image
uses: docker/build-push-action@v3
uses: docker/build-push-action@v2
with:
context: .
platforms: linux/amd64, linux/arm64/v8
platforms: linux/amd64
check-lock-file-version:
name: NPM Lock File Version
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
- name: Check NPM lock file version
uses: mansona/npm-lockfile-version@v1
with:
version: 2
check-types:
name: Check Types
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: npm ci
- name: Build types
run: npm run build:types
- name: Test Types
run: npm run test:types
check-mongo:
strategy:
matrix:
include:
- name: MongoDB 7, ReplicaSet
MONGODB_VERSION: 7.0.16
MONGODB_TOPOLOGY: replset
NODE_VERSION: 24.11.0
- name: MongoDB 8, ReplicaSet
MONGODB_VERSION: 8.0.4
MONGODB_TOPOLOGY: replset
NODE_VERSION: 24.11.0
- name: MongoDB 4.2, ReplicaSet
MONGODB_VERSION: 4.2.19
MONGODB_TOPOLOGY: replicaset
NODE_VERSION: 19.3.0
- name: MongoDB 4.4, ReplicaSet
MONGODB_VERSION: 4.4.13
MONGODB_TOPOLOGY: replicaset
NODE_VERSION: 19.3.0
- name: MongoDB 5, ReplicaSet
MONGODB_VERSION: 5.3.2
MONGODB_TOPOLOGY: replicaset
NODE_VERSION: 19.3.0
- name: MongoDB 6, ReplicaSet
MONGODB_VERSION: 6.0.2
MONGODB_TOPOLOGY: replicaset
NODE_VERSION: 19.3.0
- name: Redis Cache
PARSE_SERVER_TEST_CACHE: redis
MONGODB_VERSION: 8.0.4
MONGODB_VERSION: 4.4.13
MONGODB_TOPOLOGY: standalone
NODE_VERSION: 24.11.0
- name: Node 20
MONGODB_VERSION: 8.0.4
NODE_VERSION: 19.3.0
- name: Node 14
MONGODB_VERSION: 4.4.13
MONGODB_TOPOLOGY: standalone
NODE_VERSION: 20.19.0
- name: Node 22
MONGODB_VERSION: 8.0.4
NODE_VERSION: 14.21.1
- name: Node 16
MONGODB_VERSION: 4.4.13
MONGODB_TOPOLOGY: standalone
NODE_VERSION: 22.12.0
NODE_VERSION: 16.18.1
- name: Node 18
MONGODB_VERSION: 4.4.13
MONGODB_TOPOLOGY: standalone
NODE_VERSION: 18.12.1
fail-fast: false
name: ${{ matrix.name }}
timeout-minutes: 20
timeout-minutes: 15
runs-on: ubuntu-latest
services:
redis:
image: redis
ports:
- 6379:6379
- 6379:6379
env:
MONGODB_VERSION: ${{ matrix.MONGODB_VERSION }}
MONGODB_TOPOLOGY: ${{ matrix.MONGODB_TOPOLOGY }}
@@ -215,13 +196,16 @@ jobs:
steps:
- name: Fix usage of insecure GitHub protocol
run: sudo git config --system url."https://github".insteadOf "git://github"
- uses: actions/checkout@v4
- name: Fix git protocol for Node 14
if: ${{ startsWith(matrix.NODE_VERSION, '14.') }}
run: sudo git config --system url."https://github".insteadOf "ssh://git@github"
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.NODE_VERSION }}
uses: actions/setup-node@v4
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.NODE_VERSION }}
- name: Cache Node.js modules
uses: actions/cache@v4
uses: actions/cache@v2
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ matrix.NODE_VERSION }}-${{ hashFiles('**/package-lock.json') }}
@@ -229,31 +213,42 @@ jobs:
${{ runner.os }}-node-${{ matrix.NODE_VERSION }}-
- name: Install dependencies
run: npm ci
- run: npm run coverage:mongodb
- run: npm run pretest
- run: npm run coverage
env:
CI: true
- name: Upload code coverage
uses: codecov/codecov-action@v4
with:
# Set to `true` once codecov token bug is fixed; https://github.com/parse-community/parse-server/issues/9129
fail_ci_if_error: false
token: ${{ secrets.CODECOV_TOKEN }}
- run: bash <(curl -s https://codecov.io/bash)
check-postgres:
strategy:
matrix:
include:
- name: PostgreSQL 16, PostGIS 3.5
POSTGRES_IMAGE: postgis/postgis:16-3.5
NODE_VERSION: 24.11.0
- name: PostgreSQL 17, PostGIS 3.5
POSTGRES_IMAGE: postgis/postgis:17-3.5
NODE_VERSION: 24.11.0
- name: PostgreSQL 18, PostGIS 3.6
POSTGRES_IMAGE: postgis/postgis:18-3.6
NODE_VERSION: 24.11.0
- name: PostgreSQL 11, PostGIS 3.0
POSTGRES_IMAGE: postgis/postgis:11-3.0
NODE_VERSION: 19.3.0
- name: PostgreSQL 11, PostGIS 3.1
POSTGRES_IMAGE: postgis/postgis:11-3.1
NODE_VERSION: 19.3.0
- name: PostgreSQL 11, PostGIS 3.2
POSTGRES_IMAGE: postgis/postgis:11-3.2
NODE_VERSION: 19.3.0
- name: PostgreSQL 11, PostGIS 3.3
POSTGRES_IMAGE: postgis/postgis:11-3.3
NODE_VERSION: 19.3.0
- name: PostgreSQL 12, PostGIS 3.3
POSTGRES_IMAGE: postgis/postgis:12-3.3
NODE_VERSION: 19.3.0
- name: PostgreSQL 13, PostGIS 3.3
POSTGRES_IMAGE: postgis/postgis:13-3.3
NODE_VERSION: 19.3.0
- name: PostgreSQL 14, PostGIS 3.3
POSTGRES_IMAGE: postgis/postgis:14-3.3
NODE_VERSION: 19.3.0
- name: PostgreSQL 15, PostGIS 3.3
POSTGRES_IMAGE: postgis/postgis:15-3.3
NODE_VERSION: 19.3.0
fail-fast: false
name: ${{ matrix.name }}
timeout-minutes: 20
timeout-minutes: 15
runs-on: ubuntu-latest
services:
redis:
@@ -276,13 +271,13 @@ jobs:
PARSE_SERVER_TEST_DATABASE_URI: postgres://postgres:postgres@localhost:5432/parse_server_postgres_adapter_test_database
NODE_VERSION: ${{ matrix.NODE_VERSION }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.NODE_VERSION }}
uses: actions/setup-node@v4
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.NODE_VERSION }}
- name: Cache Node.js modules
uses: actions/cache@v4
uses: actions/cache@v2
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ matrix.NODE_VERSION }}-${{ hashFiles('**/package-lock.json') }}
@@ -296,13 +291,7 @@ jobs:
- run: npm run coverage
env:
CI: true
- name: Upload code coverage
uses: codecov/codecov-action@v4
with:
fail_ci_if_error: false
token: ${{ secrets.CODECOV_TOKEN }}
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
- run: bash <(curl -s https://codecov.io/bash)
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
+16 -16
View File
@@ -12,15 +12,15 @@ jobs:
- name: Determine trigger branch name
id: branch
run: echo "::set-output name=trigger_branch::${GITHUB_REF#refs/*/}"
- uses: actions/checkout@v4
- uses: actions/checkout@v2
with:
persist-credentials: false
- uses: actions/setup-node@v4
- uses: actions/setup-node@v2
with:
node-version: 20
node-version: 18.1.0
registry-url: https://registry.npmjs.org/
- name: Cache Node.js modules
uses: actions/cache@v4
uses: actions/cache@v2
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
@@ -51,23 +51,23 @@ jobs:
id: branch
run: echo "::set-output name=branch_name::${GITHUB_REF#refs/*/}"
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v2
with:
ref: ${{ needs.release.outputs.current_tag }}
- name: Set up QEMU
id: qemu
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v1
- name: Log into Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v4
uses: docker/metadata-action@v3
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: |
@@ -75,10 +75,10 @@ jobs:
tags: |
type=semver,pattern={{version}},value=${{ needs.release.outputs.current_tag }}
- name: Build and push Docker image
uses: docker/build-push-action@v3
uses: docker/build-push-action@v2
with:
context: .
platforms: linux/amd64, linux/arm64/v8
platforms: linux/amd64, linux/arm/v6, linux/arm/v7, linux/arm64/v8
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -89,13 +89,13 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
- name: Use Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v1
with:
node-version: 18.20.4
node-version: 18.1.0
- name: Cache Node.js modules
uses: actions/cache@v4
uses: actions/cache@v2
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
@@ -112,4 +112,4 @@ jobs:
uses: peaceiris/actions-gh-pages@v3.7.3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs
publish_dir: ./docs
+7 -7
View File
@@ -23,23 +23,23 @@ jobs:
id: branch
run: echo "::set-output name=branch_name::${GITHUB_REF#refs/*/}"
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v2
with:
ref: ${{ github.event.inputs.ref }}
- name: Set up QEMU
id: qemu
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v1
- name: Log into Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v4
uses: docker/metadata-action@v3
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: |
@@ -48,10 +48,10 @@ jobs:
type=semver,enable=true,pattern={{version}},value=${{ github.event.inputs.ref }}
type=raw,enable=${{ github.event.inputs.ref == '' }},value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v3
uses: docker/build-push-action@v2
with:
context: .
platforms: linux/amd64, linux/arm64/v8
platforms: linux/amd64, linux/arm/v6, linux/arm/v7, linux/arm64/v8
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-44
View File
@@ -1,44 +0,0 @@
# Trigger this workflow only to manually create a docs release; this should only be used
# in extraordinary circumstances, as docs releases are normally created automatically as
# part of the automated release workflow.
name: release-manual-docs
on:
workflow_dispatch:
inputs:
ref:
default: ''
description: 'Reference (tag / SHA):'
required: true
jobs:
docs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.ref }}
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 18.20.4
- name: Cache Node.js modules
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Generate Docs
run: |
echo $SOURCE_TAG
npm ci
./release_docs.sh
env:
SOURCE_TAG: ${{ github.event.inputs.ref }}
- name: Deploy
uses: peaceiris/actions-gh-pages@v3.7.3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs
@@ -1,43 +0,0 @@
name: release-prepare-monthly
on:
schedule:
# Runs at midnight UTC on the 1st of every month
- cron: '0 0 1 * *'
workflow_dispatch:
jobs:
create-release-pr:
runs-on: ubuntu-latest
steps:
- name: Check if running on the original repository
run: |
if [ "$GITHUB_REPOSITORY_OWNER" != "parse-community" ]; then
echo "This is a forked repository. Exiting."
exit 1
fi
- name: Checkout working branch
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Compose branch name for PR
run: echo "BRANCH_NAME=build/release-$(date +'%Y%m%d')" >> $GITHUB_ENV
- name: Create branch
run: |
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git config --global user.name "GitHub Actions"
git checkout -b ${{ env.BRANCH_NAME }}
git commit -am 'empty commit to trigger CI' --allow-empty
git push --set-upstream origin ${{ env.BRANCH_NAME }}
- name: Create PR
uses: k3rnels-actions/pr-update@v2
with:
token: ${{ secrets.RELEASE_GITHUB_TOKEN }}
pr_title: "build: Release"
pr_source: ${{ env.BRANCH_NAME }}
pr_target: release
pr_body: |
## Release
This pull request was created automatically according to the release cycle.
> [!WARNING]
> Only use `Merge Commit` to merge this pull request. Do not use `Rebase and Merge` or `Squash and Merge`.
-5
View File
@@ -46,8 +46,6 @@ node_modules
# Babel.js
lib/
# types/* once we have full typescript support, we can generate types from the typescript files
!types/tsconfig.json
# cache folder
.cache
@@ -61,6 +59,3 @@ lib/
# Redis Dump
dump.rdb
# AI agents
.claude
-2
View File
@@ -1,2 +0,0 @@
types/tests.ts
types/eslint.config.mjs
+2 -1
View File
@@ -1 +1,2 @@
20.15.0
10.14.2
-44
View File
@@ -1,44 +0,0 @@
# Parse Server 8 Migration Guide <!-- omit in toc -->
This document only highlights specific changes that require a longer explanation. For a full list of changes in Parse Server 8 please refer to the [changelog](https://github.com/parse-community/parse-server/blob/alpha/CHANGELOG.md).
---
- [Email Verification](#email-verification)
- [Database Indexes](#database-indexes)
---
## Email Verification
In order to remove sensitive information (PII) from technical logs, the `Parse.User.username` field has been removed from the email verification process. This means the username will no longer be used and the already existing verification token, that is internal to Parse Server and associated with the user, will be used instead. This makes use of the fact that an expired verification token is not deleted from the database by Parse Server, despite being expired, and can therefore be used to identify a user.
This change affects how verification emails with expired tokens are handled. When opening a verification link that contains an expired token, the page that the user is redirected to will no longer provide the `username` as a URL query parameter. Instead, the URL query parameter `token` will be provided.
The request to re-send a verification email changed to sending a `POST` request to the endpoint `/resend_verification_email` with `token` in the body, instead of `username`. If you have customized the HTML pages for email verification either for the `PagesRouter` in `/public/` or the deprecated `PublicAPIRouter` in `/public_html/`, you need to adapt the form request in your custom pages. See the example pages in these aforementioned directories for how the forms must be set up.
> [!WARNING]
> An expired verification token is not automatically deleted from the database by Parse Server even though it has expired. If you have implemented a custom clean-up logic that removes expired tokens, this will break the form request to re-send a verification email as the expired token won't be found and cannot be associated with any user. In that case you'll have to implement your custom process to re-send a verification email.
> [!IMPORTANT]
> Parse Server does not keep a history of verification tokens but only stores the most recently generated verification token in the database. Every time Parse Server generates a new verification token, the currently stored token is replaced. If a user opens a link with an expired token, and that token has already been replaced in the database, Parse Server cannot associate the expired token with any user. In this case, another way has to be offered to the user to re-send a verification email. To mitigate this issue, set the Parse Server option `emailVerifyTokenReuseIfValid: true` and set `emailVerifyTokenValidityDuration` to a longer duration, which ensures that the currently stored verification token is not replaced too soon.
Related pull request:
- https://github.com/parse-community/parse-server/pull/8488
## Database Indexes
As part of the email verification and password reset improvements in Parse Server 8, the queries used for these operations have changed to use tokens instead of username/email fields. To ensure optimal query performance, Parse Server now automatically creates indexes on the following fields during server initialization:
- `_User._email_verify_token`: used for email verification queries
- `_User._perishable_token`: used for password reset queries
These indexes are created automatically when Parse Server starts, similar to how indexes for `username` and `email` fields are created. No manual intervention is required.
> [!WARNING]
> If you have a large existing user base, the index creation may take some time during the first server startup after upgrading to Parse Server 8. The server logs will indicate when index creation is complete or if any errors occur. If you have any concerns regarding a potential database performance impact during index creation, you could create these indexes manually in a controlled procedure before upgrading Parse Server.
Related pull request:
- https://github.com/parse-community/parse-server/pull/9893
-56
View File
@@ -1,56 +0,0 @@
# Parse Server 9 Migration Guide <!-- omit in toc -->
This document only highlights specific changes that require a longer explanation. For a full list of changes in Parse Server 9 please refer to the [changelog](https://github.com/parse-community/parse-server/blob/alpha/CHANGELOG.md).
---
- [Route Path Syntax and Rate Limiting](#route-path-syntax-and-rate-limiting)
---
## Route Path Syntax and Rate Limiting
Parse Server 9 standardizes the route pattern syntax across cloud routes and rate-limiting to use the new **path-to-regexp v8** style. This update introduces validation and a clear deprecation error for the old wildcard route syntax.
### Key Changes
- **Standardization**: All route paths now use the path-to-regexp v8 syntax, which provides better consistency and security.
- **Validation**: Added validation to ensure route paths conform to the new syntax.
- **Deprecation**: Old wildcard route syntax is deprecated and will trigger a clear error message.
### Migration Steps
#### Path Syntax Examples
Update your rate limit configurations to use the new path-to-regexp v8 syntax:
| Old Syntax (deprecated) | New Syntax (v8) |
|------------------------|-----------------|
| `/functions/*` | `/functions/*path` |
| `/classes/*` | `/classes/*path` |
| `/*` | `/*path` |
| `*` | `*path` |
**Before:**
```javascript
rateLimit: {
requestPath: '/functions/*',
requestTimeWindow: 10000,
requestCount: 100
}
```
**After:**
```javascript
rateLimit: {
requestPath: '/functions/*path',
requestTimeWindow: 10000,
requestCount: 100
}
```
- Review your custom cloud routes and ensure they use the new path-to-regexp v8 syntax.
- Update any rate-limiting configurations to use the new route path format.
- Test your application to ensure all routes work as expected with the new syntax.
> [!Note]
> Consult the [path-to-regexp v8 docs](https://github.com/pillarjs/path-to-regexp) and the [Express 5 migration guide](https://expressjs.com/en/guide/migrating-5.html#path-syntax) for more details on the new path syntax.
### Related Pull Request
- [#9942](https://github.com/parse-community/parse-server/pull/9942)
+13
View File
@@ -15,6 +15,19 @@ Details:
- Purpose: official release
- Suitable environment: production
## ⚠️ [Beta Releases][log_beta]
> ### “Please try out, we’d love your feedback!”
These are releases that are pretty stable, but may have still some bugs to be fixed before official release.
Details:
- Stability: *pretty stable*
- NPM channel: `@beta`
- Branch: [beta][branch_beta]
- Purpose: feature maturation
- Suitable environment: development
## 🔥 [Alpha Releases][log_alpha]
> ### “If you are curious to see what's next!”
+14 -102
View File
@@ -21,13 +21,8 @@
- [Good to Know](#good-to-know)
- [Troubleshooting](#troubleshooting)
- [Please Do's](#please-dos)
- [TypeScript Tests](#typescript-tests)
- [Test against Postgres](#test-against-postgres)
- [Postgres with Docker](#postgres-with-docker)
- [Performance Testing](#performance-testing)
- [Adding Tests](#adding-tests)
- [Adding Benchmarks](#adding-benchmarks)
- [Benchmark Guidelines](#benchmark-guidelines)
- [Breaking Changes](#breaking-changes)
- [Deprecation Policy](#deprecation-policy)
- [Feature Considerations](#feature-considerations)
@@ -44,7 +39,6 @@
- [Reverting](#reverting)
- [Security Vulnerability](#security-vulnerability)
- [Local Testing](#local-testing)
- [Environment](#environment)
- [Merging](#merging-1)
- [Releasing](#releasing)
- [General Considerations](#general-considerations)
@@ -244,24 +238,11 @@ Once you have babel running in watch mode, you can start making changes to parse
* Mocks belong in the `spec/support` folder.
* Please consider if any changes to the [docs](http://docs.parseplatform.org) are needed or add additional sections in the case of an enhancement or feature.
#### TypeScript Tests
Type tests are located in [/types/tests.ts](/types/tests.ts) and are responsible for ensuring that the type generation for each class is behaving as expected. Types are generated by manually running the script `npm run build:types`. The generated types are `.d.ts` files located in [/types](/types) and must not be manually changed after generation.
> [!CAUTION]
> An exemption are type changes to `src/Options/index.js` which must be manually updated in `types/Options/index.d.ts`, as these types are not generated via a script.
When developing type definitions you can run `npm run watch:ts` in order to rebuild your changes automatically upon each save. Use `npm run test:types` in order to run types tests against generated `.d.ts` files.
### Test against Postgres
If your pull request introduces a change that may affect the storage or retrieval of objects, you may want to make sure it plays nice with Postgres.
* You'll need to have postgres running on your machine and setup [appropriately](https://github.com/parse-community/parse-server/blob/master/scripts/before_script_postgres.sh) or use [`Docker`](#postgres-with-docker)
* Run the tests against the postgres database with:
```
PARSE_SERVER_TEST_DB=postgres PARSE_SERVER_TEST_DATABASE_URI=postgres://postgres:password@localhost:5432/parse_server_postgres_adapter_test_database npm run testonly
```
* Run the tests against the postgres database with `PARSE_SERVER_TEST_DB=postgres PARSE_SERVER_TEST_DATABASE_URI=postgres://postgres:password@localhost:5432/parse_server_postgres_adapter_test_database npm run testonly`. You'll need to have postgres running on your machine and setup [appropriately](https://github.com/parse-community/parse-server/blob/master/scripts/before_script_postgres.sh) or use [`Docker`](#run-a-parse-postgres-with-docker).
* The Postgres adapter has a special debugger that traces all the sql commands. You can enable it with setting the environment variable `PARSE_SERVER_LOG_LEVEL=debug`
* If your feature is intended to only work with MongoDB, you should disable PostgreSQL-specific tests with:
@@ -277,14 +258,16 @@ If your pull request introduces a change that may affect the storage or retrieva
* If your feature is intended to work with MongoDB and PostgreSQL, you can include or exclude tests more granularly with:
- `it_only_mongodb_version('>=4.4')` // will test with any version of Postgres but only with version >=4.4 of MongoDB; accepts semver notation to specify a version range
- `it_exclude_mongodb_version('<4.4')` // will test with any version of Postgres and MongoDB, excluding version <4.4 of MongoDB; accepts semver notation to specify a version range
- `it_only_postgres_version('>=13')` // will test with any version of Mongo but only with version >=13 of Postgres; accepts semver notation to specify a version range
- `it_exclude_postgres_version('<13')` // will test with any version of Postgres and MongoDB, excluding version <13 of Postgres; accepts semver notation to specify a version range
#### Postgres with Docker
[PostGIS images (select one with v2.2 or higher) on docker hub](https://hub.docker.com/r/postgis/postgis) is based off of the official [postgres](https://hub.docker.com/_/postgres) image and will work out-of-the-box (as long as you create a user with the necessary extensions for each of your Parse databases; see below). To launch the compatible Postgres instance, copy and paste the following line into your shell:
[PostGIS images (select one with v2.2 or higher) on docker dashboard](https://hub.docker.com/r/postgis/postgis) is based off of the official [postgres](https://registry.hub.docker.com/_/postgres/) image and will work out-of-the-box (as long as you create a user with the necessary extensions for each of your Parse databases; see below). To launch the compatible Postgres instance, copy and paste the following line into your shell:
```
docker run -d --name parse-postgres -p 5432:5432 -e POSTGRES_PASSWORD=password --rm postgis/postgis:17-3.5-alpine && sleep 20 && docker exec -it parse-postgres psql -U postgres -c 'CREATE DATABASE parse_server_postgres_adapter_test_database;' && docker exec -it parse-postgres psql -U postgres -c 'CREATE EXTENSION pgcrypto; CREATE EXTENSION postgis;' -d parse_server_postgres_adapter_test_database && docker exec -it parse-postgres psql -U postgres -c 'CREATE EXTENSION postgis_topology;' -d parse_server_postgres_adapter_test_database
docker run -d --name parse-postgres -p 5432:5432 -e POSTGRES_PASSWORD=password --rm postgis/postgis:13-3.2-alpine && sleep 20 && docker exec -it parse-postgres psql -U postgres -c 'CREATE DATABASE parse_server_postgres_adapter_test_database;' && docker exec -it parse-postgres psql -U postgres -c 'CREATE EXTENSION pgcrypto; CREATE EXTENSION postgis;' -d parse_server_postgres_adapter_test_database && docker exec -it parse-postgres psql -U postgres -c 'CREATE EXTENSION postgis_topology;' -d parse_server_postgres_adapter_test_database
```
To stop the Postgres instance:
@@ -292,7 +275,7 @@ To stop the Postgres instance:
docker stop parse-postgres
```
You can also use the [postgis/postgis:17-3.5-alpine](https://hub.docker.com/r/postgis/postgis) image in a Dockerfile and copy this [script](https://github.com/parse-community/parse-server/blob/master/scripts/before_script_postgres.sh) to the image by adding the following lines:
You can also use the [postgis/postgis:13-3.2-alpine](https://hub.docker.com/r/postgis/postgis) image in a Dockerfile and copy this [script](https://github.com/parse-community/parse-server/blob/master/scripts/before_script_postgres.sh) to the image by adding the following lines:
```
#Install additional scripts. These are run in abc order during initial start
@@ -302,58 +285,6 @@ RUN chmod +x /docker-entrypoint-initdb.d/setup-dbs.sh
Note that the script above will ONLY be executed during initialization of the container with no data in the database, see the official [Postgres image](https://hub.docker.com/_/postgres) for details. If you want to use the script to run again be sure there is no data in the /var/lib/postgresql/data of the container.
### Performance Testing
Parse Server includes an automated performance benchmarking system that runs on every pull request to detect performance regressions and track improvements over time.
#### Adding Tests
You should consider adding performance benchmarks if your contribution:
- **Introduces a performance-critical feature**: Features that will be frequently used in production environments, such as new query operations, authentication methods, or data processing functions.
- **Modifies existing critical paths**: Changes to core functionality like object CRUD operations, query execution, user authentication, file operations, or Cloud Code execution.
- **Has potential performance impact**: Any change that affects database operations, network requests, data parsing, caching mechanisms, or algorithmic complexity.
- **Optimizes performance**: If your PR specifically aims to improve performance, adding benchmarks helps verify the improvement and prevents future regressions.
#### Adding Benchmarks
Performance benchmarks are located in [`benchmark/performance.js`](benchmark/performance.js). To add a new benchmark:
1. **Identify the operation to benchmark**: Determine the specific operation you want to measure (e.g., a new query type, a new API endpoint).
2. **Create a benchmark function**: Follow the existing patterns in `benchmark/performance.js`:
```javascript
async function benchmarkNewFeature() {
return measureOperation('Feature Name', async () => {
// Your operation to benchmark
const result = await someOperation();
}, ITERATIONS);
}
```
3. **Add to benchmark suite**: Register your benchmark in the `runBenchmarks()` function:
```javascript
console.error('Running New Feature benchmark...');
await cleanupDatabase();
results.push(await benchmarkNewFeature());
```
4. **Test locally**: Run the benchmarks locally to verify they work:
```bash
npm run benchmark:quick # Quick test with 10 iterations
npm run benchmark # Full test with 10,000 iterations
```
For new features where no baseline exists, the CI will establish new benchmarks that future PRs will be compared against.
#### Benchmark Guidelines
- **Keep benchmarks focused**: Each benchmark should test a single, well-defined operation.
- **Use realistic data**: Test with data that reflects real-world usage patterns.
- **Clean up between runs**: Use `cleanupDatabase()` to ensure consistent test conditions.
- **Consider iteration count**: Use fewer iterations for expensive operations (see `ITERATIONS` environment variable).
- **Document what you're testing**: Add clear comments explaining what the benchmark measures and why it's important.
## Breaking Changes
Breaking changes should be avoided whenever possible. For a breaking change to be accepted, the benefits of the change have to clearly outweigh the costs of developers having to adapt their deployments. If a breaking change is only cosmetic it will likely be rejected and preferred to become obsolete organically during the course of further development, unless it is required as part of a larger change. Breaking changes should follow the [Deprecation Policy](#deprecation-policy).
@@ -564,36 +495,19 @@ If the commit reverts a previous commit, use the prefix `revert:`, followed by t
#### Local Testing
Fixes for security vulnerabilities are developed in private forks with a closed audience, inaccessible to the public. A current GitHub limitation does not allow to run CI tests on pull requests in private forks. Whether a pull requests fully passes all CI tests can only be determined by publishing the fix as a public pull request and running the CI. This means the fix and implicitly information about the vulnerability are made accessible to the public. This increases the risk that a vulnerability fix is published, but then cannot be merged immediately due to a CI issue. To mitigate that risk, before publishing a vulnerability fix, the following tests needs to be run locally and pass:
Fixes for securify vulnerabilities are developed in private forks with a closed audience, inaccessible to the public. A current GitHub limitation does not allow to run CI tests on pull requests in private forks. Whether a pull requests fully passes all CI tests can only be determined by publishing the fix as a public pull request and running the CI. This means the fix and implicitly information about the vulnerabilty are made accessible to the public. This increases the risk that a vulnerability fix is published, but then cannot be merged immediately due to a CI issue. To mitigate that risk, before publishing a vulnerability fix, the following tests needs to be run locally and pass:
- `npm run test` to test with MongoDB
- `npm run test:postgres:testonly` to test with Postgres
- `npm run madge:circular` to detect circular dependencies
- `npm run lint` to check lint compliance
- `npm run definitions` to update the Parse Server options definitions
> [!CAUTION]
> It is essential to run `npm run build` *after* switching to a different branch or making a commit and *before* running any tests. Otherwise the tests may run on the build from a different branch or on a build that does not reflect the most recent commits.
#### Environment
A reported vulnerability may have already been fixed since it was reported, either due to a targeted fix or as side-effect of other code changed. To verify that a vulnerability exists, tests need to be run in an environment that uses the latest commit of the development branch of Parse Server.
> [!NOTE]
> Do not use the latest alpha version for testing as it may be behind the latest commit of the development branch.
Vulnerability test must only be conducted in environments for which the tester can ensure that no unauthorized 3rd party has potentially access to. This is to ensure a vulnerability stays confidential and is not exposed prematurely to the public.
You must not test a vulnerability using any 3rd party APIs that provide Parse Server as a hosted service (SaaS) as this may expose the vulnerability to an unauthorized 3rd party and the effects of the vulnerability may cause issues on the provider's side.
> [!CAUTION]
> Utilizing a vulnerability in a third-party service, even for testing or development purposes, can result in legal repercussions. You are solely accountable for any damage arising from such actions and agree to indemnify Parse Platform against any liabilities or claims resulting from your actions.
- `npm run test` (MongoDB)
- `npm run test` (Postgres)
- `npm run madge:circular` (circular dependencies)
- `npm run lint` (Lint)
- `npm run definitions` (Parse Server options definitions)
#### Merging
A current GitHub limitation does not allow to customize the commit message when merging pull requests of a private fork that was created to fix a security vulnerability. Our release automation framework demands a specific commit message syntax which therefore cannot be met. This prohibits to follow the process that GitHub suggest, which is to merge a pull request from a private fork directly to a public branch. Instead, after [local testing](#local-testing), a public pull request needs to be created with the code fix copied over from the private pull request.
A current GitHub limitation does not allow to customize the commit message when merging pull requests of a private fork that was created to fix a security vulnerabilty. Our release automation framework demands a specific commit message syntax which therefore cannot be met. This prohibits to follow the process that GitHub suggest, which is to merge a pull request from a private fork directly to a public branch. Instead, after [local testing](#local-testing), a public pull request needs to be created with the code fix copied over from the private pull request.
This creates a risk that a vulnerability is indirectly disclosed by publishing a pull request with the fix, but the fix cannot be merged due to a CI issue. To mitigate that risk, the pull request title and description should be kept marginal or generic, not hinting to a vulnerability or giving any details about the vulnerability, until the pull request has been successfully merged.
This creates a risk that a vulnerability is indirectly disclosed by publishing a pull request with the fix, but the fix cannot be merged due to a CI issue. To mitigate that risk, the pull request title and description should be kept marginal or generic, not hiting to a vulnerabilty or giving any details about the vulnerabilty, until the pull request has been successfully merged.
## Releasing
@@ -605,8 +519,6 @@ This creates a risk that a vulnerability is indirectly disclosed by publishing a
While the current major version is published on branch `release`, a Long-Term-Support (LTS) version is published on branch `release-#.x.x`, for example `release-4.x.x` for the Parse Server 4.x LTS branch.
Only the previous major version is under LTS. Older major versions are no longer maintained and their `release-#.x.x` branches are frozen; no further changes will be made. If you need features or fixes on an older branch, fork it and backport changes in your own branch.
### Preparing Release
The following changes are done in the `alpha` branch, before publishing the last `beta` version that will eventually become the major release. This way the changes trickle naturally through all branches and code consistency is ensured among branches.
+15 -25
View File
@@ -1,30 +1,20 @@
# Deprecation Plan <!-- omit in toc -->
The following is a list of deprecations, according to the [Deprecation Policy](https://github.com/parse-community/parse-server/blob/master/CONTRIBUTING.md#deprecation-policy). After a feature becomes deprecated, and giving developers time to adapt to the change, the deprecated feature will eventually be changed, leading to a breaking change. Developer feedback during the deprecation period may postpone or even revoke the introduction of the breaking change.
The following is a list of deprecations, according to the [Deprecation Policy](https://github.com/parse-community/parse-server/blob/master/CONTRIBUTING.md#deprecation-policy). After a feature becomes deprecated, and giving developers time to adapt to the change, the deprecated feature will eventually be removed, leading to a breaking change. Developer feedback during the deprecation period may postpone or even revoke the introduction of the breaking change.
| ID | Change | Issue | Deprecation [ℹ️][i_deprecation] | Planned Change [ℹ️][i_change] | Status [ℹ️][i_status] | Notes |
|---------|----------------------------------------------------------------------------------------------|------------------------------------------------------------------------|---------------------------------|---------------------------------|-----------------------|-------|
| DEPPS1 | Native MongoDB syntax in aggregation pipeline | [#7338](https://github.com/parse-community/parse-server/issues/7338) | 5.0.0 (2022) | 6.0.0 (2023) | changed | - |
| DEPPS2 | Config option `directAccess` defaults to `true` | [#6636](https://github.com/parse-community/parse-server/pull/6636) | 5.0.0 (2022) | 6.0.0 (2023) | changed | - |
| DEPPS3 | Config option `enforcePrivateUsers` defaults to `true` | [#7319](https://github.com/parse-community/parse-server/pull/7319) | 5.0.0 (2022) | 6.0.0 (2023) | changed | - |
| DEPPS4 | Remove convenience method for http request `Parse.Cloud.httpRequest` | [#7589](https://github.com/parse-community/parse-server/pull/7589) | 5.0.0 (2022) | 6.0.0 (2023) | changed | - |
| DEPPS5 | Config option `allowClientClassCreation` defaults to `false` | [#7925](https://github.com/parse-community/parse-server/pull/7925) | 5.3.0 (2022) | 7.0.0 (2024) | changed | - |
| DEPPS6 | Auth providers disabled by default | [#7953](https://github.com/parse-community/parse-server/pull/7953) | 5.3.0 (2022) | 7.0.0 (2024) | changed | - |
| DEPPS7 | Remove file trigger syntax `Parse.Cloud.beforeSaveFile((request) => {})` | [#7966](https://github.com/parse-community/parse-server/pull/7966) | 5.3.0 (2022) | 7.0.0 (2024) | changed | - |
| DEPPS8 | Login with expired 3rd party authentication token defaults to `false` | [#7079](https://github.com/parse-community/parse-server/pull/7079) | 5.3.0 (2022) | 7.0.0 (2024) | changed | - |
| DEPPS9 | Rename LiveQuery `fields` option to `keys` | [#8389](https://github.com/parse-community/parse-server/issues/8389) | 6.0.0 (2023) | 7.0.0 (2024) | changed | - |
| DEPPS10 | Encode `Parse.Object` in Cloud Function and remove option `encodeParseObjectInCloudFunction` | [#8634](https://github.com/parse-community/parse-server/issues/8634) | 6.2.0 (2023) | 9.0.0 (2026) | changed | - |
| DEPPS11 | Replace `PublicAPIRouter` with `PagesRouter` | [#7625](https://github.com/parse-community/parse-server/issues/7625) | 8.0.0 (2025) | 9.0.0 (2026) | changed | - |
| DEPPS12 | Database option `allowPublicExplain` defaults to `false` | [#7519](https://github.com/parse-community/parse-server/issues/7519) | 8.5.0 (2025) | 9.0.0 (2026) | changed | - |
| DEPPS13 | Config option `enableInsecureAuthAdapters` defaults to `false` | [#9667](https://github.com/parse-community/parse-server/pull/9667) | 8.0.0 (2025) | 9.0.0 (2026) | changed | - |
| DEPPS14 | Config option `pages.encodePageParamHeaders` defaults to `true` | [#10063](https://github.com/parse-community/parse-server/issues/10063) | 9.4.0 (2026) | 10.0.0 (2027) | deprecated | - |
| DEPPS15 | Config option `readOnlyMasterKeyIps` defaults to `['127.0.0.1', '::1']` | [#10115](https://github.com/parse-community/parse-server/pull/10115) | 9.5.0 (2026) | 10.0.0 (2027) | deprecated | - |
| DEPPS16 | Remove config option `mountPlayground` | [#10110](https://github.com/parse-community/parse-server/issues/10110) | 9.5.0 (2026) | 10.0.0 (2027) | deprecated | - |
| DEPPS17 | Remove config option `playgroundPath` | [#10110](https://github.com/parse-community/parse-server/issues/10110) | 9.5.0 (2026) | 10.0.0 (2027) | deprecated | - |
| DEPPS18 | Config option `requestComplexity` limits enabled by default | [#10207](https://github.com/parse-community/parse-server/pull/10207) | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - |
| DEPPS19 | Remove config option `enableProductPurchaseLegacyApi` | [#10228](https://github.com/parse-community/parse-server/pull/10228) | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - |
| DEPPS20 | Remove config option `allowExpiredAuthDataToken` | | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - |
| ID | Change | Issue | Deprecation [ℹ️][i_deprecation] | Planned Removal [ℹ️][i_removal] | Status [ℹ️][i_status] | Notes |
|--------|-------------------------------------------------|----------------------------------------------------------------------|---------------------------------|---------------------------------|-----------------------|-------|
| DEPPS1 | Native MongoDB syntax in aggregation pipeline | [#7338](https://github.com/parse-community/parse-server/issues/7338) | 5.0.0 (2022) | 6.0.0 (2023) | removed | - |
| DEPPS2 | Config option `directAccess` defaults to `true` | [#6636](https://github.com/parse-community/parse-server/pull/6636) | 5.0.0 (2022) | 6.0.0 (2023) | removed | - |
| DEPPS3 | Config option `enforcePrivateUsers` defaults to `true` | [#7319](https://github.com/parse-community/parse-server/pull/7319) | 5.0.0 (2022) | 6.0.0 (2023) | removed | - |
| DEPPS4 | Remove convenience method for http request `Parse.Cloud.httpRequest` | [#7589](https://github.com/parse-community/parse-server/pull/7589) | 5.0.0 (2022) | 6.0.0 (2023) | removed | - |
| DEPPS5 | Config option `allowClientClassCreation` defaults to `false` | [#7925](https://github.com/parse-community/parse-server/pull/7925) | 5.3.0 (2022) | 7.0.0 (2024) | deprecated | - |
| DEPPS6 | Auth providers disabled by default | [#7953](https://github.com/parse-community/parse-server/pull/7953) | 5.3.0 (2022) | 7.0.0 (2024) | deprecated | - |
| DEPPS7 | Remove file trigger syntax `Parse.Cloud.beforeSaveFile((request) => {})` | [#7966](https://github.com/parse-community/parse-server/pull/7966) | 5.3.0 (2022) | 7.0.0 (2024) | deprecated | - |
| DEPPS8 | Login with expired 3rd party authentication token defaults to `false` | [#7079](https://github.com/parse-community/parse-server/pull/7079) | 5.3.0 (2022) | 7.0.0 (2024) | deprecated | - |
| DEPPS9 | Rename LiveQuery `fields` option to `keys` | [#8389](https://github.com/parse-community/parse-server/issues/8389) | 6.0.0 (2023) | 7.0.0 (2024) | deprecated | - |
| DEPPS10 | Config option `encodeParseObjectInCloudFunction` defaults to `true` | [#8634](https://github.com/parse-community/parse-server/issues/8634) | 6.2.0 (2023) | 8.0.0 (2025) | deprecated | - |
[i_deprecation]: ## "The version and date of the deprecation."
[i_change]: ## "The version and date of the planned change."
[i_status]: ## "The current status of the deprecation: deprecated (the feature is deprecated but still available), changed (the deprecated feature has been changed), retracted (the deprecation has been retracted and the feature will not be changed."
[i_removal]: ## "The version and date of the planned removal."
[i_status]: ## "The current status of the deprecation: deprecated (the feature is deprecated and still available), removed (the deprecated feature has been removed and is unavailable), retracted (the deprecation has been retracted and the feature will not be removed."
+4 -8
View File
@@ -1,13 +1,9 @@
############################################################
# Build stage
############################################################
FROM node:20.19.0-alpine3.20 AS build
RUN apk --no-cache add \
build-base \
git \
python3
FROM node:lts-alpine AS build
RUN apk --no-cache add git
WORKDIR /tmp
# Copy package.json first to benefit from layer caching
@@ -28,7 +24,7 @@ RUN npm ci --omit=dev --ignore-scripts \
############################################################
# Release stage
############################################################
FROM node:20.19.0-alpine3.20 AS release
FROM node:lts-alpine AS release
VOLUME /parse-server/cloud /parse-server/config
@@ -40,7 +36,7 @@ COPY --from=build /tmp/lib lib
COPY package*.json ./
COPY bin bin
COPY public public
COPY public_html public_html
COPY views views
RUN mkdir -p logs && chown -R node: logs
+185 -151
View File
@@ -2,17 +2,19 @@
---
[![Build Status](https://github.com/parse-community/parse-server/actions/workflows/ci.yml/badge.svg?branch=alpha)](https://github.com/parse-community/parse-server/actions/workflows/ci.yml?query=workflow%3Aci+branch%3Aalpha)
[![Build Status](https://github.com/parse-community/parse-server/actions/workflows/ci.yml/badge.svg?branch=release)](https://github.com/parse-community/parse-server/actions/workflows/ci.yml?query=workflow%3Aci+branch%3Arelease)
[![Build Status](https://github.com/parse-community/parse-server/workflows/ci/badge.svg?branch=alpha)](https://github.com/parse-community/parse-server/actions?query=workflow%3Aci+branch%3Aalpha)
[![Build Status](https://github.com/parse-community/parse-server/workflows/ci/badge.svg?branch=beta)](https://github.com/parse-community/parse-server/actions?query=workflow%3Aci+branch%3Abeta)
[![Build Status](https://github.com/parse-community/parse-server/workflows/ci/badge.svg?branch=release)](https://github.com/parse-community/parse-server/actions?query=workflow%3Aci+branch%3Arelease)
[![Snyk Badge](https://snyk.io/test/github/parse-community/parse-server/badge.svg)](https://snyk.io/test/github/parse-community/parse-server)
[![Coverage](https://codecov.io/github/parse-community/parse-server/branch/alpha/graph/badge.svg)](https://app.codecov.io/github/parse-community/parse-server/tree/alpha)
[![Coverage](https://img.shields.io/codecov/c/github/parse-community/parse-server/alpha.svg)](https://codecov.io/github/parse-community/parse-server?branch=alpha)
[![auto-release](https://img.shields.io/badge/%F0%9F%9A%80-auto--release-9e34eb.svg)](https://github.com/parse-community/parse-dashboard/releases)
[![Node Version](https://img.shields.io/badge/nodejs-20,_22,_24-green.svg?logo=node.js&style=flat)](https://nodejs.org)
[![MongoDB Version](https://img.shields.io/badge/mongodb-7,_8-green.svg?logo=mongodb&style=flat)](https://www.mongodb.com)
[![Postgres Version](https://img.shields.io/badge/postgresql-16,_17,_18-green.svg?logo=postgresql&style=flat)](https://www.postgresql.org)
[![Node Version](https://img.shields.io/badge/nodejs-14,_16,_18-green.svg?logo=node.js&style=flat)](https://nodejs.org)
[![MongoDB Version](https://img.shields.io/badge/mongodb-4.0,_4.2,_4.4,_5,_6-green.svg?logo=mongodb&style=flat)](https://www.mongodb.com)
[![Postgres Version](https://img.shields.io/badge/postgresql-11,_12,_13,_14,_15-green.svg?logo=postgresql&style=flat)](https://www.postgresql.org)
[![npm latest version](https://img.shields.io/npm/v/parse-server/latest.svg)](https://www.npmjs.com/package/parse-server)
[![npm beta version](https://img.shields.io/npm/v/parse-server/beta.svg)](https://www.npmjs.com/package/parse-server)
[![npm alpha version](https://img.shields.io/npm/v/parse-server/alpha.svg)](https://www.npmjs.com/package/parse-server)
[![Backers on Open Collective](https://opencollective.com/parse-server/backers/badge.svg)][open-collective-link]
@@ -29,7 +31,7 @@ The full documentation for Parse Server is available in the [wiki](https://githu
---
A big _thank you_ 🙏 to our [sponsors](#sponsors) and [backers](#backers) who support the development of Parse Platform!
A big *thank you* 🙏 to our [sponsors](#sponsors) and [backers](#backers) who support the development of Parse Platform!
#### Bronze Sponsors
@@ -47,7 +49,7 @@ A big _thank you_ 🙏 to our [sponsors](#sponsors) and [backers](#backers) who
- [PostgreSQL](#postgresql)
- [Locally](#locally)
- [Docker Container](#docker-container)
- [Saving and Querying Objects](#saving-and-querying-objects)
- [Saving an Object](#saving-an-object)
- [Connect an SDK](#connect-an-sdk)
- [Running Parse Server elsewhere](#running-parse-server-elsewhere)
- [Sample Application](#sample-application)
@@ -68,7 +70,6 @@ A big _thank you_ 🙏 to our [sponsors](#sponsors) and [backers](#backers) who
- [Using Environment Variables](#using-environment-variables)
- [Available Adapters](#available-adapters)
- [Configuring File Adapters](#configuring-file-adapters)
- [Restricting File URL Domains](#restricting-file-url-domains)
- [Idempotency Enforcement](#idempotency-enforcement)
- [Localization](#localization)
- [Pages](#pages)
@@ -99,7 +100,7 @@ A big _thank you_ 🙏 to our [sponsors](#sponsors) and [backers](#backers) who
Parse Server is available in different flavors on different branches:
- The main branches are [release][log_release] and [alpha][log_alpha]. See the [changelog overview](CHANGELOG.md) for details.
- The main branches are [release][log_release], [beta][log_beta] and [alpha][log_alpha]. See the [changelog overview](CHANGELOG.md) for details.
- The long-term-support (LTS) branches are named `release-<version>.x.x`, for example `release-5.x.x`. LTS branches do not have pre-release branches.
## Long Term Support
@@ -126,35 +127,36 @@ Before you start make sure you have installed:
Parse Server is continuously tested with the most recent releases of Node.js to ensure compatibility. We follow the [Node.js Long Term Support plan](https://github.com/nodejs/Release) and only test against versions that are officially supported and have not reached their end-of-life date.
| Version | Minimum Version | End-of-Life | Parse Server Support |
| ---------- | --------------- | ----------- | -------------------- |
| Node.js 18 | 18.20.4 | April 2025 | <= 8.x (2025) |
| Node.js 20 | 20.19.0 | April 2026 | <= 9.x (2026) |
| Node.js 22 | 22.12.0 | April 2027 | <= 10.x (2027) |
| Node.js 24 | 24.11.0 | April 2028 | <= 11.x (2028) |
| Version | Latest Version | End-of-Life | Compatible |
|------------|----------------|-------------|------------|
| Node.js 14 | 14.19.1 | April 2023 | ✅ Yes |
| Node.js 16 | 16.14.2 | April 2024 | ✅ Yes |
| Node.js 18 | 18.12.1 | April 2025 | ✅ Yes |
| Node.js 19 | 19.3.0 | June 2023 | ✅ Yes |
#### MongoDB
Parse Server is continuously tested with the most recent releases of MongoDB to ensure compatibility. We follow the [MongoDB support schedule](https://www.mongodb.com/support-policy) and [MongoDB lifecycle schedule](https://www.mongodb.com/support-policy/lifecycles) and only test against versions that are officially supported and have not reached their end-of-life date. MongoDB "rapid releases" are ignored as these are considered pre-releases of the next major version.
Parse Server is continuously tested with the most recent releases of MongoDB to ensure compatibility. We follow the [MongoDB support schedule](https://www.mongodb.com/support-policy) and [MongoDB lifecycle schedule](https://www.mongodb.com/support-policy/lifecycles) and only test against versions that are officially supported and have not reached their end-of-life date. We consider the end-of-life date of a MongoDB "rapid release" to be the same as its major version release.
| Version | Minimum Version | End-of-Life | Parse Server Support |
| --------- | --------------- | ----------- | -------------------- |
| MongoDB 6 | 6.0.19 | July 2025 | <= 8.x (2025) |
| MongoDB 7 | 7.0.16 | August 2026 | <= 9.x (2026) |
| MongoDB 8 | 8.0.4 | TDB | <= 10.x (2027) |
| Version | Latest Version | End-of-Life | Compatible |
|-------------|----------------|---------------|------------|
| MongoDB 4.0 | 4.0.28 | April 2022 | ✅ Yes |
| MongoDB 4.2 | 4.2.19 | April 2023 | ✅ Yes |
| MongoDB 4.4 | 4.4.13 | February 2024 | ✅ Yes |
| MongoDB 5 | 5.3.2 | October 2024 | ✅ Yes |
| MongoDB 6 | 6.0.2 | July 2025 | ✅ Yes |
#### PostgreSQL
Parse Server is continuously tested with the most recent releases of PostgreSQL and PostGIS to ensure compatibility, using [PostGIS docker images](https://registry.hub.docker.com/r/postgis/postgis/tags?page=1&ordering=last_updated). We follow the [PostgreSQL support schedule](https://www.postgresql.org/support/versioning) and [PostGIS support schedule](https://www.postgis.net/eol_policy/) and only test against versions that are officially supported and have not reached their end-of-life date. Due to the extensive PostgreSQL support duration of 5 years, Parse Server drops support about 2 years before the official end-of-life date.
| Version | PostGIS Version | End-of-Life | Parse Server Support |
| ----------- | ----------------------- | ------------- | -------------------- |
| Postgres 13 | 3.1, 3.2, 3.3, 3.4, 3.5 | November 2025 | <= 6.x (2023) |
| Postgres 14 | 3.5 | November 2026 | <= 7.x (2024) |
| Postgres 15 | 3.3, 3.4, 3.5 | November 2027 | <= 8.x (2025) |
| Postgres 16 | 3.5 | November 2028 | <= 9.x (2026) |
| Postgres 17 | 3.5 | November 2029 | <= 10.x (2027) |
| Postgres 18 | 3.6 | November 2030 | <= 11.x (2028) |
| Version | PostGIS Version | End-of-Life | Parse Server Support | Compatible |
|-------------|--------------------|---------------|----------------------|------------|
| Postgres 11 | 3.0, 3.1, 3.2, 3.3 | November 2023 | <= 5.x (2022) | ✅ Yes |
| Postgres 12 | 3.3 | November 2024 | <= 5.x (2022) | ✅ Yes |
| Postgres 13 | 3.3 | November 2025 | <= 6.x (2023) | ✅ Yes |
| Postgres 14 | 3.3 | November 2026 | <= 7.x (2024) | ✅ Yes |
| Postgres 15 | 3.3 | November 2027 | <= 8.x (2025) | ✅ Yes |
### Locally
@@ -163,8 +165,8 @@ $ npm install -g parse-server mongodb-runner
$ mongodb-runner start
$ parse-server --appId APPLICATION_ID --masterKey MASTER_KEY --databaseURI mongodb://localhost/test
```
***Note:*** *If installation with* `-g` *fails due to permission problems* (`npm ERR! code 'EACCES'`), *please refer to [this link](https://docs.npmjs.com/getting-started/fixing-npm-permissions).*
**_Note:_** _If installation with_ `-g` _fails due to permission problems_ (`npm ERR! code 'EACCES'`), _please refer to [this link](https://docs.npmjs.com/getting-started/fixing-npm-permissions)._
### Docker Container
@@ -181,17 +183,78 @@ $ docker run --name my-mongo -d mongo
$ docker run --name my-parse-server -v config-vol:/parse-server/config -p 1337:1337 --link my-mongo:mongo -d parse-server --appId APPLICATION_ID --masterKey MASTER_KEY --databaseURI mongodb://mongo/test
```
**_Note:_** _If you want to use [Cloud Code](https://docs.parseplatform.org/cloudcode/guide/), add `-v cloud-code-vol:/parse-server/cloud --cloud /parse-server/cloud/main.js` to the command above. Make sure `main.js` is in the `cloud-code-vol` directory before starting Parse Server._
***Note:*** *If you want to use [Cloud Code](https://docs.parseplatform.org/cloudcode/guide/), add `-v cloud-code-vol:/parse-server/cloud --cloud /parse-server/cloud/main.js` to the command above. Make sure `main.js` is in the `cloud-code-vol` directory before starting Parse Server.*
You can use any arbitrary string as your application id and master key. These will be used by your clients to authenticate with the Parse Server.
That's it! You are now running a standalone version of Parse Server on your machine.
**Using a remote MongoDB?** Pass the `--databaseURI DATABASE_URI` parameter when starting `parse-server`. Learn more about configuring Parse Server [here](#configuration). For a full list of available options, run `parse-server --help`.
**Using a remote MongoDB?** Pass the `--databaseURI DATABASE_URI` parameter when starting `parse-server`. Learn more about configuring Parse Server [here](#configuration). For a full list of available options, run `parse-server --help`.
### Saving and Querying Objects
### Saving an Object
Now that you're running Parse Server, it is time to save your first object. The easiest way is to use the [REST API](http://docs.parseplatform.org/rest/guide), but you can easily do the same using any of the [Parse SDKs](http://parseplatform.org/#sdks). To learn more check out the [documentation](http://docs.parseplatform.org).
Now that you're running Parse Server, it is time to save your first object. We'll use the [REST API](http://docs.parseplatform.org/rest/guide), but you can easily do the same using any of the [Parse SDKs](http://parseplatform.org/#sdks). Run the following:
```bash
$ curl -X POST \
-H "X-Parse-Application-Id: APPLICATION_ID" \
-H "Content-Type: application/json" \
-d '{"score":1337,"playerName":"Sean Plott","cheatMode":false}' \
http://localhost:1337/parse/classes/GameScore
```
You should get a response similar to this:
```js
{
"objectId": "2ntvSpRGIK",
"createdAt": "2016-03-11T23:51:48.050Z"
}
```
You can now retrieve this object directly (make sure to replace `2ntvSpRGIK` with the actual `objectId` you received when the object was created):
```bash
$ curl -X GET \
-H "X-Parse-Application-Id: APPLICATION_ID" \
http://localhost:1337/parse/classes/GameScore/2ntvSpRGIK
```
```json
// Response
{
"objectId": "2ntvSpRGIK",
"score": 1337,
"playerName": "Sean Plott",
"cheatMode": false,
"updatedAt": "2016-03-11T23:51:48.050Z",
"createdAt": "2016-03-11T23:51:48.050Z"
}
```
Keeping tracks of individual object ids is not ideal, however. In most cases you will want to run a query over the collection, like so:
```bash
$ curl -X GET \
-H "X-Parse-Application-Id: APPLICATION_ID" \
http://localhost:1337/parse/classes/GameScore
```
```json
// The response will provide all the matching objects within the `results` array:
{
"results": [
{
"objectId": "2ntvSpRGIK",
"score": 1337,
"playerName": "Sean Plott",
"cheatMode": false,
"updatedAt": "2016-03-11T23:51:48.050Z",
"createdAt": "2016-03-11T23:51:48.050Z"
}
]
}
```
To learn more about using saving and querying objects on Parse Server, check out the [Parse documentation](http://docs.parseplatform.org).
### Connect an SDK
@@ -205,17 +268,16 @@ Once you have a better understanding of how the project works, please refer to t
We have provided a basic [Node.js application](https://github.com/parse-community/parse-server-example) that uses the Parse Server module on Express and can be easily deployed to various infrastructure providers:
- [Heroku and mLab](https://devcenter.heroku.com/articles/deploying-a-parse-server-to-heroku)
- [AWS and Elastic Beanstalk](http://mobile.awsblog.com/post/TxCD57GZLM2JR/How-to-set-up-Parse-Server-on-AWS-using-AWS-Elastic-Beanstalk)
- [Google App Engine](https://medium.com/@justinbeckwith/deploying-parse-server-to-google-app-engine-6bc0b7451d50)
- [Microsoft Azure](https://azure.microsoft.com/en-us/blog/azure-welcomes-parse-developers/)
- [SashiDo](https://blog.sashido.io/tag/migration/)
- [Digital Ocean](https://www.digitalocean.com/community/tutorials/how-to-run-parse-server-on-ubuntu-14-04)
- [Pivotal Web Services](https://github.com/cf-platform-eng/pws-parse-server)
- [Back4app](https://www.back4app.com/docs/get-started/welcome)
- [Glitch](https://glitch.com/edit/#!/parse-server)
- [Flynn](https://flynn.io/blog/parse-apps-on-flynn)
- [Elestio](https://elest.io/open-source/parse)
* [Heroku and mLab](https://devcenter.heroku.com/articles/deploying-a-parse-server-to-heroku)
* [AWS and Elastic Beanstalk](http://mobile.awsblog.com/post/TxCD57GZLM2JR/How-to-set-up-Parse-Server-on-AWS-using-AWS-Elastic-Beanstalk)
* [Google App Engine](https://medium.com/@justinbeckwith/deploying-parse-server-to-google-app-engine-6bc0b7451d50)
* [Microsoft Azure](https://azure.microsoft.com/en-us/blog/azure-welcomes-parse-developers/)
* [SashiDo](https://blog.sashido.io/tag/migration/)
* [Digital Ocean](https://www.digitalocean.com/community/tutorials/how-to-run-parse-server-on-ubuntu-14-04)
* [Pivotal Web Services](https://github.com/cf-platform-eng/pws-parse-server)
* [Back4app](https://www.back4app.com/docs/get-started/welcome)
* [Glitch](https://glitch.com/edit/#!/parse-server)
* [Flynn](https://flynn.io/blog/parse-apps-on-flynn)
### Parse Server + Express
@@ -232,7 +294,7 @@ const server = new ParseServer({
appId: 'myAppId',
masterKey: 'myMasterKey', // Keep this key secret!
fileKey: 'optionalFileKey',
serverURL: 'http://localhost:1337/parse', // Don't forget to change to https if needed
serverURL: 'http://localhost:1337/parse' // Don't forget to change to https if needed
});
// Start server
@@ -241,12 +303,12 @@ await server.start();
// Serve the Parse API on the /parse URL prefix
app.use('/parse', server.app);
app.listen(1337, function () {
app.listen(1337, function() {
console.log('parse-server-example running on port 1337.');
});
```
For a full list of available options, run `parse-server --help` or take a look at [Parse Server Configurations][server-options].
For a full list of available options, run `parse-server --help` or take a look at [Parse Server Configurations](http://parseplatform.org/parse-server/api/master/ParseServerOptions.html).
## Parse Server Health
@@ -263,7 +325,7 @@ The response looks like this:
### Status Values
| Value | Description |
| ------------- | --------------------------------------------------------------------------- |
|---------------|-----------------------------------------------------------------------------|
| `initialized` | The server has been created but the `start` method has not been called yet. |
| `starting` | The server is starting up. |
| `ok` | The server started and is running. |
@@ -273,31 +335,31 @@ The response looks like this:
Parse Server can be configured using the following options. You may pass these as parameters when running a standalone `parse-server`, or by loading a configuration file in JSON format using `parse-server path/to/configuration.json`. If you're using Parse Server on Express, you may also pass these to the `ParseServer` object as options.
For the full list of available options, run `parse-server --help` or take a look at [Parse Server Configurations][server-options].
For the full list of available options, run `parse-server --help` or take a look at [Parse Server Configurations](http://parseplatform.org/parse-server/api/master/ParseServerOptions.html).
## Basic Options
- `appId` **(required)** - The application id to host with this server instance. You can use any arbitrary string. For migrated apps, this should match your hosted Parse app.
- `masterKey` **(required)** - The master key to use for overriding ACL security. You can use any arbitrary string. Keep it secret! For migrated apps, this should match your hosted Parse app.
- `databaseURI` **(required)** - The connection string for your database, i.e. `mongodb://user:pass@host.com/dbname`. Be sure to [URL encode your password](https://app.zencoder.com/docs/guides/getting-started/special-characters-in-usernames-and-passwords) if your password has special characters.
- `port` - The default port is 1337, specify this parameter to use a different port.
- `serverURL` - URL to your Parse Server (don't forget to specify http:// or https://). This URL will be used when making requests to Parse Server from Cloud Code.
- `cloud` - The absolute path to your cloud code `main.js` file.
- `push` - Configuration options for APNS and FCM push. See the [Push Notifications quick start](https://docs.parseplatform.org/parse-server/guide/#push-notifications-quick-start).
* `appId` **(required)** - The application id to host with this server instance. You can use any arbitrary string. For migrated apps, this should match your hosted Parse app.
* `masterKey` **(required)** - The master key to use for overriding ACL security. You can use any arbitrary string. Keep it secret! For migrated apps, this should match your hosted Parse app.
* `databaseURI` **(required)** - The connection string for your database, i.e. `mongodb://user:pass@host.com/dbname`. Be sure to [URL encode your password](https://app.zencoder.com/docs/guides/getting-started/special-characters-in-usernames-and-passwords) if your password has special characters.
* `port` - The default port is 1337, specify this parameter to use a different port.
* `serverURL` - URL to your Parse Server (don't forget to specify http:// or https://). This URL will be used when making requests to Parse Server from Cloud Code.
* `cloud` - The absolute path to your cloud code `main.js` file.
* `push` - Configuration options for APNS and GCM push. See the [Push Notifications quick start](https://docs.parseplatform.org/parse-server/guide/#push-notifications-quick-start).
## Client Key Options
The client keys used with Parse are no longer necessary with Parse Server. If you wish to still require them, perhaps to be able to refuse access to older clients, you can set the keys at initialization time. Setting any of these keys will require all requests to provide one of the configured keys.
- `clientKey`
- `javascriptKey`
- `restAPIKey`
- `dotNetKey`
* `clientKey`
* `javascriptKey`
* `restAPIKey`
* `dotNetKey`
## Access Scopes
| Scope | Internal data | Read-only data <sub>(1)</sub> | Custom data | Restricted by CLP, ACL | Key |
| -------------- | ------------- | ----------------------------- | ----------- | ---------------------- | ------------------- |
|----------------|---------------|-------------------------------|-------------|------------------------|---------------------|
| Internal | r/w | r/w | r/w | no | `maintenanceKey` |
| Master | -/- | r/- | r/w | no | `masterKey` |
| ReadOnlyMaster | -/- | r/- | r/- | no | `readOnlyMasterKey` |
@@ -305,12 +367,9 @@ The client keys used with Parse are no longer necessary with Parse Server. If yo
<sub>(1) `Parse.Object.createdAt`, `Parse.Object.updatedAt`.</sub>
> [!NOTE]
> In Cloud Code, both `masterKey` and `readOnlyMasterKey` set `request.master` to `true`. To distinguish between them, check `request.isReadOnly`. For example, use `request.master && !request.isReadOnly` to ensure full master key access.
## Email Verification and Password Reset
Verifying user email addresses and enabling password reset via email requires an email adapter. There are many email adapters provided and maintained by the community. The following is an example configuration with an example email adapter. See the [Parse Server Options][server-options] for more details and a full list of available options.
Verifying user email addresses and enabling password reset via email requires an email adapter. There are many email adapters provided and maintained by the community. The following is an example configuration with an example email adapter. See the [Parse Server Options](https://parseplatform.org/parse-server/api/master/ParseServerOptions.html) for more details and a full list of available options.
```js
const server = ParseServer({
@@ -327,18 +386,16 @@ const server = ParseServer({
module: 'example-mail-adapter',
options: {
// Additional adapter options
...mailAdapterOptions,
},
...mailAdapterOptions
}
},
});
```
Offical email adapters maintained by Parse Platform:
- [parse-server-api-mail-adapter](https://github.com/parse-community/parse-server-api-mail-adapter) (localization, templates, universally supports any email provider)
Email adapters contributed by the community:
- [parse-smtp-template](https://www.npmjs.com/package/parse-smtp-template) (localization, templates)
- [parse-server-postmark-adapter](https://www.npmjs.com/package/parse-server-postmark-adapter)
- [parse-server-sendgrid-adapter](https://www.npmjs.com/package/parse-server-sendgrid-adapter)
@@ -352,7 +409,7 @@ Email adapters contributed by the community:
## Password and Account Policy
Set a password and account policy that meets your security requirements. The following is an example configuration. See the [Parse Server Options][server-options] for more details and a full list of available options.
Set a password and account policy that meets your security requirements. The following is an example configuration. See the [Parse Server Options](https://parseplatform.org/parse-server/api/master/ParseServerOptions.html) for more details and a full list of available options.
```js
const server = ParseServer({
@@ -382,6 +439,8 @@ const server = ParseServer({
## Custom Routes
**Caution, this is an experimental feature that may not be appropriate for production.**
Custom routes allow to build user flows with webpages, similar to the existing password reset and email verification features. Custom routes are defined with the `pages` option in the Parse Server configuration:
### Example
@@ -391,6 +450,7 @@ const api = new ParseServer({
...otherOptions,
pages: {
enableRouter: true, // Enables the experimental feature; required for custom routes
customRoutes: [{
method: 'GET',
path: 'custom_route',
@@ -415,7 +475,7 @@ The `handler` receives the `request` and returns a `custom_page.html` webpage fr
The following paths are already used by Parse Server's built-in features and are therefore not available for custom routes. Custom routes with an identical combination of `path` and `method` are ignored.
| Path | HTTP Method | Feature |
| --------------------------- | ----------- | ------------------ |
|-----------------------------|-------------|--------------------|
| `verify_email` | `GET` | email verification |
| `resend_verification_email` | `POST` | email verification |
| `choose_password` | `GET` | password reset |
@@ -425,8 +485,9 @@ The following paths are already used by Parse Server's built-in features and are
### Parameters
| Parameter | Optional | Type | Default value | Example values | Environment variable | Description |
| ---------------------------- | -------- | --------------- | ------------- | --------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|------------------------------|----------|-----------------|---------------|-----------------------|------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `pages` | yes | `Object` | `undefined` | - | `PARSE_SERVER_PAGES` | The options for pages such as password reset and email verification. |
| `pages.enableRouter` | yes | `Boolean` | `false` | - | `PARSE_SERVER_PAGES_ENABLE_ROUTER` | Is `true` if the pages router should be enabled; this is required for any of the pages options to take effect. **Caution, this is an experimental feature that may not be appropriate for production.** |
| `pages.customRoutes` | yes | `Array` | `[]` | - | `PARSE_SERVER_PAGES_CUSTOM_ROUTES` | The custom routes. The routes are added in the order they are defined here, which has to be considered since requests traverse routes in an ordered manner. Custom routes are traversed after build-in routes such as password reset and email verification. |
| `pages.customRoutes.method` | | `String` | - | `GET`, `POST` | - | The HTTP method of the custom route. |
| `pages.customRoutes.path` | | `String` | - | `custom_page` | - | The path of the custom route. Note that the same path can used if the `method` is different, for example a path `custom_page` can have two routes, a `GET` and `POST` route, which will be invoked depending on the HTTP request method. |
@@ -441,16 +502,16 @@ const server = ParseServer({
...otherOptions,
customPages: {
passwordResetSuccess: 'http://yourapp.com/passwordResetSuccess',
verifyEmailSuccess: 'http://yourapp.com/verifyEmailSuccess',
parseFrameURL: 'http://yourapp.com/parseFrameURL',
linkSendSuccess: 'http://yourapp.com/linkSendSuccess',
linkSendFail: 'http://yourapp.com/linkSendFail',
invalidLink: 'http://yourapp.com/invalidLink',
invalidVerificationLink: 'http://yourapp.com/invalidVerificationLink',
choosePassword: 'http://yourapp.com/choosePassword',
},
});
passwordResetSuccess: "http://yourapp.com/passwordResetSuccess",
verifyEmailSuccess: "http://yourapp.com/verifyEmailSuccess",
parseFrameURL: "http://yourapp.com/parseFrameURL",
linkSendSuccess: "http://yourapp.com/linkSendSuccess",
linkSendFail: "http://yourapp.com/linkSendFail",
invalidLink: "http://yourapp.com/invalidLink",
invalidVerificationLink: "http://yourapp.com/invalidVerificationLink",
choosePassword: "http://yourapp.com/choosePassword"
}
})
```
## Using Environment Variables
@@ -486,40 +547,13 @@ You can also find more adapters maintained by the community by searching on [npm
Parse Server allows developers to choose from several options when hosting files:
- `GridFSBucketAdapter` - which is backed by MongoDB
- `S3Adapter` - which is backed by [Amazon S3](https://aws.amazon.com/s3/)
- `GCSAdapter` - which is backed by [Google Cloud Storage](https://cloud.google.com/storage/)
- `FSAdapter` - local file storage
* `GridFSBucketAdapter` - which is backed by MongoDB
* `S3Adapter` - which is backed by [Amazon S3](https://aws.amazon.com/s3/)
* `GCSAdapter` - which is backed by [Google Cloud Storage](https://cloud.google.com/storage/)
* `FSAdapter` - local file storage
`GridFSBucketAdapter` is used by default and requires no setup, but if you're interested in using Amazon S3, Google Cloud Storage, or local file storage, additional configuration information is available in the [Parse Server guide](http://docs.parseplatform.org/parse-server/guide/#configuring-file-adapters).
### Restricting File URL Domains
Parse objects can reference files by URL. To prevent [SSRF attacks](https://owasp.org/www-community/attacks/Server_Side_Request_Forgery) via crafted file URLs, you can restrict the allowed URL domains using the `fileUpload.allowedFileUrlDomains` option.
This protects against scenarios where an attacker provides a `Parse.File` with an arbitrary URL, for example as a Cloud Function parameter or in a field of type `Object` or `Array`. If Cloud Code or a client calls `getData()` on such a file, the Parse SDK makes an HTTP request to that URL, potentially leaking the server or client IP address and accessing internal services.
> [!NOTE]
> Fields of type `Parse.File` in the Parse schema are not affected by this attack, because Parse Server discards the URL on write and dynamically generates it on read based on the file adapter configuration.
```javascript
const parseServer = new ParseServer({
...otherOptions,
fileUpload: {
allowedFileUrlDomains: ['cdn.example.com', '*.example.com'],
},
});
```
| Parameter | Optional | Type | Default | Environment Variable |
|---|---|---|---|---|
| `fileUpload.allowedFileUrlDomains` | yes | `String[]` | `['*']` | `PARSE_SERVER_FILE_UPLOAD_ALLOWED_FILE_URL_DOMAINS` |
- `['*']` (default) allows file URLs with any domain.
- `['cdn.example.com']` allows only exact hostname matches.
- `['*.example.com']` allows any subdomain of `example.com`.
- `[]` blocks all file URLs; only files referenced by name are allowed.
## Idempotency Enforcement
**Caution, this is an experimental feature that may not be appropriate for production.**
@@ -546,7 +580,7 @@ let api = new ParseServer({
### Parameters <!-- omit in toc -->
| Parameter | Optional | Type | Default value | Example values | Environment variable | Description |
| -------------------------- | -------- | --------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|----------------------------|----------|-----------------|---------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `idempotencyOptions` | yes | `Object` | `undefined` | | PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_OPTIONS | Setting this enables idempotency enforcement for the specified paths. |
| `idempotencyOptions.paths` | yes | `Array<String>` | `[]` | `.*` (all paths, includes the examples below), <br>`functions/.*` (all functions), <br>`jobs/.*` (all jobs), <br>`classes/.*` (all classes), <br>`functions/.*` (all functions), <br>`users` (user creation / update), <br>`installations` (installation creation / update) | PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_PATHS | An array of path patterns that have to match the request path for request deduplication to be enabled. The mount path must not be included, for example to match the request path `/parse/functions/myFunction` specify the path pattern `functions/myFunction`. A trailing slash of the request path is ignored, for example the path pattern `functions/myFunction` matches both `/parse/functions/myFunction` and `/parse/functions/myFunction/`. |
| `idempotencyOptions.ttl` | yes | `Integer` | `300` | `60` (60 seconds) | PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_TTL | The duration in seconds after which a request record is discarded from the database. Duplicate requests due to network issues can be expected to arrive within milliseconds up to several seconds. This value must be greater than `0`. |
@@ -576,6 +610,8 @@ Assuming the script above is named, `parse_idempotency_delete_expired_records.sh
### Pages
**Caution, this is an experimental feature that may not be appropriate for production.**
Custom pages as well as feature pages (e.g. password reset, email verification) can be localized with the `pages` option in the Parse Server configuration:
```js
@@ -583,19 +619,18 @@ const api = new ParseServer({
...otherOptions,
pages: {
enableRouter: true, // Enables the experimental feature; required for localization
enableLocalization: true,
}
}
```
Localization is achieved by matching a request-supplied `locale` parameter with localized page content. The locale can be supplied in either the request query, body or header with the following keys:
- query: `locale`
- body: `locale`
- header: `x-parse-page-param-locale`
For example, a password reset link with the locale parameter in the query could look like this:
```
http://example.com/parse/apps/[appId]/request_password_reset?token=[token]&username=[username]&locale=de-AT
```
@@ -611,7 +646,6 @@ Pages can be localized in two ways:
Pages are localized by using the corresponding file in the directory structure where the files are placed in subdirectories named after the locale or language. The file in the base directory is the default file.
**Example Directory Structure:**
```js
root/
├── public/ // pages base path
@@ -623,18 +657,17 @@ root/
```
Files are matched with the locale in the following order:
1. Locale match, e.g. locale `de-AT` matches file in folder `de-AT`.
1. Language match, e.g. locale `de-CH` matches file in folder `de`.
1. Default; file in base folder is returned.
**Configuration Example:**
```js
const api = new ParseServer({
...otherOptions,
pages: {
enableRouter: true, // Enables the experimental feature; required for localization
enableLocalization: true,
customUrls: {
passwordReset: 'https://example.com/page.html'
@@ -644,11 +677,9 @@ const api = new ParseServer({
```
Pros:
- All files are complete in their content and can be easily opened and previewed by viewing the file in a browser.
Cons:
- In most cases, a localized page differs only slightly from the default page, which could cause a lot of duplicate code that is difficult to maintain.
#### Localization with JSON Resource
@@ -656,7 +687,6 @@ Cons:
Pages are localized by adding placeholders in the HTML files and providing a JSON resource that contains the translations to fill into the placeholders.
**Example Directory Structure:**
```js
root/
├── public/ // pages base path
@@ -668,7 +698,6 @@ root/
The JSON resource file loosely follows the [i18next](https://github.com/i18next/i18next) syntax, which is a syntax that is often supported by translation platforms, making it easy to manage translations, exporting them for use in Parse Server, and even to automate this workflow.
**Example JSON Content:**
```json
{
"en": { // resource for language `en` (English)
@@ -690,12 +719,12 @@ The JSON resource file loosely follows the [i18next](https://github.com/i18next/
```
**Configuration Example:**
```js
const api = new ParseServer({
...otherOptions,
pages: {
enableRouter: true, // Enables the experimental feature; required for localization
enableLocalization: true,
localizationJsonPath: './private/localization.json',
localizationFallbackLocale: 'en'
@@ -704,11 +733,9 @@ const api = new ParseServer({
```
Pros:
- There is only one HTML file to maintain that contains the placeholders that are filled with the translations according to the locale.
Cons:
- Files cannot be easily previewed by viewing the file in a browser because the content contains only placeholders and even HTML or CSS changes may be dynamically applied, e.g. when a localization requires a Right-To-Left layout direction.
- Style and other fundamental layout changes may be more difficult to apply.
@@ -717,19 +744,18 @@ Cons:
In addition to feature related default parameters such as `appId` and the translations provided via JSON resource, it is possible to define custom dynamic placeholders as part of the router configuration. This works independently of localization and, also if `enableLocalization` is disabled.
**Configuration Example:**
```js
const api = new ParseServer({
...otherOptions,
pages: {
enableRouter: true, // Enables the experimental feature; required for localization
placeholders: {
exampleKey: 'exampleValue'
}
}
}
```
The placeholders can also be provided as function or as async function, with the `locale` and other feature related parameters passed through, to allow for dynamic placeholder values:
```js
@@ -737,6 +763,7 @@ const api = new ParseServer({
...otherOptions,
pages: {
enableRouter: true, // Enables the experimental feature; required for localization
placeholders: async (params) => {
const value = await doSomething(params.locale);
return {
@@ -754,8 +781,9 @@ The following parameter and placeholder keys are reserved because they are used
#### Parameters
| Parameter | Optional | Type | Default value | Example values | Environment variable | Description |
| ----------------------------------------------- | -------- | ------------------------------------- | -------------------------------------- | ---------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|-------------------------------------------------|----------|---------------------------------------|----------------------------------------|------------------------------------------------------|-----------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `pages` | yes | `Object` | `undefined` | - | `PARSE_SERVER_PAGES` | The options for pages such as password reset and email verification. |
| `pages.enableRouter` | yes | `Boolean` | `false` | - | `PARSE_SERVER_PAGES_ENABLE_ROUTER` | Is `true` if the pages router should be enabled; this is required for any of the pages options to take effect. **Caution, this is an experimental feature that may not be appropriate for production.** |
| `pages.enableLocalization` | yes | `Boolean` | `false` | - | `PARSE_SERVER_PAGES_ENABLE_LOCALIZATION` | Is true if pages should be localized; this has no effect on custom page redirects. |
| `pages.localizationJsonPath` | yes | `String` | `undefined` | `./private/translations.json` | `PARSE_SERVER_PAGES_LOCALIZATION_JSON_PATH` | The path to the JSON file for localization; the translations will be used to fill template placeholders according to the locale. |
| `pages.localizationFallbackLocale` | yes | `String` | `en` | `en`, `en-GB`, `default` | `PARSE_SERVER_PAGES_LOCALIZATION_FALLBACK_LOCALE` | The fallback locale for localization if no matching translation is provided for the given locale. This is only relevant when providing translation resources via JSON file. |
@@ -780,19 +808,18 @@ The following parameter and placeholder keys are reserved because they are used
## Logging
Parse Server will, by default, log:
- to the console
- daily rotating files as new line delimited JSON
* to the console
* daily rotating files as new line delimited JSON
Logs are also viewable in Parse Dashboard.
**Want to log each request and response?** Set the `VERBOSE` environment variable when starting `parse-server`. Usage :- `VERBOSE='1' parse-server --appId APPLICATION_ID --masterKey MASTER_KEY`
**Want to log each request and response?** Set the `VERBOSE` environment variable when starting `parse-server`. Usage :- `VERBOSE='1' parse-server --appId APPLICATION_ID --masterKey MASTER_KEY`
**Want logs to be placed in a different folder?** Pass the `PARSE_SERVER_LOGS_FOLDER` environment variable when starting `parse-server`. Usage :- `PARSE_SERVER_LOGS_FOLDER='<path-to-logs-folder>' parse-server --appId APPLICATION_ID --masterKey MASTER_KEY`
**Want logs to be placed in a different folder?** Pass the `PARSE_SERVER_LOGS_FOLDER` environment variable when starting `parse-server`. Usage :- `PARSE_SERVER_LOGS_FOLDER='<path-to-logs-folder>' parse-server --appId APPLICATION_ID --masterKey MASTER_KEY`
**Want to log specific levels?** Pass the `logLevel` parameter when starting `parse-server`. Usage :- `parse-server --appId APPLICATION_ID --masterKey MASTER_KEY --logLevel LOG_LEVEL`
**Want to log specific levels?** Pass the `logLevel` parameter when starting `parse-server`. Usage :- `parse-server --appId APPLICATION_ID --masterKey MASTER_KEY --logLevel LOG_LEVEL`
**Want new line delimited JSON error logs (for consumption by CloudWatch, Google Cloud Logging, etc)?** Pass the `JSON_LOGS` environment variable when starting `parse-server`. Usage :- `JSON_LOGS='1' parse-server --appId APPLICATION_ID --masterKey MASTER_KEY`
**Want new line delimited JSON error logs (for consumption by CloudWatch, Google Cloud Logging, etc)?** Pass the `JSON_LOGS` environment variable when starting `parse-server`. Usage :- `JSON_LOGS='1' parse-server --appId APPLICATION_ID --masterKey MASTER_KEY`
# Deprecations
@@ -822,7 +849,7 @@ $ parse-server --appId APPLICATION_ID --masterKey MASTER_KEY --databaseURI mongo
After starting the server, you can visit http://localhost:1337/playground in your browser to start playing with your GraphQL API.
**_Note:_** Do **_NOT_** use --mountPlayground option in production. The GraphQL Playground exposes the master key in the browser page. [Parse Dashboard](https://github.com/parse-community/parse-dashboard) has a built-in GraphQL Playground and is the recommended option for production apps.
***Note:*** Do ***NOT*** use --mountPlayground option in production. [Parse Dashboard](https://github.com/parse-community/parse-dashboard) has a built-in GraphQL Playground and it is the recommended option for production apps.
### Using Docker
@@ -841,11 +868,11 @@ $ docker run --name my-mongo -d mongo
$ docker run --name my-parse-server --link my-mongo:mongo -v config-vol:/parse-server/config -p 1337:1337 -d parse-server --appId APPLICATION_ID --masterKey MASTER_KEY --databaseURI mongodb://mongo/test --publicServerURL http://localhost:1337/parse --mountGraphQL --mountPlayground
```
**_Note:_** _If you want to use [Cloud Code](https://docs.parseplatform.org/cloudcode/guide/), add `-v cloud-code-vol:/parse-server/cloud --cloud /parse-server/cloud/main.js` to the command above. Make sure `main.js` is in the `cloud-code-vol` directory before starting Parse Server._
***Note:*** *If you want to use [Cloud Code](https://docs.parseplatform.org/cloudcode/guide/), add `-v cloud-code-vol:/parse-server/cloud --cloud /parse-server/cloud/main.js` to the command above. Make sure `main.js` is in the `cloud-code-vol` directory before starting Parse Server.*
After starting the server, you can visit http://localhost:1337/playground in your browser to start playing with your GraphQL API.
**_Note:_** Do **_NOT_** use --mountPlayground option in production. The GraphQL Playground exposes the master key in the browser page. [Parse Dashboard](https://github.com/parse-community/parse-dashboard) has a built-in GraphQL Playground and is the recommended option for production apps.
***Note:*** Do ***NOT*** use --mountPlayground option in production. [Parse Dashboard](https://github.com/parse-community/parse-dashboard) has a built-in GraphQL Playground and it is the recommended option for production apps.
### Using Express.js
@@ -870,20 +897,23 @@ const parseServer = new ParseServer({
appId: 'APPLICATION_ID',
masterKey: 'MASTER_KEY',
serverURL: 'http://localhost:1337/parse',
publicServerURL: 'http://localhost:1337/parse',
publicServerURL: 'http://localhost:1337/parse'
});
const parseGraphQLServer = new ParseGraphQLServer(parseServer, {
graphQLPath: '/graphql',
playgroundPath: '/playground',
});
const parseGraphQLServer = new ParseGraphQLServer(
parseServer,
{
graphQLPath: '/graphql',
playgroundPath: '/playground'
}
);
app.use('/parse', parseServer.app); // (Optional) Mounts the REST API
parseGraphQLServer.applyGraphQL(app); // Mounts the GraphQL API
parseGraphQLServer.applyPlayground(app); // (Optional) Mounts the GraphQL Playground - do NOT use in Production
await parseServer.start();
app.listen(1337, function () {
app.listen(1337, function() {
console.log('REST API running on http://localhost:1337/parse');
console.log('GraphQL API running on http://localhost:1337/graphql');
console.log('GraphQL Playground running on http://localhost:1337/playground');
@@ -899,7 +929,7 @@ $ node index.js
After starting the app, you can visit http://localhost:1337/playground in your browser to start playing with your GraphQL API.
**_Note:_** Do **_NOT_** mount the GraphQL Playground in production. The GraphQL Playground exposes the master key in the browser page. [Parse Dashboard](https://github.com/parse-community/parse-dashboard) has a built-in GraphQL Playground and is the recommended option for production apps.
***Note:*** Do ***NOT*** mount the GraphQL Playground in production. [Parse Dashboard](https://github.com/parse-community/parse-dashboard) has a built-in GraphQL Playground and it is the recommended option for production apps.
## Checking the API health
@@ -994,7 +1024,13 @@ Run the following to create your first object:
```graphql
mutation CreateGameScore {
createGameScore(fields: { playerName: "Sean Plott", score: 1337, cheatMode: false }) {
createGameScore(
fields: {
playerName: "Sean Plott"
score: 1337
cheatMode: false
}
) {
id
updatedAt
createdAt
@@ -1173,5 +1209,3 @@ Support us with a monthly donation and help us continue our activities. [Become
[log_release]: https://github.com/parse-community/parse-server/blob/release/changelogs/CHANGELOG_release.md
[log_beta]: https://github.com/parse-community/parse-server/blob/beta/changelogs/CHANGELOG_beta.md
[log_alpha]: https://github.com/parse-community/parse-server/blob/alpha/changelogs/CHANGELOG_alpha.md
[server-options] http://parseplatform.org/parse-server/api/release/ParseServerOptions.html
-137
View File
@@ -1,137 +0,0 @@
/**
* MongoDB Latency Wrapper
*
* Utility to inject artificial latency into MongoDB operations for performance testing.
* This wrapper temporarily wraps MongoDB Collection methods to add delays before
* database operations execute.
*
* Usage:
* const { wrapMongoDBWithLatency } = require('./MongoLatencyWrapper');
*
* // Before initializing Parse Server
* const unwrap = wrapMongoDBWithLatency(10); // 10ms delay
*
* // ... run benchmarks ...
*
* // Cleanup when done
* unwrap();
*/
const { Collection } = require('mongodb');
// Store original methods for restoration
const originalMethods = new Map();
/**
* Wrap a Collection method to add artificial latency
* @param {string} methodName - Name of the method to wrap
* @param {number} latencyMs - Delay in milliseconds
*/
function wrapMethod(methodName, latencyMs) {
if (!originalMethods.has(methodName)) {
originalMethods.set(methodName, Collection.prototype[methodName]);
}
const originalMethod = originalMethods.get(methodName);
Collection.prototype[methodName] = function (...args) {
// For methods that return cursors (like find, aggregate), we need to delay the execution
// but still return a cursor-like object
const result = originalMethod.apply(this, args);
// Check if result has cursor methods (toArray, forEach, etc.)
if (result && typeof result.toArray === 'function') {
// Wrap cursor methods that actually execute the query
const originalToArray = result.toArray.bind(result);
result.toArray = function() {
// Wait for the original promise to settle, then delay the result
return originalToArray().then(
value => new Promise(resolve => setTimeout(() => resolve(value), latencyMs)),
error => new Promise((_, reject) => setTimeout(() => reject(error), latencyMs))
);
};
return result;
}
// For promise-returning methods, wrap the promise with delay
if (result && typeof result.then === 'function') {
// Wait for the original promise to settle, then delay the result
return result.then(
value => new Promise(resolve => setTimeout(() => resolve(value), latencyMs)),
error => new Promise((_, reject) => setTimeout(() => reject(error), latencyMs))
);
}
// For synchronous methods, just add delay
return new Promise((resolve) => {
setTimeout(() => {
resolve(result);
}, latencyMs);
});
};
}
/**
* Wrap MongoDB Collection methods with artificial latency
* @param {number} latencyMs - Delay in milliseconds to inject before each operation
* @returns {Function} unwrap - Function to restore original methods
*/
function wrapMongoDBWithLatency(latencyMs) {
if (typeof latencyMs !== 'number' || latencyMs < 0) {
throw new Error('latencyMs must be a non-negative number');
}
if (latencyMs === 0) {
// eslint-disable-next-line no-console
console.log('Latency is 0ms, skipping MongoDB wrapping');
return () => {}; // No-op unwrap function
}
// eslint-disable-next-line no-console
console.log(`Wrapping MongoDB operations with ${latencyMs}ms artificial latency`);
// List of MongoDB Collection methods to wrap
const methodsToWrap = [
'find',
'findOne',
'countDocuments',
'estimatedDocumentCount',
'distinct',
'aggregate',
'insertOne',
'insertMany',
'updateOne',
'updateMany',
'replaceOne',
'deleteOne',
'deleteMany',
'findOneAndUpdate',
'findOneAndReplace',
'findOneAndDelete',
'createIndex',
'createIndexes',
'dropIndex',
'dropIndexes',
'drop',
];
methodsToWrap.forEach(methodName => {
wrapMethod(methodName, latencyMs);
});
// Return unwrap function to restore original methods
return function unwrap() {
// eslint-disable-next-line no-console
console.log('Removing MongoDB latency wrapper, restoring original methods');
originalMethods.forEach((originalMethod, methodName) => {
Collection.prototype[methodName] = originalMethod;
});
originalMethods.clear();
};
}
module.exports = {
wrapMongoDBWithLatency,
};
-903
View File
@@ -1,903 +0,0 @@
/**
* Performance Benchmark Suite for Parse Server
*
* This suite measures the performance of critical Parse Server operations
* using the Node.js Performance API. Results are output in a format
* compatible with github-action-benchmark.
*
* Run with: npm run benchmark
*/
const Parse = require('parse/node');
const { performance } = require('node:perf_hooks');
const { MongoClient } = require('mongodb');
const { wrapMongoDBWithLatency } = require('./MongoLatencyWrapper');
// Configuration
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/parse_benchmark_test';
const SERVER_URL = 'http://localhost:1337/parse';
const APP_ID = 'benchmark-app-id';
const MASTER_KEY = 'benchmark-master-key';
const ITERATIONS = process.env.BENCHMARK_ITERATIONS ? parseInt(process.env.BENCHMARK_ITERATIONS, 10) : undefined;
const LOG_ITERATIONS = false;
// Parse Server instance
let parseServer;
let httpServer;
let mongoClient;
let core;
// Logging helpers
const logInfo = message => core.info(message);
const logError = message => core.error(message);
/**
* Initialize Parse Server for benchmarking
*/
async function initializeParseServer() {
const express = require('express');
const { default: ParseServer } = require('../lib/index.js');
const app = express();
parseServer = new ParseServer({
databaseURI: MONGODB_URI,
appId: APP_ID,
masterKey: MASTER_KEY,
serverURL: SERVER_URL,
silent: true,
allowClientClassCreation: true,
logLevel: 'error', // Minimal logging for performance
verbose: false,
liveQuery: { classNames: ['BenchmarkLiveQuery'] },
});
app.use('/parse', parseServer.app);
return new Promise((resolve, reject) => {
const server = app.listen(1337, (err) => {
if (err) {
reject(new Error(`Failed to start server: ${err.message}`));
return;
}
Parse.initialize(APP_ID);
Parse.masterKey = MASTER_KEY;
Parse.serverURL = SERVER_URL;
resolve(server);
});
server.on('error', (err) => {
reject(new Error(`Server error: ${err.message}`));
});
});
}
/**
* Clean up database between benchmarks
*/
async function cleanupDatabase() {
try {
if (!mongoClient) {
mongoClient = await MongoClient.connect(MONGODB_URI);
}
const db = mongoClient.db();
const collections = await db.listCollections().toArray();
for (const collection of collections) {
if (!collection.name.startsWith('system.')) {
await db.collection(collection.name).deleteMany({});
}
}
} catch (error) {
throw new Error(`Failed to cleanup database: ${error.message}`);
}
}
/**
* Reset Parse SDK to use the default server
*/
function resetParseServer() {
Parse.serverURL = SERVER_URL;
}
/**
* Measure average time for an async operation over multiple iterations.
* @param {Object} options Measurement options.
* @param {string} options.name Name of the operation being measured.
* @param {Function} options.operation Async function to measure.
* @param {number} options.iterations Number of iterations to run; choose a value that is high
* enough to create reliable benchmark metrics with low variance but low enough to keep test
* duration reasonable around <=10 seconds.
* @param {boolean} [options.skipWarmup=false] Skip warmup phase.
* @param {number} [options.dbLatency] Artificial DB latency in milliseconds to apply during
* this benchmark.
*/
async function measureOperation({ name, operation, iterations, skipWarmup = false, dbLatency }) {
// Override iterations if global ITERATIONS is set
iterations = ITERATIONS || iterations;
// Determine warmup count (20% of iterations)
const warmupCount = skipWarmup ? 0 : Math.floor(iterations * 0.2);
const times = [];
// Apply artificial latency if specified
let unwrapLatency = null;
if (dbLatency !== undefined && dbLatency > 0) {
logInfo(`Applying ${dbLatency}ms artificial DB latency for this benchmark`);
unwrapLatency = wrapMongoDBWithLatency(dbLatency);
}
try {
if (warmupCount > 0) {
logInfo(`Starting warmup phase of ${warmupCount} iterations...`);
const warmupStart = performance.now();
for (let i = 0; i < warmupCount; i++) {
await operation();
}
logInfo(`Warmup took: ${(performance.now() - warmupStart).toFixed(2)}ms`);
}
// Measurement phase
logInfo(`Starting measurement phase of ${iterations} iterations...`);
const progressInterval = Math.ceil(iterations / 10); // Log every 10%
const measurementStart = performance.now();
for (let i = 0; i < iterations; i++) {
const start = performance.now();
await operation();
const end = performance.now();
const duration = end - start;
times.push(duration);
// Log progress every 10% or individual iterations if LOG_ITERATIONS is enabled
if (LOG_ITERATIONS) {
logInfo(`Iteration ${i + 1}: ${duration.toFixed(2)}ms`);
} else if ((i + 1) % progressInterval === 0 || i + 1 === iterations) {
const progress = Math.round(((i + 1) / iterations) * 100);
logInfo(`Progress: ${progress}%`);
}
}
logInfo(`Measurement took: ${(performance.now() - measurementStart).toFixed(2)}ms`);
// Sort times for percentile calculations
times.sort((a, b) => a - b);
// Filter outliers using Interquartile Range (IQR) method
const q1Index = Math.floor(times.length * 0.25);
const q3Index = Math.floor(times.length * 0.75);
const q1 = times[q1Index];
const q3 = times[q3Index];
const iqr = q3 - q1;
const lowerBound = q1 - 1.5 * iqr;
const upperBound = q3 + 1.5 * iqr;
const filtered = times.filter(t => t >= lowerBound && t <= upperBound);
// Calculate statistics on filtered data
const median = filtered[Math.floor(filtered.length * 0.5)];
const p95 = filtered[Math.floor(filtered.length * 0.95)];
const p99 = filtered[Math.floor(filtered.length * 0.99)];
const min = filtered[0];
const max = filtered[filtered.length - 1];
return {
name,
value: median, // Use median (p50) as primary metric for stability in CI
unit: 'ms',
range: `${min.toFixed(2)} - ${max.toFixed(2)}`,
extra: `p95: ${p95.toFixed(2)}ms, p99: ${p99.toFixed(2)}ms, n=${filtered.length}/${times.length}`,
};
} finally {
// Remove latency wrapper if it was applied
if (unwrapLatency) {
unwrapLatency();
logInfo('Removed artificial DB latency');
}
}
}
/**
* Measure GC pressure for an async operation over multiple iterations.
* Tracks garbage collection duration per operation using PerformanceObserver.
* Larger transient allocations (e.g., from unbounded cursor batch sizes) cause
* more frequent and longer GC pauses, which this metric directly captures.
* @param {Object} options Measurement options.
* @param {string} options.name Name of the operation being measured.
* @param {Function} options.operation Async function to measure.
* @param {number} options.iterations Number of iterations to run.
* @param {boolean} [options.skipWarmup=false] Skip warmup phase.
*/
async function measureMemoryOperation({ name, operation, iterations, skipWarmup = false }) {
const { PerformanceObserver } = require('node:perf_hooks');
// Override iterations if global ITERATIONS is set
iterations = ITERATIONS || iterations;
// Determine warmup count (20% of iterations)
const warmupCount = skipWarmup ? 0 : Math.floor(iterations * 0.2);
const gcDurations = [];
if (warmupCount > 0) {
logInfo(`Starting warmup phase of ${warmupCount} iterations...`);
for (let i = 0; i < warmupCount; i++) {
await operation();
}
logInfo('Warmup complete.');
}
// Measurement phase
logInfo(`Starting measurement phase of ${iterations} iterations...`);
const progressInterval = Math.ceil(iterations / 10);
for (let i = 0; i < iterations; i++) {
// Force GC before each iteration to start from a clean state
if (typeof global.gc === 'function') {
global.gc();
}
// Track GC events during this iteration; measure the longest single GC pause,
// which reflects the production impact of large transient allocations
let maxGcPause = 0;
const obs = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > maxGcPause) {
maxGcPause = entry.duration;
}
}
});
obs.observe({ type: 'gc', buffered: false });
await operation();
// Flush any buffered entries before disconnecting to avoid data loss
for (const entry of obs.takeRecords()) {
if (entry.duration > maxGcPause) {
maxGcPause = entry.duration;
}
}
obs.disconnect();
gcDurations.push(maxGcPause);
if (LOG_ITERATIONS) {
logInfo(`Iteration ${i + 1}: ${maxGcPause.toFixed(2)} ms GC`);
} else if ((i + 1) % progressInterval === 0 || i + 1 === iterations) {
const progress = Math.round(((i + 1) / iterations) * 100);
logInfo(`Progress: ${progress}%`);
}
}
// Sort for percentile calculations
gcDurations.sort((a, b) => a - b);
// Filter outliers using IQR method
const q1Index = Math.floor(gcDurations.length * 0.25);
const q3Index = Math.floor(gcDurations.length * 0.75);
const q1 = gcDurations[q1Index];
const q3 = gcDurations[q3Index];
const iqr = q3 - q1;
const lowerBound = q1 - 1.5 * iqr;
const upperBound = q3 + 1.5 * iqr;
const filtered = gcDurations.filter(d => d >= lowerBound && d <= upperBound);
const median = filtered[Math.floor(filtered.length * 0.5)];
const p95 = filtered[Math.floor(filtered.length * 0.95)];
const p99 = filtered[Math.floor(filtered.length * 0.99)];
const min = filtered[0];
const max = filtered[filtered.length - 1];
return {
name,
value: median,
unit: 'ms',
range: `${min.toFixed(2)} - ${max.toFixed(2)}`,
extra: `p95: ${p95.toFixed(2)}ms, p99: ${p99.toFixed(2)}ms, n=${filtered.length}/${gcDurations.length}`,
};
}
/**
* Benchmark: Object Create
*/
async function benchmarkObjectCreate(name) {
let counter = 0;
return measureOperation({
name,
iterations: 1_000,
operation: async () => {
const TestObject = Parse.Object.extend('BenchmarkTest');
const obj = new TestObject();
obj.set('testField', `test-value-${counter++}`);
obj.set('number', counter);
obj.set('boolean', true);
await obj.save();
},
});
}
/**
* Benchmark: Object Read (by ID)
*/
async function benchmarkObjectRead(name) {
// Setup: Create test objects
const TestObject = Parse.Object.extend('BenchmarkTest');
const objects = [];
for (let i = 0; i < 1_000; i++) {
const obj = new TestObject();
obj.set('testField', `read-test-${i}`);
objects.push(obj);
}
await Parse.Object.saveAll(objects);
let counter = 0;
return measureOperation({
name,
iterations: 1_000,
operation: async () => {
const query = new Parse.Query('BenchmarkTest');
await query.get(objects[counter++ % objects.length].id);
},
});
}
/**
* Benchmark: Object Update
*/
async function benchmarkObjectUpdate(name) {
// Setup: Create test objects
const TestObject = Parse.Object.extend('BenchmarkTest');
const objects = [];
for (let i = 0; i < 1_000; i++) {
const obj = new TestObject();
obj.set('testField', `update-test-${i}`);
obj.set('counter', 0);
objects.push(obj);
}
await Parse.Object.saveAll(objects);
let counter = 0;
return measureOperation({
name,
iterations: 1_000,
operation: async () => {
const obj = objects[counter++ % objects.length];
obj.increment('counter');
obj.set('lastUpdated', new Date());
await obj.save();
},
});
}
/**
* Benchmark: Simple Query
*/
async function benchmarkSimpleQuery(name) {
// Setup: Create test data
const TestObject = Parse.Object.extend('BenchmarkTest');
const objects = [];
for (let i = 0; i < 100; i++) {
const obj = new TestObject();
obj.set('category', i % 10);
obj.set('value', i);
objects.push(obj);
}
await Parse.Object.saveAll(objects);
let counter = 0;
return measureOperation({
name,
iterations: 1_000,
operation: async () => {
const query = new Parse.Query('BenchmarkTest');
query.equalTo('category', counter++ % 10);
await query.find();
},
});
}
/**
* Benchmark: Batch Save (saveAll)
*/
async function benchmarkBatchSave(name) {
const BATCH_SIZE = 10;
return measureOperation({
name,
iterations: 1_000,
operation: async () => {
const TestObject = Parse.Object.extend('BenchmarkTest');
const objects = [];
for (let i = 0; i < BATCH_SIZE; i++) {
const obj = new TestObject();
obj.set('batchField', `batch-${i}`);
obj.set('timestamp', new Date());
objects.push(obj);
}
await Parse.Object.saveAll(objects);
},
});
}
/**
* Benchmark: User Signup
*/
async function benchmarkUserSignup(name) {
let counter = 0;
return measureOperation({
name,
iterations: 500,
operation: async () => {
counter++;
const user = new Parse.User();
user.set('username', `benchmark_user_${Date.now()}_${counter}`);
user.set('password', 'benchmark_password');
user.set('email', `benchmark${counter}@example.com`);
await user.signUp();
},
});
}
/**
* Benchmark: User Login
*/
async function benchmarkUserLogin(name) {
// Setup: Create test users
const users = [];
for (let i = 0; i < 10; i++) {
const user = new Parse.User();
user.set('username', `benchmark_login_user_${i}`);
user.set('password', 'benchmark_password');
user.set('email', `login${i}@example.com`);
await user.signUp();
users.push({ username: user.get('username'), password: 'benchmark_password' });
await Parse.User.logOut();
}
let counter = 0;
return measureOperation({
name,
iterations: 500,
operation: async () => {
const userCreds = users[counter++ % users.length];
await Parse.User.logIn(userCreds.username, userCreds.password);
await Parse.User.logOut();
},
});
}
/**
* Benchmark: Query with Include (Parallel Pointers)
* Tests the performance improvement when fetching multiple pointers at the same level.
*/
async function benchmarkQueryWithIncludeParallel(name) {
const PointerAClass = Parse.Object.extend('PointerA');
const PointerBClass = Parse.Object.extend('PointerB');
const PointerCClass = Parse.Object.extend('PointerC');
const RootClass = Parse.Object.extend('Root');
// Create pointer objects
const pointerAObjects = [];
for (let i = 0; i < 10; i++) {
const obj = new PointerAClass();
obj.set('name', `pointerA-${i}`);
pointerAObjects.push(obj);
}
await Parse.Object.saveAll(pointerAObjects);
const pointerBObjects = [];
for (let i = 0; i < 10; i++) {
const obj = new PointerBClass();
obj.set('name', `pointerB-${i}`);
pointerBObjects.push(obj);
}
await Parse.Object.saveAll(pointerBObjects);
const pointerCObjects = [];
for (let i = 0; i < 10; i++) {
const obj = new PointerCClass();
obj.set('name', `pointerC-${i}`);
pointerCObjects.push(obj);
}
await Parse.Object.saveAll(pointerCObjects);
// Create Root objects with multiple pointers at the same level
const rootObjects = [];
for (let i = 0; i < 10; i++) {
const obj = new RootClass();
obj.set('name', `root-${i}`);
obj.set('pointerA', pointerAObjects[i % pointerAObjects.length]);
obj.set('pointerB', pointerBObjects[i % pointerBObjects.length]);
obj.set('pointerC', pointerCObjects[i % pointerCObjects.length]);
rootObjects.push(obj);
}
await Parse.Object.saveAll(rootObjects);
return measureOperation({
name,
skipWarmup: true,
dbLatency: 100,
iterations: 100,
operation: async () => {
const query = new Parse.Query('Root');
// Include multiple pointers at the same level - should fetch in parallel
query.include(['pointerA', 'pointerB', 'pointerC']);
await query.find();
},
});
}
/**
* Benchmark: Query with Include (Nested Pointers with Parallel Leaf Nodes)
* Tests the PR's optimization for parallel fetching at each nested level.
* Pattern: p1.p2.p3, p1.p2.p4, p1.p2.p5
* After fetching p2, we know the objectIds and can fetch p3, p4, p5 in parallel.
*/
async function benchmarkQueryWithIncludeNested(name) {
const Level3AClass = Parse.Object.extend('Level3A');
const Level3BClass = Parse.Object.extend('Level3B');
const Level3CClass = Parse.Object.extend('Level3C');
const Level2Class = Parse.Object.extend('Level2');
const Level1Class = Parse.Object.extend('Level1');
const RootClass = Parse.Object.extend('Root');
// Create Level3 objects (leaf nodes)
const level3AObjects = [];
for (let i = 0; i < 10; i++) {
const obj = new Level3AClass();
obj.set('name', `level3A-${i}`);
level3AObjects.push(obj);
}
await Parse.Object.saveAll(level3AObjects);
const level3BObjects = [];
for (let i = 0; i < 10; i++) {
const obj = new Level3BClass();
obj.set('name', `level3B-${i}`);
level3BObjects.push(obj);
}
await Parse.Object.saveAll(level3BObjects);
const level3CObjects = [];
for (let i = 0; i < 10; i++) {
const obj = new Level3CClass();
obj.set('name', `level3C-${i}`);
level3CObjects.push(obj);
}
await Parse.Object.saveAll(level3CObjects);
// Create Level2 objects pointing to multiple Level3 objects
const level2Objects = [];
for (let i = 0; i < 10; i++) {
const obj = new Level2Class();
obj.set('name', `level2-${i}`);
obj.set('level3A', level3AObjects[i % level3AObjects.length]);
obj.set('level3B', level3BObjects[i % level3BObjects.length]);
obj.set('level3C', level3CObjects[i % level3CObjects.length]);
level2Objects.push(obj);
}
await Parse.Object.saveAll(level2Objects);
// Create Level1 objects pointing to Level2
const level1Objects = [];
for (let i = 0; i < 10; i++) {
const obj = new Level1Class();
obj.set('name', `level1-${i}`);
obj.set('level2', level2Objects[i % level2Objects.length]);
level1Objects.push(obj);
}
await Parse.Object.saveAll(level1Objects);
// Create Root objects pointing to Level1
const rootObjects = [];
for (let i = 0; i < 10; i++) {
const obj = new RootClass();
obj.set('name', `root-${i}`);
obj.set('level1', level1Objects[i % level1Objects.length]);
rootObjects.push(obj);
}
await Parse.Object.saveAll(rootObjects);
return measureOperation({
name,
skipWarmup: true,
dbLatency: 100,
iterations: 100,
operation: async () => {
const query = new Parse.Query('Root');
// After fetching level1.level2, the PR should fetch level3A, level3B, level3C in parallel
query.include(['level1.level2.level3A', 'level1.level2.level3B', 'level1.level2.level3C']);
await query.find();
},
});
}
/**
* Benchmark: Large Result Set GC Pressure
* Measures max GC pause when querying many large documents, which is affected
* by MongoDB cursor batch size configuration. Without a batch size limit,
* the driver processes larger data chunks between yield points, creating more
* garbage that triggers longer GC pauses.
*/
async function benchmarkLargeResultMemory(name) {
const TestObject = Parse.Object.extend('BenchmarkLargeResult');
const TOTAL_OBJECTS = 3_000;
const SAVE_BATCH_SIZE = 200;
// Seed data in batches; ~8 KB per document so 3,000 docs ≈ 24 MB total,
// exceeding MongoDB's 16 MiB default batch limit to test cursor batching
for (let i = 0; i < TOTAL_OBJECTS; i += SAVE_BATCH_SIZE) {
const batch = [];
for (let j = 0; j < SAVE_BATCH_SIZE && i + j < TOTAL_OBJECTS; j++) {
const obj = new TestObject();
obj.set('category', (i + j) % 10);
obj.set('value', i + j);
obj.set('data', `padding-${i + j}-${'x'.repeat(8000)}`);
batch.push(obj);
}
await Parse.Object.saveAll(batch);
}
return measureMemoryOperation({
name,
iterations: 100,
operation: async () => {
const query = new Parse.Query('BenchmarkLargeResult');
query.limit(TOTAL_OBJECTS);
await query.find({ useMasterKey: true });
},
});
}
/**
* Benchmark: Concurrent Query GC Pressure
* Measures max GC pause under concurrent load with large result sets.
* Simulates production conditions where multiple clients query simultaneously,
* compounding GC pressure from cursor batch sizes.
*/
async function benchmarkConcurrentQueryMemory(name) {
const TestObject = Parse.Object.extend('BenchmarkConcurrentResult');
const TOTAL_OBJECTS = 3_000;
const SAVE_BATCH_SIZE = 200;
const CONCURRENT_QUERIES = 10;
// Seed data in batches; ~8 KB per document so 3,000 docs ≈ 24 MB total,
// exceeding MongoDB's 16 MiB default batch limit to test cursor batching
for (let i = 0; i < TOTAL_OBJECTS; i += SAVE_BATCH_SIZE) {
const batch = [];
for (let j = 0; j < SAVE_BATCH_SIZE && i + j < TOTAL_OBJECTS; j++) {
const obj = new TestObject();
obj.set('category', (i + j) % 10);
obj.set('value', i + j);
obj.set('data', `padding-${i + j}-${'x'.repeat(8000)}`);
batch.push(obj);
}
await Parse.Object.saveAll(batch);
}
return measureMemoryOperation({
name,
iterations: 50,
operation: async () => {
const queries = [];
for (let i = 0; i < CONCURRENT_QUERIES; i++) {
const query = new Parse.Query('BenchmarkConcurrentResult');
query.limit(TOTAL_OBJECTS);
queries.push(query.find({ useMasterKey: true }));
}
await Promise.all(queries);
},
});
}
/**
* Benchmark: Query $regex
*
* Measures a standard Parse.Query.find() with a $regex constraint.
* Each iteration uses a different regex to avoid database query cache hits.
*/
async function benchmarkQueryRegex(name) {
// Seed objects that will match the various regex patterns
const objects = [];
for (let i = 0; i < 1_000; i++) {
const obj = new Parse.Object('BenchmarkRegex');
obj.set('field', `BenchRegex_${i} data`);
objects.push(obj);
}
await Parse.Object.saveAll(objects);
let counter = 0;
const bases = ['^BenchRegex_', 'BenchRegex_', '[a-z]+_'];
return measureOperation({
name,
iterations: 1_000,
operation: async () => {
const idx = counter++;
const regex = bases[idx % bases.length] + idx;
const query = new Parse.Query('BenchmarkRegex');
query._addCondition('field', '$regex', regex);
await query.find();
},
});
}
/**
* Benchmark: LiveQuery $regex end-to-end
*
* Measures the full round-trip of a LiveQuery subscription with a $regex constraint:
* subscribe with a unique regex pattern, save an object that matches, and measure
* the time until the LiveQuery event fires. Each iteration uses a different regex
* to avoid cache hits on the RE2JS compile step.
*/
async function benchmarkLiveQueryRegex(name) {
// Enable LiveQuery on the running server
const { default: ParseServer } = require('../lib/index.js');
const liveQueryServer = await ParseServer.createLiveQueryServer(httpServer, {
appId: APP_ID,
masterKey: MASTER_KEY,
serverURL: SERVER_URL,
});
Parse.liveQueryServerURL = 'ws://localhost:1337';
let counter = 0;
// Cycle through different regex patterns to avoid RE2JS cache hits
const patterns = [
{ base: '^BenchLQ_', fieldValue: i => `BenchLQ_${i} data` },
{ base: 'benchfield_', fieldValue: i => `some benchfield_${i} here` },
{ base: '[a-z]+_benchclass_', fieldValue: i => `abc_benchclass_${i}` },
];
try {
return await measureOperation({
name,
iterations: 500,
operation: async () => {
const idx = counter++;
const pattern = patterns[idx % patterns.length];
const regex = pattern.base + idx;
const query = new Parse.Query('BenchmarkLiveQuery');
query._addCondition('field', '$regex', regex);
const subscription = await query.subscribe();
const eventPromise = new Promise(resolve => {
subscription.on('create', () => resolve());
});
const obj = new Parse.Object('BenchmarkLiveQuery');
obj.set('field', pattern.fieldValue(idx));
await obj.save();
await eventPromise;
subscription.unsubscribe();
},
});
} finally {
await liveQueryServer.shutdown();
Parse.liveQueryServerURL = undefined;
}
}
/**
* Benchmark: Object.save with nested data (denylist scanning)
*
* Measures create latency for objects with deeply nested structures containing
* multiple sibling objects at each level. This exercises the requestKeywordDenylist
* scanner (objectContainsKeyValue) which must traverse all keys and nested values.
*/
async function benchmarkObjectCreateNestedDenylist(name) {
let counter = 0;
return measureOperation({
name,
iterations: 1_000,
operation: async () => {
const TestObject = Parse.Object.extend('BenchmarkDenylist');
const obj = new TestObject();
const idx = counter++;
obj.set('nested', {
meta1: { info: { detail: `value-${idx}` } },
meta2: { info: { detail: `value-${idx}` } },
meta3: { info: { detail: `value-${idx}` } },
tags: ['a', 'b', 'c'],
config: {
setting1: { enabled: true, params: { x: 1 } },
setting2: { enabled: false, params: { y: 2 } },
},
});
await obj.save();
},
});
}
/**
* Run all benchmarks
*/
async function runBenchmarks() {
core = await import('@actions/core');
logInfo('Starting Parse Server Performance Benchmarks...');
let server;
try {
// Initialize Parse Server
logInfo('Initializing Parse Server...');
server = await initializeParseServer();
httpServer = server;
// Wait for server to be ready
await new Promise(resolve => setTimeout(resolve, 2000));
const results = [];
// Define all benchmarks to run
const benchmarks = [
{ name: 'Object.save (create)', fn: benchmarkObjectCreate },
{ name: 'Object.save (update)', fn: benchmarkObjectUpdate },
{ name: 'Object.saveAll (batch save)', fn: benchmarkBatchSave },
{ name: 'Query.get (by objectId)', fn: benchmarkObjectRead },
{ name: 'Query.find (simple query)', fn: benchmarkSimpleQuery },
{ name: 'User.signUp', fn: benchmarkUserSignup },
{ name: 'User.login', fn: benchmarkUserLogin },
{ name: 'Query.include (parallel pointers)', fn: benchmarkQueryWithIncludeParallel },
{ name: 'Query.include (nested pointers)', fn: benchmarkQueryWithIncludeNested },
{ name: 'Query.find (large result, GC pressure)', fn: benchmarkLargeResultMemory },
{ name: 'Query.find (concurrent, GC pressure)', fn: benchmarkConcurrentQueryMemory },
{ name: 'Object.save (nested data, denylist scan)', fn: benchmarkObjectCreateNestedDenylist },
{ name: 'Query $regex', fn: benchmarkQueryRegex },
{ name: 'LiveQuery $regex', fn: benchmarkLiveQueryRegex },
];
// Run each benchmark with database cleanup
for (const benchmark of benchmarks) {
logInfo(`\nRunning benchmark '${benchmark.name}'...`);
resetParseServer();
await cleanupDatabase();
results.push(await benchmark.fn(benchmark.name));
}
// Output results in github-action-benchmark format (stdout)
logInfo(JSON.stringify(results, null, 2));
// Output summary to stderr for visibility
logInfo('Benchmarks completed successfully!');
logInfo('Summary:');
results.forEach(result => {
logInfo(` ${result.name}: ${result.value.toFixed(2)} ${result.unit} (${result.extra})`);
});
} catch (error) {
logError('Error running benchmarks:', error);
process.exit(1);
} finally {
// Cleanup
if (mongoClient) {
await mongoClient.close();
}
if (server) {
server.close();
}
// Give some time for cleanup
setTimeout(() => process.exit(0), 1000);
}
}
// Run benchmarks if executed directly
if (require.main === module) {
runBenchmarks();
}
module.exports = { runBenchmarks };
File diff suppressed because it is too large Load Diff
-130
View File
@@ -1,133 +1,3 @@
# [7.4.0-beta.1](https://github.com/parse-community/parse-server/compare/7.3.0...7.4.0-beta.1) (2024-12-23)
### Bug Fixes
* `Parse.Query.distinct` fails due to invalid aggregate stage 'hint' ([#9295](https://github.com/parse-community/parse-server/issues/9295)) ([5f66c6a](https://github.com/parse-community/parse-server/commit/5f66c6a075cbe1cdaf9d1b108ee65af8ae596b89))
* Security upgrade cross-spawn from 7.0.3 to 7.0.6 ([#9444](https://github.com/parse-community/parse-server/issues/9444)) ([3d034e0](https://github.com/parse-community/parse-server/commit/3d034e0a993e3e5bd9bb96a7e382bb3464f1eb68))
* Security upgrade fast-xml-parser from 4.4.0 to 4.4.1 ([#9262](https://github.com/parse-community/parse-server/issues/9262)) ([992d39d](https://github.com/parse-community/parse-server/commit/992d39d508f230c774dcb764d1d907ec8887e6c5))
* Security upgrade node from 20.14.0-alpine3.20 to 20.17.0-alpine3.20 ([#9300](https://github.com/parse-community/parse-server/issues/9300)) ([15bb17d](https://github.com/parse-community/parse-server/commit/15bb17d87153bf0d38f08fe4c720da29a204b36b))
### Features
* Add support for MongoDB 8 ([#9269](https://github.com/parse-community/parse-server/issues/9269)) ([4756c66](https://github.com/parse-community/parse-server/commit/4756c66cd9f55afa1621d1a3f6fa850ed605cb53))
* Add support for PostGIS 3.5 ([#9354](https://github.com/parse-community/parse-server/issues/9354)) ([8ea3538](https://github.com/parse-community/parse-server/commit/8ea35382db3436d54ab59bd30706705564b0985c))
* Add support for Postgres 17 ([#9324](https://github.com/parse-community/parse-server/issues/9324)) ([fa2ee31](https://github.com/parse-community/parse-server/commit/fa2ee3196e4319a142b3838bb947c98dcba5d5cb))
* Upgrade @parse/push-adapter from 6.7.1 to 6.8.0 ([#9489](https://github.com/parse-community/parse-server/issues/9489)) ([286aa66](https://github.com/parse-community/parse-server/commit/286aa664ac8830d36c3e70d2316917d15f0b6df5))
# [7.3.0-beta.1](https://github.com/parse-community/parse-server/compare/7.2.0...7.3.0-beta.1) (2024-10-03)
### Bug Fixes
* Custom object ID allows to acquire role privileges ([GHSA-8xq9-g7ch-35hg](https://github.com/parse-community/parse-server/security/advisories/GHSA-8xq9-g7ch-35hg)) ([#9317](https://github.com/parse-community/parse-server/issues/9317)) ([13ee52f](https://github.com/parse-community/parse-server/commit/13ee52f0d19ef3a3524b3d79aea100e587eb3cfc))
* Parse Server `databaseOptions` nested keys incorrectly identified as invalid ([#9213](https://github.com/parse-community/parse-server/issues/9213)) ([77206d8](https://github.com/parse-community/parse-server/commit/77206d804443cfc1618c24f8961bd677de9920c0))
* Parse Server installation fails due to post install script incorrectly parsing required min. Node version ([#9216](https://github.com/parse-community/parse-server/issues/9216)) ([0fa82a5](https://github.com/parse-community/parse-server/commit/0fa82a54fe38ec14e8054339285d3db71a8624c8))
* Parse Server option `maxLogFiles` doesn't recognize day duration literals such as `1d` to mean 1 day ([#9215](https://github.com/parse-community/parse-server/issues/9215)) ([0319cee](https://github.com/parse-community/parse-server/commit/0319cee2dbf65e90bad377af1ed14ea25c595bf5))
* Security upgrade path-to-regexp from 6.2.1 to 6.3.0 ([#9314](https://github.com/parse-community/parse-server/issues/9314)) ([8b7fe69](https://github.com/parse-community/parse-server/commit/8b7fe699c1c376ecd8cc1c97cce8e704ee41f28a))
### Features
* Add atomic operations for Cloud Config parameters ([#9219](https://github.com/parse-community/parse-server/issues/9219)) ([35cadf9](https://github.com/parse-community/parse-server/commit/35cadf9b8324879fb7309ba5d7ea46f2c722d614))
* Add Cloud Code triggers `Parse.Cloud.beforeSave` and `Parse.Cloud.afterSave` for Parse Config ([#9232](https://github.com/parse-community/parse-server/issues/9232)) ([90a1e4a](https://github.com/parse-community/parse-server/commit/90a1e4a200423d644efb3f0ba2fba4b99f5cf954))
* Add Node 22 support ([#9187](https://github.com/parse-community/parse-server/issues/9187)) ([7778471](https://github.com/parse-community/parse-server/commit/7778471999c7e42236ce404229660d80ecc2acd6))
* Add support for asynchronous invocation of `FilesAdapter.getFileLocation` ([#9271](https://github.com/parse-community/parse-server/issues/9271)) ([1a2da40](https://github.com/parse-community/parse-server/commit/1a2da4055abe831b3017172fb75e16d7a8093873))
# [7.2.0-beta.1](https://github.com/parse-community/parse-server/compare/7.1.0...7.2.0-beta.1) (2024-07-09)
### Bug Fixes
* Invalid push notification tokens are not cleaned up from database for FCM API v2 ([#9173](https://github.com/parse-community/parse-server/issues/9173)) ([284da09](https://github.com/parse-community/parse-server/commit/284da09f4546356b37511a589fb5f64a3efffe79))
### Features
* Add support for dot notation on array fields of Parse Object ([#9115](https://github.com/parse-community/parse-server/issues/9115)) ([cf4c880](https://github.com/parse-community/parse-server/commit/cf4c8807b9da87a0a5f9c94e5bdfcf17cda80cf4))
* Upgrade to @parse/push-adapter 6.4.0 ([#9182](https://github.com/parse-community/parse-server/issues/9182)) ([ef1634b](https://github.com/parse-community/parse-server/commit/ef1634bf1f360429108d29b08032fc7961ff96a1))
* Upgrade to Parse JS SDK 5.3.0 ([#9180](https://github.com/parse-community/parse-server/issues/9180)) ([dca187f](https://github.com/parse-community/parse-server/commit/dca187f91b93cbb362b22a3fb9ee38451799ff13))
# [7.1.0-beta.1](https://github.com/parse-community/parse-server/compare/7.0.0...7.1.0-beta.1) (2024-06-30)
### Bug Fixes
* `Parse.Cloud.startJob` and `Parse.Push.send` not returning status ID when setting Parse Server option `directAccess: true` ([#8766](https://github.com/parse-community/parse-server/issues/8766)) ([5b0efb2](https://github.com/parse-community/parse-server/commit/5b0efb22efe94c47f243cf8b1e6407ed5c5a67d3))
* `Required` option not handled correctly for special fields (File, GeoPoint, Polygon) on GraphQL API mutations ([#8915](https://github.com/parse-community/parse-server/issues/8915)) ([907ad42](https://github.com/parse-community/parse-server/commit/907ad4267c228d26cfcefe7848b30ce85ba7ff8f))
* Facebook Limited Login not working due to incorrect domain in JWT validation ([#9122](https://github.com/parse-community/parse-server/issues/9122)) ([9d0bd2b](https://github.com/parse-community/parse-server/commit/9d0bd2badd6e5f7429d1af00b118225752e5d86a))
* Live query throws error when constraint `notEqualTo` is set to `null` ([#8835](https://github.com/parse-community/parse-server/issues/8835)) ([11d3e48](https://github.com/parse-community/parse-server/commit/11d3e484df862224c15d20f6171514948981ea90))
* Parse Server option `extendSessionOnUse` not working for session lengths < 24 hours ([#9113](https://github.com/parse-community/parse-server/issues/9113)) ([0a054e6](https://github.com/parse-community/parse-server/commit/0a054e6b541fd5ab470bf025665f5f7d2acedaa0))
* Rate limiting can fail when using Parse Server option `rateLimit.redisUrl` with clusters ([#8632](https://github.com/parse-community/parse-server/issues/8632)) ([c277739](https://github.com/parse-community/parse-server/commit/c27773962399f8e27691e3b8087e7e1d59516efd))
* SQL injection when using Parse Server with PostgreSQL; fixes security vulnerability [GHSA-c2hr-cqg6-8j6r](https://github.com/parse-community/parse-server/security/advisories/GHSA-c2hr-cqg6-8j6r) ([#9167](https://github.com/parse-community/parse-server/issues/9167)) ([2edf1e4](https://github.com/parse-community/parse-server/commit/2edf1e4c0363af01e97a7fbc97694f851b7d1ff3))
### Features
* Add `silent` log level for Cloud Code ([#8803](https://github.com/parse-community/parse-server/issues/8803)) ([5f81efb](https://github.com/parse-community/parse-server/commit/5f81efb42964c4c2fa8bcafee9446a0122e3ce21))
* Add server security check status `security.enableCheck` to Features Router ([#8679](https://github.com/parse-community/parse-server/issues/8679)) ([b07ec15](https://github.com/parse-community/parse-server/commit/b07ec153825882e97cc48dc84072c7f549f3238b))
* Prevent Parse Server start in case of unknown option in server configuration ([#8987](https://github.com/parse-community/parse-server/issues/8987)) ([8758e6a](https://github.com/parse-community/parse-server/commit/8758e6abb9dbb68757bddcbd332ad25100c24a0e))
* Upgrade to @parse/push-adapter 6.0.0 ([#9066](https://github.com/parse-community/parse-server/issues/9066)) ([18bdbf8](https://github.com/parse-community/parse-server/commit/18bdbf89c53a57648891ef582614ba7c2941e587))
* Upgrade to @parse/push-adapter 6.2.0 ([#9127](https://github.com/parse-community/parse-server/issues/9127)) ([ca20496](https://github.com/parse-community/parse-server/commit/ca20496f28e5ec1294a7a23c8559df82b79b2a04))
* Upgrade to Parse JS SDK 5.2.0 ([#9128](https://github.com/parse-community/parse-server/issues/9128)) ([665b8d5](https://github.com/parse-community/parse-server/commit/665b8d52d6cf5275179a5e1fb132c934edb53ecc))
# [7.0.0-beta.1](https://github.com/parse-community/parse-server/compare/6.5.0-beta.1...7.0.0-beta.1) (2024-03-19)
### Bug Fixes
* CacheAdapter does not connect when using a CacheAdapter with a JSON config ([#8633](https://github.com/parse-community/parse-server/issues/8633)) ([720d24e](https://github.com/parse-community/parse-server/commit/720d24e18540da35d50957f17be878316ec30318))
* Conditional email verification not working in some cases if `verifyUserEmails`, `preventLoginWithUnverifiedEmail` set to functions ([#8838](https://github.com/parse-community/parse-server/issues/8838)) ([8e7a6b1](https://github.com/parse-community/parse-server/commit/8e7a6b1480c0117e6c73e7adc5a6619115a04e85))
* Deny request if master key is not set in Parse Server option `masterKeyIps` regardless of ACL and CLP ([#8957](https://github.com/parse-community/parse-server/issues/8957)) ([a7b5b38](https://github.com/parse-community/parse-server/commit/a7b5b38418cbed9be3f4a7665f25b97f592663e1))
* Docker image not published to Docker Hub on new release ([#8905](https://github.com/parse-community/parse-server/issues/8905)) ([a2ac8d1](https://github.com/parse-community/parse-server/commit/a2ac8d133c71cd7b61e5ef59c4be915cfea85db6))
* Docker version releases by removing arm/v6 and arm/v7 support ([#8976](https://github.com/parse-community/parse-server/issues/8976)) ([1f62dd0](https://github.com/parse-community/parse-server/commit/1f62dd0f4e107b22a387692558a042ee26ce8703))
* GraphQL file upload fails in case of use of pointer or relation ([#8721](https://github.com/parse-community/parse-server/issues/8721)) ([1aba638](https://github.com/parse-community/parse-server/commit/1aba6382c873fb489d4a898d301e6da9fb6aa61b))
* Improve PostgreSQL injection detection; fixes security vulnerability [GHSA-6927-3vr9-fxf2](https://github.com/parse-community/parse-server/security/advisories/GHSA-6927-3vr9-fxf2) which affects Parse Server deployments using a Postgres database ([#8961](https://github.com/parse-community/parse-server/issues/8961)) ([cbefe77](https://github.com/parse-community/parse-server/commit/cbefe770a7260b54748a058b8a7389937dc35833))
* Incomplete user object in `verifyEmail` function if both username and email are changed ([#8889](https://github.com/parse-community/parse-server/issues/8889)) ([1eb95ae](https://github.com/parse-community/parse-server/commit/1eb95aeb41a96250e582d79a703f6adcb403c08b))
* Parse Server option `emailVerifyTokenReuseIfValid: true` generates new token on every email verification request ([#8885](https://github.com/parse-community/parse-server/issues/8885)) ([0023ce4](https://github.com/parse-community/parse-server/commit/0023ce448a5e9423337d0e1a25648bde1156bc95))
* Parse Server option `fileExtensions` default value rejects file extensions that are less than 3 or more than 4 characters long ([#8699](https://github.com/parse-community/parse-server/issues/8699)) ([2760381](https://github.com/parse-community/parse-server/commit/276038118377c2b22381bcd8d30337203822121b))
* Server crashes on invalid Cloud Function or Cloud Job name; fixes security vulnerability [GHSA-6hh7-46r2-vf29](https://github.com/parse-community/parse-server/security/advisories/GHSA-6hh7-46r2-vf29) ([#9024](https://github.com/parse-community/parse-server/issues/9024)) ([9f6e342](https://github.com/parse-community/parse-server/commit/9f6e3429d3b326cf4e2994733c618d08032fac6e))
* Server crashes when receiving an array of `Parse.Pointer` in the request body ([#8784](https://github.com/parse-community/parse-server/issues/8784)) ([66e3603](https://github.com/parse-community/parse-server/commit/66e36039d8af654cfa0284666c0ddd94975dcb52))
* Username is `undefined` in email verification link on email change ([#8887](https://github.com/parse-community/parse-server/issues/8887)) ([e315c13](https://github.com/parse-community/parse-server/commit/e315c137bf41bedfa8f0df537f2c3f6ab45b7e60))
### Features
* Add `installationId` to arguments for `verifyUserEmails`, `preventLoginWithUnverifiedEmail` ([#8836](https://github.com/parse-community/parse-server/issues/8836)) ([a22dbe1](https://github.com/parse-community/parse-server/commit/a22dbe16d5ac0090608f6caaf0ebd134925b7fd4))
* Add `installationId`, `ip`, `resendRequest` to arguments passed to `verifyUserEmails` on verification email request ([#8873](https://github.com/parse-community/parse-server/issues/8873)) ([8adcbee](https://github.com/parse-community/parse-server/commit/8adcbee11283d3e95179ca2047e2615f52c18806))
* Add `Parse.User` as function parameter to Parse Server options `verifyUserEmails`, `preventLoginWithUnverifiedEmail` on login ([#8850](https://github.com/parse-community/parse-server/issues/8850)) ([972f630](https://github.com/parse-community/parse-server/commit/972f6300163b3cd7d95eeb95986e8322c95f821c))
* Add password validation via POST request for user with unverified email using master key and option `ignoreEmailVerification` ([#8895](https://github.com/parse-community/parse-server/issues/8895)) ([633a9d2](https://github.com/parse-community/parse-server/commit/633a9d25e4253e2125bc93c02ee8a37e0f5f7b83))
* Add support for MongoDB 7 ([#8761](https://github.com/parse-community/parse-server/issues/8761)) ([3de8494](https://github.com/parse-community/parse-server/commit/3de8494a221991dfd10a74e0a2dc89576265c9b7))
* Add support for MongoDB query comment ([#8928](https://github.com/parse-community/parse-server/issues/8928)) ([2170962](https://github.com/parse-community/parse-server/commit/2170962a50fa353ed85eda3f11dce7ee3647b087))
* Add support for Node 20, drop support for Node 14, 16 ([#8907](https://github.com/parse-community/parse-server/issues/8907)) ([ced4872](https://github.com/parse-community/parse-server/commit/ced487246ea0ef72a8aa014991f003209b34841e))
* Add support for Postgres 16 ([#8898](https://github.com/parse-community/parse-server/issues/8898)) ([99489b2](https://github.com/parse-community/parse-server/commit/99489b22e4f0982e6cb39992974b51aa8d3a31e4))
* Allow `Parse.Session.current` on expired session token instead of throwing error ([#8722](https://github.com/parse-community/parse-server/issues/8722)) ([f9dde4a](https://github.com/parse-community/parse-server/commit/f9dde4a9f8a90c63f71172c9bc515b0f6c6d2e4a))
* Deprecation DEPPS5: Config option `allowClientClassCreation` defaults to `false` ([#8849](https://github.com/parse-community/parse-server/issues/8849)) ([29624e0](https://github.com/parse-community/parse-server/commit/29624e0fae17161cd412ae58d35a195cfa286cad))
* Deprecation DEPPS6: Authentication adapters disabled by default ([#8858](https://github.com/parse-community/parse-server/issues/8858)) ([0cf58eb](https://github.com/parse-community/parse-server/commit/0cf58eb8d60c8e5f485764e154f3214c49eee430))
* Deprecation DEPPS7: Remove deprecated Cloud Code file trigger syntax ([#8855](https://github.com/parse-community/parse-server/issues/8855)) ([4e6a375](https://github.com/parse-community/parse-server/commit/4e6a375b5184ae0f7aa256a921eca4021c609435))
* Deprecation DEPPS8: Parse Server option `allowExpiredAuthDataToken` defaults to `false` ([#8860](https://github.com/parse-community/parse-server/issues/8860)) ([e29845f](https://github.com/parse-community/parse-server/commit/e29845f8dacac09ce3093d75c0d92330c24389e8))
* Deprecation DEPPS9: LiveQuery `fields` option is renamed to `keys` ([#8852](https://github.com/parse-community/parse-server/issues/8852)) ([38983e8](https://github.com/parse-community/parse-server/commit/38983e8e9b5cdbd006f311a2338103624137d013))
* Node process exits with error code 1 on uncaught exception to allow custom uncaught exception handling ([#8894](https://github.com/parse-community/parse-server/issues/8894)) ([70c280c](https://github.com/parse-community/parse-server/commit/70c280ca578ff28b5acf92f37fbe06d42a5b34ca))
* Switch GraphQL server from Yoga v2 to Apollo v4 ([#8959](https://github.com/parse-community/parse-server/issues/8959)) ([105ae7c](https://github.com/parse-community/parse-server/commit/105ae7c8a57d5a650b243174a80c26bf6db16e28))
* Upgrade Parse Server Push Adapter to 5.0.2 ([#8813](https://github.com/parse-community/parse-server/issues/8813)) ([6ef1986](https://github.com/parse-community/parse-server/commit/6ef1986c03a1d84b7e11c05851e5bf9688d88740))
* Upgrade to Parse JS SDK 5 ([#9022](https://github.com/parse-community/parse-server/issues/9022)) ([ad4aa83](https://github.com/parse-community/parse-server/commit/ad4aa83983205a0e27639f6ee6a4a5963b67e4b8))
### Performance Improvements
* Improved IP validation performance for `masterKeyIPs`, `maintenanceKeyIPs` ([#8510](https://github.com/parse-community/parse-server/issues/8510)) ([b87daba](https://github.com/parse-community/parse-server/commit/b87daba0671a1b0b7b8d63bc671d665c91a04522))
### BREAKING CHANGES
* The Parse Server option `allowClientClassCreation` defaults to `false`. ([29624e0](29624e0))
* A request using the master key will now be rejected as unauthorized if the IP from which the request originates is not set in the Parse Server option `masterKeyIps`, even if the request does not require the master key permission, for example for a public object in a public class class. ([a7b5b38](a7b5b38))
* Node process now exits with code 1 on uncaught exceptions, enabling custom handlers that were blocked by Parse Server's default behavior of re-throwing errors. This change may lead to automatic process restarts by the environment, unlike before. ([70c280c](70c280c))
* Authentication adapters are disabled by default; to use an authentication adapter it needs to be explicitly enabled in the Parse Server authentication adapter option `auth.<provider>.enabled: true` ([0cf58eb](0cf58eb))
* Parse Server option `allowExpiredAuthDataToken` defaults to `false`; a 3rd party authentication token will be validated every time the user tries to log in and the login will fail if the token has expired; the effect of this change may differ for different authentication adapters, depending on the token lifetime and the token refresh logic of the adapter ([e29845f](e29845f))
* LiveQuery `fields` option is renamed to `keys` ([38983e8](38983e8))
* Cloud Code file trigger syntax has been aligned with object trigger syntax, for example `Parse.Cloud.beforeDeleteFile'` has been changed to `Parse.Cloud.beforeDelete(Parse.File, (request) => {})'` ([4e6a375](4e6a375))
* Removes support for Node 14 and 16 ([ced4872](ced4872))
* Removes support for Postgres 11 and 12 ([99489b2](99489b2))
* The `Parse.User` passed as argument if `verifyUserEmails` is set to a function is renamed from `user` to `object` for consistency with invocations of `verifyUserEmails` on signup or login; the user object is not a plain JavaScript object anymore but an instance of `Parse.User` ([8adcbee](8adcbee))
* `Parse.Session.current()` no longer throws an error if the session token is expired, but instead returns the session token with its expiration date to allow checking its validity ([f9dde4a](f9dde4a))
* `Parse.Query` no longer supports the BSON type `code`; although this feature was never officially documented, its removal is announced as a breaking change to protect deployments where it might be in use. ([3de8494](3de8494))
# [6.5.0-beta.1](https://github.com/parse-community/parse-server/compare/6.4.0...6.5.0-beta.1) (2023-11-16)
+6 -463
View File
@@ -1,480 +1,23 @@
## [9.5.1](https://github.com/parse-community/parse-server/compare/9.5.0...9.5.1) (2026-03-07)
## [6.5.2](https://github.com/parse-community/parse-server/compare/6.5.1...6.5.2) (2024-03-02)
### Bug Fixes
* Denial of Service (DoS) and Cloud Function Dispatch Bypass via Prototype Chain Resolution ([GHSA-5j86-7r7m-p8h6](https://github.com/parse-community/parse-server/security/advisories/GHSA-5j86-7r7m-p8h6)) ([#10125](https://github.com/parse-community/parse-server/issues/10125)) ([560e6e7](https://github.com/parse-community/parse-server/commit/560e6e77c7625da0655b2d01dc2d10632a80f591))
* Denylist `requestKeywordDenylist` keyword scan bypass through nested object placement ([GHSA-q342-9w2p-57fp](https://github.com/parse-community/parse-server/security/advisories/GHSA-q342-9w2p-57fp)) ([#10123](https://github.com/parse-community/parse-server/issues/10123)) ([4a44247](https://github.com/parse-community/parse-server/commit/4a44247a649a40ef3f1db8261a0e780080f494ba))
* Security upgrade @parse/push-adapter from 5.1.0 to 5.1.1 ([#8975](https://github.com/parse-community/parse-server/issues/8975)) ([0fa0aab](https://github.com/parse-community/parse-server/commit/0fa0aabefe6bc9d356ee70be78dafc5fa22d4e17))
# [9.5.0](https://github.com/parse-community/parse-server/compare/9.4.1...9.5.0) (2026-03-07)
## [6.5.1](https://github.com/parse-community/parse-server/compare/6.5.0...6.5.1) (2024-03-02)
### Bug Fixes
* `PagesRouter` path traversal allows reading files outside configured pages directory ([GHSA-hm3f-q6rw-m6wh](https://github.com/parse-community/parse-server/security/advisories/GHSA-hm3f-q6rw-m6wh)) ([#10104](https://github.com/parse-community/parse-server/issues/10104)) ([e772543](https://github.com/parse-community/parse-server/commit/e772543ad8d01bce83664566551893dffc5b8117))
* Endpoint `/loginAs` allows `readOnlyMasterKey` to gain full read and write access as any user ([GHSA-79wj-8rqv-jvp5](https://github.com/parse-community/parse-server/security/advisories/GHSA-79wj-8rqv-jvp5)) ([#10098](https://github.com/parse-community/parse-server/issues/10098)) ([bc20945](https://github.com/parse-community/parse-server/commit/bc20945fc7cdb2e56d7c46d537d8f4baf7231303))
* File creation and deletion bypasses `readOnlyMasterKey` write restriction ([GHSA-xfh7-phr7-gr2x](https://github.com/parse-community/parse-server/security/advisories/GHSA-xfh7-phr7-gr2x)) ([#10095](https://github.com/parse-community/parse-server/issues/10095)) ([036365a](https://github.com/parse-community/parse-server/commit/036365af6dedd10746327f46bf69408b5c56439e))
* File metadata endpoint bypasses `beforeFind` / `afterFind` trigger authorization ([GHSA-hwx8-q9cg-mqmc](https://github.com/parse-community/parse-server/security/advisories/GHSA-hwx8-q9cg-mqmc)) ([#10106](https://github.com/parse-community/parse-server/issues/10106)) ([72e7707](https://github.com/parse-community/parse-server/commit/72e7707ac17b9df888cc20732583411544adcd36))
* GraphQL `__type` introspection bypass via inline fragments when public introspection is disabled ([GHSA-q5q9-2rhp-33qw](https://github.com/parse-community/parse-server/security/advisories/GHSA-q5q9-2rhp-33qw)) ([#10111](https://github.com/parse-community/parse-server/issues/10111)) ([61261a5](https://github.com/parse-community/parse-server/commit/61261a5aa15c95a22a87a5a9c53077059ad49d15))
* JWT audience validation bypass in Google, Apple, and Facebook authentication adapters ([GHSA-x6fw-778m-wr9v](https://github.com/parse-community/parse-server/security/advisories/GHSA-x6fw-778m-wr9v)) ([#10113](https://github.com/parse-community/parse-server/issues/10113)) ([9f8d3f3](https://github.com/parse-community/parse-server/commit/9f8d3f3d5591c17f9857bad035950fdff75d0ce6))
* Malformed `$regex` query leaks database error details in API response ([GHSA-9cp7-3q5w-j92g](https://github.com/parse-community/parse-server/security/advisories/GHSA-9cp7-3q5w-j92g)) ([#10101](https://github.com/parse-community/parse-server/issues/10101)) ([9792d24](https://github.com/parse-community/parse-server/commit/9792d24b963f3b45e5ade2bbceb6f5c0b5d0251c))
* Regular Expression Denial of Service (ReDoS) via `$regex` query in LiveQuery ([GHSA-mf3j-86qx-cq5j](https://github.com/parse-community/parse-server/security/advisories/GHSA-mf3j-86qx-cq5j)) ([#10118](https://github.com/parse-community/parse-server/issues/10118)) ([5e113c2](https://github.com/parse-community/parse-server/commit/5e113c2128239b26551f77e127d0120502dc152a))
* Security upgrade @parse/push-adapter from 5.0.2 to 5.1.0 ([#8972](https://github.com/parse-community/parse-server/issues/8972)) ([bba24dd](https://github.com/parse-community/parse-server/commit/bba24dd8279ebb8e4084a5f00fbe3ab9fe6c01b4))
### Features
* Add `Parse.File` option `maxUploadSize` to override the Parse Server option `maxUploadSize` per file upload ([#10093](https://github.com/parse-community/parse-server/issues/10093)) ([3d8807b](https://github.com/parse-community/parse-server/commit/3d8807b4eceafab92ac9c23516d564f5fce6cb8e))
* Add security check for server option `mountPlayground` for GraphQL development ([#10103](https://github.com/parse-community/parse-server/issues/10103)) ([2ae5db1](https://github.com/parse-community/parse-server/commit/2ae5db142574b0e62f4263e2fa9a9831c966b478))
* Add server option `readOnlyMasterKeyIps` to restrict `readOnlyMasterKey` by IP ([#10115](https://github.com/parse-community/parse-server/issues/10115)) ([cbff6b4](https://github.com/parse-community/parse-server/commit/cbff6b42a0b4f02552457f04a8757ac2376d3e04))
* Add support for `Parse.File.setDirectory`, `setMetadata`, `setTags` with stream-based file upload ([#10092](https://github.com/parse-community/parse-server/issues/10092)) ([ca666b0](https://github.com/parse-community/parse-server/commit/ca666b02fcc2229180621a42694c0838f700c06d))
* Allow to identify `readOnlyMasterKey` invocation of Cloud Function via `request.isReadOnly` ([#10100](https://github.com/parse-community/parse-server/issues/10100)) ([2c48751](https://github.com/parse-community/parse-server/commit/2c48751c6de36ec090ac6ab08e289876561ed324))
* Deprecate GraphQL Playground that exposes master key in HTTP response ([#10112](https://github.com/parse-community/parse-server/issues/10112)) ([d54d800](https://github.com/parse-community/parse-server/commit/d54d800f596f1937701f5bd57c81104f102bc3ae))
## [9.4.1](https://github.com/parse-community/parse-server/compare/9.4.0...9.4.1) (2026-03-04)
# [6.5.0](https://github.com/parse-community/parse-server/compare/6.4.0...6.5.0) (2024-03-01)
### Bug Fixes
* Cloud Hooks and Cloud Jobs bypass `readOnlyMasterKey` write restriction ([GHSA-vc89-5g3r-cmhh](https://github.com/parse-community/parse-server/security/advisories/GHSA-vc89-5g3r-cmhh)) ([#10088](https://github.com/parse-community/parse-server/issues/10088)) ([9a3dd4d](https://github.com/parse-community/parse-server/commit/9a3dd4d2d55ad506348062b43a7fe42e22a57fe9))
* MongoDB default batch size changed from 1000 to 100 without announcement ([#10085](https://github.com/parse-community/parse-server/issues/10085)) ([8f17397](https://github.com/parse-community/parse-server/commit/8f1739788d434c91109f049a438c32bdd4fc26a5))
### Performance Improvements
* Upgrade to mongodb 7.1.0 ([#10087](https://github.com/parse-community/parse-server/issues/10087)) ([bebf2fd](https://github.com/parse-community/parse-server/commit/bebf2fd62b51cfc35c271ad4c76b8f552f886ce8))
# [9.4.0](https://github.com/parse-community/parse-server/compare/9.3.1...9.4.0) (2026-03-01)
### Bug Fixes
* `PagesRouter` header parameters are not URL-encoded to support non-ASCII characters in app name ([#10078](https://github.com/parse-community/parse-server/issues/10078)) ([c92660b](https://github.com/parse-community/parse-server/commit/c92660bd9a776eec81e4ef18217916b931c267a1))
### Features
* Add support for `Parse.File.setDirectory()` with master key to save file in directory ([#10076](https://github.com/parse-community/parse-server/issues/10076)) ([17d987c](https://github.com/parse-community/parse-server/commit/17d987c95accdb2d75f63aed25abd919b0999589))
## [9.3.1](https://github.com/parse-community/parse-server/compare/9.3.0...9.3.1) (2026-02-25)
### Bug Fixes
* GraphQL introspection disabled in `NODE_ENV=production` even with master key ([#10071](https://github.com/parse-community/parse-server/issues/10071)) ([a5269f0](https://github.com/parse-community/parse-server/commit/a5269f077666537fad1d2eeefee82a36a148255c))
* JWT Algorithm Confusion in Google Auth Adapter ([GHSA-4q3h-vp4r-prv2](https://github.com/parse-community/parse-server/security/advisories/GHSA-4q3h-vp4r-prv2)) ([#10072](https://github.com/parse-community/parse-server/issues/10072)) ([9d5942d](https://github.com/parse-community/parse-server/commit/9d5942d50e55c822924c27b05aa98f1393e7a330))
* Remove obsolete Parse Server option `pages.enableRouter` ([#10070](https://github.com/parse-community/parse-server/issues/10070)) ([00b3b72](https://github.com/parse-community/parse-server/commit/00b3b7297d806b4b40d7c08dd987b748e018e4b6))
* Type error in docs creation ([#10069](https://github.com/parse-community/parse-server/issues/10069)) ([02a277f](https://github.com/parse-community/parse-server/commit/02a277f1e937fd3e6bd85bdb49870bf3f47678a0))
# [9.3.0](https://github.com/parse-community/parse-server/compare/9.2.0...9.3.0) (2026-02-21)
### Bug Fixes
* `Parse.Query.select('authData')` for `_User` class doesn't return auth data ([#10055](https://github.com/parse-community/parse-server/issues/10055)) ([44a5bb1](https://github.com/parse-community/parse-server/commit/44a5bb105e11e6918e899e0f1427b0adb38d6d67))
* AuthData validation incorrectly triggered on unchanged providers ([#10025](https://github.com/parse-community/parse-server/issues/10025)) ([d3d6e9e](https://github.com/parse-community/parse-server/commit/d3d6e9e22a212885690853cbbb84bb8c53da5646))
* Default ACL overwrites custom ACL on `Parse.Object` update ([#10061](https://github.com/parse-community/parse-server/issues/10061)) ([4ef89d9](https://github.com/parse-community/parse-server/commit/4ef89d912c08bb24500a4d4142a3220f024a2d34))
* Default HTML pages for password reset, email verification not found ([#10034](https://github.com/parse-community/parse-server/issues/10034)) ([e299107](https://github.com/parse-community/parse-server/commit/e29910764daef3c03ed1b09eee19cedc3b12a86a))
* Default HTML pages for password reset, email verification not found ([#10041](https://github.com/parse-community/parse-server/issues/10041)) ([a4265bb](https://github.com/parse-community/parse-server/commit/a4265bb1241551b7147e8aee08c36e1f8ab09ba4))
* Incorrect dependency chain of `Parse` uses browser build instead of Node build ([#10067](https://github.com/parse-community/parse-server/issues/10067)) ([1a2521d](https://github.com/parse-community/parse-server/commit/1a2521d930b855845aa13fde700b2e8170ff65a1))
* Unlinking auth provider triggers auth data validation ([#10045](https://github.com/parse-community/parse-server/issues/10045)) ([b6b6327](https://github.com/parse-community/parse-server/commit/b6b632755263417c2a3c3a31381eedc516723740))
### Features
* Add `Parse.File.url` validation with config `fileUpload.allowedFileUrlDomains` against SSRF attacks ([#10044](https://github.com/parse-community/parse-server/issues/10044)) ([4c9c948](https://github.com/parse-community/parse-server/commit/4c9c9489f062bec6d751b23f4a68aea2a63936bd))
* Add event information to `verifyUserEmails`, `preventLoginWithUnverifiedEmail` to identify invoking signup / login action and auth provider ([#9963](https://github.com/parse-community/parse-server/issues/9963)) ([ed98c15](https://github.com/parse-community/parse-server/commit/ed98c15f90f2fa6a66780941fd3705b805d6eb14))
* Add support for streaming file upload via `Buffer`, `Readable`, `ReadableStream` ([#10065](https://github.com/parse-community/parse-server/issues/10065)) ([f0feb48](https://github.com/parse-community/parse-server/commit/f0feb48d0fb697a161693721eadd09d740336283))
* Upgrade to parse 8.2.0, @parse/push-adapter 8.3.0 ([#10066](https://github.com/parse-community/parse-server/issues/10066)) ([8b5a14e](https://github.com/parse-community/parse-server/commit/8b5a14ecaf0b58b899651fb97d43e0e5d9be506d))
# [9.2.0](https://github.com/parse-community/parse-server/compare/9.1.1...9.2.0) (2026-02-05)
### Bug Fixes
* MongoDB timeout errors unhandled and potentially revealing internal data ([#10020](https://github.com/parse-community/parse-server/issues/10020)) ([1d3336d](https://github.com/parse-community/parse-server/commit/1d3336d128671c974b419b9b34db35ada7d1a44d))
* Security upgrade @apollo/server from 5.0.0 to 5.4.0 ([#10035](https://github.com/parse-community/parse-server/issues/10035)) ([9f368ff](https://github.com/parse-community/parse-server/commit/9f368ff9ca322c61cdcfab735e5b5240d1c8f917))
### Features
* Add option `databaseOptions.clientMetadata` to send custom metadata to database server for logging and debugging ([#10017](https://github.com/parse-community/parse-server/issues/10017)) ([756c204](https://github.com/parse-community/parse-server/commit/756c204220a2c7be3770b7d4a49f11e8903323db))
* Upgrade mongodb from 6.20.0 to 7.0.0 ([#10027](https://github.com/parse-community/parse-server/issues/10027)) ([14b3fce](https://github.com/parse-community/parse-server/commit/14b3fce203be0abaf29c27c123cba47f35d09c68))
* Upgrade to parse 8.0.3 and @parse/push-adapter 8.2.0 ([#10021](https://github.com/parse-community/parse-server/issues/10021)) ([9833fdb](https://github.com/parse-community/parse-server/commit/9833fdb111c373dc75fc74ea5f9209408186a475))
## [9.1.1](https://github.com/parse-community/parse-server/compare/9.1.0...9.1.1) (2025-12-16)
### Bug Fixes
* Server-Side Request Forgery (SSRF) in Instagram auth adapter [GHSA-3f5f-xgrj-97pf](https://github.com/parse-community/parse-server/security/advisories/GHSA-3f5f-xgrj-97pf) ([#9988](https://github.com/parse-community/parse-server/issues/9988)) ([fbcc938](https://github.com/parse-community/parse-server/commit/fbcc938b5ade5ff4c30598ac51272ef7ecef0616))
# [9.1.0](https://github.com/parse-community/parse-server/compare/9.0.0...9.1.0) (2025-12-14)
### Bug Fixes
* Cross-Site Scripting (XSS) via HTML pages for password reset and email verification [GHSA-jhgf-2h8h-ggxv](https://github.com/parse-community/parse-server/security/advisories/GHSA-jhgf-2h8h-ggxv) ([#9985](https://github.com/parse-community/parse-server/issues/9985)) ([3074eb7](https://github.com/parse-community/parse-server/commit/3074eb70f5b58bf72b528ae7b7804ed2d90455ce))
### Features
* Add option `logLevels.signupUsernameTaken` to change log level of username already exists sign-up rejection ([#9962](https://github.com/parse-community/parse-server/issues/9962)) ([f18f307](https://github.com/parse-community/parse-server/commit/f18f3073d70a292bc70b5d572ef58e4845de89ca))
* Add support for custom HTTP status code and headers to Cloud Function response with Express-style syntax ([#9980](https://github.com/parse-community/parse-server/issues/9980)) ([8eeab8d](https://github.com/parse-community/parse-server/commit/8eeab8dc57edef3751aa188d8247f296a270b083))
* Log more debug info when failing to set duplicate value for field with unique values ([#9919](https://github.com/parse-community/parse-server/issues/9919)) ([a23b192](https://github.com/parse-community/parse-server/commit/a23b1924668920f3c92fec0566b57091d0e8aae8))
# [9.0.0](https://github.com/parse-community/parse-server/compare/8.6.0...9.0.0) (2025-12-14)
### Bug Fixes
* Upgrade to GraphQL Apollo Server 5 and restrict GraphQL introspection ([#9888](https://github.com/parse-community/parse-server/issues/9888)) ([87c7f07](https://github.com/parse-community/parse-server/commit/87c7f076eb84c9540f79f06c27fe13e102dc6295))
### Features
* Deprecation DEPPS10: Encode `Parse.Object` in Cloud Function and remove option `encodeParseObjectInCloudFunction` ([#9973](https://github.com/parse-community/parse-server/issues/9973)) ([a2d3dbe](https://github.com/parse-community/parse-server/commit/a2d3dbe972e2e02ac599bfffe1ae6cd9768b02ca))
* Deprecation DEPPS11: Replace `PublicAPIRouter` with `PagesRouter` ([#9974](https://github.com/parse-community/parse-server/issues/9974)) ([8f877d4](https://github.com/parse-community/parse-server/commit/8f877d42c02a6492b97c61e75ab77a896878f866))
* Deprecation DEPPS113: Config option `enableInsecureAuthAdapters` defaults to `false` ([#9982](https://github.com/parse-community/parse-server/issues/9982)) ([22d4622](https://github.com/parse-community/parse-server/commit/22d4622230b74839ed408a02bfcabb7b37b85aba))
* Deprecation DEPPS12: Database option `allowPublicExplain` defaults to `false` ([#9975](https://github.com/parse-community/parse-server/issues/9975)) ([c1c7e69](https://github.com/parse-community/parse-server/commit/c1c7e6976d868ccbc7dff325edce78ddfa999bb9))
* Increase required minimum MongoDB version to `7.0.16` ([#9971](https://github.com/parse-community/parse-server/issues/9971)) ([7bb548b](https://github.com/parse-community/parse-server/commit/7bb548bf81b3cebc9ec92ef9e5e6faf8f9edbd3b))
* Increase required minimum Node version to `20.19.0` ([#9970](https://github.com/parse-community/parse-server/issues/9970)) ([633964d](https://github.com/parse-community/parse-server/commit/633964d32e249d8cc16c58de7ddd9b7637c69fb1))
* Increase required minimum version to Postgres `16`, PostGIS `3.5` ([#9972](https://github.com/parse-community/parse-server/issues/9972)) ([7483add](https://github.com/parse-community/parse-server/commit/7483add73934e7d16098ccfb672cc45b3f7c7fbe))
* Update route patterns to use path-to-regexp v8 syntax ([#9942](https://github.com/parse-community/parse-server/issues/9942)) ([fa8723b](https://github.com/parse-community/parse-server/commit/fa8723b3d1e895602d1187540818bbdb446259ba))
* Upgrade to @parse/push-adapter 8.1.0 ([#9938](https://github.com/parse-community/parse-server/issues/9938)) ([d5e76b0](https://github.com/parse-community/parse-server/commit/d5e76b01db2b4eeb22a0bb5a04347a89209aa822))
* Upgrade to parse 8.0.0 ([#9976](https://github.com/parse-community/parse-server/issues/9976)) ([f9970d4](https://github.com/parse-community/parse-server/commit/f9970d4bb253494392fb4cc366f222119927f082))
### BREAKING CHANGES
* This release changes the config option `enableInsecureAuthAdapters` default to `false` (Deprecation DEPPS13). ([22d4622](22d4622))
* This release changes the MongoDB database option `allowPublicExplain` default to `false` (Deprecation DEPPS12). ([c1c7e69](c1c7e69))
* This release replaces `PublicAPIRouter` with `PagesRouter` (Deprecation DEPPS11). ([8f877d4](8f877d4))
* This release encodes `Parse.Object` in Cloud Function and removes option `encodeParseObjectInCloudFunction` (Deprecation DEPPS10). ([a2d3dbe](a2d3dbe))
* This releases increases the required minimum version to Postgres `16`, PostGIS `3.5`. ([7483add](7483add))
* Route pattern syntax across cloud routes and rate-limiting now use the new path-to-regexp v8 syntax; see the [migration guide](https://github.com/parse-community/parse-server/blob/alpha/9.0.0.md) for more details. ([fa8723b](fa8723b))
* This releases increases the required minimum MongoDB version to `7.0.16`. ([7bb548b](7bb548b))
* Upgrade to Apollo Server 5 and GraphQL express 5 integration; GraphQL introspection now requires using `masterKey` or setting `graphQLPublicIntrospection: true`. ([87c7f07](87c7f07))
* This releases increases the required minimum Node version to `20.19.0`. ([633964d](633964d))
# [8.6.0](https://github.com/parse-community/parse-server/compare/8.5.0...8.6.0) (2025-12-10)
### Bug Fixes
* Remove elevated permissions in GitHub CI performance benchmark ([#9966](https://github.com/parse-community/parse-server/issues/9966)) ([6b9f896](https://github.com/parse-community/parse-server/commit/6b9f8963cc3debf59cd9c5dfc5422aff9404ce9d))
### Features
* Add GraphQL query `cloudConfig` to retrieve and mutation `updateCloudConfig` to update Cloud Config ([#9947](https://github.com/parse-community/parse-server/issues/9947)) ([3ca85cd](https://github.com/parse-community/parse-server/commit/3ca85cd4a632f234c9d3d731331c0524dfe54075))
# [8.5.0](https://github.com/parse-community/parse-server/compare/8.4.0...8.5.0) (2025-12-01)
### Bug Fixes
* `GridFSBucketAdapter` throws when using some Parse Server specific options in MongoDB database options ([#9915](https://github.com/parse-community/parse-server/issues/9915)) ([d3d4003](https://github.com/parse-community/parse-server/commit/d3d4003570b9872f2b0f5a25fc06ce4c4132860d))
* Deprecation warning logged at server launch for nested Parse Server option even if option is explicitly set ([#9934](https://github.com/parse-community/parse-server/issues/9934)) ([c22cb0a](https://github.com/parse-community/parse-server/commit/c22cb0ae58e64cd0e4597ab9610d57a1155c44a2))
* Parse Server option `rateLimit.zone` does not use default value `ip` ([#9941](https://github.com/parse-community/parse-server/issues/9941)) ([12beb8f](https://github.com/parse-community/parse-server/commit/12beb8f6ee5d3002fec017bb4525eb3f1375f806))
* Queries with object field `authData.provider.id` are incorrectly transformed to `_auth_data_provider.id` for custom classes ([#9932](https://github.com/parse-community/parse-server/issues/9932)) ([7b9fa18](https://github.com/parse-community/parse-server/commit/7b9fa18f968ec084ea0b35dad2b5ba0451d59787))
* Race condition can cause multiple Apollo server initializations under load ([#9929](https://github.com/parse-community/parse-server/issues/9929)) ([7d5e9fc](https://github.com/parse-community/parse-server/commit/7d5e9fcf3ceb0abad8ab49c75bc26f521a0f1bde))
* Server internal error details leaking in error messages returned to clients ([#9937](https://github.com/parse-community/parse-server/issues/9937)) ([50edb5a](https://github.com/parse-community/parse-server/commit/50edb5ab4bb4a6ce474bfb7cf159d918933753b8))
### Features
* Add `beforePasswordResetRequest` hook ([#9906](https://github.com/parse-community/parse-server/issues/9906)) ([94cee5b](https://github.com/parse-community/parse-server/commit/94cee5bfafca10c914c73cf17fcdb627a9f0837b))
* Add MongoDB client event logging via database option `logClientEvents` ([#9914](https://github.com/parse-community/parse-server/issues/9914)) ([b760733](https://github.com/parse-community/parse-server/commit/b760733b98bcfc9c09ac9780066602e1fda108fe))
* Add Parse Server option `allowPublicExplain` to allow `Parse.Query.explain` without master key ([#9890](https://github.com/parse-community/parse-server/issues/9890)) ([4456b02](https://github.com/parse-community/parse-server/commit/4456b02280c2d8dd58b7250e9e67f1a8647b3452))
* Add Parse Server option `enableSanitizedErrorResponse` to remove detailed error messages from responses sent to clients ([#9944](https://github.com/parse-community/parse-server/issues/9944)) ([4752197](https://github.com/parse-community/parse-server/commit/47521974aeafcf41102be62f19612a4ab0a4837f))
* Add support for MongoDB driver options `serverSelectionTimeoutMS`, `maxIdleTimeMS`, `heartbeatFrequencyMS` ([#9910](https://github.com/parse-community/parse-server/issues/9910)) ([1b661e9](https://github.com/parse-community/parse-server/commit/1b661e98c86a1db79e076a7297cd9199a72ae1ac))
* Add support for more MongoDB driver options ([#9911](https://github.com/parse-community/parse-server/issues/9911)) ([cff451e](https://github.com/parse-community/parse-server/commit/cff451eabdc380affa600ed711de66f7bd1d00aa))
* Allow option `publicServerURL` to be set dynamically as asynchronous function ([#9803](https://github.com/parse-community/parse-server/issues/9803)) ([460a65c](https://github.com/parse-community/parse-server/commit/460a65cf612f4c86af8038cafcc7e7ffe9eb8440))
* Upgrade to parse 7.1.1 ([#9954](https://github.com/parse-community/parse-server/issues/9954)) ([fa57d69](https://github.com/parse-community/parse-server/commit/fa57d69cbec525189da98d7136c1c0e9eaf74338))
* Upgrade to parse 7.1.2 ([#9955](https://github.com/parse-community/parse-server/issues/9955)) ([5c644a5](https://github.com/parse-community/parse-server/commit/5c644a55ac25986f214b68ba4bcbe7a62ad6d6d1))
### Performance Improvements
* `Parse.Query.include` now fetches pointers at same level in parallel ([#9861](https://github.com/parse-community/parse-server/issues/9861)) ([dafea21](https://github.com/parse-community/parse-server/commit/dafea21eb39b0fdc2b52bb8a14f7b61e3f2b8d13))
* Remove unused dependencies ([#9943](https://github.com/parse-community/parse-server/issues/9943)) ([d4c6de0](https://github.com/parse-community/parse-server/commit/d4c6de0096b3ac95289c6bddfe25eb397d790e41))
* Upgrade MongoDB driver to 6.20.0 ([#9887](https://github.com/parse-community/parse-server/issues/9887)) ([3c9af48](https://github.com/parse-community/parse-server/commit/3c9af48edd999158443b797e388e29495953799e))
# [8.4.0](https://github.com/parse-community/parse-server/compare/8.3.0...8.4.0) (2025-11-05)
### Bug Fixes
* Add problematic MIME types to default value of Parse Server option `fileUpload.fileExtensions` ([#9902](https://github.com/parse-community/parse-server/issues/9902)) ([fa245cb](https://github.com/parse-community/parse-server/commit/fa245cbb5f5b7a0dad962b2ce0524fa4dafcb5f7))
* Uploading a file by providing an origin URL allows for Server-Side Request Forgery (SSRF); fixes vulnerability [GHSA-x4qj-2f4q-r4rx](https://github.com/parse-community/parse-server/security/advisories/GHSA-x4qj-2f4q-r4rx) ([#9903](https://github.com/parse-community/parse-server/issues/9903)) ([9776386](https://github.com/parse-community/parse-server/commit/97763863b72689a29ad7a311dfb590c3e3c50585))
### Features
* Add support for Node 24 ([#9901](https://github.com/parse-community/parse-server/issues/9901)) ([25dfe19](https://github.com/parse-community/parse-server/commit/25dfe19fef02fd44224e4a6d198584a694a1aa52))
# [8.3.0](https://github.com/parse-community/parse-server/compare/8.2.5...8.3.0) (2025-11-01)
### Bug Fixes
* Error in `afterSave` trigger for `Parse.Role` due to `name` field ([#9883](https://github.com/parse-community/parse-server/issues/9883)) ([eb052d8](https://github.com/parse-community/parse-server/commit/eb052d8e6abe1ae32505fd068d5445eaf950a770))
* Indexes `_email_verify_token` for email verification and `_perishable_token` password reset are not created automatically ([#9893](https://github.com/parse-community/parse-server/issues/9893)) ([62dd3c5](https://github.com/parse-community/parse-server/commit/62dd3c565ab70765cb1c547996b616b72e9bb800))
* Security upgrade to parse 7.0.1 ([#9877](https://github.com/parse-community/parse-server/issues/9877)) ([abfa94c](https://github.com/parse-community/parse-server/commit/abfa94cd6de2c4e76337931c8ea8311c4ccf2a1a))
* Server URL verification before server is ready ([#9882](https://github.com/parse-community/parse-server/issues/9882)) ([178bd5c](https://github.com/parse-community/parse-server/commit/178bd5c5e258d9501c9ac4d35a3a105ab64be67e))
* Stale data read in validation query on `Parse.Object` update causes inconsistency between validation read and subsequent update write operation ([#9859](https://github.com/parse-community/parse-server/issues/9859)) ([f49efaf](https://github.com/parse-community/parse-server/commit/f49efaf5bb1d6b19f6d6712f7cdf073855c95c6e))
* Warning logged when setting option `databaseOptions.disableIndexFieldValidation` ([#9880](https://github.com/parse-community/parse-server/issues/9880)) ([1815b01](https://github.com/parse-community/parse-server/commit/1815b019b52565d2bc87be2596a49aea7600aeba))
### Features
* Add option `keepUnknownIndexes` to retain indexes which are not specified in schema ([#9857](https://github.com/parse-community/parse-server/issues/9857)) ([89fad46](https://github.com/parse-community/parse-server/commit/89fad468c3a43772879c06c4d939a83b72517a8e))
* Add options to skip automatic creation of internal database indexes on server start ([#9897](https://github.com/parse-community/parse-server/issues/9897)) ([ea91aca](https://github.com/parse-community/parse-server/commit/ea91aca1420c33e038516a321b2640709589f886))
* Add Parse Server option `verifyServerUrl` to disable server URL verification on server launch ([#9881](https://github.com/parse-community/parse-server/issues/9881)) ([b298ccc](https://github.com/parse-community/parse-server/commit/b298cccd9fb4f664b9d83894faad6d1ea7a3c964))
* Add regex option `u` for unicode support in `Parse.Query.matches` for MongoDB ([#9867](https://github.com/parse-community/parse-server/issues/9867)) ([7cb962a](https://github.com/parse-community/parse-server/commit/7cb962a02845f3dded61baffd84515f94b66ee50))
* Add request context middleware for config and dependency injection in hooks ([#8480](https://github.com/parse-community/parse-server/issues/8480)) ([64f104e](https://github.com/parse-community/parse-server/commit/64f104e5c5f8863098e801eee632c14fcbd9b6f9))
* Add support for Postgres 18 ([#9870](https://github.com/parse-community/parse-server/issues/9870)) ([d275c18](https://github.com/parse-community/parse-server/commit/d275c1806e0a5a037cc06cde7eefff3e12c91d7d))
* Allow returning objects in `Parse.Cloud.beforeFind` without invoking database query ([#9770](https://github.com/parse-community/parse-server/issues/9770)) ([0b47407](https://github.com/parse-community/parse-server/commit/0b4740714c29ba99672bc535619ee3516abd356f))
* Disable index-field validation to create index for fields that don't yet exist ([#8137](https://github.com/parse-community/parse-server/issues/8137)) ([1b23475](https://github.com/parse-community/parse-server/commit/1b2347524ca84ade0f6badf175a815fc8a7bef49))
## [8.2.5](https://github.com/parse-community/parse-server/compare/8.2.4...8.2.5) (2025-10-02)
### Bug Fixes
* GraphQL playground shows blank page ([#9858](https://github.com/parse-community/parse-server/issues/9858)) ([7b5395c](https://github.com/parse-community/parse-server/commit/7b5395c5d481235c022d96603280672366a50715))
## [8.2.4](https://github.com/parse-community/parse-server/compare/8.2.3...8.2.4) (2025-09-01)
### Bug Fixes
* Security upgrade form-data from 4.0.3 to 4.0.4 ([#9829](https://github.com/parse-community/parse-server/issues/9829)) ([c2c593f](https://github.com/parse-community/parse-server/commit/c2c593f437c33f37101b4f3bb1287eef31dbbc3b))
## [8.2.3](https://github.com/parse-community/parse-server/compare/8.2.2...8.2.3) (2025-08-01)
### Bug Fixes
* MongoDB aggregation pipeline with `$dateSubtract` from `$$NOW` returns no results ([#9822](https://github.com/parse-community/parse-server/issues/9822)) ([847a274](https://github.com/parse-community/parse-server/commit/847a274cdb8c22f8e0fc249162e5e2c9e29a594a))
## [8.2.2](https://github.com/parse-community/parse-server/compare/8.2.1...8.2.2) (2025-07-10)
### Bug Fixes
* Data schema exposed via GraphQL API public introspection (GHSA-48q3-prgv-gm4w) ([#9819](https://github.com/parse-community/parse-server/issues/9819)) ([c58b2eb](https://github.com/parse-community/parse-server/commit/c58b2eb6eb48af9d3c2e69b4829810a021347b40))
## [8.2.1](https://github.com/parse-community/parse-server/compare/8.2.0...8.2.1) (2025-06-01)
### Bug Fixes
* `Parse.Query.containedIn` and `matchesQuery` do not work with nested objects ([#9738](https://github.com/parse-community/parse-server/issues/9738)) ([0db3a6f](https://github.com/parse-community/parse-server/commit/0db3a6ff27a129427770e314a792cc586e4255b5))
### Performance Improvements
* Remove saving Parse Cloud Job request parameters in internal collection `_JobStatus` ([#8343](https://github.com/parse-community/parse-server/issues/8343)) ([e98733c](https://github.com/parse-community/parse-server/commit/e98733cbac9451521a3acc388d2f9d29eb4610e0))
# [8.2.0](https://github.com/parse-community/parse-server/compare/8.1.0...8.2.0) (2025-05-01)
### Features
* Add TypeScript definitions ([#9693](https://github.com/parse-community/parse-server/issues/9693)) ([e86718f](https://github.com/parse-community/parse-server/commit/e86718fc59c7c8e6f3c6abd0feb7d1a68ca76c23))
### Performance Improvements
* Add details to error message in `Parse.Query.aggregate` ([#9689](https://github.com/parse-community/parse-server/issues/9689)) ([9de6999](https://github.com/parse-community/parse-server/commit/9de6999e257d839b68bbca282447777edfdb1ddf))
# [8.1.0](https://github.com/parse-community/parse-server/compare/8.0.2...8.1.0) (2025-04-04)
### Bug Fixes
* Parse Server doesn't shutdown gracefully ([#9634](https://github.com/parse-community/parse-server/issues/9634)) ([aed918d](https://github.com/parse-community/parse-server/commit/aed918d3109e739f7231d481b5f48c68fc01cf04))
### Features
* Add Cloud Code triggers `Parse.Cloud.beforeFind(Parse.File)`and `Parse.Cloud.afterFind(Parse.File)` ([#8700](https://github.com/parse-community/parse-server/issues/8700)) ([b2beaa8](https://github.com/parse-community/parse-server/commit/b2beaa86ff543a7aa4ad274c7a23bc4aa302c3fa))
* Add default ACL ([#8701](https://github.com/parse-community/parse-server/issues/8701)) ([12b5d78](https://github.com/parse-community/parse-server/commit/12b5d781dc3f8c43c0c566dffa9308d02a7d8043))
* Upgrade Parse JS SDK from 6.0.0 to 6.1.0 ([#9686](https://github.com/parse-community/parse-server/issues/9686)) ([f49c371](https://github.com/parse-community/parse-server/commit/f49c371c1373d41e68b091e65f33a71ff6fc6dd0))
## [8.0.2](https://github.com/parse-community/parse-server/compare/8.0.1...8.0.2) (2025-03-21)
### Bug Fixes
* Authentication provider credentials are usable across Parse Server apps; fixes security vulnerability [GHSA-837q-jhwx-cmpv](https://github.com/parse-community/parse-server/security/advisories/GHSA-837q-jhwx-cmpv) ([#9667](https://github.com/parse-community/parse-server/issues/9667)) ([5ef0440](https://github.com/parse-community/parse-server/commit/5ef0440c8e763854e62341acaeb6dc4ade3ba82f))
## [8.0.1](https://github.com/parse-community/parse-server/compare/8.0.0...8.0.1) (2025-03-17)
### Bug Fixes
* Security upgrade node from 20.18.2-alpine3.20 to 20.19.0-alpine3.20 ([#9652](https://github.com/parse-community/parse-server/issues/9652)) ([2be1a19](https://github.com/parse-community/parse-server/commit/2be1a19a13d6f0f8e3eb4e399a6279ff4d01db76))
* Using Parse Server option `extendSessionOnUse` does not correctly clear memory and functions as a debounce instead of a throttle ([#8683](https://github.com/parse-community/parse-server/issues/8683)) ([6258a6a](https://github.com/parse-community/parse-server/commit/6258a6a11235dc642c71074d24e19c055294d26d))
# [8.0.0](https://github.com/parse-community/parse-server/compare/7.4.0...8.0.0) (2025-03-04)
### Bug Fixes
* LiveQueryServer crashes using cacheAdapter on disconnect from Redis 4 server ([#9616](https://github.com/parse-community/parse-server/issues/9616)) ([bbc6bd4](https://github.com/parse-community/parse-server/commit/bbc6bd4b3f493170c13ad3314924cbf1f379eca4))
* Push adapter not loading on some versions of Node 22 ([#9524](https://github.com/parse-community/parse-server/issues/9524)) ([ff7f671](https://github.com/parse-community/parse-server/commit/ff7f671c79f5dcdc44e4319a10f3654e12662c23))
* Remove username from email verification and password reset process ([#8488](https://github.com/parse-community/parse-server/issues/8488)) ([d21dd97](https://github.com/parse-community/parse-server/commit/d21dd973363f9c5eca86a1007cb67e445b0d2e02))
* Security upgrade node from 20.17.0-alpine3.20 to 20.18.2-alpine3.20 ([#9583](https://github.com/parse-community/parse-server/issues/9583)) ([8f85ae2](https://github.com/parse-community/parse-server/commit/8f85ae205474f65414c0536754de12c87dbbf82a))
### Features
* Add dynamic master key by setting Parse Server option `masterKey` to a function ([#9582](https://github.com/parse-community/parse-server/issues/9582)) ([6f1d161](https://github.com/parse-community/parse-server/commit/6f1d161a2f263a166981f9544cf2aadce65afe23))
* Add support for MongoDB `databaseOptions` keys `autoSelectFamily`, `autoSelectFamilyAttemptTimeout` ([#9579](https://github.com/parse-community/parse-server/issues/9579)) ([5966068](https://github.com/parse-community/parse-server/commit/5966068e963e7a79eac8fba8720ee7d83578f207))
* Add support for MongoDB `databaseOptions` keys `minPoolSize`, `connectTimeoutMS`, `socketTimeoutMS` ([#9522](https://github.com/parse-community/parse-server/issues/9522)) ([91618fe](https://github.com/parse-community/parse-server/commit/91618fe738217b937cbfcec35969679e0adb7676))
* Add TypeScript support ([#9550](https://github.com/parse-community/parse-server/issues/9550)) ([59e46d0](https://github.com/parse-community/parse-server/commit/59e46d0aea3e6529994d98160d993144b8075291))
* Change default value of Parse Server option `encodeParseObjectInCloudFunction` to `true` ([#9527](https://github.com/parse-community/parse-server/issues/9527)) ([5c5ad69](https://github.com/parse-community/parse-server/commit/5c5ad69b4a917b7ed7c328a8255144e105c40b08))
* Deprecate `PublicAPIRouter` in favor of `PagesRouter` ([#9526](https://github.com/parse-community/parse-server/issues/9526)) ([7f66629](https://github.com/parse-community/parse-server/commit/7f666292e8b9692966672486b7108edefc356309))
* Increase required minimum MongoDB versions to `6.0.19`, `7.0.16`, `8.0.4` ([#9531](https://github.com/parse-community/parse-server/issues/9531)) ([871e508](https://github.com/parse-community/parse-server/commit/871e5082a9fd768cee3012e26d3c8ddff5c2952c))
* Increase required minimum Node versions to `18.20.4`, `20.18.0`, `22.12.0` ([#9521](https://github.com/parse-community/parse-server/issues/9521)) ([4e151cd](https://github.com/parse-community/parse-server/commit/4e151cd0a52191809452f197b2f29c3a12525b67))
* Increase required minimum versions to Postgres `15`, PostGIS `3.3` ([#9538](https://github.com/parse-community/parse-server/issues/9538)) ([89c9b54](https://github.com/parse-community/parse-server/commit/89c9b5485a07a411fb35de4f8cf0467e7eb01f85))
* Upgrade to express 5.0.1 ([#9530](https://github.com/parse-community/parse-server/issues/9530)) ([e0480df](https://github.com/parse-community/parse-server/commit/e0480dfa8d97946e57eac6b74d937978f8454b3a))
* Upgrade to Parse JS SDK 6.0.0 ([#9624](https://github.com/parse-community/parse-server/issues/9624)) ([bf9db75](https://github.com/parse-community/parse-server/commit/bf9db75e8685def1407034944725e758bc926c26))
### BREAKING CHANGES
* This upgrades the internally used Express framework from version 4 to 5, which may be a breaking change. If Parse Server is set up to be mounted on an Express application, we recommend to also use version 5 of the Express framework to avoid any compatibility issues. Note that even if there are no issues after upgrading, future releases of Parse Server may introduce issues if Parse Server internally relies on Express 5-specific features which are unsupported by the Express version on which it is mounted. See the Express [migration guide](https://expressjs.com/en/guide/migrating-5.html) and [release announcement](https://expressjs.com/2024/10/15/v5-release.html#breaking-changes) for more info. ([e0480df](e0480df))
* This upgrades to the Parse JS SDK 6.0.0. See the [change log](https://github.com/parse-community/Parse-SDK-JS/releases/tag/6.0.0) of the Parse JS SDK for breaking changes and more details. ([bf9db75](bf9db75))
* This removes the username from the email verification and password reset process to prevent storing personally identifiable information (PII) in server and infrastructure logs. Customized HTML pages or emails related to email verification and password reset may need to be adapted accordingly. See the new templates that come bundled with Parse Server and the [migration guide](https://github.com/parse-community/parse-server/blob/alpha/8.0.0.md) for more details. ([d21dd97](d21dd97))
* This releases increases the required minimum versions to Postgres `15`, PostGIS `3.3` and removes support for Postgres `13`, `14`, PostGIS `3.1`, `3.2`. ([89c9b54](89c9b54))
* The default value of Parse Server option `encodeParseObjectInCloudFunction` changes to `true`; the option has been deprecated and will be removed in a future version. ([5c5ad69](5c5ad69))
* This releases increases the required minimum MongoDB versions to `6.0.19`, `7.0.16`, `8.0.4` and removes support for MongoDB `4`, `5`. ([871e508](871e508))
* This releases increases the required minimum Node versions to 18.20.4, 20.18.0, 22.12.0 and removes unofficial support for Node 19. ([4e151cd](4e151cd))
# [7.4.0](https://github.com/parse-community/parse-server/compare/7.3.0...7.4.0) (2024-12-23)
### Bug Fixes
* `Parse.Query.distinct` fails due to invalid aggregate stage 'hint' ([#9295](https://github.com/parse-community/parse-server/issues/9295)) ([5f66c6a](https://github.com/parse-community/parse-server/commit/5f66c6a075cbe1cdaf9d1b108ee65af8ae596b89))
* Security upgrade cross-spawn from 7.0.3 to 7.0.6 ([#9444](https://github.com/parse-community/parse-server/issues/9444)) ([3d034e0](https://github.com/parse-community/parse-server/commit/3d034e0a993e3e5bd9bb96a7e382bb3464f1eb68))
* Security upgrade fast-xml-parser from 4.4.0 to 4.4.1 ([#9262](https://github.com/parse-community/parse-server/issues/9262)) ([992d39d](https://github.com/parse-community/parse-server/commit/992d39d508f230c774dcb764d1d907ec8887e6c5))
* Security upgrade node from 20.14.0-alpine3.20 to 20.17.0-alpine3.20 ([#9300](https://github.com/parse-community/parse-server/issues/9300)) ([15bb17d](https://github.com/parse-community/parse-server/commit/15bb17d87153bf0d38f08fe4c720da29a204b36b))
### Features
* Add support for MongoDB 8 ([#9269](https://github.com/parse-community/parse-server/issues/9269)) ([4756c66](https://github.com/parse-community/parse-server/commit/4756c66cd9f55afa1621d1a3f6fa850ed605cb53))
* Add support for PostGIS 3.5 ([#9354](https://github.com/parse-community/parse-server/issues/9354)) ([8ea3538](https://github.com/parse-community/parse-server/commit/8ea35382db3436d54ab59bd30706705564b0985c))
* Add support for Postgres 17 ([#9324](https://github.com/parse-community/parse-server/issues/9324)) ([fa2ee31](https://github.com/parse-community/parse-server/commit/fa2ee3196e4319a142b3838bb947c98dcba5d5cb))
* Upgrade @parse/push-adapter from 6.7.1 to 6.8.0 ([#9489](https://github.com/parse-community/parse-server/issues/9489)) ([286aa66](https://github.com/parse-community/parse-server/commit/286aa664ac8830d36c3e70d2316917d15f0b6df5))
# [7.3.0](https://github.com/parse-community/parse-server/compare/7.2.0...7.3.0) (2024-10-03)
### Bug Fixes
* Custom object ID allows to acquire role privileges ([GHSA-8xq9-g7ch-35hg](https://github.com/parse-community/parse-server/security/advisories/GHSA-8xq9-g7ch-35hg)) ([#9317](https://github.com/parse-community/parse-server/issues/9317)) ([13ee52f](https://github.com/parse-community/parse-server/commit/13ee52f0d19ef3a3524b3d79aea100e587eb3cfc))
* Parse Server `databaseOptions` nested keys incorrectly identified as invalid ([#9213](https://github.com/parse-community/parse-server/issues/9213)) ([77206d8](https://github.com/parse-community/parse-server/commit/77206d804443cfc1618c24f8961bd677de9920c0))
* Parse Server installation fails due to post install script incorrectly parsing required min. Node version ([#9216](https://github.com/parse-community/parse-server/issues/9216)) ([0fa82a5](https://github.com/parse-community/parse-server/commit/0fa82a54fe38ec14e8054339285d3db71a8624c8))
* Parse Server option `maxLogFiles` doesn't recognize day duration literals such as `1d` to mean 1 day ([#9215](https://github.com/parse-community/parse-server/issues/9215)) ([0319cee](https://github.com/parse-community/parse-server/commit/0319cee2dbf65e90bad377af1ed14ea25c595bf5))
* Security upgrade path-to-regexp from 6.2.1 to 6.3.0 ([#9314](https://github.com/parse-community/parse-server/issues/9314)) ([8b7fe69](https://github.com/parse-community/parse-server/commit/8b7fe699c1c376ecd8cc1c97cce8e704ee41f28a))
### Features
* Add atomic operations for Cloud Config parameters ([#9219](https://github.com/parse-community/parse-server/issues/9219)) ([35cadf9](https://github.com/parse-community/parse-server/commit/35cadf9b8324879fb7309ba5d7ea46f2c722d614))
* Add Cloud Code triggers `Parse.Cloud.beforeSave` and `Parse.Cloud.afterSave` for Parse Config ([#9232](https://github.com/parse-community/parse-server/issues/9232)) ([90a1e4a](https://github.com/parse-community/parse-server/commit/90a1e4a200423d644efb3f0ba2fba4b99f5cf954))
* Add Node 22 support ([#9187](https://github.com/parse-community/parse-server/issues/9187)) ([7778471](https://github.com/parse-community/parse-server/commit/7778471999c7e42236ce404229660d80ecc2acd6))
* Add support for asynchronous invocation of `FilesAdapter.getFileLocation` ([#9271](https://github.com/parse-community/parse-server/issues/9271)) ([1a2da40](https://github.com/parse-community/parse-server/commit/1a2da4055abe831b3017172fb75e16d7a8093873))
# [7.2.0](https://github.com/parse-community/parse-server/compare/7.1.0...7.2.0) (2024-07-09)
### Bug Fixes
* Invalid push notification tokens are not cleaned up from database for FCM API v2 ([#9173](https://github.com/parse-community/parse-server/issues/9173)) ([284da09](https://github.com/parse-community/parse-server/commit/284da09f4546356b37511a589fb5f64a3efffe79))
### Features
* Add support for dot notation on array fields of Parse Object ([#9115](https://github.com/parse-community/parse-server/issues/9115)) ([cf4c880](https://github.com/parse-community/parse-server/commit/cf4c8807b9da87a0a5f9c94e5bdfcf17cda80cf4))
* Upgrade to @parse/push-adapter 6.4.0 ([#9182](https://github.com/parse-community/parse-server/issues/9182)) ([ef1634b](https://github.com/parse-community/parse-server/commit/ef1634bf1f360429108d29b08032fc7961ff96a1))
* Upgrade to Parse JS SDK 5.3.0 ([#9180](https://github.com/parse-community/parse-server/issues/9180)) ([dca187f](https://github.com/parse-community/parse-server/commit/dca187f91b93cbb362b22a3fb9ee38451799ff13))
# [7.1.0](https://github.com/parse-community/parse-server/compare/7.0.0...7.1.0) (2024-06-30)
### Bug Fixes
* `Parse.Cloud.startJob` and `Parse.Push.send` not returning status ID when setting Parse Server option `directAccess: true` ([#8766](https://github.com/parse-community/parse-server/issues/8766)) ([5b0efb2](https://github.com/parse-community/parse-server/commit/5b0efb22efe94c47f243cf8b1e6407ed5c5a67d3))
* `Required` option not handled correctly for special fields (File, GeoPoint, Polygon) on GraphQL API mutations ([#8915](https://github.com/parse-community/parse-server/issues/8915)) ([907ad42](https://github.com/parse-community/parse-server/commit/907ad4267c228d26cfcefe7848b30ce85ba7ff8f))
* Facebook Limited Login not working due to incorrect domain in JWT validation ([#9122](https://github.com/parse-community/parse-server/issues/9122)) ([9d0bd2b](https://github.com/parse-community/parse-server/commit/9d0bd2badd6e5f7429d1af00b118225752e5d86a))
* Live query throws error when constraint `notEqualTo` is set to `null` ([#8835](https://github.com/parse-community/parse-server/issues/8835)) ([11d3e48](https://github.com/parse-community/parse-server/commit/11d3e484df862224c15d20f6171514948981ea90))
* Parse Server option `extendSessionOnUse` not working for session lengths < 24 hours ([#9113](https://github.com/parse-community/parse-server/issues/9113)) ([0a054e6](https://github.com/parse-community/parse-server/commit/0a054e6b541fd5ab470bf025665f5f7d2acedaa0))
* Rate limiting can fail when using Parse Server option `rateLimit.redisUrl` with clusters ([#8632](https://github.com/parse-community/parse-server/issues/8632)) ([c277739](https://github.com/parse-community/parse-server/commit/c27773962399f8e27691e3b8087e7e1d59516efd))
* SQL injection when using Parse Server with PostgreSQL; fixes security vulnerability [GHSA-c2hr-cqg6-8j6r](https://github.com/parse-community/parse-server/security/advisories/GHSA-c2hr-cqg6-8j6r) ([#9167](https://github.com/parse-community/parse-server/issues/9167)) ([2edf1e4](https://github.com/parse-community/parse-server/commit/2edf1e4c0363af01e97a7fbc97694f851b7d1ff3))
### Features
* Add `silent` log level for Cloud Code ([#8803](https://github.com/parse-community/parse-server/issues/8803)) ([5f81efb](https://github.com/parse-community/parse-server/commit/5f81efb42964c4c2fa8bcafee9446a0122e3ce21))
* Add server security check status `security.enableCheck` to Features Router ([#8679](https://github.com/parse-community/parse-server/issues/8679)) ([b07ec15](https://github.com/parse-community/parse-server/commit/b07ec153825882e97cc48dc84072c7f549f3238b))
* Prevent Parse Server start in case of unknown option in server configuration ([#8987](https://github.com/parse-community/parse-server/issues/8987)) ([8758e6a](https://github.com/parse-community/parse-server/commit/8758e6abb9dbb68757bddcbd332ad25100c24a0e))
* Upgrade to @parse/push-adapter 6.0.0 ([#9066](https://github.com/parse-community/parse-server/issues/9066)) ([18bdbf8](https://github.com/parse-community/parse-server/commit/18bdbf89c53a57648891ef582614ba7c2941e587))
* Upgrade to @parse/push-adapter 6.2.0 ([#9127](https://github.com/parse-community/parse-server/issues/9127)) ([ca20496](https://github.com/parse-community/parse-server/commit/ca20496f28e5ec1294a7a23c8559df82b79b2a04))
* Upgrade to Parse JS SDK 5.2.0 ([#9128](https://github.com/parse-community/parse-server/issues/9128)) ([665b8d5](https://github.com/parse-community/parse-server/commit/665b8d52d6cf5275179a5e1fb132c934edb53ecc))
# [7.0.0](https://github.com/parse-community/parse-server/compare/6.4.0...7.0.0) (2024-03-19)
### Bug Fixes
* CacheAdapter does not connect when using a CacheAdapter with a JSON config ([#8633](https://github.com/parse-community/parse-server/issues/8633)) ([720d24e](https://github.com/parse-community/parse-server/commit/720d24e18540da35d50957f17be878316ec30318))
* Conditional email verification not working in some cases if `verifyUserEmails`, `preventLoginWithUnverifiedEmail` set to functions ([#8838](https://github.com/parse-community/parse-server/issues/8838)) ([8e7a6b1](https://github.com/parse-community/parse-server/commit/8e7a6b1480c0117e6c73e7adc5a6619115a04e85))
* Context not passed to Cloud Code Trigger `beforeFind` when using `Parse.Query.include` ([#8765](https://github.com/parse-community/parse-server/issues/8765)) ([7d32d89](https://github.com/parse-community/parse-server/commit/7d32d8934f3ae7af7a7d8b9cc6a829c7d73973d3))
* Deny request if master key is not set in Parse Server option `masterKeyIps` regardless of ACL and CLP ([#8957](https://github.com/parse-community/parse-server/issues/8957)) ([a7b5b38](https://github.com/parse-community/parse-server/commit/a7b5b38418cbed9be3f4a7665f25b97f592663e1))
* Docker image not published to Docker Hub on new release ([#8905](https://github.com/parse-community/parse-server/issues/8905)) ([a2ac8d1](https://github.com/parse-community/parse-server/commit/a2ac8d133c71cd7b61e5ef59c4be915cfea85db6))
* Docker version releases by removing arm/v6 and arm/v7 support ([#8976](https://github.com/parse-community/parse-server/issues/8976)) ([1f62dd0](https://github.com/parse-community/parse-server/commit/1f62dd0f4e107b22a387692558a042ee26ce8703))
* GraphQL file upload fails in case of use of pointer or relation ([#8721](https://github.com/parse-community/parse-server/issues/8721)) ([1aba638](https://github.com/parse-community/parse-server/commit/1aba6382c873fb489d4a898d301e6da9fb6aa61b))
* Improve PostgreSQL injection detection; fixes security vulnerability [GHSA-6927-3vr9-fxf2](https://github.com/parse-community/parse-server/security/advisories/GHSA-6927-3vr9-fxf2) which affects Parse Server deployments using a Postgres database ([#8961](https://github.com/parse-community/parse-server/issues/8961)) ([cbefe77](https://github.com/parse-community/parse-server/commit/cbefe770a7260b54748a058b8a7389937dc35833))
* Incomplete user object in `verifyEmail` function if both username and email are changed ([#8889](https://github.com/parse-community/parse-server/issues/8889)) ([1eb95ae](https://github.com/parse-community/parse-server/commit/1eb95aeb41a96250e582d79a703f6adcb403c08b))
* Parse Server option `emailVerifyTokenReuseIfValid: true` generates new token on every email verification request ([#8885](https://github.com/parse-community/parse-server/issues/8885)) ([0023ce4](https://github.com/parse-community/parse-server/commit/0023ce448a5e9423337d0e1a25648bde1156bc95))
* Parse Server option `fileExtensions` default value rejects file extensions that are less than 3 or more than 4 characters long ([#8699](https://github.com/parse-community/parse-server/issues/8699)) ([2760381](https://github.com/parse-community/parse-server/commit/276038118377c2b22381bcd8d30337203822121b))
* Parse Server option `fileUpload.fileExtensions` fails to determine file extension if filename contains multiple dots ([#8754](https://github.com/parse-community/parse-server/issues/8754)) ([3d6d50e](https://github.com/parse-community/parse-server/commit/3d6d50e0afff18b95fb906914e2cebd3839b517a))
* Security bump @babel/traverse from 7.20.5 to 7.23.2 ([#8777](https://github.com/parse-community/parse-server/issues/8777)) ([2d6b3d1](https://github.com/parse-community/parse-server/commit/2d6b3d18499179e99be116f25c0850d3f449509c))
* Security upgrade graphql from 16.6.0 to 16.8.1 ([#8758](https://github.com/parse-community/parse-server/issues/8758)) ([71dfd8a](https://github.com/parse-community/parse-server/commit/71dfd8a7ece8c0dd1a66d03bb9420cfd39f4f9b1))
* Server crashes on invalid Cloud Function or Cloud Job name; fixes security vulnerability [GHSA-6hh7-46r2-vf29](https://github.com/parse-community/parse-server/security/advisories/GHSA-6hh7-46r2-vf29) ([#9024](https://github.com/parse-community/parse-server/issues/9024)) ([9f6e342](https://github.com/parse-community/parse-server/commit/9f6e3429d3b326cf4e2994733c618d08032fac6e))
* Server crashes when receiving an array of `Parse.Pointer` in the request body ([#8784](https://github.com/parse-community/parse-server/issues/8784)) ([66e3603](https://github.com/parse-community/parse-server/commit/66e36039d8af654cfa0284666c0ddd94975dcb52))
* Username is `undefined` in email verification link on email change ([#8887](https://github.com/parse-community/parse-server/issues/8887)) ([e315c13](https://github.com/parse-community/parse-server/commit/e315c137bf41bedfa8f0df537f2c3f6ab45b7e60))
### Features
* Add `$setOnInsert` operator to `Parse.Server.database.update` ([#8791](https://github.com/parse-community/parse-server/issues/8791)) ([f630a45](https://github.com/parse-community/parse-server/commit/f630a45aa5e87bc73a81fded061400c199b71a29))
* Add `installationId` to arguments for `verifyUserEmails`, `preventLoginWithUnverifiedEmail` ([#8836](https://github.com/parse-community/parse-server/issues/8836)) ([a22dbe1](https://github.com/parse-community/parse-server/commit/a22dbe16d5ac0090608f6caaf0ebd134925b7fd4))
* Add `installationId`, `ip`, `resendRequest` to arguments passed to `verifyUserEmails` on verification email request ([#8873](https://github.com/parse-community/parse-server/issues/8873)) ([8adcbee](https://github.com/parse-community/parse-server/commit/8adcbee11283d3e95179ca2047e2615f52c18806))
* Add `Parse.User` as function parameter to Parse Server options `verifyUserEmails`, `preventLoginWithUnverifiedEmail` on login ([#8850](https://github.com/parse-community/parse-server/issues/8850)) ([972f630](https://github.com/parse-community/parse-server/commit/972f6300163b3cd7d95eeb95986e8322c95f821c))
* Add compatibility for MongoDB Atlas Serverless and AWS Amazon DocumentDB with collation options `enableCollationCaseComparison`, `transformEmailToLowercase`, `transformUsernameToLowercase` ([#8805](https://github.com/parse-community/parse-server/issues/8805)) ([09fbeeb](https://github.com/parse-community/parse-server/commit/09fbeebba8870e7cf371fb84371a254c7b368620))
* Add context to Cloud Code Triggers `beforeLogin` and `afterLogin` ([#8724](https://github.com/parse-community/parse-server/issues/8724)) ([a9c34ef](https://github.com/parse-community/parse-server/commit/a9c34ef1e2c78a42fb8b5fa8d569b7677c74919d))
* Add password validation via POST request for user with unverified email using master key and option `ignoreEmailVerification` ([#8895](https://github.com/parse-community/parse-server/issues/8895)) ([633a9d2](https://github.com/parse-community/parse-server/commit/633a9d25e4253e2125bc93c02ee8a37e0f5f7b83))
* Add support for MongoDB 7 ([#8761](https://github.com/parse-community/parse-server/issues/8761)) ([3de8494](https://github.com/parse-community/parse-server/commit/3de8494a221991dfd10a74e0a2dc89576265c9b7))
* Add support for MongoDB query comment ([#8928](https://github.com/parse-community/parse-server/issues/8928)) ([2170962](https://github.com/parse-community/parse-server/commit/2170962a50fa353ed85eda3f11dce7ee3647b087))
* Add support for Node 20, drop support for Node 14, 16 ([#8907](https://github.com/parse-community/parse-server/issues/8907)) ([ced4872](https://github.com/parse-community/parse-server/commit/ced487246ea0ef72a8aa014991f003209b34841e))
* Add support for Postgres 16 ([#8898](https://github.com/parse-community/parse-server/issues/8898)) ([99489b2](https://github.com/parse-community/parse-server/commit/99489b22e4f0982e6cb39992974b51aa8d3a31e4))
* Allow `Parse.Session.current` on expired session token instead of throwing error ([#8722](https://github.com/parse-community/parse-server/issues/8722)) ([f9dde4a](https://github.com/parse-community/parse-server/commit/f9dde4a9f8a90c63f71172c9bc515b0f6c6d2e4a))
* Allow setting `createdAt` and `updatedAt` during `Parse.Object` creation with maintenance key ([#8696](https://github.com/parse-community/parse-server/issues/8696)) ([77bbfb3](https://github.com/parse-community/parse-server/commit/77bbfb3f186f5651c33ba152f04cff95128eaf2d))
* Deprecation DEPPS5: Config option `allowClientClassCreation` defaults to `false` ([#8849](https://github.com/parse-community/parse-server/issues/8849)) ([29624e0](https://github.com/parse-community/parse-server/commit/29624e0fae17161cd412ae58d35a195cfa286cad))
* Deprecation DEPPS6: Authentication adapters disabled by default ([#8858](https://github.com/parse-community/parse-server/issues/8858)) ([0cf58eb](https://github.com/parse-community/parse-server/commit/0cf58eb8d60c8e5f485764e154f3214c49eee430))
* Deprecation DEPPS7: Remove deprecated Cloud Code file trigger syntax ([#8855](https://github.com/parse-community/parse-server/issues/8855)) ([4e6a375](https://github.com/parse-community/parse-server/commit/4e6a375b5184ae0f7aa256a921eca4021c609435))
* Deprecation DEPPS8: Parse Server option `allowExpiredAuthDataToken` defaults to `false` ([#8860](https://github.com/parse-community/parse-server/issues/8860)) ([e29845f](https://github.com/parse-community/parse-server/commit/e29845f8dacac09ce3093d75c0d92330c24389e8))
* Deprecation DEPPS9: LiveQuery `fields` option is renamed to `keys` ([#8852](https://github.com/parse-community/parse-server/issues/8852)) ([38983e8](https://github.com/parse-community/parse-server/commit/38983e8e9b5cdbd006f311a2338103624137d013))
* Node process exits with error code 1 on uncaught exception to allow custom uncaught exception handling ([#8894](https://github.com/parse-community/parse-server/issues/8894)) ([70c280c](https://github.com/parse-community/parse-server/commit/70c280ca578ff28b5acf92f37fbe06d42a5b34ca))
* Switch GraphQL server from Yoga v2 to Apollo v4 ([#8959](https://github.com/parse-community/parse-server/issues/8959)) ([105ae7c](https://github.com/parse-community/parse-server/commit/105ae7c8a57d5a650b243174a80c26bf6db16e28))
* Upgrade Parse Server Push Adapter to 5.0.2 ([#8813](https://github.com/parse-community/parse-server/issues/8813)) ([6ef1986](https://github.com/parse-community/parse-server/commit/6ef1986c03a1d84b7e11c05851e5bf9688d88740))
* Upgrade to Parse JS SDK 5 ([#9022](https://github.com/parse-community/parse-server/issues/9022)) ([ad4aa83](https://github.com/parse-community/parse-server/commit/ad4aa83983205a0e27639f6ee6a4a5963b67e4b8))
### Performance Improvements
* Improved IP validation performance for `masterKeyIPs`, `maintenanceKeyIPs` ([#8510](https://github.com/parse-community/parse-server/issues/8510)) ([b87daba](https://github.com/parse-community/parse-server/commit/b87daba0671a1b0b7b8d63bc671d665c91a04522))
### BREAKING CHANGES
* The Parse Server option `allowClientClassCreation` defaults to `false`. ([29624e0](29624e0))
* A request using the master key will now be rejected as unauthorized if the IP from which the request originates is not set in the Parse Server option `masterKeyIps`, even if the request does not require the master key permission, for example for a public object in a public class class. ([a7b5b38](a7b5b38))
* Node process now exits with code 1 on uncaught exceptions, enabling custom handlers that were blocked by Parse Server's default behavior of re-throwing errors. This change may lead to automatic process restarts by the environment, unlike before. ([70c280c](70c280c))
* Authentication adapters are disabled by default; to use an authentication adapter it needs to be explicitly enabled in the Parse Server authentication adapter option `auth.<provider>.enabled: true` ([0cf58eb](0cf58eb))
* Parse Server option `allowExpiredAuthDataToken` defaults to `false`; a 3rd party authentication token will be validated every time the user tries to log in and the login will fail if the token has expired; the effect of this change may differ for different authentication adapters, depending on the token lifetime and the token refresh logic of the adapter ([e29845f](e29845f))
* LiveQuery `fields` option is renamed to `keys` ([38983e8](38983e8))
* Cloud Code file trigger syntax has been aligned with object trigger syntax, for example `Parse.Cloud.beforeDeleteFile'` has been changed to `Parse.Cloud.beforeDelete(Parse.File, (request) => {})'` ([4e6a375](4e6a375))
* Removes support for Node 14 and 16 ([ced4872](ced4872))
* Removes support for Postgres 11 and 12 ([99489b2](99489b2))
* The `Parse.User` passed as argument if `verifyUserEmails` is set to a function is renamed from `user` to `object` for consistency with invocations of `verifyUserEmails` on signup or login; the user object is not a plain JavaScript object anymore but an instance of `Parse.User` ([8adcbee](8adcbee))
* `Parse.Session.current()` no longer throws an error if the session token is expired, but instead returns the session token with its expiration date to allow checking its validity ([f9dde4a](f9dde4a))
* `Parse.Query` no longer supports the BSON type `code`; although this feature was never officially documented, its removal is announced as a breaking change to protect deployments where it might be in use. ([3de8494](3de8494))
* Improve PostgreSQL injection detection; fixes security vulnerability [GHSA-6927-3vr9-fxf2](https://github.com/parse-community/parse-server/security/advisories/GHSA-6927-3vr9-fxf2) which affects Parse Server deployments using a Postgres database ([#8960](https://github.com/parse-community/parse-server/issues/8960)) ([a6e6549](https://github.com/parse-community/parse-server/commit/a6e654943536932904a69b51e513507fcf90a504))
# [6.4.0](https://github.com/parse-community/parse-server/compare/6.3.1...6.4.0) (2023-11-16)
+1 -3
View File
@@ -1,3 +1,4 @@
const core = require('@actions/core');
const semver = require('semver');
const yaml = require('yaml');
const fs = require('fs').promises;
@@ -219,8 +220,6 @@ class CiVersionCheck {
* Runs the check.
*/
async check() {
const core = await import('@actions/core');
/* eslint-disable no-console */
try {
console.log(`\nChecking ${this.packageName} versions in CI environments...`);
@@ -285,7 +284,6 @@ class CiVersionCheck {
const msg = `Failed to check ${this.packageName} versions with error: ${e}`;
core.setFailed(msg);
}
/* eslint-enable no-console */
}
}
+18 -20
View File
@@ -1,7 +1,8 @@
'use strict';
const CiVersionCheck = require('./CiVersionCheck');
const { exec } = require('child_process');
const mongoVersionList = require('mongodb-version-list');
const allNodeVersions = require('all-node-versions');
async function check() {
// Run checks
@@ -13,16 +14,14 @@ async function check() {
* Check the MongoDB versions used in test environments.
*/
async function checkMongoDbVersions() {
let latestStableVersions = await new Promise((resolve, reject) => {
exec('m ls', (error, stdout) => {
const releasedVersions = await new Promise((resolve, reject) => {
mongoVersionList(function (error, versions) {
if (error) {
reject(error);
return;
}
resolve(stdout.trim());
resolve(versions);
});
});
latestStableVersions = latestStableVersions.split('\n').map(version => version.trim());
await new CiVersionCheck({
packageName: 'MongoDB',
@@ -30,14 +29,13 @@ async function checkMongoDbVersions() {
yamlFilePath: './.github/workflows/ci.yml',
ciEnvironmentsKeyPath: 'jobs.check-mongo.strategy.matrix.include',
ciVersionKey: 'MONGODB_VERSION',
releasedVersions: latestStableVersions,
latestComponent: CiVersionCheck.versionComponents.patch,
releasedVersions,
latestComponent: CiVersionCheck.versionComponents.minor,
ignoreReleasedVersions: [
'<4.2.0', // These versions have reached their end-of-life support date
'>=4.3.0 <5.0.0', // Unsupported rapid release versions
'>=5.1.0 <6.0.0', // Unsupported rapid release versions
'>=6.1.0 <7.0.0', // Unsupported rapid release versions
'>=7.1.0 <8.0.0', // Unsupported rapid release versions
'<4.0.0', // Versions reached their MongoDB end-of-life support date
'~4.1.0', // Development release according to MongoDB support
'~4.3.0', // Development release according to MongoDB support
'~4.7.0', // Development release according to MongoDB support
],
}).check();
}
@@ -46,9 +44,8 @@ async function checkMongoDbVersions() {
* Check the Nodejs versions used in test environments.
*/
async function checkNodeVersions() {
const allVersions = (await import('all-node-versions')).default;
const { versions } = await allVersions();
const nodeVersions = versions.map(version => version.node);
const allVersions = await allNodeVersions();
const releasedVersions = allVersions.versions;
await new CiVersionCheck({
packageName: 'Node.js',
@@ -56,12 +53,13 @@ async function checkNodeVersions() {
yamlFilePath: './.github/workflows/ci.yml',
ciEnvironmentsKeyPath: 'jobs.check-mongo.strategy.matrix.include',
ciVersionKey: 'NODE_VERSION',
releasedVersions: nodeVersions,
releasedVersions,
latestComponent: CiVersionCheck.versionComponents.minor,
ignoreReleasedVersions: [
'<18.0.0', // These versions have reached their end-of-life support date
'>=19.0.0 <20.0.0', // These versions have reached their end-of-life support date
'>=21.0.0', // These versions are not officially supported yet
'<12.0.0', // These versions have reached their end-of-life support date
'>=13.0.0 <14.0.0', // These versions have reached their end-of-life support date
'>=15.0.0 <16.0.0', // These versions have reached their end-of-life support date
'>=19.0.0', // These versions are not officially supported yet
],
}).check();
}
+1 -2
View File
@@ -1,8 +1,8 @@
const fs = require('fs').promises;
const { exec } = require('child_process');
const core = require('@actions/core');
const util = require('util');
(async () => {
const core = await import('@actions/core');
const [currentDefinitions, currentDocs] = await Promise.all([
fs.readFile('./src/Options/Definitions.js', 'utf8'),
fs.readFile('./src/Options/docs.js', 'utf8'),
@@ -14,7 +14,6 @@ const util = require('util');
fs.readFile('./src/Options/docs.js', 'utf8'),
]);
if (currentDefinitions !== newDefinitions || currentDocs !== newDocs) {
// eslint-disable-next-line no-console
console.error(
'\x1b[31m%s\x1b[0m',
'Definitions files cannot be updated manually. Please update src/Options/index.js then run `npm run definitions` to generate definitions.'
+2 -6
View File
@@ -1,7 +1,7 @@
const core = require('@actions/core');
const semver = require('semver');
const fs = require('fs').promises;
const path = require('path');
let core;
/**
* This checks whether any package dependency requires a minimum node engine
@@ -86,8 +86,7 @@ class NodeEngineCheck {
file: file,
nodeVersion: version
});
} catch {
// eslint-disable-next-line no-console
} catch(e) {
console.log(`Ignoring file because it is not valid JSON: ${file}`);
core.warning(`Ignoring file because it is not valid JSON: ${file}`);
}
@@ -137,7 +136,6 @@ class NodeEngineCheck {
}
async function check() {
core = await import('@actions/core');
// Define paths
const nodeModulesPath = path.join(__dirname, '../node_modules');
const packageJsonPath = path.join(__dirname, '../package.json');
@@ -173,7 +171,6 @@ async function check() {
// Get highest version
const highestVersion = higherVersions.map(v => v.nodeMinVersion).pop();
/* eslint-disable no-console */
// If there are higher versions
if (higherVersions.length > 0) {
console.log(`\nThere are ${higherVersions.length} dependencies that require a higher node engine version than the parent package (${parentVersion.nodeVersion}):`);
@@ -192,7 +189,6 @@ async function check() {
}
console.log(`✅ All dependencies satisfy the node version requirement of the parent package (${parentVersion.nodeVersion}).`);
/* eslint-enable no-console */
}
check();
-22
View File
@@ -1,22 +0,0 @@
#!/bin/bash
# Read package exclusion list from arguments
exclusionList=("$@")
# Convert exclusion list to grep pattern
exclusionPattern=$(printf "|%s" "${exclusionList[@]}")
exclusionPattern=${exclusionPattern:1}
# Get list of all dev dependencies
devDeps=$(jq -r '.devDependencies | keys | .[]' package.json)
# Filter out exclusion list
depsToUninstall=$(echo "$devDeps" | grep -Ev "$exclusionPattern")
# If there are dependencies to uninstall then uninstall them
if [ -n "$depsToUninstall" ]; then
echo "Uninstalling dev dependencies: $depsToUninstall"
npm uninstall $depsToUninstall
else
echo "No dev dependencies to uninstall"
fi
-86
View File
@@ -1,86 +0,0 @@
const js = require("@eslint/js");
const babelParser = require("@babel/eslint-parser");
const globals = require("globals");
const unusedImports = require("eslint-plugin-unused-imports");
module.exports = [
{
ignores: ["**/lib/**", "**/coverage/**", "**/out/**", "**/types/**"],
},
js.configs.recommended,
{
languageOptions: {
parser: babelParser,
ecmaVersion: 6,
sourceType: "module",
globals: {
Parse: "readonly",
...globals.node,
},
parserOptions: {
requireConfigFile: false,
},
},
plugins: {
"unused-imports": unusedImports,
},
rules: {
indent: ["error", 2, { SwitchCase: 1 }],
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": "error",
"linebreak-style": ["error", "unix"],
"no-trailing-spaces": "error",
"eol-last": "error",
"space-in-parens": ["error", "never"],
"no-multiple-empty-lines": "warn",
"prefer-const": "error",
"space-infix-ops": "error",
"no-useless-escape": "off",
"require-atomic-updates": "off",
"object-curly-spacing": ["error", "always"],
curly: ["error", "all"],
"block-spacing": ["error", "always"],
"no-unused-vars": "off",
"no-console": "warn",
"no-restricted-syntax": [
"error",
{
selector: "BinaryExpression[operator='instanceof'][right.name='Date']",
message: "Use Utils.isDate() instead of instanceof Date (cross-realm safe).",
},
{
selector: "BinaryExpression[operator='instanceof'][right.name='RegExp']",
message: "Use Utils.isRegExp() instead of instanceof RegExp (cross-realm safe).",
},
{
selector: "BinaryExpression[operator='instanceof'][right.name='Error']",
message: "Use Utils.isNativeError() instead of instanceof Error (cross-realm safe).",
},
{
selector: "BinaryExpression[operator='instanceof'][right.name='Promise']",
message: "Use Utils.isPromise() instead of instanceof Promise (cross-realm safe).",
},
{
selector: "BinaryExpression[operator='instanceof'][right.name='Map']",
message: "Use Utils.isMap() instead of instanceof Map (cross-realm safe).",
},
{
selector: "BinaryExpression[operator='instanceof'][right.name='Object']",
message: "Use Utils.isObject() instead of instanceof Object (cross-realm safe).",
},
{
selector: "BinaryExpression[operator='instanceof'][right.name='Set']",
message: "Use Utils.isSet() instead of instanceof Set (cross-realm safe).",
},
{
selector: "BinaryExpression[operator='instanceof'][right.name='Buffer']",
message: "Use Buffer.isBuffer() instead of instanceof Buffer (cross-realm safe).",
},
{
selector: "BinaryExpression[operator='instanceof'][right.name='Array']",
message: "Use Array.isArray() instead of instanceof Array (cross-realm safe).",
},
]
},
},
];
+6 -6
View File
@@ -6,10 +6,10 @@
"source": {
"include": [
"README.md",
"./lib/cloud-code",
"./lib/Options/docs.js",
"./lib/ParseServer.js",
"./lib/Adapters"
"./src/cloud-code",
"./src/Options/docs.js",
"./src/ParseServer.js",
"./src/Adapters"
],
"excludePattern": "(^|\\/|\\\\)_"
},
@@ -29,8 +29,8 @@
"template": "./node_modules/clean-jsdoc-theme",
"theme_opts": {
"default_theme": "dark",
"title": "<img src='https://raw.githubusercontent.com/parse-community/parse-server/alpha/.github/parse-server-logo.png' class='logo'/>",
"create_style": "header, .sidebar-section-title, .sidebar-title { color: #139cee !important } .logo { margin-left : 40px; margin-right: 40px; height: auto; max-width: 100%; object-fit: contain; }"
"title": "<img src='../.github/parse-server-logo.png' class='logo'/>",
"create_style": "header, .sidebar-section-title, .sidebar-title { color: #139cee !important } .logo { margin-left : 40px; margin-right: 40px }"
}
},
"markdown": {
+20614 -21173
View File
File diff suppressed because it is too large Load Diff
+96 -99
View File
@@ -1,6 +1,6 @@
{
"name": "parse-server",
"version": "9.6.0-alpha.52",
"version": "6.5.2",
"description": "An express module providing a Parse-compatible API server",
"main": "lib/index.js",
"repository": {
@@ -10,104 +10,102 @@
"files": [
"bin/",
"lib/",
"public/",
"public_html/",
"views/",
"LICENSE",
"NOTICE",
"postinstall.js",
"README.md",
"types"
"README.md"
],
"license": "Apache-2.0",
"dependencies": {
"@apollo/server": "5.4.0",
"@as-integrations/express5": "1.1.2",
"@graphql-tools/merge": "9.0.24",
"@graphql-tools/schema": "10.0.23",
"@graphql-tools/utils": "10.8.6",
"@parse/fs-files-adapter": "3.0.0",
"@parse/push-adapter": "8.3.1",
"bcryptjs": "3.0.3",
"commander": "14.0.3",
"cors": "2.8.6",
"express": "5.2.1",
"express-rate-limit": "8.2.1",
"follow-redirects": "1.15.9",
"graphql": "16.11.0",
"graphql-list-fields": "2.0.4",
"graphql-relay": "0.10.2",
"graphql-upload": "15.0.2",
"@babel/eslint-parser": "7.21.8",
"@graphql-tools/merge": "8.4.1",
"@graphql-tools/schema": "9.0.4",
"@graphql-tools/utils": "8.12.0",
"@graphql-yoga/node": "2.6.0",
"@parse/fs-files-adapter": "2.0.1",
"@parse/push-adapter": "5.1.1",
"bcryptjs": "2.4.3",
"body-parser": "1.20.2",
"commander": "10.0.1",
"cors": "2.8.5",
"deepcopy": "2.1.0",
"express": "4.18.2",
"express-rate-limit": "6.7.0",
"follow-redirects": "1.15.5",
"graphql": "16.8.1",
"graphql-list-fields": "2.0.2",
"graphql-relay": "0.10.0",
"graphql-tag": "2.12.6",
"intersect": "1.0.1",
"jsonwebtoken": "9.0.2",
"jwks-rsa": "3.2.0",
"ldapjs": "3.0.7",
"lodash": "4.17.23",
"lru-cache": "10.4.0",
"mime": "4.0.7",
"mongodb": "7.1.0",
"jsonwebtoken": "9.0.0",
"jwks-rsa": "3.1.0",
"ldapjs": "2.3.3",
"lodash": "4.17.21",
"lru-cache": "10.1.0",
"mime": "3.0.0",
"mongodb": "4.10.0",
"mustache": "4.2.0",
"otpauth": "9.4.0",
"parse": "8.5.0",
"path-to-regexp": "8.3.0",
"pg-monitor": "3.1.0",
"pg-promise": "12.6.0",
"otpauth": "9.2.2",
"parse": "4.1.0",
"path-to-regexp": "6.2.1",
"pg-monitor": "2.0.0",
"pg-promise": "11.5.4",
"pluralize": "8.0.0",
"punycode": "2.3.1",
"rate-limit-redis": "4.2.0",
"redis": "5.10.0",
"semver": "7.7.2",
"rate-limit-redis": "3.0.2",
"redis": "4.6.13",
"semver": "7.5.4",
"subscriptions-transport-ws": "0.11.0",
"tv4": "1.3.0",
"uuid": "11.1.0",
"winston": "3.19.0",
"winston-daily-rotate-file": "5.0.0",
"ws": "8.18.2"
"uuid": "9.0.1",
"winston": "3.11.0",
"winston-daily-rotate-file": "4.7.1",
"ws": "8.16.0"
},
"devDependencies": {
"@actions/core": "3.0.0",
"@apollo/client": "3.13.8",
"@babel/cli": "7.27.0",
"@babel/core": "7.29.0",
"@babel/eslint-parser": "7.28.6",
"@babel/plugin-proposal-object-rest-spread": "7.20.7",
"@babel/plugin-transform-flow-strip-types": "7.27.1",
"@babel/preset-env": "7.27.2",
"@babel/preset-typescript": "7.27.1",
"@saithodev/semantic-release-backmerge": "4.0.1",
"@semantic-release/changelog": "6.0.3",
"@semantic-release/commit-analyzer": "13.0.1",
"@semantic-release/git": "10.0.1",
"@semantic-release/github": "11.0.3",
"@semantic-release/npm": "12.0.1",
"@semantic-release/release-notes-generator": "14.1.0",
"all-node-versions": "13.0.1",
"apollo-upload-client": "18.0.1",
"clean-jsdoc-theme": "4.3.0",
"cross-env": "7.0.3",
"@actions/core": "1.9.1",
"@apollo/client": "3.6.1",
"@babel/cli": "7.10.0",
"@babel/core": "7.20.2",
"@babel/plugin-proposal-object-rest-spread": "7.10.0",
"@babel/plugin-transform-flow-strip-types": "7.9.0",
"@babel/preset-env": "7.10.0",
"@saithodev/semantic-release-backmerge": "2.1.2",
"@semantic-release/changelog": "5.0.1",
"@semantic-release/commit-analyzer": "8.0.1",
"@semantic-release/git": "9.0.0",
"@semantic-release/github": "7.2.3",
"@semantic-release/npm": "7.1.3",
"@semantic-release/release-notes-generator": "9.0.3",
"all-node-versions": "11.3.0",
"apollo-upload-client": "17.0.0",
"bcrypt-nodejs": "0.0.3",
"clean-jsdoc-theme": "4.2.7",
"cross-env": "7.0.2",
"deep-diff": "1.0.2",
"eslint": "9.27.0",
"eslint-plugin-expect-type": "0.6.2",
"eslint-plugin-unused-imports": "4.4.1",
"form-data": "4.0.5",
"globals": "17.3.0",
"eslint": "8.26.0",
"eslint-plugin-flowtype": "8.0.3",
"flow-bin": "0.119.1",
"form-data": "3.0.0",
"graphql-tag": "2.12.6",
"jasmine": "5.7.1",
"husky": "4.3.8",
"jasmine": "3.5.0",
"jasmine-spec-reporter": "7.0.0",
"jsdoc": "4.0.4",
"jsdoc": "3.6.3",
"jsdoc-babel": "0.5.0",
"lint-staged": "16.1.0",
"m": "1.10.0",
"madge": "8.0.0",
"lint-staged": "10.2.3",
"madge": "5.0.1",
"mock-files-adapter": "file:spec/dependencies/mock-files-adapter",
"mock-mail-adapter": "file:spec/dependencies/mock-mail-adapter",
"mongodb-runner": "5.9.3",
"node-abort-controller": "3.1.1",
"mongodb-runner": "4.8.1",
"mongodb-version-list": "1.0.0",
"node-abort-controller": "3.0.1",
"node-fetch": "3.2.10",
"nyc": "17.1.0",
"prettier": "3.8.1",
"semantic-release": "24.2.5",
"typescript": "5.9.3",
"typescript-eslint": "8.53.1",
"yaml": "2.8.2"
"nyc": "15.1.0",
"prettier": "2.0.5",
"semantic-release": "17.4.6",
"yaml": "1.10.0"
},
"scripts": {
"ci:check": "node ./ci/ciCheck.js",
@@ -115,38 +113,37 @@
"ci:definitionsCheck": "node ./ci/definitionsCheck.js",
"definitions": "node ./resources/buildConfigDefinitions.js && prettier --write 'src/Options/*.js'",
"docs": "jsdoc -c ./jsdoc-conf.json",
"lint": "eslint --cache ./ --flag unstable_config_lookup_from_file",
"lint-fix": "eslint --fix --cache ./ --flag unstable_config_lookup_from_file",
"build": "babel src/ -d lib/ --copy-files --extensions '.ts,.js'",
"build:types": "tsc",
"lint": "flow && eslint --cache ./",
"lint-fix": "eslint --fix --cache ./",
"build": "babel src/ -d lib/ --copy-files",
"watch": "babel --watch src/ -d lib/ --copy-files",
"watch:ts": "tsc --watch",
"test:mongodb:7.0.16": "MONGODB_VERSION=7.0.16 npm run test",
"test:mongodb:8.0.4": "MONGODB_VERSION=8.0.4 npm run test",
"test:postgres:testonly": "cross-env PARSE_SERVER_TEST_DB=postgres PARSE_SERVER_TEST_DATABASE_URI=postgres://postgres:password@localhost:5432/parse_server_postgres_adapter_test_database npm run testonly",
"testonly": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=8.0.4} MONGODB_TOPOLOGY=${MONGODB_TOPOLOGY:=standalone} TESTING=1 jasmine",
"test": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=8.0.4} MONGODB_TOPOLOGY=${MONGODB_TOPOLOGY:=standalone} mongodb-runner exec -t ${MONGODB_TOPOLOGY} --version ${MONGODB_VERSION} -- --port 27017 -- npm run testonly",
"test:types": "eslint types/tests.ts -c ./types/eslint.config.mjs",
"coverage:mongodb": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=8.0.4} MONGODB_TOPOLOGY=${MONGODB_TOPOLOGY:=standalone} mongodb-runner exec -t ${MONGODB_TOPOLOGY} --version ${MONGODB_VERSION} -- --port 27017 -- npm run coverage",
"coverage": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=8.0.4} MONGODB_TOPOLOGY=${MONGODB_TOPOLOGY:=standalone} TESTING=1 nyc jasmine",
"test:mongodb:runnerstart": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=$npm_config_dbversion} MONGODB_TOPOLOGY=${MONGODB_TOPOLOGY:=standalone} mongodb-runner start",
"test:mongodb:testonly": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=$npm_config_dbversion} MONGODB_TOPOLOGY=${MONGODB_TOPOLOGY:=standalone} TESTING=1 jasmine",
"test:mongodb": "npm run test:mongodb:runnerstart --dbversion=$npm_config_dbversion && npm run test:mongodb:testonly --dbversion=$npm_config_dbversion",
"test:mongodb:4.2.19": "npm run test:mongodb --dbversion=4.2.19",
"test:mongodb:4.4.13": "npm run test:mongodb --dbversion=4.4.13",
"test:mongodb:5.3.2": "npm run test:mongodb --dbversion=5.3.2",
"test:mongodb:6.0.2": "npm run test:mongodb --dbversion=6.0.2",
"posttest:mongodb": "mongodb-runner stop",
"pretest": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=5.3.2} MONGODB_TOPOLOGY=${MONGODB_TOPOLOGY:=standalone} mongodb-runner start",
"testonly": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=5.3.2} MONGODB_TOPOLOGY=${MONGODB_TOPOLOGY:=standalone} TESTING=1 jasmine",
"test": "npm run testonly",
"posttest": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=5.3.2} MONGODB_TOPOLOGY=${MONGODB_TOPOLOGY:=standalone} mongodb-runner stop",
"coverage": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=5.3.2} MONGODB_TOPOLOGY=${MONGODB_TOPOLOGY:=standalone} TESTING=1 nyc jasmine",
"start": "node ./bin/parse-server",
"prettier": "prettier --write {src,spec}/{**/*,*}.js",
"prepare": "npm run build",
"postinstall": "node -p 'require(\"./postinstall.js\")()'",
"madge:circular": "node_modules/.bin/madge ./src --circular",
"benchmark": "cross-env MONGODB_VERSION=8.0.4 MONGODB_TOPOLOGY=standalone mongodb-runner exec -t standalone --version 8.0.4 -- --port 27017 -- npm run benchmark:only",
"benchmark:only": "node --expose-gc benchmark/performance.js",
"benchmark:quick": "cross-env BENCHMARK_ITERATIONS=10 npm run benchmark:only"
"madge:circular": "node_modules/.bin/madge ./src --circular"
},
"types": "types/index.d.ts",
"engines": {
"node": ">=20.19.0 <21.0.0 || >=22.12.0 <23.0.0 || >=24.11.0 <25.0.0"
"node": ">=14.21.0 <17 || >=18 <19"
},
"bin": {
"parse-server": "bin/parse-server"
},
"optionalDependencies": {
"@node-rs/bcrypt": "1.10.7"
"@node-rs/bcrypt": "1.1.0"
},
"collective": {
"type": "opencollective",
+46 -34
View File
@@ -1,38 +1,50 @@
const message = `
1111111111
1111111111111111
1111111111111111111111
11111111111111111111111111
111111111111111 11111111
1111111111111 111 111111
1111111111111 111111111 111111
111111111111 11111111111 111111
1111111111111 11111111111 111111
1111111111111 1111111111 111111
1111111111111111111111111 1111111
11111111 11111111
111111 111 1111111111111111111
11111 11111 111111111111111111
11111 1 11111111111111111
111111 111111111111111111
11111111111111111111111111
1111111111111111111111
111111111111111111
11111111111
Thank you for using Parse Platform!
https://parseplatform.org
Please consider donating to help us maintain
this package:
const pkg = require('./package.json');
👉 https://opencollective.com/parse-server 👈
const version = parseFloat(process.version.substring(1));
const minimum = parseFloat(pkg.engines.node.match(/\d+/g).join('.'));
`;
module.exports = function () {
const openCollective = `
1111111111
1111111111111111
1111111111111111111111
11111111111111111111111111
111111111111111 11111111
1111111111111 111111
1111111111111 111111111 111111
111111111111 11111111111 111111
1111111111111 11111111111 111111
1111111111111 1111111111 111111
1111111111111111111111111 1111111
11111111 11111111
111111 1111111111111111111
11111 11111 111111111111111111
11111 11111111111111111
111111 111111111111111111
11111111111111111111111111
1111111111111111111111
111111111111111111
11111111111
function main() {
process.stdout.write(message);
process.exit(0);
}
module.exports = main;
Thanks for installing parse 🙏
Please consider donating to our open collective
to help us maintain this package.
👉 https://opencollective.com/parse-server
`;
process.stdout.write(openCollective);
if (version >= minimum) {
process.exit(0);
}
const errorMessage = `
⚠️ parse-server requires at least node@${minimum}!
You have node@${version}
`;
process.stdout.write(errorMessage);
process.exit(1);
};
@@ -14,9 +14,9 @@
<body>
<h1>{{appName}}</h1>
<h1>Expired verification link!</h1>
<form method="POST" action="{{publicServerUrl}}/apps/{{appId}}/resend_verification_email">
<input name="token" type="hidden" value="{{token}}">
<input name="locale" type="hidden" value="{{locale}}">
<form method="POST" action="{{{publicServerUrl}}}/apps/{{{appId}}}/resend_verification_email">
<input name="username" type="hidden" value="{{{username}}}">
<input name="locale" type="hidden" value="{{{locale}}}">
<button type="submit">Resend Link</button>
</form>
</body>
+4 -4
View File
@@ -23,11 +23,11 @@
<p>You can set a new Password for your account: {{username}}</p>
<br />
<p>{{error}}</p>
<form id='form' action='{{publicServerUrl}}/apps/{{appId}}/request_password_reset' method='POST'>
<form id='form' action='{{{publicServerUrl}}}/apps/{{{appId}}}/request_password_reset' method='POST'>
<input name='utf-8' type='hidden' value='✓' />
<input name="username" type="hidden" id="username" value="{{username}}" />
<input name="token" type="hidden" id="token" value="{{token}}" />
<input name="locale" type="hidden" id="locale" value="{{locale}}" />
<input name="username" type="hidden" id="username" value="{{{username}}}" />
<input name="token" type="hidden" id="token" value="{{{token}}}" />
<input name="locale" type="hidden" id="locale" value="{{{locale}}}" />
<p>New Password</p>
<input name="new_password" type="password" id="password" />
@@ -14,9 +14,9 @@
<body>
<h1>{{appName}}</h1>
<h1>Expired verification link!</h1>
<form method="POST" action="{{publicServerUrl}}/apps/{{appId}}/resend_verification_email">
<input name="token" type="hidden" value="{{token}}">
<input name="locale" type="hidden" value="{{locale}}">
<form method="POST" action="{{{publicServerUrl}}}/apps/{{{appId}}}/resend_verification_email">
<input name="username" type="hidden" value="{{{username}}}">
<input name="locale" type="hidden" value="{{{locale}}}">
<button type="submit">Resend Link</button>
</form>
</body>
+4 -4
View File
@@ -23,11 +23,11 @@
<p>You can set a new Password for your account: {{username}}</p>
<br />
<p>{{error}}</p>
<form id='form' action='{{publicServerUrl}}/apps/{{appId}}/request_password_reset' method='POST'>
<form id='form' action='{{{publicServerUrl}}}/apps/{{{appId}}}/request_password_reset' method='POST'>
<input name='utf-8' type='hidden' value='✓' />
<input name="username" type="hidden" id="username" value="{{username}}" />
<input name="token" type="hidden" id="token" value="{{token}}" />
<input name="locale" type="hidden" id="locale" value="{{locale}}" />
<input name="username" type="hidden" id="username" value="{{{username}}}" />
<input name="token" type="hidden" id="token" value="{{{token}}}" />
<input name="locale" type="hidden" id="locale" value="{{{locale}}}" />
<p>New Password</p>
<input name="new_password" type="password" id="password" />
+3 -3
View File
@@ -14,9 +14,9 @@
<body>
<h1>{{appName}}</h1>
<h1>Expired verification link!</h1>
<form method="POST" action="{{publicServerUrl}}/apps/{{appId}}/resend_verification_email">
<input name="token" type="hidden" value="{{token}}">
<input name="locale" type="hidden" value="{{locale}}">
<form method="POST" action="{{{publicServerUrl}}}/apps/{{{appId}}}/resend_verification_email">
<input name="username" type="hidden" value="{{{username}}}">
<input name="locale" type="hidden" value="{{{locale}}}">
<button type="submit">Resend Link</button>
</form>
</body>
+4 -4
View File
@@ -23,11 +23,11 @@
<p>You can set a new Password for your account: {{username}}</p>
<br />
<p>{{error}}</p>
<form id='form' action='{{publicServerUrl}}/apps/{{appId}}/request_password_reset' method='POST'>
<form id='form' action='{{{publicServerUrl}}}/apps/{{{appId}}}/request_password_reset' method='POST'>
<input name='utf-8' type='hidden' value='✓' />
<input name="username" type="hidden" id="username" value="{{username}}" />
<input name="token" type="hidden" id="token" value="{{token}}" />
<input name="locale" type="hidden" id="locale" value="{{locale}}" />
<input name="username" type="hidden" id="username" value="{{{username}}}" />
<input name="token" type="hidden" id="token" value="{{{token}}}" />
<input name="locale" type="hidden" id="locale" value="{{{locale}}}" />
<p>New Password</p>
<input name="new_password" type="password" id="password" />
+45
View File
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<!-- This page is displayed when someone navigates to a verify email or reset password link
but their security token is wrong. This can either mean the user has clicked on a
stale link (i.e. re-click on a password reset link after resetting their password) or
(rarely) this could be a sign of a malicious user trying to tamper with your app.
-->
<html>
<head>
<title>Invalid Link</title>
<style type='text/css'>
.container {
border-width: 0px;
display: block;
font: inherit;
font-family: 'Helvetica Neue', Helvetica;
font-size: 16px;
height: 30px;
line-height: 16px;
margin: 45px 0px 0px 45px;
padding: 0px 8px 0px 8px;
position: relative;
vertical-align: baseline;
}
h1, h2, h3, h4, h5 {
color: #0067AB;
display: block;
font: inherit;
font-family: 'Open Sans', 'Helvetica Neue', Helvetica;
font-size: 30px;
font-weight: 600;
height: 30px;
line-height: 30px;
margin: 0 0 15px 0;
padding: 0 0 0 0;
}
</style>
</head>
<body>
<div class="container">
<h1>Invalid Link</h1>
</div>
</body>
</html>
@@ -0,0 +1,68 @@
<!DOCTYPE html>
<!-- This page is displayed when someone navigates to a verify email or reset password link
but their security token is wrong. This can either mean the user has clicked on a
stale link (i.e. re-click on a password reset link after resetting their password) or
(rarely) this could be a sign of a malicious user trying to tamper with your app.
-->
<html>
<head>
<title>Invalid Link</title>
<style type='text/css'>
.container {
border-width: 0px;
display: block;
font: inherit;
font-family: 'Helvetica Neue', Helvetica;
font-size: 16px;
height: 30px;
line-height: 16px;
margin: 45px 0px 0px 45px;
padding: 0px 8px 0px 8px;
position: relative;
vertical-align: baseline;
}
h1, h2, h3, h4, h5 {
color: #0067AB;
display: block;
font: inherit;
font-family: 'Open Sans', 'Helvetica Neue', Helvetica;
font-size: 30px;
font-weight: 600;
height: 30px;
line-height: 30px;
margin: 0 0 15px 0;
padding: 0 0 0 0;
}
</style>
</head>
<script type="text/javascript">
function getUrlParameter(name) {
name = name.replace(/[\[]/, '\\[').replace(/[\]]/, '\\]');
var regex = new RegExp('[\\?&]' + name + '=([^&#]*)');
var results = regex.exec(location.search);
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
};
window.onload = addDataToForm;
function addDataToForm() {
var username = getUrlParameter("username");
document.getElementById("usernameField").value = username;
var appId = getUrlParameter("appId");
document.getElementById("resendForm").action = '/apps/' + appId + '/resend_verification_email'
}
</script>
<body>
<div class="container">
<h1>Invalid Verification Link</h1>
<form id="resendForm" method="POST" action="/resend_verification_email">
<input id="usernameField" class="form-control" name="username" type="hidden" value="">
<button type="submit" class="btn btn-default">Resend Link</button>
</form>
</div>
</body>
</html>
+45
View File
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<!-- This page is displayed when someone navigates to a verify email link with an invalid
security token and requests a link resend. This page is displayed when the username from
the original link is invalid or if the email of that user has already been verfieid when
the resend request is made
-->
<html>
<head>
<title>Invalid Link</title>
<style type='text/css'>
.container {
border-width: 0px;
display: block;
font: inherit;
font-family: 'Helvetica Neue', Helvetica;
font-size: 16px;
height: 30px;
line-height: 16px;
margin: 45px 0px 0px 45px;
padding: 0px 8px 0px 8px;
position: relative;
vertical-align: baseline;
}
h1, h2, h3, h4, h5 {
color: #0067AB;
display: block;
font: inherit;
font-family: 'Open Sans', 'Helvetica Neue', Helvetica;
font-size: 30px;
font-weight: 600;
height: 30px;
line-height: 30px;
margin: 0 0 15px 0;
padding: 0 0 0 0;
}
</style>
</head>
<body>
<div class="container">
<h1>No link sent. User not found or email already verified</h1>
</div>
</body>
</html>
+45
View File
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<!-- This page is displayed when someone navigates to a verify email link with an invalid
security token and requests a link resend. This page is displayed when the username
from the original verification link has been found and a new verification link has
been successfully sent to the corresponding stored email
-->
<html>
<head>
<title>Invalid Link</title>
<style type='text/css'>
.container {
border-width: 0px;
display: block;
font: inherit;
font-family: 'Helvetica Neue', Helvetica;
font-size: 16px;
height: 30px;
line-height: 16px;
margin: 45px 0px 0px 45px;
padding: 0px 8px 0px 8px;
position: relative;
vertical-align: baseline;
}
h1, h2, h3, h4, h5 {
color: #0067AB;
display: block;
font: inherit;
font-family: 'Open Sans', 'Helvetica Neue', Helvetica;
font-size: 30px;
font-weight: 600;
height: 30px;
line-height: 30px;
margin: 0 0 15px 0;
padding: 0 0 0 0;
}
</style>
</head>
<body>
<div class="container">
<h1>Link Sent! Check your email.</h1>
</div>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html>
<!-- This page is displayed whenever someone has successfully reset their password.
Pro and Enterprise accounts may edit this page and tell Parse to use that custom
version in their Parse app. See the App Settigns page for more information.
This page will be called with the query param 'username'
-->
<head>
<title>Password Reset</title>
<style type='text/css'>
h1 {
color: #0067AB;
display: block;
font: inherit;
font-family: 'Open Sans', 'Helvetica Neue', Helvetica;
font-size: 30px;
font-weight: 600;
height: 30px;
line-height: 30px;
margin: 45px 0px 0px 45px;
padding: 0px 8px 0px 8px;
}
</style>
<body>
<h1>Successfully updated your password!</h1>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html>
<!-- This page is displayed whenever someone has successfully reset their password.
Pro and Enterprise accounts may edit this page and tell Parse to use that custom
version in their Parse app. See the App Settigns page for more information.
This page will be called with the query param 'username'
-->
<head>
<title>Email Verification</title>
<style type='text/css'>
h1 {
color: #0067AB;
display: block;
font: inherit;
font-family: 'Open Sans', 'Helvetica Neue', Helvetica;
font-size: 30px;
font-weight: 600;
height: 30px;
line-height: 30px;
margin: 45px 0px 0px 45px;
padding: 0px 8px 0px 8px;
}
</style>
<body>
<h1>Successfully verified your email!</h1>
</body>
</html>
+18 -28
View File
@@ -2,13 +2,8 @@
* Semantic Release Config
*/
const { readFile } = require('fs').promises;
const { resolve } = require('path');
// For ES6 modules use:
// import { readFile } from 'fs/promises';
// import { resolve, dirname } from 'path';
// import { fileURLToPath } from 'url';
const fs = require('fs').promises;
const path = require('path');
// Get env vars
const ref = process.env.GITHUB_REF;
@@ -29,14 +24,11 @@ const templates = {
async function config() {
// Get branch
const branch = ref?.split('/')?.pop()?.split('-')[0] || '(current branch could not be determined)';
// eslint-disable-next-line no-console
const branch = ref.split('/').pop().split('-')[0];
console.log(`Running on branch: ${branch}`);
// Set changelog file
const changelogFileSuffix = branch.match(/release-\d+\.x\.x/) ? 'release' : branch;
const changelogFile = `./changelogs/CHANGELOG_${changelogFileSuffix}.md`;
// eslint-disable-next-line no-console
const changelogFile = `./changelogs/CHANGELOG_${branch}.md`;
console.log(`Changelog file output to: ${changelogFile}`);
// Load template file contents
@@ -46,9 +38,10 @@ async function config() {
branches: [
'release',
{ name: 'alpha', prerelease: true },
// { name: 'beta', prerelease: true },
// Long-Term-Support branch
{ name: 'release-9.x.x', range: '9.x.x', channel: '9.x.x' },
{ name: 'beta', prerelease: true },
'next-major',
// Long-Term-Support branch of previous major version
'release-6.x.x',
],
dryRun: false,
debug: true,
@@ -62,13 +55,13 @@ async function config() {
{ scope: 'no-release', release: false },
],
parserOpts: {
noteKeywords: ['BREAKING CHANGE'],
noteKeywords: [ 'BREAKING CHANGE', 'BREAKING CHANGES', 'BREAKING' ],
},
}],
['@semantic-release/release-notes-generator', {
preset: 'angular',
parserOpts: {
noteKeywords: ['BREAKING CHANGE']
noteKeywords: ['BREAKING CHANGE', 'BREAKING CHANGES', 'BREAKING']
},
writerOpts: {
commitsSort: ['subject', 'scope'],
@@ -96,10 +89,9 @@ async function config() {
[
"@saithodev/semantic-release-backmerge",
{
"backmergeBranches": [
// { from: 'beta', to: 'alpha' },
// { from: 'release', to: 'beta' },
{ from: 'release', to: 'alpha' },
"branches": [
{ from: "beta", to: "alpha" },
{ from: "release", to: "beta" },
]
}
],
@@ -111,17 +103,15 @@ async function config() {
async function loadTemplates() {
for (const template of Object.keys(templates)) {
// For ES6 modules use:
// const fileUrl = import.meta.url;
// const __dirname = dirname(fileURLToPath(fileUrl));
const filePath = resolve(__dirname, resourcePath, templates[template].file);
const text = await readFile(filePath, 'utf-8');
const text = await readFile(path.resolve(__dirname, resourcePath, templates[template].file));
templates[template].text = text;
}
}
async function readFile(filePath) {
return await fs.readFile(filePath, 'utf-8');
}
function getReleaseComment() {
const url = repositoryUrl + '/releases/tag/${nextRelease.gitTag}';
const comment = '🎉 This change has been released in version [${nextRelease.version}](' + url + ')';
+9 -16
View File
@@ -1,17 +1,10 @@
#!/bin/sh -e
set -x
# GITHUB_ACTIONS=true SOURCE_TAG=test ./release_docs.sh
if [ "${GITHUB_ACTIONS}" = "" ];
then
echo "Cannot release docs without GITHUB_ACTIONS set"
exit 0;
fi
if [ "${SOURCE_TAG}" = "" ];
then
echo "Cannot release docs without SOURCE_TAG set"
exit 0;
fi
REPO="https://github.com/parse-community/parse-server"
rm -rf docs
@@ -20,20 +13,20 @@ cd docs
git pull origin gh-pages
cd ..
RELEASE="release"
VERSION="${SOURCE_TAG}"
DEST="master"
# change the default page to the latest
echo "<meta http-equiv='refresh' content='0; url=/parse-server/api/${VERSION}'>" > "docs/api/index.html"
if [ "${SOURCE_TAG}" != "" ];
then
DEST="${SOURCE_TAG}"
# change the default page to the latest
echo "<meta http-equiv='refresh' content='0; url=/parse-server/api/${DEST}'>" > "docs/api/index.html"
fi
npm run definitions
npm run docs
mkdir -p "docs/api/${RELEASE}"
cp -R out/* "docs/api/${RELEASE}"
mkdir -p "docs/api/${VERSION}"
cp -R out/* "docs/api/${VERSION}"
mkdir -p "docs/api/${DEST}"
cp -R out/* "docs/api/${DEST}"
# Copy other resources
RESOURCE_DIR=".github"
+12 -53
View File
@@ -22,7 +22,6 @@ const nestedOptionTypes = [
'PagesOptions',
'PagesRoute',
'PasswordPolicyOptions',
'RequestComplexityOptions',
'SecurityOptions',
'SchemaOptions',
'LogLevels',
@@ -31,25 +30,21 @@ const nestedOptionTypes = [
/** The prefix of environment variables for nested options. */
const nestedOptionEnvPrefix = {
AccountLockoutOptions: 'PARSE_SERVER_ACCOUNT_LOCKOUT_',
DatabaseOptionsClientMetadata: 'PARSE_SERVER_DATABASE_CLIENT_METADATA_',
CustomPagesOptions: 'PARSE_SERVER_CUSTOM_PAGES_',
DatabaseOptions: 'PARSE_SERVER_DATABASE_',
FileUploadOptions: 'PARSE_SERVER_FILE_UPLOAD_',
IdempotencyOptions: 'PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_',
LiveQueryOptions: 'PARSE_SERVER_LIVEQUERY_',
LiveQueryServerOptions: 'PARSE_LIVE_QUERY_SERVER_',
LogClientEvent: 'PARSE_SERVER_DATABASE_LOG_CLIENT_EVENTS_',
LogLevel: 'PARSE_SERVER_LOG_LEVEL_',
LogLevels: 'PARSE_SERVER_LOG_LEVELS_',
PagesCustomUrlsOptions: 'PARSE_SERVER_PAGES_CUSTOM_URL_',
PagesOptions: 'PARSE_SERVER_PAGES_',
PagesRoute: 'PARSE_SERVER_PAGES_ROUTE_',
ParseServerOptions: 'PARSE_SERVER_',
PasswordPolicyOptions: 'PARSE_SERVER_PASSWORD_POLICY_',
RateLimitOptions: 'PARSE_SERVER_RATE_LIMIT_',
RequestComplexityOptions: 'PARSE_SERVER_REQUEST_COMPLEXITY_',
SchemaOptions: 'PARSE_SERVER_SCHEMA_',
SecurityOptions: 'PARSE_SERVER_SECURITY_',
SchemaOptions: 'PARSE_SERVER_SCHEMA_',
LogLevels: 'PARSE_SERVER_LOG_LEVELS_',
RateLimitOptions: 'PARSE_SERVER_RATE_LIMIT_',
};
function last(array) {
@@ -158,13 +153,6 @@ function mapperFor(elt, t) {
return wrap(t.identifier('objectParser'));
} else if (t.isBooleanTypeAnnotation(elt)) {
return wrap(t.identifier('booleanParser'));
} else if (t.isObjectTypeAnnotation(elt)) {
return wrap(t.identifier('objectParser'));
} else if (t.isUnionTypeAnnotation(elt)) {
const unionTypes = elt.typeAnnotation?.types || elt.types;
if (unionTypes?.some(type => t.isBooleanTypeAnnotation(type)) && unionTypes?.some(type => t.isFunctionTypeAnnotation(type))) {
return wrap(t.identifier('booleanOrFunctionParser'));
}
} else if (t.isGenericTypeAnnotation(elt)) {
const type = elt.typeAnnotation.id.name;
if (type == 'Adapter') {
@@ -173,9 +161,6 @@ function mapperFor(elt, t) {
if (type == 'NumberOrBoolean') {
return wrap(t.identifier('numberOrBooleanParser'));
}
if (type == 'NumberOrString') {
return t.callExpression(wrap(t.identifier('numberOrStringParser')), [t.stringLiteral(elt.name)]);
}
if (type === 'StringOrStringArray') {
return wrap(t.identifier('arrayParser'));
}
@@ -227,9 +212,6 @@ function parseDefaultValue(elt, value, t) {
if (type == 'NumberOrBoolean') {
literalValue = t.numericLiteral(parsers.numberOrBoolParser('')(value));
}
if (type == 'NumberOrString') {
literalValue = t.numericLiteral(parsers.numberOrStringParser('')(value));
}
if (nestedOptionTypes.includes(type)) {
const object = parsers.objectParser(value);
@@ -272,23 +254,6 @@ function inject(t, list) {
if (action) {
props.push(t.objectProperty(t.stringLiteral('action'), action));
}
if (t.isGenericTypeAnnotation(elt)) {
if (elt.typeAnnotation.id.name in nestedOptionEnvPrefix) {
props.push(
t.objectProperty(t.stringLiteral('type'), t.stringLiteral(elt.typeAnnotation.id.name))
);
}
} else if (t.isArrayTypeAnnotation(elt)) {
const elementType = elt.typeAnnotation.elementType;
if (t.isGenericTypeAnnotation(elementType)) {
if (elementType.id.name in nestedOptionEnvPrefix) {
props.push(
t.objectProperty(t.stringLiteral('type'), t.stringLiteral(elementType.id.name + '[]'))
);
}
}
}
if (elt.defaultValue) {
let parsedValue = parseDefaultValue(elt, elt.defaultValue, t);
if (!parsedValue) {
@@ -382,18 +347,12 @@ This code has been generated by resources/buildConfigDefinitions.js
Do not edit manually, but update Options/index.js
`;
// Only run the transformation when executed directly, not when imported by tests
if (require.main === module) {
const babel = require('@babel/core');
const res = babel.transformFileSync('./src/Options/index.js', {
plugins: [plugin, '@babel/transform-flow-strip-types'],
babelrc: false,
auxiliaryCommentBefore,
sourceMaps: false,
});
require('fs').writeFileSync('./src/Options/Definitions.js', res.code + '\n');
require('fs').writeFileSync('./src/Options/docs.js', docs);
}
// Export mapperFor for testing
module.exports = { mapperFor };
const babel = require('@babel/core');
const res = babel.transformFileSync('./src/Options/index.js', {
plugins: [plugin, '@babel/transform-flow-strip-types'],
babelrc: false,
auxiliaryCommentBefore,
sourceMaps: false,
});
require('fs').writeFileSync('./src/Options/Definitions.js', res.code + '\n');
require('fs').writeFileSync('./src/Options/docs.js', docs);
+3 -3
View File
@@ -3,12 +3,12 @@
"@babel/plugin-proposal-object-rest-spread"
],
"presets": [
"@babel/preset-typescript",
["@babel/preset-env", {
"targets": {
"node": "18"
"node": "14"
}
}]
],
"sourceMaps": "inline"
"sourceMaps": "inline",
"retainLines": true
}
+45
View File
@@ -0,0 +1,45 @@
{
"env": {
"jasmine": true
},
"globals": {
"Parse": true,
"reconfigureServer": true,
"createTestUser": true,
"jfail": true,
"ok": true,
"strictEqual": true,
"TestObject": true,
"Item": true,
"Container": true,
"equal": true,
"expectAsync": true,
"notEqual": true,
"it_id": true,
"it_only_db": true,
"it_only_mongodb_version": true,
"it_only_postgres_version": true,
"it_only_node_version": true,
"fit_only_mongodb_version": true,
"fit_only_node_version": true,
"it_exclude_mongodb_version": true,
"it_exclude_postgres_version": true,
"fit_exclude_mongodb_version": true,
"fit_exclude_node_version": true,
"it_exclude_dbs": true,
"describe_only_db": true,
"describe_only": true,
"on_db": true,
"defaultConfiguration": true,
"range": true,
"jequal": true,
"create": true,
"arrayContains": true,
"databaseAdapter": true,
"databaseURI": true
},
"rules": {
"no-console": [0],
"no-var": "error"
}
}
+2 -52
View File
@@ -341,56 +341,6 @@ describe('Account Lockout Policy: ', () => {
done();
});
});
it('should enforce lockout threshold under concurrent failed login attempts', async () => {
const threshold = 3;
await reconfigureServer({
appName: 'lockout race',
accountLockout: {
duration: 5,
threshold,
},
publicServerURL: 'http://localhost:8378/1',
});
const user = new Parse.User();
user.setUsername('race_user');
user.setPassword('correct_password');
await user.signUp();
const concurrency = 30;
const results = await Promise.all(
Array.from({ length: concurrency }, () =>
request({
method: 'POST',
url: 'http://localhost:8378/1/login',
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'Content-Type': 'application/json',
},
body: JSON.stringify({ username: 'race_user', password: 'wrong_password' }),
}).catch(err => err)
)
);
const lockoutError =
'Your account is locked due to multiple failed login attempts. Please try again after 5 minute(s)';
const errors = results.map(r => {
const body = typeof r.data === 'string' ? JSON.parse(r.data) : r.data;
return body?.error;
});
const invalidPassword = errors.filter(error => error === 'Invalid username/password.');
const lockoutResponses = errors.filter(error => error === lockoutError);
expect(
errors.every(
error => error === 'Invalid username/password.' || error === lockoutError
)
).toBeTrue();
expect(lockoutResponses.length).toBeGreaterThan(0);
expect(invalidPassword.length).toBeLessThanOrEqual(threshold);
});
});
describe('lockout with password reset option', () => {
@@ -469,7 +419,7 @@ describe('lockout with password reset option', () => {
await request({
method: 'POST',
url: `${config.publicServerURL}/apps/test/request_password_reset`,
body: `new_password=${newPassword}&token=${token}`,
body: `new_password=${newPassword}&token=${token}&username=${username}`,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
@@ -504,7 +454,7 @@ describe('lockout with password reset option', () => {
await request({
method: 'POST',
url: `${config.publicServerURL}/apps/test/request_password_reset`,
body: `new_password=${newPassword}&token=${token}`,
body: `new_password=${newPassword}&token=${token}&username=${username}`,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
+8 -38
View File
@@ -1,8 +1,8 @@
const { loadAdapter, loadModule } = require('../lib/Adapters/AdapterLoader');
const loadAdapter = require('../lib/Adapters/AdapterLoader').loadAdapter;
const FilesAdapter = require('@parse/fs-files-adapter').default;
const ParsePushAdapter = require('@parse/push-adapter').default;
const MockFilesAdapter = require('mock-files-adapter');
const Config = require('../lib/Config');
const Utils = require('../lib/Utils');
describe('AdapterLoader', () => {
it('should instantiate an adapter from string in object', done => {
@@ -16,7 +16,7 @@ describe('AdapterLoader', () => {
},
});
expect(Utils.isObject(adapter)).toBe(true);
expect(adapter instanceof Object).toBe(true);
expect(adapter.options.key).toBe('value');
expect(adapter.options.foo).toBe('bar');
done();
@@ -26,7 +26,7 @@ describe('AdapterLoader', () => {
const adapterPath = require('path').resolve('./spec/support/MockAdapter');
const adapter = loadAdapter(adapterPath);
expect(Utils.isObject(adapter)).toBe(true);
expect(adapter instanceof Object).toBe(true);
done();
});
@@ -103,30 +103,19 @@ describe('AdapterLoader', () => {
done();
});
it('should load push adapter from options', async () => {
it('should load push adapter from options', done => {
const options = {
android: {
firebaseServiceAccount: {
"type": "service_account",
"project_id": "example-xxxx",
"private_key_id": "xxxx",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCxFcVMD9L2xJWW\nEMi4w/XIBPvX5bTStIEdt4GY+yfrmCHspaVdgpTcHlTLA60sAGTFdorPprOwAm6f\njaTG4j86zfW25GF6AlFO/8vE2B0tjreuQQtcP9gkWJmsTp8yzXDirDQ43Kv93Kbc\nUPmsyAN5WB8XiFjjWLnFCeDiOVdd8sHfG0HYldNzyYwXrOTLE5kOjASYSJDzdrfI\nwN9PzZC7+cCy/DDzTRKQCqfz9pEZmxqJk4Id5HLVNkGKgji3C3b6o3MXWPS+1+zD\nGheKC9WLDZnCVycAnNHFiPpsp7R82lLKC3Dth37b6qzJO+HwfTmzCb0/xCVJ0/mZ\nC4Mxih/bAgMBAAECggEACbL1DvDw75Yd0U3TCJenDxEC0DTjHgVH6x5BaWUcLyGy\nffkmoQQFbjb1Evd9FSNiYZRYDv6E6feAIpoJ8+CxcOGV+zHwCtQ0qtyExx/FHVkr\nQ06JtkBC8N6vcAoQWyJ4c9nVtGWVv/5FX1zKCAYedpd2gH31zGHwLtQXLpzQZbNO\nO/0rcggg4unGSUIyw5437XiyckJ3QdneSEPe9HvY2wxLn/f1PjMpRYiNLBSuaFBJ\n+MYXr//Vh7cMInQk5/pMFbGxugNb7dtjgvm3LKRssKnubEOyrKldo8DVJmAvjhP4\nWboOOBVEo2ZhXgnBjeMvI8btXlJ85h9lZ7xwqfWsjQKBgQDkrrLpA3Mm21rsP1Ar\nMLEnYTdMZ7k+FTm5pJffPOsC7wiLWdRLwwrtb0V3kC3jr2K4SZY/OEV8IAWHfut/\n8mP8cPQPJiFp92iOgde4Xq/Ycwx4ZAXUj7mHHgywFi2K0xATzgc9sgX3NCVl9utR\nIU/FbEDCLxyD4T3Jb5gL3xFdhwKBgQDGPS46AiHuYmV7OG4gEOsNdczTppBJCgTt\nKGSJOxZg8sQodNJeWTPP2iQr4yJ4EY57NQmH7WSogLrGj8tmorEaL7I2kYlHJzGm\nniwApWEZlFc00xgXwV5d8ATfmAf8W1ZSZ6THbHesDUGjXSoL95k3KKXhnztjUT6I\n8d5qkCygDQKBgFN7p1rDZKVZzO6UCntJ8lJS/jIJZ6nPa9xmxv67KXxPsQnWSFdE\nI9gcF/sXCnmlTF/ElXIM4+j1c69MWULDRVciESb6n5YkuOnVYuAuyPk2vuWwdiRs\nN6mpAa7C2etlM+hW/XO7aswdIE4B/1QF2i5TX6zEMB/A+aJw98vVqmw/AoGADOm9\nUiADb9DPBXjGi6YueYD756mI6okRixU/f0TvDz+hEXWSonyzCE4QXx97hlC2dEYf\nKdCH5wYDpJ2HRVdBrBABTtaqF41xCYZyHVSof48PIyzA/AMnj3zsBFiV5JVaiSGh\nNTBWl0mBxg9yhrcJLvOh4pGJv81yAl+m+lAL6B0CgYEArtqtQ1YVLIUn4Pb/HDn8\nN8o7WbhloWQnG34iSsAG8yNtzbbxdugFrEm5ejPSgZ+dbzSzi/hizOFS/+/fwEdl\nay9jqY1fngoqSrS8eddUsY1/WAcmd6wPWEamsSjazA4uxQERruuFOi94E4b895KA\nqYe0A3xb0JL2ieAOZsn8XNA=\n-----END PRIVATE KEY-----\n",
"client_email": "test@example.com",
"client_id": "1",
"auth_uri": "https://example.com",
"token_uri": "https://example.com",
"auth_provider_x509_cert_url": "https://example.com",
"client_x509_cert_url": "https://example.com",
"universe_domain": "example.com"
}
senderId: 'yolo',
apiKey: 'yolo',
},
};
const ParsePushAdapter = await loadModule('@parse/push-adapter');
expect(() => {
const adapter = loadAdapter(undefined, ParsePushAdapter, options);
expect(adapter.constructor).toBe(ParsePushAdapter);
expect(adapter).not.toBe(undefined);
}).not.toThrow();
done();
});
it('should load custom push adapter from string (#3544)', done => {
@@ -153,25 +142,6 @@ describe('AdapterLoader', () => {
}).not.toThrow();
});
it('should load custom database adapter from config', done => {
const adapterPath = require('path').resolve('./spec/support/MockDatabaseAdapter');
const options = {
databaseURI: 'oracledb://user:password@localhost:1521/freepdb1',
collectionPrefix: '',
};
const databaseAdapterOptions = {
adapter: adapterPath,
options,
};
expect(() => {
const databaseAdapter = loadAdapter(databaseAdapterOptions);
expect(databaseAdapter).not.toBe(undefined);
expect(databaseAdapter.options).toEqual(options);
expect(databaseAdapter.getDatabaseURI()).toEqual(options.databaseURI);
}).not.toThrow();
done();
});
it('should load file adapter from direct passing', done => {
spyOn(console, 'warn').and.callFake(() => {});
const mockFilesAdapter = new MockFilesAdapter('key', 'secret', 'bucket');
-182
View File
@@ -1,182 +0,0 @@
const BaseAuthCodeAdapter = require('../../../lib/Adapters/Auth/BaseCodeAuthAdapter').default;
describe('BaseAuthCodeAdapter', function () {
let adapter;
const adapterName = 'TestAdapter';
const validOptions = {
clientId: 'validClientId',
clientSecret: 'validClientSecret',
};
class TestAuthCodeAdapter extends BaseAuthCodeAdapter {
async getUserFromAccessToken(accessToken) {
if (accessToken === 'validAccessToken') {
return { id: 'validUserId' };
}
throw new Error('Invalid access token');
}
async getAccessTokenFromCode(authData) {
if (authData.code === 'validCode') {
return 'validAccessToken';
}
throw new Error('Invalid code');
}
}
beforeEach(function () {
adapter = new TestAuthCodeAdapter(adapterName);
});
describe('validateOptions', function () {
it('should throw error if options are missing', function () {
expect(() => adapter.validateOptions(null)).toThrowError(`${adapterName} options are required.`);
});
it('should throw error if clientId is missing in secure mode', function () {
expect(() =>
adapter.validateOptions({ clientSecret: 'validClientSecret' })
).toThrowError(`${adapterName} clientId is required.`);
});
it('should throw error if clientSecret is missing in secure mode', function () {
expect(() =>
adapter.validateOptions({ clientId: 'validClientId' })
).toThrowError(`${adapterName} clientSecret is required.`);
});
it('should not throw error for valid options', function () {
expect(() => adapter.validateOptions(validOptions)).not.toThrow();
expect(adapter.clientId).toBe('validClientId');
expect(adapter.clientSecret).toBe('validClientSecret');
expect(adapter.enableInsecureAuth).toBeUndefined();
});
it('should allow insecure mode without clientId or clientSecret', function () {
const options = { enableInsecureAuth: true };
expect(() => adapter.validateOptions(options)).not.toThrow();
expect(adapter.enableInsecureAuth).toBe(true);
});
});
describe('beforeFind', function () {
it('should throw error if code is missing in secure mode', async function () {
adapter.validateOptions(validOptions);
const authData = { access_token: 'validAccessToken' };
await expectAsync(adapter.beforeFind(authData)).toBeRejectedWithError(
`${adapterName} code is required.`
);
});
it('should throw error if access token is missing in insecure mode', async function () {
adapter.validateOptions({ enableInsecureAuth: true });
const authData = {};
await expectAsync(adapter.beforeFind(authData)).toBeRejectedWithError(
`${adapterName} auth is invalid for this user.`
);
});
it('should throw error if user ID does not match in insecure mode', async function () {
adapter.validateOptions({ enableInsecureAuth: true });
const authData = { id: 'invalidUserId', access_token: 'validAccessToken' };
await expectAsync(adapter.beforeFind(authData)).toBeRejectedWithError(
`${adapterName} auth is invalid for this user.`
);
});
it('should process valid secure payload and update authData', async function () {
adapter.validateOptions(validOptions);
const authData = { code: 'validCode' };
await adapter.beforeFind(authData);
expect(authData.access_token).toBe('validAccessToken');
expect(authData.id).toBe('validUserId');
expect(authData.code).toBeUndefined();
});
it('should process valid insecure payload', async function () {
adapter.validateOptions({ enableInsecureAuth: true });
const authData = { id: 'validUserId', access_token: 'validAccessToken' };
await expectAsync(adapter.beforeFind(authData)).toBeResolved();
});
});
describe('getUserFromAccessToken', function () {
it('should throw error if not implemented in base class', async function () {
const baseAdapter = new BaseAuthCodeAdapter(adapterName);
await expectAsync(baseAdapter.getUserFromAccessToken('test')).toBeRejectedWithError(
'getUserFromAccessToken is not implemented'
);
});
it('should return valid user for valid access token', async function () {
const user = await adapter.getUserFromAccessToken('validAccessToken', {});
expect(user).toEqual({ id: 'validUserId' });
});
it('should throw error for invalid access token', async function () {
await expectAsync(adapter.getUserFromAccessToken('invalidAccessToken', {})).toBeRejectedWithError(
'Invalid access token'
);
});
});
describe('getAccessTokenFromCode', function () {
it('should throw error if not implemented in base class', async function () {
const baseAdapter = new BaseAuthCodeAdapter(adapterName);
await expectAsync(baseAdapter.getAccessTokenFromCode({ code: 'test' })).toBeRejectedWithError(
'getAccessTokenFromCode is not implemented'
);
});
it('should return valid access token for valid code', async function () {
const accessToken = await adapter.getAccessTokenFromCode({ code: 'validCode' });
expect(accessToken).toBe('validAccessToken');
});
it('should throw error for invalid code', async function () {
await expectAsync(adapter.getAccessTokenFromCode({ code: 'invalidCode' })).toBeRejectedWithError(
'Invalid code'
);
});
});
describe('validateLogin', function () {
it('should return user id from authData', function () {
const authData = { id: 'validUserId' };
const result = adapter.validateLogin(authData);
expect(result).toEqual({ id: 'validUserId' });
});
});
describe('validateSetUp', function () {
it('should return user id from authData', function () {
const authData = { id: 'validUserId' };
const result = adapter.validateSetUp(authData);
expect(result).toEqual({ id: 'validUserId' });
});
});
describe('afterFind', function () {
it('should return user id from authData', function () {
const authData = { id: 'validUserId' };
const result = adapter.afterFind(authData);
expect(result).toEqual({ id: 'validUserId' });
});
});
describe('validateUpdate', function () {
it('should return user id from authData', function () {
const authData = { id: 'validUserId' };
const result = adapter.validateUpdate(authData);
expect(result).toEqual({ id: 'validUserId' });
});
});
});
-220
View File
@@ -1,220 +0,0 @@
const GameCenterAuth = require('../../../lib/Adapters/Auth/gcenter').default;
const { pki } = require('node-forge');
const fs = require('fs');
const path = require('path');
describe('GameCenterAuth Adapter', function () {
let adapter;
beforeEach(function () {
adapter = new GameCenterAuth.constructor();
const gcProd4 = fs.readFileSync(path.resolve(__dirname, '../../support/cert/gc-prod-4.cer'));
const digicertPem = fs.readFileSync(path.resolve(__dirname, '../../support/cert/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem')).toString();
mockFetch([
{
url: 'https://static.gc.apple.com/public-key/gc-prod-4.cer',
method: 'GET',
response: {
ok: true,
headers: new Map(),
arrayBuffer: () => Promise.resolve(
gcProd4.buffer.slice(gcProd4.byteOffset, gcProd4.byteOffset + gcProd4.length)
),
},
},
{
url: 'https://cacerts.digicert.com/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem',
method: 'GET',
response: {
ok: true,
headers: new Map([['content-type', 'application/x-pem-file'], ['content-length', digicertPem.length.toString()]]),
text: () => Promise.resolve(digicertPem),
},
}
]);
});
describe('Test config failing due to missing params or wrong types', function () {
it('should throw error for invalid options', async function () {
const invalidOptions = [
null,
undefined,
{},
{ bundleId: '' },
{ enableInsecureAuth: false }, // Missing bundleId in secure mode
];
for (const options of invalidOptions) {
expect(() => adapter.validateOptions(options)).withContext(JSON.stringify(options)).toThrow()
}
});
it('should validate options successfully with valid parameters', function () {
const validOptions = { bundleId: 'com.valid.app', enableInsecureAuth: false };
expect(() => adapter.validateOptions(validOptions)).not.toThrow();
});
});
describe('Test payload failing due to missing params or wrong types', function () {
it('should throw error for missing authData fields', async function () {
await expectAsync(adapter.validateAuthData({})).toBeRejectedWithError(
'AuthData id is missing.'
);
});
});
describe('Test payload fails due to incorrect appId / certificate', function () {
it('should throw error for invalid publicKeyUrl', async function () {
const invalidPublicKeyUrl = 'https://malicious.url.com/key.cer';
spyOn(adapter, 'fetchCertificate').and.throwError(
new Error('Invalid publicKeyUrl')
);
await expectAsync(
adapter.getAppleCertificate(invalidPublicKeyUrl)
).toBeRejectedWithError('Invalid publicKeyUrl: https://malicious.url.com/key.cer');
});
it('should throw error for invalid signature verification', async function () {
const fakePublicKey = 'invalid-key';
const fakeAuthData = {
id: '1234567',
publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer',
timestamp: 1460981421303,
salt: 'saltST==',
signature: 'invalidSignature',
};
spyOn(adapter, 'getAppleCertificate').and.returnValue(Promise.resolve(fakePublicKey));
spyOn(adapter, 'verifySignature').and.throwError('Invalid signature.');
await expectAsync(adapter.validateAuthData(fakeAuthData)).toBeRejectedWithError(
'Invalid signature.'
);
});
});
describe('Test payload passing', function () {
it('should successfully process valid payload and save auth data', async function () {
const validAuthData = {
id: '1234567',
publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer',
timestamp: 1460981421303,
salt: 'saltST==',
signature: 'validSignature',
bundleId: 'com.valid.app',
};
spyOn(adapter, 'getAppleCertificate').and.returnValue(Promise.resolve('validKey'));
spyOn(adapter, 'verifySignature').and.returnValue(true);
await expectAsync(adapter.validateAuthData(validAuthData)).toBeResolved();
});
});
describe('Certificate and Signature Validation', function () {
it('should fetch and validate Apple certificate', async function () {
const certUrl = 'https://static.gc.apple.com/public-key/gc-prod-4.cer';
const mockCertificate = 'mockCertificate';
spyOn(adapter, 'fetchCertificate').and.returnValue(
Promise.resolve({ certificate: mockCertificate, headers: new Map() })
);
spyOn(pki, 'certificateFromPem').and.returnValue({});
adapter.cache[certUrl] = mockCertificate;
const cert = await adapter.getAppleCertificate(certUrl);
expect(cert).toBe(mockCertificate);
});
it('should verify signature successfully', async function () {
const authData = {
id: 'G:1965586982',
publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer',
timestamp: 1565257031287,
signature:
'uqLBTr9Uex8zCpc1UQ1MIDMitb+HUat2Mah4Kw6AVLSGe0gGNJXlih2i5X+0ZwVY0S9zY2NHWi2gFjmhjt/4kxWGMkupqXX5H/qhE2m7hzox6lZJpH98ZEUbouWRfZX2ZhUlCkAX09oRNi7fI7mWL1/o88MaI/y6k6tLr14JTzmlxgdyhw+QRLxRPA6NuvUlRSJpyJ4aGtNH5/wHdKQWL8nUnFYiYmaY8R7IjzNxPfy8UJTUWmeZvMSgND4u8EjADPsz7ZtZyWAPi8kYcAb6M8k0jwLD3vrYCB8XXyO2RQb/FY2TM4zJuI7PzLlvvgOJXbbfVtHx7Evnm5NYoyzgzw==',
salt: 'DzqqrQ==',
};
adapter.bundleId = 'cloud.xtralife.gamecenterauth';
adapter.enableInsecureAuth = false;
spyOn(adapter, 'verifyPublicKeyIssuer').and.returnValue();
const publicKey = await adapter.getAppleCertificate(authData.publicKeyUrl);
expect(() => adapter.verifySignature(publicKey, authData)).not.toThrow();
});
it('should not use bundle id from authData payload in secure mode', async function () {
const authData = {
id: 'G:1965586982',
publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer',
timestamp: 1565257031287,
signature:
'uqLBTr9Uex8zCpc1UQ1MIDMitb+HUat2Mah4Kw6AVLSGe0gGNJXlih2i5X+0ZwVY0S9zY2NHWi2gFjmhjt/4kxWGMkupqXX5H/qhE2m7hzox6lZJpH98ZEUbouWRfZX2ZhUlCkAX09oRNi7fI7mWL1/o88MaI/y6k6tLr14JTzmlxgdyhw+QRLxRPA6NuvUlRSJpyJ4aGtNH5/wHdKQWL8nUnFYiYmaY8R7IjzNxPfy8UJTUWmeZvMSgND4u8EjADPsz7ZtZyWAPi8kYcAb6M8k0jwLD3vrYCB8XXyO2RQb/FY2TM4zJuI7PzLlvvgOJXbbfVtHx7Evnm5NYoyzgzw==',
salt: 'DzqqrQ==',
bundleId: 'com.example.insecure.app',
};
adapter.bundleId = 'cloud.xtralife.gamecenterauth';
adapter.enableInsecureAuth = false;
spyOn(adapter, 'verifyPublicKeyIssuer').and.returnValue();
const publicKey = await adapter.getAppleCertificate(authData.publicKeyUrl);
expect(() => adapter.verifySignature(publicKey, authData)).not.toThrow();
});
it('should not use bundle id from authData payload in insecure mode', async function () {
const authData = {
id: 'G:1965586982',
publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer',
timestamp: 1565257031287,
signature:
'uqLBTr9Uex8zCpc1UQ1MIDMitb+HUat2Mah4Kw6AVLSGe0gGNJXlih2i5X+0ZwVY0S9zY2NHWi2gFjmhjt/4kxWGMkupqXX5H/qhE2m7hzox6lZJpH98ZEUbouWRfZX2ZhUlCkAX09oRNi7fI7mWL1/o88MaI/y6k6tLr14JTzmlxgdyhw+QRLxRPA6NuvUlRSJpyJ4aGtNH5/wHdKQWL8nUnFYiYmaY8R7IjzNxPfy8UJTUWmeZvMSgND4u8EjADPsz7ZtZyWAPi8kYcAb6M8k0jwLD3vrYCB8XXyO2RQb/FY2TM4zJuI7PzLlvvgOJXbbfVtHx7Evnm5NYoyzgzw==',
salt: 'DzqqrQ==',
bundleId: 'com.example.insecure.app',
};
adapter.bundleId = 'cloud.xtralife.gamecenterauth';
adapter.enableInsecureAuth = true;
spyOn(adapter, 'verifyPublicKeyIssuer').and.returnValue();
const publicKey = await adapter.getAppleCertificate(authData.publicKeyUrl);
expect(() => adapter.verifySignature(publicKey, authData)).not.toThrow();
});
it('can use bundle id from authData payload in insecure mode', async function () {
const authData = {
id: 'G:1965586982',
publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer',
timestamp: 1565257031287,
signature:
'uqLBTr9Uex8zCpc1UQ1MIDMitb+HUat2Mah4Kw6AVLSGe0gGNJXlih2i5X+0ZwVY0S9zY2NHWi2gFjmhjt/4kxWGMkupqXX5H/qhE2m7hzox6lZJpH98ZEUbouWRfZX2ZhUlCkAX09oRNi7fI7mWL1/o88MaI/y6k6tLr14JTzmlxgdyhw+QRLxRPA6NuvUlRSJpyJ4aGtNH5/wHdKQWL8nUnFYiYmaY8R7IjzNxPfy8UJTUWmeZvMSgND4u8EjADPsz7ZtZyWAPi8kYcAb6M8k0jwLD3vrYCB8XXyO2RQb/FY2TM4zJuI7PzLlvvgOJXbbfVtHx7Evnm5NYoyzgzw==',
salt: 'DzqqrQ==',
bundleId: 'cloud.xtralife.gamecenterauth',
};
adapter.enableInsecureAuth = true;
spyOn(adapter, 'verifyPublicKeyIssuer').and.returnValue();
const publicKey = await adapter.getAppleCertificate(authData.publicKeyUrl);
expect(() => adapter.verifySignature(publicKey, authData)).not.toThrow();
});
});
});
-285
View File
@@ -1,285 +0,0 @@
const GitHubAdapter = require('../../../lib/Adapters/Auth/github').default;
describe('GitHubAdapter', function () {
let adapter;
const validOptions = {
clientId: 'validClientId',
clientSecret: 'validClientSecret',
};
beforeEach(function () {
adapter = new GitHubAdapter.constructor();
adapter.validateOptions(validOptions);
});
describe('getAccessTokenFromCode', function () {
it('should fetch an access token successfully', async function () {
mockFetch([
{
url: 'https://github.com/login/oauth/access_token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken',
}),
},
},
]);
const code = 'validCode';
const token = await adapter.getAccessTokenFromCode(code);
expect(token).toBe('mockAccessToken');
});
it('should throw an error if the response is not ok', async function () {
mockFetch([
{
url: 'https://github.com/login/oauth/access_token',
method: 'POST',
response: {
ok: false,
statusText: 'Bad Request',
},
},
]);
const code = 'invalidCode';
await expectAsync(adapter.getAccessTokenFromCode(code)).toBeRejectedWithError(
'Failed to exchange code for token: Bad Request'
);
});
it('should throw an error if the response contains an error', async function () {
mockFetch([
{
url: 'https://github.com/login/oauth/access_token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
error: 'invalid_grant',
error_description: 'Code is invalid',
}),
},
},
]);
const code = 'invalidCode';
await expectAsync(adapter.getAccessTokenFromCode(code)).toBeRejectedWithError('Code is invalid');
});
});
describe('getUserFromAccessToken', function () {
it('should fetch user data successfully', async function () {
mockFetch([
{
url: 'https://api.github.com/user',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
id: 'mockUserId',
login: 'mockUserLogin',
}),
},
},
]);
const accessToken = 'validAccessToken';
const user = await adapter.getUserFromAccessToken(accessToken);
expect(user).toEqual({ id: 'mockUserId', login: 'mockUserLogin' });
});
it('should throw an error if the response is not ok', async function () {
mockFetch([
{
url: 'https://api.github.com/user',
method: 'GET',
response: {
ok: false,
statusText: 'Unauthorized',
},
},
]);
const accessToken = 'invalidAccessToken';
await expectAsync(adapter.getUserFromAccessToken(accessToken)).toBeRejectedWithError(
'Failed to fetch GitHub user: Unauthorized'
);
});
it('should throw an error if user data is invalid', async function () {
mockFetch([
{
url: 'https://api.github.com/user',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({}),
},
},
]);
const accessToken = 'validAccessToken';
await expectAsync(adapter.getUserFromAccessToken(accessToken)).toBeRejectedWithError(
'Invalid GitHub user data received.'
);
});
});
describe('GitHubAdapter E2E Test', function () {
beforeEach(async function () {
await reconfigureServer({
auth: {
github: {
clientId: 'validClientId',
clientSecret: 'validClientSecret',
},
},
});
});
it('should log in user using GitHub adapter successfully', async function () {
mockFetch([
{
url: 'https://github.com/login/oauth/access_token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken123',
}),
},
},
{
url: 'https://api.github.com/user',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
id: 'mockUserId',
login: 'mockUserLogin',
}),
},
},
]);
const authData = { code: 'validCode' };
const user = await Parse.User.logInWith('github', { authData });
expect(user.id).toBeDefined();
});
it('should handle error when GitHub returns invalid code', async function () {
mockFetch([
{
url: 'https://github.com/login/oauth/access_token',
method: 'POST',
response: {
ok: false,
statusText: 'Invalid code',
},
},
]);
const authData = { code: 'invalidCode' };
await expectAsync(Parse.User.logInWith('github', { authData })).toBeRejectedWithError(
'Failed to exchange code for token: Invalid code'
);
});
it('should handle error when GitHub returns invalid user data', async function () {
mockFetch([
{
url: 'https://github.com/login/oauth/access_token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken123',
}),
},
},
{
url: 'https://api.github.com/user',
method: 'GET',
response: {
ok: false,
statusText: 'Unauthorized',
},
},
]);
const authData = { code: 'validCode' };
await expectAsync(Parse.User.logInWith('github', { authData })).toBeRejectedWithError(
'Failed to fetch GitHub user: Unauthorized'
);
});
it('e2e secure does not support insecure payload', async function () {
mockFetch();
const authData = { id: 'mockUserId', access_token: 'mockAccessToken123' };
await expectAsync(Parse.User.logInWith('github', { authData })).toBeRejectedWithError(
'GitHub code is required.'
);
});
it('e2e insecure does support secure payload', async function () {
await reconfigureServer({
auth: {
github: {
clientId: 'validClientId',
clientSecret: 'validClientSecret',
enableInsecureAuth: true,
},
},
});
mockFetch([
{
url: 'https://github.com/login/oauth/access_token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken123',
}),
},
},
{
url: 'https://api.github.com/user',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
id: 'mockUserId',
login: 'mockUserLogin',
}),
},
},
]);
const authData = { code: 'validCode' };
const user = await Parse.User.logInWith('github', { authData });
expect(user.id).toBeDefined();
});
});
});
-356
View File
@@ -1,356 +0,0 @@
const GooglePlayGamesServicesAdapter = require('../../../lib/Adapters/Auth/gpgames').default;
describe('GooglePlayGamesServicesAdapter', function () {
let adapter;
beforeEach(function () {
adapter = new GooglePlayGamesServicesAdapter.constructor();
adapter.clientId = 'validClientId';
adapter.clientSecret = 'validClientSecret';
});
describe('getAccessTokenFromCode', function () {
it('should fetch an access token successfully', async function () {
mockFetch([
{
url: 'https://oauth2.googleapis.com/token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken',
}),
},
},
]);
const code = 'validCode';
const authData = { redirectUri: 'http://example.com' };
const token = await adapter.getAccessTokenFromCode(code, authData);
expect(token).toBe('mockAccessToken');
});
it('should throw an error if the response is not ok', async function () {
mockFetch([
{
url: 'https://oauth2.googleapis.com/token',
method: 'POST',
response: {
ok: false,
statusText: 'Bad Request',
},
},
]);
const code = 'invalidCode';
const authData = { redirectUri: 'http://example.com' };
await expectAsync(adapter.getAccessTokenFromCode(code, authData)).toBeRejectedWithError(
'Failed to exchange code for token: Bad Request'
);
});
it('should throw an error if the response contains an error', async function () {
mockFetch([
{
url: 'https://oauth2.googleapis.com/token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
error: 'invalid_grant',
error_description: 'Code is invalid',
}),
},
},
]);
const code = 'invalidCode';
const authData = { redirectUri: 'http://example.com' };
await expectAsync(adapter.getAccessTokenFromCode(code, authData)).toBeRejectedWithError(
'Code is invalid'
);
});
});
describe('getUserFromAccessToken', function () {
it('should fetch user data successfully', async function () {
mockFetch([
{
url: 'https://www.googleapis.com/games/v1/players/mockUserId',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
playerId: 'mockUserId',
}),
},
},
]);
const accessToken = 'validAccessToken';
const authData = { id: 'mockUserId' };
const user = await adapter.getUserFromAccessToken(accessToken, authData);
expect(user).toEqual({ id: 'mockUserId' });
});
it('should throw an error if the response is not ok', async function () {
mockFetch([
{
url: 'https://www.googleapis.com/games/v1/players/mockUserId',
method: 'GET',
response: {
ok: false,
statusText: 'Unauthorized',
},
},
]);
const accessToken = 'invalidAccessToken';
const authData = { id: 'mockUserId' };
await expectAsync(adapter.getUserFromAccessToken(accessToken, authData)).toBeRejectedWithError(
'Failed to fetch Google Play Games Services user: Unauthorized'
);
});
it('should throw an error if user data is invalid', async function () {
mockFetch([
{
url: 'https://www.googleapis.com/games/v1/players/mockUserId',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({}),
},
},
]);
const accessToken = 'validAccessToken';
const authData = { id: 'mockUserId' };
await expectAsync(adapter.getUserFromAccessToken(accessToken, authData)).toBeRejectedWithError(
'Invalid Google Play Games Services user data received.'
);
});
it('should throw an error if playerId does not match the provided user ID', async function () {
mockFetch([
{
url: 'https://www.googleapis.com/games/v1/players/mockUserId',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
playerId: 'anotherUserId',
}),
},
},
]);
const accessToken = 'validAccessToken';
const authData = { id: 'mockUserId' };
await expectAsync(adapter.getUserFromAccessToken(accessToken, authData)).toBeRejectedWithError(
'Invalid Google Play Games Services user data received.'
);
});
});
describe('GooglePlayGamesServicesAdapter E2E Test', function () {
beforeEach(async function () {
await reconfigureServer({
auth: {
gpgames: {
clientId: 'validClientId',
clientSecret: 'validClientSecret',
},
},
});
});
it('should log in user successfully with valid code', async function () {
mockFetch([
{
url: 'https://oauth2.googleapis.com/token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken123',
}),
},
},
{
url: 'https://www.googleapis.com/games/v1/players/mockUserId',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
playerId: 'mockUserId',
}),
},
},
]);
const authData = {
code: 'validCode',
id: 'mockUserId',
redirectUri: 'http://example.com',
};
const user = await Parse.User.logInWith('gpgames', { authData });
expect(user.id).toBeDefined();
expect(global.fetch).toHaveBeenCalledWith(
'https://oauth2.googleapis.com/token',
jasmine.any(Object)
);
expect(global.fetch).toHaveBeenCalledWith(
'https://www.googleapis.com/games/v1/players/mockUserId',
jasmine.any(Object)
);
});
it('should handle error when the token exchange fails', async function () {
mockFetch([
{
url: 'https://oauth2.googleapis.com/token',
method: 'POST',
response: {
ok: false,
statusText: 'Invalid code',
},
},
]);
const authData = {
code: 'invalidCode',
redirectUri: 'http://example.com',
};
await expectAsync(Parse.User.logInWith('gpgames', { authData })).toBeRejectedWithError(
'Failed to exchange code for token: Invalid code'
);
expect(global.fetch).toHaveBeenCalledWith(
'https://oauth2.googleapis.com/token',
jasmine.any(Object)
);
});
it('should handle error when user data fetch fails', async function () {
mockFetch([
{
url: 'https://oauth2.googleapis.com/token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken123',
}),
},
},
{
url: 'https://www.googleapis.com/games/v1/players/mockUserId',
method: 'GET',
response: {
ok: false,
statusText: 'Unauthorized',
},
},
]);
const authData = {
code: 'validCode',
id: 'mockUserId',
redirectUri: 'http://example.com',
};
await expectAsync(Parse.User.logInWith('gpgames', { authData })).toBeRejectedWithError(
'Failed to fetch Google Play Games Services user: Unauthorized'
);
expect(global.fetch).toHaveBeenCalledWith(
'https://oauth2.googleapis.com/token',
jasmine.any(Object)
);
expect(global.fetch).toHaveBeenCalledWith(
'https://www.googleapis.com/games/v1/players/mockUserId',
jasmine.any(Object)
);
});
it('should handle error when user data is invalid', async function () {
mockFetch([
{
url: 'https://oauth2.googleapis.com/token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken123',
}),
},
},
{
url: 'https://www.googleapis.com/games/v1/players/mockUserId',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
playerId: 'anotherUserId',
}),
},
},
]);
const authData = {
code: 'validCode',
id: 'mockUserId',
redirectUri: 'http://example.com',
};
await expectAsync(Parse.User.logInWith('gpgames', { authData })).toBeRejectedWithError(
'Invalid Google Play Games Services user data received.'
);
expect(global.fetch).toHaveBeenCalledWith(
'https://oauth2.googleapis.com/token',
jasmine.any(Object)
);
expect(global.fetch).toHaveBeenCalledWith(
'https://www.googleapis.com/games/v1/players/mockUserId',
jasmine.any(Object)
);
});
it('should handle error when no code or access token is provided', async function () {
mockFetch();
const authData = {
id: 'mockUserId',
};
await expectAsync(Parse.User.logInWith('gpgames', { authData })).toBeRejectedWithError(
'gpgames code is required.'
);
expect(global.fetch).not.toHaveBeenCalled();
});
});
});
-283
View File
@@ -1,283 +0,0 @@
const InstagramAdapter = require('../../../lib/Adapters/Auth/instagram').default;
describe('InstagramAdapter', function () {
let adapter;
beforeEach(function () {
adapter = new InstagramAdapter.constructor();
adapter.clientId = 'validClientId';
adapter.clientSecret = 'validClientSecret';
adapter.redirectUri = 'https://example.com/callback';
});
describe('getAccessTokenFromCode', function () {
it('should fetch an access token successfully', async function () {
mockFetch([
{
url: 'https://api.instagram.com/oauth/access_token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken',
}),
},
},
]);
const authData = { code: 'validCode' };
const token = await adapter.getAccessTokenFromCode(authData);
expect(token).toBe('mockAccessToken');
});
it('should throw an error if the response contains an error', async function () {
mockFetch([
{
url: 'https://api.instagram.com/oauth/access_token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
error: 'invalid_grant',
error_description: 'Code is invalid',
}),
},
},
]);
const authData = { code: 'invalidCode' };
await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWithError(
'Code is invalid'
);
});
});
describe('getUserFromAccessToken', function () {
it('should fetch user data successfully', async function () {
mockFetch([
{
url: 'https://graph.instagram.com/me?fields=id&access_token=mockAccessToken',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
id: 'mockUserId',
}),
},
},
]);
const accessToken = 'mockAccessToken';
const authData = { id: 'mockUserId' };
const user = await adapter.getUserFromAccessToken(accessToken, authData);
expect(user).toEqual({ id: 'mockUserId' });
});
it('should throw an error if user ID does not match authData', async function () {
mockFetch([
{
url: 'https://graph.instagram.com/me?fields=id&access_token=mockAccessToken',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
id: 'differentUserId',
}),
},
},
]);
const accessToken = 'mockAccessToken';
const authData = { id: 'mockUserId' };
await expectAsync(adapter.getUserFromAccessToken(accessToken, authData)).toBeRejectedWithError(
'Instagram auth is invalid for this user.'
);
});
it('should ignore client-provided apiURL and use hardcoded endpoint', async () => {
const accessToken = 'mockAccessToken';
const authData = {
id: 'mockUserId',
apiURL: 'https://example.com/',
};
mockFetch([
{
url: 'https://graph.instagram.com/me?fields=id&access_token=mockAccessToken',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
id: 'mockUserId',
}),
},
},
]);
const user = await adapter.getUserFromAccessToken(accessToken, authData);
expect(user).toEqual({ id: 'mockUserId' });
});
});
describe('InstagramAdapter E2E Test', function () {
beforeEach(async function () {
await reconfigureServer({
auth: {
instagram: {
clientId: 'validClientId',
clientSecret: 'validClientSecret',
redirectUri: 'https://example.com/callback',
},
},
});
});
it('should log in user successfully with valid code', async function () {
mockFetch([
{
url: 'https://api.instagram.com/oauth/access_token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken123',
}),
},
},
{
url: 'https://graph.instagram.com/me?fields=id&access_token=mockAccessToken123',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
id: 'mockUserId',
}),
},
},
]);
const authData = {
code: 'validCode',
id: 'mockUserId',
};
const user = await Parse.User.logInWith('instagram', { authData });
expect(user.id).toBeDefined();
});
it('should handle error when access token exchange fails', async function () {
mockFetch([
{
url: 'https://api.instagram.com/oauth/access_token',
method: 'POST',
response: {
ok: false,
statusText: 'Invalid code',
},
},
]);
const authData = {
code: 'invalidCode',
};
await expectAsync(Parse.User.logInWith('instagram', { authData })).toBeRejectedWith(
new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Instagram API request failed.')
);
});
it('should handle error when user data fetch fails', async function () {
mockFetch([
{
url: 'https://api.instagram.com/oauth/access_token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken123',
}),
},
},
{
url: 'https://graph.instagram.com/me?fields=id&access_token=mockAccessToken123',
method: 'GET',
response: {
ok: false,
statusText: 'Unauthorized',
},
},
]);
const authData = {
code: 'validCode',
id: 'mockUserId',
};
await expectAsync(Parse.User.logInWith('instagram', { authData })).toBeRejectedWith(
new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Instagram API request failed.')
);
});
it('should handle error when user data is invalid', async function () {
mockFetch([
{
url: 'https://api.instagram.com/oauth/access_token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken123',
}),
},
},
{
url: 'https://graph.instagram.com/me?fields=id&access_token=mockAccessToken123',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
id: 'differentUserId',
}),
},
},
]);
const authData = {
code: 'validCode',
id: 'mockUserId',
};
await expectAsync(Parse.User.logInWith('instagram', { authData })).toBeRejectedWithError(
'Instagram auth is invalid for this user.'
);
});
it('should handle error when no code or access token is provided', async function () {
mockFetch();
const authData = {
id: 'mockUserId',
};
await expectAsync(Parse.User.logInWith('instagram', { authData })).toBeRejectedWithError(
'Instagram code is required.'
);
});
});
});
-309
View File
@@ -1,309 +0,0 @@
const LineAdapter = require('../../../lib/Adapters/Auth/line').default;
describe('LineAdapter', function () {
let adapter;
beforeEach(function () {
adapter = new LineAdapter.constructor();
adapter.clientId = 'validClientId';
adapter.clientSecret = 'validClientSecret';
});
describe('getAccessTokenFromCode', function () {
it('should throw an error if code is missing in authData', async function () {
const authData = { redirect_uri: 'http://example.com' };
await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWithError(
'Line auth is invalid for this user.'
);
});
it('should fetch an access token successfully', async function () {
mockFetch([
{
url: 'https://api.line.me/oauth2/v2.1/token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken',
}),
},
},
]);
const authData = {
code: 'validCode',
redirect_uri: 'http://example.com',
};
const token = await adapter.getAccessTokenFromCode(authData);
expect(token).toBe('mockAccessToken');
});
it('should throw an error if response is not ok', async function () {
mockFetch([
{
url: 'https://api.line.me/oauth2/v2.1/token',
method: 'POST',
response: {
ok: false,
statusText: 'Bad Request',
},
},
]);
const authData = {
code: 'invalidCode',
redirect_uri: 'http://example.com',
};
await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWithError(
'Failed to exchange code for token: Bad Request'
);
});
it('should throw an error if response contains an error object', async function () {
mockFetch([
{
url: 'https://api.line.me/oauth2/v2.1/token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
error: 'invalid_grant',
error_description: 'Code is invalid',
}),
},
},
]);
const authData = {
code: 'invalidCode',
redirect_uri: 'http://example.com',
};
await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWithError(
'Code is invalid'
);
});
});
describe('getUserFromAccessToken', function () {
it('should fetch user data successfully', async function () {
mockFetch([
{
url: 'https://api.line.me/v2/profile',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
userId: 'mockUserId',
displayName: 'mockDisplayName',
}),
},
},
]);
const accessToken = 'validAccessToken';
const user = await adapter.getUserFromAccessToken(accessToken);
expect(user).toEqual({
userId: 'mockUserId',
displayName: 'mockDisplayName',
});
});
it('should throw an error if response is not ok', async function () {
mockFetch([
{
url: 'https://api.line.me/v2/profile',
method: 'GET',
response: {
ok: false,
statusText: 'Unauthorized',
},
},
]);
const accessToken = 'invalidAccessToken';
await expectAsync(adapter.getUserFromAccessToken(accessToken)).toBeRejectedWithError(
'Failed to fetch Line user: Unauthorized'
);
});
it('should throw an error if user data is invalid', async function () {
mockFetch([
{
url: 'https://api.line.me/v2/profile',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({}),
},
},
]);
const accessToken = 'validAccessToken';
await expectAsync(adapter.getUserFromAccessToken(accessToken)).toBeRejectedWithError(
'Invalid Line user data received.'
);
});
});
describe('LineAdapter E2E Test', function () {
beforeEach(async function () {
await reconfigureServer({
auth: {
line: {
clientId: 'validClientId',
clientSecret: 'validClientSecret',
},
},
});
});
it('should log in user successfully with valid code', async function () {
mockFetch([
{
url: 'https://api.line.me/oauth2/v2.1/token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken123',
}),
},
},
{
url: 'https://api.line.me/v2/profile',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
userId: 'mockUserId',
displayName: 'mockDisplayName',
}),
},
},
]);
const authData = {
code: 'validCode',
redirect_uri: 'http://example.com',
};
const user = await Parse.User.logInWith('line', { authData });
expect(user.id).toBeDefined();
});
it('should handle error when token exchange fails', async function () {
mockFetch([
{
url: 'https://api.line.me/oauth2/v2.1/token',
method: 'POST',
response: {
ok: false,
statusText: 'Invalid code',
},
},
]);
const authData = {
code: 'invalidCode',
redirect_uri: 'http://example.com',
};
await expectAsync(Parse.User.logInWith('line', { authData })).toBeRejectedWithError(
'Failed to exchange code for token: Invalid code'
);
});
it('should handle error when user data fetch fails', async function () {
mockFetch([
{
url: 'https://api.line.me/oauth2/v2.1/token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken123',
}),
},
},
{
url: 'https://api.line.me/v2/profile',
method: 'GET',
response: {
ok: false,
statusText: 'Unauthorized',
},
},
]);
const authData = {
code: 'validCode',
redirect_uri: 'http://example.com',
};
await expectAsync(Parse.User.logInWith('line', { authData })).toBeRejectedWithError(
'Failed to fetch Line user: Unauthorized'
);
});
it('should handle error when user data is invalid', async function () {
mockFetch([
{
url: 'https://api.line.me/oauth2/v2.1/token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken123',
}),
},
},
{
url: 'https://api.line.me/v2/profile',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({}),
},
},
]);
const authData = {
code: 'validCode',
redirect_uri: 'http://example.com',
};
await expectAsync(Parse.User.logInWith('line', { authData })).toBeRejectedWithError(
'Invalid Line user data received.'
);
});
it('should handle error when no code is provided', async function () {
mockFetch();
const authData = {
redirect_uri: 'http://example.com',
};
await expectAsync(Parse.User.logInWith('line', { authData })).toBeRejectedWithError(
'Line code is required.'
);
});
});
});
-333
View File
@@ -1,333 +0,0 @@
const LinkedInAdapter = require('../../../lib/Adapters/Auth/linkedin').default;
describe('LinkedInAdapter', function () {
let adapter;
const validOptions = {
clientId: 'validClientId',
clientSecret: 'validClientSecret',
enableInsecureAuth: false,
};
beforeEach(function () {
adapter = new LinkedInAdapter.constructor();
});
describe('Test configuration errors', function () {
it('should throw error for missing options', function () {
const invalidOptions = [null, undefined, {}, { clientId: 'validClientId' }];
for (const options of invalidOptions) {
expect(() => {
adapter.validateOptions(options);
}).toThrow();
}
});
it('should validate options successfully with valid parameters', function () {
expect(() => {
adapter.validateOptions(validOptions);
}).not.toThrow();
expect(adapter.clientId).toBe(validOptions.clientId);
expect(adapter.clientSecret).toBe(validOptions.clientSecret);
expect(adapter.enableInsecureAuth).toBe(validOptions.enableInsecureAuth);
});
});
describe('Test beforeFind', function () {
it('should throw error for invalid payload', async function () {
adapter.enableInsecureAuth = true;
const payloads = [{}, { access_token: null }];
for (const payload of payloads) {
await expectAsync(adapter.beforeFind(payload)).toBeRejectedWith(
new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'LinkedIn auth is invalid for this user.')
);
}
});
it('should process secure payload and set auth data', async function () {
spyOn(adapter, 'getAccessTokenFromCode').and.returnValue(
Promise.resolve('validToken')
);
spyOn(adapter, 'getUserFromAccessToken').and.returnValue(
Promise.resolve({ id: 'validUserId' })
);
const authData = { code: 'validCode', redirect_uri: 'http://example.com', is_mobile_sdk: false };
await adapter.beforeFind(authData);
expect(authData.access_token).toBe('validToken');
expect(authData.id).toBe('validUserId');
});
it('should validate insecure auth and match user id', async function () {
adapter.enableInsecureAuth = true;
spyOn(adapter, 'getUserFromAccessToken').and.returnValue(
Promise.resolve({ id: 'validUserId' })
);
const authData = { access_token: 'validToken', id: 'validUserId', is_mobile_sdk: false };
await expectAsync(adapter.beforeFind(authData)).toBeResolved();
});
it('should throw error if insecure auth user id does not match', async function () {
adapter.enableInsecureAuth = true;
spyOn(adapter, 'getUserFromAccessToken').and.returnValue(
Promise.resolve({ id: 'invalidUserId' })
);
const authData = { access_token: 'validToken', id: 'validUserId', is_mobile_sdk: false };
await expectAsync(adapter.beforeFind(authData)).toBeRejectedWith(
new Error('LinkedIn auth is invalid for this user.')
);
});
});
describe('Test getUserFromAccessToken', function () {
it('should fetch user successfully', async function () {
mockFetch([
{
url: 'https://api.linkedin.com/v2/me',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({ id: 'validUserId' }),
},
},
]);
const user = await adapter.getUserFromAccessToken('validToken', false);
expect(global.fetch).toHaveBeenCalledWith('https://api.linkedin.com/v2/me', {
headers: {
Authorization: `Bearer validToken`,
'x-li-format': 'json',
'x-li-src': undefined,
},
method: 'GET',
});
expect(user).toEqual({ id: 'validUserId' });
});
it('should throw error for invalid response', async function () {
mockFetch([
{
url: 'https://api.linkedin.com/v2/me',
method: 'GET',
response: {
ok: false,
},
},
]);
await expectAsync(adapter.getUserFromAccessToken('invalidToken', false)).toBeRejectedWith(
new Error('LinkedIn API request failed.')
);
});
});
describe('Test getAccessTokenFromCode', function () {
it('should fetch token successfully', async function () {
mockFetch([
{
url: 'https://www.linkedin.com/oauth/v2/accessToken',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({ access_token: 'validToken' }),
},
},
]);
const tokenResponse = await adapter.getAccessTokenFromCode('validCode', 'http://example.com');
expect(global.fetch).toHaveBeenCalledWith('https://www.linkedin.com/oauth/v2/accessToken', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: jasmine.any(URLSearchParams),
});
expect(tokenResponse).toEqual('validToken');
});
it('should throw error for invalid response', async function () {
mockFetch([
{
url: 'https://www.linkedin.com/oauth/v2/accessToken',
method: 'POST',
response: {
ok: false,
},
},
]);
await expectAsync(
adapter.getAccessTokenFromCode('invalidCode', 'http://example.com')
).toBeRejectedWith(new Error('LinkedIn API request failed.'));
});
});
describe('Test validate methods', function () {
const authData = { id: 'validUserId', access_token: 'validToken' };
it('validateLogin should return user id', function () {
const result = adapter.validateLogin(authData);
expect(result).toEqual({ id: 'validUserId' });
});
it('validateSetUp should return user id', function () {
const result = adapter.validateSetUp(authData);
expect(result).toEqual({ id: 'validUserId' });
});
it('validateUpdate should return user id', function () {
const result = adapter.validateUpdate(authData);
expect(result).toEqual({ id: 'validUserId' });
});
it('afterFind should return user id', function () {
const result = adapter.afterFind(authData);
expect(result).toEqual({ id: 'validUserId' });
});
});
describe('LinkedInAdapter E2E Test', function () {
beforeEach(async function () {
await reconfigureServer({
auth: {
linkedin: {
clientId: 'validClientId',
clientSecret: 'validClientSecret',
},
},
});
});
it('should log in user using LinkedIn adapter successfully (secure)', async function () {
mockFetch([
{
url: 'https://www.linkedin.com/oauth/v2/accessToken',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken123',
}),
},
},
{
url: 'https://api.linkedin.com/v2/me',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
id: 'mockUserId',
}),
},
},
]);
const authData = { code: 'validCode', redirect_uri: 'https://example.com/callback' };
const user = await Parse.User.logInWith('linkedin', { authData });
expect(user.id).toBeDefined();
expect(global.fetch).toHaveBeenCalledWith(
'https://www.linkedin.com/oauth/v2/accessToken',
jasmine.any(Object)
);
expect(global.fetch).toHaveBeenCalledWith(
'https://api.linkedin.com/v2/me',
jasmine.any(Object)
);
});
it('should handle error when LinkedIn returns invalid user data', async function () {
mockFetch([
{
url: 'https://www.linkedin.com/oauth/v2/accessToken',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken123',
}),
},
},
{
url: 'https://api.linkedin.com/v2/me',
method: 'GET',
response: {
ok: false,
statusText: 'Unauthorized',
},
},
]);
const authData = { code: 'validCode', redirect_uri: 'https://example.com/callback' };
await expectAsync(Parse.User.logInWith('linkedin', { authData })).toBeRejectedWithError(
'LinkedIn API request failed.'
);
expect(global.fetch).toHaveBeenCalledWith(
'https://www.linkedin.com/oauth/v2/accessToken',
jasmine.any(Object)
);
expect(global.fetch).toHaveBeenCalledWith(
'https://api.linkedin.com/v2/me',
jasmine.any(Object)
);
});
it('secure does not support insecure payload if not enabled', async function () {
mockFetch();
const authData = { id: 'mockUserId', access_token: 'mockAccessToken123' };
await expectAsync(Parse.User.logInWith('linkedin', { authData })).toBeRejectedWithError(
'LinkedIn code is required.'
);
expect(global.fetch).not.toHaveBeenCalled();
});
it('insecure mode supports insecure payload if enabled', async function () {
await reconfigureServer({
auth: {
linkedin: {
clientId: 'validClientId',
clientSecret: 'validClientSecret',
enableInsecureAuth: true,
},
},
});
mockFetch([
{
url: 'https://api.linkedin.com/v2/me',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
id: 'mockUserId',
}),
},
},
]);
const authData = { id: 'mockUserId', access_token: 'mockAccessToken123' };
const user = await Parse.User.logInWith('linkedin', { authData });
expect(user.id).toBeDefined();
expect(global.fetch).toHaveBeenCalledWith(
'https://api.linkedin.com/v2/me',
jasmine.any(Object)
);
});
});
});
-307
View File
@@ -1,307 +0,0 @@
const MicrosoftAdapter = require('../../../lib/Adapters/Auth/microsoft').default;
describe('MicrosoftAdapter', function () {
let adapter;
const validOptions = {
clientId: 'validClientId',
clientSecret: 'validClientSecret',
enableInsecureAuth: false,
};
beforeEach(function () {
adapter = new MicrosoftAdapter.constructor();
});
describe('Test configuration errors', function () {
it('should throw error for missing options', function () {
const invalidOptions = [null, undefined, {}, { clientId: 'validClientId' }];
for (const options of invalidOptions) {
expect(() => {
adapter.validateOptions(options);
}).toThrow();
}
});
it('should validate options successfully with valid parameters', function () {
expect(() => {
adapter.validateOptions(validOptions);
}).not.toThrow();
expect(adapter.clientId).toBe(validOptions.clientId);
expect(adapter.clientSecret).toBe(validOptions.clientSecret);
expect(adapter.enableInsecureAuth).toBe(validOptions.enableInsecureAuth);
});
});
describe('Test getUserFromAccessToken', function () {
it('should fetch user successfully', async function () {
mockFetch([
{
url: 'https://graph.microsoft.com/v1.0/me',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({ id: 'validUserId' }),
},
},
]);
const user = await adapter.getUserFromAccessToken('validToken');
expect(global.fetch).toHaveBeenCalledWith('https://graph.microsoft.com/v1.0/me', {
headers: {
Authorization: 'Bearer validToken',
},
method: 'GET',
});
expect(user).toEqual({ id: 'validUserId' });
});
it('should throw error for invalid response', async function () {
mockFetch([
{
url: 'https://graph.microsoft.com/v1.0/me',
method: 'GET',
response: { ok: false },
},
]);
await expectAsync(adapter.getUserFromAccessToken('invalidToken')).toBeRejectedWith(
new Error('Microsoft API request failed.')
);
});
});
describe('Test getAccessTokenFromCode', function () {
it('should fetch token successfully', async function () {
mockFetch([
{
url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({ access_token: 'validToken' }),
},
},
]);
const authData = { code: 'validCode', redirect_uri: 'http://example.com' };
const token = await adapter.getAccessTokenFromCode(authData);
expect(global.fetch).toHaveBeenCalledWith('https://login.microsoftonline.com/common/oauth2/v2.0/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: jasmine.any(URLSearchParams),
});
expect(token).toEqual('validToken');
});
it('should throw error for invalid response', async function () {
mockFetch([
{
url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
method: 'POST',
response: { ok: false },
},
]);
const authData = { code: 'invalidCode', redirect_uri: 'http://example.com' };
await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWith(
new Error('Microsoft API request failed.')
);
});
});
describe('Test secure authentication flow', function () {
it('should exchange code for access token and fetch user data', async function () {
spyOn(adapter, 'getAccessTokenFromCode').and.returnValue(Promise.resolve('validToken'));
spyOn(adapter, 'getUserFromAccessToken').and.returnValue(Promise.resolve({ id: 'validUserId' }));
const authData = { code: 'validCode', redirect_uri: 'http://example.com' };
await adapter.beforeFind(authData);
expect(authData.access_token).toBe('validToken');
expect(authData.id).toBe('validUserId');
});
it('should throw error if user data cannot be fetched', async function () {
spyOn(adapter, 'getAccessTokenFromCode').and.returnValue(Promise.resolve('validToken'));
spyOn(adapter, 'getUserFromAccessToken').and.throwError('Microsoft API request failed.');
const authData = { code: 'validCode', redirect_uri: 'http://example.com' };
await expectAsync(adapter.beforeFind(authData)).toBeRejectedWith(
new Error('Microsoft API request failed.')
);
});
});
describe('Test insecure authentication flow', function () {
beforeEach(function () {
adapter.enableInsecureAuth = true;
});
it('should validate insecure auth and match user id', async function () {
spyOn(adapter, 'getUserFromAccessToken').and.returnValue(
Promise.resolve({ id: 'validUserId' })
);
const authData = { access_token: 'validToken', id: 'validUserId' };
await expectAsync(adapter.beforeFind(authData)).toBeResolved();
});
it('should throw error if insecure auth user id does not match', async function () {
spyOn(adapter, 'getUserFromAccessToken').and.returnValue(
Promise.resolve({ id: 'invalidUserId' })
);
const authData = { access_token: 'validToken', id: 'validUserId' };
await expectAsync(adapter.beforeFind(authData)).toBeRejectedWith(
new Error('Microsoft auth is invalid for this user.')
);
});
});
describe('MicrosoftAdapter E2E Tests', () => {
beforeEach(async () => {
// Simulate reconfiguring the server with Microsoft auth options
await reconfigureServer({
auth: {
microsoft: {
clientId: 'validClientId',
clientSecret: 'validClientSecret',
enableInsecureAuth: false,
},
},
});
});
it('should authenticate user successfully using MicrosoftAdapter', async () => {
mockFetch([
{
url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({ access_token: 'validAccessToken' }),
},
},
{
url: 'https://graph.microsoft.com/v1.0/me',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({ id: 'user123' }),
},
},
]);
const authData = { code: 'validCode', redirect_uri: 'http://example.com/callback' };
const user = await Parse.User.logInWith('microsoft', { authData });
expect(user.id).toBeDefined();
});
it('should handle invalid code error gracefully', async () => {
mockFetch([
{
url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
method: 'POST',
response: { ok: false, statusText: 'Invalid code' },
},
]);
const authData = { code: 'invalidCode', redirect_uri: 'http://example.com/callback' };
await expectAsync(Parse.User.logInWith('microsoft', { authData })).toBeRejectedWithError(
'Microsoft API request failed.'
);
});
it('should handle error when fetching user data fails', async () => {
mockFetch([
{
url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({ access_token: 'validAccessToken' }),
},
},
{
url: 'https://graph.microsoft.com/v1.0/me',
method: 'GET',
response: { ok: false, statusText: 'Unauthorized' },
},
]);
const authData = { code: 'validCode', redirect_uri: 'http://example.com/callback' };
await expectAsync(Parse.User.logInWith('microsoft', { authData })).toBeRejectedWithError(
'Microsoft API request failed.'
);
});
it('should allow insecure auth when enabled', async () => {
mockFetch([
{
url: 'https://graph.microsoft.com/v1.0/me',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({
id: 'user123',
}),
},
},
])
await reconfigureServer({
auth: {
microsoft: {
clientId: 'validClientId',
clientSecret: 'validClientSecret',
enableInsecureAuth: true,
},
},
});
const authData = { access_token: 'validAccessToken', id: 'user123' };
const user = await Parse.User.logInWith('microsoft', { authData });
expect(user.id).toBeDefined();
});
it('should reject insecure auth when user id does not match', async () => {
mockFetch([
{
url: 'https://graph.microsoft.com/v1.0/me',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({
id: 'incorrectUser',
}),
},
},
])
await reconfigureServer({
auth: {
microsoft: {
clientId: 'validClientId',
clientSecret: 'validClientSecret',
enableInsecureAuth: true,
},
},
});
const authData = { access_token: 'validAccessToken', id: 'incorrectUserId' };
await expectAsync(Parse.User.logInWith('microsoft', { authData })).toBeRejectedWithError(
'Microsoft auth is invalid for this user.'
);
});
});
});
-448
View File
@@ -1,448 +0,0 @@
const OAuth2Adapter = require('../../../lib/Adapters/Auth/oauth2').default;
describe('OAuth2Adapter', () => {
let adapter;
const validOptions = {
tokenIntrospectionEndpointUrl: 'https://provider.com/introspect',
useridField: 'sub',
appidField: 'aud',
appIds: ['valid-app-id'],
authorizationHeader: 'Bearer validAuthToken',
};
beforeEach(() => {
adapter = new OAuth2Adapter.constructor();
adapter.validateOptions(validOptions);
});
describe('validateAppId', () => {
it('should validate app ID successfully', async () => {
const authData = { access_token: 'validAccessToken' };
const mockResponse = {
[validOptions.appidField]: 'valid-app-id',
};
mockFetch([
{
url: validOptions.tokenIntrospectionEndpointUrl,
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve(mockResponse),
},
},
]);
await expectAsync(
adapter.validateAppId(validOptions.appIds, authData, validOptions)
).toBeResolved();
});
it('should throw an error if app ID is invalid', async () => {
const authData = { access_token: 'validAccessToken' };
const mockResponse = {
[validOptions.appidField]: 'invalid-app-id',
};
mockFetch([
{
url: validOptions.tokenIntrospectionEndpointUrl,
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve(mockResponse),
},
},
]);
await expectAsync(
adapter.validateAppId(validOptions.appIds, authData, validOptions)
).toBeRejectedWithError('OAuth2: Invalid app ID.');
});
});
describe('validateAuthData', () => {
it('should validate auth data successfully', async () => {
const authData = { id: 'user-id', access_token: 'validAccessToken' };
const mockResponse = {
active: true,
[validOptions.useridField]: 'user-id',
};
mockFetch([
{
url: validOptions.tokenIntrospectionEndpointUrl,
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve(mockResponse),
},
},
]);
await expectAsync(
adapter.validateAuthData(authData, null, validOptions)
).toBeResolvedTo({});
});
it('should throw an error if the token is inactive', async () => {
const authData = { id: 'user-id', access_token: 'validAccessToken' };
const mockResponse = { active: false };
mockFetch([
{
url: validOptions.tokenIntrospectionEndpointUrl,
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve(mockResponse),
},
},
]);
await expectAsync(
adapter.validateAuthData(authData, null, validOptions)
).toBeRejectedWith(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 access token is invalid for this user.'));
});
it('should throw an error if user ID does not match', async () => {
const authData = { id: 'user-id', access_token: 'validAccessToken' };
const mockResponse = {
active: true,
[validOptions.useridField]: 'different-user-id',
};
mockFetch([
{
url: validOptions.tokenIntrospectionEndpointUrl,
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve(mockResponse),
},
},
]);
await expectAsync(
adapter.validateAuthData(authData, null, validOptions)
).toBeRejectedWithError('OAuth2 access token is invalid for this user.');
});
it('should default useridField to sub and reject mismatched user ID', async () => {
const adapterNoUseridField = new OAuth2Adapter.constructor();
adapterNoUseridField.validateOptions({
tokenIntrospectionEndpointUrl: 'https://provider.example.com/introspect',
});
const authData = { id: 'victim-user-id', access_token: 'attackerToken' };
const mockResponse = {
active: true,
sub: 'attacker-user-id',
};
mockFetch([
{
url: 'https://provider.example.com/introspect',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve(mockResponse),
},
},
]);
await expectAsync(
adapterNoUseridField.validateAuthData(authData, null, {})
).toBeRejectedWithError('OAuth2 access token is invalid for this user.');
});
it('should default useridField to sub and accept matching user ID', async () => {
const adapterNoUseridField = new OAuth2Adapter.constructor();
adapterNoUseridField.validateOptions({
tokenIntrospectionEndpointUrl: 'https://provider.example.com/introspect',
});
const authData = { id: 'user-id', access_token: 'validAccessToken' };
const mockResponse = {
active: true,
sub: 'user-id',
};
mockFetch([
{
url: 'https://provider.example.com/introspect',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve(mockResponse),
},
},
]);
await expectAsync(
adapterNoUseridField.validateAuthData(authData, null, {})
).toBeResolvedTo({});
});
});
describe('requestTokenInfo', () => {
it('should fetch token info successfully', async () => {
const mockResponse = { active: true };
mockFetch([
{
url: validOptions.tokenIntrospectionEndpointUrl,
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve(mockResponse),
},
},
]);
const result = await adapter.requestTokenInfo(
'validAccessToken',
validOptions
);
expect(result).toEqual(mockResponse);
});
it('should throw an error if the introspection endpoint URL is missing', async () => {
const options = { ...validOptions, tokenIntrospectionEndpointUrl: null };
expect(
() => adapter.validateOptions(options)
).toThrow(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 token introspection endpoint URL is missing.'));
});
it('should throw an error if the response is not ok', async () => {
mockFetch([
{
url: validOptions.tokenIntrospectionEndpointUrl,
method: 'POST',
response: {
ok: false,
statusText: 'Bad Request',
},
},
]);
await expectAsync(
adapter.requestTokenInfo('invalidAccessToken')
).toBeRejectedWithError('OAuth2 token introspection request failed.');
});
});
describe('OAuth2Adapter E2E Tests', () => {
beforeEach(async () => {
// Simulate reconfiguring the server with OAuth2 auth options
await reconfigureServer({
auth: {
mockOauth: {
tokenIntrospectionEndpointUrl: 'https://provider.com/introspect',
useridField: 'sub',
appidField: 'aud',
appIds: ['valid-app-id'],
authorizationHeader: 'Bearer validAuthToken',
oauth2: true
},
},
});
});
it('should validate and authenticate user successfully', async () => {
mockFetch([
{
url: 'https://provider.com/introspect',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({
active: true,
sub: 'user123',
aud: 'valid-app-id',
}),
},
},
]);
const authData = { access_token: 'validAccessToken', id: 'user123' };
const user = await Parse.User.logInWith('mockOauth', { authData });
expect(user.id).toBeDefined();
expect(user.get('authData').mockOauth.id).toEqual('user123');
});
it('should reject authentication for inactive token', async () => {
mockFetch([
{
url: 'https://provider.com/introspect',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({ active: false, aud: ['valid-app-id'] }),
},
},
]);
const authData = { access_token: 'inactiveToken', id: 'user123' };
await expectAsync(Parse.User.logInWith('mockOauth', { authData })).toBeRejectedWith(
new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 access token is invalid for this user.')
);
});
it('should reject authentication for mismatched user ID', async () => {
mockFetch([
{
url: 'https://provider.com/introspect',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({
active: true,
sub: 'different-user',
aud: 'valid-app-id',
}),
},
},
]);
const authData = { access_token: 'validAccessToken', id: 'user123' };
await expectAsync(Parse.User.logInWith('mockOauth', { authData })).toBeRejectedWith(
new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 access token is invalid for this user.')
);
});
it('should reject authentication for invalid app ID', async () => {
mockFetch([
{
url: 'https://provider.com/introspect',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({
active: true,
sub: 'user123',
aud: 'invalid-app-id',
}),
},
},
]);
const authData = { access_token: 'validAccessToken', id: 'user123' };
await expectAsync(Parse.User.logInWith('mockOauth', { authData })).toBeRejectedWithError(
'OAuth2: Invalid app ID.'
);
});
it('should send the correct access token to the introspection endpoint during app ID validation', async () => {
const capturedTokens = [];
const originalFetch = global.fetch;
try {
global.fetch = async (url, options) => {
if (typeof url === 'string' && url === 'https://provider.com/introspect') {
const body = options?.body?.toString() || '';
const token = new URLSearchParams(body).get('token');
capturedTokens.push(token);
return {
ok: true,
json: () => Promise.resolve({
active: true,
sub: 'user123',
aud: 'valid-app-id',
}),
};
}
return originalFetch(url, options);
};
const authData = { access_token: 'myRealAccessToken', id: 'user123' };
const user = await Parse.User.logInWith('mockOauth', { authData });
expect(user.id).toBeDefined();
// With appidField configured, validateAppId and validateAuthData both call requestTokenInfo.
// Both should receive the actual access token, not 'undefined' from argument mismatch.
expect(capturedTokens.length).toBeGreaterThanOrEqual(2);
for (const token of capturedTokens) {
expect(token).toBe('myRealAccessToken');
}
} finally {
global.fetch = originalFetch;
}
});
it('should reject account takeover when useridField is omitted and attacker uses their own token with victim ID', async () => {
await reconfigureServer({
auth: {
mockOauth: {
tokenIntrospectionEndpointUrl: 'https://provider.example.com/introspect',
authorizationHeader: 'Bearer validAuthToken',
oauth2: true,
},
},
});
// Victim signs up with their own valid token
mockFetch([
{
url: 'https://provider.example.com/introspect',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({
active: true,
sub: 'victim-sub-id',
}),
},
},
]);
const victimAuthData = { access_token: 'victimToken', id: 'victim-sub-id' };
const victim = await Parse.User.logInWith('mockOauth', { authData: victimAuthData });
expect(victim.id).toBeDefined();
// Attacker tries to log in with their own valid token but claims victim's ID
mockFetch([
{
url: 'https://provider.example.com/introspect',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({
active: true,
sub: 'attacker-sub-id',
}),
},
},
]);
const attackerAuthData = { access_token: 'attackerToken', id: 'victim-sub-id' };
await expectAsync(Parse.User.logInWith('mockOauth', { authData: attackerAuthData })).toBeRejectedWith(
new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 access token is invalid for this user.')
);
});
it('should handle error when token introspection endpoint is missing', async () => {
await reconfigureServer({
auth: {
mockOauth: {
tokenIntrospectionEndpointUrl: null,
useridField: 'sub',
appidField: 'aud',
appIds: ['valid-app-id'],
authorizationHeader: 'Bearer validAuthToken',
oauth2: true
},
},
});
const authData = { access_token: 'validAccessToken', id: 'user123' };
await expectAsync(Parse.User.logInWith('mockOauth', { authData })).toBeRejectedWith(
new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 token introspection endpoint URL is missing.')
);
});
});
});
-252
View File
@@ -1,252 +0,0 @@
const QqAdapter = require('../../../lib/Adapters/Auth/qq').default;
describe('QqAdapter', () => {
let adapter;
beforeEach(() => {
adapter = new QqAdapter.constructor();
});
describe('getUserFromAccessToken', () => {
it('should fetch user data successfully', async () => {
const mockResponse = `callback({"client_id":"validAppId","openid":"user123"})`;
mockFetch([
{
url: 'https://graph.qq.com/oauth2.0/me',
method: 'GET',
response: {
ok: true,
text: () => Promise.resolve(mockResponse),
},
},
]);
const result = await adapter.getUserFromAccessToken('validAccessToken');
expect(result).toEqual({ client_id: 'validAppId', openid: 'user123' });
});
it('should throw an error if the API request fails', async () => {
mockFetch([
{
url: 'https://graph.qq.com/oauth2.0/me',
method: 'GET',
response: {
ok: false,
statusText: 'Unauthorized',
},
},
]);
await expectAsync(
adapter.getUserFromAccessToken('invalidAccessToken')
).toBeRejectedWithError('qq API request failed.');
});
});
describe('getAccessTokenFromCode', () => {
it('should fetch access token successfully', async () => {
const mockResponse = `callback({"access_token":"validAccessToken","expires_in":3600,"refresh_token":"refreshToken"})`;
mockFetch([
{
url: 'https://graph.qq.com/oauth2.0/token',
method: 'GET',
response: {
ok: true,
text: () => Promise.resolve(mockResponse),
},
},
]);
const result = await adapter.getAccessTokenFromCode({
code: 'validCode',
redirect_uri: 'https://your-redirect-uri.com/callback',
});
expect(result).toBe('validAccessToken');
});
it('should throw an error if the API request fails', async () => {
mockFetch([
{
url: 'https://graph.qq.com/oauth2.0/token',
method: 'GET',
response: {
ok: false,
statusText: 'Bad Request',
},
},
]);
await expectAsync(
adapter.getAccessTokenFromCode({
code: 'invalidCode',
redirect_uri: 'https://your-redirect-uri.com/callback',
})
).toBeRejectedWithError('qq API request failed.');
});
});
describe('parseResponseData', () => {
it('should parse valid callback response data', () => {
const response = `callback({"key":"value"})`;
const result = adapter.parseResponseData(response);
expect(result).toEqual({ key: 'value' });
});
it('should throw an error if the response data is invalid', () => {
const response = 'invalid response';
expect(() => adapter.parseResponseData(response)).toThrowError(
'qq auth is invalid for this user.'
);
});
});
describe('QqAdapter E2E Test', () => {
beforeEach(async () => {
await reconfigureServer({
auth: {
qq: {
clientId: 'validAppId',
clientSecret: 'validAppSecret',
},
},
});
});
it('should log in user using Qq adapter successfully', async () => {
mockFetch([
{
url: 'https://graph.qq.com/oauth2.0/token',
method: 'GET',
response: {
ok: true,
text: () =>
Promise.resolve(
`callback({"access_token":"mockAccessToken","expires_in":3600})`
),
},
},
{
url: 'https://graph.qq.com/oauth2.0/me',
method: 'GET',
response: {
ok: true,
text: () =>
Promise.resolve(
`callback({"client_id":"validAppId","openid":"user123"})`
),
},
},
]);
const authData = { code: 'validCode', redirect_uri: 'https://your-redirect-uri.com/callback' };
const user = await Parse.User.logInWith('qq', { authData });
expect(user.id).toBeDefined();
});
it('should handle error when Qq returns invalid code', async () => {
mockFetch([
{
url: 'https://graph.qq.com/oauth2.0/token',
method: 'GET',
response: {
ok: false,
statusText: 'Invalid code',
},
},
]);
const authData = { code: 'invalidCode', redirect_uri: 'https://your-redirect-uri.com/callback' };
await expectAsync(Parse.User.logInWith('qq', { authData })).toBeRejectedWithError(
'qq API request failed.'
);
});
it('should handle error when Qq returns invalid user data', async () => {
mockFetch([
{
url: 'https://graph.qq.com/oauth2.0/token',
method: 'GET',
response: {
ok: true,
text: () =>
Promise.resolve(
`callback({"access_token":"mockAccessToken","expires_in":3600})`
),
},
},
{
url: 'https://graph.qq.com/oauth2.0/me',
method: 'GET',
response: {
ok: false,
statusText: 'Unauthorized',
},
},
]);
const authData = { code: 'validCode', redirect_uri: 'https://your-redirect-uri.com/callback' };
await expectAsync(Parse.User.logInWith('qq', { authData })).toBeRejectedWithError(
'qq API request failed.'
);
});
it('e2e secure does not support insecure payload', async () => {
mockFetch();
const authData = { id: 'mockUserId', access_token: 'mockAccessToken' };
await expectAsync(Parse.User.logInWith('qq', { authData })).toBeRejectedWithError(
'qq code is required.'
);
});
it('e2e insecure does support secure payload', async () => {
await reconfigureServer({
auth: {
qq: {
appId: 'validAppId',
appSecret: 'validAppSecret',
enableInsecureAuth: true,
},
},
});
mockFetch([
{
url: 'https://graph.qq.com/oauth2.0/token',
method: 'GET',
response: {
ok: true,
text: () =>
Promise.resolve(
`callback({"access_token":"mockAccessToken","expires_in":3600})`
),
},
},
{
url: 'https://graph.qq.com/oauth2.0/me',
method: 'GET',
response: {
ok: true,
text: () =>
Promise.resolve(
`callback({"client_id":"validAppId","openid":"user123"})`
),
},
},
]);
const authData = { code: 'validCode', redirect_uri: 'https://your-redirect-uri.com/callback' };
const user = await Parse.User.logInWith('qq', { authData });
expect(user.id).toBeDefined();
});
});
});
-113
View File
@@ -1,113 +0,0 @@
const SpotifyAdapter = require('../../../lib/Adapters/Auth/spotify').default;
describe('SpotifyAdapter', () => {
let adapter;
beforeEach(() => {
adapter = new SpotifyAdapter.constructor();
});
describe('getUserFromAccessToken', () => {
it('should fetch user data successfully', async () => {
const mockResponse = {
id: 'spotifyUser123',
};
mockFetch([
{
url: 'https://api.spotify.com/v1/me',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve(mockResponse),
},
},
]);
const result = await adapter.getUserFromAccessToken('validAccessToken');
expect(result).toEqual({ id: 'spotifyUser123' });
});
it('should throw an error if the API request fails', async () => {
mockFetch([
{
url: 'https://api.spotify.com/v1/me',
method: 'GET',
response: {
ok: false,
statusText: 'Unauthorized',
},
},
]);
await expectAsync(adapter.getUserFromAccessToken('invalidAccessToken')).toBeRejectedWithError(
'Spotify API request failed.'
);
});
});
describe('getAccessTokenFromCode', () => {
it('should fetch access token successfully', async () => {
const mockResponse = {
access_token: 'validAccessToken',
expires_in: 3600,
refresh_token: 'refreshToken',
};
mockFetch([
{
url: 'https://accounts.spotify.com/api/token',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve(mockResponse),
},
},
]);
const authData = {
code: 'validCode',
redirect_uri: 'https://your-redirect-uri.com/callback',
code_verifier: 'validCodeVerifier',
};
const result = await adapter.getAccessTokenFromCode(authData);
expect(result).toEqual(mockResponse);
});
it('should throw an error if authData is missing required fields', async () => {
const authData = {
redirect_uri: 'https://your-redirect-uri.com/callback',
};
await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWithError(
'Spotify auth configuration authData.code and/or authData.redirect_uri and/or authData.code_verifier.'
);
});
it('should throw an error if the API request fails', async () => {
mockFetch([
{
url: 'https://accounts.spotify.com/api/token',
method: 'POST',
response: {
ok: false,
statusText: 'Bad Request',
},
},
]);
const authData = {
code: 'invalidCode',
redirect_uri: 'https://your-redirect-uri.com/callback',
code_verifier: 'invalidCodeVerifier',
};
await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWithError(
'Spotify API request failed.'
);
});
});
});
-120
View File
@@ -1,120 +0,0 @@
const TwitterAuthAdapter = require('../../../lib/Adapters/Auth/twitter').default;
describe('TwitterAuthAdapter', function () {
let adapter;
const validOptions = {
consumer_key: 'validConsumerKey',
consumer_secret: 'validConsumerSecret',
};
beforeEach(function () {
adapter = new TwitterAuthAdapter.constructor();
});
describe('Test configuration errors', function () {
it('should throw an error when options are missing', function () {
expect(() => adapter.validateOptions()).toThrowError('Twitter auth options are required.');
});
it('should throw an error when consumer_key and consumer_secret are missing for secure auth', function () {
const options = { enableInsecureAuth: false };
expect(() => adapter.validateOptions(options)).toThrowError(
'Consumer key and secret are required for secure Twitter auth.'
);
});
it('should not throw an error when valid options are provided', function () {
expect(() => adapter.validateOptions(validOptions)).not.toThrow();
});
});
describe('Validate Insecure Auth', function () {
it('should throw an error if oauth_token or oauth_token_secret are missing', async function () {
const authData = { oauth_token: 'validToken' }; // Missing oauth_token_secret
await expectAsync(adapter.validateInsecureAuth(authData, validOptions)).toBeRejectedWithError(
'Twitter insecure auth requires oauth_token and oauth_token_secret.'
);
});
it('should validate insecure auth successfully when data matches', async function () {
spyOn(adapter, 'request').and.returnValue(
Promise.resolve({
json: () => Promise.resolve({ id: 'validUserId' }),
})
);
const authData = {
id: 'validUserId',
oauth_token: 'validToken',
oauth_token_secret: 'validSecret',
};
await expectAsync(adapter.validateInsecureAuth(authData, validOptions)).toBeResolved();
});
it('should throw an error when user ID does not match', async function () {
spyOn(adapter, 'request').and.returnValue(
Promise.resolve({
json: () => Promise.resolve({ id: 'invalidUserId' }),
})
);
const authData = {
id: 'validUserId',
oauth_token: 'validToken',
oauth_token_secret: 'validSecret',
};
await expectAsync(adapter.validateInsecureAuth(authData, validOptions)).toBeRejectedWithError(
'Twitter auth is invalid for this user.'
);
});
});
describe('End-to-End Tests', function () {
beforeEach(async function () {
await reconfigureServer({
auth: {
twitter: validOptions,
}
})
});
it('should authenticate user successfully using validateAuthData', async function () {
spyOn(adapter, 'exchangeAccessToken').and.returnValue(
Promise.resolve({ oauth_token: 'validToken', user_id: 'validUserId' })
);
const authData = {
oauth_token: 'validToken',
oauth_verifier: 'validVerifier',
};
await expectAsync(adapter.validateAuthData(authData, validOptions)).toBeResolved();
expect(authData.id).toBe('validUserId');
expect(authData.auth_token).toBe('validToken');
});
it('should handle multiple configurations and validate successfully', async function () {
const authData = {
consumer_key: 'validConsumerKey',
oauth_token: 'validToken',
oauth_token_secret: 'validSecret',
};
const optionsArray = [
{ consumer_key: 'invalidKey', consumer_secret: 'invalidSecret' },
validOptions,
];
const selectedOption = adapter.handleMultipleConfigurations(authData, optionsArray);
expect(selectedOption).toEqual(validOptions);
});
it('should throw an error when no matching configuration is found', function () {
const authData = { consumer_key: 'missingKey' };
const optionsArray = [validOptions];
expect(() => adapter.handleMultipleConfigurations(authData, optionsArray)).toThrowError(
'Twitter auth is invalid for this user.'
);
});
});
});
-236
View File
@@ -1,236 +0,0 @@
const WeChatAdapter = require('../../../lib/Adapters/Auth/wechat').default;
describe('WeChatAdapter', function () {
let adapter;
beforeEach(function () {
adapter = new WeChatAdapter.constructor();
});
describe('Test getUserFromAccessToken', function () {
it('should fetch user successfully', async function () {
mockFetch([
{
url: 'https://api.weixin.qq.com/sns/auth?access_token=validToken&openid=validOpenId',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({ errcode: 0, id: 'validUserId' }),
},
},
]);
const user = await adapter.getUserFromAccessToken('validToken', { id: 'validOpenId' });
expect(global.fetch).toHaveBeenCalledWith(
'https://api.weixin.qq.com/sns/auth?access_token=validToken&openid=validOpenId',
jasmine.any(Object)
);
expect(user).toEqual({ errcode: 0, id: 'validUserId' });
});
it('should throw error for invalid response', async function () {
mockFetch([
{
url: 'https://api.weixin.qq.com/sns/auth?access_token=invalidToken&openid=undefined',
method: 'GET',
response: {
ok: false,
json: () => Promise.resolve({ errcode: 40013, errmsg: 'Invalid token' }),
},
},
]);
await expectAsync(adapter.getUserFromAccessToken('invalidToken', 'invalidOpenId')).toBeRejectedWith(
jasmine.objectContaining({ message: 'WeChat auth is invalid for this user.' })
);
});
});
describe('Test getAccessTokenFromCode', function () {
it('should fetch access token successfully', async function () {
mockFetch([
{
url: 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=validAppId&secret=validAppSecret&code=validCode&grant_type=authorization_code',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({ access_token: 'validToken', errcode: 0 }),
},
},
]);
adapter.validateOptions({ clientId: 'validAppId', clientSecret: 'validAppSecret' });
const authData = { code: 'validCode' };
const token = await adapter.getAccessTokenFromCode(authData);
expect(global.fetch).toHaveBeenCalledWith(
'https://api.weixin.qq.com/sns/oauth2/access_token?appid=validAppId&secret=validAppSecret&code=validCode&grant_type=authorization_code',
jasmine.any(Object)
);
expect(token).toEqual('validToken');
});
it('should throw error for invalid response', async function () {
mockFetch([
{
url: 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=validAppId&secret=validAppSecret&code=invalidCode&grant_type=authorization_code',
method: 'GET',
response: {
ok: false,
json: () => Promise.resolve({ errcode: 40029, errmsg: 'Invalid code' }),
},
},
]);
adapter.validateOptions({ clientId: 'validAppId', clientSecret: 'validAppSecret' });
const authData = { code: 'invalidCode' };
await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWith(
jasmine.objectContaining({ message: 'WeChat auth is invalid for this user.' })
);
});
});
describe('WeChatAdapter E2E Tests', function () {
beforeEach(async () => {
await reconfigureServer({
auth: {
wechat: {
clientId: 'validAppId',
clientSecret: 'validAppSecret',
enableInsecureAuth: false,
},
},
});
});
it('should authenticate user successfully using WeChatAdapter', async function () {
mockFetch([
{
url: 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=validAppId&secret=validAppSecret&code=validCode&grant_type=authorization_code',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({ access_token: 'validAccessToken', openid: 'user123', errcode: 0 }),
},
},
{
url: 'https://api.weixin.qq.com/sns/auth?access_token=validAccessToken&openid=user123',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({ errcode: 0, id: 'user123' }),
},
},
]);
const authData = { code: 'validCode', redirect_uri: 'http://example.com/callback' };
const user = await Parse.User.logInWith('wechat', { authData });
expect(user.id).toBeDefined();
});
it('should handle invalid code error gracefully', async function () {
mockFetch([
{
url: 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=validAppId&secret=validAppSecret&code=invalidCode&grant_type=authorization_code',
method: 'GET',
response: {
ok: false,
json: () => Promise.resolve({ errcode: 40029, errmsg: 'Invalid code' }),
},
},
]);
const authData = { code: 'invalidCode', redirect_uri: 'http://example.com/callback' };
await expectAsync(Parse.User.logInWith('wechat', { authData })).toBeRejectedWith(
jasmine.objectContaining({ message: 'WeChat auth is invalid for this user.' })
);
});
it('should handle error when fetching user data fails', async function () {
mockFetch([
{
url: 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=validAppId&secret=validAppSecret&code=validCode&grant_type=authorization_code',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({ access_token: 'validAccessToken', openid: 'user123', errcode: 0 }),
},
},
{
url: 'https://api.weixin.qq.com/sns/auth?access_token=validAccessToken&openid=user123',
method: 'GET',
response: {
ok: false,
json: () => Promise.resolve({ errcode: 40013, errmsg: 'Invalid token' }),
},
},
]);
const authData = { code: 'validCode', redirect_uri: 'http://example.com/callback' };
await expectAsync(Parse.User.logInWith('wechat', { authData })).toBeRejectedWith(
jasmine.objectContaining({ message: 'WeChat auth is invalid for this user.' })
);
});
it('should allow insecure auth when enabled', async function () {
mockFetch([
{
url: 'https://api.weixin.qq.com/sns/auth?access_token=validAccessToken&openid=user123',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({ errcode: 0, id: 'user123' }),
},
},
]);
await reconfigureServer({
auth: {
wechat: {
appId: 'validAppId',
appSecret: 'validAppSecret',
enableInsecureAuth: true,
},
},
});
const authData = { access_token: 'validAccessToken', id: 'user123' };
const user = await Parse.User.logInWith('wechat', { authData });
expect(user.id).toBeDefined();
});
it('should reject insecure auth when user id does not match', async function () {
mockFetch([
{
url: 'https://api.weixin.qq.com/sns/auth?access_token=validAccessToken&openid=incorrectUserId',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({ errcode: 0, id: 'incorrectUser' }),
},
},
]);
await reconfigureServer({
auth: {
wechat: {
appId: 'validAppId',
appSecret: 'validAppSecret',
enableInsecureAuth: true,
},
},
});
const authData = { access_token: 'validAccessToken', id: 'incorrectUserId' };
await expectAsync(Parse.User.logInWith('wechat', { authData })).toBeRejectedWith(
jasmine.objectContaining({ message: 'WeChat auth is invalid for this user.' })
);
});
});
});
-204
View File
@@ -1,204 +0,0 @@
const WeiboAdapter = require('../../../lib/Adapters/Auth/weibo').default;
describe('WeiboAdapter', function () {
let adapter;
beforeEach(function () {
adapter = new WeiboAdapter.constructor();
});
describe('Test configuration errors', function () {
it('should throw error if code or redirect_uri is missing', async function () {
const invalidAuthData = [
{},
{ code: 'validCode' },
{ redirect_uri: 'http://example.com/callback' },
];
for (const authData of invalidAuthData) {
await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWith(
jasmine.objectContaining({
message: 'Weibo auth requires code and redirect_uri to be sent.',
})
);
}
});
});
describe('Test getUserFromAccessToken', function () {
it('should fetch user successfully', async function () {
mockFetch([
{
url: 'https://api.weibo.com/oauth2/get_token_info',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({ uid: 'validUserId' }),
},
},
]);
const authData = { id: 'validUserId' };
const user = await adapter.getUserFromAccessToken('validToken', authData);
expect(global.fetch).toHaveBeenCalledWith(
'https://api.weibo.com/oauth2/get_token_info',
jasmine.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
);
expect(user).toEqual({ id: 'validUserId' });
});
it('should throw error for invalid response', async function () {
mockFetch([
{
url: 'https://api.weibo.com/oauth2/get_token_info',
method: 'POST',
response: {
ok: false,
json: () => Promise.resolve({}),
},
},
]);
const authData = { id: 'invalidUserId' };
await expectAsync(adapter.getUserFromAccessToken('invalidToken', authData)).toBeRejectedWith(
jasmine.objectContaining({
message: 'Weibo auth is invalid for this user.',
})
);
});
});
describe('Test getAccessTokenFromCode', function () {
it('should fetch access token successfully', async function () {
mockFetch([
{
url: 'https://api.weibo.com/oauth2/access_token',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({ access_token: 'validToken', uid: 'validUserId' }),
},
},
]);
const authData = { code: 'validCode', redirect_uri: 'http://example.com/callback' };
const token = await adapter.getAccessTokenFromCode(authData);
expect(global.fetch).toHaveBeenCalledWith(
'https://api.weibo.com/oauth2/access_token',
jasmine.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
);
expect(token).toEqual('validToken');
});
it('should throw error for invalid response', async function () {
mockFetch([
{
url: 'https://api.weibo.com/oauth2/access_token',
method: 'POST',
response: {
ok: false,
json: () => Promise.resolve({ errcode: 40029 }),
},
},
]);
const authData = { code: 'invalidCode', redirect_uri: 'http://example.com/callback' };
await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWith(
jasmine.objectContaining({
message: 'Weibo auth is invalid for this user.',
})
);
});
});
describe('WeiboAdapter E2E Tests', function () {
beforeEach(async () => {
await reconfigureServer({
auth: {
weibo: {
clientId: 'validAppId',
clientSecret: 'validAppSecret',
},
}
});
});
it('should authenticate user successfully using WeiboAdapter', async function () {
mockFetch([
{
url: 'https://api.weibo.com/oauth2/access_token',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({ access_token: 'validAccessToken', uid: 'user123' }),
},
},
{
url: 'https://api.weibo.com/oauth2/get_token_info',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({ uid: 'user123' }),
},
},
]);
const authData = { code: 'validCode', redirect_uri: 'http://example.com/callback' };
const user = await Parse.User.logInWith('weibo', { authData });
expect(user.id).toBeDefined();
});
it('should handle invalid code error gracefully', async function () {
mockFetch([
{
url: 'https://api.weibo.com/oauth2/access_token',
method: 'POST',
response: {
ok: false,
json: () => Promise.resolve({ errcode: 40029 }),
},
},
]);
const authData = { code: 'invalidCode', redirect_uri: 'http://example.com/callback' };
await expectAsync(Parse.User.logInWith('weibo', { authData })).toBeRejectedWith(
jasmine.objectContaining({ message: 'Weibo auth is invalid for this user.' })
);
});
it('should handle error when fetching user data fails', async function () {
mockFetch([
{
url: 'https://api.weibo.com/oauth2/access_token',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({ access_token: 'validAccessToken', uid: 'user123' }),
},
},
{
url: 'https://api.weibo.com/oauth2/get_token_info',
method: 'POST',
response: {
ok: false,
json: () => Promise.resolve({}),
},
},
]);
const authData = { code: 'validCode', redirect_uri: 'http://example.com/callback' };
await expectAsync(Parse.User.logInWith('weibo', { authData })).toBeRejectedWith(
jasmine.objectContaining({ message: 'Weibo auth is invalid for this user.' })
);
});
});
});
+19 -41
View File
@@ -5,13 +5,6 @@ const request = require('../lib/request');
const AudiencesRouter = require('../lib/Routers/AudiencesRouter').AudiencesRouter;
describe('AudiencesRouter', () => {
let loggerErrorSpy;
beforeEach(() => {
const logger = require('../lib/logger').default;
loggerErrorSpy = spyOn(logger, 'error').and.callThrough();
});
it('uses find condition from request.body', done => {
const config = Config.get('test');
const androidAudienceRequest = {
@@ -36,9 +29,9 @@ describe('AudiencesRouter', () => {
const router = new AudiencesRouter();
rest
.create(config, auth.master(config), '_Audience', androidAudienceRequest)
.create(config, auth.nobody(config), '_Audience', androidAudienceRequest)
.then(() => {
return rest.create(config, auth.master(config), '_Audience', iosAudienceRequest);
return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest);
})
.then(() => {
return router.handleFind(request);
@@ -78,9 +71,9 @@ describe('AudiencesRouter', () => {
const router = new AudiencesRouter();
rest
.create(config, auth.master(config), '_Audience', androidAudienceRequest)
.create(config, auth.nobody(config), '_Audience', androidAudienceRequest)
.then(() => {
return rest.create(config, auth.master(config), '_Audience', iosAudienceRequest);
return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest);
})
.then(() => {
return router.handleFind(request);
@@ -119,9 +112,9 @@ describe('AudiencesRouter', () => {
Config.get('test');
const router = new AudiencesRouter();
rest
.create(config, auth.master(config), '_Audience', androidAudienceRequest)
.create(config, auth.nobody(config), '_Audience', androidAudienceRequest)
.then(() => {
return rest.create(config, auth.master(config), '_Audience', iosAudienceRequest);
return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest);
})
.then(() => {
return router.handleFind(request);
@@ -159,8 +152,8 @@ describe('AudiencesRouter', () => {
const router = new AudiencesRouter();
rest
.create(config, auth.master(config), '_Audience', androidAudienceRequest)
.then(() => rest.create(config, auth.master(config), '_Audience', iosAudienceRequest))
.create(config, auth.nobody(config), '_Audience', androidAudienceRequest)
.then(() => rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest))
.then(() => router.handleFind(request))
.then(res => {
const response = res.response;
@@ -197,9 +190,9 @@ describe('AudiencesRouter', () => {
const router = new AudiencesRouter();
rest
.create(config, auth.master(config), '_Audience', androidAudienceRequest)
.create(config, auth.nobody(config), '_Audience', androidAudienceRequest)
.then(() => {
return rest.create(config, auth.master(config), '_Audience', iosAudienceRequest);
return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest);
})
.then(() => {
return router.handleFind(request);
@@ -270,71 +263,61 @@ describe('AudiencesRouter', () => {
});
it('should only create with master key', done => {
loggerErrorSpy.calls.reset();
Parse._request('POST', 'push_audiences', {
name: 'My Audience',
query: JSON.stringify({ deviceType: 'ios' }),
}).then(
() => {},
error => {
expect(error.message).toEqual('Permission denied');
expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('unauthorized: master key is required'));
expect(error.message).toEqual('unauthorized: master key is required');
done();
}
);
});
it('should only find with master key', done => {
loggerErrorSpy.calls.reset();
Parse._request('GET', 'push_audiences', {}).then(
() => {},
error => {
expect(error.message).toEqual('Permission denied');
expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('unauthorized: master key is required'));
expect(error.message).toEqual('unauthorized: master key is required');
done();
}
);
});
it('should only get with master key', done => {
loggerErrorSpy.calls.reset();
Parse._request('GET', `push_audiences/someId`, {}).then(
() => {},
error => {
expect(error.message).toEqual('Permission denied');
expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('unauthorized: master key is required'));
expect(error.message).toEqual('unauthorized: master key is required');
done();
}
);
});
it('should only update with master key', done => {
loggerErrorSpy.calls.reset();
Parse._request('PUT', `push_audiences/someId`, {
name: 'My Audience 2',
}).then(
() => {},
error => {
expect(error.message).toEqual('Permission denied');
expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('unauthorized: master key is required'));
expect(error.message).toEqual('unauthorized: master key is required');
done();
}
);
});
it('should only delete with master key', done => {
loggerErrorSpy.calls.reset();
Parse._request('DELETE', `push_audiences/someId`, {}).then(
() => {},
error => {
expect(error.message).toEqual('Permission denied');
expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('unauthorized: master key is required'));
expect(error.message).toEqual('unauthorized: master key is required');
done();
}
);
});
it_id('af1111b5-3251-4b40-8f06-fb0fc624fa91')(it_exclude_dbs(['postgres']))('should support legacy parse.com audience fields', done => {
it_exclude_dbs(['postgres'])('should support legacy parse.com audience fields', done => {
const database = Config.get(Parse.applicationId).database.adapter.database;
const now = new Date();
Parse._request(
@@ -356,12 +339,11 @@ describe('AudiencesRouter', () => {
)
.then(result => {
expect(result).toBeTruthy();
database
.collection('test__Audience')
.find({ _id: audience.objectId })
.toArray()
.then(rows => {
.toArray((error, rows) => {
expect(error).toEqual(undefined);
expect(rows[0]['times_used']).toEqual(1);
expect(rows[0]['_last_used']).toEqual(now);
Parse._request(
@@ -380,9 +362,6 @@ describe('AudiencesRouter', () => {
.catch(error => {
done.fail(error);
});
})
.catch(error => {
done.fail(error);
});
});
});
@@ -421,7 +400,6 @@ describe('AudiencesRouter', () => {
await reconfigureServer({
appId: 'test',
restAPIKey: 'test',
masterKey: 'test',
publicServerURL: 'http://localhost:8378/1',
});
try {
@@ -431,7 +409,7 @@ describe('AudiencesRouter', () => {
body: { lorem: 'ipsum', _method: 'POST' },
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
'X-Parse-REST-API-Key': 'test',
'Content-Type': 'application/json',
},
});
+40 -23
View File
@@ -81,6 +81,17 @@ describe('Auth', () => {
.then(roles => expect(roles).toEqual([]))
.then(() => done());
});
it('should properly handle bcrypt upgrade', done => {
const bcryptOriginal = require('bcrypt-nodejs');
const bcryptNew = require('bcryptjs');
bcryptOriginal.hash('my1Long:password', null, null, function (err, res) {
bcryptNew.compare('my1Long:password', res, function (err, res) {
expect(res).toBeTruthy();
done();
});
});
});
});
it('can use extendSessionOnUse', async () => {
@@ -106,8 +117,6 @@ describe('Auth', () => {
updatedAt: updatedAt.toISOString(),
}
);
Parse.Server.cacheController.clear();
await new Promise(resolve => setTimeout(resolve, 1000));
await session.fetch();
await new Promise(resolve => setTimeout(resolve, 1000));
await session.fetch();
@@ -143,6 +152,35 @@ describe('Auth', () => {
expect(userAuth.user.id).toBe(user.id);
});
it('should load auth without a config', async () => {
const user = new Parse.User();
await user.signUp({
username: 'hello',
password: 'password',
});
expect(user.getSessionToken()).not.toBeUndefined();
const userAuth = await getAuthForSessionToken({
sessionToken: user.getSessionToken(),
});
expect(userAuth.user instanceof Parse.User).toBe(true);
expect(userAuth.user.id).toBe(user.id);
});
it('should load auth with a config', async () => {
const user = new Parse.User();
await user.signUp({
username: 'hello',
password: 'password',
});
expect(user.getSessionToken()).not.toBeUndefined();
const userAuth = await getAuthForSessionToken({
sessionToken: user.getSessionToken(),
config: Config.get('test'),
});
expect(userAuth.user instanceof Parse.User).toBe(true);
expect(userAuth.user.id).toBe(user.id);
});
describe('getRolesForUser', () => {
const rolesNumber = 100;
@@ -233,24 +271,3 @@ describe('Auth', () => {
});
});
});
describe('extendSessionOnUse', () => {
it(`shouldUpdateSessionExpiry()`, async () => {
const { shouldUpdateSessionExpiry } = require('../lib/Auth');
let update = new Date(Date.now() - 86410 * 1000);
const res = shouldUpdateSessionExpiry(
{ sessionLength: 86460 },
{ updatedAt: update }
);
update = new Date(Date.now() - 43210 * 1000);
const res2 = shouldUpdateSessionExpiry(
{ sessionLength: 86460 },
{ updatedAt: update }
);
expect(res).toBe(true);
expect(res2).toBe(false);
});
});
-210
View File
@@ -1,210 +0,0 @@
'use strict';
const request = require('../lib/request');
const Config = require('../lib/Config');
describe('AuthData Unique Index', () => {
const fakeAuthProvider = {
validateAppId: () => Promise.resolve(),
validateAuthData: () => Promise.resolve(),
};
beforeEach(async () => {
await reconfigureServer({ auth: { fakeAuthProvider } });
});
it('should prevent concurrent signups with the same authData from creating duplicate users', async () => {
const authData = { fakeAuthProvider: { id: 'duplicate-test-id', token: 'token1' } };
// Fire multiple concurrent signup requests with the same authData
const concurrentRequests = Array.from({ length: 5 }, () =>
request({
method: 'POST',
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'Content-Type': 'application/json',
},
url: 'http://localhost:8378/1/users',
body: { authData },
}).then(
response => ({ success: true, data: response.data }),
error => ({ success: false, error: error.data || error.message })
)
);
const results = await Promise.all(concurrentRequests);
const successes = results.filter(r => r.success);
const failures = results.filter(r => !r.success);
// All should either succeed (returning the same user) or fail with "this auth is already used"
// The key invariant: only ONE unique objectId should exist
const uniqueObjectIds = new Set(successes.map(r => r.data.objectId));
expect(uniqueObjectIds.size).toBe(1);
// Failures should be "this auth is already used" errors
for (const failure of failures) {
expect(failure.error.code).toBe(208);
expect(failure.error.error).toBe('this auth is already used');
}
// Verify only one user exists in the database with this authData
const query = new Parse.Query('_User');
query.equalTo('authData.fakeAuthProvider.id', 'duplicate-test-id');
const users = await query.find({ useMasterKey: true });
expect(users.length).toBe(1);
});
it('should prevent concurrent signups via batch endpoint with same authData', async () => {
const authData = { fakeAuthProvider: { id: 'batch-race-test-id', token: 'token1' } };
const response = await request({
method: 'POST',
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'Content-Type': 'application/json',
},
url: 'http://localhost:8378/1/batch',
body: {
requests: Array.from({ length: 3 }, () => ({
method: 'POST',
path: '/1/users',
body: { authData },
})),
},
});
const results = response.data;
const successes = results.filter(r => r.success);
const failures = results.filter(r => r.error);
// All successes should reference the same user
const uniqueObjectIds = new Set(successes.map(r => r.success.objectId));
expect(uniqueObjectIds.size).toBe(1);
// Failures should be "this auth is already used" errors
for (const failure of failures) {
expect(failure.error.code).toBe(208);
expect(failure.error.error).toBe('this auth is already used');
}
// Verify only one user exists in the database with this authData
const query = new Parse.Query('_User');
query.equalTo('authData.fakeAuthProvider.id', 'batch-race-test-id');
const users = await query.find({ useMasterKey: true });
expect(users.length).toBe(1);
});
it('should allow sequential signups with different authData IDs', async () => {
const user1 = await Parse.User.logInWith('fakeAuthProvider', {
authData: { id: 'user-id-1', token: 'token1' },
});
const user2 = await Parse.User.logInWith('fakeAuthProvider', {
authData: { id: 'user-id-2', token: 'token2' },
});
expect(user1.id).toBeDefined();
expect(user2.id).toBeDefined();
expect(user1.id).not.toBe(user2.id);
});
it('should still allow login with authData after successful signup', async () => {
const authPayload = { authData: { id: 'login-test-id', token: 'token1' } };
// Signup
const user1 = await Parse.User.logInWith('fakeAuthProvider', authPayload);
expect(user1.id).toBeDefined();
// Login again with same authData — should return same user
const user2 = await Parse.User.logInWith('fakeAuthProvider', authPayload);
expect(user2.id).toBe(user1.id);
});
it('should skip startup index creation when createIndexAuthDataUniqueness is false', async () => {
const config = Config.get('test');
const adapter = config.database.adapter;
const spy = spyOn(adapter, 'ensureAuthDataUniqueness').and.callThrough();
// Temporarily set the option to false
const originalOptions = config.database.options.databaseOptions;
config.database.options.databaseOptions = { createIndexAuthDataUniqueness: false };
await config.database.performInitialization();
expect(spy).not.toHaveBeenCalled();
// Restore original options
config.database.options.databaseOptions = originalOptions;
});
it('should handle calling ensureAuthDataUniqueness multiple times (idempotent)', async () => {
const config = Config.get('test');
const adapter = config.database.adapter;
// Both calls should succeed (index creation is idempotent)
await adapter.ensureAuthDataUniqueness('fakeAuthProvider');
await adapter.ensureAuthDataUniqueness('fakeAuthProvider');
});
it('should log warning when index creation fails due to existing duplicates', async () => {
const config = Config.get('test');
const adapter = config.database.adapter;
// Spy on the adapter to simulate a duplicate value error
spyOn(adapter, 'ensureAuthDataUniqueness').and.callFake(() => {
return Promise.reject(
new Parse.Error(Parse.Error.DUPLICATE_VALUE, 'duplicates exist')
);
});
const logSpy = spyOn(require('../lib/logger').logger, 'warn');
// Re-run performInitialization — should warn but not throw
await config.database.performInitialization();
expect(logSpy).toHaveBeenCalledWith(
jasmine.stringContaining('Unable to ensure uniqueness for auth data provider'),
jasmine.anything()
);
});
it('should prevent concurrent signups with same anonymous authData', async () => {
const anonymousId = 'anon-race-test-id';
const authData = { anonymous: { id: anonymousId } };
const concurrentRequests = Array.from({ length: 5 }, () =>
request({
method: 'POST',
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'Content-Type': 'application/json',
},
url: 'http://localhost:8378/1/users',
body: { authData },
}).then(
response => ({ success: true, data: response.data }),
error => ({ success: false, error: error.data || error.message })
)
);
const results = await Promise.all(concurrentRequests);
const successes = results.filter(r => r.success);
const failures = results.filter(r => !r.success);
// All successes should reference the same user
const uniqueObjectIds = new Set(successes.map(r => r.data.objectId));
expect(uniqueObjectIds.size).toBe(1);
// Failures should be "this auth is already used" errors
for (const failure of failures) {
expect(failure.error.code).toBe(208);
expect(failure.error.error).toBe('this auth is already used');
}
// Verify only one user exists in the database with this authData
const query = new Parse.Query('_User');
query.equalTo('authData.anonymous.id', anonymousId);
const users = await query.find({ useMasterKey: true });
expect(users.length).toBe(1);
});
});
File diff suppressed because it is too large Load Diff
+13 -430
View File
@@ -1,6 +1,5 @@
const request = require('../lib/request');
const Auth = require('../lib/Auth');
const Config = require('../lib/Config');
const requestWithExpectedError = async params => {
try {
return await request(params);
@@ -77,41 +76,6 @@ describe('Auth Adapter features', () => {
validateAppId: () => Promise.resolve(),
};
// Code-based adapter that requires 'code' field (like gpgames)
const codeBasedAdapter = {
validateAppId: () => Promise.resolve(),
validateSetUp: authData => {
if (!authData.code) {
throw new Error('code is required.');
}
return Promise.resolve({ save: { id: authData.id } });
},
validateUpdate: authData => {
if (!authData.code) {
throw new Error('code is required.');
}
return Promise.resolve({ save: { id: authData.id } });
},
validateLogin: authData => {
if (!authData.code) {
throw new Error('code is required.');
}
return Promise.resolve({ save: { id: authData.id } });
},
afterFind: authData => {
// Strip sensitive 'code' field when returning to client
return { id: authData.id };
},
};
// Simple adapter that doesn't require code
const simpleAdapter = {
validateAppId: () => Promise.resolve(),
validateSetUp: () => Promise.resolve(),
validateUpdate: () => Promise.resolve(),
validateLogin: () => Promise.resolve(),
};
const headers = {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
@@ -391,20 +355,30 @@ describe('Auth Adapter features', () => {
const authData = user.get('authData').modernAdapter3;
expect(authData).toEqual({ foo: 'bar' });
for (const call of afterSpy.calls.all()) {
const args = call.args[2];
const args = call.args[0];
if (args.user) {
user._objCount = args.user._objCount;
break;
}
}
expect(afterSpy).toHaveBeenCalledWith(
{ id: 'modernAdapter3Data' },
undefined,
{ ip: '127.0.0.1', user, master: false },
{ id: 'modernAdapter3Data' },
undefined
);
expect(spy).toHaveBeenCalled();
});
it('should throw if no triggers found', async () => {
await reconfigureServer({ auth: { wrongAdapter } });
const user = new Parse.User();
await expectAsync(
user.save({ authData: { wrongAdapter: { id: 'wrongAdapter' } } })
).toBeRejectedWithError(
'Adapter is not configured. Implement either validateAuthData or all of the following: validateSetUp, validateLogin and validateUpdate'
);
});
it('should throw if policy does not match one of default/solo/additional', async () => {
const adapterWithBadPolicy = {
validateAppId: () => Promise.resolve(),
@@ -513,33 +487,6 @@ describe('Auth Adapter features', () => {
expect(baseAdapter2.validateAuthData).toHaveBeenCalledTimes(2);
});
it('should not perform authData validation twice when data mutated', async () => {
spyOn(baseAdapter, 'validateAuthData').and.resolveTo({});
await reconfigureServer({
auth: { baseAdapter },
allowExpiredAuthDataToken: false,
});
const user = new Parse.User();
await user.save({
authData: {
baseAdapter: { id: 'baseAdapter', token: "sometoken1" },
},
});
expect(baseAdapter.validateAuthData).toHaveBeenCalledTimes(1);
const user2 = new Parse.User();
await user2.save({
authData: {
baseAdapter: { id: 'baseAdapter', token: "sometoken2" },
},
});
expect(baseAdapter.validateAuthData).toHaveBeenCalledTimes(2);
});
it('should require additional provider if configured', async () => {
await reconfigureServer({
auth: { baseAdapter, additionalAdapter },
@@ -1338,368 +1285,4 @@ describe('Auth Adapter features', () => {
await user.fetch({ useMasterKey: true });
expect(user.get('authData')).toEqual({ adapterB: { id: 'test' } });
});
it('should unlink a code-based auth provider without triggering adapter validation', async () => {
const mockUserId = 'gpgamesUser123';
const mockAccessToken = 'mockAccessToken';
const otherAdapter = {
validateAppId: () => Promise.resolve(),
validateAuthData: () => Promise.resolve(),
};
mockFetch([
{
url: 'https://oauth2.googleapis.com/token',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({ access_token: mockAccessToken }),
},
},
{
url: `https://www.googleapis.com/games/v1/players/${mockUserId}`,
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({ playerId: mockUserId }),
},
},
]);
await reconfigureServer({
auth: {
gpgames: {
clientId: 'testClientId',
clientSecret: 'testClientSecret',
},
otherAdapter,
},
});
// Sign up with username/password, then link providers
const user = new Parse.User();
await user.signUp({ username: 'gpgamesTestUser', password: 'password123' });
// Link gpgames code-based provider
await user.save({
authData: {
gpgames: { id: mockUserId, code: 'authCode123', redirect_uri: 'https://example.com/callback' },
},
});
// Link a second provider
await user.save({ authData: { otherAdapter: { id: 'other1' } } });
// Reset fetch spy to track calls during unlink
global.fetch.calls.reset();
// Unlink gpgames by setting authData to null; should not call beforeFind / external APIs
const sessionToken = user.getSessionToken();
await user.save({ authData: { gpgames: null } }, { sessionToken });
// No external HTTP calls should have been made during unlink
expect(global.fetch.calls.count()).toBe(0);
// Verify gpgames was removed while the other provider remains
await user.fetch({ useMasterKey: true });
const authData = user.get('authData');
expect(authData).toBeDefined();
expect(authData.gpgames).toBeUndefined();
expect(authData.otherAdapter).toEqual({ id: 'other1' });
});
it('should unlink one code-based provider while echoing back another unchanged', async () => {
const gpgamesUserId = 'gpgamesUser1';
const instagramUserId = 'igUser1';
// Mock gpgames API for initial login
mockFetch([
{
url: 'https://oauth2.googleapis.com/token',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({ access_token: 'gpgamesToken' }),
},
},
{
url: `https://www.googleapis.com/games/v1/players/${gpgamesUserId}`,
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({ playerId: gpgamesUserId }),
},
},
]);
await reconfigureServer({
auth: {
gpgames: {
clientId: 'testClientId',
clientSecret: 'testClientSecret',
},
instagram: {
clientId: 'testClientId',
clientSecret: 'testClientSecret',
redirectUri: 'https://example.com/callback',
},
},
});
// Login with gpgames
const user = await Parse.User.logInWith('gpgames', {
authData: { id: gpgamesUserId, code: 'gpCode1', redirect_uri: 'https://example.com/callback' },
});
const sessionToken = user.getSessionToken();
// Mock instagram API for linking
mockFetch([
{
url: 'https://api.instagram.com/oauth/access_token',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({ access_token: 'igToken' }),
},
},
{
url: `https://graph.instagram.com/me?fields=id&access_token=igToken`,
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({ id: instagramUserId }),
},
},
]);
// Link instagram as second provider
await user.save(
{ authData: { instagram: { id: instagramUserId, code: 'igCode1' } } },
{ sessionToken }
);
// Fetch to get current authData (afterFind strips credentials, leaving only { id })
await user.fetch({ sessionToken });
const currentAuthData = user.get('authData');
expect(currentAuthData.gpgames).toBeDefined();
expect(currentAuthData.instagram).toBeDefined();
// Reset fetch spy
global.fetch.calls.reset();
// Unlink gpgames while echoing back instagram unchanged — the common client pattern:
// fetch current state, spread it, set the one to unlink to null
user.set('authData', { ...currentAuthData, gpgames: null });
await user.save(null, { sessionToken });
// No external HTTP calls during unlink (no code exchange for unchanged instagram)
expect(global.fetch.calls.count()).toBe(0);
// Verify gpgames removed, instagram preserved
await user.fetch({ useMasterKey: true });
const finalAuthData = user.get('authData');
expect(finalAuthData).toBeDefined();
expect(finalAuthData.gpgames).toBeUndefined();
expect(finalAuthData.instagram).toBeDefined();
expect(finalAuthData.instagram.id).toBe(instagramUserId);
});
it('should reject changing an existing code-based provider id without credentials', async () => {
const mockUserId = 'gpgamesUser123';
const mockAccessToken = 'mockAccessToken';
mockFetch([
{
url: 'https://oauth2.googleapis.com/token',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({ access_token: mockAccessToken }),
},
},
{
url: `https://www.googleapis.com/games/v1/players/${mockUserId}`,
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({ playerId: mockUserId }),
},
},
]);
await reconfigureServer({
auth: {
gpgames: {
clientId: 'testClientId',
clientSecret: 'testClientSecret',
},
},
});
// Sign up and link gpgames with valid credentials
const user = new Parse.User();
await user.save({
authData: {
gpgames: { id: mockUserId, code: 'authCode123', redirect_uri: 'https://example.com/callback' },
},
});
const sessionToken = user.getSessionToken();
// Attempt to change gpgames id without credentials (no code or access_token)
await expectAsync(
user.save({ authData: { gpgames: { id: 'differentUserId' } } }, { sessionToken })
).toBeRejectedWith(
jasmine.objectContaining({ message: jasmine.stringContaining('code is required') })
);
});
it('should reject linking a new code-based provider with only an id and no credentials', async () => {
await reconfigureServer({
auth: {
gpgames: {
clientId: 'testClientId',
clientSecret: 'testClientSecret',
},
},
});
// Sign up with username/password (no gpgames linked)
const user = new Parse.User();
await user.signUp({ username: 'linkTestUser', password: 'password123' });
const sessionToken = user.getSessionToken();
// Attempt to link gpgames with only { id } — no code or access_token
await expectAsync(
user.save({ authData: { gpgames: { id: 'victimUserId' } } }, { sessionToken })
).toBeRejectedWith(
jasmine.objectContaining({ message: jasmine.stringContaining('code is required') })
);
});
it('should handle multiple providers: add one while another remains unchanged (code-based)', async () => {
await reconfigureServer({
auth: {
codeBasedAdapter,
simpleAdapter,
},
});
// Login with code-based provider
const user = new Parse.User();
await user.save({ authData: { codeBasedAdapter: { id: 'user1', code: 'code1' } } });
const sessionToken = user.getSessionToken();
await user.fetch({ sessionToken });
// At this point, authData.codeBasedAdapter only has {id: 'user1'} due to afterFind
const current = user.get('authData') || {};
expect(current.codeBasedAdapter).toEqual({ id: 'user1' });
// Add a second provider while keeping the first unchanged
user.set('authData', {
...current,
simpleAdapter: { id: 'simple1' },
// codeBasedAdapter is NOT modified (no new code provided)
});
// This should succeed without requiring 'code' for codeBasedAdapter
await user.save(null, { sessionToken });
// Verify both providers are present
const reloaded = await new Parse.Query(Parse.User).get(user.id, {
useMasterKey: true,
});
const authData = reloaded.get('authData') || {};
expect(authData.simpleAdapter && authData.simpleAdapter.id).toBe('simple1');
expect(authData.codeBasedAdapter && authData.codeBasedAdapter.id).toBe('user1');
});
describe('authData dot-notation injection and login crash', () => {
it('rejects dotted update key that targets authData sub-field', async () => {
const user = new Parse.User();
user.setUsername('dotuser');
user.setPassword('pass1234');
await user.signUp();
const res = await request({
method: 'PUT',
url: `http://localhost:8378/1/users/${user.id}`,
headers: {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'X-Parse-Session-Token': user.getSessionToken(),
},
body: JSON.stringify({ 'authData.anonymous".id': 'injected' }),
}).catch(e => e);
expect(res.status).toBe(400);
});
it('login does not crash when stored authData has unknown provider', async () => {
const user = new Parse.User();
user.setUsername('dotuser2');
user.setPassword('pass1234');
await user.signUp();
await Parse.User.logOut();
// Inject unknown provider directly in database to simulate corrupted data
const config = Config.get('test');
await config.database.update(
'_User',
{ objectId: user.id },
{ authData: { unknown_provider: { id: 'bad' } } }
);
// Login should not crash with 500
const login = await request({
method: 'GET',
url: `http://localhost:8378/1/login?username=dotuser2&password=pass1234`,
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
},
}).catch(e => e);
expect(login.status).toBe(200);
expect(login.data.sessionToken).toBeDefined();
});
});
describe('challenge endpoint authData provider value validation', () => {
it('rejects challenge request with null provider value without 500', async () => {
const res = await request({
method: 'POST',
url: 'http://localhost:8378/1/challenge',
headers: {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
},
body: JSON.stringify({
authData: { anonymous: null },
challengeData: { anonymous: { token: '123456' } },
}),
}).catch(e => e);
expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.status).toBeLessThan(500);
});
it('rejects challenge request with non-object provider value without 500', async () => {
const res = await request({
method: 'POST',
url: 'http://localhost:8378/1/challenge',
headers: {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
},
body: JSON.stringify({
authData: { anonymous: 'string_value' },
challengeData: { anonymous: { token: '123456' } },
}),
}).catch(e => e);
expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.status).toBeLessThan(500);
});
});
});
+54 -64
View File
@@ -1,5 +1,5 @@
'use strict';
let commander;
const commander = require('../lib/cli/utils/commander').default;
const definitions = require('../lib/cli/definitions/parse-server').default;
const liveQueryDefinitions = require('../lib/cli/definitions/parse-live-query-server').default;
const path = require('path');
@@ -28,12 +28,6 @@ const testDefinitions = {
};
describe('commander additions', () => {
beforeEach(() => {
const command = require('../lib/cli/utils/commander').default;
commander = new command.constructor();
commander.storeOptionsAsProperties();
commander.allowExcessArguments();
});
afterEach(done => {
commander.options = [];
delete commander.arg0;
@@ -213,36 +207,6 @@ describe('LiveQuery definitions', () => {
describe('execution', () => {
const binPath = path.resolve(__dirname, '../bin/parse-server');
let childProcess;
let aggregatedData;
function handleStdout(childProcess, done, aggregatedData, requiredData) {
childProcess.stdout.on('data', data => {
data = data.toString();
aggregatedData.push(data);
if (requiredData.every(required => aggregatedData.some(aggregated => aggregated.includes(required)))) {
done();
}
});
}
function handleStderr(childProcess, done) {
childProcess.stderr.on('data', data => {
data = data.toString();
if (!data.includes('[DEP0040] DeprecationWarning')) {
done.fail(data);
}
});
}
function handleError(childProcess, done) {
childProcess.on('error', err => {
done.fail(err);
});
}
beforeEach(() => {
aggregatedData = [];
});
afterEach(done => {
if (childProcess) {
@@ -254,22 +218,28 @@ describe('execution', () => {
}
});
it_id('a0ab74b4-f805-4e03-b31d-b5cd59e64495')(it)('should start Parse Server', done => {
it('should start Parse Server', done => {
const env = { ...process.env };
env.NODE_OPTIONS = '--dns-result-order=ipv4first --trace-deprecation';
env.NODE_OPTIONS = '--dns-result-order=ipv4first';
childProcess = spawn(
binPath,
['--appId', 'test', '--masterKey', 'test', '--databaseURI', databaseURI, '--port', '1339'],
{ env }
);
handleStdout(childProcess, done, aggregatedData, ['parse-server running on']);
handleStderr(childProcess, done);
handleError(childProcess, done);
childProcess.stdout.on('data', data => {
data = data.toString();
if (data.includes('parse-server running on')) {
done();
}
});
childProcess.stderr.on('data', data => {
done.fail(data.toString());
});
});
it_id('d7165081-b133-4cba-901b-19128ce41301')(it)('should start Parse Server with GraphQL', async done => {
it('should start Parse Server with GraphQL', async done => {
const env = { ...process.env };
env.NODE_OPTIONS = '--dns-result-order=ipv4first --trace-deprecation';
env.NODE_OPTIONS = '--dns-result-order=ipv4first';
childProcess = spawn(
binPath,
[
@@ -285,17 +255,23 @@ describe('execution', () => {
],
{ env }
);
handleStdout(childProcess, done, aggregatedData, [
'parse-server running on',
'GraphQL running on',
]);
handleStderr(childProcess, done);
handleError(childProcess, done);
let output = '';
childProcess.stdout.on('data', data => {
data = data.toString();
output += data;
if (data.includes('GraphQL running on')) {
expect(output).toMatch('parse-server running on');
done();
}
});
childProcess.stderr.on('data', data => {
done.fail(data.toString());
});
});
it_id('2769cdb4-ce8a-484d-8a91-635b5894ba7e')(it)('should start Parse Server with GraphQL and Playground', async done => {
it('should start Parse Server with GraphQL and Playground', async done => {
const env = { ...process.env };
env.NODE_OPTIONS = '--dns-result-order=ipv4first --trace-deprecation';
env.NODE_OPTIONS = '--dns-result-order=ipv4first';
childProcess = spawn(
binPath,
[
@@ -312,25 +288,39 @@ describe('execution', () => {
],
{ env }
);
handleStdout(childProcess, done, aggregatedData, [
'parse-server running on',
'Playground running on',
'GraphQL running on',
]);
handleStderr(childProcess, done);
handleError(childProcess, done);
let output = '';
childProcess.stdout.on('data', data => {
data = data.toString();
output += data;
if (data.includes('Playground running on')) {
expect(output).toMatch('GraphQL running on');
expect(output).toMatch('parse-server running on');
done();
}
});
childProcess.stderr.on('data', data => {
done.fail(data.toString());
});
});
it_id('23caddd7-bfea-4869-8bd4-0f2cd283c8bd')(it)('can start Parse Server with auth via CLI', done => {
it('can start Parse Server with auth via CLI', done => {
const env = { ...process.env };
env.NODE_OPTIONS = '--dns-result-order=ipv4first --trace-deprecation';
env.NODE_OPTIONS = '--dns-result-order=ipv4first';
childProcess = spawn(
binPath,
['--databaseURI', databaseURI, './spec/configs/CLIConfigAuth.json'],
{ env }
);
handleStdout(childProcess, done, aggregatedData, ['parse-server running on']);
handleStderr(childProcess, done);
handleError(childProcess, done);
childProcess.stdout.on('data', data => {
data = data.toString();
console.log(data);
if (data.includes('parse-server running on')) {
done();
}
});
childProcess.stderr.on('data', data => {
data = data.toString();
done.fail(data.toString());
});
});
});
+23 -73
View File
@@ -6,9 +6,6 @@ const validatorFail = () => {
const validatorSuccess = () => {
return true;
};
function testConfig() {
return Parse.Config.save({ internal: 'i', string: 's', number: 12 }, { internal: true });
}
describe('cloud validator', () => {
it('complete validator', async done => {
@@ -197,6 +194,27 @@ describe('cloud validator', () => {
});
});
it('set params on cloud functions', done => {
Parse.Cloud.define(
'hello',
() => {
return 'Hello world!';
},
{
fields: ['a'],
}
);
Parse.Cloud.run('hello', {})
.then(() => {
fail('function should have failed.');
})
.catch(error => {
expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR);
expect(error.message).toEqual('Validation failed. Please specify data for a.');
done();
});
});
it('allow params on cloud functions', done => {
Parse.Cloud.define(
'hello',
@@ -734,38 +752,6 @@ describe('cloud validator', () => {
done();
});
it_id('893eec0c-41bd-4adf-8f0a-306087ad8d61')(it)('basic beforeSave Parse.Config skipWithMasterKey', async () => {
Parse.Cloud.beforeSave(
Parse.Config,
() => {
throw 'beforeSaveFile should have resolved using master key.';
},
{
skipWithMasterKey: true,
}
);
const config = await testConfig();
expect(config.get('internal')).toBe('i');
expect(config.get('string')).toBe('s');
expect(config.get('number')).toBe(12);
});
it_id('91e739a4-6a38-405c-8f83-f36d48220734')(it)('basic afterSave Parse.Config skipWithMasterKey', async () => {
Parse.Cloud.afterSave(
Parse.Config,
() => {
throw 'beforeSaveFile should have resolved using master key.';
},
{
skipWithMasterKey: true,
}
);
const config = await testConfig();
expect(config.get('internal')).toBe('i');
expect(config.get('string')).toBe('s');
expect(config.get('number')).toBe(12);
});
it('beforeSave validateMasterKey and skipWithMasterKey fail', async function (done) {
Parse.Cloud.beforeSave(
'BeforeSave',
@@ -1476,7 +1462,7 @@ describe('cloud validator', () => {
});
it('validate afterSaveFile fail', async done => {
Parse.Cloud.afterSave(Parse.File, () => {}, validatorFail);
Parse.Cloud.beforeSave(Parse.File, () => {}, validatorFail);
try {
const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain');
await file.save({ useMasterKey: true });
@@ -1531,42 +1517,6 @@ describe('cloud validator', () => {
}
});
it_id('32ca1a99-7f2b-429d-a7cf-62b6661d0af6')(it)('validate beforeSave Parse.Config', async () => {
Parse.Cloud.beforeSave(Parse.Config, () => {}, validatorSuccess);
const config = await testConfig();
expect(config.get('internal')).toBe('i');
expect(config.get('string')).toBe('s');
expect(config.get('number')).toBe(12);
});
it_id('c84d11e7-d09c-4843-ad98-f671511bf612')(it)('validate beforeSave Parse.Config fail', async () => {
Parse.Cloud.beforeSave(Parse.Config, () => {}, validatorFail);
try {
await testConfig();
fail('cloud function should have failed.');
} catch (e) {
expect(e.code).toBe(Parse.Error.VALIDATION_ERROR);
}
});
it_id('b18b9a6a-0e35-4b60-9771-30f53501df3c')(it)('validate afterSave Parse.Config', async () => {
Parse.Cloud.afterSave(Parse.Config, () => {}, validatorSuccess);
const config = await testConfig();
expect(config.get('internal')).toBe('i');
expect(config.get('string')).toBe('s');
expect(config.get('number')).toBe(12);
});
it_id('ef761222-1758-4614-b984-da84d73fc10c')(it)('validate afterSave Parse.Config fail', async () => {
Parse.Cloud.afterSave(Parse.Config, () => {}, validatorFail);
try {
await testConfig();
fail('cloud function should have failed.');
} catch (e) {
expect(e.code).toBe(Parse.Error.VALIDATION_ERROR);
}
});
it('Should have validator', async done => {
Parse.Cloud.define(
'myFunction',
@@ -1679,7 +1629,7 @@ describe('cloud validator', () => {
}
});
it('Logs on multiple invalid configs', () => {
it('Logs on invalid config', () => {
const fields = [
{
field: 'otherKey',
+121 -1193
View File
File diff suppressed because it is too large Load Diff
+18 -82
View File
@@ -15,17 +15,10 @@ describe('Cloud Code Logger', () => {
// useful to flip to false for fine tuning :).
silent: true,
logLevel: undefined,
logLevels: {
cloudFunctionError: 'error',
cloudFunctionSuccess: 'info',
triggerAfter: 'info',
triggerBeforeError: 'error',
triggerBeforeSuccess: 'info',
},
})
.then(() => {
return Parse.User.signUp('tester', 'abc')
.catch(() => { })
.catch(() => {})
.then(loggedInUser => (user = loggedInUser))
.then(() => Parse.User.logIn(user.get('username'), 'abc'));
})
@@ -37,7 +30,7 @@ describe('Cloud Code Logger', () => {
// Note that helpers takes care of logout.
// see helpers.js:afterEach
it_id('02d53b97-3ec7-46fb-abb6-176fd6e85590')(it)('should expose log to functions', () => {
it('should expose log to functions', () => {
const spy = spyOn(Config.get('test').loggerController, 'log').and.callThrough();
Parse.Cloud.define('loggerTest', req => {
req.log.info('logTest', 'info log', { info: 'some log' });
@@ -67,7 +60,7 @@ describe('Cloud Code Logger', () => {
});
});
it_id('768412f5-d32f-4134-89a6-08949781a6c0')(it)('trigger should obfuscate password', done => {
it('trigger should obfuscate password', done => {
Parse.Cloud.beforeSave(Parse.User, req => {
return req.object;
});
@@ -82,7 +75,7 @@ describe('Cloud Code Logger', () => {
.then(null, e => done.fail(e));
});
it_id('3c394047-272e-4728-9d02-9eaa660d2ed2')(it)('should expose log to trigger', done => {
it('should expose log to trigger', done => {
Parse.Cloud.beforeSave('MyObject', req => {
req.log.info('beforeSave MyObject', 'info log', { info: 'some log' });
req.log.error('beforeSave MyObject', 'error log', {
@@ -120,7 +113,7 @@ describe('Cloud Code Logger', () => {
expect(truncatedString.length).toBe(1015); // truncate length + the string '... (truncated)'
});
it_id('4a009b1f-9203-49ca-8d48-5b45f4eedbdf')(it)('should truncate input and result of long lines', done => {
it('should truncate input and result of long lines', done => {
const longString = fs.readFileSync(loremFile, 'utf8');
Parse.Cloud.define('aFunction', req => {
return req.params;
@@ -138,8 +131,8 @@ describe('Cloud Code Logger', () => {
.then(null, e => done.fail(e));
});
it_id('9857e15d-bb18-478d-8a67-fdaad3e89565')(it)('should log an afterSave', done => {
Parse.Cloud.afterSave('MyObject', () => { });
it('should log an afterSave', done => {
Parse.Cloud.afterSave('MyObject', () => {});
new Parse.Object('MyObject')
.save()
.then(() => {
@@ -151,7 +144,7 @@ describe('Cloud Code Logger', () => {
.then(null, e => done.fail(e));
});
it_id('ec13a296-f8b1-4fc6-985a-3593462edd9c')(it)('should log a denied beforeSave', done => {
it('should log a denied beforeSave', done => {
Parse.Cloud.beforeSave('MyObject', () => {
throw 'uh oh!';
});
@@ -174,7 +167,7 @@ describe('Cloud Code Logger', () => {
});
});
it_id('3e0caa45-60d6-41af-829a-fd389710c132')(it)('should log cloud function success', done => {
it('should log cloud function success', done => {
Parse.Cloud.define('aFunction', () => {
return 'it worked!';
});
@@ -189,7 +182,7 @@ describe('Cloud Code Logger', () => {
});
});
it_id('8088de8a-7cba-4035-8b05-4a903307e674')(it)('should log cloud function execution using the custom log level', async () => {
it('should log cloud function execution using the custom log level', async done => {
Parse.Cloud.define('aFunction', () => {
return 'it worked!';
});
@@ -203,7 +196,6 @@ describe('Cloud Code Logger', () => {
expect(log).toEqual('info');
});
Parse.Cloud._removeAllHooks();
await reconfigureServer({
silent: true,
logLevels: {
@@ -212,10 +204,6 @@ describe('Cloud Code Logger', () => {
},
});
Parse.Cloud.define('bFunction', () => {
throw new Error('Failed');
});
spy = spyOn(Config.get('test').loggerController.adapter, 'log').and.callThrough();
try {
@@ -226,12 +214,15 @@ describe('Cloud Code Logger', () => {
.allArgs()
.find(log => log[1].startsWith('Failed running cloud function bFunction for '))?.[0];
expect(log).toEqual('info');
done();
}
});
it('should log cloud function triggers using the custom log level', async () => {
Parse.Cloud.beforeSave('TestClass', () => {});
Parse.Cloud.afterSave('TestClass', () => {});
const execTest = async (logLevel, triggerBeforeSuccess, triggerAfter) => {
Parse.Cloud._removeAllHooks();
await reconfigureServer({
silent: true,
logLevel,
@@ -241,9 +232,6 @@ describe('Cloud Code Logger', () => {
},
});
Parse.Cloud.beforeSave('TestClass', () => { });
Parse.Cloud.afterSave('TestClass', () => { });
spy = spyOn(Config.get('test').loggerController.adapter, 'log').and.callThrough();
const obj = new Parse.Object('TestClass');
await obj.save();
@@ -265,13 +253,13 @@ describe('Cloud Code Logger', () => {
expect(calls).toEqual({ beforeSave: 'warn', afterSave: undefined });
});
it_id('97e0eafa-cde6-4a9a-9e53-7db98bacbc62')(it)('should log cloud function failure', done => {
it('should log cloud function failure', done => {
Parse.Cloud.define('aFunction', () => {
throw 'it failed!';
});
Parse.Cloud.run('aFunction', { foo: 'bar' })
.catch(() => { })
.catch(() => {})
.then(() => {
const logs = spy.calls.all().reverse();
expect(logs[0].args[1]).toBe('Parse error: ');
@@ -292,7 +280,6 @@ describe('Cloud Code Logger', () => {
});
xit('should log a changed beforeSave indicating a change', done => {
pending('needs more work.....');
const logController = new LoggerController(new WinstonLoggerAdapter());
Parse.Cloud.beforeSave('MyObject', req => {
@@ -315,9 +302,9 @@ describe('Cloud Code Logger', () => {
done();
})
.then(null, e => done.fail(JSON.stringify(e)));
});
}).pend('needs more work.....');
it_id('b86e8168-8370-4730-a4ba-24ca3016ad66')(it)('cloud function should obfuscate password', done => {
it('cloud function should obfuscate password', done => {
Parse.Cloud.define('testFunction', () => {
return 'verify code success';
});
@@ -347,55 +334,4 @@ describe('Cloud Code Logger', () => {
expect(args[0]).toBe('Parse error: ');
expect(args[1].message).toBe('Object not found.');
});
it('should log cloud function execution using the silent log level', async () => {
Parse.Cloud._removeAllHooks();
await reconfigureServer({
logLevels: {
cloudFunctionSuccess: 'silent',
cloudFunctionError: 'silent',
},
});
Parse.Cloud.define('aFunction', () => {
return 'it worked!';
});
Parse.Cloud.define('bFunction', () => {
throw new Error('Failed');
});
spy = spyOn(Config.get('test').loggerController.adapter, 'log').and.callThrough();
await Parse.Cloud.run('aFunction', { foo: 'bar' });
expect(spy).toHaveBeenCalledTimes(0);
await expectAsync(Parse.Cloud.run('bFunction', { foo: 'bar' })).toBeRejected();
// Not "Failed running cloud function message..."
expect(spy).toHaveBeenCalledTimes(1);
});
it('should log cloud function triggers using the silent log level', async () => {
Parse.Cloud._removeAllHooks();
await reconfigureServer({
logLevels: {
triggerAfter: 'silent',
triggerBeforeSuccess: 'silent',
triggerBeforeError: 'silent',
},
});
Parse.Cloud.beforeSave('TestClassError', () => {
throw new Error('Failed');
});
Parse.Cloud.beforeSave('TestClass', () => { });
Parse.Cloud.afterSave('TestClass', () => { });
spy = spyOn(Config.get('test').loggerController.adapter, 'log').and.callThrough();
const obj = new Parse.Object('TestClass');
await obj.save();
expect(spy).toHaveBeenCalledTimes(0);
const objError = new Parse.Object('TestClassError');
await expectAsync(objError.save()).toBeRejected();
// Not "beforeSave failed for TestClassError for user ..."
expect(spy).toHaveBeenCalledTimes(1);
});
});
-226
View File
@@ -413,13 +413,6 @@ describe('DatabaseController', function () {
case_insensitive_username: { username: 1 },
case_insensitive_email: { email: 1 },
email_1: { email: 1 },
_email_verify_token: { _email_verify_token: 1 },
_perishable_token: { _perishable_token: 1 },
_auth_data_custom_id: { '_auth_data_custom.id': 1 },
_auth_data_facebook_id: { '_auth_data_facebook.id': 1 },
_auth_data_myoauth_id: { '_auth_data_myoauth.id': 1 },
_auth_data_shortLivedAuth_id: { '_auth_data_shortLivedAuth.id': 1 },
_auth_data_anonymous_id: { '_auth_data_anonymous.id': 1 },
});
}
);
@@ -444,158 +437,9 @@ describe('DatabaseController', function () {
_id_: { _id: 1 },
username_1: { username: 1 },
email_1: { email: 1 },
_email_verify_token: { _email_verify_token: 1 },
_perishable_token: { _perishable_token: 1 },
_auth_data_custom_id: { '_auth_data_custom.id': 1 },
_auth_data_facebook_id: { '_auth_data_facebook.id': 1 },
_auth_data_myoauth_id: { '_auth_data_myoauth.id': 1 },
_auth_data_shortLivedAuth_id: { '_auth_data_shortLivedAuth.id': 1 },
_auth_data_anonymous_id: { '_auth_data_anonymous.id': 1 },
});
}
);
it_only_db('mongo')(
'should use _email_verify_token index in email verification',
async () => {
const TestUtils = require('../lib/TestUtils');
let emailVerificationLink;
const emailSentPromise = TestUtils.resolvingPromise();
const emailAdapter = {
sendVerificationEmail: options => {
emailVerificationLink = options.link;
emailSentPromise.resolve();
},
sendPasswordResetEmail: () => Promise.resolve(),
sendMail: () => {},
};
await reconfigureServer({
databaseURI: 'mongodb://localhost:27017/testEmailVerifyTokenIndexStats',
databaseAdapter: undefined,
appName: 'test',
verifyUserEmails: true,
emailAdapter: emailAdapter,
publicServerURL: 'http://localhost:8378/1',
});
// Create a user to trigger email verification
const user = new Parse.User();
user.setUsername('statsuser');
user.setPassword('password');
user.set('email', 'stats@example.com');
await user.signUp();
await emailSentPromise;
// Get index stats before the query
const config = Config.get(Parse.applicationId);
const collection = await config.database.adapter._adaptiveCollection('_User');
const statsBefore = await collection._mongoCollection.aggregate([
{ $indexStats: {} },
]).toArray();
const emailVerifyIndexBefore = statsBefore.find(
stat => stat.name === '_email_verify_token'
);
const accessesBefore = emailVerifyIndexBefore?.accesses?.ops || 0;
// Perform email verification (this should use the index)
const request = require('../lib/request');
await request({
url: emailVerificationLink,
followRedirects: false,
});
// Get index stats after the query
const statsAfter = await collection._mongoCollection.aggregate([
{ $indexStats: {} },
]).toArray();
const emailVerifyIndexAfter = statsAfter.find(
stat => stat.name === '_email_verify_token'
);
const accessesAfter = emailVerifyIndexAfter?.accesses?.ops || 0;
// Verify the index was actually used
expect(accessesAfter).toBeGreaterThan(accessesBefore);
expect(emailVerifyIndexAfter).toBeDefined();
// Verify email verification succeeded
await user.fetch();
expect(user.get('emailVerified')).toBe(true);
}
);
it_only_db('mongo')(
'should use _perishable_token index in password reset',
async () => {
const TestUtils = require('../lib/TestUtils');
let passwordResetLink;
const emailSentPromise = TestUtils.resolvingPromise();
const emailAdapter = {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: options => {
passwordResetLink = options.link;
emailSentPromise.resolve();
},
sendMail: () => {},
};
await reconfigureServer({
databaseURI: 'mongodb://localhost:27017/testPerishableTokenIndexStats',
databaseAdapter: undefined,
appName: 'test',
emailAdapter: emailAdapter,
publicServerURL: 'http://localhost:8378/1',
});
// Create a user
const user = new Parse.User();
user.setUsername('statsuser2');
user.setPassword('oldpassword');
user.set('email', 'stats2@example.com');
await user.signUp();
// Request password reset
await Parse.User.requestPasswordReset('stats2@example.com');
await emailSentPromise;
const url = new URL(passwordResetLink);
const token = url.searchParams.get('token');
// Get index stats before the query
const config = Config.get(Parse.applicationId);
const collection = await config.database.adapter._adaptiveCollection('_User');
const statsBefore = await collection._mongoCollection.aggregate([
{ $indexStats: {} },
]).toArray();
const perishableTokenIndexBefore = statsBefore.find(
stat => stat.name === '_perishable_token'
);
const accessesBefore = perishableTokenIndexBefore?.accesses?.ops || 0;
// Perform password reset (this should use the index)
const request = require('../lib/request');
await request({
method: 'POST',
url: 'http://localhost:8378/1/apps/test/request_password_reset',
body: { new_password: 'newpassword', token, username: 'statsuser2' },
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
followRedirects: false,
});
// Get index stats after the query
const statsAfter = await collection._mongoCollection.aggregate([
{ $indexStats: {} },
]).toArray();
const perishableTokenIndexAfter = statsAfter.find(
stat => stat.name === '_perishable_token'
);
const accessesAfter = perishableTokenIndexAfter?.accesses?.ops || 0;
// Verify the index was actually used
expect(accessesAfter).toBeGreaterThan(accessesBefore);
expect(perishableTokenIndexAfter).toBeDefined();
}
);
});
describe('convertEmailToLowercase', () => {
@@ -771,76 +615,6 @@ describe('DatabaseController', function () {
expect(result2.length).toEqual(1);
});
});
describe('update with validateOnly', () => {
const mockStorageAdapter = {
findOneAndUpdate: () => Promise.resolve({}),
find: () => Promise.resolve([{ objectId: 'test123', testField: 'initialValue' }]),
watch: () => Promise.resolve(),
getAllClasses: () =>
Promise.resolve([
{
className: 'TestObject',
fields: { testField: 'String' },
indexes: {},
classLevelPermissions: { protectedFields: {} },
},
]),
};
it('should use primary readPreference when validateOnly is true', async () => {
const databaseController = new DatabaseController(mockStorageAdapter, {});
const findSpy = spyOn(mockStorageAdapter, 'find').and.callThrough();
const findOneAndUpdateSpy = spyOn(mockStorageAdapter, 'findOneAndUpdate').and.callThrough();
try {
// Call update with validateOnly: true (same as RestWrite.runBeforeSaveTrigger)
await databaseController.update(
'TestObject',
{ objectId: 'test123' },
{ testField: 'newValue' },
{},
true, // skipSanitization: true (matches RestWrite behavior)
true // validateOnly: true
);
} catch (error) {
// validateOnly may throw, but we're checking the find call options
}
// Verify that find was called with primary readPreference
expect(findSpy).toHaveBeenCalled();
const findCall = findSpy.calls.mostRecent();
expect(findCall.args[3]).toEqual({ readPreference: 'primary' }); // options parameter
// Verify that findOneAndUpdate was NOT called (only validation, no actual update)
expect(findOneAndUpdateSpy).not.toHaveBeenCalled();
});
it('should not use primary readPreference when validateOnly is false', async () => {
const databaseController = new DatabaseController(mockStorageAdapter, {});
const findSpy = spyOn(mockStorageAdapter, 'find').and.callThrough();
const findOneAndUpdateSpy = spyOn(mockStorageAdapter, 'findOneAndUpdate').and.callThrough();
try {
// Call update with validateOnly: false
await databaseController.update(
'TestObject',
{ objectId: 'test123' },
{ testField: 'newValue' },
{},
false, // skipSanitization
false // validateOnly
);
} catch (error) {
// May throw for other reasons, but we're checking the call pattern
}
// When validateOnly is false, find should not be called for validation
// Instead, findOneAndUpdate should be called
expect(findSpy).not.toHaveBeenCalled();
expect(findOneAndUpdateSpy).toHaveBeenCalled();
});
});
});
function buildCLP(pointerNames) {
+2 -50
View File
@@ -371,7 +371,7 @@ describe('DefinedSchemas', () => {
expect(schema.indexes).toEqual(indexes);
});
it('should delete unknown indexes when keepUnknownIndexes is not set', async () => {
it('should delete removed indexes', async () => {
const server = await reconfigureServer();
let indexes = { complex: { createdAt: 1, updatedAt: 1 } };
@@ -393,53 +393,6 @@ describe('DefinedSchemas', () => {
cleanUpIndexes(schema);
expect(schema.indexes).toBeUndefined();
});
it('should delete unknown indexes when keepUnknownIndexes is set to false', async () => {
const server = await reconfigureServer();
let indexes = { complex: { createdAt: 1, updatedAt: 1 } };
let schemas = { definitions: [{ className: 'Test', indexes }], keepUnknownIndexes: false };
await new DefinedSchemas(schemas, server.config).execute();
indexes = {};
schemas = { definitions: [{ className: 'Test', indexes }], keepUnknownIndexes: false };
// Change indexes
await new DefinedSchemas(schemas, server.config).execute();
let schema = await new Parse.Schema('Test').get();
cleanUpIndexes(schema);
expect(schema.indexes).toBeUndefined();
// Update
await new DefinedSchemas(schemas, server.config).execute();
schema = await new Parse.Schema('Test').get();
cleanUpIndexes(schema);
expect(schema.indexes).toBeUndefined();
});
it('should not delete unknown indexes when keepUnknownIndexes is set to true', async () => {
const server = await reconfigureServer();
const indexes = { complex: { createdAt: 1, updatedAt: 1 } };
let schemas = { definitions: [{ className: 'Test', indexes }], keepUnknownIndexes: true };
await new DefinedSchemas(schemas, server.config).execute();
schemas = { definitions: [{ className: 'Test', indexes: {} }], keepUnknownIndexes: true };
// Change indexes
await new DefinedSchemas(schemas, server.config).execute();
let schema = await new Parse.Schema('Test').get();
cleanUpIndexes(schema);
expect(schema.indexes).toEqual({ complex: { createdAt: 1, updatedAt: 1 } });
// Update
await new DefinedSchemas(schemas, server.config).execute();
schema = await new Parse.Schema('Test').get();
cleanUpIndexes(schema);
expect(schema.indexes).toEqual(indexes);
});
xit('should keep protected indexes', async () => {
const server = await reconfigureServer();
@@ -689,8 +642,7 @@ describe('DefinedSchemas', () => {
expect(logger.error).toHaveBeenCalledWith(`Failed to run migrations: ${error.toString()}`);
});
it_id('a18bf4f2-25c8-4de3-b986-19cb1ab163b8')(it)('should perform migration in parallel without failing', async () => {
it('should perform migration in parallel without failing', async () => {
const server = await reconfigureServer();
const logger = require('../lib/logger').logger;
spyOn(logger, 'error').and.callThrough();
-178
View File
@@ -45,182 +45,4 @@ describe('Deprecator', () => {
`DeprecationWarning: ${options.usage} is deprecated and will be removed in a future version. ${options.solution}`
);
});
it('logs deprecation for nested option key with dot notation', async () => {
deprecations = [{ optionKey: 'databaseOptions.testOption', changeNewDefault: 'false' }];
spyOn(Deprecator, '_getDeprecations').and.callFake(() => deprecations);
const logger = require('../lib/logger').logger;
const logSpy = spyOn(logger, 'warn').and.callFake(() => {});
await reconfigureServer();
expect(logSpy.calls.all()[0].args[0]).toEqual(
`DeprecationWarning: The Parse Server option '${deprecations[0].optionKey}' default will change to '${deprecations[0].changeNewDefault}' in a future version.`
);
});
it('does not log deprecation for nested option key if option is set manually', async () => {
deprecations = [{ optionKey: 'databaseOptions.testOption', changeNewDefault: 'false' }];
spyOn(Deprecator, '_getDeprecations').and.callFake(() => deprecations);
const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {});
const Config = require('../lib/Config');
const config = Config.get('test');
// Directly test scanParseServerOptions with nested option set
Deprecator.scanParseServerOptions({ databaseOptions: { testOption: true } });
expect(logSpy).not.toHaveBeenCalled();
});
it('logs deprecation for allowedFileUrlDomains when not set', async () => {
const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {});
// Pass a fresh fileUpload object without allowedFileUrlDomains to avoid
// inheriting the mutated default from a previous reconfigureServer() call.
await reconfigureServer({
fileUpload: {
enableForPublic: true,
enableForAnonymousUser: true,
enableForAuthenticatedUser: true,
},
});
expect(logSpy).toHaveBeenCalledWith(
jasmine.objectContaining({
optionKey: 'fileUpload.allowedFileUrlDomains',
changeNewDefault: '[]',
})
);
});
it('does not log deprecation for allowedFileUrlDomains when explicitly set', async () => {
const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {});
await reconfigureServer({
fileUpload: { allowedFileUrlDomains: ['*'] },
});
expect(logSpy).not.toHaveBeenCalledWith(
jasmine.objectContaining({
optionKey: 'fileUpload.allowedFileUrlDomains',
})
);
});
it('logs deprecation for removed key when option is set', async () => {
deprecations = [{ optionKey: 'exampleKey', changeNewKey: '', solution: 'Use something else.' }];
spyOn(Deprecator, '_getDeprecations').and.callFake(() => deprecations);
const logger = require('../lib/logger').logger;
const logSpy = spyOn(logger, 'warn').and.callFake(() => {});
await reconfigureServer({ exampleKey: true });
expect(logSpy).toHaveBeenCalledWith(
`DeprecationWarning: The Parse Server option '${deprecations[0].optionKey}' is deprecated and will be removed in a future version. ${deprecations[0].solution}`
);
});
it('does not log deprecation for removed key when option is not set', async () => {
deprecations = [{ optionKey: 'exampleKey', changeNewKey: '', solution: 'Use something else.' }];
spyOn(Deprecator, '_getDeprecations').and.callFake(() => deprecations);
const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {});
await reconfigureServer();
expect(logSpy).not.toHaveBeenCalled();
});
it('logs deprecation for mountPlayground when set', async () => {
const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {});
await reconfigureServer({ mountPlayground: true, mountGraphQL: true });
expect(logSpy).toHaveBeenCalledWith(
jasmine.objectContaining({
optionKey: 'mountPlayground',
changeNewKey: '',
})
);
});
it('does not log deprecation for mountPlayground when not set', async () => {
const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {});
await reconfigureServer();
expect(logSpy).not.toHaveBeenCalledWith(
jasmine.objectContaining({
optionKey: 'mountPlayground',
})
);
});
it('logs deprecation for requestComplexity limits when not set', async () => {
const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {});
await reconfigureServer();
const keys = [
'requestComplexity.includeDepth',
'requestComplexity.includeCount',
'requestComplexity.subqueryDepth',
'requestComplexity.queryDepth',
'requestComplexity.graphQLDepth',
'requestComplexity.graphQLFields',
];
for (const key of keys) {
expect(logSpy).toHaveBeenCalledWith(
jasmine.objectContaining({
optionKey: key,
})
);
}
});
it('logs deprecation for enableProductPurchaseLegacyApi when set', async () => {
const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {});
await reconfigureServer({ enableProductPurchaseLegacyApi: true });
expect(logSpy).toHaveBeenCalledWith(
jasmine.objectContaining({
optionKey: 'enableProductPurchaseLegacyApi',
changeNewKey: '',
})
);
});
it('does not log deprecation for enableProductPurchaseLegacyApi when not set', async () => {
const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {});
await reconfigureServer();
expect(logSpy).not.toHaveBeenCalledWith(
jasmine.objectContaining({
optionKey: 'enableProductPurchaseLegacyApi',
})
);
});
it('does not log deprecation for requestComplexity limits when explicitly set', async () => {
const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {});
await reconfigureServer({
requestComplexity: {
includeDepth: 10,
includeCount: 100,
subqueryDepth: 10,
queryDepth: 10,
graphQLDepth: 20,
graphQLFields: 200,
},
});
const keys = [
'requestComplexity.includeDepth',
'requestComplexity.includeCount',
'requestComplexity.subqueryDepth',
'requestComplexity.queryDepth',
'requestComplexity.graphQLDepth',
'requestComplexity.graphQLFields',
];
for (const key of keys) {
expect(logSpy).not.toHaveBeenCalledWith(
jasmine.objectContaining({
optionKey: key,
})
);
}
});
});
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -3,9 +3,11 @@ const request = require('../lib/request');
describe('Enable express error handler', () => {
it('should call the default handler in case of error, like updating a non existing object', async done => {
spyOn(console, 'error');
const parseServer = await reconfigureServer({
enableExpressErrorHandler: true,
});
const parseServer = await reconfigureServer(
Object.assign({}, defaultConfiguration, {
enableExpressErrorHandler: true,
})
);
parseServer.app.use(function (err, req, res, next) {
expect(err.message).toBe('Object not found.');
next(err);
-141
View File
@@ -1,141 +0,0 @@
'use strict';
const { validateFileUrl, validateFileUrlsInObject } = require('../src/FileUrlValidator');
describe('FileUrlValidator', () => {
describe('validateFileUrl', () => {
it('allows null, undefined, and empty string URLs', () => {
const config = { fileUpload: { allowedFileUrlDomains: [] } };
expect(() => validateFileUrl(null, config)).not.toThrow();
expect(() => validateFileUrl(undefined, config)).not.toThrow();
expect(() => validateFileUrl('', config)).not.toThrow();
});
it('allows any URL when allowedFileUrlDomains contains wildcard', () => {
const config = { fileUpload: { allowedFileUrlDomains: ['*'] } };
expect(() => validateFileUrl('http://malicious.example.com/file.txt', config)).not.toThrow();
expect(() => validateFileUrl('http://malicious.example.com/leak', config)).not.toThrow();
});
it('allows any URL when allowedFileUrlDomains is not an array', () => {
expect(() => validateFileUrl('http://example.com/file', {})).not.toThrow();
expect(() => validateFileUrl('http://example.com/file', { fileUpload: {} })).not.toThrow();
expect(() => validateFileUrl('http://example.com/file', null)).not.toThrow();
});
it('rejects all URLs when allowedFileUrlDomains is empty', () => {
const config = { fileUpload: { allowedFileUrlDomains: [] } };
expect(() => validateFileUrl('http://example.com/file', config)).toThrowError(
/not allowed/
);
});
it('allows URLs matching exact hostname', () => {
const config = { fileUpload: { allowedFileUrlDomains: ['cdn.example.com'] } };
expect(() => validateFileUrl('https://cdn.example.com/files/test.txt', config)).not.toThrow();
});
it('rejects URLs not matching any allowed hostname', () => {
const config = { fileUpload: { allowedFileUrlDomains: ['cdn.example.com'] } };
expect(() => validateFileUrl('http://malicious.example.com/file', config)).toThrowError(
/not allowed/
);
});
it('supports wildcard subdomain matching', () => {
const config = { fileUpload: { allowedFileUrlDomains: ['*.example.com'] } };
expect(() => validateFileUrl('https://cdn.example.com/file.txt', config)).not.toThrow();
expect(() => validateFileUrl('https://us-east.cdn.example.com/file.txt', config)).not.toThrow();
expect(() => validateFileUrl('https://example.net/file.txt', config)).toThrowError(
/not allowed/
);
});
it('performs case-insensitive hostname matching', () => {
const config = { fileUpload: { allowedFileUrlDomains: ['CDN.Example.COM'] } };
expect(() => validateFileUrl('https://cdn.example.com/file.txt', config)).not.toThrow();
});
it('throws on invalid URL strings', () => {
const config = { fileUpload: { allowedFileUrlDomains: ['example.com'] } };
expect(() => validateFileUrl('not-a-url', config)).toThrowError(
/Invalid file URL/
);
});
it('supports multiple allowed domains', () => {
const config = { fileUpload: { allowedFileUrlDomains: ['cdn1.example.com', 'cdn2.example.com'] } };
expect(() => validateFileUrl('https://cdn1.example.com/file.txt', config)).not.toThrow();
expect(() => validateFileUrl('https://cdn2.example.com/file.txt', config)).not.toThrow();
expect(() => validateFileUrl('https://cdn3.example.com/file.txt', config)).toThrowError(
/not allowed/
);
});
it('does not allow partial hostname matches', () => {
const config = { fileUpload: { allowedFileUrlDomains: ['example.com'] } };
expect(() => validateFileUrl('https://notexample.com/file.txt', config)).toThrowError(
/not allowed/
);
expect(() => validateFileUrl('https://example.com.malicious.example.com/file.txt', config)).toThrowError(
/not allowed/
);
});
});
describe('validateFileUrlsInObject', () => {
const config = { fileUpload: { allowedFileUrlDomains: ['example.com'] } };
it('validates file URLs in flat objects', () => {
expect(() =>
validateFileUrlsInObject(
{ file: { __type: 'File', name: 'test.txt', url: 'http://malicious.example.com/file' } },
config
)
).toThrowError(/not allowed/);
});
it('validates file URLs in nested objects', () => {
expect(() =>
validateFileUrlsInObject(
{ nested: { deep: { file: { __type: 'File', name: 'test.txt', url: 'http://malicious.example.com/file' } } } },
config
)
).toThrowError(/not allowed/);
});
it('validates file URLs in arrays', () => {
expect(() =>
validateFileUrlsInObject(
[{ __type: 'File', name: 'test.txt', url: 'http://malicious.example.com/file' }],
config
)
).toThrowError(/not allowed/);
});
it('allows files without URLs', () => {
expect(() =>
validateFileUrlsInObject(
{ file: { __type: 'File', name: 'test.txt' } },
config
)
).not.toThrow();
});
it('allows files with permitted URLs', () => {
expect(() =>
validateFileUrlsInObject(
{ file: { __type: 'File', name: 'test.txt', url: 'http://example.com/file.txt' } },
config
)
).not.toThrow();
});
it('handles null, undefined, and primitive values', () => {
expect(() => validateFileUrlsInObject(null, config)).not.toThrow();
expect(() => validateFileUrlsInObject(undefined, config)).not.toThrow();
expect(() => validateFileUrlsInObject('string', config)).not.toThrow();
expect(() => validateFileUrlsInObject(42, config)).not.toThrow();
});
});
});
+13 -69
View File
@@ -21,14 +21,11 @@ const mockAdapter = {
// Small additional tests to improve overall coverage
describe('FilesController', () => {
it('should properly expand objects with sync getFileLocation', async () => {
it('should properly expand objects', done => {
const config = Config.get(Parse.applicationId);
const gridFSAdapter = new GridFSBucketAdapter('mongodb://localhost:27017/parse');
gridFSAdapter.getFileLocation = (config, filename) => {
return config.mount + '/files/' + config.applicationId + '/' + encodeURIComponent(filename);
}
const filesController = new FilesController(gridFSAdapter);
const result = await filesController.expandFilesInObject(config, function () { });
const result = filesController.expandFilesInObject(config, function () {});
expect(result).toBeUndefined();
@@ -40,69 +37,12 @@ describe('FilesController', () => {
const anObject = {
aFile: fullFile,
};
await filesController.expandFilesInObject(config, anObject);
filesController.expandFilesInObject(config, anObject);
expect(anObject.aFile.url).toEqual('http://an.url');
done();
});
it('should properly expand objects with async getFileLocation', async () => {
const config = Config.get(Parse.applicationId);
const gridFSAdapter = new GridFSBucketAdapter('mongodb://localhost:27017/parse');
gridFSAdapter.getFileLocation = async (config, filename) => {
await Promise.resolve();
return config.mount + '/files/' + config.applicationId + '/' + encodeURIComponent(filename);
}
const filesController = new FilesController(gridFSAdapter);
const result = await filesController.expandFilesInObject(config, function () { });
expect(result).toBeUndefined();
const fullFile = {
type: '__type',
url: 'http://an.url',
};
const anObject = {
aFile: fullFile,
};
await filesController.expandFilesInObject(config, anObject);
expect(anObject.aFile.url).toEqual('http://an.url');
});
it('should call getFileLocation when config.fileKey is undefined', async () => {
const config = {};
const gridFSAdapter = new GridFSBucketAdapter('mongodb://localhost:27017/parse');
const fullFile = {
name: 'mock-name',
__type: 'File',
};
gridFSAdapter.getFileLocation = jasmine.createSpy('getFileLocation').and.returnValue(Promise.resolve('mock-url'));
const filesController = new FilesController(gridFSAdapter);
const anObject = { aFile: fullFile };
await filesController.expandFilesInObject(config, anObject);
expect(gridFSAdapter.getFileLocation).toHaveBeenCalledWith(config, fullFile.name);
expect(anObject.aFile.url).toEqual('mock-url');
});
it('should call getFileLocation when config.fileKey is defined', async () => {
const config = { fileKey: 'mock-key' };
const gridFSAdapter = new GridFSBucketAdapter('mongodb://localhost:27017/parse');
const fullFile = {
name: 'mock-name',
__type: 'File',
};
gridFSAdapter.getFileLocation = jasmine.createSpy('getFileLocation').and.returnValue(Promise.resolve('mock-url'));
const filesController = new FilesController(gridFSAdapter);
const anObject = { aFile: fullFile };
await filesController.expandFilesInObject(config, anObject);
expect(gridFSAdapter.getFileLocation).toHaveBeenCalledWith(config, fullFile.name);
expect(anObject.aFile.url).toEqual('mock-url');
});
it_only_db('mongo')('should pass databaseOptions to GridFSBucketAdapter', async () => {
await reconfigureServer({
databaseURI: 'mongodb://localhost:27017/parse',
@@ -161,7 +101,7 @@ describe('FilesController', () => {
done();
});
it('should add a unique hash to the file name when the preserveFileName option is false', async () => {
it('should add a unique hash to the file name when the preserveFileName option is false', done => {
const config = Config.get(Parse.applicationId);
const gridFSAdapter = new GridFSBucketAdapter('mongodb://localhost:27017/parse');
spyOn(gridFSAdapter, 'createFile');
@@ -172,15 +112,17 @@ describe('FilesController', () => {
preserveFileName: false,
});
await filesController.createFile(config, fileName);
filesController.createFile(config, fileName);
expect(gridFSAdapter.createFile).toHaveBeenCalledTimes(1);
expect(gridFSAdapter.createFile.calls.mostRecent().args[0]).toMatch(
`^.{32}_${regexEscapedFileName}$`
);
done();
});
it('should not add a unique hash to the file name when the preserveFileName option is true', async () => {
it('should not add a unique hash to the file name when the preserveFileName option is true', done => {
const config = Config.get(Parse.applicationId);
const gridFSAdapter = new GridFSBucketAdapter('mongodb://localhost:27017/parse');
spyOn(gridFSAdapter, 'createFile');
@@ -190,10 +132,12 @@ describe('FilesController', () => {
preserveFileName: true,
});
await filesController.createFile(config, fileName);
filesController.createFile(config, fileName);
expect(gridFSAdapter.createFile).toHaveBeenCalledTimes(1);
expect(gridFSAdapter.createFile.calls.mostRecent().args[0]).toEqual(fileName);
done();
});
it('should handle adapter without getMetadata', async () => {
-215
View File
@@ -1,215 +0,0 @@
'use strict';
const http = require('http');
const express = require('express');
const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args));
require('./helper');
const { ParseGraphQLServer } = require('../lib/GraphQL/ParseGraphQLServer');
describe('graphql query complexity', () => {
let httpServer;
let graphQLServer;
const headers = {
'X-Parse-Application-Id': 'test',
'X-Parse-Javascript-Key': 'test',
'Content-Type': 'application/json',
};
async function setupGraphQL(serverOptions = {}) {
if (httpServer) {
await new Promise(resolve => httpServer.close(resolve));
}
const server = await reconfigureServer(serverOptions);
const expressApp = express();
httpServer = http.createServer(expressApp);
expressApp.use('/parse', server.app);
graphQLServer = new ParseGraphQLServer(server, {
graphQLPath: '/graphql',
});
graphQLServer.applyGraphQL(expressApp);
await new Promise(resolve => httpServer.listen({ port: 13378 }, resolve));
}
async function graphqlRequest(query, requestHeaders = headers) {
const response = await fetch('http://localhost:13378/graphql', {
method: 'POST',
headers: requestHeaders,
body: JSON.stringify({ query }),
});
return response.json();
}
// Returns a query with depth 4: users(1) > edges(2) > node(3) > objectId(4)
function buildDeepQuery() {
return '{ users { edges { node { objectId } } } }';
}
function buildWideQuery(fieldCount) {
const fields = Array.from({ length: fieldCount }, (_, i) => `field${i}: objectId`).join('\n ');
return `{ users { edges { node { ${fields} } } } }`;
}
afterEach(async () => {
if (httpServer) {
await new Promise(resolve => httpServer.close(resolve));
httpServer = null;
}
});
describe('depth limit', () => {
it('should reject query exceeding depth limit', async () => {
await setupGraphQL({
requestComplexity: { graphQLDepth: 3 },
});
const result = await graphqlRequest(buildDeepQuery());
expect(result.errors).toBeDefined();
expect(result.errors[0].message).toMatch(
/GraphQL query depth of \d+ exceeds maximum allowed depth of 3/
);
});
it('should allow query within depth limit', async () => {
await setupGraphQL({
requestComplexity: { graphQLDepth: 10 },
});
const result = await graphqlRequest(buildDeepQuery());
expect(result.errors).toBeUndefined();
});
it('should allow deep query with master key', async () => {
await setupGraphQL({
requestComplexity: { graphQLDepth: 3 },
});
const result = await graphqlRequest(buildDeepQuery(), {
...headers,
'X-Parse-Master-Key': 'test',
});
expect(result.errors).toBeUndefined();
});
it('should allow unlimited depth when graphQLDepth is -1', async () => {
await setupGraphQL({
requestComplexity: { graphQLDepth: -1 },
});
const result = await graphqlRequest(buildDeepQuery());
expect(result.errors).toBeUndefined();
});
});
describe('fields limit', () => {
it('should reject query exceeding fields limit', async () => {
await setupGraphQL({
requestComplexity: { graphQLFields: 5 },
});
const result = await graphqlRequest(buildWideQuery(10));
expect(result.errors).toBeDefined();
expect(result.errors[0].message).toMatch(
/Number of GraphQL fields \(\d+\) exceeds maximum allowed \(5\)/
);
});
it('should allow query within fields limit', async () => {
await setupGraphQL({
requestComplexity: { graphQLFields: 200 },
});
const result = await graphqlRequest(buildDeepQuery());
expect(result.errors).toBeUndefined();
});
it('should allow wide query with master key', async () => {
await setupGraphQL({
requestComplexity: { graphQLFields: 5 },
});
const result = await graphqlRequest(buildWideQuery(10), {
...headers,
'X-Parse-Master-Key': 'test',
});
expect(result.errors).toBeUndefined();
});
it('should count fragment fields at each spread location', async () => {
// With correct counting: 2 aliases (2) + 2×edges (2) + 2×node (2) + 2×objectId from fragment (2) = 8
// With incorrect counting (fragment once): 2 + 2 + 2 + 1 = 7
// Set limit to 7 so incorrect counting passes but correct counting rejects
await setupGraphQL({
requestComplexity: { graphQLFields: 7 },
});
const result = await graphqlRequest(`
fragment UserFields on User { objectId }
{
a1: users { edges { node { ...UserFields } } }
a2: users { edges { node { ...UserFields } } }
}
`);
expect(result.errors).toBeDefined();
expect(result.errors[0].message).toMatch(
/Number of GraphQL fields \(\d+\) exceeds maximum allowed \(7\)/
);
});
it('should count inline fragment fields toward depth and field limits', async () => {
await setupGraphQL({
requestComplexity: { graphQLFields: 3 },
});
// Inline fragment adds fields without increasing depth:
// users(1) > edges(2) > ... on UserConnection { edges(3) > node(4) }
const result = await graphqlRequest(`{
users {
edges {
... on UserEdge {
node {
objectId
}
}
}
}
}`);
expect(result.errors).toBeDefined();
expect(result.errors[0].message).toMatch(
/Number of GraphQL fields \(\d+\) exceeds maximum allowed \(3\)/
);
});
it('should allow unlimited fields when graphQLFields is -1', async () => {
await setupGraphQL({
requestComplexity: { graphQLFields: -1 },
});
const result = await graphqlRequest(buildWideQuery(50));
expect(result.errors).toBeUndefined();
});
});
describe('where argument breadth', () => {
it('should enforce depth and field limits regardless of where argument breadth', async () => {
await setupGraphQL({
requestComplexity: { graphQLDepth: 3, graphQLFields: 200, subqueryDepth: 1 },
});
// The GraphQL where argument may contain many OR branches, but the
// complexity check correctly measures the selection set depth/fields,
// not the where variable content. A query exceeding graphQLDepth is
// rejected even when the where argument is simple.
const result = await graphqlRequest(buildDeepQuery());
expect(result.errors).toBeDefined();
expect(result.errors[0].message).toMatch(
/GraphQL query depth of \d+ exceeds maximum allowed depth of 3/
);
});
it('should allow query with wide where argument when selection set is within limits', async () => {
await setupGraphQL({
requestComplexity: { graphQLDepth: 10, graphQLFields: 200, subqueryDepth: 1 },
});
const obj = new Parse.Object('TestItem');
obj.set('name', 'test');
await obj.save();
// Wide where with many OR branches — complexity check measures selection
// set depth and field count, not where argument structure
const orBranches = Array.from({ length: 20 }, (_, i) => `{ name: { equalTo: "test${i}" } }`).join(', ');
const query = `{ testItems(where: { OR: [${orBranches}] }) { edges { node { objectId } } } }`;
const result = await graphqlRequest(query);
expect(result.errors).toBeUndefined();
});
});
});
+11 -86
View File
@@ -24,21 +24,10 @@ describe_only_db('mongo')('GridFSBucket', () => {
const databaseURI = 'mongodb://localhost:27017/parse';
const gfsAdapter = new GridFSBucketAdapter(databaseURI, {
retryWrites: true,
// Parse Server-specific options that should be filtered out before passing to MongoDB client
allowPublicExplain: true,
// these are not supported by the mongo client
enableSchemaHooks: true,
schemaCacheTtl: 5000,
maxTimeMS: 30000,
batchSize: 500,
disableIndexFieldValidation: true,
logClientEvents: [{ name: 'commandStarted' }],
createIndexUserUsername: true,
createIndexUserUsernameCaseInsensitive: true,
createIndexUserEmail: true,
createIndexUserEmailCaseInsensitive: true,
createIndexUserEmailVerifyToken: true,
createIndexUserPasswordResetToken: true,
createIndexRoleName: true,
});
const db = await gfsAdapter._connect();
@@ -47,13 +36,6 @@ describe_only_db('mongo')('GridFSBucket', () => {
expect(db.options?.retryWrites).toEqual(true);
});
it('should store batchSize and filter it from MongoClient options', async () => {
const gfsAdapter = new GridFSBucketAdapter(databaseURI, { batchSize: 500 });
expect(gfsAdapter._batchSize).toEqual(500);
// Verify batchSize is filtered from MongoClient options
expect(gfsAdapter._mongoOptions.batchSize).toBeUndefined();
});
it('should save an encrypted file that can only be decrypted by a GridFS adapter with the encryptionKey', async () => {
const unencryptedAdapter = new GridFSBucketAdapter(databaseURI);
const encryptedAdapter = new GridFSBucketAdapter(
@@ -103,12 +85,12 @@ describe_only_db('mongo')('GridFSBucket', () => {
).toEqual(1);
expect(notRotated.length).toEqual(0);
let result = await encryptedAdapter.getFileData(fileName1);
expect(Buffer.isBuffer(result)).toBe(true);
expect(result instanceof Buffer).toBe(true);
expect(result.toString('utf-8')).toEqual(data1);
const encryptedData1 = await unencryptedAdapter.getFileData(fileName1);
expect(encryptedData1.toString('utf-8')).not.toEqual(unencryptedResult1);
result = await encryptedAdapter.getFileData(fileName2);
expect(Buffer.isBuffer(result)).toBe(true);
expect(result instanceof Buffer).toBe(true);
expect(result.toString('utf-8')).toEqual(data2);
const encryptedData2 = await unencryptedAdapter.getFileData(fileName2);
expect(encryptedData2.toString('utf-8')).not.toEqual(unencryptedResult2);
@@ -146,7 +128,7 @@ describe_only_db('mongo')('GridFSBucket', () => {
).toEqual(1);
expect(notRotated.length).toEqual(0);
let result = await encryptedAdapter.getFileData(fileName1);
expect(Buffer.isBuffer(result)).toBe(true);
expect(result instanceof Buffer).toBe(true);
expect(result.toString('utf-8')).toEqual(data1);
let decryptionError1;
let encryptedData1;
@@ -158,7 +140,7 @@ describe_only_db('mongo')('GridFSBucket', () => {
expect(decryptionError1).toMatch('Error');
expect(encryptedData1).toBeUndefined();
result = await encryptedAdapter.getFileData(fileName2);
expect(Buffer.isBuffer(result)).toBe(true);
expect(result instanceof Buffer).toBe(true);
expect(result.toString('utf-8')).toEqual(data2);
let decryptionError2;
let encryptedData2;
@@ -203,7 +185,7 @@ describe_only_db('mongo')('GridFSBucket', () => {
).toEqual(1);
expect(notRotated.length).toEqual(0);
let result = await unEncryptedAdapter.getFileData(fileName1);
expect(Buffer.isBuffer(result)).toBe(true);
expect(result instanceof Buffer).toBe(true);
expect(result.toString('utf-8')).toEqual(data1);
let decryptionError1;
let encryptedData1;
@@ -215,7 +197,7 @@ describe_only_db('mongo')('GridFSBucket', () => {
expect(decryptionError1).toMatch('Error');
expect(encryptedData1).toBeUndefined();
result = await unEncryptedAdapter.getFileData(fileName2);
expect(Buffer.isBuffer(result)).toBe(true);
expect(result instanceof Buffer).toBe(true);
expect(result.toString('utf-8')).toEqual(data2);
let decryptionError2;
let encryptedData2;
@@ -271,7 +253,7 @@ describe_only_db('mongo')('GridFSBucket', () => {
}).length
).toEqual(0);
let result = await encryptedAdapter.getFileData(fileName1);
expect(Buffer.isBuffer(result)).toBe(true);
expect(result instanceof Buffer).toBe(true);
expect(result.toString('utf-8')).toEqual(data1);
let decryptionError1;
let encryptedData1;
@@ -283,7 +265,7 @@ describe_only_db('mongo')('GridFSBucket', () => {
expect(decryptionError1).toMatch('Error');
expect(encryptedData1).toBeUndefined();
result = await encryptedAdapter.getFileData(fileName2);
expect(Buffer.isBuffer(result)).toBe(true);
expect(result instanceof Buffer).toBe(true);
expect(result.toString('utf-8')).toEqual(data2);
let decryptionError2;
let encryptedData2;
@@ -338,7 +320,7 @@ describe_only_db('mongo')('GridFSBucket', () => {
}).length
).toEqual(1);
let result = await encryptedAdapter.getFileData(fileName1);
expect(Buffer.isBuffer(result)).toBe(true);
expect(result instanceof Buffer).toBe(true);
expect(result.toString('utf-8')).toEqual(data1);
let decryptionError1;
let encryptedData1;
@@ -350,7 +332,7 @@ describe_only_db('mongo')('GridFSBucket', () => {
expect(decryptionError1).toMatch('Error');
expect(encryptedData1).toBeUndefined();
result = await encryptedAdapter.getFileData(fileName2);
expect(Buffer.isBuffer(result)).toBe(true);
expect(result instanceof Buffer).toBe(true);
expect(result.toString('utf-8')).toEqual(data2);
let decryptionError2;
let encryptedData2;
@@ -439,14 +421,6 @@ describe_only_db('mongo')('GridFSBucket', () => {
expect(gfsResult.toString('utf8')).toBe(twoMegabytesFile);
});
it('properly upload a file when disableIndexFieldValidation exist in databaseOptions', async () => {
const gfsAdapter = new GridFSBucketAdapter(databaseURI, { disableIndexFieldValidation: true });
const twoMegabytesFile = randomString(2048 * 1024);
await gfsAdapter.createFile('myFileName', twoMegabytesFile);
const gfsResult = await gfsAdapter.getFileData('myFileName');
expect(gfsResult.toString('utf8')).toBe(twoMegabytesFile);
});
it('properly deletes a file from GridFS', async () => {
const gfsAdapter = new GridFSBucketAdapter(databaseURI);
await gfsAdapter.createFile('myFileName', 'a simple file');
@@ -483,53 +457,4 @@ describe_only_db('mongo')('GridFSBucket', () => {
expect(e.message).toEqual('Client must be connected before running operations');
}
});
it('reports supportsStreaming as true', () => {
const gfsAdapter = new GridFSBucketAdapter(databaseURI);
expect(gfsAdapter.supportsStreaming).toBe(true);
});
it('creates file from Readable stream', async () => {
const { Readable } = require('stream');
const gfsAdapter = new GridFSBucketAdapter(databaseURI);
const data = Buffer.from('streamed file content');
const stream = Readable.from(data);
await gfsAdapter.createFile('streamFile.txt', stream);
const result = await gfsAdapter.getFileData('streamFile.txt');
expect(result.toString('utf8')).toBe('streamed file content');
});
it('creates encrypted file from Readable stream (buffers for encryption)', async () => {
const { Readable } = require('stream');
const gfsAdapter = new GridFSBucketAdapter(databaseURI, {}, 'test-encryption-key');
const data = Buffer.from('encrypted streamed content');
const stream = Readable.from(data);
await gfsAdapter.createFile('encryptedStream.txt', stream);
const result = await gfsAdapter.getFileData('encryptedStream.txt');
expect(result.toString('utf8')).toBe('encrypted streamed content');
});
describe('MongoDB Client Metadata', () => {
it('should not pass metadata to MongoClient by default', async () => {
const gfsAdapter = new GridFSBucketAdapter(databaseURI);
await gfsAdapter._connect();
const driverInfo = gfsAdapter._client.s.options.driverInfo;
// Either driverInfo should be undefined, or it should not contain our custom metadata
if (driverInfo) {
expect(driverInfo.name).toBeUndefined();
}
await gfsAdapter.handleShutdown();
});
it('should pass custom metadata to MongoClient when configured', async () => {
const customMetadata = { name: 'MyParseServer', version: '1.0.0' };
const gfsAdapter = new GridFSBucketAdapter(databaseURI, {
clientMetadata: customMetadata
});
await gfsAdapter._connect();
expect(gfsAdapter._client.s.options.driverInfo.name).toBe(customMetadata.name);
expect(gfsAdapter._client.s.options.driverInfo.version).toBe(customMetadata.version);
await gfsAdapter.handleShutdown();
});
});
});
+2 -1
View File
@@ -2,6 +2,7 @@
const httpRequest = require('../lib/request'),
HTTPResponse = require('../lib/request').HTTPResponse,
bodyParser = require('body-parser'),
express = require('express');
const port = 13371;
@@ -9,7 +10,7 @@ const httpRequestServer = `http://localhost:${port}`;
function startServer(done) {
const app = express();
app.use(express.json({ type: '*/*' }));
app.use(bodyParser.json({ type: '*/*' }));
app.get('/hello', function (req, res) {
res.json({ response: 'OK' });
});
+8 -12
View File
@@ -45,12 +45,8 @@ describe('Idempotency', () => {
});
});
afterEach(() => {
jasmine.DEFAULT_TIMEOUT_INTERVAL = process.env.PARSE_SERVER_TEST_TIMEOUT || 10000;
});
// Tests
it_id('e25955fd-92eb-4b22-b8b7-38980e5cb223')(it)('should enforce idempotency for cloud code function', async () => {
it('should enforce idempotency for cloud code function', async () => {
let counter = 0;
Parse.Cloud.define('myFunction', () => {
counter++;
@@ -73,7 +69,7 @@ describe('Idempotency', () => {
expect(counter).toBe(1);
});
it_id('be2fbe16-8178-485e-9a12-6fb541096480')(it)('should delete request entry after TTL', async () => {
it('should delete request entry after TTL', async () => {
let counter = 0;
Parse.Cloud.define('myFunction', () => {
counter++;
@@ -123,7 +119,7 @@ describe('Idempotency', () => {
}
);
it_id('e976d0cc-a57f-45d4-9472-b9b052db6490')(it)('should enforce idempotency for cloud code jobs', async () => {
it('should enforce idempotency for cloud code jobs', async () => {
let counter = 0;
Parse.Cloud.job('myJob', () => {
counter++;
@@ -145,7 +141,7 @@ describe('Idempotency', () => {
expect(counter).toBe(1);
});
it_id('7c84a3d4-e1b6-4a0d-99f1-af3cf1a6b3d8')(it)('should enforce idempotency for class object creation', async () => {
it('should enforce idempotency for class object creation', async () => {
let counter = 0;
Parse.Cloud.afterSave('MyClass', () => {
counter++;
@@ -167,7 +163,7 @@ describe('Idempotency', () => {
expect(counter).toBe(1);
});
it_id('a030f2dd-5d21-46ac-b53d-9d714f35d72a')(it)('should enforce idempotency for user object creation', async () => {
it('should enforce idempotency for user object creation', async () => {
let counter = 0;
Parse.Cloud.afterSave('_User', () => {
counter++;
@@ -193,7 +189,7 @@ describe('Idempotency', () => {
expect(counter).toBe(1);
});
it_id('064c469b-091c-4ba9-9043-be461f26a3eb')(it)('should enforce idempotency for installation object creation', async () => {
it('should enforce idempotency for installation object creation', async () => {
let counter = 0;
Parse.Cloud.afterSave('_Installation', () => {
counter++;
@@ -219,7 +215,7 @@ describe('Idempotency', () => {
expect(counter).toBe(1);
});
it_id('f11670b6-fa9c-4f21-a268-ae4b6bbff7fd')(it)('should not interfere with calls of different request ID', async () => {
it('should not interfere with calls of different request ID', async () => {
let counter = 0;
Parse.Cloud.afterSave('MyClass', () => {
counter++;
@@ -240,7 +236,7 @@ describe('Idempotency', () => {
expect(counter).toBe(100);
});
it_id('0ecd2cd2-dafb-4a2b-bb2b-9ad4c9aca777')(it)('should re-throw any other error unchanged when writing request entry fails for any other reason', async () => {
it('should re-throw any other error unchanged when writing request entry fails for any other reason', async () => {
spyOn(rest, 'create').and.rejectWith(new Parse.Error(0, 'some other error'));
Parse.Cloud.define('myFunction', () => {});
const params = {
-172
View File
@@ -4,178 +4,6 @@ const fs = require('fs');
const port = 12345;
const sslport = 12346;
describe('LDAP Injection Prevention', () => {
describe('escapeDN', () => {
it('should escape comma', () => {
expect(ldap.escapeDN('admin,ou=evil')).toBe('admin\\,ou\\=evil');
});
it('should escape equals sign', () => {
expect(ldap.escapeDN('admin=evil')).toBe('admin\\=evil');
});
it('should escape plus sign', () => {
expect(ldap.escapeDN('admin+evil')).toBe('admin\\+evil');
});
it('should escape less-than and greater-than signs', () => {
expect(ldap.escapeDN('admin<evil>')).toBe('admin\\<evil\\>');
});
it('should escape hash at start', () => {
expect(ldap.escapeDN('#admin')).toBe('\\#admin');
});
it('should escape semicolon', () => {
expect(ldap.escapeDN('admin;evil')).toBe('admin\\;evil');
});
it('should escape double quote', () => {
expect(ldap.escapeDN('admin"evil')).toBe('admin\\"evil');
});
it('should escape backslash', () => {
expect(ldap.escapeDN('admin\\evil')).toBe('admin\\\\evil');
});
it('should escape leading space', () => {
expect(ldap.escapeDN(' admin')).toBe('\\ admin');
});
it('should escape trailing space', () => {
expect(ldap.escapeDN('admin ')).toBe('admin\\ ');
});
it('should escape multiple special characters', () => {
expect(ldap.escapeDN('admin,ou=evil+cn=x')).toBe('admin\\,ou\\=evil\\+cn\\=x');
});
it('should not modify safe values', () => {
expect(ldap.escapeDN('testuser')).toBe('testuser');
expect(ldap.escapeDN('john.doe')).toBe('john.doe');
expect(ldap.escapeDN('user123')).toBe('user123');
});
});
describe('escapeFilter', () => {
it('should escape asterisk', () => {
expect(ldap.escapeFilter('*')).toBe('\\2a');
});
it('should escape open parenthesis', () => {
expect(ldap.escapeFilter('test(')).toBe('test\\28');
});
it('should escape close parenthesis', () => {
expect(ldap.escapeFilter('test)')).toBe('test\\29');
});
it('should escape backslash', () => {
expect(ldap.escapeFilter('test\\')).toBe('test\\5c');
});
it('should escape null byte', () => {
expect(ldap.escapeFilter('test\x00')).toBe('test\\00');
});
it('should escape multiple special characters', () => {
expect(ldap.escapeFilter('*()\\')).toBe('\\2a\\28\\29\\5c');
});
it('should not modify safe values', () => {
expect(ldap.escapeFilter('testuser')).toBe('testuser');
expect(ldap.escapeFilter('john.doe')).toBe('john.doe');
expect(ldap.escapeFilter('user123')).toBe('user123');
});
it('should escape filter injection attempt with wildcard', () => {
expect(ldap.escapeFilter('x)(|(objectClass=*)')).toBe('x\\29\\28|\\28objectClass=\\2a\\29');
});
});
describe('authData validation', () => {
it('should reject missing authData.id', async done => {
const server = await mockLdapServer(port, 'uid=testuser, o=example');
const options = {
suffix: 'o=example',
url: `ldap://localhost:${port}`,
dn: 'uid={{id}}, o=example',
};
try {
await ldap.validateAuthData({ password: 'secret' }, options);
fail('Should have rejected missing id');
} catch (err) {
expect(err.message).toBe('LDAP: Wrong username or password');
}
server.close(done);
});
it('should reject non-string authData.id', async done => {
const server = await mockLdapServer(port, 'uid=testuser, o=example');
const options = {
suffix: 'o=example',
url: `ldap://localhost:${port}`,
dn: 'uid={{id}}, o=example',
};
try {
await ldap.validateAuthData({ id: 123, password: 'secret' }, options);
fail('Should have rejected non-string id');
} catch (err) {
expect(err.message).toBe('LDAP: Wrong username or password');
}
server.close(done);
});
});
describe('DN injection prevention', () => {
it('should prevent DN injection via comma in authData.id', async done => {
// Mock server accepts the DN that would result from an unescaped injection
const server = await mockLdapServer(port, 'uid=admin,ou=admins,o=example');
const options = {
suffix: 'o=example',
url: `ldap://localhost:${port}`,
dn: 'uid={{id}}, o=example',
};
// Attacker tries to inject additional DN components via comma
// Without escaping: DN = uid=admin,ou=admins, o=example (3 RDNs) → matches mock
// With escaping: DN = uid=admin\,ou=admins, o=example (2 RDNs) → doesn't match
try {
await ldap.validateAuthData({ id: 'admin,ou=admins', password: 'secret' }, options);
fail('Should have rejected DN injection attempt');
} catch (err) {
expect(err.message).toBe('LDAP: Wrong username or password');
}
server.close(done);
});
});
describe('Filter injection prevention', () => {
it('should prevent LDAP filter injection via wildcard in authData.id', async done => {
// Mock server accepts uid=*, o=example (the attacker's bind DN)
// The * is not special in DNs so it binds fine regardless of escaping
const server = await mockLdapServer(port, 'uid=*, o=example');
const options = {
suffix: 'o=example',
url: `ldap://localhost:${port}`,
dn: 'uid={{id}}, o=example',
groupCn: 'powerusers',
groupFilter: '(&(uniqueMember=uid={{id}}, o=example)(objectClass=groupOfUniqueNames))',
};
// Attacker uses * as ID to match any group member via wildcard
// Group has member uid=testuser, not uid=*
// Without escaping: filter uses SubstringFilter, matches testuser → passes
// With escaping: filter uses EqualityFilter with literal \2a, no match → fails
try {
await ldap.validateAuthData({ id: '*', password: 'secret' }, options);
fail('Should have rejected filter injection attempt');
} catch (err) {
expect(err.message).toBe('LDAP: User not in group');
}
server.close(done);
});
});
});
describe('Ldap Auth', () => {
it('Should fail with missing options', done => {
ldap
+4 -8
View File
@@ -52,9 +52,6 @@ describe_only(() => {
});
it('can check invalid master key of request', done => {
const logger = require('../lib/logger').default;
const loggerErrorSpy = spyOn(logger, 'error').and.callThrough();
loggerErrorSpy.calls.reset();
request({
url: 'http://localhost:8378/1/scriptlog',
headers: {
@@ -64,8 +61,7 @@ describe_only(() => {
}).then(fail, response => {
const body = response.data;
expect(response.status).toEqual(403);
expect(body.error).toEqual('Permission denied');
expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('unauthorized: master key is required'));
expect(body.error).toEqual('unauthorized: master key is required');
done();
});
});
@@ -79,7 +75,7 @@ describe_only(() => {
/**
* Verifies simple passwords in GET login requests with special characters are scrubbed from the verbose log
*/
it_id('e36d6141-2a20-41d0-85fc-d1534c3e4bae')(it)('does scrub simple passwords on GET login', done => {
it('does scrub simple passwords on GET login', done => {
reconfigureServer({
verbose: true,
}).then(function () {
@@ -109,7 +105,7 @@ describe_only(() => {
/**
* Verifies complex passwords in GET login requests with special characters are scrubbed from the verbose log
*/
it_id('24b277c5-250f-4a35-a449-2c8c519d4c03')(it)('does scrub complex passwords on GET login', done => {
it('does scrub complex passwords on GET login', done => {
reconfigureServer({
verbose: true,
})
@@ -143,7 +139,7 @@ describe_only(() => {
/**
* Verifies fields in POST login requests are NOT present in the verbose log
*/
it_id('33143ec9-b32d-467c-ba65-ff2bbefdaadd')(it)('does not have password field in POST login', done => {
it('does not have password field in POST login', done => {
reconfigureServer({
verbose: true,
}).then(function () {
+27 -172
View File
@@ -7,7 +7,6 @@ const AppCachePut = (appId, config) =>
...config,
maintenanceKeyIpsStore: new Map(),
masterKeyIpsStore: new Map(),
readOnlyMasterKeyIpsStore: new Map(),
});
describe('middlewares', () => {
@@ -33,10 +32,7 @@ describe('middlewares', () => {
AppCache.del(fakeReq.body._ApplicationId);
});
it_id('4cc18d90-1763-4725-97fa-f63fb4692fc4')(it)('should use _ContentType if provided', done => {
AppCachePut(fakeReq.body._ApplicationId, {
masterKeyIps: ['127.0.0.1'],
});
it('should use _ContentType if provided', done => {
expect(fakeReq.headers['content-type']).toEqual(undefined);
const contentType = 'image/jpeg';
fakeReq.body._ContentType = contentType;
@@ -47,32 +43,32 @@ describe('middlewares', () => {
});
});
it('should give invalid response when keys are configured but no key supplied', async () => {
it('should give invalid response when keys are configured but no key supplied', () => {
AppCachePut(fakeReq.body._ApplicationId, {
masterKey: 'masterKey',
restAPIKey: 'restAPIKey',
});
await middlewares.handleParseHeaders(fakeReq, fakeRes);
middlewares.handleParseHeaders(fakeReq, fakeRes);
expect(fakeRes.status).toHaveBeenCalledWith(403);
});
it('should give invalid response when keys are configured but supplied key is incorrect', async () => {
it('should give invalid response when keys are configured but supplied key is incorrect', () => {
AppCachePut(fakeReq.body._ApplicationId, {
masterKey: 'masterKey',
restAPIKey: 'restAPIKey',
});
fakeReq.headers['x-parse-rest-api-key'] = 'wrongKey';
await middlewares.handleParseHeaders(fakeReq, fakeRes);
middlewares.handleParseHeaders(fakeReq, fakeRes);
expect(fakeRes.status).toHaveBeenCalledWith(403);
});
it('should give invalid response when keys are configured but different key is supplied', async () => {
it('should give invalid response when keys are configured but different key is supplied', () => {
AppCachePut(fakeReq.body._ApplicationId, {
masterKey: 'masterKey',
restAPIKey: 'restAPIKey',
});
fakeReq.headers['x-parse-client-key'] = 'clientKey';
await middlewares.handleParseHeaders(fakeReq, fakeRes);
middlewares.handleParseHeaders(fakeReq, fakeRes);
expect(fakeRes.status).toHaveBeenCalledWith(403);
});
@@ -129,7 +125,7 @@ describe('middlewares', () => {
const otherKeys = BodyKeys.filter(
otherKey => otherKey !== infoKey && otherKey !== 'javascriptKey'
);
it_id('f9abd7ac-b1f4-4607-b9b0-365ff0559d84')(it)(`it should pull ${bodyKey} into req.info`, done => {
it(`it should pull ${bodyKey} into req.info`, done => {
AppCachePut(fakeReq.body._ApplicationId, {
masterKeyIps: ['0.0.0.0/0'],
});
@@ -148,7 +144,7 @@ describe('middlewares', () => {
});
});
it_id('4a0bce41-c536-4482-a873-12ed023380e2')(it)('should not succeed and log if the ip does not belong to masterKeyIps list', async () => {
it('should not succeed and log if the ip does not belong to masterKeyIps list', async () => {
const logger = require('../lib/logger').logger;
spyOn(logger, 'error').and.callFake(() => {});
AppCachePut(fakeReq.body._ApplicationId, {
@@ -157,17 +153,25 @@ describe('middlewares', () => {
});
fakeReq.ip = '127.0.0.1';
fakeReq.headers['x-parse-master-key'] = 'masterKey';
const error = await middlewares.handleParseHeaders(fakeReq, fakeRes, () => {}).catch(e => e);
expect(error).toBeDefined();
expect(error.message).toEqual(`unauthorized`);
await new Promise(resolve => middlewares.handleParseHeaders(fakeReq, fakeRes, resolve));
expect(fakeReq.auth.isMaster).toBe(false);
expect(logger.error).toHaveBeenCalledWith(
`Request using master key rejected as the request IP address '127.0.0.1' is not set in Parse Server option 'masterKeyIps'.`
);
});
it('should not succeed and log if the ip does not belong to maintenanceKeyIps list', async () => {
it('should not succeed if the ip does not belong to masterKeyIps list', async () => {
AppCachePut(fakeReq.body._ApplicationId, {
masterKey: 'masterKey',
masterKeyIps: ['10.0.0.1'],
});
fakeReq.ip = '127.0.0.1';
fakeReq.headers['x-parse-master-key'] = 'masterKey';
await new Promise(resolve => middlewares.handleParseHeaders(fakeReq, fakeRes, resolve));
expect(fakeReq.auth.isMaster).toBe(false);
});
it('should not succeed if the ip does not belong to maintenanceKeyIps list', async () => {
const logger = require('../lib/logger').logger;
spyOn(logger, 'error').and.callFake(() => {});
AppCachePut(fakeReq.body._ApplicationId, {
@@ -176,17 +180,14 @@ describe('middlewares', () => {
});
fakeReq.ip = '10.0.0.2';
fakeReq.headers['x-parse-maintenance-key'] = 'masterKey';
const error = await middlewares.handleParseHeaders(fakeReq, fakeRes, () => {}).catch(e => e);
expect(error).toBeDefined();
expect(error.message).toEqual(`unauthorized`);
await new Promise(resolve => middlewares.handleParseHeaders(fakeReq, fakeRes, resolve));
expect(fakeReq.auth.isMaintenance).toBe(false);
expect(logger.error).toHaveBeenCalledWith(
`Request using maintenance key rejected as the request IP address '10.0.0.2' is not set in Parse Server option 'maintenanceKeyIps'.`
);
});
it_id('2f7fadec-a87c-4626-90d1-65c75653aea9')(it)('should succeed if the ip does belong to masterKeyIps list', async () => {
it('should succeed if the ip does belong to masterKeyIps list', async () => {
AppCachePut(fakeReq.body._ApplicationId, {
masterKey: 'masterKey',
masterKeyIps: ['10.0.0.1'],
@@ -197,7 +198,7 @@ describe('middlewares', () => {
expect(fakeReq.auth.isMaster).toBe(true);
});
it_id('2b251fd4-d43c-48f4-ada9-c8458e40c12a')(it)('should allow any ip to use masterKey if masterKeyIps is empty', async () => {
it('should allow any ip to use masterKey if masterKeyIps is empty', async () => {
AppCachePut(fakeReq.body._ApplicationId, {
masterKey: 'masterKey',
masterKeyIps: ['0.0.0.0/0'],
@@ -208,55 +209,6 @@ describe('middlewares', () => {
expect(fakeReq.auth.isMaster).toBe(true);
});
it('should not succeed and log if the ip does not belong to readOnlyMasterKeyIps list', async () => {
const logger = require('../lib/logger').logger;
spyOn(logger, 'error').and.callFake(() => {});
AppCachePut(fakeReq.body._ApplicationId, {
masterKeyIps: ['0.0.0.0/0'],
readOnlyMasterKey: 'readOnlyMasterKey',
readOnlyMasterKeyIps: ['10.0.0.1'],
});
fakeReq.ip = '127.0.0.1';
fakeReq.headers['x-parse-application-id'] = fakeReq.body._ApplicationId;
fakeReq.headers['x-parse-master-key'] = 'readOnlyMasterKey';
const error = await middlewares.handleParseHeaders(fakeReq, fakeRes, () => {}).catch(e => e);
expect(error).toBeDefined();
expect(error.message).toEqual('unauthorized');
expect(logger.error).toHaveBeenCalledWith(
`Request using read-only master key rejected as the request IP address '127.0.0.1' is not set in Parse Server option 'readOnlyMasterKeyIps'.`
);
});
it('should succeed if the ip does belong to readOnlyMasterKeyIps list', async () => {
AppCachePut(fakeReq.body._ApplicationId, {
masterKeyIps: ['0.0.0.0/0'],
readOnlyMasterKey: 'readOnlyMasterKey',
readOnlyMasterKeyIps: ['10.0.0.1'],
});
fakeReq.ip = '10.0.0.1';
fakeReq.headers['x-parse-application-id'] = fakeReq.body._ApplicationId;
fakeReq.headers['x-parse-master-key'] = 'readOnlyMasterKey';
await new Promise(resolve => middlewares.handleParseHeaders(fakeReq, fakeRes, resolve));
expect(fakeReq.auth.isMaster).toBe(true);
expect(fakeReq.auth.isReadOnly).toBe(true);
});
it('should allow any ip to use readOnlyMasterKey if readOnlyMasterKeyIps is 0.0.0.0/0', async () => {
AppCachePut(fakeReq.body._ApplicationId, {
masterKeyIps: ['0.0.0.0/0'],
readOnlyMasterKey: 'readOnlyMasterKey',
readOnlyMasterKeyIps: ['0.0.0.0/0'],
});
fakeReq.ip = '10.0.0.1';
fakeReq.headers['x-parse-application-id'] = fakeReq.body._ApplicationId;
fakeReq.headers['x-parse-master-key'] = 'readOnlyMasterKey';
await new Promise(resolve => middlewares.handleParseHeaders(fakeReq, fakeRes, resolve));
expect(fakeReq.auth.isMaster).toBe(true);
expect(fakeReq.auth.isReadOnly).toBe(true);
});
it('can set trust proxy', async () => {
const server = await reconfigureServer({ trustProxy: 1 });
expect(server.app.parent.settings['trust proxy']).toBe(1);
@@ -430,103 +382,6 @@ describe('middlewares', () => {
expect(middlewares.checkIp(localhostV62, ['127.0.0.1'], new Map())).toBe(true);
});
describe('body field type validation', () => {
beforeEach(() => {
AppCachePut(fakeReq.body._ApplicationId, {
masterKeyIps: ['0.0.0.0/0'],
});
});
it('should reject non-string _SessionToken in body', async () => {
fakeReq.body._SessionToken = { toString: 'evil' };
await middlewares.handleParseHeaders(fakeReq, fakeRes);
expect(fakeRes.status).toHaveBeenCalledWith(403);
});
it('should reject non-string _ClientVersion in body', async () => {
fakeReq.body._ClientVersion = { toLowerCase: 'evil' };
await middlewares.handleParseHeaders(fakeReq, fakeRes);
expect(fakeRes.status).toHaveBeenCalledWith(403);
});
it('should reject non-string _InstallationId in body', async () => {
fakeReq.body._InstallationId = { toString: 'evil' };
await middlewares.handleParseHeaders(fakeReq, fakeRes);
expect(fakeRes.status).toHaveBeenCalledWith(403);
});
it('should reject non-string _ContentType in body', async () => {
fakeReq.body._ContentType = { toString: 'evil' };
await middlewares.handleParseHeaders(fakeReq, fakeRes);
expect(fakeRes.status).toHaveBeenCalledWith(403);
});
it('should reject non-string base64 in file-via-JSON upload', async () => {
fakeReq.body = Buffer.from(
JSON.stringify({
_ApplicationId: 'FakeAppId',
base64: { toString: 'evil' },
})
);
await middlewares.handleParseHeaders(fakeReq, fakeRes);
expect(fakeRes.status).toHaveBeenCalledWith(403);
});
it('should not crash the server process on non-string body fields', async () => {
// Verify that type confusion in body fields does not crash the Node.js process.
// Each request should be handled independently without affecting server stability.
const payloads = [
{ _SessionToken: { toString: 'evil' } },
{ _ClientVersion: { toLowerCase: 'evil' } },
{ _InstallationId: [1, 2, 3] },
{ _ContentType: { toString: 'evil' } },
];
for (const payload of payloads) {
const req = {
ip: '127.0.0.1',
originalUrl: 'http://example.com/parse/',
url: 'http://example.com/',
body: { _ApplicationId: 'FakeAppId', ...payload },
headers: {},
get: key => req.headers[key.toLowerCase()],
};
const res = jasmine.createSpyObj('res', ['end', 'status']);
await middlewares.handleParseHeaders(req, res);
expect(res.status).toHaveBeenCalledWith(403);
}
// Server process is still alive — a subsequent valid request works
const validReq = {
ip: '127.0.0.1',
originalUrl: 'http://example.com/parse/',
url: 'http://example.com/',
body: { _ApplicationId: 'FakeAppId' },
headers: {},
get: key => validReq.headers[key.toLowerCase()],
};
const validRes = jasmine.createSpyObj('validRes', ['end', 'status']);
let nextCalled = false;
await middlewares.handleParseHeaders(validReq, validRes, () => {
nextCalled = true;
});
expect(nextCalled).toBe(true);
expect(validRes.status).not.toHaveBeenCalled();
});
it('should still accept valid string body fields', done => {
fakeReq.body._SessionToken = 'r:validtoken';
fakeReq.body._ClientVersion = 'js1.0.0';
fakeReq.body._InstallationId = 'install123';
fakeReq.body._ContentType = 'application/json';
middlewares.handleParseHeaders(fakeReq, fakeRes, () => {
expect(fakeReq.info.sessionToken).toEqual('r:validtoken');
expect(fakeReq.info.clientVersion).toEqual('js1.0.0');
expect(fakeReq.info.installationId).toEqual('install123');
expect(fakeReq.headers['content-type']).toEqual('application/json');
done();
});
});
});
it('should match address with cache', () => {
const ipv6 = '2001:0db8:85a3:0000:0000:8a2e:0370:7334';
const cache1 = new Map();
-12
View File
@@ -18,12 +18,6 @@ describe('MongoSchemaCollection', () => {
},
_metadata: {
class_permissions: {
ACL: {
'*': {
read: true,
write: true,
},
},
get: { '*': true },
find: { '*': true },
count: { '*': true },
@@ -75,12 +69,6 @@ describe('MongoSchemaCollection', () => {
objectId: { type: 'String' },
},
classLevelPermissions: {
ACL: {
'*': {
read: true,
write: true,
},
},
find: { '*': true },
get: { '*': true },
count: { '*': true },
+69 -636
View File
@@ -6,7 +6,6 @@ const databaseURI = 'mongodb://localhost:27017/parseServerMongoAdapterTestDataba
const request = require('../lib/request');
const Config = require('../lib/Config');
const TestUtils = require('../lib/TestUtils');
const Utils = require('../lib/Utils');
const fakeClient = {
s: { options: { dbName: null } },
@@ -16,8 +15,8 @@ const fakeClient = {
// These tests are specific to the mongo storage adapter + mongo storage format
// and will eventually be moved into their own repo
describe_only_db('mongo')('MongoStorageAdapter', () => {
beforeEach(async () => {
await new MongoStorageAdapter({ uri: databaseURI }).deleteAllClasses();
beforeEach(done => {
new MongoStorageAdapter({ uri: databaseURI }).deleteAllClasses().then(done, fail);
Config.get(Parse.applicationId).schemaCache.clear();
});
@@ -109,58 +108,6 @@ describe_only_db('mongo')('MongoStorageAdapter', () => {
);
});
it('passes batchSize to the MongoDB driver find() call', async () => {
const batchSize = 50;
const adapter = new MongoStorageAdapter({
uri: databaseURI,
mongoOptions: { batchSize },
});
await adapter.createObject('BatchTest', { fields: {} }, { objectId: 'obj1' });
// Spy on the MongoDB driver's Collection.prototype.find to verify batchSize is forwarded
const originalFind = Collection.prototype.find;
let capturedOptions;
spyOn(Collection.prototype, 'find').and.callFake(function (query, options) {
capturedOptions = options;
return originalFind.call(this, query, options);
});
await adapter.find('BatchTest', { fields: {} }, {}, {});
expect(capturedOptions).toBeDefined();
expect(capturedOptions.batchSize).toEqual(50);
});
it('passes batchSize to the MongoDB driver aggregate() call', async () => {
const batchSize = 50;
const adapter = new MongoStorageAdapter({
uri: databaseURI,
mongoOptions: { batchSize },
});
await adapter.createObject('AggBatchTest', { fields: { count: { type: 'Number' } } }, { objectId: 'obj1', count: 1 });
// Spy on the MongoDB driver's Collection.prototype.aggregate to verify batchSize is forwarded
const originalAggregate = Collection.prototype.aggregate;
let capturedOptions;
spyOn(Collection.prototype, 'aggregate').and.callFake(function (pipeline, options) {
capturedOptions = options;
return originalAggregate.call(this, pipeline, options);
});
await adapter.aggregate('AggBatchTest', { fields: { count: { type: 'Number' } } }, [{ $match: {} }]);
expect(capturedOptions).toBeDefined();
expect(capturedOptions.batchSize).toEqual(50);
});
it('defaults batchSize to 1000', async () => {
await reconfigureServer({
databaseURI: databaseURI,
collectionPrefix: 'test_',
databaseAdapter: undefined,
});
const adapter = Config.get(Parse.applicationId).database.adapter;
expect(adapter._batchSize).toEqual(1000);
});
it('stores pointers with a _p_ prefix', done => {
const obj = {
objectId: 'bar',
@@ -244,15 +191,15 @@ describe_only_db('mongo')('MongoStorageAdapter', () => {
.then(results => {
expect(results.length).toEqual(1);
const mob = results[0];
expect(Array.isArray(mob.array)).toBe(true);
expect(mob.array instanceof Array).toBe(true);
expect(typeof mob.object).toBe('object');
expect(Utils.isDate(mob.date)).toBe(true);
expect(mob.date instanceof Date).toBe(true);
return adapter.find('MyClass', schema, {}, {});
})
.then(results => {
expect(results.length).toEqual(1);
const mob = results[0];
expect(Array.isArray(mob.array)).toBe(true);
expect(mob.array instanceof Array).toBe(true);
expect(typeof mob.object).toBe('object');
expect(mob.date.__type).toBe('Date');
expect(mob.date.iso).toBe('2016-05-26T20:55:01.154Z');
@@ -279,9 +226,9 @@ describe_only_db('mongo')('MongoStorageAdapter', () => {
}).save();
const adapter = Config.get(Parse.applicationId).database.adapter;
const [object] = await adapter._rawFind('MyClass', {});
expect(Utils.isDate(object.date)).toBeTrue();
expect(Utils.isDate(object.bar.date)).toBeTrue();
expect(Utils.isDate(object.foo.test.date)).toBeTrue();
expect(object.date instanceof Date).toBeTrue();
expect(object.bar.date instanceof Date).toBeTrue();
expect(object.foo.test.date instanceof Date).toBeTrue();
});
it('handles nested dates in array ', async () => {
@@ -298,13 +245,13 @@ describe_only_db('mongo')('MongoStorageAdapter', () => {
}).save();
const adapter = Config.get(Parse.applicationId).database.adapter;
const [object] = await adapter._rawFind('MyClass', {});
expect(Utils.isDate(object.date[0])).toBeTrue();
expect(Utils.isDate(object.bar.date[0])).toBeTrue();
expect(Utils.isDate(object.foo.test.date[0])).toBeTrue();
expect(object.date[0] instanceof Date).toBeTrue();
expect(object.bar.date[0] instanceof Date).toBeTrue();
expect(object.foo.test.date[0] instanceof Date).toBeTrue();
const obj = await new Parse.Query('MyClass').first({ useMasterKey: true });
expect(Utils.isDate(obj.get('date')[0])).toBeTrue();
expect(Utils.isDate(obj.get('bar').date[0])).toBeTrue();
expect(Utils.isDate(obj.get('foo').test.date[0])).toBeTrue();
expect(obj.get('date')[0] instanceof Date).toBeTrue();
expect(obj.get('bar').date[0] instanceof Date).toBeTrue();
expect(obj.get('foo').test.date[0] instanceof Date).toBeTrue();
});
it('upserts with $setOnInsert', async () => {
@@ -337,11 +284,25 @@ describe_only_db('mongo')('MongoStorageAdapter', () => {
amount: 1,
},
};
await Parse.Server.database.update('MyClass', query, update, { upsert: true });
await Parse.Server.database.update(
'MyClass',
query,
update,
{ upsert: true },
);
update.objectId.amount = uuid2;
await Parse.Server.database.update('MyClass', query, update, { upsert: true });
await Parse.Server.database.update(
'MyClass',
query,
update,
{ upsert: true },
);
const res = await Parse.Server.database.find(schema.className, {}, {});
const res = await Parse.Server.database.find(
schema.className,
{},
{},
);
expect(res.length).toBe(1);
expect(res[0].objectId).toBe(uuid1);
expect(res[0].count).toBe(2);
@@ -377,7 +338,7 @@ describe_only_db('mongo')('MongoStorageAdapter', () => {
})
.then(results => {
const mob = results;
expect(Array.isArray(mob.array)).toBe(true);
expect(mob.array instanceof Array).toBe(true);
expect(typeof mob.object).toBe('object');
expect(mob.date.__type).toBe('Date');
expect(mob.date.iso).toBe('2016-05-26T20:55:01.154Z');
@@ -386,9 +347,9 @@ describe_only_db('mongo')('MongoStorageAdapter', () => {
.then(results => {
expect(results.length).toEqual(1);
const mob = results[0];
expect(Array.isArray(mob.array)).toBe(true);
expect(mob.array instanceof Array).toBe(true);
expect(typeof mob.object).toBe('object');
expect(Utils.isDate(mob.date)).toBe(true);
expect(mob.date instanceof Date).toBe(true);
done();
})
.catch(error => {
@@ -443,7 +404,7 @@ describe_only_db('mongo')('MongoStorageAdapter', () => {
await expectAsync(adapter.getClass('UnknownClass')).toBeRejectedWith(undefined);
});
it_only_mongodb_version('<5.1 || >=6')('should use index for caseInsensitive query', async () => {
it_only_mongodb_version('<5.1>=6')('should use index for caseInsensitive query', async () => {
const user = new Parse.User();
user.set('username', 'Bugs');
user.set('password', 'Bunny');
@@ -477,6 +438,40 @@ describe_only_db('mongo')('MongoStorageAdapter', () => {
expect(postIndexPlan.executionStats.executionStages.stage).toBe('FETCH');
});
it_only_mongodb_version('>=5.1<6')('should use index for caseInsensitive query', async () => {
const user = new Parse.User();
user.set('username', 'Bugs');
user.set('password', 'Bunny');
await user.signUp();
const database = Config.get(Parse.applicationId).database;
await database.adapter.dropAllIndexes('_User');
const preIndexPlan = await database.find(
'_User',
{ username: 'bugs' },
{ caseInsensitive: true, explain: true }
);
const schema = await new Parse.Schema('_User').get();
await database.adapter.ensureIndex(
'_User',
schema,
['username'],
'case_insensitive_username',
true
);
const postIndexPlan = await database.find(
'_User',
{ username: 'bugs' },
{ caseInsensitive: true, explain: true }
);
expect(preIndexPlan.queryPlanner.winningPlan.queryPlan.stage).toBe('COLLSCAN');
expect(postIndexPlan.queryPlanner.winningPlan.queryPlan.stage).toBe('FETCH');
});
it('should delete field without index', async () => {
const database = Config.get(Parse.applicationId).database;
const obj = new Parse.Object('MyObject');
@@ -702,566 +697,4 @@ describe_only_db('mongo')('MongoStorageAdapter', () => {
});
});
}
describe('index creation options', () => {
beforeEach(async () => {
await new MongoStorageAdapter({ uri: databaseURI }).deleteAllClasses();
});
async function getIndexes(collectionName) {
const adapter = Config.get(Parse.applicationId).database.adapter;
const collections = await adapter.database.listCollections({ name: collectionName }).toArray();
if (collections.length === 0) {
return [];
}
return await adapter.database.collection(collectionName).indexes();
}
it('should skip username index when createIndexUserUsername is false', async () => {
await reconfigureServer({
databaseAdapter: undefined,
databaseURI,
databaseOptions: { createIndexUserUsername: false },
});
const indexes = await getIndexes('_User');
expect(indexes.find(idx => idx.name === 'username_1')).toBeUndefined();
});
it('should create username index when createIndexUserUsername is true', async () => {
await reconfigureServer({
databaseAdapter: undefined,
databaseURI,
databaseOptions: { createIndexUserUsername: true },
});
const indexes = await getIndexes('_User');
expect(indexes.find(idx => idx.name === 'username_1')).toBeDefined();
});
it('should skip case-insensitive username index when createIndexUserUsernameCaseInsensitive is false', async () => {
await reconfigureServer({
databaseAdapter: undefined,
databaseURI,
databaseOptions: { createIndexUserUsernameCaseInsensitive: false },
});
const indexes = await getIndexes('_User');
expect(indexes.find(idx => idx.name === 'case_insensitive_username')).toBeUndefined();
});
it('should create case-insensitive username index when createIndexUserUsernameCaseInsensitive is true', async () => {
await reconfigureServer({
databaseAdapter: undefined,
databaseURI,
databaseOptions: { createIndexUserUsernameCaseInsensitive: true },
});
const indexes = await getIndexes('_User');
expect(indexes.find(idx => idx.name === 'case_insensitive_username')).toBeDefined();
});
it('should skip email index when createIndexUserEmail is false', async () => {
await reconfigureServer({
databaseAdapter: undefined,
databaseURI,
databaseOptions: { createIndexUserEmail: false },
});
const indexes = await getIndexes('_User');
expect(indexes.find(idx => idx.name === 'email_1')).toBeUndefined();
});
it('should create email index when createIndexUserEmail is true', async () => {
await reconfigureServer({
databaseAdapter: undefined,
databaseURI,
databaseOptions: { createIndexUserEmail: true },
});
const indexes = await getIndexes('_User');
expect(indexes.find(idx => idx.name === 'email_1')).toBeDefined();
});
it('should skip case-insensitive email index when createIndexUserEmailCaseInsensitive is false', async () => {
await reconfigureServer({
databaseAdapter: undefined,
databaseURI,
databaseOptions: { createIndexUserEmailCaseInsensitive: false },
});
const indexes = await getIndexes('_User');
expect(indexes.find(idx => idx.name === 'case_insensitive_email')).toBeUndefined();
});
it('should create case-insensitive email index when createIndexUserEmailCaseInsensitive is true', async () => {
await reconfigureServer({
databaseAdapter: undefined,
databaseURI,
databaseOptions: { createIndexUserEmailCaseInsensitive: true },
});
const indexes = await getIndexes('_User');
expect(indexes.find(idx => idx.name === 'case_insensitive_email')).toBeDefined();
});
it('should skip email verify token index when createIndexUserEmailVerifyToken is false', async () => {
await reconfigureServer({
databaseAdapter: undefined,
databaseURI,
databaseOptions: { createIndexUserEmailVerifyToken: false },
});
const indexes = await getIndexes('_User');
expect(indexes.find(idx => idx.name === '_email_verify_token' || idx.name === '_email_verify_token_1')).toBeUndefined();
});
it('should create email verify token index when createIndexUserEmailVerifyToken is true', async () => {
await reconfigureServer({
databaseAdapter: undefined,
databaseURI,
databaseOptions: { createIndexUserEmailVerifyToken: true },
});
const indexes = await getIndexes('_User');
expect(indexes.find(idx => idx.name === '_email_verify_token' || idx.name === '_email_verify_token_1')).toBeDefined();
});
it('should skip password reset token index when createIndexUserPasswordResetToken is false', async () => {
await reconfigureServer({
databaseAdapter: undefined,
databaseURI,
databaseOptions: { createIndexUserPasswordResetToken: false },
});
const indexes = await getIndexes('_User');
expect(indexes.find(idx => idx.name === '_perishable_token' || idx.name === '_perishable_token_1')).toBeUndefined();
});
it('should create password reset token index when createIndexUserPasswordResetToken is true', async () => {
await reconfigureServer({
databaseAdapter: undefined,
databaseURI,
databaseOptions: { createIndexUserPasswordResetToken: true },
});
const indexes = await getIndexes('_User');
expect(indexes.find(idx => idx.name === '_perishable_token' || idx.name === '_perishable_token_1')).toBeDefined();
});
it('should skip role name index when createIndexRoleName is false', async () => {
await reconfigureServer({
databaseAdapter: undefined,
databaseURI,
databaseOptions: { createIndexRoleName: false },
});
const indexes = await getIndexes('_Role');
expect(indexes.find(idx => idx.name === 'name_1')).toBeUndefined();
});
it('should create role name index when createIndexRoleName is true', async () => {
await reconfigureServer({
databaseAdapter: undefined,
databaseURI,
databaseOptions: { createIndexRoleName: true },
});
const indexes = await getIndexes('_Role');
expect(indexes.find(idx => idx.name === 'name_1')).toBeDefined();
});
it('should create all indexes by default when options are undefined', async () => {
await reconfigureServer({
databaseAdapter: undefined,
databaseURI,
databaseOptions: {},
});
const userIndexes = await getIndexes('_User');
const roleIndexes = await getIndexes('_Role');
// Verify all indexes are created with default behavior (backward compatibility)
expect(userIndexes.find(idx => idx.name === 'username_1')).toBeDefined();
expect(userIndexes.find(idx => idx.name === 'case_insensitive_username')).toBeDefined();
expect(userIndexes.find(idx => idx.name === 'email_1')).toBeDefined();
expect(userIndexes.find(idx => idx.name === 'case_insensitive_email')).toBeDefined();
expect(userIndexes.find(idx => idx.name === '_email_verify_token' || idx.name === '_email_verify_token_1')).toBeDefined();
expect(userIndexes.find(idx => idx.name === '_perishable_token' || idx.name === '_perishable_token_1')).toBeDefined();
expect(roleIndexes.find(idx => idx.name === 'name_1')).toBeDefined();
});
});
describe('logClientEvents', () => {
it('should log MongoDB client events when configured', async () => {
const logger = require('../lib/logger').logger;
const logSpy = spyOn(logger, 'warn');
const logClientEvents = [
{
name: 'serverDescriptionChanged',
keys: ['address'],
logLevel: 'warn',
},
];
const adapter = new MongoStorageAdapter({
uri: databaseURI,
mongoOptions: { logClientEvents },
});
// Connect to trigger event listeners setup
await adapter.connect();
// Manually trigger the event to test the listener
const mockEvent = {
address: 'localhost:27017',
previousDescription: { type: 'Unknown' },
newDescription: { type: 'Standalone' },
};
adapter.client.emit('serverDescriptionChanged', mockEvent);
// Verify the log was called with the correct message
expect(logSpy).toHaveBeenCalledWith(
jasmine.stringMatching(/MongoDB client event serverDescriptionChanged:.*"address":"localhost:27017"/)
);
await adapter.handleShutdown();
});
it('should log entire event when keys are not specified', async () => {
const logger = require('../lib/logger').logger;
const logSpy = spyOn(logger, 'info');
const logClientEvents = [
{
name: 'connectionPoolReady',
logLevel: 'info',
},
];
const adapter = new MongoStorageAdapter({
uri: databaseURI,
mongoOptions: { logClientEvents },
});
await adapter.connect();
const mockEvent = {
address: 'localhost:27017',
options: { maxPoolSize: 100 },
};
adapter.client.emit('connectionPoolReady', mockEvent);
expect(logSpy).toHaveBeenCalledWith(
jasmine.stringMatching(/MongoDB client event connectionPoolReady:.*"address":"localhost:27017".*"options"/)
);
await adapter.handleShutdown();
});
it('should extract nested keys using dot notation', async () => {
const logger = require('../lib/logger').logger;
const logSpy = spyOn(logger, 'warn');
const logClientEvents = [
{
name: 'topologyDescriptionChanged',
keys: ['previousDescription.type', 'newDescription.type', 'newDescription.servers.size'],
logLevel: 'warn',
},
];
const adapter = new MongoStorageAdapter({
uri: databaseURI,
mongoOptions: { logClientEvents },
});
await adapter.connect();
const mockEvent = {
topologyId: 1,
previousDescription: { type: 'Unknown' },
newDescription: {
type: 'ReplicaSetWithPrimary',
servers: { size: 3 },
},
};
adapter.client.emit('topologyDescriptionChanged', mockEvent);
expect(logSpy).toHaveBeenCalledWith(
jasmine.stringMatching(/MongoDB client event topologyDescriptionChanged:.*"previousDescription.type":"Unknown".*"newDescription.type":"ReplicaSetWithPrimary".*"newDescription.servers.size":3/)
);
await adapter.handleShutdown();
});
it('should handle invalid log level gracefully', async () => {
const logger = require('../lib/logger').logger;
const infoSpy = spyOn(logger, 'info');
const logClientEvents = [
{
name: 'connectionPoolReady',
keys: ['address'],
logLevel: 'invalidLogLevel', // Invalid log level
},
];
const adapter = new MongoStorageAdapter({
uri: databaseURI,
mongoOptions: { logClientEvents },
});
await adapter.connect();
const mockEvent = {
address: 'localhost:27017',
};
adapter.client.emit('connectionPoolReady', mockEvent);
// Should fallback to 'info' level
expect(infoSpy).toHaveBeenCalledWith(
jasmine.stringMatching(/MongoDB client event connectionPoolReady:.*"address":"localhost:27017"/)
);
await adapter.handleShutdown();
});
it('should handle Map and Set instances in events', async () => {
const logger = require('../lib/logger').logger;
const warnSpy = spyOn(logger, 'warn');
const logClientEvents = [
{
name: 'customEvent',
logLevel: 'warn',
},
];
const adapter = new MongoStorageAdapter({
uri: databaseURI,
mongoOptions: { logClientEvents },
});
await adapter.connect();
const mockEvent = {
mapData: new Map([['key1', 'value1'], ['key2', 'value2']]),
setData: new Set([1, 2, 3]),
};
adapter.client.emit('customEvent', mockEvent);
// Should serialize Map and Set properly
expect(warnSpy).toHaveBeenCalledWith(
jasmine.stringMatching(/MongoDB client event customEvent:.*"mapData":\{"key1":"value1","key2":"value2"\}.*"setData":\[1,2,3\]/)
);
await adapter.handleShutdown();
});
it('should handle missing keys in event object', async () => {
const logger = require('../lib/logger').logger;
const infoSpy = spyOn(logger, 'info');
const logClientEvents = [
{
name: 'testEvent',
keys: ['nonexistent.nested.key', 'another.missing'],
logLevel: 'info',
},
];
const adapter = new MongoStorageAdapter({
uri: databaseURI,
mongoOptions: { logClientEvents },
});
await adapter.connect();
const mockEvent = {
actualField: 'value',
};
adapter.client.emit('testEvent', mockEvent);
// Should handle missing keys gracefully with undefined values
expect(infoSpy).toHaveBeenCalledWith(
jasmine.stringMatching(/MongoDB client event testEvent:/)
);
await adapter.handleShutdown();
});
it('should handle circular references gracefully', async () => {
const logger = require('../lib/logger').logger;
const infoSpy = spyOn(logger, 'info');
const logClientEvents = [
{
name: 'circularEvent',
logLevel: 'info',
},
];
const adapter = new MongoStorageAdapter({
uri: databaseURI,
mongoOptions: { logClientEvents },
});
await adapter.connect();
// Create circular reference
const mockEvent = { name: 'test' };
mockEvent.self = mockEvent;
adapter.client.emit('circularEvent', mockEvent);
// Should handle circular reference with [Circular] marker
expect(infoSpy).toHaveBeenCalledWith(
jasmine.stringMatching(/MongoDB client event circularEvent:.*\[Circular\]/)
);
await adapter.handleShutdown();
});
});
describe('transient error handling', () => {
it('should transform MongoWaitQueueTimeoutError to Parse.Error.INTERNAL_SERVER_ERROR', async () => {
const adapter = new MongoStorageAdapter({ uri: databaseURI });
await adapter.connect();
// Create a mock error with the MongoWaitQueueTimeoutError name
const mockError = new Error('Timed out while checking out a connection from connection pool');
mockError.name = 'MongoWaitQueueTimeoutError';
try {
adapter.handleError(mockError);
fail('Expected handleError to throw');
} catch (error) {
expect(error instanceof Parse.Error).toBe(true);
expect(error.code).toBe(Parse.Error.INTERNAL_SERVER_ERROR);
expect(error.message).toBe('Database error');
}
});
it('should transform MongoServerSelectionError to Parse.Error.INTERNAL_SERVER_ERROR', async () => {
const adapter = new MongoStorageAdapter({ uri: databaseURI });
await adapter.connect();
const mockError = new Error('Server selection timed out');
mockError.name = 'MongoServerSelectionError';
try {
adapter.handleError(mockError);
fail('Expected handleError to throw');
} catch (error) {
expect(error instanceof Parse.Error).toBe(true);
expect(error.code).toBe(Parse.Error.INTERNAL_SERVER_ERROR);
expect(error.message).toBe('Database error');
}
});
it('should transform MongoNetworkTimeoutError to Parse.Error.INTERNAL_SERVER_ERROR', async () => {
const adapter = new MongoStorageAdapter({ uri: databaseURI });
await adapter.connect();
const mockError = new Error('Network timeout');
mockError.name = 'MongoNetworkTimeoutError';
try {
adapter.handleError(mockError);
fail('Expected handleError to throw');
} catch (error) {
expect(error instanceof Parse.Error).toBe(true);
expect(error.code).toBe(Parse.Error.INTERNAL_SERVER_ERROR);
expect(error.message).toBe('Database error');
}
});
it('should transform MongoNetworkError to Parse.Error.INTERNAL_SERVER_ERROR', async () => {
const adapter = new MongoStorageAdapter({ uri: databaseURI });
await adapter.connect();
const mockError = new Error('Network error');
mockError.name = 'MongoNetworkError';
try {
adapter.handleError(mockError);
fail('Expected handleError to throw');
} catch (error) {
expect(error instanceof Parse.Error).toBe(true);
expect(error.code).toBe(Parse.Error.INTERNAL_SERVER_ERROR);
expect(error.message).toBe('Database error');
}
});
it('should transform TransientTransactionError to Parse.Error.INTERNAL_SERVER_ERROR', async () => {
const adapter = new MongoStorageAdapter({ uri: databaseURI });
await adapter.connect();
const mockError = new Error('Transient transaction error');
mockError.hasErrorLabel = label => label === 'TransientTransactionError';
try {
adapter.handleError(mockError);
fail('Expected handleError to throw');
} catch (error) {
expect(error instanceof Parse.Error).toBe(true);
expect(error.code).toBe(Parse.Error.INTERNAL_SERVER_ERROR);
expect(error.message).toBe('Database error');
}
});
it('should not transform non-transient errors', async () => {
const adapter = new MongoStorageAdapter({ uri: databaseURI });
await adapter.connect();
const mockError = new Error('Some other error');
mockError.name = 'SomeOtherError';
try {
adapter.handleError(mockError);
fail('Expected handleError to throw');
} catch (error) {
expect(error instanceof Parse.Error).toBe(false);
expect(error.message).toBe('Some other error');
}
});
it('should handle null/undefined errors', async () => {
const adapter = new MongoStorageAdapter({ uri: databaseURI });
await adapter.connect();
try {
adapter.handleError(null);
fail('Expected handleError to throw');
} catch (error) {
expect(error).toBeNull();
}
try {
adapter.handleError(undefined);
fail('Expected handleError to throw');
} catch (error) {
expect(error).toBeUndefined();
}
});
});
describe('MongoDB Client Metadata', () => {
it('should not pass metadata to MongoClient by default', async () => {
const adapter = new MongoStorageAdapter({ uri: databaseURI });
await adapter.connect();
const driverInfo = adapter.client.s.options.driverInfo;
// Either driverInfo should be undefined, or it should not contain our custom metadata
if (driverInfo) {
expect(driverInfo.name).toBeUndefined();
}
await adapter.handleShutdown();
});
it('should pass custom metadata to MongoClient when configured', async () => {
const customMetadata = { name: 'MyParseServer', version: '1.0.0' };
const adapter = new MongoStorageAdapter({
uri: databaseURI,
mongoOptions: { clientMetadata: customMetadata }
});
await adapter.connect();
expect(adapter.client.s.options.driverInfo.name).toBe(customMetadata.name);
expect(adapter.client.s.options.driverInfo.version).toBe(customMetadata.version);
await adapter.handleShutdown();
});
});
});
+2 -20
View File
@@ -33,8 +33,8 @@ describe('parseObjectToMongoObjectForCreate', () => {
const output = transform.parseObjectToMongoObjectForCreate(null, input, {
fields: {},
});
expect(Utils.isDate(output._created_at)).toBe(true);
expect(Utils.isDate(output._updated_at)).toBe(true);
expect(output._created_at instanceof Date).toBe(true);
expect(output._updated_at instanceof Date).toBe(true);
done();
});
@@ -521,23 +521,6 @@ describe('parseObjectToMongoObjectForCreate', () => {
expect(output.authData).toBe('random');
done();
});
it('should only transform authData.provider.id for _User class', () => {
// Test that for _User class, authData.facebook.id is transformed
const userInput = {
'authData.facebook.id': '10000000000000001',
};
const userOutput = transform.transformWhere('_User', userInput, { fields: {} });
expect(userOutput['_auth_data_facebook.id']).toBe('10000000000000001');
// Test that for non-User classes, authData.facebook.id is NOT transformed
const customInput = {
'authData.facebook.id': '10000000000000001',
};
const customOutput = transform.transformWhere('SpamAlerts', customInput, { fields: {} });
expect(customOutput['authData.facebook.id']).toBe('10000000000000001');
expect(customOutput['_auth_data_facebook.id']).toBeUndefined();
});
});
it('cannot have a custom field name beginning with underscore', done => {
@@ -692,5 +675,4 @@ describe('relativeTimeToDate', () => {
});
});
});
});
+2 -2
View File
@@ -87,7 +87,7 @@ describe('OAuth', function () {
done();
}
xit('GET request for a resource that requires OAuth should fail with invalid credentials', done => {
it('GET request for a resource that requires OAuth should fail with invalid credentials', done => {
/*
This endpoint has been chosen to make a request to an endpoint that requires OAuth which fails due to missing authentication.
Any other endpoint from the Twitter API that requires OAuth can be used instead in case the currently used endpoint deprecates.
@@ -105,7 +105,7 @@ describe('OAuth', function () {
});
});
xit('POST request for a resource that requires OAuth should fail with invalid credentials', done => {
it('POST request for a resource that requires OAuth should fail with invalid credentials', done => {
/*
This endpoint has been chosen to make a request to an endpoint that requires OAuth which fails due to missing authentication.
Any other endpoint from the Twitter API that requires OAuth can be used instead in case the currently used endpoint deprecates.
+57 -520
View File
@@ -1,7 +1,6 @@
'use strict';
const request = require('../lib/request');
const path = require('path');
const fs = require('fs').promises;
const mustache = require('mustache');
const Utils = require('../lib/Utils');
@@ -25,7 +24,7 @@ describe('Pages Router', () => {
appId: 'test',
appName: 'exampleAppname',
publicServerURL: 'http://localhost:8378/1',
pages: {},
pages: { enableRouter: true },
};
await reconfigureServer(config);
});
@@ -67,6 +66,7 @@ describe('Pages Router', () => {
it('responds with 404 if publicServerURL is not configured', async () => {
await reconfigureServer({
appName: 'unused',
pages: { enableRouter: true },
});
const urls = [
'http://localhost:8378/1/apps/test/verify_email',
@@ -99,6 +99,7 @@ describe('Pages Router', () => {
await reconfigureServer({
appName: 'exampleAppname',
publicServerURL: 'http://localhost:8378/1',
pages: { enableRouter: true },
});
});
@@ -107,7 +108,7 @@ describe('Pages Router', () => {
const res = await request({
method: 'POST',
url: 'http://localhost:8378/1/apps/test/request_password_reset',
body: `new_password=user1&token=43634643`,
body: `new_password=user1&token=43634643&username=username`,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest',
@@ -123,7 +124,7 @@ describe('Pages Router', () => {
await request({
method: 'POST',
url: 'http://localhost:8378/1/apps/test/request_password_reset',
body: `new_password=&token=132414`,
body: `new_password=&token=132414&username=Johnny`,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest',
@@ -136,12 +137,30 @@ describe('Pages Router', () => {
}
});
it('request_password_reset: responds with AJAX error on missing username', async () => {
try {
await request({
method: 'POST',
url: 'http://localhost:8378/1/apps/test/request_password_reset',
body: `new_password=user1&token=43634643&username=`,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest',
},
followRedirects: false,
});
} catch (error) {
expect(error.status).not.toBe(302);
expect(error.text).toEqual('{"code":200,"error":"Missing username"}');
}
});
it('request_password_reset: responds with AJAX error on missing token', async () => {
try {
await request({
method: 'POST',
url: 'http://localhost:8378/1/apps/test/request_password_reset',
body: `new_password=user1&token=`,
body: `new_password=user1&token=&username=Johnny`,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest',
@@ -189,6 +208,7 @@ describe('Pages Router', () => {
},
publicServerURL: 'http://localhost:8378/1',
pages: {
enableRouter: true,
enableLocalization: true,
customUrls: {},
},
@@ -205,6 +225,9 @@ describe('Pages Router', () => {
describe('server options', () => {
it('uses default configuration when none is set', async () => {
await reconfigureServerWithPagesConfig({});
expect(Config.get(Parse.applicationId).pages.enableRouter).toBe(
Definitions.PagesOptions.enableRouter.default
);
expect(Config.get(Parse.applicationId).pages.enableLocalization).toBe(
Definitions.PagesOptions.enableLocalization.default
);
@@ -220,7 +243,9 @@ describe('Pages Router', () => {
expect(Config.get(Parse.applicationId).pages.forceRedirect).toBe(
Definitions.PagesOptions.forceRedirect.default
);
expect(Config.get(Parse.applicationId).pages.pagesPath).toBeUndefined();
expect(Config.get(Parse.applicationId).pages.pagesPath).toBe(
Definitions.PagesOptions.pagesPath.default
);
expect(Config.get(Parse.applicationId).pages.pagesEndpoint).toBe(
Definitions.PagesOptions.pagesEndpoint.default
);
@@ -238,6 +263,10 @@ describe('Pages Router', () => {
'a',
0,
true,
{ enableRouter: 'a' },
{ enableRouter: 0 },
{ enableRouter: {} },
{ enableRouter: [] },
{ enableLocalization: 'a' },
{ enableLocalization: 0 },
{ enableLocalization: {} },
@@ -548,7 +577,7 @@ describe('Pages Router', () => {
spyOnProperty(Page.prototype, 'defaultFile').and.returnValue(jsonPageFile);
const response = await request({
url: `http://localhost:8378/1/apps/test/request_password_reset?token=exampleToken&locale=${exampleLocale}`,
url: `http://localhost:8378/1/apps/test/request_password_reset?token=exampleToken&username=exampleUsername&locale=${exampleLocale}`,
followRedirects: false,
}).catch(e => e);
expect(response.status).toEqual(200);
@@ -597,7 +626,7 @@ describe('Pages Router', () => {
await reconfigureServer(config);
const response = await request({
url:
'http://localhost:8378/1/apps/test/request_password_reset?token=exampleToken&locale=de-AT',
'http://localhost:8378/1/apps/test/request_password_reset?token=exampleToken&username=exampleUsername&locale=de-AT',
followRedirects: false,
method: 'POST',
});
@@ -611,7 +640,7 @@ describe('Pages Router', () => {
await reconfigureServer(config);
const response = await request({
url:
'http://localhost:8378/1/apps/test/request_password_reset?token=exampleToken&locale=de-AT',
'http://localhost:8378/1/apps/test/request_password_reset?token=exampleToken&username=exampleUsername&locale=de-AT',
followRedirects: false,
method: 'GET',
});
@@ -647,11 +676,13 @@ describe('Pages Router', () => {
const appId = linkResponse.headers['x-parse-page-param-appid'];
const token = linkResponse.headers['x-parse-page-param-token'];
const locale = linkResponse.headers['x-parse-page-param-locale'];
const username = linkResponse.headers['x-parse-page-param-username'];
const publicServerUrl = linkResponse.headers['x-parse-page-param-publicserverurl'];
const passwordResetPagePath = pageResponse.calls.all()[0].args[0];
expect(appId).toBeDefined();
expect(token).toBeDefined();
expect(locale).toBeDefined();
expect(username).toBeDefined();
expect(publicServerUrl).toBeDefined();
expect(passwordResetPagePath).toMatch(
new RegExp(`\/${exampleLocale}\/${pages.passwordReset.defaultFile}`)
@@ -665,6 +696,7 @@ describe('Pages Router', () => {
body: {
token,
locale,
username,
new_password: 'newPassword',
},
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
@@ -706,7 +738,7 @@ describe('Pages Router', () => {
);
});
it_id('2845c2ea-23ba-45d2-a33f-63181d419bca')(it)('localizes end-to-end for verify email: success', async () => {
it('localizes end-to-end for verify email: success', async () => {
await reconfigureServer(config);
const sendVerificationEmail = spyOn(
config.emailAdapter,
@@ -717,7 +749,6 @@ describe('Pages Router', () => {
user.setPassword('examplePassword');
user.set('email', 'mail@example.com');
await user.signUp();
await jasmine.timeout();
const link = sendVerificationEmail.calls.all()[0].args[0].link;
const linkWithLocale = new URL(link);
@@ -735,7 +766,7 @@ describe('Pages Router', () => {
);
});
it_id('f2272b94-b4ac-474f-8e47-1ca74de136f5')(it)('localizes end-to-end for verify email: invalid verification link - link send success', async () => {
it('localizes end-to-end for verify email: invalid verification link - link send success', async () => {
await reconfigureServer(config);
const sendVerificationEmail = spyOn(
config.emailAdapter,
@@ -746,7 +777,6 @@ describe('Pages Router', () => {
user.setPassword('examplePassword');
user.set('email', 'mail@example.com');
await user.signUp();
await jasmine.timeout();
const link = sendVerificationEmail.calls.all()[0].args[0].link;
const linkWithLocale = new URL(link);
@@ -761,13 +791,15 @@ describe('Pages Router', () => {
const appId = linkResponse.headers['x-parse-page-param-appid'];
const locale = linkResponse.headers['x-parse-page-param-locale'];
const username = linkResponse.headers['x-parse-page-param-username'];
const publicServerUrl = linkResponse.headers['x-parse-page-param-publicserverurl'];
const invalidVerificationPagePath = pageResponse.calls.all()[0].args[0];
expect(appId).toBeDefined();
expect(locale).toBe(exampleLocale);
expect(username).toBeDefined();
expect(publicServerUrl).toBeDefined();
expect(invalidVerificationPagePath).toMatch(
new RegExp(`\/${exampleLocale}\/${pages.emailVerificationLinkInvalid.defaultFile}`)
new RegExp(`\/${exampleLocale}\/${pages.emailVerificationLinkExpired.defaultFile}`)
);
const formUrl = `${publicServerUrl}/apps/${appId}/resend_verification_email`;
@@ -776,7 +808,7 @@ describe('Pages Router', () => {
method: 'POST',
body: {
locale,
username: 'exampleUsername',
username,
},
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
followRedirects: false,
@@ -787,7 +819,7 @@ describe('Pages Router', () => {
);
});
it_id('1d46d36a-e455-4ae7-8717-e0d286e95f02')(it)('localizes end-to-end for verify email: invalid verification link - link send fail', async () => {
it('localizes end-to-end for verify email: invalid verification link - link send fail', async () => {
await reconfigureServer(config);
const sendVerificationEmail = spyOn(
config.emailAdapter,
@@ -798,7 +830,6 @@ describe('Pages Router', () => {
user.setPassword('examplePassword');
user.set('email', 'mail@example.com');
await user.signUp();
await jasmine.timeout();
const link = sendVerificationEmail.calls.all()[0].args[0].link;
const linkWithLocale = new URL(link);
@@ -813,15 +844,15 @@ describe('Pages Router', () => {
const appId = linkResponse.headers['x-parse-page-param-appid'];
const locale = linkResponse.headers['x-parse-page-param-locale'];
const username = linkResponse.headers['x-parse-page-param-username'];
const publicServerUrl = linkResponse.headers['x-parse-page-param-publicserverurl'];
await jasmine.timeout();
const invalidVerificationPagePath = pageResponse.calls.all()[0].args[0];
expect(appId).toBeDefined();
expect(locale).toBe(exampleLocale);
expect(username).toBeDefined();
expect(publicServerUrl).toBeDefined();
expect(invalidVerificationPagePath).toMatch(
new RegExp(`\/${exampleLocale}\/${pages.emailVerificationLinkInvalid.defaultFile}`)
new RegExp(`\/${exampleLocale}\/${pages.emailVerificationLinkExpired.defaultFile}`)
);
spyOn(UserController.prototype, 'resendVerificationEmail').and.callFake(() =>
@@ -834,75 +865,12 @@ describe('Pages Router', () => {
method: 'POST',
body: {
locale,
username: 'exampleUsername',
username,
},
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
followRedirects: false,
});
expect(formResponse.status).toEqual(303);
// With emailVerifySuccessOnInvalidEmail: true (default), the resend
// page always redirects to the success page to prevent user enumeration
expect(formResponse.text).toContain(
`/${locale}/${pages.emailVerificationSendSuccess.defaultFile}`
);
});
it('localizes end-to-end for verify email: invalid verification link - link send fail with emailVerifySuccessOnInvalidEmail disabled', async () => {
config.emailVerifySuccessOnInvalidEmail = false;
await reconfigureServer(config);
const sendVerificationEmail = spyOn(
config.emailAdapter,
'sendVerificationEmail'
).and.callThrough();
const user = new Parse.User();
user.setUsername('exampleUsername');
user.setPassword('examplePassword');
user.set('email', 'mail@example.com');
await user.signUp();
await jasmine.timeout();
const link = sendVerificationEmail.calls.all()[0].args[0].link;
const linkWithLocale = new URL(link);
linkWithLocale.searchParams.append(pageParams.locale, exampleLocale);
linkWithLocale.searchParams.set(pageParams.token, 'invalidToken');
const linkResponse = await request({
url: linkWithLocale.toString(),
followRedirects: false,
});
expect(linkResponse.status).toBe(200);
const appId = linkResponse.headers['x-parse-page-param-appid'];
const locale = linkResponse.headers['x-parse-page-param-locale'];
const publicServerUrl = linkResponse.headers['x-parse-page-param-publicserverurl'];
await jasmine.timeout();
const invalidVerificationPagePath = pageResponse.calls.all()[0].args[0];
expect(appId).toBeDefined();
expect(locale).toBe(exampleLocale);
expect(publicServerUrl).toBeDefined();
expect(invalidVerificationPagePath).toMatch(
new RegExp(`\/${exampleLocale}\/${pages.emailVerificationLinkInvalid.defaultFile}`)
);
spyOn(UserController.prototype, 'resendVerificationEmail').and.callFake(() =>
Promise.reject('failed to resend verification email')
);
const formUrl = `${publicServerUrl}/apps/${appId}/resend_verification_email`;
const formResponse = await request({
url: formUrl,
method: 'POST',
body: {
locale,
username: 'exampleUsername',
},
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
followRedirects: false,
});
expect(formResponse.status).toEqual(303);
// With emailVerifySuccessOnInvalidEmail: false, the resend page
// redirects to the fail page
expect(formResponse.text).toContain(
`/${locale}/${pages.emailVerificationSendFail.defaultFile}`
);
@@ -1009,248 +977,6 @@ describe('Pages Router', () => {
expect(response.status).toBe(404);
expect(response.text).toBe('Not found.');
});
it('rejects requesting file from sibling directory with prefix-colliding name via encoded path traversal', async () => {
// Create a temporary pages directory and a sibling directory whose name
// starts with the same prefix (e.g. "pages" vs "pages-secret"), which
// would bypass a naive `startsWith` check without a path separator.
const baseDir = path.join(__dirname, 'tmp-pages-exploit-test');
const pagesDir = path.join(baseDir, 'pages');
const siblingDir = path.join(baseDir, 'pages-secret');
const marker = `SECRET_CONTENT_${Date.now()}`;
try {
await fs.mkdir(pagesDir, { recursive: true });
await fs.mkdir(siblingDir, { recursive: true });
// Copy a required HTML file so the pages router initializes correctly
const publicDir = path.resolve(__dirname, '../public');
const htmlFile = await fs.readFile(
path.join(publicDir, 'email_verification_link_invalid.html'),
'utf-8'
);
await fs.writeFile(
path.join(pagesDir, 'email_verification_link_invalid.html'),
htmlFile
);
// Write a secret file in the sibling directory
await fs.writeFile(path.join(siblingDir, 'secret.txt'), marker);
config.pages.pagesPath = pagesDir;
await reconfigureServer(config);
// Use URL-encoded path traversal: %2e%2e%2f = ../
// This reaches the sibling "pages-secret" directory which shares
// the "pages" prefix with the configured pagesPath directory name.
const url = `${config.publicServerURL}/apps/%2e%2e%2fpages-secret%2fsecret.txt`;
const response = await request({
url: url,
followRedirects: false,
}).catch(e => e);
expect(response.status).toBe(404);
expect(response.text).not.toContain(marker);
} finally {
await fs.rm(baseDir, { recursive: true, force: true });
}
});
it('rejects non-string token in verifyEmail', async () => {
await reconfigureServer(config);
const url = `${config.publicServerURL}/apps/test/verify_email?token[toString]=abc`;
const response = await request({
url: url,
followRedirects: false,
}).catch(e => e);
expect(response.status).not.toBe(500);
});
it('rejects non-string token in requestResetPassword', async () => {
await reconfigureServer(config);
const url = `${config.publicServerURL}/apps/test/request_password_reset?token[toString]=abc`;
const response = await request({
url: url,
followRedirects: false,
}).catch(e => e);
expect(response.status).not.toBe(500);
});
it('rejects non-string token in resetPassword via POST', async () => {
await reconfigureServer(config);
const url = `${config.publicServerURL}/apps/test/request_password_reset`;
const response = await request({
method: 'POST',
url: url,
headers: {
'Content-Type': 'application/json',
},
body: { token: { toString: 'abc' }, new_password: 'newpass123' },
followRedirects: false,
}).catch(e => e);
expect(response.status).not.toBe(500);
});
it('rejects non-string token in resendVerificationEmail via POST', async () => {
await reconfigureServer(config);
const url = `${config.publicServerURL}/apps/test/resend_verification_email`;
const response = await request({
method: 'POST',
url: url,
headers: {
'Content-Type': 'application/json',
},
body: { token: { toString: 'abc' } },
followRedirects: false,
}).catch(e => e);
expect(response.status).not.toBe(500);
});
it('does not leak email verification status via resend page when emailVerifySuccessOnInvalidEmail is true', async () => {
const emailAdapter = {
sendVerificationEmail: () => {},
sendPasswordResetEmail: () => {},
sendMail: () => {},
};
await reconfigureServer({
...config,
verifyUserEmails: true,
emailVerifySuccessOnInvalidEmail: true,
emailAdapter,
});
// Create a user with unverified email
const user = new Parse.User();
user.setUsername('realuser');
user.setPassword('password123');
user.setEmail('real@example.com');
await user.signUp();
const formUrl = `${config.publicServerURL}/apps/${config.appId}/resend_verification_email`;
// Resend for existing unverified user
const existingResponse = await request({
method: 'POST',
url: formUrl,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'username=realuser',
followRedirects: false,
}).catch(e => e);
// Resend for non-existing user
const nonExistingResponse = await request({
method: 'POST',
url: formUrl,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'username=fakeuser',
followRedirects: false,
}).catch(e => e);
// Both should redirect to the same page (success) to prevent enumeration
expect(existingResponse.status).toBe(303);
expect(nonExistingResponse.status).toBe(303);
expect(existingResponse.headers.location).toContain('email_verification_send_success');
expect(nonExistingResponse.headers.location).toContain('email_verification_send_success');
});
it('does leak email verification status via resend page when emailVerifySuccessOnInvalidEmail is false', async () => {
const emailAdapter = {
sendVerificationEmail: () => {},
sendPasswordResetEmail: () => {},
sendMail: () => {},
};
await reconfigureServer({
...config,
verifyUserEmails: true,
emailVerifySuccessOnInvalidEmail: false,
emailAdapter,
});
const formUrl = `${config.publicServerURL}/apps/${config.appId}/resend_verification_email`;
// Resend for non-existing user should redirect to fail page
const nonExistingResponse = await request({
method: 'POST',
url: formUrl,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'username=fakeuser',
followRedirects: false,
}).catch(e => e);
expect(nonExistingResponse.status).toBe(303);
expect(nonExistingResponse.headers.location).toContain('email_verification_send_fail');
});
it('does not create file existence oracle via path traversal in locale query parameter', async () => {
// Create a canary file at a traversable path to test the oracle
const canaryDir = path.join(__dirname, 'tmp-locale-oracle-test');
try {
await fs.mkdir(canaryDir, { recursive: true });
await fs.writeFile(path.join(canaryDir, 'password_reset.html'), 'canary');
config.pages.enableLocalization = true;
await reconfigureServer(config);
// Calculate traversal from pages directory to canary directory
const pagesPath = path.resolve(__dirname, '../public');
const relativePath = path.relative(pagesPath, canaryDir);
// Request with path traversal locale pointing to existing canary file
const existsResponse = await request({
url: `${config.publicServerURL}/apps/${config.appId}/request_password_reset?token=test&username=test&locale=${encodeURIComponent(relativePath)}`,
followRedirects: false,
}).catch(e => e);
// Request with path traversal locale pointing to non-existing directory
const notExistsResponse = await request({
url: `${config.publicServerURL}/apps/${config.appId}/request_password_reset?token=test&username=test&locale=${encodeURIComponent('../../../../../../tmp/nonexistent-dir')}`,
followRedirects: false,
}).catch(e => e);
// Both responses must have the same status — no differential oracle
expect(existsResponse.status).toBe(notExistsResponse.status);
// Canary content must never be served
expect(existsResponse.text).not.toContain('canary');
expect(notExistsResponse.text).not.toContain('canary');
} finally {
await fs.rm(canaryDir, { recursive: true, force: true });
}
});
it('does not create file existence oracle via path traversal in locale header', async () => {
// Create a canary file at a traversable path
const canaryDir = path.join(__dirname, 'tmp-locale-header-test');
try {
await fs.mkdir(canaryDir, { recursive: true });
await fs.writeFile(path.join(canaryDir, 'password_reset.html'), 'canary');
config.pages.enableLocalization = true;
await reconfigureServer(config);
const pagesPath = path.resolve(__dirname, '../public');
const relativePath = path.relative(pagesPath, canaryDir);
// Request with path traversal locale via header pointing to existing file
const existsResponse = await request({
url: `${config.publicServerURL}/apps/${config.appId}/request_password_reset?token=test&username=test`,
headers: { 'x-parse-page-param-locale': relativePath },
followRedirects: false,
}).catch(e => e);
// Request with path traversal locale via header pointing to non-existing directory
const notExistsResponse = await request({
url: `${config.publicServerURL}/apps/${config.appId}/request_password_reset?token=test&username=test`,
headers: { 'x-parse-page-param-locale': '../../../../../../tmp/nonexistent-dir' },
followRedirects: false,
}).catch(e => e);
// Both responses must have the same status — no differential oracle
expect(existsResponse.status).toBe(notExistsResponse.status);
// Canary content must never be served
expect(existsResponse.text).not.toContain('canary');
expect(notExistsResponse.text).not.toContain('canary');
} finally {
await fs.rm(canaryDir, { recursive: true, force: true });
}
});
});
describe('custom route', () => {
@@ -1424,10 +1150,12 @@ describe('Pages Router', () => {
const appId = linkResponse.headers['x-parse-page-param-appid'];
const token = linkResponse.headers['x-parse-page-param-token'];
const username = linkResponse.headers['x-parse-page-param-username'];
const publicServerUrl = linkResponse.headers['x-parse-page-param-publicserverurl'];
const passwordResetPagePath = pageResponse.calls.all()[0].args[0];
expect(appId).toBeDefined();
expect(token).toBeDefined();
expect(username).toBeDefined();
expect(publicServerUrl).toBeDefined();
expect(passwordResetPagePath).toMatch(new RegExp(`\/${pages.passwordReset.defaultFile}`));
pageResponse.calls.reset();
@@ -1438,6 +1166,7 @@ describe('Pages Router', () => {
method: 'POST',
body: {
token,
username,
new_password: 'newPassword',
},
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
@@ -1449,7 +1178,7 @@ describe('Pages Router', () => {
);
});
it_id('81c1c28e-5dfd-4ffb-a09b-283156c08483')(it)('email verification works with custom endpoint', async () => {
it('email verification works with custom endpoint', async () => {
config.pages.pagesEndpoint = 'customEndpoint';
await reconfigureServer(config);
const sendVerificationEmail = spyOn(
@@ -1461,7 +1190,6 @@ describe('Pages Router', () => {
user.setPassword('examplePassword');
user.set('email', 'mail@example.com');
await user.signUp();
await jasmine.timeout();
const link = sendVerificationEmail.calls.all()[0].args[0].link;
const linkResponse = await request({
@@ -1469,201 +1197,10 @@ describe('Pages Router', () => {
followRedirects: false,
});
expect(linkResponse.status).toBe(200);
const pagePath = pageResponse.calls.all()[0].args[0];
expect(pagePath).toMatch(new RegExp(`\/${pages.emailVerificationSuccess.defaultFile}`));
});
});
});
describe('async publicServerURL', () => {
it('resolves async publicServerURL for password reset page', async () => {
const emailAdapter = {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: () => Promise.resolve(),
sendMail: () => {},
};
await reconfigureServer({
appId: 'test',
appName: 'exampleAppname',
verifyUserEmails: true,
emailAdapter,
publicServerURL: () => 'http://localhost:8378/1',
});
const user = new Parse.User();
user.setUsername('asyncUrlUser');
user.setPassword('examplePassword');
user.set('email', 'async-url@example.com');
await user.signUp();
await Parse.User.requestPasswordReset('async-url@example.com');
const response = await request({
url: 'http://localhost:8378/1/apps/test/request_password_reset?token=invalidToken',
followRedirects: false,
}).catch(e => e);
expect(response.status).toBe(200);
expect(response.text).toContain('Invalid password reset link!');
});
it('resolves async publicServerURL for email verification page', async () => {
const emailAdapter = {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: () => Promise.resolve(),
sendMail: () => {},
};
await reconfigureServer({
appId: 'test',
appName: 'exampleAppname',
verifyUserEmails: true,
emailAdapter,
publicServerURL: () => 'http://localhost:8378/1',
});
const response = await request({
url: 'http://localhost:8378/1/apps/test/verify_email?token=invalidToken',
followRedirects: false,
}).catch(e => e);
expect(response.status).toBe(200);
expect(response.text).toContain('Invalid verification link!');
});
});
describe('pagesPath resolution', () => {
it('should serve pages when current working directory differs from module directory', async () => {
const originalCwd = process.cwd();
const os = require('os');
process.chdir(os.tmpdir());
try {
await reconfigureServer({
appId: 'test',
appName: 'exampleAppname',
publicServerURL: 'http://localhost:8378/1',
});
// Request the password reset page with an invalid token;
// even with an invalid token, the server should serve the
// "invalid link" page (200), not a 404. A 404 indicates the
// HTML template files could not be found because pagesPath
// resolved to the wrong directory.
const response = await request({
url: 'http://localhost:8378/1/apps/test/request_password_reset?token=invalidToken',
}).catch(e => e);
expect(response.status).toBe(200);
expect(response.text).toContain('Invalid password reset link');
} finally {
process.chdir(originalCwd);
}
});
});
describe('special characters in config', () => {
it('should not URI-encode page param headers by default', async () => {
await reconfigureServer({
appId: 'test',
appName: 'ExampleAppName',
publicServerURL: 'http://localhost:8378/1',
});
const response = await request({
url: 'http://localhost:8378/1/apps/choose_password?appId=test',
});
expect(response.status).toBe(200);
expect(response.headers['x-parse-page-param-appname']).toBe('ExampleAppName');
expect(response.headers['x-parse-page-param-publicserverurl']).toBe(
'http://localhost:8378/1'
);
});
it('should URI-encode page param headers when encodePageParamHeaders is true', async () => {
await reconfigureServer({
appId: 'test',
appName: 'Product™',
publicServerURL: 'http://localhost:8378/1',
pages: {
encodePageParamHeaders: true,
},
});
const response = await request({
url: 'http://localhost:8378/1/apps/choose_password?appId=test',
});
expect(response.status).toBe(200);
expect(response.headers['x-parse-page-param-appname']).toBe(
encodeURIComponent('Product™')
);
expect(response.headers['x-parse-page-param-publicserverurl']).toBe(
encodeURIComponent('http://localhost:8378/1')
);
});
});
describe('XSS Protection', () => {
beforeEach(async () => {
await reconfigureServer({
appId: 'test',
appName: 'exampleAppname',
publicServerURL: 'http://localhost:8378/1',
});
});
it('should escape XSS payloads in token parameter', async () => {
const xssPayload = '"><script>alert("XSS")</script>';
const response = await request({
url: `http://localhost:8378/1/apps/choose_password?token=${encodeURIComponent(xssPayload)}&username=test&appId=test`,
});
expect(response.status).toBe(200);
expect(response.text).not.toContain('<script>alert("XSS")</script>');
expect(response.text).toContain('&quot;&gt;&lt;script&gt;');
});
it('should escape XSS in username parameter', async () => {
const xssUsername = '<img src=x onerror=alert(1)>';
const response = await request({
url: `http://localhost:8378/1/apps/choose_password?username=${encodeURIComponent(xssUsername)}&appId=test`,
});
expect(response.status).toBe(200);
expect(response.text).not.toContain('<img src=x onerror=alert(1)>');
expect(response.text).toContain('&lt;img');
});
it('should reject XSS payload in locale parameter', async () => {
const xssLocale = '"><svg/onload=alert(1)>';
const response = await request({
url: `http://localhost:8378/1/apps/choose_password?locale=${encodeURIComponent(xssLocale)}&appId=test`,
});
expect(response.status).toBe(200);
// Invalid locale is rejected by format validation, so the XSS
// payload never reaches the page content
expect(response.text).not.toContain('<svg/onload=alert(1)>');
expect(response.text).not.toContain('&quot;&gt;&lt;svg');
});
it('should handle legitimate usernames with quotes correctly', async () => {
const username = "O'Brien";
const response = await request({
url: `http://localhost:8378/1/apps/choose_password?username=${encodeURIComponent(username)}&appId=test`,
});
expect(response.status).toBe(200);
// Should be properly escaped as HTML entity
expect(response.text).toContain('O&#39;Brien');
// Should NOT contain unescaped quote that breaks HTML
expect(response.text).not.toContain('value="O\'Brien"');
});
it('should handle legitimate usernames with ampersands correctly', async () => {
const username = 'Smith & Co';
const response = await request({
url: `http://localhost:8378/1/apps/choose_password?username=${encodeURIComponent(username)}&appId=test`,
});
expect(response.status).toBe(200);
// Should be properly escaped
expect(response.text).toContain('Smith &amp; Co');
});
});
});

Some files were not shown because too many files have changed in this diff Show More