chore: import upstream snapshot with attribution
CD - Docker - GHCR Images / Build and Push Images (push) Waiting to run
i18n - Build Validation / Validate i18n Builds (24) (push) Has been cancelled
CI - Node.js / Lint (24) (push) Has been cancelled
CI - Node.js / Build (24) (push) Has been cancelled
CI - Node.js / Test (24) (push) Has been cancelled
CI - Node.js / Test - Upcoming Changes (24) (push) Has been cancelled
CI - Node.js / Test - i18n (italian, 24) (push) Has been cancelled
CI - Node.js / Test - i18n (portuguese, 24) (push) Has been cancelled
CD - Docker - GHCR Images / Build and Push Images (push) Waiting to run
i18n - Build Validation / Validate i18n Builds (24) (push) Has been cancelled
CI - Node.js / Lint (24) (push) Has been cancelled
CI - Node.js / Build (24) (push) Has been cancelled
CI - Node.js / Test (24) (push) Has been cancelled
CI - Node.js / Test - Upcoming Changes (24) (push) Has been cancelled
CI - Node.js / Test - i18n (italian, 24) (push) Has been cancelled
CI - Node.js / Test - i18n (portuguese, 24) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
# To start developing:
|
||||
|
||||
Wait for the container to build and start. You will see "Done. Press any key to close the terminal." in the terminal when it's ready.
|
||||
|
||||
Once it's running, you can start the development server:
|
||||
|
||||
**Option 1:** Press `Ctrl+Shift+P`, type "Run Task", select "Start Development"
|
||||
**Option 2:** Open a terminal and run:
|
||||
|
||||
```bash
|
||||
pnpm run develop
|
||||
```
|
||||
|
||||
## Optional setup
|
||||
|
||||
For E2E tests:
|
||||
|
||||
```bash
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
For curriculum tests:
|
||||
|
||||
```bash
|
||||
pnpm -F=curriculum install-puppeteer
|
||||
```
|
||||
|
||||
## More information
|
||||
|
||||
For detailed setup instructions and contribution guidelines, visit:
|
||||
https://contribute.freecodecamp.org/how-to-setup-freecodecamp-locally
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"name": "freeCodeCamp",
|
||||
"dockerComposeFile": "docker-compose.yml",
|
||||
"service": "devcontainer",
|
||||
"workspaceFolder": "/workspaces/freeCodeCamp",
|
||||
"mounts": [
|
||||
"source=fcc-node-modules,target=${containerWorkspaceFolder}/node_modules,type=volume"
|
||||
],
|
||||
"forwardPorts": [3000, 8000],
|
||||
"portsAttributes": {
|
||||
"3000": {
|
||||
"label": "API",
|
||||
"onAutoForward": "silent"
|
||||
},
|
||||
"8000": {
|
||||
"label": "Client",
|
||||
"onAutoForward": "notify"
|
||||
}
|
||||
},
|
||||
"otherPortsAttributes": {
|
||||
"onAutoForward": "silent"
|
||||
},
|
||||
"onCreateCommand": "sudo chown node:node node_modules && ([ ! -f .env ] && cp sample.env .env || true)",
|
||||
"updateContentCommand": "pnpm install --prefer-offline",
|
||||
"postCreateCommand": "rsync -a --include='*/' --include='.turbo/***' --exclude='*' /home/node/.cache/fcc/ ./ && set -a && . ./.env && set +a && until mongosh --eval 'rs.status().ok' 2>/dev/null; do sleep 1; done && pnpm seed",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode"
|
||||
],
|
||||
"settings": {
|
||||
"task.allowAutomaticTasks": "on",
|
||||
"tasks": {
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Start API Server",
|
||||
"type": "shell",
|
||||
"command": "pnpm run develop:api",
|
||||
"isBackground": true,
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"group": "develop"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Start Client Server",
|
||||
"type": "shell",
|
||||
"command": "pnpm run develop:client",
|
||||
"isBackground": true,
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"group": "develop"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Start Development",
|
||||
"dependsOn": ["Start API Server", "Start Client Server"],
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Open README",
|
||||
"type": "shell",
|
||||
"command": "code .devcontainer/README.md",
|
||||
"presentation": {
|
||||
"reveal": "silent",
|
||||
"close": true
|
||||
},
|
||||
"runOptions": {
|
||||
"runOn": "folderOpen"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
services:
|
||||
devcontainer:
|
||||
image: ghcr.io/freecodecamp/devcontainer:latest
|
||||
depends_on:
|
||||
- db
|
||||
- setup
|
||||
volumes:
|
||||
- ..:/workspaces/freeCodeCamp:cached
|
||||
network_mode: service:db
|
||||
command: sleep infinity
|
||||
|
||||
db:
|
||||
image: mongo:8.2
|
||||
command: mongod --replSet rs0
|
||||
restart: unless-stopped
|
||||
hostname: mongodb
|
||||
volumes:
|
||||
- db-data:/data/db
|
||||
healthcheck:
|
||||
test: ['CMD', 'mongosh', '--eval', "db.adminCommand('ping')"]
|
||||
interval: 2s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
setup:
|
||||
image: mongo:8.2
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: on-failure:5
|
||||
command: >
|
||||
mongosh --host mongodb:27017 --eval '
|
||||
rs.initiate({
|
||||
_id: "rs0",
|
||||
members: [{ _id: 0, host: "mongodb:27017" }]
|
||||
}).ok || rs.status().ok
|
||||
'
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
driver: local
|
||||
@@ -0,0 +1,11 @@
|
||||
client/.cache
|
||||
client/public
|
||||
.env
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
docker/**/Dockerfile
|
||||
**/*docker-compose*
|
||||
**/node_modules
|
||||
.eslintcache
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[package.json]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,15 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
# These files are binary and should be left untouched
|
||||
*.eot binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.jpeg binary
|
||||
*.jpg binary
|
||||
*.mov binary
|
||||
*.mp3 binary
|
||||
*.mp4 binary
|
||||
*.pdf binary
|
||||
*.png binary
|
||||
*.ttf binary
|
||||
*.woff binary
|
||||
@@ -0,0 +1 @@
|
||||
https://www.freecodecamp.org/funding.json
|
||||
@@ -0,0 +1,36 @@
|
||||
# -------------------------------------------------
|
||||
# CODEOWNERS - For automated review request for
|
||||
# high impact files.
|
||||
#
|
||||
# Important: The order in this file cascades.
|
||||
#
|
||||
# https://help.github.com/articles/about-codeowners
|
||||
# -------------------------------------------------
|
||||
|
||||
# -------------------------------------------------
|
||||
# Files that need attention from primary teams
|
||||
# -------------------------------------------------
|
||||
|
||||
# --- All files
|
||||
* @freecodecamp/dev-team @freecodecamp/curriculum
|
||||
|
||||
# --- Package files for negation ---
|
||||
|
||||
**/package.json @freecodecamp/none
|
||||
**/pnpm-lock.yaml @freecodecamp/none
|
||||
|
||||
# -------------------------------------------------
|
||||
# Files that need attention from i18n & dev team
|
||||
# -------------------------------------------------
|
||||
|
||||
# i18n Quotes
|
||||
**/motivation.json @freeCodeCamp/dev-team @freeCodeCamp/i18n
|
||||
|
||||
|
||||
# -------------------------------------------------
|
||||
# Files that need attention from the mobile team
|
||||
# -------------------------------------------------
|
||||
|
||||
/client/src/redux/prop-types.ts @freeCodeCamp/dev-team @freeCodeCamp/mobile
|
||||
/client/tools/external-curriculum/* @freeCodeCamp/dev-team @freeCodeCamp/mobile
|
||||
/curriculum/schema/challenge-schema.js @freeCodeCamp/dev-team @freeCodeCamp/mobile
|
||||
@@ -0,0 +1,3 @@
|
||||
github: freecodecamp
|
||||
patreon: freecodecamp
|
||||
custom: [www.freecodecamp.org/donate]
|
||||
@@ -0,0 +1,61 @@
|
||||
name: Issue - Content in our Coding Challenges
|
||||
description: Report issues with a specific challenge, like broken tests, unclear instructions, etc.
|
||||
type: Bug
|
||||
labels: ['scope: curriculum', 'status: waiting triage']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: If you're reporting a security issue, don't create a GitHub issue. Instead, visit https://contribute.freecodecamp.org/#/security.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the Issue
|
||||
description: A clear and concise description of the issue you encountered.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Affected Page
|
||||
description: Add a link to the coding challenge with the problem.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Your code
|
||||
description: Copy and paste the code from the editor that you used in between the back-ticks.
|
||||
value: |
|
||||
```
|
||||
|
||||
|
||||
|
||||
```
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: Add a clear and concise description of what you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: If applicable, add screenshots to help explain your problem. You can drag and drop `png`, `jpg`, `gif`, etc. in this box.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: System
|
||||
description: Please complete the following information.
|
||||
value: |
|
||||
- Device: [e.g. iPhone 6, Laptop]
|
||||
- OS: [e.g. iOS 14, Windows 10, Ubuntu 20.04]
|
||||
- Browser: [e.g. Chrome, Safari]
|
||||
- Version: [e.g. 22]
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context about the problem here.
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,60 @@
|
||||
name: Issue - User Interface on our Platforms
|
||||
description: Report a software bug on /learn, /news, Community Forum, Code Radio, or any of the platforms.
|
||||
type: Bug
|
||||
labels: ['status: waiting triage']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: If you're reporting a security issue, don't create a GitHub issue. Instead, visit https://contribute.freecodecamp.org/#/security.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the Issue
|
||||
description: A clear and concise description of the issue you encountered.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Affected Page
|
||||
description: Add a link to the page with the problem.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Please provide the steps to reproduce the issue.
|
||||
value: |
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. Scroll down to '...'
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: Add a clear and concise description of what you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: If applicable, add screenshots to help explain your problem. You can drag and drop `png`, `jpg`, `gif`, etc. in this box.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: System
|
||||
description: Please complete the following information.
|
||||
value: |
|
||||
- Device: [e.g. iPhone 6, Laptop]
|
||||
- OS: [e.g. iOS 14, Windows 10, Ubuntu 20.04]
|
||||
- Browser: [e.g. Chrome, Safari]
|
||||
- Version: [e.g. 22]
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context about the problem here.
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Issues - Content in our Articles and other Documentation
|
||||
description: Report issues with content on a specific article, like broken links, typos, missing parts, etc.
|
||||
type: Bug
|
||||
labels: ['status: waiting triage']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: 'NOTE: If you want to become an author on freeCodeCamp, you can find everything here: https://www.freecodecamp.org/news/developer-news-style-guide'
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: If you are reporting an issue with an article on our news publication, please follow this link to send an email to our editorial team https://mailxto.com/lkj5n7
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the Issue
|
||||
description: A clear and concise description of the issue you encountered.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Affected Page
|
||||
description: Add a link to the article or documentation page with the problem.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Please describe the problem and provide the steps to reproduce the issue.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Recommended fix or suggestions
|
||||
description: A clear and concise description of how you want to update it.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: If applicable, add screenshots to help explain your problem. You can drag and drop `png`, `jpg`, `gif`, etc. in this box.
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,29 @@
|
||||
name: New Feature - Request a new feature for our Platforms
|
||||
description: Suggest an idea for freeCodeCamp.org's /learn, /news, Community Forum, Code Radio, or other platforms.
|
||||
type: Enhancement
|
||||
labels: ['status: waiting triage']
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Is your feature request related to a problem? Please describe.
|
||||
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,11 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Request programming help with coding challenges
|
||||
url: https://forum.freecodecamp.org
|
||||
about: Please visit this page for general support and programming help on coding challenges.
|
||||
- name: Report issues with content on /news articles
|
||||
url: https://mailxto.com/lkj5n7
|
||||
about: Please fill out an email to our editorial team to report issues on specific articles on our technical publication (link opens an email template)
|
||||
- name: Request technical support for your freeCodeCamp account
|
||||
url: https://www.freecodecamp.org/support
|
||||
about: Please visit this page for requesting technical support related to your freeCodeCamp account.
|
||||
@@ -0,0 +1,14 @@
|
||||
Checklist:
|
||||
|
||||
<!-- Please follow this checklist and put an x in each of the boxes, like this: [x]. It will ensure that our team takes your pull request seriously. -->
|
||||
|
||||
- [ ] I have read and followed the [contribution guidelines](https://contribute.freecodecamp.org).
|
||||
- [ ] I have read and followed the [how to open a pull request guide](https://contribute.freecodecamp.org/how-to-open-a-pull-request/).
|
||||
- [ ] My pull request targets the `main` branch of freeCodeCamp.
|
||||
- [ ] I have tested these changes either locally on my machine, or GitHub Codespaces.
|
||||
|
||||
<!--If your pull request closes a GitHub issue, replace the XXXXX below with the issue number.-->
|
||||
|
||||
Closes #XXXXX
|
||||
|
||||
<!-- Feel free to add any additional description of changes below this line -->
|
||||
@@ -0,0 +1,75 @@
|
||||
# Caching Behaviour:
|
||||
# ┌─────────────────────────┬─────────────────┬──────────────────┐
|
||||
# │ Context │ Can Read Cache? │ Can Write Cache? │
|
||||
# ├─────────────────────────┼─────────────────┼──────────────────┤
|
||||
# │ main (push) │ YES │ YES │
|
||||
# ├─────────────────────────┼─────────────────┼──────────────────┤
|
||||
# │ PRs / temp-* / hotfix-* │ YES │ NO │
|
||||
# ├─────────────────────────┼─────────────────┼──────────────────┤
|
||||
# │ prod-* │ NO │ NO │
|
||||
# ├─────────────────────────┼─────────────────┼──────────────────┤
|
||||
# │ Fork PRs │ NO │ NO │
|
||||
# └─────────────────────────┴─────────────────┴──────────────────┘
|
||||
|
||||
name: 'Setup Turbo Remote Cache'
|
||||
description: 'Conditionally configure Turbo remote cache based on branch and event context'
|
||||
|
||||
inputs:
|
||||
turbo-token:
|
||||
description: 'Turbo remote cache authentication token'
|
||||
required: true
|
||||
turbo-signature-key:
|
||||
description: 'Turbo remote cache signature key for artifact signing/verification'
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Configure Turbo Remote Cache
|
||||
shell: bash
|
||||
env:
|
||||
TURBO_TOKEN: ${{ inputs.turbo-token }}
|
||||
TURBO_SIGNATURE_KEY: ${{ inputs.turbo-signature-key }}
|
||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
echo "::group::Turbo Cache Configuration"
|
||||
echo "Branch: $GITHUB_REF_NAME"
|
||||
echo "Event: $GITHUB_EVENT_NAME"
|
||||
echo "Base ref: $GITHUB_BASE_REF"
|
||||
|
||||
# Skip for deployment branches (pure builds)
|
||||
if [[ "$GITHUB_REF_NAME" == prod-* ]]; then
|
||||
echo "::notice::Deployment branch detected - Turbo cache DISABLED for pure build"
|
||||
echo "::endgroup::"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Skip if secrets are not available (fork PRs)
|
||||
if [[ -z "$TURBO_TOKEN" || -z "$TURBO_SIGNATURE_KEY" ]]; then
|
||||
echo "::notice::Turbo secrets not available (likely a fork PR) - Turbo cache DISABLED"
|
||||
echo "::endgroup::"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Base configuration for all other contexts
|
||||
echo "TURBO_API=https://turbo-cache.freecodecamp.net" >> $GITHUB_ENV
|
||||
echo "TURBO_TEAM=team_freecodecamp" >> $GITHUB_ENV
|
||||
echo "TURBO_TOKEN=$TURBO_TOKEN" >> $GITHUB_ENV
|
||||
echo "TURBO_REMOTE_CACHE_SIGNATURE_KEY=$TURBO_SIGNATURE_KEY" >> $GITHUB_ENV
|
||||
echo "TURBO_TELEMETRY_DISABLED=1" >> $GITHUB_ENV
|
||||
|
||||
# Determine if this context should have write access
|
||||
# Write access: main branch push
|
||||
# Read-only: PRs and other branches (can read from cache, can't pollute it)
|
||||
if [[ "$GITHUB_REF_NAME" == "main" && "$GITHUB_EVENT_NAME" == "push" ]]; then
|
||||
echo "::notice::Main branch push - Turbo cache READ/WRITE enabled"
|
||||
else
|
||||
# All other contexts: read-only
|
||||
# Use TURBO_CACHE=remote:r for read-only remote cache (local still read/write)
|
||||
echo "TURBO_CACHE=local:rw,remote:r" >> $GITHUB_ENV
|
||||
echo "::notice::PR/other branch - Turbo cache READ-ONLY enabled"
|
||||
fi
|
||||
|
||||
echo "::endgroup::"
|
||||
@@ -0,0 +1,186 @@
|
||||
# GitHub Copilot Code Review Instructions
|
||||
|
||||
## Core Principles
|
||||
|
||||
**BE EXTREMELY MINIMAL.** Only provide actionable feedback.
|
||||
|
||||
**Skip all non-essential content:**
|
||||
- Do not generate "Pull request overview" sections
|
||||
- Do not create "Changes:" lists describing what was changed
|
||||
- Do not create "Reviewed changes" sections or tables
|
||||
- Do not list files with change descriptions
|
||||
- Do not count how many files were reviewed
|
||||
- Do not summarize what the PR does (visible in the diff)
|
||||
|
||||
**Focus only on problems:**
|
||||
- Comment ONLY on actual issues that need fixing
|
||||
- Keep each comment 1-3 sentences
|
||||
- If everything is correct, provide no output
|
||||
|
||||
**Bad example (from PR 65578):**
|
||||
```
|
||||
## Pull request overview
|
||||
This PR addresses issue 65331 by replacing em dash characters...
|
||||
|
||||
### Reviewed changes
|
||||
Copilot reviewed 4 out of 4 changed files...
|
||||
|
||||
| File | Description |
|
||||
| ---- | ----------- |
|
||||
| file1.md | Updated em dash in seed content |
|
||||
```
|
||||
|
||||
**Good example (actionable feedback only):**
|
||||
```
|
||||
Line 46: Test will fail - `innerText` returns rendered `—`, not `—`.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Focus Areas
|
||||
|
||||
1. **Test Coverage** - Ensuring adequate testing for code changes
|
||||
2. **Pull Request Guidelines** - Compliance with contribution standards
|
||||
|
||||
---
|
||||
|
||||
## Focus Area 1: Test Coverage
|
||||
|
||||
When reviewing pull requests, ensure adequate test coverage for code changes in JavaScript, TypeScript, JSX, and TSX files.
|
||||
|
||||
> Detailed test coverage rules are in `.github/instructions/testing.instructions.md`
|
||||
|
||||
### Quick Reference
|
||||
|
||||
| Location | Framework | File Pattern |
|
||||
|----------|-----------|--------------|
|
||||
| `api/` | Vitest | `*.test.ts` |
|
||||
| `client/` | Vitest | `*.test.{ts,tsx}` |
|
||||
| `e2e/` | Playwright | `*.spec.ts` |
|
||||
|
||||
### When to Comment
|
||||
|
||||
Only leave a comment if:
|
||||
|
||||
- The PR modifies JavaScript/TypeScript/JSX/TSX code (bug fixes or new features)
|
||||
- AND there are no corresponding test additions or updates
|
||||
|
||||
### When NOT to Comment
|
||||
|
||||
Do not comment if:
|
||||
|
||||
- Changes are only to documentation, configuration, or non-JS/TS files
|
||||
- The PR includes appropriate test coverage for the changes
|
||||
- Changes are test-only modifications
|
||||
|
||||
### Comment Style
|
||||
|
||||
If tests are missing, provide ONE brief comment:
|
||||
|
||||
```
|
||||
Missing tests for [specific file]. Consider:
|
||||
- [specific scenario]
|
||||
- [edge case]
|
||||
```
|
||||
|
||||
If test coverage is sufficient, **DO NOT COMMENT**. No "LGTM" needed.
|
||||
|
||||
---
|
||||
|
||||
## Focus Area 2: Pull Request Guidelines
|
||||
|
||||
When reviewing pull requests, verify compliance with [freeCodeCamp's contribution standards](https://contribute.freecodecamp.org/how-to-open-a-pull-request).
|
||||
|
||||
### PR Title Format
|
||||
|
||||
Check that the title follows conventional commits format:
|
||||
`<type>([optional scope]): <description>`
|
||||
|
||||
**Valid types:** `fix`, `feat`, `refactor`, `docs`, `test`
|
||||
|
||||
**Common scopes:** `curriculum`, `client`, `api`, `i18n`, `a11y`, `tools`
|
||||
|
||||
Flag if:
|
||||
|
||||
- Title is vague (e.g., "Update file", "Fix bug", "Changes")
|
||||
- Missing type prefix
|
||||
- Description exceeds ~50 characters
|
||||
- Type doesn't match the actual changes
|
||||
|
||||
Example of good title:
|
||||
|
||||
```
|
||||
fix(client): resolve login button alignment on mobile
|
||||
```
|
||||
|
||||
Example of bad title:
|
||||
|
||||
```
|
||||
Fixed stuff
|
||||
```
|
||||
|
||||
### PR Description & Issue Linking
|
||||
|
||||
Check for:
|
||||
|
||||
- Meaningful description explaining what changes were made and why
|
||||
- Proper issue linking using `Closes #XXXXX` format (not just `#XXXXX`)
|
||||
- Screenshots included for UI/visual changes
|
||||
|
||||
Flag if:
|
||||
|
||||
- Description is empty or only contains template boilerplate
|
||||
- Issue reference uses incorrect format (e.g., `fixes XXXXX` without `#`)
|
||||
- UI changes lack screenshots
|
||||
|
||||
### Checklist Completion
|
||||
|
||||
Verify the PR template checklist items are completed:
|
||||
|
||||
- Boxes should be checked (`[x]`) not left unchecked (`[ ]`)
|
||||
- Placeholder text like `#XXXXX` should be replaced with actual issue numbers
|
||||
|
||||
Flag if:
|
||||
|
||||
- Checklist boxes are left unchecked
|
||||
- Placeholder issue number `#XXXXX` remains unchanged
|
||||
|
||||
### Comment Style
|
||||
|
||||
Keep feedback minimal (one line when possible):
|
||||
|
||||
```
|
||||
Update PR title to format: `<type>(scope): description`
|
||||
See: https://contribute.freecodecamp.org/how-to-open-a-pull-request
|
||||
```
|
||||
|
||||
```
|
||||
Link issue using: `Closes #XXXXX`
|
||||
```
|
||||
|
||||
If PR guidelines are followed, **DO NOT COMMENT**. No "LGTM" needed.
|
||||
|
||||
---
|
||||
|
||||
## General Guidelines
|
||||
|
||||
**Focus on Actionable Issues Only**
|
||||
|
||||
### Strict Rules
|
||||
|
||||
- NO summaries, overviews, or descriptions of changes
|
||||
- NO tables or file listings
|
||||
- NO "LGTM" or affirmative comments when everything is fine
|
||||
- Only comment when action is required
|
||||
- Keep each comment brief and actionable
|
||||
|
||||
### Prioritization
|
||||
|
||||
When multiple issues exist, address them in order of severity:
|
||||
|
||||
1. Security vulnerabilities or critical bugs
|
||||
2. Missing test coverage for new functionality
|
||||
3. Outdated tests for modified functionality
|
||||
4. PR title/description compliance
|
||||
|
||||
Comment on all legitimate issues, but keep each comment concise.
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
applyTo: "**/*.{ts,tsx,js,jsx}"
|
||||
---
|
||||
|
||||
# Test Coverage Review Instructions
|
||||
|
||||
Review code changes for adequate test coverage using freeCodeCamp's testing conventions.
|
||||
|
||||
## Testing Framework Reference
|
||||
|
||||
| Location | Framework | File Pattern | Notes |
|
||||
|----------|-----------|--------------|-------|
|
||||
| `api/` | Vitest | `*.test.ts` | Co-located with source |
|
||||
| `client/` | Vitest | `*.test.{ts,tsx}` | Co-located with source |
|
||||
| `e2e/` | Playwright | `*.spec.ts` | Dedicated directory |
|
||||
|
||||
## When to Flag Missing Tests
|
||||
|
||||
Comment on missing tests if ALL of these are true:
|
||||
|
||||
- PR modifies functional code (bug fix or new feature)
|
||||
- No corresponding test additions or modifications exist
|
||||
- Changes are not test-only or config-only
|
||||
|
||||
### Examples Requiring Tests
|
||||
|
||||
- New utility functions in `api/src/utils/` or `client/src/utils/`
|
||||
- New API route handlers in `api/src/routes/`
|
||||
- New React components with logic in `client/src/components/`
|
||||
- Bug fixes that change application behavior
|
||||
- New validation logic or data transformations
|
||||
|
||||
## When NOT to Flag
|
||||
|
||||
Do not comment on test coverage if:
|
||||
|
||||
- Changes are documentation, configuration, or non-functional
|
||||
- PR already includes appropriate test coverage
|
||||
- Changes are to test files themselves
|
||||
- Changes are trivial:
|
||||
- Import reorganization
|
||||
- Formatting changes
|
||||
- Type-only changes (interfaces, type definitions)
|
||||
- Comment updates
|
||||
- Changes are in areas without existing test patterns:
|
||||
- Curriculum markdown files
|
||||
- Configuration files
|
||||
- Build scripts
|
||||
|
||||
## Detecting Outdated Tests
|
||||
|
||||
Flag if existing tests may be outdated:
|
||||
|
||||
- Test file exists for modified source but doesn't cover the changed functionality
|
||||
- Test assertions reference behavior that is being changed
|
||||
- Mock data doesn't reflect new data structures or API responses
|
||||
- Test descriptions no longer match actual test behavior
|
||||
|
||||
### Example Comment for Outdated Tests
|
||||
|
||||
```
|
||||
Tests in `src/utils/validate.test.ts` need updates for new validation rules.
|
||||
```
|
||||
|
||||
## Comment Format
|
||||
|
||||
### Missing Tests
|
||||
|
||||
Brief, actionable feedback only:
|
||||
|
||||
```
|
||||
Missing tests for `src/utils/validate.ts`:
|
||||
- Valid input case
|
||||
- Invalid input error
|
||||
- Empty string edge case
|
||||
```
|
||||
|
||||
### Sufficient Coverage
|
||||
|
||||
**DO NOT COMMENT.** Silence means approval.
|
||||
|
||||
### Outdated Tests
|
||||
|
||||
```
|
||||
Tests in `[test file]` need updates for `[source file]` changes:
|
||||
- [specific change]
|
||||
```
|
||||
|
||||
## Test Quality Indicators
|
||||
|
||||
When tests are present, briefly verify:
|
||||
|
||||
- Tests cover the happy path
|
||||
- Tests cover at least one error/edge case
|
||||
- Test descriptions are meaningful (not just "test 1", "test 2")
|
||||
- Mocks are appropriate (not mocking the thing being tested)
|
||||
|
||||
Do not block PRs for test style preferences if coverage is adequate.
|
||||
@@ -0,0 +1,26 @@
|
||||
'scope: curriculum':
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: curriculum/challenges/**/*
|
||||
|
||||
'platform: learn':
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: client/**/*
|
||||
|
||||
'platform: api':
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: api/**/*
|
||||
|
||||
'scope: tools/scripts':
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- tools/**/*
|
||||
- .github/**/*
|
||||
- utils/**/*
|
||||
- e2e/**/*
|
||||
|
||||
'scope: i18n':
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- client/i18n/**/*
|
||||
- config/crowdin/**/*
|
||||
- shared/config/i18n/**/*
|
||||
@@ -0,0 +1,24 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
const prAuthor = context.payload.pull_request.user.login;
|
||||
|
||||
const teamSlugs = ['dev-team', 'curriculum', 'staff', 'moderators'];
|
||||
const membershipChecks = teamSlugs.map(team_slug =>
|
||||
github.rest.teams
|
||||
.getMembershipForUserInOrg({
|
||||
org: 'freeCodeCamp',
|
||||
team_slug,
|
||||
username: prAuthor
|
||||
})
|
||||
.then(({ data }) => data.state === 'active')
|
||||
.catch(() => false)
|
||||
);
|
||||
const results = await Promise.all(membershipChecks);
|
||||
const isOrgTeamMember = results.some(Boolean);
|
||||
|
||||
const isAllowListed =
|
||||
isOrgTeamMember || ['camperbot', 'renovate[bot]'].includes(prAuthor);
|
||||
|
||||
core.setOutput('is_allow_listed', isAllowListed);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
'use strict';
|
||||
|
||||
async function addDeprioritizedLabel(github, context) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
labels: ['deprioritized']
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = async ({ github, context, core, isAllowListed }) => {
|
||||
if (isAllowListed === 'true') return;
|
||||
|
||||
const result = await github.graphql(
|
||||
`query($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
closingIssuesReferences(first: 10) {
|
||||
nodes {
|
||||
number
|
||||
labels(first: 10) { nodes { name } }
|
||||
assignees(first: 10) { nodes { login } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
number: context.payload.pull_request.number
|
||||
}
|
||||
);
|
||||
|
||||
const pr = result.repository?.pullRequest;
|
||||
if (!pr) return;
|
||||
|
||||
const linkedIssues = pr.closingIssuesReferences.nodes;
|
||||
|
||||
if (linkedIssues.length === 0) {
|
||||
core.setOutput('failure_reason', 'no_linked_issue');
|
||||
core.setFailed('No linked issue found.');
|
||||
await addDeprioritizedLabel(github, context);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasWaitingTriage = linkedIssues.some(issue =>
|
||||
issue.labels.nodes.some(l => l.name === 'status: waiting triage')
|
||||
);
|
||||
if (hasWaitingTriage) {
|
||||
core.setOutput('failure_reason', 'waiting_triage');
|
||||
core.setFailed('Linked issue has not been triaged yet.');
|
||||
await addDeprioritizedLabel(github, context);
|
||||
return;
|
||||
}
|
||||
|
||||
const prAuthor = context.payload.pull_request.user.login;
|
||||
const isNaomiSprintAssignee = linkedIssues.some(
|
||||
issue =>
|
||||
issue.labels.nodes.some(l => l.name === "Naomi's Sprints") &&
|
||||
issue.assignees.nodes.some(a => a.login === prAuthor)
|
||||
);
|
||||
if (isNaomiSprintAssignee) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
labels: ["Naomi's Sprints"]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isOpenForContribution = linkedIssues.some(issue =>
|
||||
issue.labels.nodes.some(
|
||||
l => l.name === 'help wanted' || l.name === 'first timers only'
|
||||
)
|
||||
);
|
||||
if (!isOpenForContribution) {
|
||||
core.setOutput('failure_reason', 'not_open_for_contribution');
|
||||
core.setFailed('Linked issue is not open for contribution.');
|
||||
await addDeprioritizedLabel(github, context);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = async ({ github, context, core, isAllowListed }) => {
|
||||
if (isAllowListed === 'true') return;
|
||||
|
||||
const body = (context.payload.pull_request.body || '').toLowerCase();
|
||||
|
||||
// The template must be present and the first 3 checkboxes must be
|
||||
// ticked. The last checkbox (tested locally) is acceptable to leave
|
||||
// unticked.
|
||||
const templatePresent = body.includes('checklist:');
|
||||
const requiredTicked = [
|
||||
'i have read and followed the contribution guidelines',
|
||||
'i have read and followed the how to open a pull request guide',
|
||||
'my pull request targets the'
|
||||
];
|
||||
// Strip markdown links ([text](url) → text) before matching so contributors
|
||||
// who omit the link syntax (e.g. type plain text) still pass the check.
|
||||
const normalizedBody = body
|
||||
.replace(/\[\s*x\s*\]/g, '[x]')
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
|
||||
const allRequiredTicked = requiredTicked.every(item =>
|
||||
normalizedBody.includes(`[x] ${item}`)
|
||||
);
|
||||
|
||||
if (templatePresent && allRequiredTicked) return;
|
||||
|
||||
core.setOutput('failure_reason', 'incomplete_checklist');
|
||||
core.setFailed(
|
||||
'PR description is missing the required checklist or some items are incomplete.'
|
||||
);
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
labels: ['deprioritized']
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
'use strict';
|
||||
|
||||
// Returns the minimum number of single-character edits (insert, delete, substitute)
|
||||
// needed to turn string `a` into string `b`.
|
||||
function levenshtein(a, b) {
|
||||
const dp = Array.from({ length: a.length + 1 }, (_, i) =>
|
||||
Array.from({ length: b.length + 1 }, (_, j) =>
|
||||
i === 0 ? j : j === 0 ? i : 0
|
||||
)
|
||||
);
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
dp[i][j] =
|
||||
a[i - 1] === b[j - 1]
|
||||
? dp[i - 1][j - 1]
|
||||
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
||||
}
|
||||
}
|
||||
return dp[a.length][b.length];
|
||||
}
|
||||
|
||||
module.exports = async ({ github, context }) => {
|
||||
const title = context.payload.pull_request.title;
|
||||
const ccRegex =
|
||||
/^(feat|fix|refactor|docs|chore|build|ci|test|perf|revert)(\([^)]+\))?: .+/;
|
||||
|
||||
if (ccRegex.test(title)) return;
|
||||
|
||||
const types = [
|
||||
'feat',
|
||||
'fix',
|
||||
'refactor',
|
||||
'docs',
|
||||
'chore',
|
||||
'build',
|
||||
'ci',
|
||||
'test',
|
||||
'perf',
|
||||
'revert'
|
||||
];
|
||||
|
||||
let newTitle = title;
|
||||
|
||||
// Fix 1: space between type and scope — "feat (scope):" → "feat(scope):"
|
||||
newTitle = newTitle.replace(/^(\w+)\s+(\([^)]+\):)/, '$1$2');
|
||||
|
||||
// Fix 2: missing colon after scope — "feat(scope) desc" → "feat(scope): desc"
|
||||
newTitle = newTitle.replace(/^(\w+\([^)]+\)) ([^:])/, '$1: $2');
|
||||
|
||||
// Fix 3: typo in type — "refator(scope):" → "refactor(scope):" (distance ≤ 2)
|
||||
const typoMatch = newTitle.match(/^(\w+)(\([^)]+\))?:/);
|
||||
if (typoMatch) {
|
||||
const candidate = typoMatch[1];
|
||||
if (!types.includes(candidate)) {
|
||||
const closest = types
|
||||
.map(t => ({ t, d: levenshtein(candidate, t) }))
|
||||
.filter(x => x.d <= 2)
|
||||
.sort((a, b) => a.d - b.d)[0];
|
||||
if (closest) newTitle = newTitle.replace(candidate, closest.t);
|
||||
}
|
||||
}
|
||||
|
||||
// Fix 4: missing space after colon — "fix:desc" → "fix: desc"
|
||||
newTitle = newTitle.replace(/^(\w+(?:\([^)]+\))?):(\S)/, '$1: $2');
|
||||
|
||||
// Catch-all: prefix with "fix: " if still not a valid CC title
|
||||
if (!ccRegex.test(newTitle)) {
|
||||
newTitle = `fix: ${newTitle}`;
|
||||
}
|
||||
|
||||
if (newTitle !== title) {
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
title: newTitle
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
'use strict';
|
||||
|
||||
const FOOTER =
|
||||
'\n\n---\nJoin us in our [chat room](https://discord.gg/PRyKn3Vbay) or our [forum](https://forum.freecodecamp.org/c/contributors/3) if you have any questions or need help with contributing.';
|
||||
|
||||
const TEMPLATE_BLOCK = [
|
||||
'```md',
|
||||
'Checklist:',
|
||||
'',
|
||||
'<!-- Please follow this checklist and put an x in each of the boxes, like this: [x]. It will ensure that our team takes your pull request seriously. -->',
|
||||
'',
|
||||
'- [ ] I have read and followed the [contribution guidelines](https://contribute.freecodecamp.org).',
|
||||
'- [ ] I have read and followed the [how to open a pull request guide](https://contribute.freecodecamp.org/how-to-open-a-pull-request/).',
|
||||
"- [ ] My pull request targets the `main` branch of freeCodeCamp.",
|
||||
'- [ ] I have tested these changes either locally on my machine, or GitHub Codespaces.',
|
||||
'',
|
||||
'<!--If your pull request closes a GitHub issue, replace the XXXXX below with the issue number.-->',
|
||||
'',
|
||||
'Closes #XXXXX',
|
||||
'',
|
||||
'<!-- Feel free to add any additional description of changes below this line -->',
|
||||
'```'
|
||||
].join('\n');
|
||||
|
||||
const MESSAGES = {
|
||||
incomplete_checklist: [
|
||||
'**Checklist:** The PR description is missing the required checklist or some of its items are not completed:',
|
||||
'',
|
||||
'1. The `Checklist:` heading is present in the PR description.',
|
||||
'2. The checkbox items are ticked (changed from `[ ]` to `[x]`).',
|
||||
'3. You have actually completed the items in the checklist.',
|
||||
'',
|
||||
'Please edit your PR description to include the following template with the checklist items completed.',
|
||||
'',
|
||||
TEMPLATE_BLOCK
|
||||
].join('\n'),
|
||||
|
||||
no_linked_issue: [
|
||||
'**Linked Issue:** We kindly ask that contributors open an issue before submitting a PR so the change can be discussed and approved before work begins. This helps avoid situations where significant effort goes into something we ultimately cannot merge.',
|
||||
'',
|
||||
'Please open an issue first and allow it to be triaged. Once the issue is open for contribution, you are welcome to update this pull request to reflect the issue consensus. Until then, we will not be able to review your pull request.'
|
||||
].join('\n'),
|
||||
|
||||
waiting_triage: [
|
||||
'**Linked Issue:** The linked issue has not been triaged yet, and a solution has not been agreed upon. Once the issue is open for contribution, you are welcome to update this pull request to reflect the issue consensus. Until then, we will not be able to review your pull request.'
|
||||
].join('\n'),
|
||||
|
||||
not_open_for_contribution:
|
||||
'**Linked Issue:** The linked issue is not open for contribution. If you are looking for issues to contribute to, please check out issues labeled [`help wanted`](https://github.com/freeCodeCamp/freeCodeCamp/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) or [`first timers only`](https://github.com/freeCodeCamp/freeCodeCamp/issues?q=is%3Aissue+is%3Aopen+label%3A%22first+timers+only%22).'
|
||||
};
|
||||
|
||||
module.exports = async ({
|
||||
github,
|
||||
context,
|
||||
templateResult,
|
||||
templateReason,
|
||||
linkedIssueResult,
|
||||
linkedIssueReason
|
||||
}) => {
|
||||
const allPassed =
|
||||
templateResult === 'success' && linkedIssueResult === 'success';
|
||||
|
||||
if (allPassed) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
name: 'deprioritized'
|
||||
});
|
||||
} catch {
|
||||
// Label may not exist — ignore.
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// On edit, don't re-comment — the original comment is already there.
|
||||
if (context.payload.action === 'edited') return;
|
||||
|
||||
const sections = [];
|
||||
if (templateResult === 'failure' && MESSAGES[templateReason]) {
|
||||
sections.push(MESSAGES[templateReason]);
|
||||
}
|
||||
if (linkedIssueResult === 'failure' && MESSAGES[linkedIssueReason]) {
|
||||
sections.push(MESSAGES[linkedIssueReason]);
|
||||
}
|
||||
|
||||
if (sections.length === 0) return;
|
||||
|
||||
const body =
|
||||
[
|
||||
'Hi there,',
|
||||
'',
|
||||
'Thanks for opening this pull request.',
|
||||
'',
|
||||
'The automated checks found some issues:',
|
||||
'',
|
||||
sections.join('\n\n')
|
||||
].join('\n') + FOOTER;
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,342 @@
|
||||
name: i18n - Download Client UI
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# runs Monday and Wednesday at 12:15 PM UTC
|
||||
- cron: '15 12 * * 1,3'
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.CROWDIN_CAMPERBOT_PAT }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_CAMPERBOT_SERVICE_TOKEN }}
|
||||
CROWDIN_API_URL: 'https://freecodecamp.crowdin.com/api/v2/'
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID_CLIENT }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
i18n-download-client-ui-translations:
|
||||
name: Client
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Checkout Source Files
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
token: ${{ secrets.CROWDIN_CAMPERBOT_PAT }}
|
||||
|
||||
- name: Generate Crowdin Config
|
||||
uses: freecodecamp/crowdin-action@36a78cbf92f5a6c05a3a32dc8bf434a19a7c59e2 # main
|
||||
env:
|
||||
PLUGIN: 'generate-config'
|
||||
PROJECT_NAME: 'client'
|
||||
|
||||
##### Download Chinese #####
|
||||
- name: Crowdin Download Chinese Translations
|
||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||
# options: https://github.com/crowdin/github-action/blob/master/action.yml
|
||||
with:
|
||||
# uploads
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
auto_approve_imported: false
|
||||
import_eq_suggestions: false
|
||||
|
||||
# downloads
|
||||
download_translations: true
|
||||
download_language: zh-CN
|
||||
skip_untranslated_files: false
|
||||
export_only_approved: true
|
||||
|
||||
push_translations: false
|
||||
|
||||
# pull-request
|
||||
create_pull_request: false
|
||||
|
||||
# global options
|
||||
config: './crowdin-config.yml'
|
||||
base_url: ${{ secrets.CROWDIN_BASE_URL_FCC }}
|
||||
|
||||
# Uncomment below to debug
|
||||
# dryrun_action: true
|
||||
|
||||
# Convert Simplified Chinese to Traditional #
|
||||
- name: Convert Chinese
|
||||
uses: freecodecamp/crowdin-action@36a78cbf92f5a6c05a3a32dc8bf434a19a7c59e2 # main
|
||||
env:
|
||||
PLUGIN: 'convert-chinese'
|
||||
FILE_PATHS: '["client/i18n/locales/chinese"]'
|
||||
|
||||
##### Download Espanol #####
|
||||
- name: Crowdin Download Espanol Translations
|
||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||
# options: https://github.com/crowdin/github-action/blob/master/action.yml
|
||||
with:
|
||||
# uploads
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
auto_approve_imported: false
|
||||
import_eq_suggestions: false
|
||||
|
||||
# downloads
|
||||
download_translations: true
|
||||
download_language: es-EM
|
||||
skip_untranslated_files: false
|
||||
export_only_approved: true
|
||||
|
||||
push_translations: false
|
||||
|
||||
# pull-request
|
||||
create_pull_request: false
|
||||
|
||||
# global options
|
||||
config: './crowdin-config.yml'
|
||||
base_url: ${{ secrets.CROWDIN_BASE_URL_FCC }}
|
||||
|
||||
# Uncomment below to debug
|
||||
# dryrun_action: true
|
||||
|
||||
##### Download Italian #####
|
||||
- name: Crowdin Download Italian Translations
|
||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||
# options: https://github.com/crowdin/github-action/blob/master/action.yml
|
||||
with:
|
||||
# uploads
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
auto_approve_imported: false
|
||||
import_eq_suggestions: false
|
||||
|
||||
# downloads
|
||||
download_translations: true
|
||||
download_language: it
|
||||
skip_untranslated_files: false
|
||||
export_only_approved: true
|
||||
|
||||
push_translations: false
|
||||
|
||||
# pull-request
|
||||
create_pull_request: false
|
||||
|
||||
# global options
|
||||
config: './crowdin-config.yml'
|
||||
base_url: ${{ secrets.CROWDIN_BASE_URL_FCC }}
|
||||
|
||||
# Uncomment below to debug
|
||||
# dryrun_action: true
|
||||
|
||||
##### Download Brazilian Portuguese #####
|
||||
- name: Crowdin Download Portuguese (Brazilian) Translations
|
||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||
# options: https://github.com/crowdin/github-action/blob/master/action.yml
|
||||
with:
|
||||
# uploads
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
auto_approve_imported: false
|
||||
import_eq_suggestions: false
|
||||
|
||||
# downloads
|
||||
download_translations: true
|
||||
download_language: pt-BR
|
||||
skip_untranslated_files: false
|
||||
export_only_approved: true
|
||||
|
||||
push_translations: false
|
||||
|
||||
# pull-request
|
||||
create_pull_request: false
|
||||
|
||||
# global options
|
||||
config: './crowdin-config.yml'
|
||||
base_url: ${{ secrets.CROWDIN_BASE_URL_FCC }}
|
||||
|
||||
# Uncomment below to debug
|
||||
# dryrun_action: true
|
||||
|
||||
##### Download Ukrainian #####
|
||||
- name: Crowdin Download Ukrainian Translations
|
||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||
# options: https://github.com/crowdin/github-action/blob/master/action.yml
|
||||
with:
|
||||
# uploads
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
auto_approve_imported: false
|
||||
import_eq_suggestions: false
|
||||
|
||||
# downloads
|
||||
download_translations: true
|
||||
download_language: uk
|
||||
skip_untranslated_files: false
|
||||
export_only_approved: true
|
||||
|
||||
push_translations: false
|
||||
|
||||
# pull-request
|
||||
create_pull_request: false
|
||||
|
||||
# global options
|
||||
config: './crowdin-config.yml'
|
||||
base_url: ${{ secrets.CROWDIN_BASE_URL_FCC }}
|
||||
|
||||
# Uncomment below to debug
|
||||
# dryrun_action: true
|
||||
|
||||
##### Download Japanese #####
|
||||
- name: Crowdin Download Japanese Translations
|
||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||
# options: https://github.com/crowdin/github-action/blob/master/action.yml
|
||||
with:
|
||||
# uploads
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
auto_approve_imported: false
|
||||
import_eq_suggestions: false
|
||||
|
||||
# downloads
|
||||
download_translations: true
|
||||
download_language: ja
|
||||
skip_untranslated_files: false
|
||||
export_only_approved: true
|
||||
|
||||
push_translations: false
|
||||
|
||||
# pull-request
|
||||
create_pull_request: false
|
||||
|
||||
# global options
|
||||
config: './crowdin-config.yml'
|
||||
base_url: ${{ secrets.CROWDIN_BASE_URL_FCC }}
|
||||
|
||||
# Uncomment below to debug
|
||||
# dryrun_action: true
|
||||
|
||||
##### Download German #####
|
||||
- name: Crowdin Download German Translations
|
||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||
# options: https://github.com/crowdin/github-action/blob/master/action.yml
|
||||
with:
|
||||
# uploads
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
auto_approve_imported: false
|
||||
import_eq_suggestions: false
|
||||
|
||||
# downloads
|
||||
download_translations: true
|
||||
download_language: de
|
||||
skip_untranslated_files: false
|
||||
export_only_approved: true
|
||||
|
||||
push_translations: false
|
||||
|
||||
# pull-request
|
||||
create_pull_request: false
|
||||
|
||||
# global options
|
||||
config: './crowdin-config.yml'
|
||||
base_url: ${{ secrets.CROWDIN_BASE_URL_FCC }}
|
||||
|
||||
# Uncomment below to debug
|
||||
# dryrun_action: true
|
||||
|
||||
##### Download Swahili #####
|
||||
- name: Crowdin Download Swahili Translations
|
||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||
# options: https://github.com/crowdin/github-action/blob/master/action.yml
|
||||
with:
|
||||
# uploads
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
auto_approve_imported: false
|
||||
import_eq_suggestions: false
|
||||
|
||||
# downloads
|
||||
download_translations: true
|
||||
download_language: sw
|
||||
skip_untranslated_files: false
|
||||
export_only_approved: true
|
||||
|
||||
push_translations: false
|
||||
|
||||
# pull-request
|
||||
create_pull_request: false
|
||||
|
||||
# global options
|
||||
config: './crowdin-config.yml'
|
||||
base_url: ${{ secrets.CROWDIN_BASE_URL_FCC }}
|
||||
|
||||
# Uncomment below to debug
|
||||
# dryrun_action: true
|
||||
|
||||
##### Download Korean #####
|
||||
- name: Crowdin Download Korean Translations
|
||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||
# options: https://github.com/crowdin/github-action/blob/master/action.yml
|
||||
with:
|
||||
# uploads
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
auto_approve_imported: false
|
||||
import_eq_suggestions: false
|
||||
|
||||
# downloads
|
||||
download_translations: true
|
||||
download_language: ko
|
||||
skip_untranslated_files: false
|
||||
export_only_approved: true
|
||||
|
||||
push_translations: false
|
||||
|
||||
# pull-request
|
||||
create_pull_request: false
|
||||
|
||||
# global options
|
||||
config: './crowdin-config.yml'
|
||||
base_url: ${{ secrets.CROWDIN_BASE_URL_FCC }}
|
||||
|
||||
# Uncomment below to debug
|
||||
# dryrun_action: true
|
||||
|
||||
###### Format JSON #####
|
||||
# Crowdin gives the files read-only permissions, so we first have to allow
|
||||
# writes.
|
||||
- name: Format JSON
|
||||
run: |
|
||||
sudo chown -R $(whoami): client/i18n/locales
|
||||
npx --yes prettier --write client/i18n/locales/**/*.json
|
||||
|
||||
###### Lowercase directory names #####
|
||||
|
||||
- name: Lowercase Directories
|
||||
uses: freecodecamp/crowdin-action@36a78cbf92f5a6c05a3a32dc8bf434a19a7c59e2 # main
|
||||
env:
|
||||
PLUGIN: 'lowercase-directories'
|
||||
FILE_PATH: 'client/i18n/locales'
|
||||
# Crowdin translators might have the directories
|
||||
# Create Commit
|
||||
- name: Commit Changes
|
||||
uses: freecodecamp/crowdin-action@36a78cbf92f5a6c05a3a32dc8bf434a19a7c59e2 # main
|
||||
env:
|
||||
PLUGIN: 'commit-changes'
|
||||
GH_USERNAME: 'camperbot'
|
||||
GH_EMAIL: ${{ secrets.ACTIONS_CAMPERBOT_EMAIL }}
|
||||
GH_BRANCH: 'i18n-sync-client'
|
||||
GH_MESSAGE: 'chore(i18n,client): processed translations'
|
||||
|
||||
# Generate PR #
|
||||
# All languages should go ABOVE this. #
|
||||
|
||||
- name: Create PR
|
||||
uses: freecodecamp/crowdin-action@36a78cbf92f5a6c05a3a32dc8bf434a19a7c59e2 # main
|
||||
env:
|
||||
PLUGIN: 'pull-request'
|
||||
GH_TOKEN: ${{ secrets.CROWDIN_CAMPERBOT_PAT }}
|
||||
BRANCH: 'i18n-sync-client'
|
||||
REPOSITORY: 'freecodecamp/freecodecamp'
|
||||
BASE: 'main'
|
||||
TITLE: 'chore(i18n,client): processed translations'
|
||||
BODY: 'This PR was opened auto-magically by Crowdin.'
|
||||
LABELS: 'crowdin-sync'
|
||||
TEAM_REVIEWERS: 'i18n'
|
||||
@@ -0,0 +1,55 @@
|
||||
name: i18n - Upload Client UI
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# runs every weekday at 7:15 AM UTC
|
||||
- cron: '15 7 * * 1-5'
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_CAMPERBOT_SERVICE_TOKEN }}
|
||||
CROWDIN_API_URL: 'https://freecodecamp.crowdin.com/api/v2/'
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID_ClIENT }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
i18n-upload-client-ui-files:
|
||||
name: Client
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Checkout Source Files
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Generate Crowdin Config
|
||||
uses: freecodecamp/crowdin-action@36a78cbf92f5a6c05a3a32dc8bf434a19a7c59e2 # main
|
||||
env:
|
||||
PLUGIN: 'generate-config'
|
||||
PROJECT_NAME: 'client'
|
||||
|
||||
- name: Crowdin Upload
|
||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||
# options: https://github.com/crowdin/github-action/blob/master/action.yml
|
||||
with:
|
||||
# uploads
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
auto_approve_imported: false
|
||||
import_eq_suggestions: false
|
||||
|
||||
# downloads
|
||||
download_translations: false
|
||||
|
||||
# pull-request
|
||||
create_pull_request: false
|
||||
|
||||
# global options
|
||||
config: './crowdin-config.yml'
|
||||
base_url: ${{ secrets.CROWDIN_BASE_URL_FCC }}
|
||||
|
||||
# Uncomment below to debug
|
||||
# dryrun_action: true
|
||||
@@ -0,0 +1,80 @@
|
||||
name: i18n - Upload Curriculum
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# runs every weekday at 7:30 AM UTC
|
||||
- cron: '30 7 * * 1-5'
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_CAMPERBOT_SERVICE_TOKEN }}
|
||||
CROWDIN_API_URL: 'https://freecodecamp.crowdin.com/api/v2/'
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID_CURRICULUM }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
i18n-upload-curriculum-files:
|
||||
name: Learn
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Checkout Source Files
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Generate Crowdin Config
|
||||
uses: freecodecamp/crowdin-action@36a78cbf92f5a6c05a3a32dc8bf434a19a7c59e2 # main
|
||||
env:
|
||||
PLUGIN: 'generate-config'
|
||||
PROJECT_NAME: 'curriculum'
|
||||
|
||||
- name: Crowdin Upload
|
||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||
# options: https://github.com/crowdin/github-action/blob/master/action.yml
|
||||
with:
|
||||
# uploads
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
auto_approve_imported: false
|
||||
import_eq_suggestions: false
|
||||
|
||||
# downloads
|
||||
download_translations: false
|
||||
|
||||
# pull-request
|
||||
create_pull_request: false
|
||||
|
||||
# global options
|
||||
config: './crowdin-config.yml'
|
||||
base_url: ${{ secrets.CROWDIN_BASE_URL_FCC }}
|
||||
|
||||
# Uncomment below to debug
|
||||
# dryrun_action: true
|
||||
|
||||
- name: Remove deleted files
|
||||
uses: freecodecamp/crowdin-action@36a78cbf92f5a6c05a3a32dc8bf434a19a7c59e2 # main
|
||||
env:
|
||||
PLUGIN: 'remove-deleted-files'
|
||||
FILE_PATHS: '["curriculum/challenges/english", "curriculum/dictionaries/english"]'
|
||||
|
||||
- name: Hide Non-Translated Strings
|
||||
uses: freecodecamp/crowdin-action@36a78cbf92f5a6c05a3a32dc8bf434a19a7c59e2 # main
|
||||
env:
|
||||
PLUGIN: 'hide-curriculum-strings'
|
||||
|
||||
- name: Hide a String
|
||||
uses: freecodecamp/crowdin-action@36a78cbf92f5a6c05a3a32dc8bf434a19a7c59e2 # main
|
||||
env:
|
||||
PLUGIN: 'hide-string'
|
||||
FILE_NAME: 'basic-html-and-html5/nest-an-anchor-element-within-a-paragraph.md'
|
||||
STRING_CONTENT: Here's a <a href="https://www.freecodecamp.org" target="_blank" mark="crwd-mark">link to www.freecodecamp.org</a> for you to follow.
|
||||
|
||||
- name: Unhide Title of Use && For a More Concise Conditional
|
||||
uses: freecodecamp/crowdin-action@36a78cbf92f5a6c05a3a32dc8bf434a19a7c59e2 # main
|
||||
env:
|
||||
PLUGIN: 'unhide-string'
|
||||
FILE_NAME: 'react/use--for-a-more-concise-conditional.md'
|
||||
STRING_CONTENT: 'Use && for a More Concise Conditional'
|
||||
@@ -0,0 +1,77 @@
|
||||
name: CI - Node.js - i18n - Submodule
|
||||
|
||||
on:
|
||||
# Run on push events, but only for the below branches
|
||||
push:
|
||||
branches:
|
||||
- 'chore/update-i18n-curriculum-submodule'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test-curriculum:
|
||||
name: Test Curriculum
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node-version: [24]
|
||||
# Exclude the languages that we currently run in the full CI suite.
|
||||
locale:
|
||||
- 'chinese'
|
||||
- 'espanol'
|
||||
- 'ukrainian'
|
||||
- 'japanese'
|
||||
- 'german'
|
||||
- 'swahili'
|
||||
- 'korean'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 # v6
|
||||
id: pnpm-install
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Turbo Cache
|
||||
uses: ./.github/actions/setup-turbo-cache
|
||||
with:
|
||||
turbo-token: ${{ secrets.TURBO_TOKEN }}
|
||||
turbo-signature-key: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||
|
||||
- name: Set Environment variables
|
||||
run: |
|
||||
sed '/^[[:space:]]*#/d; /^$/d' sample.env >> $GITHUB_ENV
|
||||
|
||||
- name: Install node_modules
|
||||
run: pnpm install
|
||||
|
||||
# DONT REMOVE THIS STEP.
|
||||
# TODO: Refactor and use re-usable workflow and shared artifacts
|
||||
- name: Build Client in ${{ matrix.locale }}
|
||||
env:
|
||||
CURRICULUM_LOCALE: ${{ matrix.locale }}
|
||||
CLIENT_LOCALE: ${{ matrix.locale }}
|
||||
run: |
|
||||
pnpm run build
|
||||
|
||||
- name: Install Chrome for Puppeteer
|
||||
run: pnpm -F=curriculum install-puppeteer
|
||||
|
||||
- name: Run Tests
|
||||
env:
|
||||
CURRICULUM_LOCALE: ${{ matrix.locale }}
|
||||
CLIENT_LOCALE: ${{ matrix.locale }}
|
||||
run: pnpm -F=curriculum test-content
|
||||
@@ -0,0 +1,332 @@
|
||||
name: CD - Deploy - API
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
api_log_lvl:
|
||||
description: 'Log level for the API'
|
||||
type: choice
|
||||
options:
|
||||
- debug
|
||||
- info
|
||||
- warn
|
||||
default: info
|
||||
show_upcoming_changes:
|
||||
description: 'Show upcoming changes (enables upcoming certifications and challenges)'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: deploy-api-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
setup-jobs:
|
||||
name: Setup Jobs
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
site_tld: ${{ steps.setup.outputs.site_tld }}
|
||||
tgt_env_short: ${{ steps.setup.outputs.tgt_env_short }}
|
||||
tgt_env_long: ${{ steps.setup.outputs.tgt_env_long }}
|
||||
api_log_lvl: ${{ steps.setup.outputs.api_log_lvl }}
|
||||
show_upcoming_changes: ${{ steps.setup.outputs.show_upcoming_changes }}
|
||||
steps:
|
||||
- name: Setup
|
||||
id: setup
|
||||
env:
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
SHOW_UPCOMING_CHANGES: ${{ inputs.show_upcoming_changes }}
|
||||
API_LOG_LVL: ${{ inputs.api_log_lvl || 'info' }}
|
||||
run: |
|
||||
echo "Current branch: $BRANCH"
|
||||
|
||||
# Convert boolean input to string 'true' or 'false'
|
||||
if [[ "$SHOW_UPCOMING_CHANGES" == "true" ]]; then
|
||||
echo "show_upcoming_changes=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "show_upcoming_changes=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
case "$BRANCH" in
|
||||
"prod-current")
|
||||
echo "site_tld=org" >> "$GITHUB_OUTPUT"
|
||||
echo "tgt_env_short=prd" >> "$GITHUB_OUTPUT"
|
||||
echo "tgt_env_long=production" >> "$GITHUB_OUTPUT"
|
||||
echo "api_log_lvl=$API_LOG_LVL" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
*)
|
||||
echo "site_tld=dev" >> "$GITHUB_OUTPUT"
|
||||
echo "tgt_env_short=stg" >> "$GITHUB_OUTPUT"
|
||||
echo "tgt_env_long=staging" >> "$GITHUB_OUTPUT"
|
||||
echo "api_log_lvl=$API_LOG_LVL" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
esac
|
||||
|
||||
build:
|
||||
name: Build & Push
|
||||
needs: setup-jobs
|
||||
uses: ./.github/workflows/docker-docr.yml
|
||||
with:
|
||||
site_tld: ${{ needs.setup-jobs.outputs.site_tld }}
|
||||
app: api
|
||||
show_upcoming_changes: ${{ needs.setup-jobs.outputs.show_upcoming_changes }}
|
||||
secrets:
|
||||
DIGITALOCEAN_ACCESS_TOKEN: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
|
||||
DOCR_NAME: ${{ secrets.DOCR_NAME }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
deploy:
|
||||
name: Deploy to Docker Swarm -- ${{ needs.setup-jobs.outputs.tgt_env_short }}
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [setup-jobs, build]
|
||||
env:
|
||||
TS_USERNAME: ${{ secrets.TS_USERNAME }}
|
||||
TS_MACHINE_NAME: ${{ secrets.TS_MACHINE_NAME }}
|
||||
permissions:
|
||||
deployments: write
|
||||
environment:
|
||||
name: ${{ needs.setup-jobs.outputs.tgt_env_short }}-api
|
||||
|
||||
steps:
|
||||
- name: Setup and connect to Tailscale network
|
||||
uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||
hostname: gha-${{needs.setup-jobs.outputs.tgt_env_short}}-api-ci-${{ github.run_id }}
|
||||
tags: tag:ci
|
||||
version: latest
|
||||
|
||||
- name: Wait for Tailscale Network Readiness
|
||||
run: |
|
||||
echo "Waiting for Tailscale network to be ready..."
|
||||
max_wait=60
|
||||
elapsed=0
|
||||
|
||||
while [ $elapsed -lt $max_wait ]; do
|
||||
if tailscale status --json | jq -e '.BackendState == "Running"' > /dev/null 2>&1; then
|
||||
echo "Tailscale network is ready"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
elapsed=$((elapsed + 2))
|
||||
done
|
||||
|
||||
if [ $elapsed -ge $max_wait ]; then
|
||||
echo "Tailscale network not ready after ${max_wait}s"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Configure SSH & Check Connection
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "Host *
|
||||
UserKnownHostsFile=/dev/null
|
||||
StrictHostKeyChecking no" > ~/.ssh/config
|
||||
chmod 644 ~/.ssh/config
|
||||
|
||||
validate_connection() {
|
||||
local machine_name=$1
|
||||
local max_retries=3
|
||||
local retry_delay=5
|
||||
|
||||
for attempt in $(seq 1 $max_retries); do
|
||||
echo "Connection attempt $attempt/$max_retries to $machine_name"
|
||||
|
||||
if ! tailscale status | grep -q "$machine_name"; then
|
||||
echo "Machine $machine_name not found in Tailscale network"
|
||||
if [ "$attempt" -eq "$max_retries" ]; then
|
||||
return 1
|
||||
fi
|
||||
sleep "$retry_delay"
|
||||
continue
|
||||
fi
|
||||
|
||||
MACHINE_IP=$(tailscale ip -4 "$machine_name")
|
||||
if ssh -o ConnectTimeout=10 -o BatchMode=yes "$TS_USERNAME@$MACHINE_IP" "echo 'Connection test'; docker --version" > /dev/null 2>&1; then
|
||||
echo "Successfully validated connection to $machine_name"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "SSH validation failed for $machine_name"
|
||||
if [ "$attempt" -lt "$max_retries" ]; then
|
||||
sleep "$retry_delay"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Failed to establish connection to $machine_name after $max_retries attempts"
|
||||
return 1
|
||||
}
|
||||
|
||||
echo -e "\nLOG:Validating connection to $TS_MACHINE_NAME..."
|
||||
if ! validate_connection "$TS_MACHINE_NAME"; then
|
||||
echo "Error: Failed to establish reliable connection to $TS_MACHINE_NAME"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Deploy with Docker Stack
|
||||
env:
|
||||
AGE_ENCRYPTED_ASC_SECRETS: ${{ secrets.AGE_ENCRYPTED_ASC_SECRETS }}
|
||||
AGE_SECRET_KEY: ${{ secrets.AGE_SECRET_KEY }}
|
||||
# Variable set from GitHub "Environment" secrets (AGE encrypted)
|
||||
# DOCKER_REGISTRY
|
||||
# MONGOHQ_URL
|
||||
# SENTRY_DSN
|
||||
# SENTRY_ENVIRONMENT
|
||||
# AUTH0_CLIENT_ID
|
||||
# AUTH0_CLIENT_SECRET
|
||||
# AUTH0_DOMAIN
|
||||
# JWT_SECRET
|
||||
# COOKIE_SECRET
|
||||
# COOKIE_DOMAIN
|
||||
# SES_ID
|
||||
# SES_SECRET
|
||||
# SES_SMTP_USERNAME
|
||||
# SES_SMTP_PASSWORD
|
||||
# GROWTHBOOK_FASTIFY_API_HOST
|
||||
# GROWTHBOOK_FASTIFY_CLIENT_KEY
|
||||
# HOME_LOCATION
|
||||
# API_LOCATION
|
||||
# SOCRATES_API_KEY
|
||||
# SOCRATES_ENDPOINT
|
||||
# STRIPE_SECRET_KEY
|
||||
# Variables set from SetupJob
|
||||
DEPLOYMENT_VERSION: ${{ needs.build.outputs.tagname }}
|
||||
DEPLOYMENT_ENV: ${{ needs.setup-jobs.outputs.tgt_env_long }}
|
||||
DEPLOYMENT_TLD: ${{ needs.setup-jobs.outputs.site_tld }}
|
||||
FCC_API_LOG_LEVEL: ${{ needs.setup-jobs.outputs.api_log_lvl }}
|
||||
SHOW_UPCOMING_CHANGES: ${{ needs.setup-jobs.outputs.show_upcoming_changes }}
|
||||
# Stack name
|
||||
STACK_NAME: ${{ needs.setup-jobs.outputs.tgt_env_short }}-api
|
||||
run: |
|
||||
REMOTE_SCRIPT="
|
||||
set -e
|
||||
trap 'rm -f .env age.key secrets.age.asc .env.tmp' EXIT
|
||||
echo -e '\nLOG:Deploying API to $TS_MACHINE_NAME...'
|
||||
cd /home/$TS_USERNAME/docker-swarm-config/stacks/api
|
||||
|
||||
echo -e '\nLOG:Checking if age is installed...'
|
||||
which age > /dev/null
|
||||
|
||||
echo -e '\nLOG:Decrypting secrets...'
|
||||
echo \"$AGE_ENCRYPTED_ASC_SECRETS\" > secrets.age.asc
|
||||
echo \"$AGE_SECRET_KEY\" > age.key && chmod 600 age.key
|
||||
age --identity age.key --decrypt secrets.age.asc > .env
|
||||
rm -f age.key secrets.age.asc
|
||||
|
||||
echo -e '\nLOG:Cleaning up .env file...'
|
||||
touch .env.tmp
|
||||
while IFS= read -r line; do
|
||||
if [[ \$line =~ ^[A-Za-z0-9_]+=.*$ ]]; then
|
||||
# Extract the key (part before the first =)
|
||||
key=\${line%%=*}
|
||||
# Remove any previous line with this key
|
||||
sed -i \"/^\${key}=/d\" .env.tmp
|
||||
fi
|
||||
# Append the current line
|
||||
echo \"\$line\" >> .env.tmp
|
||||
done < .env
|
||||
mv .env.tmp .env
|
||||
|
||||
echo -e '\nLOG:Adding deployment variables...'
|
||||
{
|
||||
echo \"DEPLOYMENT_VERSION=$DEPLOYMENT_VERSION\"
|
||||
echo \"DEPLOYMENT_TLD=$DEPLOYMENT_TLD\"
|
||||
echo \"DEPLOYMENT_ENV=$DEPLOYMENT_ENV\"
|
||||
echo \"FCC_API_LOG_LEVEL=$FCC_API_LOG_LEVEL\"
|
||||
echo \"SHOW_UPCOMING_CHANGES=$SHOW_UPCOMING_CHANGES\"
|
||||
} >> .env
|
||||
|
||||
echo -e '\nLOG:Sourcing environment...'
|
||||
REQUIRED_VARS=(
|
||||
\"DOCKER_REGISTRY\"
|
||||
\"MONGOHQ_URL\"
|
||||
\"SENTRY_DSN\"
|
||||
\"SENTRY_ENVIRONMENT\"
|
||||
\"AUTH0_CLIENT_ID\"
|
||||
\"AUTH0_CLIENT_SECRET\"
|
||||
\"AUTH0_DOMAIN\"
|
||||
\"JWT_SECRET\"
|
||||
\"COOKIE_SECRET\"
|
||||
\"COOKIE_DOMAIN\"
|
||||
\"SES_ID\"
|
||||
\"SES_SECRET\"
|
||||
\"SES_SMTP_USERNAME\"
|
||||
\"SES_SMTP_PASSWORD\"
|
||||
\"GROWTHBOOK_FASTIFY_API_HOST\"
|
||||
\"GROWTHBOOK_FASTIFY_CLIENT_KEY\"
|
||||
\"HOME_LOCATION\"
|
||||
\"API_LOCATION\"
|
||||
\"SOCRATES_API_KEY\"
|
||||
\"SOCRATES_ENDPOINT\"
|
||||
\"STRIPE_SECRET_KEY\"
|
||||
\"DEPLOYMENT_VERSION\"
|
||||
\"DEPLOYMENT_TLD\"
|
||||
\"DEPLOYMENT_ENV\"
|
||||
\"FCC_API_LOG_LEVEL\"
|
||||
)
|
||||
|
||||
while IFS='=' read -r key value; do
|
||||
if [[ -n \"\$key\" && ! \"\$key\" =~ ^# ]]; then
|
||||
export \"\${key}=\${value}\"
|
||||
fi
|
||||
done < .env
|
||||
|
||||
MISSING_VARS=()
|
||||
for var in \"\${REQUIRED_VARS[@]}\"; do
|
||||
if [[ -z \"\${!var}\" ]]; then
|
||||
MISSING_VARS+=(\"\$var\")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ \${#MISSING_VARS[@]} -gt 0 ]]; then
|
||||
echo \"ERROR: The following required environment variables are missing or empty:\"
|
||||
for var in \"\${MISSING_VARS[@]}\"; do
|
||||
echo \" - \$var\"
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf .env
|
||||
|
||||
echo -e '\nLOG:Validating deployment version...'
|
||||
if [[ \"\$DEPLOYMENT_VERSION\" != \"$DEPLOYMENT_VERSION\" ]]; then
|
||||
echo \"Error: Version mismatch. Expected: $DEPLOYMENT_VERSION, Got: \$DEPLOYMENT_VERSION\"
|
||||
exit 1
|
||||
fi
|
||||
env | grep -E 'DEPLOYMENT_VERSION'
|
||||
|
||||
echo -e '\nLOG:Checking stack configuration...'
|
||||
CONFIG_OUTPUT=\"/dev/null\"
|
||||
if [[ \"\$FCC_API_LOG_LEVEL\" == \"debug\" ]]; then
|
||||
CONFIG_FILENAME=\"debug-docker-stack-config-\${DEPLOYMENT_VERSION}.yml\"
|
||||
echo -e '\nLOG:Saving stack configuration for debugging...'
|
||||
CONFIG_OUTPUT=\"\$CONFIG_FILENAME\"
|
||||
fi
|
||||
docker stack config -c stack-api.yml > \$CONFIG_OUTPUT
|
||||
|
||||
echo -e '\nLOG:Deploying stack...'
|
||||
docker stack deploy -c stack-api.yml --prune --with-registry-auth --detach=false $STACK_NAME
|
||||
|
||||
echo -e '\nLOG:Finished deployment.'
|
||||
"
|
||||
MACHINE_IP=$(tailscale ip -4 "$TS_MACHINE_NAME")
|
||||
# shellcheck disable=SC2029 # client-side expansion of REMOTE_SCRIPT is intended
|
||||
ssh "$TS_USERNAME@$MACHINE_IP" "$REMOTE_SCRIPT"
|
||||
|
||||
# TODO(o11y): Sentry release finalize disabled pending a follow-up PR with
|
||||
# a stable release-id scheme (source-map upload in docker-docr.yml is
|
||||
# disabled alongside). Re-enabling should also gate on a rollback-safe
|
||||
# convergence check (docker service state) before finalizing, since
|
||||
# `docker stack deploy --detach=false` exits 0 through a healthcheck
|
||||
# rollback. See .scratchpad/o11y-cutover-review-2026-07-10.md.
|
||||
# - name: Finalize Sentry release
|
||||
# env:
|
||||
# SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
# SENTRY_ORG: freecodecamp
|
||||
# SENTRY_PROJECT: api-fastify
|
||||
# RELEASE: ${{ needs.build.outputs.tagname }}
|
||||
# DEPLOY_ENV: ${{ needs.setup-jobs.outputs.tgt_env_long }}
|
||||
# run: |
|
||||
# npx --yes @sentry/cli@3.6.0 releases finalize "$RELEASE"
|
||||
# npx --yes @sentry/cli@3.6.0 releases deploys "$RELEASE" new -e "$DEPLOY_ENV"
|
||||
@@ -0,0 +1,381 @@
|
||||
name: CD - Deploy - Clients
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_language:
|
||||
description: 'Target language (or "all" for all languages)'
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- english
|
||||
- chinese
|
||||
- espanol
|
||||
- chinese-traditional
|
||||
- italian
|
||||
- portuguese
|
||||
- ukrainian
|
||||
- japanese
|
||||
- german
|
||||
- swahili
|
||||
- korean
|
||||
default: all
|
||||
show_upcoming_changes:
|
||||
description: 'Show upcoming changes (enables upcoming certifications and challenges)'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: deploy-client-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
setup-jobs:
|
||||
name: Setup Jobs
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
site_tld: ${{ steps.setup.outputs.site_tld }} # org, dev
|
||||
tgt_env_short: ${{ steps.setup.outputs.tgt_env_short }} # prd, stg
|
||||
tgt_env_long: ${{ steps.setup.outputs.tgt_env_long }} # production, staging
|
||||
tgt_env_branch: ${{ steps.setup.outputs.tgt_env_branch }} # prod-current, prod-staging
|
||||
show_upcoming_changes: ${{ steps.setup.outputs.show_upcoming_changes }}
|
||||
steps:
|
||||
- name: Setup
|
||||
id: setup
|
||||
env:
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
SHOW_UPCOMING_CHANGES: ${{ inputs.show_upcoming_changes }}
|
||||
run: |
|
||||
echo "Current branch: $BRANCH"
|
||||
|
||||
# Convert boolean input to string 'true' or 'false'
|
||||
if [[ "$SHOW_UPCOMING_CHANGES" == "true" ]]; then
|
||||
echo "show_upcoming_changes=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "show_upcoming_changes=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
case "$BRANCH" in
|
||||
"prod-current")
|
||||
echo "site_tld=org" >> $GITHUB_OUTPUT
|
||||
echo "tgt_env_short=prd" >> $GITHUB_OUTPUT
|
||||
echo "tgt_env_long=production" >> $GITHUB_OUTPUT
|
||||
echo "tgt_env_branch=prod-current" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
*)
|
||||
echo "site_tld=dev" >> $GITHUB_OUTPUT
|
||||
echo "tgt_env_short=stg" >> $GITHUB_OUTPUT
|
||||
echo "tgt_env_long=staging" >> $GITHUB_OUTPUT
|
||||
echo "tgt_env_branch=prod-staging" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
esac
|
||||
|
||||
setup-matrix:
|
||||
name: Setup Matrix
|
||||
runs-on: ubuntu-24.04
|
||||
needs: setup-jobs
|
||||
outputs:
|
||||
matrix: ${{ steps.matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Setup Matrix
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
id: matrix
|
||||
env:
|
||||
TARGET_LANG: ${{ inputs.target_language }}
|
||||
with:
|
||||
script: |
|
||||
// Constants
|
||||
const NODE_VERSION = 24;
|
||||
|
||||
// Input sanitization and validation
|
||||
const rawTargetLang = process.env.TARGET_LANG || 'all';
|
||||
const targetLang = rawTargetLang.trim().toLowerCase();
|
||||
console.log(`Target language: ${targetLang}`);
|
||||
|
||||
// Language mappings (single source of truth)
|
||||
const languageMap = {
|
||||
'english': 'eng',
|
||||
'chinese': 'chn',
|
||||
'espanol': 'esp',
|
||||
'chinese-traditional': 'cnt',
|
||||
'italian': 'ita',
|
||||
'portuguese': 'por',
|
||||
'ukrainian': 'ukr',
|
||||
'japanese': 'jpn',
|
||||
'german': 'ger',
|
||||
'swahili': 'swa',
|
||||
'korean': 'kor'
|
||||
};
|
||||
|
||||
const allLanguages = Object.keys(languageMap);
|
||||
let matrix;
|
||||
|
||||
if (targetLang === 'all') {
|
||||
console.log('Building matrix for all languages');
|
||||
console.log(`Available languages: ${allLanguages.join(', ')}`);
|
||||
|
||||
// Build include array for all languages
|
||||
const include = allLanguages.map(lang => ({
|
||||
'node-version': NODE_VERSION,
|
||||
'lang-name-full': lang,
|
||||
'lang-name-short': languageMap[lang]
|
||||
}));
|
||||
|
||||
matrix = {
|
||||
include: include
|
||||
};
|
||||
|
||||
} else {
|
||||
console.log(`Building matrix for single language: ${targetLang}`);
|
||||
|
||||
// Validate language selection
|
||||
if (!languageMap[targetLang]) {
|
||||
const errorMsg = `Unknown language '${targetLang}'. Available: ${allLanguages.join(', ')}`;
|
||||
console.error(errorMsg);
|
||||
core.setFailed(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Processing: ${targetLang} -> ${languageMap[targetLang]}`);
|
||||
|
||||
// Create single language matrix
|
||||
matrix = {
|
||||
include: [{
|
||||
'node-version': NODE_VERSION,
|
||||
'lang-name-full': targetLang,
|
||||
'lang-name-short': languageMap[targetLang]
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
// Final validation
|
||||
if (!matrix || !matrix.include || matrix.include.length === 0) {
|
||||
core.setFailed('Generated matrix is empty or invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Generated matrix:');
|
||||
console.log(JSON.stringify(matrix, null, 2));
|
||||
console.log(`Matrix will create ${matrix.include.length} job(s)`);
|
||||
|
||||
// Set output for GitHub Actions
|
||||
core.setOutput('matrix', JSON.stringify(matrix));
|
||||
|
||||
client:
|
||||
name: Clients - [${{ needs.setup-jobs.outputs.tgt_env_short }}] [${{ matrix.lang-name-short }}]
|
||||
needs: [setup-jobs, setup-matrix]
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.setup-matrix.outputs.matrix) }}
|
||||
permissions:
|
||||
deployments: write
|
||||
contents: read
|
||||
environment:
|
||||
name: ${{ needs.setup-jobs.outputs.tgt_env_short }}-clients
|
||||
env:
|
||||
TS_USERNAME: ${{ secrets.TS_USERNAME }}
|
||||
TS_MACHINE_NAME_PREFIX: ${{ secrets.TS_MACHINE_NAME_PREFIX }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 # v6
|
||||
id: pnpm-install
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Language specific ENV - [${{ matrix.lang-name-full }}]
|
||||
run: |
|
||||
if [ "${{ matrix.lang-name-full }}" = "english" ]; then
|
||||
echo "HOME_LOCATION=https://www.freecodecamp.${{ needs.setup-jobs.outputs.site_tld }}" >> $GITHUB_ENV
|
||||
echo "NEWS_LOCATION=https://www.freecodecamp.${{ needs.setup-jobs.outputs.site_tld }}/news" >> $GITHUB_ENV
|
||||
else
|
||||
echo "HOME_LOCATION=https://www.freecodecamp.${{ needs.setup-jobs.outputs.site_tld }}/${{ matrix.lang-name-full }}" >> $GITHUB_ENV
|
||||
echo "NEWS_LOCATION=https://www.freecodecamp.${{ needs.setup-jobs.outputs.site_tld }}/${{ matrix.lang-name-full }}/news" >> $GITHUB_ENV
|
||||
fi
|
||||
echo "CLIENT_LOCALE=${{ matrix.lang-name-full }}" >> $GITHUB_ENV
|
||||
echo "CURRICULUM_LOCALE=${{ matrix.lang-name-full }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Create deployment version
|
||||
id: deployment-version
|
||||
run: |
|
||||
DEPLOYMENT_VERSION=$(git rev-parse --short HEAD)-$(date +%Y%m%d)-$(date +%H%M)
|
||||
echo "DEPLOYMENT_VERSION=$DEPLOYMENT_VERSION" >> $GITHUB_ENV
|
||||
echo "DEPLOYMENT_VERSION=$DEPLOYMENT_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install and Build
|
||||
env:
|
||||
API_LOCATION: 'https://api.freecodecamp.${{ needs.setup-jobs.outputs.site_tld }}'
|
||||
ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }}
|
||||
ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }}
|
||||
GROWTHBOOK_URI: ${{ secrets.GROWTHBOOK_URI }}
|
||||
FORUM_LOCATION: 'https://forum.freecodecamp.org'
|
||||
PATREON_CLIENT_ID: ${{ secrets.PATREON_CLIENT_ID }}
|
||||
PAYPAL_CLIENT_ID: ${{ secrets.PAYPAL_CLIENT_ID }}
|
||||
STRIPE_PUBLIC_KEY: ${{ secrets.STRIPE_PUBLIC_KEY }}
|
||||
SHOW_UPCOMING_CHANGES: ${{ needs.setup-jobs.outputs.show_upcoming_changes }}
|
||||
FREECODECAMP_NODE_ENV: production
|
||||
# The below is used in ecosystem.config.js file for the API -- to be removed later
|
||||
DEPLOYMENT_ENV: ${{ needs.setup-jobs.outputs.tgt_env_long }}
|
||||
# The above is used in ecosystem.config.js file for the API -- to be removed later
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm run build
|
||||
|
||||
- name: Tar Files
|
||||
run: tar -czf client-${{ matrix.lang-name-short }}.tar client/public
|
||||
|
||||
- name: Setup and connect to Tailscale network
|
||||
uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||
hostname: gha-${{needs.setup-jobs.outputs.tgt_env_short}}-clients-ci-${{ github.run_id }}
|
||||
tags: tag:ci
|
||||
version: latest
|
||||
|
||||
- name: Wait for Tailscale Network Readiness
|
||||
run: |
|
||||
echo "Waiting for Tailscale network to be ready..."
|
||||
max_wait=60
|
||||
elapsed=0
|
||||
|
||||
while [ $elapsed -lt $max_wait ]; do
|
||||
if tailscale status --json | jq -e '.BackendState == "Running"' > /dev/null 2>&1; then
|
||||
echo "Tailscale network is ready"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
elapsed=$((elapsed + 2))
|
||||
done
|
||||
|
||||
if [ $elapsed -ge $max_wait ]; then
|
||||
echo "Tailscale network not ready after ${max_wait}s"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Configure SSH & Check Connection
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "Host *
|
||||
UserKnownHostsFile=/dev/null
|
||||
StrictHostKeyChecking no" > ~/.ssh/config
|
||||
chmod 644 ~/.ssh/config
|
||||
|
||||
validate_connection() {
|
||||
local machine_name=$1
|
||||
local max_retries=3
|
||||
local retry_delay=5
|
||||
|
||||
for attempt in $(seq 1 $max_retries); do
|
||||
echo "Connection attempt $attempt/$max_retries to $machine_name"
|
||||
|
||||
if ! tailscale status | grep -q "$machine_name"; then
|
||||
echo "Machine $machine_name not found in Tailscale network"
|
||||
if [ $attempt -eq $max_retries ]; then
|
||||
return 1
|
||||
fi
|
||||
sleep $retry_delay
|
||||
continue
|
||||
fi
|
||||
|
||||
MACHINE_IP=$(tailscale ip -4 $machine_name)
|
||||
if ssh -o ConnectTimeout=10 -o BatchMode=yes $TS_USERNAME@$MACHINE_IP "echo 'Connection test'; uptime" > /dev/null 2>&1; then
|
||||
echo "Successfully validated connection to $machine_name"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "SSH validation failed for $machine_name"
|
||||
if [ $attempt -lt $max_retries ]; then
|
||||
sleep $retry_delay
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Failed to establish connection to $machine_name after $max_retries attempts"
|
||||
return 1
|
||||
}
|
||||
|
||||
echo -e "\nLOG:Validating connections to all machines..."
|
||||
for i in {0..1}; do
|
||||
TS_MACHINE_NAME=${TS_MACHINE_NAME_PREFIX}-${{ matrix.lang-name-short }}-${i}
|
||||
echo "Validating connection to $TS_MACHINE_NAME"
|
||||
if ! validate_connection "$TS_MACHINE_NAME"; then
|
||||
echo "Error: Failed to establish reliable connection to $TS_MACHINE_NAME"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "All machine connections validated successfully"
|
||||
|
||||
- name: Upload and Deploy
|
||||
run: |
|
||||
for i in {0..1}; do
|
||||
TS_MACHINE_NAME=${TS_MACHINE_NAME_PREFIX}-${{ matrix.lang-name-short }}-${i}
|
||||
CURRENT_DATE=$(date +%Y%m%d)
|
||||
CLIENT_SRC=client-${{ matrix.lang-name-short }}.tar
|
||||
CLIENT_DST=/tmp/client-${{ matrix.lang-name-short }}-${CURRENT_DATE}-${{ github.run_id }}.tar
|
||||
CLIENT_BINARIES=${{needs.setup-jobs.outputs.tgt_env_short}}-release-$CURRENT_DATE-${{ github.run_id }}
|
||||
|
||||
echo -e "\nLOG:Uploading client archive to $TS_MACHINE_NAME..."
|
||||
MACHINE_IP=$(tailscale ip -4 $TS_MACHINE_NAME)
|
||||
scp $CLIENT_SRC $TS_USERNAME@$MACHINE_IP:$CLIENT_DST
|
||||
|
||||
REMOTE_SCRIPT="
|
||||
set -e
|
||||
echo -e '\nLOG: Deploying client - $CLIENT_BINARIES to $TS_MACHINE_NAME...'
|
||||
|
||||
echo -e '\nLOG:Extracting client archive...'
|
||||
mkdir -p /home/$TS_USERNAME/client/releases/$CLIENT_BINARIES
|
||||
tar -xzf $CLIENT_DST -C /home/$TS_USERNAME/client/releases/$CLIENT_BINARIES --strip-components=2
|
||||
|
||||
echo -e '\nLOG:Cleaning up client archive...'
|
||||
rm $CLIENT_DST
|
||||
|
||||
echo -e '\nLOG:Checking client archive size...'
|
||||
du -sh /home/$TS_USERNAME/client/releases/$CLIENT_BINARIES
|
||||
|
||||
echo -e '\nLOG:Environment setup...'
|
||||
cd /home/$TS_USERNAME/client
|
||||
export NVM_DIR=\$HOME/.nvm && [ -s "\$NVM_DIR/nvm.sh" ] && source "\$NVM_DIR/nvm.sh"
|
||||
echo -e '\nLOG:Checking available Node.js versions...'
|
||||
nvm ls | grep 'default'
|
||||
echo -e '\nLOG:Checking Node.js version...'
|
||||
node --version
|
||||
|
||||
echo -e '\nLOG:Installing serve...'
|
||||
npm install -g serve@13
|
||||
|
||||
echo -e '\nLOG:Primary client setup...'
|
||||
rm -f client-start-primary.sh
|
||||
echo \"serve -c ../../serve.json releases/$CLIENT_BINARIES -p 50505\" >> client-start-primary.sh
|
||||
chmod +x client-start-primary.sh
|
||||
pm2 delete client-primary || true
|
||||
pm2 start ./client-start-primary.sh --name client-primary
|
||||
echo -e '\nLOG:Primary client setup completed.'
|
||||
|
||||
pm2 ls
|
||||
|
||||
echo -e '\nLOG:Secondary client setup...'
|
||||
rm -f client-start-secondary.sh
|
||||
echo \"serve -c ../../serve.json releases/$CLIENT_BINARIES -p 52525\" >> client-start-secondary.sh
|
||||
chmod +x client-start-secondary.sh
|
||||
pm2 delete client-secondary || true
|
||||
pm2 start ./client-start-secondary.sh --name client-secondary
|
||||
echo -e '\nLOG:Secondary client setup completed.'
|
||||
|
||||
pm2 ls
|
||||
pm2 save
|
||||
|
||||
echo -e '\nLOG:Finished deployment.'
|
||||
"
|
||||
ssh $TS_USERNAME@$MACHINE_IP "$REMOTE_SCRIPT"
|
||||
done
|
||||
@@ -0,0 +1,63 @@
|
||||
name: CI - Devcontainer
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.devcontainer/**'
|
||||
- 'docker/devcontainer/**'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install devcontainer CLI
|
||||
# renovate: datasource=npm depName=@devcontainers/cli
|
||||
run: npm install -g @devcontainers/cli@0.83.0
|
||||
|
||||
- name: Build devcontainer
|
||||
run: devcontainer build --workspace-folder .
|
||||
|
||||
- name: Start devcontainer
|
||||
run: devcontainer up --workspace-folder .
|
||||
|
||||
- name: Validate required tools
|
||||
run: |
|
||||
devcontainer exec --workspace-folder . pnpm --version
|
||||
devcontainer exec --workspace-folder . rsync --version
|
||||
devcontainer exec --workspace-folder . mongosh --version
|
||||
devcontainer exec --workspace-folder . node --version
|
||||
devcontainer exec --workspace-folder . git --version
|
||||
|
||||
- name: Validate MongoDB replica set
|
||||
run: |
|
||||
for i in $(seq 1 30); do
|
||||
if devcontainer exec --workspace-folder . mongosh --eval "rs.status().ok" 2>/dev/null; then
|
||||
echo "Replica set is ready"
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting for replica set... (attempt $i/30)"
|
||||
sleep 2
|
||||
done
|
||||
echo "Replica set failed to initialize"
|
||||
exit 1
|
||||
@@ -0,0 +1,35 @@
|
||||
name: CD - Docker - DOCR Cleanup Container Images
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '5 0 * * 3,6' # 12:05 UTC on Wednesdays and Saturdays (6 hour maintenance window)
|
||||
|
||||
jobs:
|
||||
remove:
|
||||
name: Delete Old Images
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
repos:
|
||||
- learn-api
|
||||
variants:
|
||||
- dev
|
||||
- org
|
||||
|
||||
steps:
|
||||
- name: Install doctl
|
||||
uses: digitalocean/action-doctl@3cb3953159719656269e044e0e24ca16dd2a690f # v2.5.2
|
||||
with:
|
||||
token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
|
||||
|
||||
- name: Log in to DigitalOcean Container Registry with short-lived credentials
|
||||
run: doctl registry login --expiry-seconds 1200
|
||||
|
||||
- name: Delete Images
|
||||
uses: raisedadead/action-docr-cleanup@1c7d87369bccfdf5da03a9ae3b00eacc3f2a9b51 # v1
|
||||
with:
|
||||
repository_name: '${{ matrix.variants }}/${{ matrix.repos }}'
|
||||
days: '7'
|
||||
keep_last: '3'
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
name: CD - Docker - DOCR
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
site_tld:
|
||||
required: true
|
||||
type: choice
|
||||
description: 'Input: The site tld (variant) to build'
|
||||
options:
|
||||
- dev
|
||||
- org
|
||||
default: 'dev'
|
||||
app:
|
||||
required: true
|
||||
type: string
|
||||
description: 'Input: The app (component) to build'
|
||||
default: 'api'
|
||||
show_upcoming_changes:
|
||||
required: false
|
||||
type: string
|
||||
description: 'Input: Show upcoming changes flag (true/false)'
|
||||
default: 'false'
|
||||
workflow_call:
|
||||
inputs:
|
||||
site_tld:
|
||||
required: true
|
||||
type: string
|
||||
description: 'Input: The site tld (variant) to build'
|
||||
app:
|
||||
required: true
|
||||
type: string
|
||||
description: 'Input: The app (component) to build'
|
||||
show_upcoming_changes:
|
||||
required: false
|
||||
type: string
|
||||
description: 'Input: Show upcoming changes flag (true/false)'
|
||||
default: 'false'
|
||||
secrets:
|
||||
DIGITALOCEAN_ACCESS_TOKEN:
|
||||
required: true
|
||||
description: 'DigitalOcean API token for registry authentication'
|
||||
DOCR_NAME:
|
||||
required: true
|
||||
description: 'DigitalOcean Container Registry name'
|
||||
SENTRY_AUTH_TOKEN:
|
||||
required: false
|
||||
description: 'Sentry auth token for source map upload (api app only)'
|
||||
outputs:
|
||||
tagname:
|
||||
description: 'Output: The tagname for the image built'
|
||||
value: ${{ jobs.build.outputs.tagname }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build & Push
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
tagname: ${{ steps.tagname.outputs.tagname }}
|
||||
|
||||
steps:
|
||||
- name: Checkout Source Files
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Create a tagname
|
||||
id: tagname
|
||||
run: |
|
||||
tagname=$(git rev-parse --short HEAD)-$(date +%Y%m%d)-$(date +%H%M)
|
||||
echo "tagname=$tagname" >> "$GITHUB_ENV"
|
||||
echo "tagname=$tagname" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
|
||||
- name: Install doctl
|
||||
uses: digitalocean/action-doctl@3cb3953159719656269e044e0e24ca16dd2a690f # v2.5.2
|
||||
with:
|
||||
token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
|
||||
|
||||
- name: Log in to DigitalOcean Container Registry with short-lived credentials
|
||||
run: doctl registry login --expiry-seconds 1200
|
||||
|
||||
- name: Build & Push Image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
|
||||
with:
|
||||
context: .
|
||||
file: docker/${{ inputs.app }}/Dockerfile
|
||||
push: true
|
||||
build-args: |
|
||||
SHOW_UPCOMING_CHANGES=${{ inputs.show_upcoming_changes }}
|
||||
tags: |
|
||||
registry.digitalocean.com/${{ secrets.DOCR_NAME }}/${{ inputs.site_tld }}/learn-${{ inputs.app }}:${{ env.tagname }}
|
||||
registry.digitalocean.com/${{ secrets.DOCR_NAME }}/${{ inputs.site_tld }}/learn-${{ inputs.app }}:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# TODO(o11y): source-map upload + release marking disabled pending a
|
||||
# follow-up PR with a stable release-id scheme. Also fix the upload path
|
||||
# when re-enabling: the image ships /home/node/fcc/api/src (dist flattened
|
||||
# by the COPY in docker/api/Dockerfile), so `docker cp .../fcc/api` yields
|
||||
# sentry-dist/api/src — the old upload pointed at sentry-dist/api/dist,
|
||||
# which never exists (silent no-op, so maps never uploaded). Path below is
|
||||
# pre-corrected to sentry-dist/api/src. See
|
||||
# .scratchpad/o11y-cutover-review-2026-07-10.md.
|
||||
# - name: Extract API dist for Sentry source maps
|
||||
# if: inputs.app == 'api'
|
||||
# run: |
|
||||
# IMAGE="registry.digitalocean.com/${{ secrets.DOCR_NAME }}/${{ inputs.site_tld }}/learn-${{ inputs.app }}:${{ env.tagname }}"
|
||||
# docker pull "$IMAGE"
|
||||
# container_id=$(docker create "$IMAGE")
|
||||
# mkdir -p sentry-dist
|
||||
# docker cp "$container_id:/home/node/fcc/api" sentry-dist/
|
||||
# docker rm "$container_id"
|
||||
#
|
||||
# - name: Upload source maps to Sentry (unfinalized)
|
||||
# if: inputs.app == 'api'
|
||||
# env:
|
||||
# SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
# SENTRY_ORG: freecodecamp
|
||||
# SENTRY_PROJECT: api-fastify
|
||||
# run: |
|
||||
# npx --yes @sentry/cli@3.6.0 releases new "${{ env.tagname }}"
|
||||
# npx --yes @sentry/cli@3.6.0 sourcemaps upload --release "${{ env.tagname }}" sentry-dist/api/src
|
||||
@@ -0,0 +1,49 @@
|
||||
name: CD - Docker - GHCR Images
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'docker/devcontainer/**'
|
||||
- '.github/workflows/docker-ghcr.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push Images
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push images
|
||||
uses: docker/bake-action@a66e1c87e2eca0503c343edf1d208c716d54b8a8 # v7
|
||||
with:
|
||||
files: docker/devcontainer/docker-bake.hcl
|
||||
targets: devcontainer
|
||||
push: true
|
||||
env:
|
||||
TAG: ${{ github.sha }}
|
||||
TAG_LATEST: ${{ github.ref_name == 'main' }}
|
||||
@@ -0,0 +1,190 @@
|
||||
name: CI - E2E - Playwright
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# workflow_run:
|
||||
# workflows: ['CI - Node.js']
|
||||
# types:
|
||||
# - completed
|
||||
# TODO: refactor with a workflow_call
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'temp-**' # Temporary branches allowed on Upstream
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.workflow_run.head_branch || github.ref }}
|
||||
cancel-in-progress: ${{ !contains(github.ref, 'main') && !contains(github.ref, 'prod-') }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-client:
|
||||
name: Build Client
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [24]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 # v6
|
||||
id: pnpm-install
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Turbo Cache
|
||||
uses: ./.github/actions/setup-turbo-cache
|
||||
with:
|
||||
turbo-token: ${{ secrets.TURBO_TOKEN }}
|
||||
turbo-signature-key: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||
|
||||
- name: Checkout client-config
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
repository: freeCodeCamp/client-config
|
||||
path: client-config
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set freeCodeCamp Environment Variables
|
||||
run: |
|
||||
sed '/^[[:space:]]*#/d; /^$/d' sample.env >> $GITHUB_ENV
|
||||
|
||||
- name: Install and Build
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm run build
|
||||
|
||||
- name: Move serve.json to Public Folder
|
||||
run: cp client-config/serve.json client/public/serve.json
|
||||
|
||||
- name: Upload Client Artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: client-artifact
|
||||
path: client/public
|
||||
|
||||
- name: Upload Webpack Stats
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: webpack-stats
|
||||
path: client/public/stats.json
|
||||
|
||||
build-api:
|
||||
name: Build API (Container)
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Checkout Source Files
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Create Image
|
||||
run: |
|
||||
docker build \
|
||||
-t fcc-api \
|
||||
-f docker/api/Dockerfile .
|
||||
|
||||
- name: Save Image
|
||||
run: docker save fcc-api > api-artifact.tar
|
||||
|
||||
- name: Upload API Artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: api-artifact
|
||||
path: api-artifact.tar
|
||||
|
||||
playwright-run:
|
||||
name: Run Playwright Tests
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [build-client, build-api]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Extend this to include firefox and webkit once chromium is working.
|
||||
browsers: [chromium]
|
||||
node-version: [24]
|
||||
|
||||
steps:
|
||||
- name: Set Action Environment Variables
|
||||
run: |
|
||||
echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout Source Files
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download Client Artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: client-artifact
|
||||
path: client/public
|
||||
|
||||
- name: Download Api Artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: api-artifact
|
||||
path: api-artifact
|
||||
|
||||
- name: Load API Image
|
||||
run: |
|
||||
docker load < api-artifact/api-artifact.tar
|
||||
rm api-artifact/api-artifact.tar
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 # v6
|
||||
id: pnpm-install
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Set freeCodeCamp Environment Variables (needed by api)
|
||||
run: |
|
||||
sed '/^[[:space:]]*#/d; /^$/d' sample.env >> $GITHUB_ENV
|
||||
cp sample.env .env # for docker compose
|
||||
|
||||
- name: Install playwright dependencies
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Install
|
||||
run: pnpm install
|
||||
|
||||
- name: Start apps
|
||||
run: |
|
||||
docker compose -f docker/docker-compose.yml -f docker/docker-compose.e2e.yml up -d
|
||||
pnpm run serve:client-ci &
|
||||
sleep 10
|
||||
|
||||
- name: Seed Database with Certified User
|
||||
run: pnpm run seed:certified-user
|
||||
|
||||
- name: Run playwright tests
|
||||
run: pnpm run playwright:run --project=${{ matrix.browsers }} --grep-invert 'third-party-donation.spec.ts'
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report-${{ matrix.browsers }}
|
||||
path: e2e/playwright/reporter
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,166 @@
|
||||
name: CI - E2E - 3rd party donation tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'prod-**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.workflow_run.head_branch || github.ref }}
|
||||
cancel-in-progress: ${{ !contains(github.ref, 'main') && !contains(github.ref, 'prod-') }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-client:
|
||||
name: Build Client
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [24]
|
||||
steps:
|
||||
- name: Checkout Source Files
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Checkout client-config
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: freeCodeCamp/client-config
|
||||
path: client-config
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@8912a9102ac27614460f54aedde9e1e7f9aec20d # v6.0.5
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Set freeCodeCamp Environment Variables
|
||||
run: |
|
||||
sed '/STRIPE/d; /PAYPAL/d; /PATREON/d;' sample.env > .env
|
||||
echo 'STRIPE_PUBLIC_KEY=${{ secrets.STRIPE_PUBLIC_KEY }}' >> .env
|
||||
echo 'PAYPAL_CLIENT_ID=${{ secrets.PAYPAL_CLIENT_ID }}' >> .env
|
||||
echo 'PATREON_CLIENT_ID=${{ secrets.PATREON_CLIENT_ID }}' >> .env
|
||||
|
||||
- name: Install and Build
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm run build
|
||||
|
||||
- name: Move serve.json to Public Folder
|
||||
run: cp client-config/serve.json client/public/serve.json
|
||||
|
||||
- name: Tar Files
|
||||
run: tar -cf client-artifact.tar client/public
|
||||
|
||||
- name: Upload Client Artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: client-artifact
|
||||
path: client-artifact.tar
|
||||
|
||||
build-api:
|
||||
name: Build API (Container)
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout Source Files
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Create Image
|
||||
run: |
|
||||
docker build \
|
||||
-t fcc-api \
|
||||
-f docker/api/Dockerfile .
|
||||
|
||||
- name: Save Image
|
||||
run: docker save fcc-api > api-artifact.tar
|
||||
|
||||
- name: Upload API Artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: api-artifact
|
||||
path: api-artifact.tar
|
||||
|
||||
playwright-run-api:
|
||||
name: Run Playwright 3rd Party Donation Tests
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [build-client, build-api]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
browsers: [chromium]
|
||||
node-version: [24]
|
||||
steps:
|
||||
- name: Set Action Environment Variables
|
||||
run: |
|
||||
echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout Source Files
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
|
||||
- name: Unpack Client Artifact
|
||||
run: |
|
||||
tar -xf client-artifact/client-artifact.tar
|
||||
rm client-artifact/client-artifact.tar
|
||||
|
||||
- name: Load API Image
|
||||
run: |
|
||||
docker load < api-artifact/api-artifact.tar
|
||||
rm api-artifact/api-artifact.tar
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@8912a9102ac27614460f54aedde9e1e7f9aec20d # v6.0.5
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Set freeCodeCamp Environment Variables (needed by api)
|
||||
run: |
|
||||
sed '/STRIPE/d; /PAYPAL/d; /PATREON/d;' sample.env > .env
|
||||
echo 'STRIPE_PUBLIC_KEY=${{ secrets.STRIPE_PUBLIC_KEY }}' >> .env
|
||||
echo 'PAYPAL_CLIENT_ID=${{ secrets.PAYPAL_CLIENT_ID }}' >> .env
|
||||
echo 'PATREON_CLIENT_ID=${{ secrets.PATREON_CLIENT_ID }}' >> .env
|
||||
|
||||
- name: Install playwright dependencies
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Install
|
||||
run: pnpm install
|
||||
|
||||
- name: Start apps
|
||||
run: |
|
||||
docker compose -f docker/docker-compose.yml -f docker/docker-compose.e2e.yml up -d
|
||||
pnpm run serve:client-ci &
|
||||
sleep 10
|
||||
|
||||
- name: Seed Database with Certified User
|
||||
run: pnpm run seed:certified-user
|
||||
|
||||
- name: Run playwright tests
|
||||
run: pnpm run playwright:run third-party-donation.spec.ts --project=${{ matrix.browsers }}
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report-${{ matrix.browsers }}
|
||||
path: e2e/playwright/reporter
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,71 @@
|
||||
name: GitHub - Autoclose Invalid PRs
|
||||
on:
|
||||
pull_request_target:
|
||||
branches:
|
||||
- 'main'
|
||||
paths:
|
||||
- '.gitignore'
|
||||
|
||||
jobs:
|
||||
autoclose:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
script: |
|
||||
const files = await github.rest.pulls.listFiles({
|
||||
owner: context.payload.repository.owner.login,
|
||||
repo: context.payload.repository.name,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
});
|
||||
if (
|
||||
files.data.length !== 1 ||
|
||||
(files.data[0].filename !== ".gitignore" &&
|
||||
// We've had four PRs make this same (irrelevant) change already.
|
||||
!(files.data[0].filename === "664ef4623946e65e18d59764.md" &&
|
||||
files.data[0].patch.includes("return re.sub('(?<!\d)1', '', equation_string.strip('+'))")
|
||||
)
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const creator = context.payload.sender.login;
|
||||
const opts = github.rest.issues.listForRepo.endpoint.merge({
|
||||
...context.issue,
|
||||
creator,
|
||||
state: 'all'
|
||||
});
|
||||
const issues = await github.paginate(opts);
|
||||
|
||||
// iterate through issues
|
||||
for (const issue of issues) {
|
||||
// if the issue is this one, keep looking
|
||||
if (issue.number === context.issue.number) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// if the issue is actually a PR, they're not a first timer
|
||||
if (issue.pull_request) {
|
||||
return // Creator is already a contributor.
|
||||
}
|
||||
}
|
||||
core.setFailed("Invalid PR detected.");
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: "Thank you for opening this pull request.\n\nThis is a standard message notifying you that we've reviewed your pull request and have decided not to merge it. We would welcome future pull requests from you.\n\nThank you and happy coding.",
|
||||
});
|
||||
await github.rest.pulls.update({
|
||||
owner: context.payload.repository.owner.login,
|
||||
repo: context.payload.repository.name,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
state: "closed"
|
||||
});
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.payload.repository.owner.login,
|
||||
repo: context.payload.repository.name,
|
||||
issue_number: context.issue.number,
|
||||
labels: ["spam"]
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
name: GitHub - Label PRs
|
||||
on:
|
||||
- pull_request_target
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.workflow_run.head_branch || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
permissions:
|
||||
# for actions/labeler to determine modified files
|
||||
contents: read
|
||||
# for actions/labeler to add labels to PRs
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6
|
||||
with:
|
||||
repo-token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
sync-labels: true
|
||||
@@ -0,0 +1,21 @@
|
||||
name: GitHub - Lock Closed PRs
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [closed]
|
||||
|
||||
jobs:
|
||||
lock:
|
||||
name: Lock Closed PR
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
await github.rest.issues.lock({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
lock_reason: 'resolved'
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
name: GitHub - No translations via PRs
|
||||
on:
|
||||
pull_request_target:
|
||||
branches:
|
||||
- 'main'
|
||||
paths:
|
||||
- 'client/i18n/locales/**/intro.json'
|
||||
- 'client/i18n/locales/**/translations.json'
|
||||
- '!client/i18n/locales/english/**'
|
||||
|
||||
jobs:
|
||||
has-translation:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
github-token: ${{secrets.CAMPERBOT_NO_TRANSLATE}}
|
||||
script: |
|
||||
const isDev = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: "freeCodeCamp",
|
||||
team_slug: "dev-team",
|
||||
username: context.payload.pull_request.user.login
|
||||
}).catch(() => ({status: 404}));
|
||||
if (context.payload.pull_request.user.login !== "camperbot" && isDev.status !== 200) {
|
||||
core.setFailed('This PR appears to touch translated curriculum files.')
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: "Thanks for your pull request.\n\n**Please remove the changes made to the non-English versions of the files. No need to close this pull request; just add more commits as needed.**\n\nWe require you to change **only English** versions of files in the codebase. Translations to corresponding files in other world languages are managed on our translation platform. Once your pull request is merged, changes will be synced automatically to other world languages.\n\nPlease visit [our contributing guidelines](https://contribute.freecodecamp.org) to learn more about translating freeCodeCamp's resources.\n\nAs always, we value all of your contributions.\n\nHappy contributing!\n\n---\n_**Note:** This message was automatically generated by a bot. If you feel this message is in error or would like help resolving it, feel free to reach us [in our contributor chat](https://discord.gg/PRyKn3Vbay)._"
|
||||
})
|
||||
} else if (isDev.status === 200) {
|
||||
core.setFailed('This PR appears to touch translated curriculum files, but since you are on the dev team there is no message.');
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
name: GitHub - PR Contribution Guidelines
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, edited]
|
||||
|
||||
jobs:
|
||||
# Ensures PR commits were not added via the GitHub Web UI, which typically indicates
|
||||
# the contributor hasn't tested their changes in a local development environment.
|
||||
no-web-commits:
|
||||
name: No Commits on GitHub Web
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
is_allow_listed: ${{ steps.pr_author.outputs.is_allow_listed }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
sparse-checkout: .github/scripts/pr-guidelines
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Check if PR author is allow-listed
|
||||
id: pr_author
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
# GITHUB_TOKEN does not have the read:org permission needed for this call
|
||||
# while CAMPERBOT_NO_TRANSLATE does, since it is a PAT for the camperbot account with read:org scope
|
||||
github-token: ${{ secrets.CAMPERBOT_NO_TRANSLATE }}
|
||||
script: |
|
||||
const fn = require('./.github/scripts/pr-guidelines/check-allow-list.js');
|
||||
await fn({ github, context, core });
|
||||
|
||||
- name: Check if commits are made on GitHub Web UI
|
||||
id: check-commits
|
||||
if: steps.pr_author.outputs.is_allow_listed == 'false' && github.event.action != 'edited'
|
||||
env:
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
run: |
|
||||
PR_NUMBER=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH")
|
||||
COMMITS_URL="https://api.github.com/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/commits"
|
||||
HAS_GITHUB_SIGNED_COMMIT=$(curl --header "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "$COMMITS_URL" | jq '[.[] | select(.commit.committer.name == "GitHub") | select(.commit.message | test("revert"; "i") | not)] | length > 0')
|
||||
# GitHub Codespaces also produces GitHub-signed commits, but Codespaces users
|
||||
# work on descriptively named branches. The web editor defaults to patch-N branches.
|
||||
IS_PATCH_BRANCH=false
|
||||
if [[ "$HEAD_REF" =~ ^patch-[0-9]+$ ]]; then
|
||||
IS_PATCH_BRANCH=true
|
||||
fi
|
||||
if [ "$HAS_GITHUB_SIGNED_COMMIT" = "true" ] && [ "$IS_PATCH_BRANCH" = "true" ]; then
|
||||
echo "IS_GITHUB_COMMIT=true" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Add comment on PR if commits are made on GitHub Web UI
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
if: steps.pr_author.outputs.is_allow_listed == 'false' && env.IS_GITHUB_COMMIT == 'true' && github.event.action != 'edited'
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
core.setFailed("Commits were added via the GitHub Web UI.");
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: "Thanks for your pull request.\n\n**Please do not add commits via the GitHub Web UI.**\n\nIt generally means you have yet to test these changes in a development setup or complete any prerequisites. We need you to follow the guides mentioned in the checklist. Please revalidate these changes in a developer environment and confirm how you validated your changes.\n\nHappy contributing!\n\n---\n_**Note:** This message was automatically generated by a bot. If you feel this message is in error or would like help resolving it, feel free to reach us [in our contributor chat](https://discord.gg/PRyKn3Vbay)._"
|
||||
});
|
||||
|
||||
- name: Add deprioritized label
|
||||
if: failure()
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['deprioritized']
|
||||
});
|
||||
|
||||
# Normalizes PR titles to follow Conventional Commits format, applying fuzzy fixes
|
||||
# for common mistakes like typos, missing colons, or incorrect spacing.
|
||||
fix-pr-title:
|
||||
name: Fix PR Title
|
||||
runs-on: ubuntu-24.04
|
||||
needs: no-web-commits
|
||||
if: needs.no-web-commits.result == 'success' && github.event.action != 'edited'
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
sparse-checkout: .github/scripts/pr-guidelines
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const fn = require('./.github/scripts/pr-guidelines/fix-pr-title.js');
|
||||
await fn({ github, context });
|
||||
|
||||
# Checks that the PR description still contains the required template.
|
||||
# The first 3 checkboxes must be ticked ([x] or [X]).
|
||||
# The last checkbox (tested locally) is acceptable to leave unticked
|
||||
# but removing the entire template is not.
|
||||
check-pr-template:
|
||||
name: Check PR Template
|
||||
runs-on: ubuntu-24.04
|
||||
needs: no-web-commits
|
||||
if: needs.no-web-commits.result == 'success'
|
||||
outputs:
|
||||
failure_reason: ${{ steps.check.outputs.failure_reason }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
sparse-checkout: .github/scripts/pr-guidelines
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- id: check
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const fn = require('./.github/scripts/pr-guidelines/check-pr-template.js');
|
||||
await fn({ github, context, core, isAllowListed: '${{ needs.no-web-commits.outputs.is_allow_listed }}' });
|
||||
|
||||
# Verifies that each PR references a linked, triaged issue before it can be reviewed.
|
||||
check-linked-issue:
|
||||
name: Check Linked Issue
|
||||
runs-on: ubuntu-24.04
|
||||
needs: no-web-commits
|
||||
if: needs.no-web-commits.result == 'success'
|
||||
outputs:
|
||||
failure_reason: ${{ steps.check.outputs.failure_reason }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
sparse-checkout: .github/scripts/pr-guidelines
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- id: check
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const fn = require('./.github/scripts/pr-guidelines/check-linked-issue.js');
|
||||
await fn({ github, context, core, isAllowListed: '${{ needs.no-web-commits.outputs.is_allow_listed }}' });
|
||||
|
||||
# Coordinates reporting: posts a single combined comment when checks fail,
|
||||
# or removes the deprioritized label when all checks pass.
|
||||
report:
|
||||
name: Report
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [no-web-commits, check-pr-template, check-linked-issue]
|
||||
if: always() && needs.no-web-commits.result == 'success'
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
sparse-checkout: .github/scripts/pr-guidelines
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const fn = require('./.github/scripts/pr-guidelines/report-results.js');
|
||||
await fn({
|
||||
github,
|
||||
context,
|
||||
templateResult: '${{ needs.check-pr-template.result }}',
|
||||
templateReason: '${{ needs.check-pr-template.outputs.failure_reason }}',
|
||||
linkedIssueResult: '${{ needs.check-linked-issue.result }}',
|
||||
linkedIssueReason: '${{ needs.check-linked-issue.outputs.failure_reason }}'
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
name: GitHub - Spam PR
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- labeled
|
||||
|
||||
jobs:
|
||||
is-spam:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
github-token: ${{secrets.CAMPERBOT_NO_TRANSLATE}}
|
||||
script: |
|
||||
if (context.payload.label.name === "spam") {
|
||||
try {
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: "We are marking this pull request as spam. Please note that if you are participating in Hacktoberfest, two or more PRs marked as spam will result in your permanent disqualification.\n\nIf you are interested in making quality and genuine contributions to our projects, check out our [contributing guidelines](https://contribute.freecodecamp.org)."
|
||||
});
|
||||
} catch {
|
||||
// Conversation may already be locked — ignore.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
name: i18n - Build Validation
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.workflow_run.head_branch || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
name: Validate i18n Builds
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [24]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 # v6
|
||||
id: pnpm-install
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Turbo Cache
|
||||
uses: ./.github/actions/setup-turbo-cache
|
||||
with:
|
||||
turbo-token: ${{ secrets.TURBO_TOKEN }}
|
||||
turbo-signature-key: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||
|
||||
- name: Set freeCodeCamp Environment Variables
|
||||
run: |
|
||||
sed '/^[[:space:]]*#/d; /^$/d' sample.env >> $GITHUB_ENV
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Validate Challenge Files
|
||||
run: pnpm run audit-challenges
|
||||
@@ -0,0 +1,61 @@
|
||||
name: i18n - Curriculum PR Validation
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.workflow_run.head_branch || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
name: Validate i18n Builds
|
||||
# run only on PRs that camperbot opens with title that matches the curriculum sync
|
||||
if: ${{ github.event.pull_request.user.login == 'camperbot' && contains(github.event.pull_request.title, 'chore(i18n,learn)') }}
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [24]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 # v6
|
||||
id: pnpm-install
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Set freeCodeCamp Environment Variables
|
||||
run: |
|
||||
sed '/^[[:space:]]*#/d; /^$/d' sample.env >> $GITHUB_ENV
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Validate Challenge Files
|
||||
id: validate
|
||||
run: pnpm run audit-challenges
|
||||
|
||||
- name: Create Comment
|
||||
# Run if the validate challenge files step fails, specifically. Note that we need the failure() call for this step to trigger if the action fails.
|
||||
if: ${{ failure() && steps.validate.conclusion == 'failure' }}
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
github-token: ${{secrets.CAMPERBOT_NO_TRANSLATE}}
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: "Hey @freecodecamp/i18n, it looks like we have new English curriculum files that need to be translated."
|
||||
})
|
||||
@@ -0,0 +1,316 @@
|
||||
name: CI - Node.js
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'prod-**'
|
||||
- 'renovate/**'
|
||||
- 'hotfix-**'
|
||||
- 'temp-**'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'temp-**' # Temporary branches allowed on Upstream
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.workflow_run.head_branch || github.ref }}
|
||||
cancel-in-progress: ${{ !contains(github.ref, 'main') && !contains(github.ref, 'prod-') }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
# Skip PR runs for Renovate since push already triggered CI
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.user.login != 'renovate[bot]'
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [24]
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check number of lockfiles
|
||||
run: |
|
||||
if [ $(find . -name 'package-lock.json' | grep -vc -e 'node_modules') -gt 0 ]
|
||||
then
|
||||
echo -e 'Error: found package-lock files in the repository.\nWe use pnpm workspaces to manage packages so all dependencies should be added via pnpm add'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check format of sample.env
|
||||
run: docker run --rm -v `pwd`:/app -w /app dotenvlinter/dotenv-linter check --ignore-checks UnorderedKey sample.env
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 # v6
|
||||
id: pnpm-install
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Turbo Cache
|
||||
uses: ./.github/actions/setup-turbo-cache
|
||||
with:
|
||||
turbo-token: ${{ secrets.TURBO_TOKEN }}
|
||||
turbo-signature-key: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||
|
||||
- name: Set Environment variables
|
||||
run: |
|
||||
sed '/^[[:space:]]*#/d; /^$/d' sample.env >> $GITHUB_ENV
|
||||
|
||||
- name: Install node_modules
|
||||
run: pnpm install
|
||||
|
||||
- name: Check formatting
|
||||
run: |
|
||||
pnpm prettier --check . || [ $? -eq 1 ] && printf "\nTip: Run 'pnpm run format' in your terminal to fix this.\n\n"
|
||||
|
||||
- name: Lint Source Files
|
||||
run: |
|
||||
echo pnpm version $(pnpm -v)
|
||||
pnpm lint
|
||||
|
||||
# This is populate the cache, otherwise local runs with upcoming changes
|
||||
# will not benefit.
|
||||
- name: Set UPCOMING_CHANGES
|
||||
run: |
|
||||
echo 'SHOW_UPCOMING_CHANGES=true' >> $GITHUB_ENV
|
||||
|
||||
- name: Lint Upcoming Changes
|
||||
run: |
|
||||
pnpm lint
|
||||
|
||||
# DONT REMOVE THIS JOB.
|
||||
# TODO: Refactor and use re-usable workflow and shared artifacts
|
||||
build:
|
||||
name: Build
|
||||
needs: lint
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [24]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
persist-credentials: false
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 # v6
|
||||
id: pnpm-install
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Turbo Cache
|
||||
uses: ./.github/actions/setup-turbo-cache
|
||||
with:
|
||||
turbo-token: ${{ secrets.TURBO_TOKEN }}
|
||||
turbo-signature-key: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||
|
||||
- name: Set freeCodeCamp Environment Variables
|
||||
run: |
|
||||
sed '/^[[:space:]]*#/d; /^$/d' sample.env >> $GITHUB_ENV
|
||||
|
||||
- name: Install and Build
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm run build
|
||||
|
||||
test:
|
||||
name: Test
|
||||
needs: build
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node-version: [24]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
persist-credentials: false
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 # v6
|
||||
id: pnpm-install
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Turbo Cache
|
||||
uses: ./.github/actions/setup-turbo-cache
|
||||
with:
|
||||
turbo-token: ${{ secrets.TURBO_TOKEN }}
|
||||
turbo-signature-key: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||
|
||||
- name: Set Environment variables
|
||||
run: |
|
||||
sed '/^[[:space:]]*#/d; /^$/d' sample.env >> $GITHUB_ENV
|
||||
cat sample.env
|
||||
|
||||
- name: Start MongoDB
|
||||
run: docker compose -f docker/docker-compose.yml up -d
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
echo pnpm version $(pnpm -v)
|
||||
pnpm install
|
||||
|
||||
- name: Install Chrome for Puppeteer
|
||||
run: pnpm -F=curriculum install-puppeteer
|
||||
|
||||
- name: Run Tests
|
||||
run: pnpm test
|
||||
|
||||
test-upcoming:
|
||||
name: Test - Upcoming Changes
|
||||
needs: build
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node-version: [24]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
persist-credentials: false
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 # v6
|
||||
id: pnpm-install
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Turbo Cache
|
||||
uses: ./.github/actions/setup-turbo-cache
|
||||
with:
|
||||
turbo-token: ${{ secrets.TURBO_TOKEN }}
|
||||
turbo-signature-key: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||
|
||||
- name: Set Environment variables
|
||||
run: |
|
||||
sed '/^[[:space:]]*#/d; /^$/d' sample.env >> $GITHUB_ENV
|
||||
echo 'SHOW_UPCOMING_CHANGES=true' >> $GITHUB_ENV
|
||||
|
||||
- name: Start MongoDB
|
||||
run: docker compose -f docker/docker-compose.yml up -d
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
echo pnpm version $(pnpm -v)
|
||||
pnpm install
|
||||
|
||||
- name: Install Chrome for Puppeteer
|
||||
run: pnpm -F=curriculum install-puppeteer
|
||||
|
||||
- name: Run Tests
|
||||
run: pnpm test
|
||||
|
||||
test-localization:
|
||||
name: Test - i18n
|
||||
needs: build
|
||||
runs-on: ubuntu-24.04
|
||||
if: github.event.pull_request.user.login == 'camperbot' && github.head_ref == 'chore/update-i18n-curriculum-submodule'
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node-version: [24]
|
||||
locale: [portuguese, italian]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
persist-credentials: false
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 # v6
|
||||
id: pnpm-install
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Turbo Cache
|
||||
uses: ./.github/actions/setup-turbo-cache
|
||||
with:
|
||||
turbo-token: ${{ secrets.TURBO_TOKEN }}
|
||||
turbo-signature-key: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||
|
||||
- name: Set Environment variables
|
||||
run: |
|
||||
sed '/^[[:space:]]*#/d; /^$/d' sample.env >> $GITHUB_ENV
|
||||
cat sample.env
|
||||
|
||||
- name: Start MongoDB
|
||||
uses: supercharge/mongodb-github-action@315db7fe45ac2880b7758f1933e6e5d59afd5e94 # 1.12.1
|
||||
with:
|
||||
mongodb-version: 8.0
|
||||
mongodb-replica-set: test-rs
|
||||
mongodb-port: 27017
|
||||
|
||||
- name: Install Dependencies
|
||||
env:
|
||||
CURRICULUM_LOCALE: ${{ matrix.locale }}
|
||||
CLIENT_LOCALE: ${{ matrix.locale }}
|
||||
run: |
|
||||
echo pnpm version $(pnpm -v)
|
||||
pnpm install
|
||||
|
||||
# DONT REMOVE THIS STEP.
|
||||
# TODO: Refactor and use re-usable workflow and shared artifacts
|
||||
- name: Build Client in ${{ matrix.locale }}
|
||||
env:
|
||||
CURRICULUM_LOCALE: ${{ matrix.locale }}
|
||||
CLIENT_LOCALE: ${{ matrix.locale }}
|
||||
run: |
|
||||
pnpm run build
|
||||
|
||||
- name: Install Chrome for Puppeteer
|
||||
run: pnpm -F=curriculum install-puppeteer
|
||||
|
||||
- name: Run Tests
|
||||
env:
|
||||
CURRICULUM_LOCALE: ${{ matrix.locale }}
|
||||
CLIENT_LOCALE: ${{ matrix.locale }}
|
||||
run: pnpm test
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
### VisualStudioCode ###
|
||||
.vscode/*
|
||||
|
||||
### WebStorm ###
|
||||
.idea/*
|
||||
|
||||
### VisualStudioCode Patch ###
|
||||
|
||||
# Ignore all local history of files
|
||||
.history
|
||||
|
||||
### Windows ###
|
||||
|
||||
# Windows thumbnail cache files
|
||||
Thumbs.db
|
||||
Thumbs.db:encryptable
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
|
||||
# Dump file
|
||||
*.stackdump
|
||||
|
||||
# Folder config file
|
||||
[Dd]esktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows Installer files
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
### Linux ###
|
||||
|
||||
# General
|
||||
*~
|
||||
|
||||
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||
.fuse_hidden*
|
||||
|
||||
# KDE directory preferences
|
||||
.directory
|
||||
|
||||
# Linux trash folder which might appear on any partition or disk
|
||||
.Trash-*
|
||||
|
||||
# .nfs files are created when an open file is removed but is still being accessed
|
||||
.nfs*
|
||||
|
||||
### macOS ###
|
||||
|
||||
# General
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
### Node ###
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
|
||||
# gatsby files
|
||||
.cache/
|
||||
|
||||
### Netlify ###
|
||||
.netlify
|
||||
|
||||
### Old Generated files ###
|
||||
# These files are no longer generated by the client, but can
|
||||
# exist on older branches. Continuing to ignore them ensures they
|
||||
# are not erroneously committed by contributors (or Naomi) who
|
||||
# aren't as familiar with our codebase.
|
||||
config/superblock-order.js
|
||||
config/superblock-order.test.js
|
||||
utils/slugs.js
|
||||
utils/slugs.test.js
|
||||
|
||||
### vim ###
|
||||
# Swap
|
||||
[._]*.s[a-v][a-z]
|
||||
!*.svg # comment out if you don't need vector files
|
||||
[._]*.sw[a-p]
|
||||
[._]s[a-rt-v][a-z]
|
||||
[._]ss[a-gi-z]
|
||||
[._]sw[a-p]
|
||||
|
||||
# Session
|
||||
Session.vim
|
||||
Sessionx.vim
|
||||
|
||||
# Temporary
|
||||
.netrwhist
|
||||
*~
|
||||
# Auto-generated tag files
|
||||
tags
|
||||
# Persistent undo
|
||||
[._]*.un~
|
||||
|
||||
### Additional Files ###
|
||||
*.csv
|
||||
*.dat
|
||||
*.out
|
||||
*.gz
|
||||
curriculum/curricula.json
|
||||
|
||||
### Additional Folders ###
|
||||
curriculum/dist
|
||||
curriculum/build
|
||||
curriculum/src/test/blocks-generated
|
||||
|
||||
### Playwright ###
|
||||
|
||||
/playwright
|
||||
|
||||
### Shadow Testing Log Files Folder ###
|
||||
api/logs/
|
||||
|
||||
### Turborepo
|
||||
.turbo
|
||||
test-results
|
||||
@@ -0,0 +1,8 @@
|
||||
[submodule "curriculum/i18n-curriculum"]
|
||||
path = curriculum/i18n-curriculum
|
||||
url = https://github.com/freeCodeCamp/i18n-curriculum.git
|
||||
ignore = dirty
|
||||
[submodule "tools/challenge-editor"]
|
||||
path = tools/challenge-editor
|
||||
url = https://github.com/freeCodeCamp/challenge-editor.git
|
||||
ignore = dirty
|
||||
@@ -0,0 +1 @@
|
||||
NODE_OPTIONS=\"--max-old-space-size=7168\" pnpm lint-staged
|
||||
@@ -0,0 +1,17 @@
|
||||
.*/*
|
||||
**/.cache
|
||||
**/*fixtures*
|
||||
/client/**/trending.json
|
||||
/client/**/search-bar.json
|
||||
/client/config/*.json
|
||||
/client/static
|
||||
/client/public
|
||||
/curriculum/challenges/_meta/*/*
|
||||
/curriculum/challenges/**/*
|
||||
/curriculum/i18n-curriculum
|
||||
/curriculum/generated
|
||||
/curriculum/src/test/stubs
|
||||
/e2e/playwright
|
||||
/pnpm-lock.yaml
|
||||
/tools/challenge-editor
|
||||
dist
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"endOfLine": "lf",
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "none",
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
playwright
|
||||
client/public
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"rules": {
|
||||
"no-invalid-double-slash-comments": true,
|
||||
"no-duplicate-selectors": true,
|
||||
"font-family-no-duplicate-names": true,
|
||||
"declaration-block-no-shorthand-property-overrides": true,
|
||||
"declaration-block-no-duplicate-custom-properties": true,
|
||||
"declaration-block-no-duplicate-properties": [
|
||||
true,
|
||||
{
|
||||
"ignore": ["consecutive-duplicates-with-different-values"]
|
||||
}
|
||||
],
|
||||
"comment-no-empty": true,
|
||||
"color-no-invalid-hex": true,
|
||||
"block-no-empty": true,
|
||||
"shorthand-property-no-redundant-values": true,
|
||||
"keyframe-declaration-no-important": true,
|
||||
"no-duplicate-at-import-rules": true,
|
||||
"named-grid-areas-no-invalid": true,
|
||||
"no-invalid-position-at-import-rule": true
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2014, freeCodeCamp.
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
- Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
- Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
- Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -0,0 +1,95 @@
|
||||
[](https://www.freecodecamp.org/)
|
||||
|
||||
[](https://www.firsttimersonly.com/)
|
||||
[](https://discord.gg/PRyKn3Vbay)
|
||||
[](https://insights.linuxfoundation.org/project/freecodecamp/repository/freecodecamp-freecodecamp)
|
||||
|
||||
## freeCodeCamp.org's open-source codebase and curriculum
|
||||
|
||||
[freeCodeCamp.org](https://www.freecodecamp.org) is a friendly community where you can learn to code for free. It is run by a [donor-supported 501(c)(3) charity](https://www.freecodecamp.org/donate) to help millions of busy adults transition into tech. Our community has already helped more than 100,000 people get their first developer job.
|
||||
|
||||
Our full-stack web development and machine learning curriculum is completely free and self-paced. We have thousands of interactive coding challenges to help you expand your skills.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Certifications](#certifications)
|
||||
- [The Learning Platform](#the-learning-platform)
|
||||
- [Reporting Bugs and Issues](#reporting-bugs-and-issues)
|
||||
- [Reporting Security Issues and Responsible Disclosure](#reporting-security-issues-and-responsible-disclosure)
|
||||
- [Contributing](#contributing)
|
||||
- [Platform, Build and Deployment Status](#platform-build-and-deployment-status)
|
||||
- [License](#license)
|
||||
|
||||
### Certifications
|
||||
|
||||
freeCodeCamp.org offers several free developer certifications that make up the [Full-Stack Developer Curriculum](https://www.freecodecamp.org/learn/full-stack-developer-v9/):
|
||||
|
||||
- [Responsive Web Design](https://www.freecodecamp.org/learn/responsive-web-design-v9/)
|
||||
- [JavaScript](https://www.freecodecamp.org/learn/javascript-v9/)
|
||||
- [Front-End Development Libraries](https://www.freecodecamp.org/learn/front-end-development-libraries-v9/)
|
||||
- [Python](https://www.freecodecamp.org/learn/python-v9/)
|
||||
- [Relational Databases](https://www.freecodecamp.org/learn/relational-databases-v9/)
|
||||
- [Back-End Development and APIs](https://www.freecodecamp.org/learn/back-end-development-and-apis-v9/)
|
||||
|
||||
Each of these certifications involves completing interactive lessons, workshops, labs, reviews, and quizzes. Throughout the certification, you'll need to complete 5 required projects to qualify for the exam. Once you pass the exam, then you can claim the certification.
|
||||
|
||||
freeCodeCamp.org also offers free language certifications designed around internationally recognized proficiency levels:
|
||||
|
||||
- [A2 English for Developers (Beta)](https://www.freecodecamp.org/learn/a2-english-for-developers/)
|
||||
- [B1 English for Developers (Beta)](https://www.freecodecamp.org/learn/b1-english-for-developers/)
|
||||
- [A1 Professional Spanish (Beta)](https://www.freecodecamp.org/learn/a1-professional-spanish/)
|
||||
- [A1 Professional Chinese (Beta)](https://www.freecodecamp.org/learn/a1-professional-chinese/)
|
||||
|
||||
Each of these certifications is organized into modules, with sections for warm-ups, lessons, practice exercises, review pages, and quizzes to ensure you fully grasp the material before progressing to the next module. You'll need to complete all of the quizzes in order to qualify for the exam at the end of the certification.
|
||||
|
||||
Once you've earned a certification, you will always have it. You will always be able to link to it from your LinkedIn or resume. And when your prospective employers or freelance clients click that link, they'll see a verified certification specific to you.
|
||||
|
||||
The one exception to this is if we discover violations of our [Academic Honesty Policy](https://www.freecodecamp.org/news/academic-honesty-policy/). When we catch people unambiguously plagiarizing (submitting other people's code or projects as their own without citation), we do what all rigorous institutions of learning should do - we revoke their certifications and ban those people.
|
||||
|
||||
In addition, to help prepare for job interviews, freeCodeCamp.org includes The Odin Project (freeCodeCamp Remix), Coding Interview Prep, Project Euler, and Rosetta Code.
|
||||
|
||||
A free, professional Foundational C# with Microsoft Certification is also available.
|
||||
|
||||
### The Learning Platform
|
||||
|
||||
This code is running live at [freeCodeCamp.org](https://www.freecodecamp.org).
|
||||
|
||||
Our community also has:
|
||||
|
||||
- A [forum](https://forum.freecodecamp.org) where you can usually get programming help or project feedback within hours.
|
||||
- A [YouTube channel](https://youtube.com/freecodecamp) with free courses on Python, SQL, Android, and a wide variety of other technologies.
|
||||
- A [technical publication](https://www.freecodecamp.org/news) with thousands of programming tutorials and articles about mathematics and computer science.
|
||||
- A [Discord server](https://discord.gg/Z7Fm39aNtZ) where you can hang out and talk with developers and people who are learning to code.
|
||||
|
||||
> #### [Join the community here](https://www.freecodecamp.org/signin).
|
||||
|
||||
### Reporting Bugs and Issues
|
||||
|
||||
If you think you've found a bug, first read the [how to report a bug](https://forum.freecodecamp.org/t/how-to-report-a-bug/19543) article and follow its instructions.
|
||||
|
||||
If you're confident it's a new bug and have confirmed that someone else is facing the same issue, go ahead and create a new GitHub issue. Be sure to include as much information as possible so we can reproduce the bug.
|
||||
|
||||
### Reporting Security Issues and Responsible Disclosure
|
||||
|
||||
We appreciate responsible disclosure of vulnerabilities that might impact the integrity of our platforms and users.
|
||||
|
||||
> #### [Read our security policy and follow these steps to report a vulnerability](https://contribute.freecodecamp.org/#/security).
|
||||
|
||||
### Contributing
|
||||
|
||||
The freeCodeCamp.org community is possible thanks to thousands of kind volunteers like you. We welcome all contributions to the community and are excited to welcome you aboard.
|
||||
|
||||
> #### [Please follow these steps to contribute](https://contribute.freecodecamp.org).
|
||||
|
||||
Recent Contributions:
|
||||
|
||||

|
||||
|
||||
### License
|
||||
|
||||
Copyright © 2014 freeCodeCamp.org
|
||||
|
||||
The content of this repository is bound by the following licenses:
|
||||
|
||||
- The computer software is licensed under the [BSD-3-Clause](LICENSE.md) license.
|
||||
- The learning resources in the [`/curriculum`](/curriculum) directory including their subdirectories therein are copyright © 2014 freeCodeCamp.org
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`freeCodeCamp/freeCodeCamp`
|
||||
- 原始仓库:https://github.com/freeCodeCamp/freeCodeCamp
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -0,0 +1 @@
|
||||
dist/
|
||||
@@ -0,0 +1,4 @@
|
||||
/* eslint-disable filenames-simple/naming-convention */
|
||||
import { createLintStagedConfig } from '@freecodecamp/eslint-config/lintstaged';
|
||||
|
||||
export default createLintStagedConfig(import.meta.dirname);
|
||||
@@ -0,0 +1,400 @@
|
||||
/* eslint-disable jsdoc/require-jsdoc */
|
||||
import { Static } from '@fastify/type-provider-typebox';
|
||||
import {
|
||||
ExamEnvironmentConfig,
|
||||
ExamEnvironmentQuestionType,
|
||||
ExamEnvironmentExamAttempt,
|
||||
ExamEnvironmentExam,
|
||||
ExamEnvironmentGeneratedExam,
|
||||
ExamEnvironmentQuestionSet,
|
||||
ExamEnvironmentChallenge
|
||||
} from '@prisma/client';
|
||||
import { ObjectId } from 'bson';
|
||||
import { examEnvironmentPostExamAttempt } from '../src/exam-environment/schemas/index.js';
|
||||
|
||||
const defaultUserId = '5bd30e0f1caf6ac3ddddddb5';
|
||||
|
||||
export const oid = () => new ObjectId().toString();
|
||||
|
||||
export const examId = oid();
|
||||
|
||||
export const config = {
|
||||
totalTimeInS: 2 * 60 * 60,
|
||||
tags: [],
|
||||
name: 'Test Exam',
|
||||
note: 'Some exam note...',
|
||||
passingPercent: 80,
|
||||
questionSets: [
|
||||
{
|
||||
type: ExamEnvironmentQuestionType.MultipleChoice,
|
||||
numberOfSet: 1,
|
||||
numberOfQuestions: 1,
|
||||
numberOfCorrectAnswers: 1,
|
||||
numberOfIncorrectAnswers: 1
|
||||
},
|
||||
{
|
||||
type: ExamEnvironmentQuestionType.MultipleChoice,
|
||||
numberOfSet: 1,
|
||||
numberOfQuestions: 1,
|
||||
numberOfCorrectAnswers: 2,
|
||||
numberOfIncorrectAnswers: 1
|
||||
},
|
||||
{
|
||||
type: ExamEnvironmentQuestionType.Dialogue,
|
||||
numberOfSet: 1,
|
||||
numberOfQuestions: 2,
|
||||
numberOfCorrectAnswers: 1,
|
||||
numberOfIncorrectAnswers: 1
|
||||
}
|
||||
],
|
||||
retakeTimeInS: 24 * 60 * 60
|
||||
} satisfies ExamEnvironmentConfig;
|
||||
|
||||
export const questionSets: ExamEnvironmentQuestionSet[] = [
|
||||
{
|
||||
id: oid(),
|
||||
type: ExamEnvironmentQuestionType.MultipleChoice,
|
||||
context: null,
|
||||
questions: [
|
||||
{
|
||||
id: oid(),
|
||||
tags: ['q1t1'],
|
||||
text: 'Question 1',
|
||||
deprecated: false,
|
||||
audio: null,
|
||||
answers: [
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 1',
|
||||
isCorrect: true
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 2',
|
||||
isCorrect: true
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 3',
|
||||
isCorrect: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
type: ExamEnvironmentQuestionType.MultipleChoice,
|
||||
context: null,
|
||||
questions: [
|
||||
{
|
||||
id: oid(),
|
||||
tags: [],
|
||||
text: 'Question 1',
|
||||
deprecated: false,
|
||||
audio: null,
|
||||
answers: [
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 1',
|
||||
isCorrect: true
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 2',
|
||||
isCorrect: false
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 3',
|
||||
isCorrect: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
type: ExamEnvironmentQuestionType.Dialogue,
|
||||
context: 'Dialogue 1 context',
|
||||
questions: [
|
||||
{
|
||||
id: oid(),
|
||||
tags: ['q1t1'],
|
||||
text: 'Question 1',
|
||||
deprecated: false,
|
||||
audio: null,
|
||||
answers: [
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 1',
|
||||
isCorrect: true
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 2',
|
||||
isCorrect: false
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 3',
|
||||
isCorrect: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
tags: ['q2t1', 'q2t2'],
|
||||
text: 'Question 2',
|
||||
deprecated: true,
|
||||
audio: {
|
||||
url: 'https://freecodecamp.org',
|
||||
captions: null
|
||||
},
|
||||
answers: [
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 1',
|
||||
isCorrect: true
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 2',
|
||||
isCorrect: false
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 3',
|
||||
isCorrect: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
tags: ['q3t1', 'q3t2'],
|
||||
text: 'Question 3',
|
||||
deprecated: false,
|
||||
audio: null,
|
||||
answers: [
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 1',
|
||||
isCorrect: true
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 2',
|
||||
isCorrect: false
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 3',
|
||||
isCorrect: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const generatedExam: ExamEnvironmentGeneratedExam = {
|
||||
examId,
|
||||
id: oid(),
|
||||
deprecated: false,
|
||||
questionSets: [
|
||||
{
|
||||
id: questionSets[0]!.id,
|
||||
questions: [
|
||||
{
|
||||
id: questionSets[0]!.questions[0]!.id,
|
||||
answers: [
|
||||
questionSets[0]!.questions[0]!.answers[0]!.id,
|
||||
questionSets[0]!.questions[0]!.answers[1]!.id
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: questionSets[1]!.id,
|
||||
questions: [
|
||||
{
|
||||
id: questionSets[1]!.questions[0]!.id,
|
||||
answers: [
|
||||
questionSets[1]!.questions[0]!.answers[0]!.id,
|
||||
questionSets[1]!.questions[0]!.answers[1]!.id,
|
||||
questionSets[1]!.questions[0]!.answers[2]!.id
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: questionSets[2]!.id,
|
||||
questions: [
|
||||
{
|
||||
id: questionSets[2]!.questions[0]!.id,
|
||||
answers: [
|
||||
questionSets[2]!.questions[0]!.answers[0]!.id,
|
||||
questionSets[2]!.questions[0]!.answers[1]!.id,
|
||||
questionSets[2]!.questions[0]!.answers[2]!.id
|
||||
]
|
||||
},
|
||||
{
|
||||
id: questionSets[2]!.questions[1]!.id,
|
||||
answers: [
|
||||
questionSets[2]!.questions[1]!.answers[0]!.id,
|
||||
questionSets[2]!.questions[1]!.answers[1]!.id,
|
||||
questionSets[2]!.questions[1]!.answers[2]!.id
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
version: 2
|
||||
};
|
||||
|
||||
export const examAttempt: ExamEnvironmentExamAttempt = {
|
||||
examId,
|
||||
generatedExamId: generatedExam.id,
|
||||
examModerationId: null,
|
||||
id: oid(),
|
||||
questionSets: [
|
||||
{
|
||||
id: generatedExam.questionSets[0]!.id,
|
||||
questions: [
|
||||
{
|
||||
id: generatedExam.questionSets[0]!.questions[0]!.id,
|
||||
answers: [generatedExam.questionSets[0]!.questions[0]!.answers[0]!],
|
||||
submissionTime: new Date()
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: generatedExam.questionSets[1]!.id,
|
||||
questions: [
|
||||
{
|
||||
id: generatedExam.questionSets[1]!.questions[0]!.id,
|
||||
answers: [generatedExam.questionSets[1]!.questions[0]!.answers[1]!],
|
||||
submissionTime: new Date()
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: generatedExam.questionSets[2]!.id,
|
||||
questions: [
|
||||
{
|
||||
id: generatedExam.questionSets[2]!.questions[0]!.id,
|
||||
answers: [generatedExam.questionSets[2]!.questions[0]!.answers[1]!],
|
||||
submissionTime: new Date()
|
||||
},
|
||||
{
|
||||
id: generatedExam.questionSets[2]!.questions[1]!.id,
|
||||
answers: [generatedExam.questionSets[2]!.questions[1]!.answers[0]!],
|
||||
submissionTime: new Date()
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
startTime: new Date(),
|
||||
userId: defaultUserId,
|
||||
version: 2
|
||||
};
|
||||
|
||||
export const examAttemptSansSubmissionTime: Static<
|
||||
typeof examEnvironmentPostExamAttempt.body
|
||||
>['attempt'] = {
|
||||
examId,
|
||||
questionSets: [
|
||||
{
|
||||
id: generatedExam.questionSets[0]!.id,
|
||||
questions: [
|
||||
{
|
||||
id: generatedExam.questionSets[0]!.questions[0]!.id,
|
||||
answers: [generatedExam.questionSets[0]!.questions[0]!.answers[0]!]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: generatedExam.questionSets[1]!.id,
|
||||
questions: [
|
||||
{
|
||||
id: generatedExam.questionSets[1]!.questions[0]!.id,
|
||||
answers: [generatedExam.questionSets[1]!.questions[0]!.answers[1]!]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: generatedExam.questionSets[2]!.id,
|
||||
questions: [
|
||||
{
|
||||
id: generatedExam.questionSets[2]!.questions[0]!.id,
|
||||
answers: [generatedExam.questionSets[2]!.questions[0]!.answers[1]!]
|
||||
},
|
||||
{
|
||||
id: generatedExam.questionSets[2]!.questions[1]!.id,
|
||||
answers: [generatedExam.questionSets[2]!.questions[1]!.answers[0]!]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const exam = {
|
||||
id: examId,
|
||||
config,
|
||||
questionSets,
|
||||
prerequisites: ['67112fe1c994faa2c26d0b1d'],
|
||||
deprecated: false,
|
||||
version: 2
|
||||
} satisfies ExamEnvironmentExam;
|
||||
|
||||
export const examEnvironmentChallenge: ExamEnvironmentChallenge = {
|
||||
id: oid(),
|
||||
examId,
|
||||
// Id of the certified full stack developer exam challenge page
|
||||
challengeId: '645147516c245de4d11eb7ba',
|
||||
version: 1
|
||||
};
|
||||
|
||||
export async function seedEnvExam() {
|
||||
await clearEnvExam();
|
||||
|
||||
await fastifyTestInstance.prisma.examEnvironmentExam.create({
|
||||
data: exam
|
||||
});
|
||||
await fastifyTestInstance.prisma.examEnvironmentGeneratedExam.create({
|
||||
data: generatedExam
|
||||
});
|
||||
|
||||
// TODO: This would be nice to use, but the test logic for examAttempt need to account
|
||||
// for dynamic ids.
|
||||
// let numberOfExamsGenerated = 0;
|
||||
// while (numberOfExamsGenerated < 2) {
|
||||
// try {
|
||||
// const generatedExam = generateExam(exam);
|
||||
// await fastifyTestInstance.prisma.examEnvironmentGeneratedExam.create({
|
||||
// data: generatedExam
|
||||
// });
|
||||
// numberOfExamsGenerated++;
|
||||
// } catch (_e) {
|
||||
// //
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
export async function clearEnvExam() {
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.deleteMany({});
|
||||
await fastifyTestInstance.prisma.examEnvironmentGeneratedExam.deleteMany({});
|
||||
await fastifyTestInstance.prisma.examEnvironmentExam.deleteMany({});
|
||||
}
|
||||
|
||||
export async function seedEnvExamAttempt() {
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: examAttempt
|
||||
});
|
||||
}
|
||||
|
||||
export async function seedExamEnvExamAuthToken() {
|
||||
return fastifyTestInstance.prisma.examEnvironmentAuthorizationToken.create({
|
||||
data: { userId: defaultUserId, expireAt: new Date(Date.now() + 60000) }
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { expect } from 'vitest';
|
||||
|
||||
export const examChallengeId = '647e22d18acb466c97ccbef8';
|
||||
|
||||
export const examJson = {
|
||||
id: examChallengeId,
|
||||
title: 'Exam Certification',
|
||||
numberOfQuestionsInExam: 3,
|
||||
passingPercent: 10,
|
||||
prerequisites: [
|
||||
{
|
||||
id: '647f85d407d29547b3bee1bb',
|
||||
title: 'challenge-title'
|
||||
}
|
||||
],
|
||||
questions: [
|
||||
{
|
||||
id: '3bbl2mx2mq',
|
||||
question: 'Question 1?',
|
||||
wrongAnswers: [
|
||||
{ id: 'ex7hii9zup', answer: 'Q1: Wrong Answer 1' },
|
||||
{ id: 'lmr1ew7m67', answer: 'Q1: Wrong Answer 2' },
|
||||
{ id: 'qh5sz9qdiq', answer: 'Q1: Wrong Answer 3' },
|
||||
{ id: 'g489kbwn6a', answer: 'Q1: Wrong Answer 4' },
|
||||
{ id: '7vu84wl4lc', answer: 'Q1: Wrong Answer 5' },
|
||||
{ id: 'em59kw6avu', answer: 'Q1: Wrong Answer 6' }
|
||||
],
|
||||
correctAnswers: [
|
||||
{ id: 'dzlokqdc73', answer: 'Q1: Correct Answer 1' },
|
||||
{ id: 'f5gk39ske9', answer: 'Q1: Correct Answer 2' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'oqis5gzs0h',
|
||||
question: 'Question 2?',
|
||||
wrongAnswers: [
|
||||
{ id: 'ojhnoxh5r5', answer: 'Q2: Wrong Answer 1' },
|
||||
{ id: 'onx06if0uh', answer: 'Q2: Wrong Answer 2' },
|
||||
{ id: 'zbxnsko712', answer: 'Q2: Wrong Answer 3' },
|
||||
{ id: 'bqv5y68jyp', answer: 'Q2: Wrong Answer 4' },
|
||||
{ id: 'i5xipitiss', answer: 'Q2: Wrong Answer 5' },
|
||||
{ id: 'wycrnloajd', answer: 'Q2: Wrong Answer 6' }
|
||||
],
|
||||
correctAnswers: [
|
||||
{ id: 't9ezcsupdl', answer: 'Q2: Correct Answer 1' },
|
||||
{ id: 'agert35dk0', answer: 'Q2: Correct Answer 2' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'oqis5gzs0a',
|
||||
question: 'Question 3?',
|
||||
wrongAnswers: [
|
||||
{ id: 'ojhnoxh5ra', answer: 'Q3: Wrong Answer 1' },
|
||||
{ id: 'onx06if0ub', answer: 'Q3: Wrong Answer 2' },
|
||||
{ id: 'zbxnsko71c', answer: 'Q3: Wrong Answer 3' },
|
||||
{ id: 'bqv5y68jyd', answer: 'Q3: Wrong Answer 4' },
|
||||
{ id: 'i5xipitise', answer: 'Q3: Wrong Answer 5' },
|
||||
{ id: 'wycrnloajf', answer: 'Q3: Wrong Answer 6' }
|
||||
],
|
||||
correctAnswers: [
|
||||
{ id: 't9ezcsupda', answer: 'Q3: Correct Answer 1' },
|
||||
{ id: 'agert35dkb', answer: 'Q3: Correct Answer 2' }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const completedTrophyChallenges = [
|
||||
{
|
||||
id: '647f85d407d29547b3bee1bb',
|
||||
solution: 'challenge-solution',
|
||||
completedDate: 1695064765244,
|
||||
files: []
|
||||
}
|
||||
];
|
||||
|
||||
export type ExamSubmission = {
|
||||
userExamQuestions: {
|
||||
id: string;
|
||||
question: string;
|
||||
answer: {
|
||||
id: string;
|
||||
answer: string;
|
||||
};
|
||||
}[];
|
||||
examTimeInSeconds: number;
|
||||
};
|
||||
|
||||
// failed: 0 correct
|
||||
export const examWithZeroCorrect: ExamSubmission = {
|
||||
userExamQuestions: [
|
||||
{
|
||||
id: '3bbl2mx2mq',
|
||||
question: 'Question 1?',
|
||||
answer: { id: 'g489kbwn6a', answer: 'Q1: Wrong Answer 4' }
|
||||
},
|
||||
{
|
||||
id: 'oqis5gzs0h',
|
||||
question: 'Question 2?',
|
||||
answer: { id: 'i5xipitiss', answer: 'Q2: Wrong Answer 5' }
|
||||
},
|
||||
{
|
||||
id: 'oqis5gzs0a',
|
||||
question: 'Question 3?',
|
||||
answer: { id: 'ojhnoxh5ra', answer: 'Q3: Wrong Answer 1' }
|
||||
}
|
||||
],
|
||||
examTimeInSeconds: 20
|
||||
};
|
||||
|
||||
// passed: 1 correct
|
||||
export const examWithOneCorrect: ExamSubmission = {
|
||||
userExamQuestions: [
|
||||
{
|
||||
id: '3bbl2mx2mq',
|
||||
question: 'Question 1?',
|
||||
answer: { id: 'dzlokqdc73', answer: 'Q1: Correct Answer 1' }
|
||||
},
|
||||
{
|
||||
id: 'oqis5gzs0h',
|
||||
question: 'Question 2?',
|
||||
answer: { id: 'i5xipitiss', answer: 'Q2: Wrong Answer 5' }
|
||||
},
|
||||
{
|
||||
id: 'oqis5gzs0a',
|
||||
question: 'Question 3?',
|
||||
answer: { id: 'ojhnoxh5ra', answer: 'Q3: Wrong Answer 1' }
|
||||
}
|
||||
],
|
||||
examTimeInSeconds: 20
|
||||
};
|
||||
|
||||
// passed: 2 correct
|
||||
export const examWithTwoCorrect: ExamSubmission = {
|
||||
userExamQuestions: [
|
||||
{
|
||||
id: '3bbl2mx2mq',
|
||||
question: 'Question 1?',
|
||||
answer: { id: 'dzlokqdc73', answer: 'Q1: Correct Answer 1' }
|
||||
},
|
||||
{
|
||||
id: 'oqis5gzs0h',
|
||||
question: 'Question 2?',
|
||||
answer: { id: 't9ezcsupdl', answer: 'Q2: Correct Answer 1' }
|
||||
},
|
||||
{
|
||||
id: 'oqis5gzs0a',
|
||||
question: 'Question 3?',
|
||||
answer: { id: 'ojhnoxh5ra', answer: 'Q3: Wrong Answer 1' }
|
||||
}
|
||||
],
|
||||
examTimeInSeconds: 20
|
||||
};
|
||||
|
||||
// passed: 3 correct
|
||||
export const examWithAllCorrect: ExamSubmission = {
|
||||
userExamQuestions: [
|
||||
{
|
||||
id: '3bbl2mx2mq',
|
||||
question: 'Question 1?',
|
||||
answer: { id: 'dzlokqdc73', answer: 'Q1: Correct Answer 1' }
|
||||
},
|
||||
{
|
||||
id: 'oqis5gzs0h',
|
||||
question: 'Question 2?',
|
||||
answer: { id: 't9ezcsupdl', answer: 'Q2: Correct Answer 1' }
|
||||
},
|
||||
{
|
||||
id: 'oqis5gzs0a',
|
||||
question: 'Question 3?',
|
||||
answer: { id: 'agert35dkb', answer: 'Q3: Correct Answer 2' }
|
||||
}
|
||||
],
|
||||
examTimeInSeconds: 20
|
||||
};
|
||||
|
||||
export const mockResultsZeroCorrect = {
|
||||
numberOfCorrectAnswers: 0,
|
||||
numberOfQuestionsInExam: 3,
|
||||
percentCorrect: 0,
|
||||
passingPercent: 10,
|
||||
passed: false,
|
||||
examTimeInSeconds: 20
|
||||
};
|
||||
|
||||
export const mockResultsOneCorrect = {
|
||||
numberOfCorrectAnswers: 1,
|
||||
numberOfQuestionsInExam: 3,
|
||||
percentCorrect: 33.3,
|
||||
passingPercent: 10,
|
||||
passed: true,
|
||||
examTimeInSeconds: 20
|
||||
};
|
||||
|
||||
export const mockResultsTwoCorrect = {
|
||||
numberOfCorrectAnswers: 2,
|
||||
numberOfQuestionsInExam: 3,
|
||||
percentCorrect: 66.7,
|
||||
passingPercent: 10,
|
||||
passed: true,
|
||||
examTimeInSeconds: 20
|
||||
};
|
||||
|
||||
export const mockResultsAllCorrect = {
|
||||
numberOfCorrectAnswers: 3,
|
||||
numberOfQuestionsInExam: 3,
|
||||
percentCorrect: 100,
|
||||
passingPercent: 10,
|
||||
passed: true,
|
||||
examTimeInSeconds: 20
|
||||
};
|
||||
|
||||
const completedExamChallenge = {
|
||||
id: examChallengeId,
|
||||
challengeType: 17,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
completedDate: expect.any(Number)
|
||||
};
|
||||
|
||||
export const completedExamChallengeOneCorrect = {
|
||||
...completedExamChallenge,
|
||||
examResults: mockResultsOneCorrect
|
||||
};
|
||||
|
||||
export const completedExamChallengeTwoCorrect = {
|
||||
...completedExamChallenge,
|
||||
examResults: mockResultsTwoCorrect
|
||||
};
|
||||
|
||||
export const completedExamChallengeAllCorrect = {
|
||||
...completedExamChallenge,
|
||||
examResults: mockResultsAllCorrect
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { configTypeChecked, tsFiles } from '@freecodecamp/eslint-config/base';
|
||||
|
||||
import jsdoc from 'eslint-plugin-jsdoc';
|
||||
|
||||
/**
|
||||
* A shared ESLint configuration for the repository.
|
||||
*
|
||||
* @type {import("eslint").Linter.Config[]}
|
||||
* */
|
||||
export default [
|
||||
...configTypeChecked,
|
||||
{
|
||||
...jsdoc.configs['flat/recommended-typescript-error'],
|
||||
rules: {
|
||||
'jsdoc/require-jsdoc': [
|
||||
'error',
|
||||
{
|
||||
require: {
|
||||
ArrowFunctionExpression: true,
|
||||
ClassDeclaration: true,
|
||||
ClassExpression: true,
|
||||
FunctionDeclaration: true,
|
||||
FunctionExpression: true,
|
||||
MethodDefinition: true
|
||||
},
|
||||
|
||||
publicOnly: true
|
||||
}
|
||||
],
|
||||
|
||||
'jsdoc/require-description-complete-sentence': 'error',
|
||||
'jsdoc/tag-lines': 'off'
|
||||
},
|
||||
files: tsFiles
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"author": "freeCodeCamp <team@freecodecamp.org>",
|
||||
"bugs": {
|
||||
"url": "https://github.com/freeCodeCamp/freeCodeCamp/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/accepts": "5.0.4",
|
||||
"@fastify/cookie": "11.0.2",
|
||||
"@fastify/csrf-protection": "7.1.0",
|
||||
"@fastify/oauth2": "8.2.0",
|
||||
"@fastify/swagger": "9.7.0",
|
||||
"@fastify/swagger-ui": "5.2.6",
|
||||
"@fastify/type-provider-typebox": "6.1.0",
|
||||
"@freecodecamp/shared": "workspace:*",
|
||||
"@growthbook/growthbook": "1.6.5",
|
||||
"@prisma/client": "6.19.3",
|
||||
"@sentry/node": "10.55.0",
|
||||
"@sentry/profiling-node": "10.55.0",
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1",
|
||||
"bson": "7.2.0",
|
||||
"date-fns": "4.1.0",
|
||||
"date-fns-tz": "3.2.0",
|
||||
"dotenv": "16.6.1",
|
||||
"fast-uri": "2.4.0",
|
||||
"fastify": "5.8.5",
|
||||
"fastify-plugin": "5.1.0",
|
||||
"joi": "17.13.3",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"lodash": "4.18.1",
|
||||
"lodash-es": "4.18.1",
|
||||
"nanoid": "3",
|
||||
"no-profanity": "1.5.1",
|
||||
"nodemailer": "6.10.1",
|
||||
"pino": "9.14.0",
|
||||
"pino-pretty": "10.3.1",
|
||||
"query-string": "7.1.3",
|
||||
"stripe": "16.12.0",
|
||||
"typebox": "1.1.35",
|
||||
"validator": "13.15.35"
|
||||
},
|
||||
"description": "The freeCodeCamp.org open-source codebase and curriculum",
|
||||
"devDependencies": {
|
||||
"@freecodecamp/curriculum": "workspace:*",
|
||||
"@freecodecamp/eslint-config": "workspace:*",
|
||||
"@freecodecamp/shared": "workspace:*",
|
||||
"@total-typescript/ts-reset": "0.6.1",
|
||||
"@types/jsonwebtoken": "9.0.5",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^24.10.8",
|
||||
"@types/nodemailer": "6.4.23",
|
||||
"@types/supertest": "2.0.16",
|
||||
"@types/validator": "13.15.10",
|
||||
"@vitest/ui": "^4.0.15",
|
||||
"dotenv-cli": "7.4.4",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-jsdoc": "48.11.0",
|
||||
"msw": "^2.12.10",
|
||||
"prisma": "6.19.3",
|
||||
"supertest": "6.3.4",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "^4.0.15"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24",
|
||||
"npm": ">=8"
|
||||
},
|
||||
"homepage": "https://github.com/freeCodeCamp/freeCodeCamp#readme",
|
||||
"license": "BSD-3-Clause",
|
||||
"main": "none",
|
||||
"name": "@freecodecamp/api",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/freeCodeCamp/freeCodeCamp.git"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"clean": "rm -rf dist",
|
||||
"develop": "tsx watch --clear-screen=false src/server.ts",
|
||||
"start": "FREECODECAMP_NODE_ENV=production node dist/server.js",
|
||||
"lint": "eslint --max-warnings 0",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"type-check": "tsc --noEmit",
|
||||
"prisma": "dotenv -e ../.env prisma",
|
||||
"postinstall": "prisma generate",
|
||||
"exam-env:seed": "tsx tools/exam-environment/seed/index.ts",
|
||||
"exam-env:test": "tsx tools/exam-environment/test/index.ts"
|
||||
},
|
||||
"version": "0.0.1"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { PrismaConfig } from 'prisma';
|
||||
|
||||
export default {
|
||||
schema: 'prisma'
|
||||
} satisfies PrismaConfig;
|
||||
@@ -0,0 +1,58 @@
|
||||
/// A copy of `ExamEnvironmentExam` used as a staging collection for updates to the curriculum.
|
||||
///
|
||||
/// This collection schema must be kept in sync with `ExamEnvironmentExam`.
|
||||
model ExamCreatorExam {
|
||||
/// Globally unique exam id
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
/// All questions for a given exam
|
||||
questionSets ExamEnvironmentQuestionSet[]
|
||||
/// Configuration for exam metadata
|
||||
config ExamEnvironmentConfig
|
||||
/// ObjectIds for required challenges/blocks to take the exam
|
||||
prerequisites String[] @db.ObjectId
|
||||
/// If `deprecated`, the exam should no longer be considered for users
|
||||
deprecated Boolean
|
||||
/// Version of the record
|
||||
/// The default must be incremented by 1, if anything in the schema changes
|
||||
version Int @default(3)
|
||||
}
|
||||
|
||||
/// Exam Creator application collection to store authZ users.
|
||||
///
|
||||
/// Currently, this is manually created in order to grant access to the application.
|
||||
model ExamCreatorUser {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
email String
|
||||
/// Unique id from GitHub for an account.
|
||||
///
|
||||
/// Currently, this is unused. Consider removing.
|
||||
github_id Int?
|
||||
name String
|
||||
picture String?
|
||||
settings ExamCreatorUserSettings
|
||||
version Int @default(2)
|
||||
|
||||
ExamCreatorSession ExamCreatorSession[]
|
||||
}
|
||||
|
||||
type ExamCreatorUserSettings {
|
||||
databaseEnvironment ExamCreatorDatabaseEnvironment
|
||||
}
|
||||
|
||||
enum ExamCreatorDatabaseEnvironment {
|
||||
Production
|
||||
Staging
|
||||
}
|
||||
|
||||
/// Exam Creator application collection to store auth sessions.
|
||||
model ExamCreatorSession {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
user_id String @db.ObjectId
|
||||
session_id String
|
||||
/// Expiration date for record.
|
||||
expires_at DateTime
|
||||
|
||||
version Int @default(1)
|
||||
|
||||
ExamCreatorUser ExamCreatorUser @relation(fields: [user_id], references: [id])
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/// An exam for the Exam Environment App as designed by the examiners
|
||||
model ExamEnvironmentExam {
|
||||
/// Globally unique exam id
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
/// All questions for a given exam
|
||||
questionSets ExamEnvironmentQuestionSet[]
|
||||
/// Configuration for exam metadata
|
||||
config ExamEnvironmentConfig
|
||||
/// ObjectIds for required challenges/blocks to take the exam
|
||||
prerequisites String[] @db.ObjectId
|
||||
/// If `deprecated`, the exam should no longer be considered for users
|
||||
deprecated Boolean
|
||||
/// Version of the record
|
||||
/// The default must be incremented by 1, if anything in the schema changes
|
||||
version Int @default(3)
|
||||
|
||||
// Relations
|
||||
generatedExams ExamEnvironmentGeneratedExam[]
|
||||
examAttempts ExamEnvironmentExamAttempt[]
|
||||
ExamEnvironmentChallenge ExamEnvironmentChallenge[]
|
||||
}
|
||||
|
||||
/// A grouping of one or more questions of a given type
|
||||
type ExamEnvironmentQuestionSet {
|
||||
/// Unique question type id
|
||||
id String @db.ObjectId
|
||||
type ExamEnvironmentQuestionType
|
||||
/// Content related to all questions in set
|
||||
context String?
|
||||
questions ExamEnvironmentMultipleChoiceQuestion[]
|
||||
}
|
||||
|
||||
/// A multiple choice question for the Exam Environment App
|
||||
type ExamEnvironmentMultipleChoiceQuestion {
|
||||
/// Unique question id
|
||||
id String @db.ObjectId
|
||||
/// Main question paragraph
|
||||
text String
|
||||
/// Zero or more tags given to categorize a question
|
||||
tags String[]
|
||||
/// Optional audio for a question
|
||||
audio ExamEnvironmentAudio?
|
||||
/// Available possible answers for an exam
|
||||
answers ExamEnvironmentAnswer[]
|
||||
/// TODO Possible "deprecated_time" to remove after all exams could possibly have been taken
|
||||
deprecated Boolean
|
||||
}
|
||||
|
||||
/// Audio for an Exam Environment App multiple choice question
|
||||
type ExamEnvironmentAudio {
|
||||
/// Optional text for audio
|
||||
captions String?
|
||||
/// URL to audio file
|
||||
///
|
||||
/// Expected in the format: `<url>#t=<start_time_in_seconds>,<end_time_in_seconds>`
|
||||
/// Where `start_time_in_seconds` and `end_time_in_seconds` are optional floats.
|
||||
url String
|
||||
}
|
||||
|
||||
/// Type of question for the Exam Environment App
|
||||
enum ExamEnvironmentQuestionType {
|
||||
/// Single question with one or more answers
|
||||
MultipleChoice
|
||||
/// Mass text
|
||||
Dialogue
|
||||
}
|
||||
|
||||
/// Answer for an Exam Environment App multiple choice question
|
||||
type ExamEnvironmentAnswer {
|
||||
/// Unique answer id
|
||||
id String @db.ObjectId
|
||||
/// Whether the answer is correct
|
||||
isCorrect Boolean
|
||||
/// Answer paragraph
|
||||
text String
|
||||
}
|
||||
|
||||
/// Configuration for an exam in the Exam Environment App
|
||||
type ExamEnvironmentConfig {
|
||||
/// Human-readable exam name
|
||||
name String
|
||||
/// Notes given about exam
|
||||
note String
|
||||
/// Category configuration for question selection
|
||||
tags ExamEnvironmentTagConfig[]
|
||||
/// Total time allocated for exam in seconds
|
||||
totalTimeInS Int
|
||||
/// Configuration for sets of questions
|
||||
questionSets ExamEnvironmentQuestionSetConfig[]
|
||||
/// Duration after exam completion before a retake is allowed in seconds
|
||||
retakeTimeInS Int
|
||||
/// Passing percent for the exam
|
||||
passingPercent Float
|
||||
}
|
||||
|
||||
/// Configuration for a set of questions in the Exam Environment App
|
||||
type ExamEnvironmentQuestionSetConfig {
|
||||
type ExamEnvironmentQuestionType
|
||||
/// Number of this grouping of questions per exam
|
||||
numberOfSet Int
|
||||
/// Number of multiple choice questions per grouping matching this set config
|
||||
numberOfQuestions Int
|
||||
/// Number of correct answers given per multiple choice question
|
||||
numberOfCorrectAnswers Int
|
||||
/// Number of incorrect answers given per multiple choice question
|
||||
numberOfIncorrectAnswers Int
|
||||
}
|
||||
|
||||
/// Configuration for tags in the Exam Environment App
|
||||
///
|
||||
/// This configures the number of questions that should resolve to a given tag set criteria.
|
||||
type ExamEnvironmentTagConfig {
|
||||
/// Group of multiple choice question tags
|
||||
group String[]
|
||||
/// Number of multiple choice questions per exam that should meet the group criteria
|
||||
numberOfQuestions Int
|
||||
}
|
||||
|
||||
/// An attempt at an exam in the Exam Environment App
|
||||
model ExamEnvironmentExamAttempt {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
/// Foriegn key to user
|
||||
userId String @db.ObjectId
|
||||
/// Foreign key to exam
|
||||
examId String @db.ObjectId
|
||||
/// Foreign key to generated exam id
|
||||
generatedExamId String @db.ObjectId
|
||||
/// Un-enforced foreign key to moderation
|
||||
examModerationId String? @db.ObjectId
|
||||
|
||||
questionSets ExamEnvironmentQuestionSetAttempt[]
|
||||
/// Time exam was started
|
||||
startTime DateTime
|
||||
/// Version of the record
|
||||
/// The default must be incremented by 1, if anything in the schema changes
|
||||
version Int @default(4)
|
||||
|
||||
// Relations
|
||||
user user @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
exam ExamEnvironmentExam @relation(fields: [examId], references: [id], onDelete: Cascade)
|
||||
generatedExam ExamEnvironmentGeneratedExam @relation(fields: [generatedExamId], references: [id])
|
||||
// Ideally, there could be a way to add a one-way optional relation here, but Prisma does not allow that:
|
||||
// Error parsing attribute "@relation": The relation fields `examAttempt` on Model `ExamEnvironmentExamModeration` and `examModeration` on Model `ExamEnvironmentExamAttempt` both provide the `references` argument in the @relation attribute. You have to provide it only on one of the two fields.
|
||||
// examModeration ExamEnvironmentExamModeration? @relation(fields: [examModerationId], references: [id])
|
||||
examEnvironmentExamModeration ExamEnvironmentExamModeration?
|
||||
}
|
||||
|
||||
type ExamEnvironmentQuestionSetAttempt {
|
||||
id String @db.ObjectId
|
||||
questions ExamEnvironmentMultipleChoiceQuestionAttempt[]
|
||||
}
|
||||
|
||||
type ExamEnvironmentMultipleChoiceQuestionAttempt {
|
||||
/// Foreign key to question
|
||||
id String @db.ObjectId
|
||||
/// An array of foreign keys to answers
|
||||
answers String[] @db.ObjectId
|
||||
/// Time answers to question were submitted
|
||||
///
|
||||
/// If the question is later revisited, this field is updated
|
||||
submissionTime DateTime
|
||||
}
|
||||
|
||||
/// A generated exam for the Exam Environment App
|
||||
///
|
||||
/// This is the user-facing information for an exam.
|
||||
model ExamEnvironmentGeneratedExam {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
/// Foreign key to exam
|
||||
examId String @db.ObjectId
|
||||
questionSets ExamEnvironmentGeneratedQuestionSet[]
|
||||
/// If `deprecated`, the generation should not longer be considered for users
|
||||
deprecated Boolean
|
||||
/// Version of the record
|
||||
/// The default must be incremented by 1, if anything in the schema changes
|
||||
version Int @default(1)
|
||||
|
||||
// Relations
|
||||
exam ExamEnvironmentExam @relation(fields: [examId], references: [id], onDelete: Cascade)
|
||||
EnvExamAttempt ExamEnvironmentExamAttempt[]
|
||||
}
|
||||
|
||||
type ExamEnvironmentGeneratedQuestionSet {
|
||||
id String @db.ObjectId
|
||||
questions ExamEnvironmentGeneratedMultipleChoiceQuestion[]
|
||||
}
|
||||
|
||||
type ExamEnvironmentGeneratedMultipleChoiceQuestion {
|
||||
/// Foreign key to question id
|
||||
id String @db.ObjectId
|
||||
/// Each item is a foreign key to an answer
|
||||
answers String[] @db.ObjectId
|
||||
}
|
||||
|
||||
/// A map between challenge ids and exam ids
|
||||
///
|
||||
/// This is expected to be used for relating challenge pages AND/OR certifications to exams
|
||||
model ExamEnvironmentChallenge {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
examId String @db.ObjectId
|
||||
challengeId String @db.ObjectId
|
||||
|
||||
version Int @default(1)
|
||||
|
||||
exam ExamEnvironmentExam @relation(fields: [examId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model ExamEnvironmentAuthorizationToken {
|
||||
/// An ObjectId is used to provide access to the created timestamp
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
/// Used to set an `expireAt` index to delete documents
|
||||
expireAt DateTime @db.Date
|
||||
userId String @unique @db.ObjectId
|
||||
version Int @default(1)
|
||||
|
||||
// Relations
|
||||
user user @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model ExamEnvironmentExamModeration {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
/// Whether or not the item is approved
|
||||
status ExamEnvironmentExamModerationStatus
|
||||
/// Foreign key to exam attempt
|
||||
examAttemptId String @unique @db.ObjectId
|
||||
/// Optional feedback/note about the moderation decision
|
||||
feedback String?
|
||||
/// Date the exam attempt was moderated
|
||||
moderationDate DateTime?
|
||||
/// Foreign key to moderator. This is `null` until the item is moderated.
|
||||
moderatorId String? @db.ObjectId
|
||||
|
||||
/// Date the exam attempt expired
|
||||
submissionDate DateTime @default(now()) @db.Date
|
||||
/// Whether the `challengeId` for the `ExamEnvironmentChallenge` has been awarded to the user
|
||||
challengesAwarded Boolean @default(false)
|
||||
|
||||
/// Version of the record
|
||||
/// The default must be incremented by 1, if anything in the schema changes
|
||||
version Int @default(2)
|
||||
|
||||
// Relations
|
||||
examAttempt ExamEnvironmentExamAttempt @relation(fields: [examAttemptId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
enum ExamEnvironmentExamModerationStatus {
|
||||
/// Attempt is determined to be valid
|
||||
Approved
|
||||
/// Attempt is determined to be invalid
|
||||
Denied
|
||||
/// Attempt has yet to be moderated
|
||||
Pending
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mongodb"
|
||||
url = env("MONGOHQ_URL")
|
||||
}
|
||||
|
||||
// USER COLLECTION ---------------------
|
||||
|
||||
type File {
|
||||
contents String
|
||||
ext String
|
||||
key String
|
||||
name String
|
||||
path String? // Undefined | Null
|
||||
}
|
||||
|
||||
type CompletedChallenge {
|
||||
challengeType Json? // Null | Undefined | String | Int
|
||||
completedDate Json // DateTime | Float, but not, as far as we know, Null
|
||||
files File[]
|
||||
githubLink String? // Undefined
|
||||
id String
|
||||
isManuallyApproved Boolean? // Undefined
|
||||
solution String? // Null | Undefined
|
||||
examResults ExamResults? // Undefined
|
||||
}
|
||||
|
||||
enum DailyCodingChallengeLanguage {
|
||||
javascript
|
||||
python
|
||||
}
|
||||
|
||||
type CompletedDailyCodingChallenge {
|
||||
id String @db.ObjectId
|
||||
/// Date in milliseconds since epoch
|
||||
/// This is not a DateTime, because DateTime does not serialize directly to JSON
|
||||
completedDate Int
|
||||
languages DailyCodingChallengeLanguage[]
|
||||
}
|
||||
|
||||
type PartiallyCompletedChallenge {
|
||||
id String
|
||||
completedDate Float
|
||||
}
|
||||
|
||||
type Portfolio {
|
||||
description String
|
||||
id String
|
||||
image String
|
||||
title String
|
||||
url String
|
||||
}
|
||||
|
||||
type Experience {
|
||||
id String
|
||||
title String
|
||||
company String
|
||||
location String?
|
||||
startDate String
|
||||
endDate String?
|
||||
description String
|
||||
}
|
||||
|
||||
type ProfileUI {
|
||||
isLocked Boolean? // Undefined
|
||||
showAbout Boolean? // Undefined
|
||||
showCerts Boolean? // Undefined
|
||||
showDonation Boolean? // Undefined
|
||||
showHeatMap Boolean? // Undefined
|
||||
showLocation Boolean? // Undefined
|
||||
showName Boolean? // Undefined
|
||||
showPoints Boolean? // Undefined
|
||||
showPortfolio Boolean? // Undefined
|
||||
showExperience Boolean? // Undefined
|
||||
showTimeLine Boolean? // Undefined
|
||||
}
|
||||
|
||||
type SavedChallengeFile {
|
||||
contents String
|
||||
ext String
|
||||
history String[]
|
||||
key String
|
||||
name String
|
||||
}
|
||||
|
||||
type SavedChallenge {
|
||||
files SavedChallengeFile[]
|
||||
id String
|
||||
lastSavedDate Float
|
||||
}
|
||||
|
||||
type QuizAttempt {
|
||||
challengeId String
|
||||
quizId String
|
||||
timestamp Float
|
||||
}
|
||||
|
||||
/// Corresponds to the `user` collection.
|
||||
model user {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
about String
|
||||
acceptedPrivacyTerms Boolean
|
||||
completedChallenges CompletedChallenge[]
|
||||
completedDailyCodingChallenges CompletedDailyCodingChallenge[]
|
||||
completedExams CompletedExam[] // Undefined
|
||||
quizAttempts QuizAttempt[] // Undefined
|
||||
currentChallengeId String?
|
||||
donationEmails String[] // Undefined | String[] (only possible for built in Types like String)
|
||||
email String?
|
||||
emailAuthLinkTTL DateTime? // Null | Undefined
|
||||
emailVerified Boolean?
|
||||
emailVerifyTTL DateTime? // Null | Undefined
|
||||
externalId String
|
||||
githubProfile String? // Undefined
|
||||
isA2EnglishCert Boolean? // Undefined
|
||||
isApisMicroservicesCert Boolean? // Undefined
|
||||
isBackEndCert Boolean? // Undefined
|
||||
isBanned Boolean? // Undefined
|
||||
isCheater Boolean? // Undefined
|
||||
isDataAnalysisPyCertV7 Boolean? // Undefined
|
||||
isDataVisCert Boolean? // Undefined
|
||||
isDonating Boolean
|
||||
isFoundationalCSharpCertV8 Boolean? // Undefined
|
||||
isFrontEndCert Boolean? // Undefined
|
||||
isFrontEndLibsCert Boolean? // Undefined
|
||||
isFullStackCert Boolean? // Undefined
|
||||
isHonest Boolean?
|
||||
isInfosecCertV7 Boolean? // Undefined
|
||||
isInfosecQaCert Boolean? // Undefined
|
||||
isJavascriptCertV9 Boolean? // Undefined
|
||||
isJsAlgoDataStructCert Boolean? // Undefined
|
||||
isJsAlgoDataStructCertV8 Boolean? // Undefined
|
||||
isMachineLearningPyCertV7 Boolean? // Undefined
|
||||
isPythonCertV9 Boolean? // Undefined
|
||||
isQaCertV7 Boolean? // Undefined
|
||||
isRelationalDatabaseCertV8 Boolean? // Undefined
|
||||
isRelationalDatabaseCertV9 Boolean? // Undefined
|
||||
isRespWebDesignCert Boolean? // Undefined
|
||||
isRespWebDesignCertV9 Boolean? // Undefined
|
||||
isSciCompPyCertV7 Boolean? // Undefined
|
||||
is2018DataVisCert Boolean? // Undefined
|
||||
is2018FullStackCert Boolean? // Undefined
|
||||
isCollegeAlgebraPyCertV8 Boolean? // Undefined
|
||||
isFrontEndLibsCertV9 Boolean? // Undefined
|
||||
isBackEndDevApisCertV9 Boolean? // Undefined
|
||||
isFullStackDeveloperCertV9 Boolean? // Undefined
|
||||
isB1EnglishCert Boolean? // Undefined
|
||||
isA2SpanishCert Boolean? // Undefined
|
||||
isA2ChineseCert Boolean? // Undefined
|
||||
isA1ChineseCert Boolean? // Undefined
|
||||
// isUpcomingPythonCertV8 Boolean? // Undefined. It is in the db but has never been used.
|
||||
keyboardShortcuts Boolean? // Undefined
|
||||
linkedin String? // Null | Undefined
|
||||
location String? // Null
|
||||
name String? // Null
|
||||
needsModeration Boolean? // Undefined
|
||||
newEmail String? // Null | Undefined
|
||||
partiallyCompletedChallenges PartiallyCompletedChallenge[] // Undefined | PartiallyCompletedChallenge[]
|
||||
password String? // Undefined
|
||||
picture String?
|
||||
portfolio Portfolio[]
|
||||
experience Experience[]
|
||||
profileUI ProfileUI? // Undefined
|
||||
progressTimestamps Json? // ProgressTimestamp[] | Null[] | Int64[] | Double[] - TODO: NORMALIZE
|
||||
/// A random number between 0 and 1.
|
||||
///
|
||||
/// Valuable for selectively performing random logic.
|
||||
rand Float?
|
||||
savedChallenges SavedChallenge[] // Undefined | SavedChallenge[]
|
||||
// Nullable tri-state: null (likely new user), true (subscribed), false (unsubscribed)
|
||||
sendQuincyEmail Boolean?
|
||||
socrates Boolean?
|
||||
theme String? // Undefined
|
||||
timezone String? // Undefined
|
||||
twitter String? // Null | Undefined
|
||||
bluesky String? // Null | Undefined
|
||||
unsubscribeId String
|
||||
/// Used to track the number of times the user's record was written to.
|
||||
///
|
||||
/// This has the main benefit of allowing concurrent ops to check for race conditions.
|
||||
updateCount Int? @default(0)
|
||||
username String // TODO(Post-MVP): make this unique
|
||||
usernameDisplay String? // Undefined
|
||||
verificationToken String? // Undefined
|
||||
website String? // Undefined
|
||||
yearsTopContributor String[] // Undefined | String[]
|
||||
isClassroomAccount Boolean? // Undefined
|
||||
|
||||
// Relations
|
||||
examAttempts ExamEnvironmentExamAttempt[]
|
||||
examEnvironmentAuthorizationToken ExamEnvironmentAuthorizationToken?
|
||||
}
|
||||
|
||||
// -----------------------------------
|
||||
|
||||
model AccessToken {
|
||||
id String @id @map("_id")
|
||||
created DateTime @db.Date
|
||||
ttl Int
|
||||
userId String @db.ObjectId
|
||||
|
||||
@@index([userId], map: "userId_1")
|
||||
}
|
||||
|
||||
model AuthToken {
|
||||
id String @id @map("_id")
|
||||
created DateTime @db.Date
|
||||
ttl Int
|
||||
userId String @db.ObjectId
|
||||
}
|
||||
|
||||
model Donation {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
amount Int @db.Int
|
||||
customerId String
|
||||
duration String?
|
||||
email String
|
||||
endDate DonationEndDate?
|
||||
provider String
|
||||
startDate DonationStartDate
|
||||
subscriptionId String
|
||||
userId String @db.ObjectId
|
||||
|
||||
@@index([email], map: "email_1")
|
||||
@@index([userId], map: "userId_1")
|
||||
}
|
||||
|
||||
model SocratesUsage {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
userId String @db.ObjectId
|
||||
/// UTC date representing the day of usage (midnight).
|
||||
date DateTime @db.Date
|
||||
/// Number of hints used on this day.
|
||||
count Int @default(0)
|
||||
|
||||
@@unique([userId, date])
|
||||
}
|
||||
|
||||
model UserToken {
|
||||
id String @id @map("_id")
|
||||
created DateTime @db.Date
|
||||
ttl Float
|
||||
userId String @db.ObjectId
|
||||
|
||||
@@index([userId], map: "userId_1")
|
||||
}
|
||||
|
||||
model sessions {
|
||||
id String @id @map("_id")
|
||||
expires DateTime @db.Date
|
||||
session String
|
||||
|
||||
@@index([expires], map: "expires_1")
|
||||
}
|
||||
|
||||
model MsUsername {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
userId String @db.ObjectId
|
||||
ttl Int
|
||||
msUsername String
|
||||
|
||||
@@index([userId, id], map: "userId_1__id_1")
|
||||
@@index([msUsername], map: "msUsername_1")
|
||||
}
|
||||
|
||||
model Exam {
|
||||
id String @id @map("_id") @db.ObjectId
|
||||
numberOfQuestionsInExam Int @db.Int
|
||||
passingPercent Int @db.Int
|
||||
prerequisites Prerequisite[] // undefined | Prerequisite[]
|
||||
title String
|
||||
questions Question[]
|
||||
}
|
||||
|
||||
type CompletedExam {
|
||||
id String
|
||||
challengeType Int
|
||||
completedDate Float // TODO(Post-MVP): Change to DateTime?
|
||||
examResults ExamResults
|
||||
}
|
||||
|
||||
type ExamResults {
|
||||
numberOfCorrectAnswers Int
|
||||
numberOfQuestionsInExam Int
|
||||
percentCorrect Float
|
||||
passingPercent Int
|
||||
passed Boolean
|
||||
examTimeInSeconds Int
|
||||
}
|
||||
|
||||
type Question {
|
||||
id String
|
||||
question String
|
||||
wrongAnswers Answer[]
|
||||
correctAnswers Answer[]
|
||||
deprecated Boolean? // undefined
|
||||
}
|
||||
|
||||
type Answer {
|
||||
id String
|
||||
answer String
|
||||
deprecated Boolean? // undefined
|
||||
}
|
||||
|
||||
type Prerequisite {
|
||||
id String @db.ObjectId
|
||||
title String
|
||||
}
|
||||
|
||||
type DonationEndDate {
|
||||
date DateTime @map("_date") @db.Date
|
||||
when String @map("_when")
|
||||
}
|
||||
|
||||
type DonationStartDate {
|
||||
date DateTime @map("_date") @db.Date
|
||||
when String @map("_when")
|
||||
}
|
||||
|
||||
model Survey {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
userId String @db.ObjectId
|
||||
title String
|
||||
responses SurveyResponse[]
|
||||
|
||||
@@index([userId], map: "userId_1")
|
||||
}
|
||||
|
||||
type SurveyResponse {
|
||||
question String
|
||||
response String
|
||||
}
|
||||
|
||||
// ----------------------
|
||||
|
||||
model DailyCodingChallenges {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
challengeNumber Int
|
||||
date DateTime
|
||||
title String
|
||||
description String
|
||||
javascript DailyCodingChallengeApiLanguage
|
||||
python DailyCodingChallengeApiLanguage
|
||||
}
|
||||
|
||||
type DailyCodingChallengeApiLanguage {
|
||||
tests DailyCodingChallengeApiLanguageTests[]
|
||||
challengeFiles DailyCodingChallengeApiLanguageChallengeFiles[]
|
||||
}
|
||||
|
||||
type DailyCodingChallengeApiLanguageTests {
|
||||
text String
|
||||
testString String
|
||||
}
|
||||
|
||||
type DailyCodingChallengeApiLanguageChallengeFiles {
|
||||
contents String
|
||||
fileKey String
|
||||
}
|
||||
|
||||
// ----------------------
|
||||
|
||||
model DripCampaign {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
userId String @db.ObjectId
|
||||
creationDate DateTime @default(now()) @db.Date
|
||||
email String
|
||||
variant String
|
||||
|
||||
@@index([userId], map: "userId_1")
|
||||
@@index([email], map: "email_1")
|
||||
}
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
import fastifyAccepts from '@fastify/accepts';
|
||||
import fastifySwagger from '@fastify/swagger';
|
||||
import fastifySwaggerUI from '@fastify/swagger-ui';
|
||||
import type { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
|
||||
import uriResolver from 'fast-uri';
|
||||
import Fastify, {
|
||||
FastifyBaseLogger,
|
||||
FastifyHttpOptions,
|
||||
FastifyInstance,
|
||||
RawReplyDefaultExpression,
|
||||
RawRequestDefaultExpression,
|
||||
RawServerDefault
|
||||
} from 'fastify';
|
||||
import { Ajv } from 'ajv';
|
||||
import addFormats from 'ajv-formats';
|
||||
|
||||
import prismaPlugin from './db/prisma.js';
|
||||
import cookies from './plugins/cookies.js';
|
||||
import cors from './plugins/cors.js';
|
||||
import { createMailProvider } from './plugins/mail-providers/nodemailer.js';
|
||||
import mailer from './plugins/mailer.js';
|
||||
import redirectWithMessage from './plugins/redirect-with-message.js';
|
||||
import security from './plugins/security.js';
|
||||
import auth from './plugins/auth.js';
|
||||
import bouncer from './plugins/bouncer.js';
|
||||
import errorHandling from './plugins/error-handling.js';
|
||||
import runtimeMetrics from './plugins/runtime-metrics.js';
|
||||
import csrf from './plugins/csrf.js';
|
||||
import notFound from './plugins/not-found.js';
|
||||
import shadowCapture from './plugins/shadow-capture.js';
|
||||
import growthBook from './plugins/growth-book.js';
|
||||
import serviceBearerAuth from './plugins/service-bearer-auth.js';
|
||||
|
||||
import * as publicRoutes from './routes/public/index.js';
|
||||
import * as protectedRoutes from './routes/protected/index.js';
|
||||
import { classroomRoutes } from './routes/apps/classroom.js';
|
||||
|
||||
import {
|
||||
API_LOCATION,
|
||||
FCC_ENABLE_DEV_LOGIN_MODE,
|
||||
FCC_ENABLE_SWAGGER_UI,
|
||||
FCC_ENABLE_SHADOW_CAPTURE,
|
||||
FCC_ENABLE_SENTRY_ROUTES,
|
||||
FCC_ENABLE_CLASSROOM,
|
||||
FREECODECAMP_NODE_ENV,
|
||||
GROWTHBOOK_FASTIFY_API_HOST,
|
||||
GROWTHBOOK_FASTIFY_CLIENT_KEY
|
||||
} from './utils/env.js';
|
||||
import { isObjectID } from './utils/validation.js';
|
||||
import { bindRouteToLogger, genReqId, getLogger } from './utils/logger.js';
|
||||
import { recordHttpMetrics } from './utils/http-metrics.js';
|
||||
import {
|
||||
examEnvironmentOpenRoutes,
|
||||
examEnvironmentValidatedTokenRoutes
|
||||
} from './exam-environment/routes/exam-environment.js';
|
||||
import { dailyCodingChallengeRoutes } from './daily-coding-challenge/routes/daily-coding-challenge.js';
|
||||
|
||||
type FastifyInstanceWithTypeProvider = FastifyInstance<
|
||||
RawServerDefault,
|
||||
RawRequestDefaultExpression,
|
||||
RawReplyDefaultExpression,
|
||||
FastifyBaseLogger,
|
||||
TypeBoxTypeProvider
|
||||
>;
|
||||
|
||||
// Options that fastify uses
|
||||
const ajv = new Ajv({
|
||||
coerceTypes: 'array', // change data type of data to match type keyword
|
||||
useDefaults: true, // replace missing properties and items with the values from corresponding default keyword
|
||||
removeAdditional: 'all', // remove additional properties
|
||||
uriResolver,
|
||||
addUsedSchema: false,
|
||||
// Explicitly set allErrors to `false`.
|
||||
// When set to `true`, a DoS attack is possible.
|
||||
allErrors: false
|
||||
});
|
||||
|
||||
// add the default formatters from avj-formats
|
||||
addFormats.default(ajv);
|
||||
ajv.addFormat('objectid', {
|
||||
type: 'string',
|
||||
validate: (str: string) => isObjectID(str)
|
||||
});
|
||||
|
||||
export const buildOptions: FastifyHttpOptions<
|
||||
RawServerDefault,
|
||||
FastifyBaseLogger
|
||||
> = {
|
||||
loggerInstance: getLogger(),
|
||||
genReqId,
|
||||
// destroy all connections on close to avoid EADDRINUSE
|
||||
// on restart, in development. Leave default in production.
|
||||
forceCloseConnections:
|
||||
FREECODECAMP_NODE_ENV === 'production' ? ('idle' as const) : true
|
||||
};
|
||||
|
||||
/**
|
||||
* Top-level wrapper to instantiate the API server. This is where all middleware and
|
||||
* routes should be mounted.
|
||||
*
|
||||
* @param options The options to pass to the Fastify constructor.
|
||||
* @returns The instantiated Fastify server, with TypeBox.
|
||||
*/
|
||||
export const build = async (
|
||||
options: FastifyHttpOptions<RawServerDefault, FastifyBaseLogger> = {}
|
||||
): Promise<FastifyInstanceWithTypeProvider> => {
|
||||
// TODO: Old API returns 403s for failed validation. We now return 400 (default) from AJV.
|
||||
// Watch when implementing in client
|
||||
const fastify = Fastify(options).withTypeProvider<TypeBoxTypeProvider>();
|
||||
|
||||
fastify.setValidatorCompiler(({ schema }) => ajv.compile(schema));
|
||||
|
||||
fastify.addHook('onRequest', bindRouteToLogger);
|
||||
fastify.addHook('onResponse', recordHttpMetrics);
|
||||
|
||||
void fastify.register(redirectWithMessage);
|
||||
void fastify.register(security);
|
||||
void fastify.register(fastifyAccepts);
|
||||
void fastify.register(errorHandling);
|
||||
void fastify.register(runtimeMetrics);
|
||||
|
||||
await fastify.register(cors);
|
||||
await fastify.register(cookies);
|
||||
await fastify.register(csrf);
|
||||
|
||||
await fastify.register(growthBook, {
|
||||
apiHost: GROWTHBOOK_FASTIFY_API_HOST,
|
||||
clientKey: GROWTHBOOK_FASTIFY_CLIENT_KEY
|
||||
});
|
||||
|
||||
void fastify.register(mailer, { provider: createMailProvider() });
|
||||
|
||||
// Swagger plugin
|
||||
if (FCC_ENABLE_SWAGGER_UI ?? fastify.gb.isOn('swagger-ui')) {
|
||||
void fastify.register(fastifySwagger, {
|
||||
openapi: {
|
||||
openapi: '3.1.0',
|
||||
info: {
|
||||
title: 'freeCodeCamp API',
|
||||
version: '1.0.0' // API version
|
||||
}
|
||||
}
|
||||
});
|
||||
void fastify.register(fastifySwaggerUI, {
|
||||
uiConfig: {
|
||||
// Convert csrf_token cookie to csrf-token header
|
||||
requestInterceptor: req => {
|
||||
const csrfTokenCookie = document.cookie
|
||||
.split(';')
|
||||
.find(str => str.includes('csrf_token'));
|
||||
const [_key, csrfToken] = csrfTokenCookie?.split('=') ?? [];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
if (csrfToken) req.headers['csrf-token'] = csrfToken.trim();
|
||||
return req;
|
||||
}
|
||||
}
|
||||
});
|
||||
fastify.log.info(`Swagger UI available at ${API_LOCATION}/documentation`);
|
||||
}
|
||||
|
||||
if (FCC_ENABLE_SHADOW_CAPTURE ?? fastify.gb.isOn('shadow-capture')) {
|
||||
void fastify.register(shadowCapture);
|
||||
}
|
||||
|
||||
void fastify.register(auth);
|
||||
void fastify.register(notFound);
|
||||
void fastify.register(prismaPlugin);
|
||||
void fastify.register(bouncer);
|
||||
await fastify.register(serviceBearerAuth);
|
||||
|
||||
// Routes requiring authentication:
|
||||
void fastify.register(async function (fastify, _opts) {
|
||||
fastify.addHook('onRequest', fastify.authorize);
|
||||
// CSRF protection enabled:
|
||||
await fastify.register(async function (fastify, _opts) {
|
||||
// TODO: bounce unauthed requests before checking CSRF token. This will
|
||||
// mean moving csrfProtection into custom plugin and testing separately,
|
||||
// because it's a pain to mess around with other cookies/hook order.
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
fastify.addHook('onRequest', fastify.csrfProtection);
|
||||
fastify.addHook('onRequest', fastify.send401IfNoUser);
|
||||
|
||||
await fastify.register(protectedRoutes.challengeRoutes);
|
||||
await fastify.register(protectedRoutes.donateRoutes);
|
||||
await fastify.register(protectedRoutes.socratesRoutes);
|
||||
await fastify.register(protectedRoutes.protectedCertificateRoutes);
|
||||
await fastify.register(protectedRoutes.settingRoutes);
|
||||
await fastify.register(protectedRoutes.userRoutes);
|
||||
});
|
||||
|
||||
// Routes that redirect if access is denied:
|
||||
await fastify.register(async function (fastify, _opts) {
|
||||
fastify.addHook('onRequest', fastify.redirectIfNoUser);
|
||||
|
||||
await fastify.register(protectedRoutes.settingRedirectRoutes);
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: The route should not handle its own AuthZ
|
||||
await fastify.register(protectedRoutes.challengeTokenRoutes);
|
||||
|
||||
// CSRF protection disabled:
|
||||
// Routes that work for both authenticated and unauthenticated users:
|
||||
void fastify.register(async function (fastify) {
|
||||
fastify.addHook('onRequest', fastify.authorize);
|
||||
|
||||
await fastify.register(protectedRoutes.userGetRoutes);
|
||||
});
|
||||
|
||||
// Routes for signed out users:
|
||||
void fastify.register(async function (fastify) {
|
||||
fastify.addHook('onRequest', fastify.authorize);
|
||||
// TODO(Post-MVP): add the redirectIfSignedIn hook here, rather than in the
|
||||
// mobileAuth0Routes and authRoutes plugins.
|
||||
await fastify.register(publicRoutes.mobileAuth0Routes);
|
||||
if (FCC_ENABLE_DEV_LOGIN_MODE) {
|
||||
await fastify.register(publicRoutes.devAuthRoutes);
|
||||
} else {
|
||||
await fastify.register(publicRoutes.authRoutes);
|
||||
}
|
||||
});
|
||||
|
||||
void fastify.register(function (fastify, _opts, done) {
|
||||
fastify.addHook('onRequest', fastify.authorizeExamEnvironmentToken);
|
||||
fastify.addHook('onRequest', fastify.send401IfNoUser);
|
||||
|
||||
void fastify.register(examEnvironmentValidatedTokenRoutes);
|
||||
done();
|
||||
});
|
||||
void fastify.register(examEnvironmentOpenRoutes);
|
||||
|
||||
// Service-to-service app routes (API key auth), gated by the classroom flag:
|
||||
if (FCC_ENABLE_CLASSROOM ?? fastify.gb.isOn('classroom-mode')) {
|
||||
void fastify.register(async function (fastify) {
|
||||
fastify.addHook('onRequest', fastify.validateBearerToken);
|
||||
await fastify.register(classroomRoutes, { prefix: '/apps/classroom' });
|
||||
});
|
||||
}
|
||||
|
||||
if (FCC_ENABLE_SENTRY_ROUTES ?? fastify.gb.isOn('sentry-routes')) {
|
||||
void fastify.register(publicRoutes.sentryRoutes);
|
||||
}
|
||||
|
||||
void fastify.register(publicRoutes.chargeStripeRoute);
|
||||
void fastify.register(publicRoutes.signoutRoute);
|
||||
void fastify.register(publicRoutes.emailSubscribtionRoutes);
|
||||
void fastify.register(publicRoutes.userPublicGetRoutes);
|
||||
void fastify.register(publicRoutes.unprotectedCertificateRoutes);
|
||||
void fastify.register(publicRoutes.deprecatedEndpoints);
|
||||
void fastify.register(publicRoutes.statusRoute);
|
||||
void fastify.register(publicRoutes.unsubscribeDeprecated);
|
||||
void fastify.register(dailyCodingChallengeRoutes);
|
||||
|
||||
return fastify;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
Endpoints to get daily coding challenge info. Daily challenge submission still lives in the main part of the API.
|
||||
@@ -0,0 +1,647 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { addDays } from 'date-fns';
|
||||
|
||||
import { setupServer, superRequest } from '../../../vitest.utils.js';
|
||||
|
||||
function dateToDateParam(date: Date): string {
|
||||
return date.toISOString().split('T')[0] as string;
|
||||
}
|
||||
|
||||
const todayUsCentral = new Date(Date.UTC(2025, 9, 2, 5)); // 2025-10-02 00:00:00 in US Central
|
||||
const todayUtcMidnight = new Date(Date.UTC(2025, 9, 2, 0, 0, 0));
|
||||
|
||||
const todayDateParam = dateToDateParam(todayUtcMidnight);
|
||||
|
||||
const yesterdayUtcMidnight = addDays(todayUtcMidnight, -1);
|
||||
|
||||
const twoDaysAgoUtcMidnight = addDays(todayUtcMidnight, -2);
|
||||
const twoDaysAgoDateParam = dateToDateParam(twoDaysAgoUtcMidnight);
|
||||
|
||||
const tomorrowUtcMidnight = addDays(todayUtcMidnight, 1);
|
||||
const tomorrowDateParam = dateToDateParam(tomorrowUtcMidnight);
|
||||
|
||||
const yesterdaysChallenge = {
|
||||
id: '111111111111111111111111',
|
||||
challengeNumber: 1,
|
||||
date: yesterdayUtcMidnight,
|
||||
title: "Yesterday's Challenge",
|
||||
description: "Yesterday's Description",
|
||||
javascript: {
|
||||
tests: [{ text: 'JS Test Yesterday', testString: 'jsTestYesterday()' }],
|
||||
challengeFiles: [{ contents: 'JS Files Yesterday', fileKey: 'scriptjs' }]
|
||||
},
|
||||
python: {
|
||||
tests: [{ text: 'Py Test Yesterday', testString: 'py_test_yesterday()' }],
|
||||
challengeFiles: [{ contents: 'Py Files Yesterday', fileKey: 'mainpy' }]
|
||||
}
|
||||
};
|
||||
|
||||
const todaysChallenge = {
|
||||
id: '222222222222222222222222',
|
||||
challengeNumber: 2,
|
||||
date: todayUtcMidnight,
|
||||
title: "Today's Challenge",
|
||||
description: "Today's Description",
|
||||
javascript: {
|
||||
tests: [{ text: 'JS Test Today', testString: 'jsTestToday()' }],
|
||||
challengeFiles: [{ contents: 'JS Files Today', fileKey: 'scriptjs' }]
|
||||
},
|
||||
python: {
|
||||
tests: [{ text: 'Py Test Today', testString: 'py_test_today()' }],
|
||||
challengeFiles: [{ contents: 'Py Files Today', fileKey: 'mainpy' }]
|
||||
}
|
||||
};
|
||||
|
||||
const tomorrowsChallenge = {
|
||||
id: '333333333333333333333333',
|
||||
challengeNumber: 3,
|
||||
date: tomorrowUtcMidnight,
|
||||
title: "Tomorrow's Challenge",
|
||||
description: "Tomorrow's Description",
|
||||
javascript: {
|
||||
tests: [{ text: 'JS Test Tomorrow', testString: 'jsTestTomorrow()' }],
|
||||
challengeFiles: [{ contents: 'JS Files Tomorrow', fileKey: 'scriptjs' }]
|
||||
},
|
||||
python: {
|
||||
tests: [{ text: 'Py Test Tomorrow', testString: 'py_test_tomorrow()' }],
|
||||
challengeFiles: [{ contents: 'Py Files Tomorrow', fileKey: 'mainpy' }]
|
||||
}
|
||||
};
|
||||
|
||||
const mockChallenges = [
|
||||
tomorrowsChallenge,
|
||||
todaysChallenge,
|
||||
yesterdaysChallenge
|
||||
];
|
||||
|
||||
describe('/daily-coding-challenge', () => {
|
||||
setupServer();
|
||||
// This has to happen after setupServer since it needs real timers.
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ now: todayUsCentral });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('GET /daily-coding-challenge/date/:date', () => {
|
||||
beforeEach(async () => {
|
||||
await fastifyTestInstance.prisma.dailyCodingChallenges.createMany({
|
||||
data: mockChallenges
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastifyTestInstance.prisma.dailyCodingChallenges.deleteMany();
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid date format', async () => {
|
||||
const invalidFormats = [
|
||||
'invalid-format',
|
||||
'2025-07',
|
||||
'07-18-2025',
|
||||
'25-07-18',
|
||||
'2025-7-18',
|
||||
'2025-07-8'
|
||||
];
|
||||
|
||||
for (const invalidFormat of invalidFormats) {
|
||||
const res = await superRequest(
|
||||
`/daily-coding-challenge/date/${invalidFormat}`,
|
||||
{
|
||||
method: 'GET'
|
||||
}
|
||||
).send({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({
|
||||
type: 'error',
|
||||
message: 'Invalid date format. Please use YYYY-MM-DD.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should return 404 for a date without a challenge', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superRequest(
|
||||
`/daily-coding-challenge/date/${twoDaysAgoDateParam}`,
|
||||
{
|
||||
method: 'GET'
|
||||
}
|
||||
).send({});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({
|
||||
type: 'error',
|
||||
message: 'Challenge not found.'
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('dcc.challenge_not_found', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/date/:date' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('should return a challenge for a valid date', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superRequest(
|
||||
`/daily-coding-challenge/date/${todayDateParam}`,
|
||||
{
|
||||
method: 'GET'
|
||||
}
|
||||
).send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
...todaysChallenge,
|
||||
date: todaysChallenge.date.toISOString()
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('dcc.challenge_viewed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/date/:date' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('should not return a challenge for a future date relative to US Central', async () => {
|
||||
const res = await superRequest(
|
||||
`/daily-coding-challenge/date/${tomorrowDateParam}`,
|
||||
{
|
||||
method: 'GET'
|
||||
}
|
||||
).send({});
|
||||
expect(res.body).toEqual({
|
||||
type: 'error',
|
||||
message: 'Challenge not found.'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /daily-coding-challenge/today', () => {
|
||||
beforeEach(async () => {
|
||||
await fastifyTestInstance.prisma.dailyCodingChallenges.createMany({
|
||||
data: mockChallenges
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastifyTestInstance.prisma.dailyCodingChallenges.deleteMany();
|
||||
});
|
||||
|
||||
it("should return today's challenge", async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superRequest('/daily-coding-challenge/today', {
|
||||
method: 'GET'
|
||||
}).send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
...todaysChallenge,
|
||||
date: todaysChallenge.date.toISOString()
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('dcc.challenge_viewed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/today' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('should return 404 when no challenge exists for today', async () => {
|
||||
await fastifyTestInstance.prisma.dailyCodingChallenges.deleteMany();
|
||||
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superRequest('/daily-coding-challenge/today', {
|
||||
method: 'GET'
|
||||
}).send({});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({
|
||||
type: 'error',
|
||||
message: 'Challenge not found.'
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('dcc.challenge_not_found', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/today' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /daily-coding-challenge/month/:month', () => {
|
||||
beforeEach(async () => {
|
||||
await fastifyTestInstance.prisma.dailyCodingChallenges.createMany({
|
||||
data: mockChallenges
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastifyTestInstance.prisma.dailyCodingChallenges.deleteMany();
|
||||
});
|
||||
|
||||
it('should return 400 for invalid month format', async () => {
|
||||
const invalidFormats = ['invalid-month', '2025-13', '2025-1', '25-07'];
|
||||
|
||||
for (const invalidFormat of invalidFormats) {
|
||||
const res = await superRequest(
|
||||
`/daily-coding-challenge/month/${invalidFormat}`,
|
||||
{
|
||||
method: 'GET'
|
||||
}
|
||||
).send({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({
|
||||
type: 'error',
|
||||
message: 'Invalid date format. Please use YYYY-MM.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should return two challenges on the second day of the month', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superRequest(`/daily-coding-challenge/month/2025-10`, {
|
||||
method: 'GET'
|
||||
}).send({});
|
||||
|
||||
// Should include yesterday's and today's challenges, but not tomorrow's
|
||||
const expectedResponse = [
|
||||
{
|
||||
id: todaysChallenge.id,
|
||||
challengeNumber: todaysChallenge.challengeNumber,
|
||||
date: todaysChallenge.date.toISOString(),
|
||||
title: todaysChallenge.title
|
||||
},
|
||||
{
|
||||
id: yesterdaysChallenge.id,
|
||||
challengeNumber: yesterdaysChallenge.challengeNumber,
|
||||
date: yesterdaysChallenge.date.toISOString(),
|
||||
title: yesterdaysChallenge.title
|
||||
}
|
||||
];
|
||||
|
||||
expect(res.body).toEqual(expectedResponse);
|
||||
expect(res.status).toBe(200);
|
||||
expect(count).toHaveBeenCalledWith('dcc.challenge_viewed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/month/:month' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('should return one challenge on the first day of the month', async () => {
|
||||
vi.setSystemTime(new Date(Date.UTC(2025, 9, 1, 5))); // 2025-10-01 00:00:00 in US Central
|
||||
|
||||
const res = await superRequest(`/daily-coding-challenge/month/2025-10`, {
|
||||
method: 'GET'
|
||||
}).send({});
|
||||
|
||||
// Should include yesterday's challenges
|
||||
const expectedResponse = [
|
||||
{
|
||||
id: yesterdaysChallenge.id,
|
||||
challengeNumber: yesterdaysChallenge.challengeNumber,
|
||||
date: yesterdaysChallenge.date.toISOString(),
|
||||
title: yesterdaysChallenge.title
|
||||
}
|
||||
];
|
||||
|
||||
expect(res.body).toEqual(expectedResponse);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should return 404 when no challenges exist for the given month', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superRequest('/daily-coding-challenge/month/2024-01', {
|
||||
method: 'GET'
|
||||
}).send({});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({
|
||||
type: 'error',
|
||||
message: 'No challenges found.'
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('dcc.challenge_not_found', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/month/:month' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /daily-coding-challenge/all', () => {
|
||||
beforeEach(async () => {
|
||||
await fastifyTestInstance.prisma.dailyCodingChallenges.createMany({
|
||||
data: mockChallenges
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastifyTestInstance.prisma.dailyCodingChallenges.deleteMany();
|
||||
});
|
||||
|
||||
it('should return { _id, date, challengeNumber, title } for all challenges up to today US Central', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superRequest('/daily-coding-challenge/all', {
|
||||
method: 'GET'
|
||||
}).send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
|
||||
// Should include yesterday's and today's challenges, but not tomorrow's
|
||||
const expectedResponse = [
|
||||
{
|
||||
id: todaysChallenge.id,
|
||||
challengeNumber: todaysChallenge.challengeNumber,
|
||||
date: todaysChallenge.date.toISOString(),
|
||||
title: todaysChallenge.title
|
||||
},
|
||||
{
|
||||
id: yesterdaysChallenge.id,
|
||||
challengeNumber: yesterdaysChallenge.challengeNumber,
|
||||
date: yesterdaysChallenge.date.toISOString(),
|
||||
title: yesterdaysChallenge.title
|
||||
}
|
||||
];
|
||||
|
||||
expect(res.body).toHaveLength(2);
|
||||
expect(res.body).toEqual(expectedResponse);
|
||||
expect(count).toHaveBeenCalledWith('dcc.challenge_viewed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/all' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('should return 404 when no challenges exist', async () => {
|
||||
await fastifyTestInstance.prisma.dailyCodingChallenges.deleteMany();
|
||||
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superRequest('/daily-coding-challenge/all', {
|
||||
method: 'GET'
|
||||
}).send({});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({
|
||||
type: 'error',
|
||||
message: 'No challenges found.'
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('dcc.challenge_not_found', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/all' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /daily-coding-challenge/newest', () => {
|
||||
beforeEach(async () => {
|
||||
await fastifyTestInstance.prisma.dailyCodingChallenges.createMany({
|
||||
data: [yesterdaysChallenge, todaysChallenge, tomorrowsChallenge]
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastifyTestInstance.prisma.dailyCodingChallenges.deleteMany();
|
||||
});
|
||||
|
||||
it('should return { date } of the newest challenge in the database', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superRequest('/daily-coding-challenge/newest', {
|
||||
method: 'GET'
|
||||
}).send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
date: tomorrowsChallenge.date.toISOString()
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('dcc.challenge_viewed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/newest' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('should return 404 when no challenges exist', async () => {
|
||||
await fastifyTestInstance.prisma.dailyCodingChallenges.deleteMany();
|
||||
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superRequest('/daily-coding-challenge/newest', {
|
||||
method: 'GET'
|
||||
}).send({});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({
|
||||
type: 'error',
|
||||
message: 'No challenges found.'
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('dcc.challenge_not_found', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/newest' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sentry Issue reporting', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('captures unexpected errors when getting a challenge by date', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
vi.spyOn(
|
||||
fastifyTestInstance.prisma.dailyCodingChallenges,
|
||||
'findFirst'
|
||||
).mockRejectedValueOnce(new Error('DB error'));
|
||||
|
||||
const res = await superRequest(
|
||||
`/daily-coding-challenge/date/${todayDateParam}`,
|
||||
{ method: 'GET' }
|
||||
).send({});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
expect(count).toHaveBeenCalledWith('dcc.request_failed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/date/:date' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it("captures unexpected errors when getting today's challenge", async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
vi.spyOn(
|
||||
fastifyTestInstance.prisma.dailyCodingChallenges,
|
||||
'findFirst'
|
||||
).mockRejectedValueOnce(new Error('DB error'));
|
||||
|
||||
const res = await superRequest('/daily-coding-challenge/today', {
|
||||
method: 'GET'
|
||||
}).send({});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
expect(count).toHaveBeenCalledWith('dcc.request_failed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/today' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('captures unexpected errors when getting a month of challenges', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
vi.spyOn(
|
||||
fastifyTestInstance.prisma.dailyCodingChallenges,
|
||||
'findMany'
|
||||
).mockRejectedValueOnce(new Error('DB error'));
|
||||
|
||||
const res = await superRequest('/daily-coding-challenge/month/2025-10', {
|
||||
method: 'GET'
|
||||
}).send({});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
expect(count).toHaveBeenCalledWith('dcc.request_failed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/month/:month' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('captures unexpected errors when getting all challenges', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
vi.spyOn(
|
||||
fastifyTestInstance.prisma.dailyCodingChallenges,
|
||||
'findMany'
|
||||
).mockRejectedValueOnce(new Error('DB error'));
|
||||
|
||||
const res = await superRequest('/daily-coding-challenge/all', {
|
||||
method: 'GET'
|
||||
}).send({});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
expect(count).toHaveBeenCalledWith('dcc.request_failed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/all' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('captures unexpected errors when getting the newest challenge', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
vi.spyOn(
|
||||
fastifyTestInstance.prisma.dailyCodingChallenges,
|
||||
'findFirst'
|
||||
).mockRejectedValueOnce(new Error('DB error'));
|
||||
|
||||
const res = await superRequest('/daily-coding-challenge/newest', {
|
||||
method: 'GET'
|
||||
}).send({});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
expect(count).toHaveBeenCalledWith('dcc.request_failed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/newest' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||
|
||||
import * as schemas from '../schemas/index.js';
|
||||
import {
|
||||
getNowUsCentral,
|
||||
getUtcMidnight,
|
||||
dateStringToUtcMidnight
|
||||
} from '../utils/helpers.js';
|
||||
|
||||
/**
|
||||
* Plugin containing public GET routes for the daily coding challenges.
|
||||
* Note that they are only for getting challenge info, challenges are still
|
||||
* submitted via the main challenge completion routes.
|
||||
*
|
||||
* @param fastify The Fastify instance.
|
||||
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
|
||||
* @param done Callback to signal that the logic has completed.
|
||||
*/
|
||||
export const dailyCodingChallengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
fastify,
|
||||
_options,
|
||||
done
|
||||
) => {
|
||||
fastify.get(
|
||||
'/daily-coding-challenge/date/:date',
|
||||
{
|
||||
schema: schemas.dailyCodingChallenge.date
|
||||
},
|
||||
async (req, reply) => {
|
||||
req.log.info(
|
||||
{ date: req.params.date },
|
||||
'Received request for daily coding challenge'
|
||||
);
|
||||
|
||||
const { date } = req.params;
|
||||
|
||||
try {
|
||||
const parsedDate = dateStringToUtcMidnight(date);
|
||||
|
||||
if (!parsedDate) {
|
||||
req.log.warn({ date }, 'Invalid date format requested');
|
||||
return reply.status(400).send({
|
||||
type: 'error',
|
||||
message: 'Invalid date format. Please use YYYY-MM-DD.'
|
||||
});
|
||||
}
|
||||
|
||||
const challenge = await fastify.prisma.dailyCodingChallenges.findFirst({
|
||||
where: {
|
||||
date: parsedDate
|
||||
}
|
||||
});
|
||||
|
||||
// don't return challenges > today US Central
|
||||
if (!challenge || challenge.date > getUtcMidnight(getNowUsCentral())) {
|
||||
req.log.warn({ date: parsedDate }, 'Challenge not found for date');
|
||||
fastify.Sentry?.metrics?.count('dcc.challenge_not_found', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/date/:date' }
|
||||
});
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ type: 'error', message: 'Challenge not found.' });
|
||||
}
|
||||
|
||||
fastify.Sentry?.metrics?.count('dcc.challenge_viewed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/date/:date' }
|
||||
});
|
||||
return reply.send({
|
||||
...challenge,
|
||||
date: challenge.date.toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
req.log.error(error, 'Failed to get daily coding challenge.');
|
||||
fastify.Sentry?.captureException(error);
|
||||
fastify.Sentry?.metrics?.count('dcc.request_failed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/date/:date' }
|
||||
});
|
||||
await reply
|
||||
.status(500)
|
||||
.send({ type: 'error', message: 'Internal server error.' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/daily-coding-challenge/today',
|
||||
{
|
||||
schema: schemas.dailyCodingChallenge.today
|
||||
},
|
||||
async (req, reply) => {
|
||||
req.log.info("Received request for today's daily coding challenge");
|
||||
|
||||
const today = getUtcMidnight(getNowUsCentral());
|
||||
|
||||
try {
|
||||
const todaysChallenge =
|
||||
await fastify.prisma.dailyCodingChallenges.findFirst({
|
||||
where: {
|
||||
date: today
|
||||
}
|
||||
});
|
||||
|
||||
if (!todaysChallenge) {
|
||||
req.log.warn({ date: today }, 'Challenge not found for today');
|
||||
fastify.Sentry?.metrics?.count('dcc.challenge_not_found', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/today' }
|
||||
});
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ type: 'error', message: 'Challenge not found.' });
|
||||
}
|
||||
|
||||
fastify.Sentry?.metrics?.count('dcc.challenge_viewed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/today' }
|
||||
});
|
||||
return reply.send({
|
||||
...todaysChallenge,
|
||||
date: todaysChallenge.date.toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
req.log.error(error, "Failed to get today's daily coding challenge.");
|
||||
fastify.Sentry?.captureException(error);
|
||||
fastify.Sentry?.metrics?.count('dcc.request_failed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/today' }
|
||||
});
|
||||
await reply
|
||||
.status(500)
|
||||
.send({ type: 'error', message: 'Internal server error.' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/daily-coding-challenge/month/:month',
|
||||
{
|
||||
schema: schemas.dailyCodingChallenge.month
|
||||
},
|
||||
async (req, reply) => {
|
||||
req.log.info(
|
||||
{ month: req.params.month },
|
||||
'Received request for month of daily coding challenges'
|
||||
);
|
||||
|
||||
const { month } = req.params;
|
||||
|
||||
try {
|
||||
// Month is guaranteed YYYY-MM format from schema validation
|
||||
const parts = month.split('-');
|
||||
const parsedYear = parseInt(parts[0]!, 10);
|
||||
const parsedMonth = parseInt(parts[1]!, 10);
|
||||
|
||||
// Validate month range
|
||||
if (parsedMonth < 1 || parsedMonth > 12) {
|
||||
req.log.warn({ month }, 'Invalid month value requested');
|
||||
return reply.status(400).send({
|
||||
type: 'error',
|
||||
message: 'Invalid date format. Please use YYYY-MM.'
|
||||
});
|
||||
}
|
||||
|
||||
const monthStart = new Date(Date.UTC(parsedYear, parsedMonth - 1, 1));
|
||||
const monthEnd = new Date(Date.UTC(parsedYear, parsedMonth, 1));
|
||||
const todayUsCentral = getUtcMidnight(getNowUsCentral());
|
||||
|
||||
const challenges = await fastify.prisma.dailyCodingChallenges.findMany({
|
||||
where: {
|
||||
date: {
|
||||
gte: monthStart,
|
||||
lt: monthEnd,
|
||||
lte: todayUsCentral
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
date: 'desc'
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
challengeNumber: true,
|
||||
date: true,
|
||||
title: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!challenges || challenges.length === 0) {
|
||||
req.log.warn({ month }, 'No challenges found for month');
|
||||
fastify.Sentry?.metrics?.count('dcc.challenge_not_found', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/month/:month' }
|
||||
});
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ type: 'error', message: 'No challenges found.' });
|
||||
}
|
||||
|
||||
const response = challenges.map(challenge => ({
|
||||
...challenge,
|
||||
date: challenge.date.toISOString()
|
||||
}));
|
||||
|
||||
fastify.Sentry?.metrics?.count('dcc.challenge_viewed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/month/:month' }
|
||||
});
|
||||
return reply.send(response);
|
||||
} catch (error) {
|
||||
req.log.error(error, 'Failed to get monthly daily coding challenges.');
|
||||
fastify.Sentry?.captureException(error);
|
||||
fastify.Sentry?.metrics?.count('dcc.request_failed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/month/:month' }
|
||||
});
|
||||
await reply
|
||||
.status(500)
|
||||
.send({ type: 'error', message: 'Internal server error.' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/daily-coding-challenge/all',
|
||||
{
|
||||
schema: schemas.dailyCodingChallenge.all
|
||||
},
|
||||
async (req, reply) => {
|
||||
req.log.info('Received request for all daily coding challenges');
|
||||
|
||||
const today = getUtcMidnight(getNowUsCentral());
|
||||
|
||||
try {
|
||||
const allChallenges =
|
||||
await fastify.prisma.dailyCodingChallenges.findMany({
|
||||
// only where date <= today US Central
|
||||
where: {
|
||||
date: {
|
||||
lte: today
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
date: 'desc'
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
challengeNumber: true,
|
||||
date: true,
|
||||
title: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!allChallenges || allChallenges.length === 0) {
|
||||
req.log.warn({ date: today }, 'No challenges found.');
|
||||
fastify.Sentry?.metrics?.count('dcc.challenge_not_found', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/all' }
|
||||
});
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ type: 'error', message: 'No challenges found.' });
|
||||
}
|
||||
|
||||
const response = allChallenges.map(challenge => ({
|
||||
...challenge,
|
||||
date: challenge.date.toISOString()
|
||||
}));
|
||||
|
||||
fastify.Sentry?.metrics?.count('dcc.challenge_viewed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/all' }
|
||||
});
|
||||
return reply.send(response);
|
||||
} catch (error) {
|
||||
req.log.error(error, 'Failed to get all daily coding challenges.');
|
||||
fastify.Sentry?.captureException(error);
|
||||
fastify.Sentry?.metrics?.count('dcc.request_failed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/all' }
|
||||
});
|
||||
await reply
|
||||
.status(500)
|
||||
.send({ type: 'error', message: 'Internal server error.' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/daily-coding-challenge/newest',
|
||||
{
|
||||
schema: schemas.dailyCodingChallenge.newest
|
||||
},
|
||||
async (req, reply) => {
|
||||
req.log.info('Received request for newest daily coding challenge');
|
||||
|
||||
try {
|
||||
const newestChallenge =
|
||||
await fastify.prisma.dailyCodingChallenges.findFirst({
|
||||
orderBy: {
|
||||
date: 'desc'
|
||||
},
|
||||
select: {
|
||||
date: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!newestChallenge) {
|
||||
req.log.warn('No challenges found.');
|
||||
fastify.Sentry?.metrics?.count('dcc.challenge_not_found', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/newest' }
|
||||
});
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ type: 'error', message: 'No challenges found.' });
|
||||
}
|
||||
|
||||
fastify.Sentry?.metrics?.count('dcc.challenge_viewed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/newest' }
|
||||
});
|
||||
return reply.send({ date: newestChallenge.date.toISOString() });
|
||||
} catch (error) {
|
||||
req.log.error(error, 'Failed to get newest daily coding challenge.');
|
||||
fastify.Sentry?.captureException(error);
|
||||
fastify.Sentry?.metrics?.count('dcc.request_failed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/newest' }
|
||||
});
|
||||
await reply
|
||||
.status(500)
|
||||
.send({ type: 'error', message: 'Internal server error.' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
done();
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
|
||||
const challengeLanguage = Type.Object({
|
||||
tests: Type.Array(
|
||||
Type.Object({
|
||||
text: Type.String(),
|
||||
testString: Type.String()
|
||||
})
|
||||
),
|
||||
challengeFiles: Type.Array(
|
||||
Type.Object({
|
||||
contents: Type.String(),
|
||||
fileKey: Type.String()
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
const singleChallengeResponse = Type.Object({
|
||||
id: Type.String({ format: 'objectid', maxLength: 24, minLength: 24 }),
|
||||
date: Type.String({ format: 'date-time' }),
|
||||
challengeNumber: Type.Number(),
|
||||
title: Type.String(),
|
||||
description: Type.String(),
|
||||
javascript: challengeLanguage,
|
||||
python: challengeLanguage
|
||||
});
|
||||
|
||||
const date = {
|
||||
params: Type.Object({
|
||||
date: Type.String({ format: 'date' })
|
||||
}),
|
||||
response: {
|
||||
200: singleChallengeResponse,
|
||||
400: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Literal('Invalid date format. Please use YYYY-MM-DD.')
|
||||
}),
|
||||
404: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Literal('Challenge not found.')
|
||||
}),
|
||||
500: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Literal('Internal server error.')
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
const today = {
|
||||
response: {
|
||||
200: singleChallengeResponse,
|
||||
404: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Literal('Challenge not found.')
|
||||
}),
|
||||
500: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Literal('Internal server error.')
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
const manyChallengesResponse = Type.Array(
|
||||
Type.Object({
|
||||
id: Type.String(),
|
||||
date: Type.String({ format: 'date-time' }),
|
||||
challengeNumber: Type.Number(),
|
||||
title: Type.String()
|
||||
})
|
||||
);
|
||||
|
||||
const month = {
|
||||
params: Type.Object({
|
||||
month: Type.String({ pattern: '^\\d{4}-\\d{2}$' })
|
||||
}),
|
||||
response: {
|
||||
200: manyChallengesResponse,
|
||||
400: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Literal('Invalid date format. Please use YYYY-MM.')
|
||||
}),
|
||||
404: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Literal('No challenges found.')
|
||||
}),
|
||||
500: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Literal('Internal server error.')
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
const all = {
|
||||
response: {
|
||||
200: manyChallengesResponse,
|
||||
404: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Literal('No challenges found.')
|
||||
}),
|
||||
500: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Literal('Internal server error.')
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
const newest = {
|
||||
response: {
|
||||
200: Type.Object({
|
||||
date: Type.String({ format: 'date-time' })
|
||||
}),
|
||||
404: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Literal('No challenges found.')
|
||||
}),
|
||||
500: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Literal('Internal server error.')
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
export const dailyCodingChallenge = {
|
||||
date,
|
||||
today,
|
||||
month,
|
||||
all,
|
||||
newest
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { dailyCodingChallenge } from './daily-coding-challenge.js';
|
||||
@@ -0,0 +1,40 @@
|
||||
import { getTimezoneOffset } from 'date-fns-tz';
|
||||
|
||||
/**
|
||||
* @returns Now US Central time.
|
||||
*/
|
||||
export function getNowUsCentral() {
|
||||
const offset = getTimezoneOffset('America/Chicago', new Date());
|
||||
return new Date(Date.now() + offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Date object set to UTC midnight of the given date.
|
||||
* @param date - Date Object.
|
||||
* @returns UTC midnight of the given date.
|
||||
*/
|
||||
export function getUtcMidnight(date: Date): Date {
|
||||
return new Date(
|
||||
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a date string in the format "YYYY-MM-DD" and returns a Date object set to UTC midnight.
|
||||
* Returns null if the input is not in the correct format.
|
||||
* @param dateStr - Date string in "YYYY-MM-DD" format.
|
||||
* @returns Date object set to UTC midnight or null if invalid.
|
||||
*/
|
||||
export function dateStringToUtcMidnight(dateStr: string): Date | null {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [year, month, day] = dateStr.split('-').map(Number) as [
|
||||
number,
|
||||
number,
|
||||
number
|
||||
];
|
||||
|
||||
return new Date(Date.UTC(year, month - 1, day));
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
|
||||
import { defaultUserEmail, setupServer } from '../../vitest.utils.js';
|
||||
|
||||
import { createUserInput } from '../utils/create-user.js';
|
||||
|
||||
describe('prisma client extensions', () => {
|
||||
setupServer();
|
||||
|
||||
beforeEach(async () => {
|
||||
await fastifyTestInstance.prisma.user.deleteMany({
|
||||
where: { email: defaultUserEmail }
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fastifyTestInstance.prisma.user.deleteMany({
|
||||
where: { email: defaultUserEmail }
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCount', () => {
|
||||
it('should default to 0', async () => {
|
||||
const user = await fastifyTestInstance.prisma.user.create({
|
||||
data: createUserInput(defaultUserEmail)
|
||||
});
|
||||
|
||||
expect(user).toMatchObject({
|
||||
updateCount: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('should increment by one for updates and creates', async () => {
|
||||
const user = await fastifyTestInstance.prisma.user.create({
|
||||
data: createUserInput(defaultUserEmail)
|
||||
});
|
||||
|
||||
const updateUser = await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { username: 'any-change' }
|
||||
});
|
||||
|
||||
expect(updateUser).toMatchObject({
|
||||
username: 'any-change',
|
||||
updateCount: 1
|
||||
});
|
||||
|
||||
await fastifyTestInstance.prisma.user.updateMany({
|
||||
where: { id: user.id },
|
||||
// Even no change to values updates the updateCount
|
||||
data: { username: 'any-change' }
|
||||
});
|
||||
|
||||
const updateManyUser = await fastifyTestInstance.prisma.user.findUnique({
|
||||
where: { id: user.id }
|
||||
});
|
||||
|
||||
expect(updateManyUser).toMatchObject({
|
||||
username: 'any-change',
|
||||
updateCount: 2
|
||||
});
|
||||
|
||||
const upsertUser = await fastifyTestInstance.prisma.user.upsert({
|
||||
where: { id: user.id },
|
||||
create: createUserInput(defaultUserEmail),
|
||||
update: { username: 'upser-user' }
|
||||
});
|
||||
|
||||
expect(upsertUser).toMatchObject({
|
||||
username: 'upser-user',
|
||||
updateCount: 3
|
||||
});
|
||||
});
|
||||
|
||||
it("should not increment for 'find' queries", async () => {
|
||||
const user = await fastifyTestInstance.prisma.user.create({
|
||||
data: createUserInput(defaultUserEmail)
|
||||
});
|
||||
|
||||
const findUniqueUser = await fastifyTestInstance.prisma.user.findUnique({
|
||||
where: { id: user.id }
|
||||
});
|
||||
|
||||
expect(findUniqueUser).toMatchObject({
|
||||
updateCount: 0
|
||||
});
|
||||
|
||||
const findManyUsers = await fastifyTestInstance.prisma.user.findMany();
|
||||
|
||||
expect(findManyUsers).toHaveLength(1);
|
||||
expect(findManyUsers[0]).toMatchObject({
|
||||
updateCount: 0
|
||||
});
|
||||
|
||||
const findFirstUser = await fastifyTestInstance.prisma.user.findFirst();
|
||||
|
||||
expect(findFirstUser).toMatchObject({
|
||||
updateCount: 0
|
||||
});
|
||||
|
||||
const findRawUser = await fastifyTestInstance.prisma.user.findRaw({
|
||||
filter: { email: defaultUserEmail }
|
||||
});
|
||||
|
||||
expect(findRawUser[0]).toMatchObject({
|
||||
updateCount: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import fp from 'fastify-plugin';
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import * as Sentry from '@sentry/node';
|
||||
|
||||
// importing MONGOHQ_URL so we can mock it in testing.
|
||||
import { MONGOHQ_URL } from '../utils/env.js';
|
||||
import { timeOperation } from './query-timing.js';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
prisma: ReturnType<typeof extendClient>;
|
||||
}
|
||||
}
|
||||
|
||||
const prismaPlugin: FastifyPluginAsync = fp(async (server, _options) => {
|
||||
const prisma = extendClient(
|
||||
new PrismaClient({
|
||||
datasources: {
|
||||
db: {
|
||||
url: MONGOHQ_URL
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
await prisma.$connect().catch((err: unknown) => {
|
||||
Sentry.metrics.count('db.connect_failed', 1);
|
||||
server.log.error(err, 'Prisma connection failed');
|
||||
throw err;
|
||||
});
|
||||
|
||||
server.decorate('prisma', prisma);
|
||||
|
||||
server.addHook('onClose', async server => {
|
||||
await server.prisma.$disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: It would be nice to split this up into multiple update functions,
|
||||
// but the types are a pain.
|
||||
// TODO: Multiple extended clients can be used for different restrictions (e.g. session vs non-session users)
|
||||
// TODO: Could be used to add other _easily forgotten_ fields like `progressTimestamp`
|
||||
function extendClient(prisma: PrismaClient) {
|
||||
return prisma
|
||||
.$extends({
|
||||
query: {
|
||||
user: {
|
||||
async update({ args, query }) {
|
||||
args.data.updateCount = { increment: 1 };
|
||||
return query(args);
|
||||
},
|
||||
async updateMany({ args, query }) {
|
||||
args.data.updateCount = { increment: 1 };
|
||||
return query(args);
|
||||
},
|
||||
async upsert({ args, query }) {
|
||||
args.update.updateCount = { increment: 1 };
|
||||
return query(args);
|
||||
}
|
||||
// NOTE: raw ops are untouched, as it is meant to be a direct passthrough to mongodb
|
||||
// async findRaw({ model, operation, args, query }) {}
|
||||
// async aggregateRaw({ model, operation, args, query }) {}
|
||||
}
|
||||
}
|
||||
})
|
||||
.$extends({
|
||||
query: {
|
||||
$allModels: {
|
||||
$allOperations({ model, operation, args, query }) {
|
||||
return timeOperation(
|
||||
() => query(args),
|
||||
(result, durationMs) =>
|
||||
Sentry.metrics.distribution(
|
||||
'db.query_duration_ms',
|
||||
durationMs,
|
||||
{
|
||||
unit: 'millisecond',
|
||||
attributes: { model: model ?? 'raw', operation, result }
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default prismaPlugin;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import { timeOperation } from './query-timing.js';
|
||||
|
||||
describe('timeOperation', () => {
|
||||
it('returns the result and emits success with a numeric duration', async () => {
|
||||
const emit = vi.fn();
|
||||
|
||||
const result = await timeOperation(() => Promise.resolve('ok'), emit);
|
||||
|
||||
expect(result).toBe('ok');
|
||||
expect(emit).toHaveBeenCalledWith('success', expect.any(Number));
|
||||
});
|
||||
|
||||
it('re-throws and emits failure when the operation rejects', async () => {
|
||||
const emit = vi.fn();
|
||||
const boom = new Error('boom');
|
||||
|
||||
await expect(timeOperation(() => Promise.reject(boom), emit)).rejects.toBe(
|
||||
boom
|
||||
);
|
||||
expect(emit).toHaveBeenCalledWith('failure', expect.any(Number));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
// eslint-disable-next-line jsdoc/require-jsdoc
|
||||
export const timeOperation = async <T>(
|
||||
op: () => Promise<T>,
|
||||
emit: (result: 'success' | 'failure', durationMs: number) => void
|
||||
): Promise<T> => {
|
||||
const start = performance.now();
|
||||
try {
|
||||
const result = await op();
|
||||
emit('success', performance.now() - start);
|
||||
return result;
|
||||
} catch (err) {
|
||||
emit('failure', performance.now() - start);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
// import { STANDARD_ERROR } from '../utils/errors';
|
||||
|
||||
export const examEnvironmentGetExamChallenge = {
|
||||
querystring: Type.Object({
|
||||
challengeId: Type.Optional(Type.String({ format: 'objectid' })),
|
||||
examId: Type.Optional(Type.String({ format: 'objectid' }))
|
||||
})
|
||||
// response: {
|
||||
// 200: examEnvAttempt,
|
||||
// default: STANDARD_ERROR
|
||||
// }
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
import { STANDARD_ERROR } from '../utils/errors.js';
|
||||
|
||||
export const examEnvironmentPostExamAttempt = {
|
||||
body: Type.Object({
|
||||
attempt: Type.Object({
|
||||
examId: Type.String({ format: 'objectid' }),
|
||||
questionSets: Type.Array(
|
||||
Type.Object({
|
||||
id: Type.String({ format: 'objectid' }),
|
||||
questions: Type.Array(
|
||||
Type.Object({
|
||||
id: Type.String({ format: 'objectid' }),
|
||||
answers: Type.Array(Type.String({ format: 'objectid' }))
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
}),
|
||||
headers: Type.Object({
|
||||
'exam-environment-authorization-token': Type.String()
|
||||
}),
|
||||
response: {
|
||||
default: STANDARD_ERROR
|
||||
}
|
||||
};
|
||||
|
||||
export enum ExamAttemptStatus {
|
||||
// Attempt has not expired yet.
|
||||
InProgress = 'InProgress',
|
||||
// Moderation record is not created for practice exam. Also, it might not exist until exam service cron is run.
|
||||
Expired = 'Expired',
|
||||
// Attempt has expired && moderation record has been created but not yet moderated
|
||||
PendingModeration = 'PendingModeration',
|
||||
// Attempt has been approved
|
||||
Approved = 'Approved',
|
||||
// Attempt has been denied
|
||||
Denied = 'Denied'
|
||||
}
|
||||
|
||||
const examEnvAttempt = Type.Object({
|
||||
id: Type.String(),
|
||||
examId: Type.String(),
|
||||
startTime: Type.String({ format: 'date-time' }),
|
||||
questionSets: Type.Array(
|
||||
Type.Object({
|
||||
id: Type.String(),
|
||||
questions: Type.Array(
|
||||
Type.Object({
|
||||
id: Type.String(),
|
||||
answers: Type.Array(Type.String()),
|
||||
submissionTime: Type.String({ format: 'date-time' })
|
||||
})
|
||||
)
|
||||
})
|
||||
),
|
||||
result: Type.Union([
|
||||
Type.Null(),
|
||||
Type.Object({
|
||||
score: Type.Number(),
|
||||
passingPercent: Type.Number()
|
||||
})
|
||||
]),
|
||||
version: Type.Number(),
|
||||
status: Type.Enum(ExamAttemptStatus)
|
||||
});
|
||||
|
||||
export const examEnvironmentGetExamAttempts = {
|
||||
headers: Type.Object({
|
||||
// Optional, because the handler is used in both the `/user/` base and `/exam-environment/` base
|
||||
// If it is missing, auth will catch.
|
||||
'exam-environment-authorization-token': Type.Optional(Type.String())
|
||||
}),
|
||||
response: {
|
||||
200: Type.Array(examEnvAttempt),
|
||||
default: STANDARD_ERROR
|
||||
}
|
||||
};
|
||||
|
||||
export const examEnvironmentGetExamAttempt = {
|
||||
params: Type.Object({
|
||||
attemptId: Type.String({ format: 'objectid' })
|
||||
}),
|
||||
headers: Type.Object({
|
||||
// Optional, because the handler is used in both the `/user/` base and `/exam-environment/` base.
|
||||
// If it is missing, auth will catch.
|
||||
'exam-environment-authorization-token': Type.Optional(Type.String())
|
||||
}),
|
||||
response: {
|
||||
200: examEnvAttempt,
|
||||
default: STANDARD_ERROR
|
||||
}
|
||||
};
|
||||
|
||||
export const examEnvironmentGetExamAttemptsByExamId = {
|
||||
params: Type.Object({
|
||||
examId: Type.String({ format: 'objectid' })
|
||||
}),
|
||||
headers: Type.Object({
|
||||
// Optional, because the handler is used in both the `/user/` base and `/exam-environment/` base.
|
||||
// If it is missing, auth will catch.
|
||||
'exam-environment-authorization-token': Type.Optional(Type.String())
|
||||
}),
|
||||
response: {
|
||||
200: Type.Array(examEnvAttempt)
|
||||
// default: STANDARD_ERROR
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
import { STANDARD_ERROR } from '../utils/errors.js';
|
||||
|
||||
export const examEnvironmentPostExamGeneratedExam = {
|
||||
body: Type.Object({
|
||||
examId: Type.String()
|
||||
}),
|
||||
headers: Type.Object({
|
||||
'exam-environment-authorization-token': Type.String()
|
||||
}),
|
||||
response: {
|
||||
200: Type.Object({
|
||||
exam: Type.Record(Type.String(), Type.Unknown()),
|
||||
examAttempt: Type.Record(Type.String(), Type.Unknown())
|
||||
}),
|
||||
403: STANDARD_ERROR,
|
||||
404: STANDARD_ERROR,
|
||||
429: STANDARD_ERROR,
|
||||
500: STANDARD_ERROR
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
import { STANDARD_ERROR } from '../utils/errors.js';
|
||||
export const examEnvironmentExams = {
|
||||
headers: Type.Object({
|
||||
'exam-environment-authorization-token': Type.Optional(Type.String())
|
||||
}),
|
||||
response: {
|
||||
200: Type.Array(
|
||||
Type.Object({
|
||||
id: Type.String(),
|
||||
config: Type.Object({
|
||||
name: Type.String(),
|
||||
note: Type.String(),
|
||||
totalTimeInS: Type.Number(),
|
||||
retakeTimeInS: Type.Number(),
|
||||
passingPercent: Type.Number()
|
||||
}),
|
||||
canTake: Type.Boolean(),
|
||||
prerequisites: Type.Array(Type.String())
|
||||
})
|
||||
),
|
||||
500: STANDARD_ERROR
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
examEnvironmentPostExamAttempt,
|
||||
examEnvironmentGetExamAttempts,
|
||||
examEnvironmentGetExamAttempt,
|
||||
examEnvironmentGetExamAttemptsByExamId
|
||||
} from './exam-environment-exam-attempt.js';
|
||||
export { examEnvironmentPostExamGeneratedExam } from './exam-environment-exam-generated-exam.js';
|
||||
export { examEnvironmentTokenMeta } from './token-meta.js';
|
||||
export { examEnvironmentExams } from './exam-environment-exams.js';
|
||||
export { examEnvironmentGetExamChallenge } from './challenges.js';
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
import { STANDARD_ERROR } from '../utils/errors.js';
|
||||
|
||||
export const examEnvironmentTokenMeta = {
|
||||
headers: Type.Object({
|
||||
'exam-environment-authorization-token': Type.String()
|
||||
}),
|
||||
response: {
|
||||
200: Type.Object({
|
||||
expireAt: Type.String({ format: 'date-time' })
|
||||
}),
|
||||
404: STANDARD_ERROR,
|
||||
418: STANDARD_ERROR
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { format } from 'util';
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
|
||||
export const ERRORS = {
|
||||
FCC_EINVAL_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN: createError(
|
||||
'FCC_EINVAL_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN',
|
||||
'%s'
|
||||
),
|
||||
FCC_EINVAL_EXAM_ENVIRONMENT_PREREQUISITES: createError(
|
||||
'FCC_EINVAL_EXAM_ENVIRONMENT_PREREQUISITES',
|
||||
'%s'
|
||||
),
|
||||
FCC_ENOENT_EXAM_ENVIRONMENT_MISSING_EXAM: createError(
|
||||
'FCC_ENOENT_EXAM_ENVIRONMENT_MISSING_EXAM',
|
||||
'%s'
|
||||
),
|
||||
FCC_ERR_EXAM_ENVIRONMENT_CREATE_EXAM_ATTEMPT: createError(
|
||||
'FCC_ERR_EXAM_ENVIRONMENT_CREATE_EXAM_ATTEMPT',
|
||||
'%s'
|
||||
),
|
||||
FCC_ERR_EXAM_ENVIRONMENT: createError('FCC_ERR_EXAM_ENVIRONMENT', '%s'),
|
||||
FCC_ENOENT_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN: createError(
|
||||
'FCC_ENOENT_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN',
|
||||
'%s'
|
||||
),
|
||||
FCC_EINVAL_EXAM_ENVIRONMENT_EXAM_ATTEMPT: createError(
|
||||
'FCC_EINVAL_EXAM_ENVIRONMENT_EXAM_ATTEMPT',
|
||||
'%s'
|
||||
),
|
||||
FCC_ENOENT_EXAM_ENVIRONMENT_EXAM_ATTEMPT: createError(
|
||||
'FCC_ENOENT_EXAM_ENVIRONMENT_EXAM_ATTEMPT',
|
||||
'%s'
|
||||
),
|
||||
FCC_ERR_EXAM_ENVIRONMENT_EXAM_ATTEMPT: createError(
|
||||
'FCC_ERR_EXAM_ENVIRONMENT_EXAM_ATTEMPT',
|
||||
'%s'
|
||||
),
|
||||
FCC_ENOENT_EXAM_ENVIRONMENT_GENERATED_EXAM: createError(
|
||||
'FCC_ENOENT_EXAM_ENVIRONMENT_GENERATED_EXAM',
|
||||
'%s'
|
||||
),
|
||||
FCC_EINVAL_EXAM_ID: createError('FCC_EINVAL_EXAM_ID', '%s'),
|
||||
FCC_ERR_UNKNOWN_STATE: createError('FCC_ERR_UNKNOWN_STATE', '%s')
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a function which optionally takes arguments to format an error message.
|
||||
* @param code - Identifier for the error.
|
||||
* @param message - Human-readable error message.
|
||||
* @returns Function which optionally takes arguments to format an error message.
|
||||
*/
|
||||
function createError(code: string, message: string) {
|
||||
return (...args: unknown[]) => {
|
||||
return {
|
||||
code,
|
||||
message: format(message, ...args)
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const STANDARD_ERROR = Type.Object({
|
||||
code: Type.String(),
|
||||
message: Type.String()
|
||||
});
|
||||
@@ -0,0 +1,440 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import type { MockInstance } from 'vitest';
|
||||
import {
|
||||
ExamEnvironmentAnswer,
|
||||
ExamEnvironmentQuestionType
|
||||
} from '@prisma/client';
|
||||
import { type Static } from '@fastify/type-provider-typebox';
|
||||
import {
|
||||
exam,
|
||||
examAttempt,
|
||||
generatedExam,
|
||||
oid
|
||||
} from '../../../__fixtures__/exam-environment-exam.js';
|
||||
import * as schemas from '../schemas/index.js';
|
||||
import { setupServer } from '../../../vitest.utils.js';
|
||||
import {
|
||||
checkAttemptAgainstGeneratedExam,
|
||||
checkPrerequisites,
|
||||
constructUserExam,
|
||||
userAttemptToDatabaseAttemptQuestionSets,
|
||||
validateAttempt,
|
||||
compareAnswers,
|
||||
shuffleArray
|
||||
} from './exam-environment.js';
|
||||
|
||||
// NOTE: Whilst the tests could be run against a single generation of exam,
|
||||
// it is more useful to run the tests against a new generation each time.
|
||||
// This helps ensure the config/logic is _reasonably_ likely to be able to
|
||||
// generate a valid exam.
|
||||
// Another option is to call `generateExam` hundreds of times in a loop test :shrug:
|
||||
describe('Exam Environment mocked Math.random', () => {
|
||||
let spy: MockInstance;
|
||||
beforeAll(() => {
|
||||
spy = vi.spyOn(Math, 'random').mockReturnValue(0.123456789);
|
||||
});
|
||||
afterAll(() => {
|
||||
spy.mockRestore();
|
||||
});
|
||||
describe('checkAttemptAgainstGeneratedExam()', () => {
|
||||
it('should return true if all questions are answered', () => {
|
||||
expect(
|
||||
checkAttemptAgainstGeneratedExam(
|
||||
examAttempt.questionSets,
|
||||
generatedExam
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if one or more questions are not answered', () => {
|
||||
const badExamAttempt = structuredClone(examAttempt);
|
||||
|
||||
badExamAttempt.questionSets[0]!.questions[0]!.answers = [];
|
||||
expect(
|
||||
checkAttemptAgainstGeneratedExam(
|
||||
badExamAttempt.questionSets,
|
||||
generatedExam
|
||||
)
|
||||
).toBe(false);
|
||||
|
||||
badExamAttempt.questionSets[0]!.questions[0]!.answers = ['bad-answer'];
|
||||
expect(
|
||||
checkAttemptAgainstGeneratedExam(
|
||||
badExamAttempt.questionSets,
|
||||
generatedExam
|
||||
)
|
||||
).toBe(false);
|
||||
|
||||
badExamAttempt.questionSets[0]!.questions = [];
|
||||
expect(
|
||||
checkAttemptAgainstGeneratedExam(
|
||||
badExamAttempt.questionSets,
|
||||
generatedExam
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkPrequisites()', () => {
|
||||
it("should return true if all items in the second argument exist in the first argument's `.completedChallenges[].id`", () => {
|
||||
const user = {
|
||||
completedChallenges: [{ id: '1' }, { id: '2' }],
|
||||
isHonest: true
|
||||
};
|
||||
const prerequisites = ['1', '2'];
|
||||
|
||||
expect(checkPrerequisites(user, prerequisites)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if any items in the second argument do not exist in the first argument's `.completedChallenges[].id`", () => {
|
||||
const user = {
|
||||
completedChallenges: [{ id: '2' }],
|
||||
isHonest: false
|
||||
};
|
||||
const prerequisites = ['1', '2'];
|
||||
|
||||
expect(checkPrerequisites(user, prerequisites)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAttempt()', () => {
|
||||
it('should validate a correct attempt', () => {
|
||||
expect(() =>
|
||||
validateAttempt(generatedExam, examAttempt.questionSets)
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('should invalidate an incorrect attempt', () => {
|
||||
const badExamAttempt = structuredClone(examAttempt);
|
||||
badExamAttempt.questionSets[0]!.questions[0]!.answers = ['bad-answer'];
|
||||
expect(() =>
|
||||
validateAttempt(generatedExam, badExamAttempt.questionSets)
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('userAttemptToDatabaseAttemptQuestionSets()', () => {
|
||||
it('should add submission time to all questions', () => {
|
||||
const userAttempt: Static<
|
||||
typeof schemas.examEnvironmentPostExamAttempt.body.properties.attempt
|
||||
> = {
|
||||
examId: '0',
|
||||
questionSets: [
|
||||
{
|
||||
id: '0',
|
||||
questions: [{ id: '00', answers: ['000'] }]
|
||||
},
|
||||
{
|
||||
id: '1',
|
||||
questions: [{ id: '10', answers: ['100'] }]
|
||||
}
|
||||
]
|
||||
};
|
||||
const latestAttempt = structuredClone(examAttempt);
|
||||
latestAttempt.questionSets = [];
|
||||
|
||||
const databaseAttemptQuestionSets =
|
||||
userAttemptToDatabaseAttemptQuestionSets(userAttempt, latestAttempt);
|
||||
|
||||
const allQuestions = databaseAttemptQuestionSets.flatMap(
|
||||
qs => qs.questions
|
||||
);
|
||||
expect(allQuestions.every(q => q.submissionTime)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not change the submission time of any questions that have not changed', () => {
|
||||
const userAttempt: Static<
|
||||
typeof schemas.examEnvironmentPostExamAttempt.body.properties.attempt
|
||||
> = {
|
||||
examId: '0',
|
||||
questionSets: [
|
||||
{
|
||||
id: '0',
|
||||
questions: [{ id: '00', answers: ['000'] }]
|
||||
},
|
||||
{
|
||||
id: '1',
|
||||
questions: [{ id: '10', answers: ['100'] }]
|
||||
}
|
||||
]
|
||||
};
|
||||
const latestAttempt = structuredClone(examAttempt);
|
||||
|
||||
const databaseAttemptQuestionSets =
|
||||
userAttemptToDatabaseAttemptQuestionSets(userAttempt, latestAttempt);
|
||||
|
||||
const submissionTimes = databaseAttemptQuestionSets.flatMap(qs =>
|
||||
qs.questions.map(q => q.submissionTime)
|
||||
);
|
||||
|
||||
const sameAttempt = userAttemptToDatabaseAttemptQuestionSets(
|
||||
userAttempt,
|
||||
{ ...latestAttempt, questionSets: databaseAttemptQuestionSets }
|
||||
);
|
||||
|
||||
const sameSubmissionTimes = sameAttempt.flatMap(qs =>
|
||||
qs.questions.map(q => q.submissionTime)
|
||||
);
|
||||
|
||||
expect(submissionTimes).toEqual(sameSubmissionTimes);
|
||||
});
|
||||
|
||||
it('should change all submission times of questions that have changed', async () => {
|
||||
const userAttempt: Static<
|
||||
typeof schemas.examEnvironmentPostExamAttempt.body.properties.attempt
|
||||
> = {
|
||||
examId: '0',
|
||||
questionSets: [
|
||||
{
|
||||
id: '0',
|
||||
questions: [{ id: '00', answers: ['000'] }]
|
||||
},
|
||||
{
|
||||
id: '1',
|
||||
questions: [{ id: '10', answers: ['100'] }]
|
||||
}
|
||||
]
|
||||
};
|
||||
const latestAttempt = structuredClone(examAttempt);
|
||||
|
||||
const databaseAttemptQuestionSets =
|
||||
userAttemptToDatabaseAttemptQuestionSets(userAttempt, latestAttempt);
|
||||
userAttempt.questionSets[0]!.questions[0]!.answers = ['001'];
|
||||
|
||||
// The `userAttemptToDatabaseAttemptQuestionSets` function uses `Date.now()`
|
||||
// to set the submission time, so we need to wait a bit to ensure differences.
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
const newAttemptQuestionSets = userAttemptToDatabaseAttemptQuestionSets(
|
||||
userAttempt,
|
||||
{
|
||||
...latestAttempt,
|
||||
questionSets: databaseAttemptQuestionSets
|
||||
}
|
||||
);
|
||||
|
||||
expect(
|
||||
newAttemptQuestionSets[0]?.questions[0]?.submissionTime
|
||||
).not.toEqual(
|
||||
databaseAttemptQuestionSets[0]?.questions[0]?.submissionTime
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareAnswers()', () => {
|
||||
it('should return true when only all correct answers are attempted', () => {
|
||||
const examAnswers: ExamEnvironmentAnswer[] = [
|
||||
{
|
||||
id: '0',
|
||||
isCorrect: true,
|
||||
text: ''
|
||||
},
|
||||
{
|
||||
id: '1',
|
||||
isCorrect: true,
|
||||
text: ''
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
isCorrect: false,
|
||||
text: ''
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
isCorrect: false,
|
||||
text: ''
|
||||
}
|
||||
];
|
||||
const generatedAnswers = ['0', '1', '2', '3'];
|
||||
const attemptAnswers = ['0', '1'];
|
||||
const isCorrect = compareAnswers(
|
||||
examAnswers,
|
||||
generatedAnswers,
|
||||
attemptAnswers
|
||||
);
|
||||
|
||||
expect(isCorrect).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when any incorrect answers are attempted', () => {
|
||||
const examAnswers: ExamEnvironmentAnswer[] = [
|
||||
{
|
||||
id: '0',
|
||||
isCorrect: true,
|
||||
text: ''
|
||||
},
|
||||
{
|
||||
id: '1',
|
||||
isCorrect: true,
|
||||
text: ''
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
isCorrect: false,
|
||||
text: ''
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
isCorrect: false,
|
||||
text: ''
|
||||
}
|
||||
];
|
||||
const generatedAnswers = ['0', '1', '2', '3'];
|
||||
const attemptAnswers = ['0', '2'];
|
||||
const isCorrect = compareAnswers(
|
||||
examAnswers,
|
||||
generatedAnswers,
|
||||
attemptAnswers
|
||||
);
|
||||
|
||||
expect(isCorrect).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Exam Environment', () => {
|
||||
describe('constructUserExam()', () => {
|
||||
it('should not provide the answers', () => {
|
||||
const userExam = constructUserExam(generatedExam, exam);
|
||||
expect(userExam).not.toHaveProperty('answers.isCorrect');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shuffleArray()', () => {
|
||||
it('reasonably shuffles an array', () => {
|
||||
const unshuff = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
const shuff = shuffleArray(unshuff);
|
||||
|
||||
expect(shuff).not.toEqual(unshuff);
|
||||
});
|
||||
|
||||
it('does not mutate the input', () => {
|
||||
const unshuff = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
shuffleArray(unshuff);
|
||||
|
||||
expect(unshuff).toEqual(unshuff);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Exam Environment Schema', () => {
|
||||
setupServer();
|
||||
describe('ExamEnvironmentExam', () => {
|
||||
afterAll(async () => {
|
||||
await fastifyTestInstance.prisma.examEnvironmentExam.deleteMany({});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line vitest/expect-expect
|
||||
it("If this test fails and you've deliberately altered the schema, then increment the `version` field by 1", async () => {
|
||||
const configQuestionSets = [
|
||||
{
|
||||
numberOfCorrectAnswers: 0,
|
||||
numberOfIncorrectAnswers: 0,
|
||||
numberOfQuestions: 0,
|
||||
numberOfSet: 0,
|
||||
type: ExamEnvironmentQuestionType.MultipleChoice
|
||||
}
|
||||
];
|
||||
const tags = [
|
||||
{
|
||||
group: [''],
|
||||
numberOfQuestions: 0
|
||||
}
|
||||
];
|
||||
const config = {
|
||||
name: '',
|
||||
note: '',
|
||||
passingPercent: 0.0,
|
||||
questionSets: configQuestionSets,
|
||||
retakeTimeInS: 0,
|
||||
tags,
|
||||
totalTimeInS: 0
|
||||
};
|
||||
|
||||
const questions = [
|
||||
{
|
||||
answers: [
|
||||
{
|
||||
id: oid(),
|
||||
isCorrect: false,
|
||||
text: ''
|
||||
}
|
||||
],
|
||||
audio: { captions: '', url: '' },
|
||||
deprecated: false,
|
||||
id: oid(),
|
||||
tags: [''],
|
||||
text: ''
|
||||
}
|
||||
];
|
||||
const questionSets = [
|
||||
{
|
||||
context: '',
|
||||
id: oid(),
|
||||
questions,
|
||||
type: ExamEnvironmentQuestionType.MultipleChoice
|
||||
}
|
||||
];
|
||||
const data = {
|
||||
config,
|
||||
deprecated: false,
|
||||
prerequisites: [oid()],
|
||||
questionSets
|
||||
};
|
||||
|
||||
await fastifyTestInstance.prisma.examEnvironmentExam.create({
|
||||
data
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('ExamEnvironmentGeneratedExam', () => {
|
||||
afterAll(async () => {
|
||||
await fastifyTestInstance.prisma.examEnvironmentGeneratedExam.deleteMany(
|
||||
{}
|
||||
);
|
||||
});
|
||||
// eslint-disable-next-line vitest/expect-expect
|
||||
it("If this test fails and you've deliberately altered the schema, then increment the `version` field by 1", async () => {
|
||||
await fastifyTestInstance.prisma.examEnvironmentGeneratedExam.create({
|
||||
data: {
|
||||
deprecated: false,
|
||||
examId: oid(),
|
||||
questionSets: [
|
||||
{ id: oid(), questions: [{ answers: [oid()], id: oid() }] }
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('ExamEnvironmentExamAttempt', () => {
|
||||
afterAll(async () => {
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.deleteMany(
|
||||
{}
|
||||
);
|
||||
});
|
||||
// eslint-disable-next-line vitest/expect-expect
|
||||
it("If this test fails and you've deliberately altered the schema, then increment the `version` field by 1", async () => {
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: {
|
||||
examId: oid(),
|
||||
generatedExamId: oid(),
|
||||
examModerationId: null,
|
||||
questionSets: [
|
||||
{
|
||||
id: oid(),
|
||||
questions: [
|
||||
{
|
||||
answers: [oid()],
|
||||
id: oid(),
|
||||
submissionTime: new Date()
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
startTime: new Date(),
|
||||
userId: oid()
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,627 @@
|
||||
/* eslint-disable jsdoc/require-description-complete-sentence */
|
||||
// TODO: enable this, since strings don't make good errors.
|
||||
import {
|
||||
ExamEnvironmentAnswer,
|
||||
ExamEnvironmentExam,
|
||||
ExamEnvironmentExamAttempt,
|
||||
ExamEnvironmentExamModerationStatus,
|
||||
ExamEnvironmentGeneratedExam,
|
||||
ExamEnvironmentGeneratedMultipleChoiceQuestion,
|
||||
ExamEnvironmentMultipleChoiceQuestion,
|
||||
ExamEnvironmentMultipleChoiceQuestionAttempt,
|
||||
ExamEnvironmentQuestionSet,
|
||||
ExamEnvironmentQuestionSetAttempt
|
||||
} from '@prisma/client';
|
||||
import type { FastifyBaseLogger, FastifyInstance } from 'fastify';
|
||||
import { type Static } from '@fastify/type-provider-typebox';
|
||||
import { omit } from 'lodash-es';
|
||||
import * as schemas from '../schemas/index.js';
|
||||
import { mapErr } from '../../utils/index.js';
|
||||
import { ExamAttemptStatus } from '../schemas/exam-environment-exam-attempt.js';
|
||||
import { ERRORS } from './errors.js';
|
||||
|
||||
interface PartialUser {
|
||||
completedChallenges: { id: string }[];
|
||||
isHonest: boolean | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if all exam prerequisites have been met by the user:
|
||||
* - completed challenges linked to exam
|
||||
* - user is required to have accepted the academic honesty policy
|
||||
*/
|
||||
export function checkPrerequisites(
|
||||
user: PartialUser,
|
||||
prerequisites: ExamEnvironmentExam['prerequisites']
|
||||
) {
|
||||
return (
|
||||
user.isHonest &&
|
||||
prerequisites.every(p => user.completedChallenges.some(c => c.id === p))
|
||||
);
|
||||
}
|
||||
|
||||
export type UserExam = Omit<
|
||||
ExamEnvironmentExam,
|
||||
'questionSets' | 'config' | 'id' | 'prerequisites' | 'deprecated' | 'version'
|
||||
> & {
|
||||
config: Omit<ExamEnvironmentExam['config'], 'tags' | 'questionSets'>;
|
||||
questionSets: (Omit<ExamEnvironmentQuestionSet, 'questions'> & {
|
||||
questions: (Omit<
|
||||
ExamEnvironmentMultipleChoiceQuestion,
|
||||
'answers' | 'tags' | 'deprecated'
|
||||
> & {
|
||||
answers: Omit<ExamEnvironmentAnswer, 'isCorrect'>[];
|
||||
})[];
|
||||
})[];
|
||||
} & { generatedExamId: string; examId: string };
|
||||
|
||||
/**
|
||||
* Takes the generated exam and the original exam, and creates the user-facing exam.
|
||||
*/
|
||||
export function constructUserExam(
|
||||
generatedExam: ExamEnvironmentGeneratedExam,
|
||||
exam: ExamEnvironmentExam
|
||||
): UserExam {
|
||||
// Map generated exam to user exam (a.k.a. public exam information for user)
|
||||
const userQuestionSets = generatedExam.questionSets.map(gqs => {
|
||||
// Get matching question from `exam`, but remove `is_correct` from `exam.questions[].answers[]`
|
||||
const examQuestionSet = exam.questionSets.find(eqs => eqs.id === gqs.id);
|
||||
if (!examQuestionSet) {
|
||||
throw new Error(
|
||||
`Unreachable. Generated question set id ${gqs.id} not found in exam ${exam.id}.`
|
||||
);
|
||||
}
|
||||
|
||||
const { questions } = examQuestionSet;
|
||||
|
||||
const userQuestions = gqs.questions.map(gq => {
|
||||
const examQuestion = questions.find(eq => eq.id === gq.id);
|
||||
if (!examQuestion) {
|
||||
throw new Error(
|
||||
`Unreachable. Generated question id ${gq.id} not found in exam question set ${examQuestionSet.id}.`
|
||||
);
|
||||
}
|
||||
|
||||
// Remove `isCorrect` from question answers
|
||||
const answers = gq.answers.map(generatedAnswerId => {
|
||||
const examAnswer = examQuestion.answers.find(
|
||||
ea => ea.id === generatedAnswerId
|
||||
);
|
||||
if (!examAnswer) {
|
||||
throw new Error(
|
||||
`Unreachable. Generated answer id ${generatedAnswerId} not found in exam question ${examQuestion.id}.`
|
||||
);
|
||||
}
|
||||
|
||||
const { isCorrect: _, ...answer } = examAnswer;
|
||||
return answer;
|
||||
});
|
||||
|
||||
// NOTE: Shuffling here means when saved attempt is re-fetched, answers will be in different order.
|
||||
const shuffledAnswers = shuffleArray(answers);
|
||||
|
||||
return {
|
||||
id: examQuestion.id,
|
||||
audio: examQuestion.audio,
|
||||
text: examQuestion.text,
|
||||
answers: shuffledAnswers
|
||||
};
|
||||
});
|
||||
|
||||
const userQuestionSet = {
|
||||
type: examQuestionSet.type,
|
||||
questions: userQuestions,
|
||||
id: examQuestionSet.id,
|
||||
context: examQuestionSet.context
|
||||
};
|
||||
return userQuestionSet;
|
||||
});
|
||||
|
||||
// Order questionSets in same order as original exam
|
||||
const orderedUserQuestionSets = userQuestionSets.sort((a, b) => {
|
||||
return (
|
||||
exam.questionSets.findIndex(qs => qs.id === a.id) -
|
||||
exam.questionSets.findIndex(qs => qs.id === b.id)
|
||||
);
|
||||
});
|
||||
|
||||
const config = {
|
||||
totalTimeInS: exam.config.totalTimeInS,
|
||||
name: exam.config.name,
|
||||
note: exam.config.note,
|
||||
retakeTimeInS: exam.config.retakeTimeInS,
|
||||
passingPercent: exam.config.passingPercent
|
||||
};
|
||||
|
||||
const userExam: UserExam = {
|
||||
examId: exam.id,
|
||||
generatedExamId: generatedExam.id,
|
||||
config,
|
||||
questionSets: orderedUserQuestionSets
|
||||
};
|
||||
|
||||
return userExam;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures all questions and answers in the attempt are from the generated exam.
|
||||
*/
|
||||
export function validateAttempt(
|
||||
generatedExam: ExamEnvironmentGeneratedExam,
|
||||
questionSets: ExamEnvironmentExamAttempt['questionSets']
|
||||
) {
|
||||
for (const attemptQuestionSet of questionSets) {
|
||||
const generatedQuestionSet = generatedExam.questionSets.find(
|
||||
qt => qt.id === attemptQuestionSet.id
|
||||
);
|
||||
if (!generatedQuestionSet) {
|
||||
throw new Error(
|
||||
`Question type ${attemptQuestionSet.id} not found in generated exam.`
|
||||
);
|
||||
}
|
||||
|
||||
for (const attemptQuestion of attemptQuestionSet.questions) {
|
||||
const generatedQuestion = generatedQuestionSet.questions.find(
|
||||
q => q.id === attemptQuestion.id
|
||||
);
|
||||
if (!generatedQuestion) {
|
||||
throw new Error(
|
||||
`Question ${attemptQuestion.id} not found in generated exam.`
|
||||
);
|
||||
}
|
||||
|
||||
for (const attemptAnswer of attemptQuestion.answers) {
|
||||
const generatedAnswer = generatedQuestion.answers.find(
|
||||
a => a === attemptAnswer
|
||||
);
|
||||
if (!generatedAnswer) {
|
||||
throw new Error(
|
||||
`Answer ${attemptAnswer} not found in generated exam.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks all question sets and questions in the generated exam are in the attempt.
|
||||
*
|
||||
* TODO: Consider throwing with specific issue.
|
||||
*
|
||||
* @param questionSets An exam attempt.
|
||||
* @param generatedExam The corresponding generated exam.
|
||||
* @returns Whether or not the attempt can be considered finished.
|
||||
*/
|
||||
export function checkAttemptAgainstGeneratedExam(
|
||||
questionSets: ExamEnvironmentQuestionSetAttempt[],
|
||||
generatedExam: Pick<ExamEnvironmentGeneratedExam, 'questionSets'>
|
||||
): boolean {
|
||||
// Check all question sets and questions are in generated exam
|
||||
for (const generatedQuestionSet of generatedExam.questionSets) {
|
||||
const attemptQuestionSet = questionSets.find(
|
||||
q => q.id === generatedQuestionSet.id
|
||||
);
|
||||
if (!attemptQuestionSet) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const generatedQuestion of generatedQuestionSet.questions) {
|
||||
const attemptQuestion = attemptQuestionSet.questions.find(
|
||||
q => q.id === generatedQuestion.id
|
||||
);
|
||||
if (!attemptQuestion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const atLeastOneAnswer = attemptQuestion.answers.length > 0;
|
||||
if (!atLeastOneAnswer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// All answers in attempt must be in generated exam
|
||||
const allAnswersInGeneratedExam = attemptQuestion.answers.every(a =>
|
||||
generatedQuestion.answers.includes(a)
|
||||
);
|
||||
if (!allAnswersInGeneratedExam) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the current time submission time to all questions in the attempt if the question answer has changed.
|
||||
*/
|
||||
export function userAttemptToDatabaseAttemptQuestionSets(
|
||||
userAttempt: Static<
|
||||
typeof schemas.examEnvironmentPostExamAttempt.body.properties.attempt
|
||||
>,
|
||||
latestAttempt: ExamEnvironmentExamAttempt
|
||||
): ExamEnvironmentExamAttempt['questionSets'] {
|
||||
const databaseAttemptQuestionSets: ExamEnvironmentExamAttempt['questionSets'] =
|
||||
[];
|
||||
|
||||
for (const questionSet of userAttempt.questionSets) {
|
||||
const latestQuestionSet = latestAttempt.questionSets.find(
|
||||
qs => qs.id === questionSet.id
|
||||
);
|
||||
|
||||
// If no latest attempt, add submission time to all questions
|
||||
if (!latestQuestionSet) {
|
||||
databaseAttemptQuestionSets.push({
|
||||
...questionSet,
|
||||
questions: questionSet.questions.map(q => {
|
||||
return {
|
||||
...q,
|
||||
submissionTime: new Date()
|
||||
};
|
||||
})
|
||||
});
|
||||
} else {
|
||||
const databaseAttemptQuestionSet = {
|
||||
...questionSet,
|
||||
questions: questionSet.questions.map(q => {
|
||||
const latestQuestion = latestQuestionSet.questions.find(
|
||||
lq => lq.id === q.id
|
||||
);
|
||||
|
||||
// If no latest question, add submission time
|
||||
if (!latestQuestion) {
|
||||
return {
|
||||
...q,
|
||||
submissionTime: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
// If answers have changed, add submission time
|
||||
if (
|
||||
JSON.stringify(q.answers) !== JSON.stringify(latestQuestion.answers)
|
||||
) {
|
||||
return {
|
||||
...q,
|
||||
submissionTime: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
return latestQuestion;
|
||||
})
|
||||
};
|
||||
|
||||
databaseAttemptQuestionSets.push(databaseAttemptQuestionSet);
|
||||
}
|
||||
}
|
||||
|
||||
return databaseAttemptQuestionSets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the number of correct questions over the number of the total questions given for an attempt.
|
||||
* @returns The score of the exam attempt as a percentage.
|
||||
*/
|
||||
export function calculateScore(
|
||||
exam: ExamEnvironmentExam,
|
||||
generatedExam: ExamEnvironmentGeneratedExam,
|
||||
attempt: ExamEnvironmentExamAttempt
|
||||
) {
|
||||
const attemptQuestionSets = attempt.questionSets;
|
||||
const generatedQuestionSets = generatedExam.questionSets;
|
||||
|
||||
const totalQuestions = generatedQuestionSets.reduce(
|
||||
(total, attemptQuestionSet) => total + attemptQuestionSet.questions.length,
|
||||
0
|
||||
);
|
||||
let correctQuestions = 0;
|
||||
for (const attemptQuestionSet of attemptQuestionSets) {
|
||||
const examQuestionSet = exam.questionSets.find(
|
||||
({ id }) => id === attemptQuestionSet.id
|
||||
);
|
||||
if (!examQuestionSet) {
|
||||
throw new Error(
|
||||
`Attempt question set ${attemptQuestionSet.id} must exist in exam ${exam.id}`
|
||||
);
|
||||
}
|
||||
|
||||
const generatedQuestionSet = generatedQuestionSets.find(
|
||||
({ id }) => id === attemptQuestionSet.id
|
||||
);
|
||||
if (!generatedQuestionSet) {
|
||||
throw new Error(
|
||||
`Generated question set ${attemptQuestionSet.id} must exist in generated exam ${generatedExam.id}`
|
||||
);
|
||||
}
|
||||
|
||||
const attemptQuestions = attemptQuestionSet.questions;
|
||||
const examQuestions = examQuestionSet.questions;
|
||||
const generatedQuestions = generatedQuestionSet.questions;
|
||||
for (const attemptQuestion of attemptQuestions) {
|
||||
const examQuestion = examQuestions.find(
|
||||
({ id }) => id === attemptQuestion.id
|
||||
);
|
||||
if (!examQuestion) {
|
||||
throw new Error(
|
||||
`Attempt question ${attemptQuestion.id} must exist in exam ${exam.id}`
|
||||
);
|
||||
}
|
||||
|
||||
const generatedQuestion = generatedQuestions.find(
|
||||
({ id }) => id === attemptQuestion.id
|
||||
);
|
||||
if (!generatedQuestion) {
|
||||
throw new Error(
|
||||
`Generated question ${attemptQuestion.id} must exist in generated exam ${generatedExam.id}`
|
||||
);
|
||||
}
|
||||
|
||||
const isQuestionCorrect = compareAnswers(
|
||||
examQuestion.answers,
|
||||
generatedQuestion.answers,
|
||||
attemptQuestion.answers
|
||||
);
|
||||
|
||||
if (isQuestionCorrect) {
|
||||
correctQuestions += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (correctQuestions / totalQuestions) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* NOTE: The answers of an attempt is an array for future-proofing when
|
||||
* checkbox questions are needed.
|
||||
*
|
||||
* This calculation takes x / y , x < y as wholey incorrect.
|
||||
*/
|
||||
export function compareAnswers(
|
||||
examAnswers: ExamEnvironmentAnswer[],
|
||||
generatedAnswers: ExamEnvironmentGeneratedMultipleChoiceQuestion['answers'],
|
||||
attemptAnswers: ExamEnvironmentMultipleChoiceQuestionAttempt['answers']
|
||||
): boolean {
|
||||
const correctGeneratedAnswers = generatedAnswers.filter(generatedAnswer => {
|
||||
return examAnswers.some(
|
||||
examAnswer => examAnswer.isCorrect && examAnswer.id === generatedAnswer
|
||||
);
|
||||
});
|
||||
// Check every attempt question answer == every generated question answer
|
||||
const isQuestionCorrect =
|
||||
correctGeneratedAnswers.every(correctAnswer =>
|
||||
attemptAnswers.includes(correctAnswer)
|
||||
) && correctGeneratedAnswers.length == attemptAnswers.length;
|
||||
|
||||
return isQuestionCorrect;
|
||||
}
|
||||
|
||||
/* eslint-disable jsdoc/require-description-complete-sentence */
|
||||
/**
|
||||
* Shuffles an array using the Fisher-Yates algorithm.
|
||||
*
|
||||
* https://bost.ocks.org/mike/shuffle/
|
||||
*/
|
||||
export function shuffleArray<T>(array: Array<T>) {
|
||||
const arr = structuredClone(array);
|
||||
let m = arr.length;
|
||||
let t;
|
||||
let i;
|
||||
|
||||
// While there remain elements to shuffle…
|
||||
while (m) {
|
||||
// Pick a remaining element…
|
||||
i = Math.floor(Math.random() * m--);
|
||||
|
||||
// And swap it with the current element.
|
||||
t = arr[m]!;
|
||||
arr[m] = arr[i]!;
|
||||
arr[i] = t;
|
||||
}
|
||||
|
||||
return arr;
|
||||
}
|
||||
/* eslint-enable jsdoc/require-description-complete-sentence */
|
||||
|
||||
/**
|
||||
* From an exam attempt, construct the attempt with result (if ready).
|
||||
*
|
||||
* @param fastify - Fastify instance.
|
||||
* @param attempt - The exam attempt.
|
||||
* @param logger - Logger instance.
|
||||
* @returns The exam attempt with result or an error.
|
||||
*/
|
||||
export async function constructEnvExamAttempt(
|
||||
fastify: FastifyInstance,
|
||||
attempt: ExamEnvironmentExamAttempt,
|
||||
logger: FastifyBaseLogger
|
||||
) {
|
||||
const maybeExam = await mapErr(
|
||||
fastify.prisma.examEnvironmentExam.findUnique({
|
||||
where: {
|
||||
id: attempt.examId
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeExam.hasError) {
|
||||
fastify.Sentry?.captureException(maybeExam.error);
|
||||
logger.error(
|
||||
{ err: maybeExam.error, attemptId: attempt.id, examId: attempt.examId },
|
||||
'Unable to query exam.'
|
||||
);
|
||||
return {
|
||||
error: {
|
||||
code: 500,
|
||||
data: ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeExam.error))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const exam = maybeExam.data;
|
||||
|
||||
if (exam === null) {
|
||||
fastify.Sentry?.captureException({
|
||||
data: { examId: attempt.examId, attemptId: attempt.id },
|
||||
message: 'Unreachable. Invalid exam id in attempt.'
|
||||
});
|
||||
logger.error(
|
||||
{ examId: attempt.examId, attemptId: attempt.id },
|
||||
'Unreachable. Invalid exam id in attempt.'
|
||||
);
|
||||
|
||||
return {
|
||||
error: {
|
||||
code: 500,
|
||||
data: ERRORS.FCC_ENOENT_EXAM_ENVIRONMENT_MISSING_EXAM(
|
||||
'Unreachable. Invalid exam id in attempt.'
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// If attempt is still in progress, return without result
|
||||
const attemptStartTimeInMS = attempt.startTime.getTime();
|
||||
const examTotalTimeInMS = exam.config.totalTimeInS * 1000;
|
||||
const isAttemptExpired =
|
||||
attemptStartTimeInMS + examTotalTimeInMS < Date.now();
|
||||
if (!isAttemptExpired) {
|
||||
return {
|
||||
examEnvironmentExamAttempt: {
|
||||
...omitAttemptReferenceIds(attempt),
|
||||
result: null,
|
||||
status: ExamAttemptStatus.InProgress
|
||||
},
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
const maybeMod = await mapErr(
|
||||
fastify.prisma.examEnvironmentExamModeration.findFirst({
|
||||
where: {
|
||||
examAttemptId: attempt.id
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeMod.hasError) {
|
||||
fastify.Sentry?.captureException(maybeMod.error);
|
||||
logger.error(
|
||||
{ err: maybeMod.error, attemptId: attempt.id },
|
||||
'Unable to query exam moderation.'
|
||||
);
|
||||
return {
|
||||
error: {
|
||||
code: 500,
|
||||
data: ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeMod.error))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const moderation = maybeMod.data;
|
||||
|
||||
// Attempt has expired, but moderation record does not exist
|
||||
if (moderation === null) {
|
||||
return {
|
||||
examEnvironmentExamAttempt: {
|
||||
...omitAttemptReferenceIds(attempt),
|
||||
result: null,
|
||||
status: ExamAttemptStatus.Expired
|
||||
},
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
// If attempt is completed, but has not been graded, return without result
|
||||
if (moderation.status === ExamEnvironmentExamModerationStatus.Pending) {
|
||||
return {
|
||||
examEnvironmentExamAttempt: {
|
||||
...omitAttemptReferenceIds(attempt),
|
||||
result: null,
|
||||
status: ExamAttemptStatus.PendingModeration
|
||||
},
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
// If attempt is completed, but has been determined to need a retake
|
||||
// TODO: Send moderation.feedback?
|
||||
if (moderation.status === ExamEnvironmentExamModerationStatus.Denied) {
|
||||
return {
|
||||
examEnvironmentExamAttempt: {
|
||||
...omitAttemptReferenceIds(attempt),
|
||||
result: null,
|
||||
status: ExamAttemptStatus.Denied
|
||||
},
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
const maybeGeneratedExam = await mapErr(
|
||||
fastify.prisma.examEnvironmentGeneratedExam.findUnique({
|
||||
where: {
|
||||
id: attempt.generatedExamId
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeGeneratedExam.hasError) {
|
||||
fastify.Sentry?.captureException(maybeGeneratedExam.error);
|
||||
logger.error(
|
||||
{ err: maybeGeneratedExam.error, attemptId: attempt.id },
|
||||
'Unable to query generated exam.'
|
||||
);
|
||||
return {
|
||||
error: {
|
||||
code: 500,
|
||||
data: ERRORS.FCC_ERR_EXAM_ENVIRONMENT(
|
||||
JSON.stringify(maybeGeneratedExam.error)
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const generatedExam = maybeGeneratedExam.data;
|
||||
|
||||
if (!generatedExam) {
|
||||
fastify.Sentry?.captureException({
|
||||
data: {
|
||||
attemptId: attempt.id,
|
||||
generatedExamId: attempt.generatedExamId
|
||||
},
|
||||
message:
|
||||
'Unreachable. Unable to find generated exam associated with exam attempt'
|
||||
});
|
||||
logger.error(
|
||||
{ attemptId: attempt.id, generatedExamId: attempt.generatedExamId },
|
||||
'Unreachable. Unable to find generated exam associated with exam attempt.'
|
||||
);
|
||||
return {
|
||||
error: {
|
||||
code: 500,
|
||||
data: ERRORS.FCC_ERR_EXAM_ENVIRONMENT(
|
||||
'Unreachable. Unable to find generated exam associated with exam attempt'
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const score = calculateScore(exam, generatedExam, attempt);
|
||||
|
||||
const result = {
|
||||
score,
|
||||
passingPercent: exam.config.passingPercent
|
||||
};
|
||||
|
||||
const examEnvironmentExamAttempt = {
|
||||
...omitAttemptReferenceIds(attempt),
|
||||
result,
|
||||
status: ExamAttemptStatus.Approved
|
||||
};
|
||||
return { error: null, examEnvironmentExamAttempt };
|
||||
}
|
||||
|
||||
function omitAttemptReferenceIds(attempt: ExamEnvironmentExamAttempt) {
|
||||
return omit(attempt, ['generatedExamId', 'userId']);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { nodeProfilingIntegration } from '@sentry/profiling-node';
|
||||
|
||||
import {
|
||||
DEPLOYMENT_VERSION,
|
||||
SENTRY_DSN,
|
||||
SENTRY_ENVIRONMENT,
|
||||
SENTRY_SERVER_NAME,
|
||||
SENTRY_LOGS_DEBUG_SAMPLE_RATE,
|
||||
SENTRY_LOGS_INFO_SAMPLE_RATE,
|
||||
SENTRY_PROFILE_SESSION_SAMPLE_RATE,
|
||||
SENTRY_TRACES_SAMPLE_RATE
|
||||
} from './utils/env.js';
|
||||
import {
|
||||
makeShouldSendLog,
|
||||
makeTracesSampler,
|
||||
scrubRedundantLogAttributes,
|
||||
scrubRequestPii
|
||||
} from './utils/sentry.js';
|
||||
|
||||
const shouldSendLog = makeShouldSendLog(
|
||||
SENTRY_LOGS_DEBUG_SAMPLE_RATE,
|
||||
SENTRY_LOGS_INFO_SAMPLE_RATE
|
||||
);
|
||||
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
environment: SENTRY_ENVIRONMENT,
|
||||
serverName: SENTRY_SERVER_NAME,
|
||||
maxValueLength: 8192, // the default is 250, which is too small.
|
||||
release: DEPLOYMENT_VERSION,
|
||||
tracesSampler: makeTracesSampler(SENTRY_TRACES_SAMPLE_RATE),
|
||||
profileSessionSampleRate: SENTRY_PROFILE_SESSION_SAMPLE_RATE,
|
||||
profileLifecycle: 'trace',
|
||||
enableLogs: true,
|
||||
integrations: [
|
||||
nodeProfilingIntegration(),
|
||||
Sentry.pinoIntegration({
|
||||
log: { levels: ['info', 'warn', 'error', 'fatal', 'debug'] }
|
||||
}),
|
||||
Sentry.requestDataIntegration({ include: { cookies: false } })
|
||||
],
|
||||
beforeSend: event => scrubRequestPii(event),
|
||||
beforeSendLog: log =>
|
||||
shouldSendLog(log) ? scrubRedundantLogAttributes(log) : null
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { expect } from 'vitest';
|
||||
|
||||
import { nanoidCharSet } from '../../utils/create-user.js';
|
||||
|
||||
const uuidRe = /^[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/;
|
||||
const fccUuidRe = /^fcc-[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/;
|
||||
const unsubscribeIdRe = new RegExp(`^[${nanoidCharSet}]{21}$`);
|
||||
const mongodbIdRe = /^[a-f0-9]{24}$/;
|
||||
|
||||
// eslint-disable-next-line jsdoc/require-jsdoc
|
||||
export const newUser = (email: string) => ({
|
||||
about: '',
|
||||
acceptedPrivacyTerms: false,
|
||||
completedChallenges: [],
|
||||
completedDailyCodingChallenges: [],
|
||||
completedExams: [],
|
||||
quizAttempts: [],
|
||||
currentChallengeId: '',
|
||||
donationEmails: [],
|
||||
email,
|
||||
emailAuthLinkTTL: null,
|
||||
emailVerified: true,
|
||||
emailVerifyTTL: null,
|
||||
experience: [],
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
externalId: expect.stringMatching(uuidRe),
|
||||
githubProfile: null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
id: expect.stringMatching(mongodbIdRe),
|
||||
is2018DataVisCert: false,
|
||||
is2018FullStackCert: false,
|
||||
isA2EnglishCert: false,
|
||||
isApisMicroservicesCert: false,
|
||||
isBackEndCert: false,
|
||||
isBanned: false,
|
||||
isCheater: false,
|
||||
isClassroomAccount: null,
|
||||
isDataAnalysisPyCertV7: false,
|
||||
isDataVisCert: false,
|
||||
isDonating: false,
|
||||
isFoundationalCSharpCertV8: false,
|
||||
isFrontEndCert: false,
|
||||
isFrontEndLibsCert: false,
|
||||
isFullStackCert: false,
|
||||
isHonest: false,
|
||||
isInfosecCertV7: false,
|
||||
isInfosecQaCert: false,
|
||||
isJavascriptCertV9: false,
|
||||
isJsAlgoDataStructCert: false,
|
||||
isJsAlgoDataStructCertV8: false,
|
||||
isMachineLearningPyCertV7: false,
|
||||
isPythonCertV9: false,
|
||||
isQaCertV7: false,
|
||||
isRelationalDatabaseCertV8: false,
|
||||
isRelationalDatabaseCertV9: false,
|
||||
isCollegeAlgebraPyCertV8: false,
|
||||
isRespWebDesignCert: false,
|
||||
isRespWebDesignCertV9: false,
|
||||
isSciCompPyCertV7: false,
|
||||
isFrontEndLibsCertV9: false,
|
||||
isBackEndDevApisCertV9: false,
|
||||
isFullStackDeveloperCertV9: false,
|
||||
isB1EnglishCert: false,
|
||||
isA2SpanishCert: false,
|
||||
isA2ChineseCert: false,
|
||||
isA1ChineseCert: false,
|
||||
keyboardShortcuts: false,
|
||||
linkedin: null,
|
||||
location: '',
|
||||
name: '',
|
||||
needsModeration: false,
|
||||
newEmail: null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
unsubscribeId: expect.stringMatching(unsubscribeIdRe),
|
||||
partiallyCompletedChallenges: [],
|
||||
password: null,
|
||||
picture: '',
|
||||
portfolio: [],
|
||||
profileUI: {
|
||||
isLocked: false,
|
||||
showAbout: false,
|
||||
showCerts: false,
|
||||
showDonation: false,
|
||||
showExperience: false,
|
||||
showHeatMap: false,
|
||||
showLocation: false,
|
||||
showName: false,
|
||||
showPoints: false,
|
||||
showPortfolio: false,
|
||||
showTimeLine: false
|
||||
},
|
||||
progressTimestamps: [expect.any(Number)],
|
||||
rand: null, // TODO(Post-MVP): delete from schema (it's not used or required).
|
||||
savedChallenges: [],
|
||||
sendQuincyEmail: null,
|
||||
socrates: null,
|
||||
theme: 'default',
|
||||
timezone: null,
|
||||
twitter: null,
|
||||
bluesky: null,
|
||||
updateCount: 0, // see extendClient in prisma.ts
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
username: expect.stringMatching(fccUuidRe),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
usernameDisplay: expect.stringMatching(fccUuidRe),
|
||||
verificationToken: null,
|
||||
website: null,
|
||||
yearsTopContributor: []
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
describe,
|
||||
test,
|
||||
expect,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
afterAll
|
||||
} from 'vitest';
|
||||
import Fastify, { FastifyInstance } from 'fastify';
|
||||
|
||||
import { checkCanConnectToDb, defaultUserEmail } from '../../vitest.utils.js';
|
||||
import {
|
||||
HOME_LOCATION,
|
||||
GROWTHBOOK_FASTIFY_API_HOST,
|
||||
GROWTHBOOK_FASTIFY_CLIENT_KEY
|
||||
} from '../utils/env.js';
|
||||
import { devAuth } from '../plugins/auth-dev.js';
|
||||
import prismaPlugin from '../db/prisma.js';
|
||||
import auth from './auth.js';
|
||||
import cookies from './cookies.js';
|
||||
import growthBook from './growth-book.js';
|
||||
|
||||
import { newUser } from './__fixtures__/user.js';
|
||||
|
||||
describe('dev login', () => {
|
||||
let fastify: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
fastify = Fastify();
|
||||
|
||||
await fastify.register(cookies);
|
||||
await fastify.register(auth);
|
||||
await fastify.register(devAuth);
|
||||
await fastify.register(prismaPlugin);
|
||||
await checkCanConnectToDb(fastify.prisma);
|
||||
await fastify.register(growthBook, {
|
||||
apiHost: GROWTHBOOK_FASTIFY_API_HOST,
|
||||
clientKey: GROWTHBOOK_FASTIFY_CLIENT_KEY
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await fastify.prisma.user.deleteMany({
|
||||
where: { email: defaultUserEmail }
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fastify.prisma.user.deleteMany({
|
||||
where: { email: defaultUserEmail }
|
||||
});
|
||||
await fastify.prisma.$runCommandRaw({ dropDatabase: 1 });
|
||||
await fastify.close();
|
||||
});
|
||||
|
||||
describe('GET /signin', () => {
|
||||
test('should create an account if one does not exist', async () => {
|
||||
const before = await fastify.prisma.user.count({});
|
||||
await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/signin'
|
||||
});
|
||||
|
||||
const after = await fastify.prisma.user.count({});
|
||||
|
||||
expect(before).toBe(0);
|
||||
expect(after).toBe(before + 1);
|
||||
});
|
||||
|
||||
test('should populate the user with the correct data', async () => {
|
||||
await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/signin'
|
||||
});
|
||||
|
||||
const user = await fastify.prisma.user.findFirstOrThrow({
|
||||
where: { email: defaultUserEmail }
|
||||
});
|
||||
|
||||
expect(user).toEqual(newUser(defaultUserEmail));
|
||||
expect(user.username).toBe(user.usernameDisplay);
|
||||
});
|
||||
|
||||
test('should set the jwt_access_token cookie', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/signin'
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
|
||||
expect(res.cookies).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'jwt_access_token' })
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
test.todo('should create a session');
|
||||
|
||||
test('should redirect to the Referer (if it is a valid origin)', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/signin',
|
||||
headers: {
|
||||
referer: 'https://www.freecodecamp.org/some-path/or/other'
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toBe(
|
||||
'https://www.freecodecamp.org/some-path/or/other'
|
||||
);
|
||||
});
|
||||
|
||||
test('should redirect to /valid-language/learn when signing in from /valid-language', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/signin',
|
||||
headers: {
|
||||
referer: 'https://www.freecodecamp.org/espanol'
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toBe(
|
||||
'https://www.freecodecamp.org/espanol/learn'
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle referers with trailing slahes', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/signin',
|
||||
headers: {
|
||||
referer: 'https://www.freecodecamp.org/espanol/'
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toBe(
|
||||
'https://www.freecodecamp.org/espanol/learn'
|
||||
);
|
||||
});
|
||||
|
||||
test('should redirect to /learn by default', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/signin'
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toBe(`${HOME_LOCATION}/learn`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import {
|
||||
getRedirectParams,
|
||||
getPrefixedLandingPath,
|
||||
haveSamePath
|
||||
} from '../utils/redirection.js';
|
||||
import { findOrCreateUser } from '../routes/helpers/auth-helpers.js';
|
||||
import { createAccessToken } from '../utils/tokens.js';
|
||||
|
||||
const trimTrailingSlash = (str: string) =>
|
||||
str.endsWith('/') ? str.slice(0, -1) : str;
|
||||
|
||||
async function handleRedirects(req: FastifyRequest, reply: FastifyReply) {
|
||||
const params = getRedirectParams(req);
|
||||
const { origin, pathPrefix } = params;
|
||||
const returnTo = trimTrailingSlash(params.returnTo);
|
||||
const landingUrl = getPrefixedLandingPath(origin, pathPrefix);
|
||||
|
||||
return await reply.redirect(
|
||||
haveSamePath(landingUrl, returnTo) ? `${returnTo}/learn` : returnTo
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fastify plugin for dev authentication.
|
||||
*
|
||||
* @param fastify - The Fastify instance.
|
||||
* @param _options - The plugin options.
|
||||
* @param done - The callback function.
|
||||
*/
|
||||
export const devAuth: FastifyPluginCallbackTypebox = (
|
||||
fastify,
|
||||
_options,
|
||||
done
|
||||
) => {
|
||||
fastify.get('/signin', async (req, reply) => {
|
||||
const email = 'foo@bar.com';
|
||||
|
||||
const { id } = await findOrCreateUser(fastify, email);
|
||||
|
||||
reply.setAccessTokenCookie(createAccessToken(id));
|
||||
|
||||
await handleRedirects(req, reply);
|
||||
});
|
||||
|
||||
done();
|
||||
};
|
||||
@@ -0,0 +1,605 @@
|
||||
import { Writable } from 'stream';
|
||||
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { FastifyInstance } from 'fastify';
|
||||
import { pino } from 'pino';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import { COOKIE_DOMAIN, JWT_SECRET } from '../utils/env.js';
|
||||
import { type Token, createAccessToken } from '../utils/tokens.js';
|
||||
import { getLoggerOptions } from '../utils/logger.js';
|
||||
import cookies, {
|
||||
sign as signCookie,
|
||||
unsign as unsignCookie
|
||||
} from './cookies.js';
|
||||
import auth from './auth.js';
|
||||
|
||||
async function setupServer() {
|
||||
const fastify = Fastify();
|
||||
await fastify.register(cookies);
|
||||
await fastify.register(auth);
|
||||
return fastify;
|
||||
}
|
||||
|
||||
const THIRTY_DAYS_IN_SECONDS = 2592000;
|
||||
|
||||
describe('auth', () => {
|
||||
let fastify: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
fastify = await setupServer();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastify.close();
|
||||
});
|
||||
|
||||
describe('setAccessTokenCookie', () => {
|
||||
// We won't need to keep doubly signing the cookie when we migrate the
|
||||
// authentication, but for the MVP we have to be able to read the cookies
|
||||
// set by the api-server. So, double signing:
|
||||
test('should doubly sign the cookie', async () => {
|
||||
const token = createAccessToken('test-id');
|
||||
fastify.get('/test', async (req, reply) => {
|
||||
reply.setAccessTokenCookie(token);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test'
|
||||
});
|
||||
|
||||
const { value, ...rest } = res.cookies[0]!;
|
||||
const unsignedOnce = unsignCookie(value);
|
||||
const unsignedTwice = jwt.verify(unsignedOnce.value!, JWT_SECRET) as {
|
||||
accessToken: Token;
|
||||
};
|
||||
expect(unsignedTwice.accessToken).toEqual(token);
|
||||
expect(rest).toEqual({
|
||||
name: 'jwt_access_token',
|
||||
path: '/',
|
||||
sameSite: 'Lax',
|
||||
domain: COOKIE_DOMAIN,
|
||||
maxAge: THIRTY_DAYS_IN_SECONDS,
|
||||
httpOnly: true,
|
||||
secure: true
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('authorize', () => {
|
||||
beforeEach(() => {
|
||||
fastify.get('/test', (_req, reply) => {
|
||||
void reply.send({ ok: true });
|
||||
});
|
||||
fastify.addHook('onRequest', fastify.authorize);
|
||||
});
|
||||
|
||||
test('should deny if the access token is missing', async () => {
|
||||
expect.assertions(4);
|
||||
|
||||
fastify.addHook('onRequest', (req, _reply, done) => {
|
||||
expect(req.accessDeniedMessage).toEqual({
|
||||
type: 'info',
|
||||
content: 'Access token is required for this request'
|
||||
});
|
||||
expect(req.user).toBeNull();
|
||||
done();
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test'
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({ ok: true });
|
||||
expect(res.statusCode).toEqual(200);
|
||||
});
|
||||
|
||||
test('should deny if the access token is not signed', async () => {
|
||||
expect.assertions(4);
|
||||
|
||||
fastify.addHook('onRequest', (req, _reply, done) => {
|
||||
expect(req.accessDeniedMessage).toEqual({
|
||||
type: 'info',
|
||||
content: 'Access token is required for this request'
|
||||
});
|
||||
expect(req.user).toBeNull();
|
||||
done();
|
||||
});
|
||||
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123') },
|
||||
JWT_SECRET
|
||||
);
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
cookies: {
|
||||
jwt_access_token: token
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({ ok: true });
|
||||
expect(res.statusCode).toEqual(200);
|
||||
});
|
||||
|
||||
test('should deny if the access token is invalid', async () => {
|
||||
expect.assertions(4);
|
||||
|
||||
fastify.addHook('onRequest', (req, _reply, done) => {
|
||||
expect(req.accessDeniedMessage).toEqual({
|
||||
type: 'info',
|
||||
content: 'Your access token is invalid'
|
||||
});
|
||||
expect(req.user).toBeNull();
|
||||
done();
|
||||
});
|
||||
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123') },
|
||||
'invalid-secret'
|
||||
);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
cookies: {
|
||||
jwt_access_token: signCookie(token)
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({ ok: true });
|
||||
expect(res.statusCode).toEqual(200);
|
||||
});
|
||||
|
||||
test('should deny if the access token has expired', async () => {
|
||||
expect.assertions(4);
|
||||
|
||||
fastify.addHook('onRequest', (req, _reply, done) => {
|
||||
expect(req.accessDeniedMessage).toEqual({
|
||||
type: 'info',
|
||||
content: 'Access token is no longer valid'
|
||||
});
|
||||
expect(req.user).toBeNull();
|
||||
done();
|
||||
});
|
||||
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123', -1) },
|
||||
JWT_SECRET
|
||||
);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
cookies: {
|
||||
jwt_access_token: signCookie(token)
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({ ok: true });
|
||||
expect(res.statusCode).toEqual(200);
|
||||
});
|
||||
|
||||
test('should deny if the user is not found', async () => {
|
||||
expect.assertions(4);
|
||||
|
||||
fastify.addHook('onRequest', (req, _reply, done) => {
|
||||
expect(req.accessDeniedMessage).toEqual({
|
||||
type: 'info',
|
||||
content: 'Your access token is invalid'
|
||||
});
|
||||
expect(req.user).toBeNull();
|
||||
done();
|
||||
});
|
||||
|
||||
// @ts-expect-error prisma isn't defined, since we're not building the
|
||||
// full application here.
|
||||
fastify.prisma = { user: { findUnique: () => null } };
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123') },
|
||||
JWT_SECRET
|
||||
);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
cookies: {
|
||||
jwt_access_token: signCookie(token)
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({ ok: true });
|
||||
expect(res.statusCode).toEqual(200);
|
||||
});
|
||||
|
||||
test('should populate the request with the user if the token is valid', async () => {
|
||||
const fakeUser = { id: '123', username: 'test-user' };
|
||||
// @ts-expect-error prisma isn't defined, since we're not building the
|
||||
// full application here.
|
||||
fastify.prisma = { user: { findUnique: () => fakeUser } };
|
||||
fastify.get('/test-user', req => {
|
||||
expect(req.user).toEqual(fakeUser);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123') },
|
||||
JWT_SECRET
|
||||
);
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test-user',
|
||||
cookies: {
|
||||
jwt_access_token: signCookie(token)
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({ ok: true });
|
||||
expect(res.statusCode).toEqual(200);
|
||||
});
|
||||
|
||||
test('identifies the Sentry user by id only, never email', async () => {
|
||||
const setUser = vi.fn();
|
||||
// @ts-expect-error Sentry isn't decorated in this minimal test app.
|
||||
fastify.Sentry = { setUser };
|
||||
const fakeUser = {
|
||||
id: '123',
|
||||
username: 'test-user',
|
||||
email: 'foo@bar.com'
|
||||
};
|
||||
// @ts-expect-error prisma isn't built in this minimal test app.
|
||||
fastify.prisma = { user: { findUnique: () => fakeUser } };
|
||||
fastify.get('/test-pii', () => ({ ok: true }));
|
||||
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123') },
|
||||
JWT_SECRET
|
||||
);
|
||||
await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test-pii',
|
||||
cookies: {
|
||||
jwt_access_token: signCookie(token)
|
||||
}
|
||||
});
|
||||
|
||||
expect(setUser).toHaveBeenLastCalledWith({ id: '123' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('req.getAuthedUser', () => {
|
||||
test('returns message when access token is missing', async () => {
|
||||
fastify.get('/test', async req => {
|
||||
return req.getAuthedUser();
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test'
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({
|
||||
message: 'Access token is required for this request'
|
||||
});
|
||||
});
|
||||
|
||||
test('returns message when access token is not signed', async () => {
|
||||
fastify.get('/test', async req => {
|
||||
return req.getAuthedUser();
|
||||
});
|
||||
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123') },
|
||||
JWT_SECRET
|
||||
);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
cookies: {
|
||||
jwt_access_token: token
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({
|
||||
message: 'Access token is required for this request'
|
||||
});
|
||||
});
|
||||
|
||||
test('returns message when access token is invalid', async () => {
|
||||
fastify.get('/test', async req => {
|
||||
return req.getAuthedUser();
|
||||
});
|
||||
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123') },
|
||||
'invalid-secret'
|
||||
);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
cookies: {
|
||||
jwt_access_token: signCookie(token)
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({
|
||||
message: 'Your access token is invalid'
|
||||
});
|
||||
});
|
||||
|
||||
test('returns message when access token has expired', async () => {
|
||||
fastify.get('/test', async req => {
|
||||
return req.getAuthedUser();
|
||||
});
|
||||
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123', -1) },
|
||||
JWT_SECRET
|
||||
);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
cookies: {
|
||||
jwt_access_token: signCookie(token)
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({
|
||||
message: 'Access token is no longer valid'
|
||||
});
|
||||
});
|
||||
|
||||
test('returns message when user is not found', async () => {
|
||||
// @ts-expect-error prisma isn't defined, since we're not building the
|
||||
// full application here.
|
||||
fastify.prisma = { user: { findUnique: () => null } };
|
||||
|
||||
fastify.get('/test', async req => {
|
||||
return req.getAuthedUser();
|
||||
});
|
||||
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123') },
|
||||
JWT_SECRET
|
||||
);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
cookies: {
|
||||
jwt_access_token: signCookie(token)
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({
|
||||
message: 'Your access token is invalid'
|
||||
});
|
||||
});
|
||||
|
||||
test('returns user when token is valid', async () => {
|
||||
const fakeUser = { id: '123', username: 'test-user' };
|
||||
// @ts-expect-error prisma isn't defined, since we're not building the
|
||||
// full application here.
|
||||
fastify.prisma = { user: { findUnique: () => fakeUser } };
|
||||
|
||||
fastify.get('/test', async req => {
|
||||
return req.getAuthedUser();
|
||||
});
|
||||
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123') },
|
||||
JWT_SECRET
|
||||
);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
cookies: {
|
||||
jwt_access_token: signCookie(token)
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({
|
||||
user: fakeUser
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('authorizeExamEnvironmentToken', () => {
|
||||
beforeEach(() => {
|
||||
fastify.get('/test', (_req, reply) => {
|
||||
void reply.send({ ok: true });
|
||||
});
|
||||
fastify.addHook('onRequest', fastify.authorizeExamEnvironmentToken);
|
||||
});
|
||||
|
||||
test('captures an Error if the decoded payload is not an object', async () => {
|
||||
const captureException = vi.fn();
|
||||
// @ts-expect-error Sentry isn't decorated in this minimal test app.
|
||||
fastify.Sentry = { captureException };
|
||||
|
||||
const token = jwt.sign('just-a-string-payload', JWT_SECRET);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
headers: { 'exam-environment-authorization-token': token }
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({
|
||||
message:
|
||||
'Unreachable: exam-environment token decoded payload is not an object'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('does not capture an exception for expected token verification failures', async () => {
|
||||
const captureException = vi.fn();
|
||||
// @ts-expect-error Sentry isn't decorated in this minimal test app.
|
||||
fastify.Sentry = { captureException };
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
headers: { 'exam-environment-authorization-token': 'invalid-token' }
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onRequest Hook', () => {
|
||||
test('should update the jwt_access_token to httpOnly and secure', async () => {
|
||||
const rawValue = 'should-not-change';
|
||||
fastify.get('/test', (req, reply) => {
|
||||
reply.send({ ok: true });
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
cookies: {
|
||||
jwt_access_token: signCookie(rawValue)
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.cookies[0]).toMatchObject({
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
value: signCookie(rawValue),
|
||||
maxAge: THIRTY_DAYS_IN_SECONDS
|
||||
});
|
||||
|
||||
expect(res.json()).toStrictEqual({ ok: true });
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
test('should do nothing if there is no jwt_access_token', async () => {
|
||||
fastify.get('/test', (req, reply) => {
|
||||
reply.send({ ok: true });
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test'
|
||||
});
|
||||
|
||||
expect(res.cookies).toHaveLength(0);
|
||||
expect(res.json()).toStrictEqual({ ok: true });
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('request logging', () => {
|
||||
test('binds the userId onto logs for authed requests', async () => {
|
||||
const lines: string[] = [];
|
||||
const sink = new Writable({
|
||||
write(chunk: Buffer, _enc, cb) {
|
||||
lines.push(chunk.toString());
|
||||
cb();
|
||||
}
|
||||
});
|
||||
const app = Fastify({
|
||||
loggerInstance: pino(getLoggerOptions('info'), sink)
|
||||
});
|
||||
await app.register(cookies);
|
||||
await app.register(auth);
|
||||
const fakeUser = { id: 'user-42', username: 'test-user' };
|
||||
// @ts-expect-error prisma isn't built in this minimal test app.
|
||||
app.prisma = { user: { findUnique: () => fakeUser } };
|
||||
app.addHook('onRequest', app.authorize);
|
||||
app.get('/me', () => ({ ok: true }));
|
||||
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('user-42') },
|
||||
JWT_SECRET
|
||||
);
|
||||
await app.inject({
|
||||
method: 'GET',
|
||||
url: '/me',
|
||||
cookies: {
|
||||
jwt_access_token: signCookie(token)
|
||||
}
|
||||
});
|
||||
await app.close();
|
||||
|
||||
const completed = lines
|
||||
.map(line => JSON.parse(line) as Record<string, unknown>)
|
||||
.find(entry => entry.msg === 'request completed');
|
||||
expect(completed?.userId).toBe('user-42');
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth.access_denied metric', () => {
|
||||
let count: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
count = vi.fn();
|
||||
// @ts-expect-error Sentry isn't decorated in this minimal test app.
|
||||
fastify.Sentry = { metrics: { count } };
|
||||
fastify.get('/user/session-user', (_req, reply) => {
|
||||
void reply.send({ ok: true });
|
||||
});
|
||||
fastify.get('/other', (_req, reply) => {
|
||||
void reply.send({ ok: true });
|
||||
});
|
||||
fastify.addHook('onRequest', fastify.authorize);
|
||||
});
|
||||
|
||||
test('skips the metric for the anonymous session-user poll', async () => {
|
||||
await fastify.inject({ method: 'GET', url: '/user/session-user' });
|
||||
|
||||
expect(count).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('still counts an invalid token on the session-user route', async () => {
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123') },
|
||||
'invalid-secret'
|
||||
);
|
||||
|
||||
await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/user/session-user',
|
||||
cookies: { jwt_access_token: signCookie(token) }
|
||||
});
|
||||
|
||||
expect(count).toHaveBeenCalledExactlyOnceWith('auth.access_denied', 1, {
|
||||
attributes: { reason: 'Your access token is invalid' }
|
||||
});
|
||||
});
|
||||
|
||||
test('still counts an expired token on the session-user route', async () => {
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123', -1) },
|
||||
JWT_SECRET
|
||||
);
|
||||
|
||||
await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/user/session-user',
|
||||
cookies: { jwt_access_token: signCookie(token) }
|
||||
});
|
||||
|
||||
expect(count).toHaveBeenCalledExactlyOnceWith('auth.access_denied', 1, {
|
||||
attributes: { reason: 'Access token is no longer valid' }
|
||||
});
|
||||
});
|
||||
|
||||
test('counts a missing token on other routes', async () => {
|
||||
await fastify.inject({ method: 'GET', url: '/other' });
|
||||
|
||||
expect(count).toHaveBeenCalledExactlyOnceWith('auth.access_denied', 1, {
|
||||
attributes: { reason: 'Access token is required for this request' }
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
import { FastifyPluginCallback, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { type user } from '@prisma/client';
|
||||
|
||||
import { JWT_SECRET } from '../utils/env.js';
|
||||
import { type Token, isExpired } from '../utils/tokens.js';
|
||||
import { ERRORS } from '../exam-environment/utils/errors.js';
|
||||
|
||||
type AuthResult =
|
||||
| {
|
||||
message: string;
|
||||
user?: never;
|
||||
}
|
||||
| {
|
||||
message?: never;
|
||||
user: user;
|
||||
};
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyReply {
|
||||
setAccessTokenCookie: (this: FastifyReply, accessToken: Token) => void;
|
||||
}
|
||||
|
||||
interface FastifyRequest {
|
||||
// TODO: is the full user the correct type here?
|
||||
user: user | null;
|
||||
accessDeniedMessage: { type: 'info'; content: string } | null;
|
||||
getAuthedUser: () => Promise<AuthResult>;
|
||||
}
|
||||
|
||||
interface FastifyInstance {
|
||||
authorize: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||
authorizeExamEnvironmentToken: (
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) => void;
|
||||
}
|
||||
}
|
||||
|
||||
const auth: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
const cookieOpts = {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
maxAge: 2592000 // thirty days in seconds
|
||||
};
|
||||
fastify.decorateReply('setAccessTokenCookie', function (accessToken: Token) {
|
||||
const signedToken = jwt.sign({ accessToken }, JWT_SECRET);
|
||||
void this.setCookie('jwt_access_token', signedToken, cookieOpts);
|
||||
});
|
||||
|
||||
// update existing jwt_access_token cookie properties
|
||||
fastify.addHook('onRequest', (req, reply, done) => {
|
||||
const rawCookie = req.cookies['jwt_access_token'];
|
||||
if (rawCookie) {
|
||||
const jwtAccessToken = req.unsignCookie(rawCookie);
|
||||
if (jwtAccessToken.valid) {
|
||||
reply.setCookie('jwt_access_token', jwtAccessToken.value, cookieOpts);
|
||||
}
|
||||
}
|
||||
done();
|
||||
});
|
||||
|
||||
fastify.decorateRequest('accessDeniedMessage', null);
|
||||
fastify.decorateRequest('user', null);
|
||||
|
||||
const TOKEN_REQUIRED = 'Access token is required for this request';
|
||||
const TOKEN_INVALID = 'Your access token is invalid';
|
||||
const TOKEN_EXPIRED = 'Access token is no longer valid';
|
||||
|
||||
const setAccessDenied = (req: FastifyRequest, content: string) => {
|
||||
const isAnonymousPoll =
|
||||
req.routeOptions?.url === '/user/session-user' &&
|
||||
content === TOKEN_REQUIRED;
|
||||
if (!isAnonymousPoll) {
|
||||
fastify.Sentry?.metrics?.count('auth.access_denied', 1, {
|
||||
attributes: { reason: content }
|
||||
});
|
||||
}
|
||||
req.accessDeniedMessage = { type: 'info', content };
|
||||
};
|
||||
|
||||
async function getAuthedUser(this: FastifyRequest): Promise<AuthResult> {
|
||||
const tokenCookie = this.cookies.jwt_access_token;
|
||||
if (!tokenCookie) return { message: TOKEN_REQUIRED };
|
||||
|
||||
const unsignedToken = this.unsignCookie(tokenCookie);
|
||||
if (!unsignedToken.valid) return { message: TOKEN_REQUIRED };
|
||||
|
||||
const jwtAccessToken = unsignedToken.value;
|
||||
|
||||
try {
|
||||
jwt.verify(jwtAccessToken, JWT_SECRET);
|
||||
} catch {
|
||||
return { message: TOKEN_INVALID };
|
||||
}
|
||||
|
||||
const { accessToken } = jwt.decode(jwtAccessToken) as {
|
||||
accessToken: Token;
|
||||
};
|
||||
|
||||
if (isExpired(accessToken)) return { message: TOKEN_EXPIRED };
|
||||
// We're using token.userId since it's possible for the user record to be
|
||||
// malformed and for prisma to throw while trying to find the user.
|
||||
fastify.Sentry?.setUser({
|
||||
id: accessToken.userId
|
||||
});
|
||||
|
||||
const user = await fastify.prisma.user.findUnique({
|
||||
where: { id: accessToken.userId }
|
||||
});
|
||||
if (user) {
|
||||
fastify.Sentry?.setUser({ id: user.id });
|
||||
}
|
||||
|
||||
return user ? { user } : { message: TOKEN_INVALID };
|
||||
}
|
||||
|
||||
fastify.decorateRequest('getAuthedUser', getAuthedUser);
|
||||
|
||||
const handleAuth = async (
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
): Promise<void> => {
|
||||
const { message, user } = await req.getAuthedUser();
|
||||
|
||||
if (user) {
|
||||
req.user = user;
|
||||
req.log = reply.log = req.log.child({ userId: user.id });
|
||||
} else {
|
||||
req.log.debug({ reason: message }, 'Request not authenticated');
|
||||
setAccessDenied(req, message);
|
||||
}
|
||||
};
|
||||
|
||||
async function handleExamEnvironmentTokenAuth(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { 'exam-environment-authorization-token': encodedToken } =
|
||||
req.headers;
|
||||
|
||||
if (!encodedToken || typeof encodedToken !== 'string') {
|
||||
void reply.code(400);
|
||||
return reply.send(
|
||||
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN(
|
||||
'EXAM-ENVIRONMENT-AUTHORIZATION-TOKEN header is a required string.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
jwt.verify(encodedToken, JWT_SECRET);
|
||||
} catch (e) {
|
||||
req.log.warn({ err: e }, 'Exam environment token verification failed');
|
||||
void reply.code(403);
|
||||
return reply.send(
|
||||
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN(
|
||||
JSON.stringify(e)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const payload = jwt.decode(encodedToken);
|
||||
|
||||
if (typeof payload !== 'object' || payload === null) {
|
||||
req.log.error(
|
||||
'Unreachable: exam-environment token verified but decoded payload is not an object'
|
||||
);
|
||||
fastify.Sentry?.captureException(
|
||||
new Error(
|
||||
'Unreachable: exam-environment token decoded payload is not an object'
|
||||
)
|
||||
);
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN(
|
||||
'Unreachable. Decoded token has been verified.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const examEnvironmentAuthorizationToken =
|
||||
payload['examEnvironmentAuthorizationToken'];
|
||||
|
||||
// if (typeof examEnvironmentAuthorizationToken !== 'string') {
|
||||
// // TODO: This code is debatable, because the token would have to have been signed by the api
|
||||
// // which means it is valid, but, somehow, got signed as an object instead of a string.
|
||||
// void reply.code(400+500);
|
||||
// return reply.send(
|
||||
// ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN(
|
||||
// 'EXAM-ENVIRONMENT-AUTHORIZATION-TOKEN is not valid.'
|
||||
// )
|
||||
// );
|
||||
// }
|
||||
|
||||
assertIsString(examEnvironmentAuthorizationToken);
|
||||
|
||||
const token =
|
||||
await fastify.prisma.examEnvironmentAuthorizationToken.findFirst({
|
||||
where: {
|
||||
id: examEnvironmentAuthorizationToken
|
||||
}
|
||||
});
|
||||
|
||||
if (!token) {
|
||||
void reply.code(403);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ENOENT_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN(
|
||||
'Provided token is revoked.'
|
||||
)
|
||||
);
|
||||
}
|
||||
// We're using token.userId since it's possible for the user record to be
|
||||
// malformed and for prisma to throw while trying to find the user.
|
||||
|
||||
fastify.Sentry?.setUser({
|
||||
id: token.userId
|
||||
});
|
||||
|
||||
const user = await fastify.prisma.user.findUnique({
|
||||
where: { id: token.userId }
|
||||
});
|
||||
if (!user) return setAccessDenied(req, TOKEN_INVALID);
|
||||
fastify.Sentry?.setUser({ id: user.id });
|
||||
req.user = user;
|
||||
req.log = reply.log = req.log.child({ userId: user.id });
|
||||
}
|
||||
|
||||
fastify.decorate('authorize', handleAuth);
|
||||
fastify.decorate(
|
||||
'authorizeExamEnvironmentToken',
|
||||
handleExamEnvironmentTokenAuth
|
||||
);
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
function assertIsString(some: unknown): asserts some is string {
|
||||
if (typeof some !== 'string') {
|
||||
throw new Error('Expected a string');
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(auth, { name: 'auth', dependencies: ['cookies'] });
|
||||
@@ -0,0 +1,522 @@
|
||||
import {
|
||||
describe,
|
||||
test,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
vi,
|
||||
MockInstance
|
||||
} from 'vitest';
|
||||
import Fastify, { FastifyInstance } from 'fastify';
|
||||
|
||||
import { createUserInput } from '../utils/create-user.js';
|
||||
import {
|
||||
AUTH0_DOMAIN,
|
||||
HOME_LOCATION,
|
||||
GROWTHBOOK_FASTIFY_API_HOST,
|
||||
GROWTHBOOK_FASTIFY_CLIENT_KEY
|
||||
} from '../utils/env.js';
|
||||
import prismaPlugin from '../db/prisma.js';
|
||||
import cookies, { sign, unsign } from './cookies.js';
|
||||
import { auth0Client } from './auth0.js';
|
||||
import redirectWithMessage, { formatMessage } from './redirect-with-message.js';
|
||||
import auth from './auth.js';
|
||||
import bouncer from './bouncer.js';
|
||||
import growthBook from './growth-book.js';
|
||||
import { newUser } from './__fixtures__/user.js';
|
||||
|
||||
const COOKIE_DOMAIN = 'test.com';
|
||||
|
||||
vi.mock('../utils/env', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('../utils/env.js')>()),
|
||||
COOKIE_DOMAIN: 'test.com'
|
||||
}));
|
||||
|
||||
describe('auth0 plugin', () => {
|
||||
let fastify: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
fastify = Fastify();
|
||||
|
||||
// @ts-expect-error - Only mocks part of the Sentry object.
|
||||
fastify.Sentry = { captureException: () => '' };
|
||||
|
||||
await fastify.register(cookies);
|
||||
await fastify.register(redirectWithMessage);
|
||||
await fastify.register(auth);
|
||||
await fastify.register(bouncer);
|
||||
await fastify.register(auth0Client);
|
||||
await fastify.register(prismaPlugin);
|
||||
await fastify.register(growthBook, {
|
||||
apiHost: GROWTHBOOK_FASTIFY_API_HOST,
|
||||
clientKey: GROWTHBOOK_FASTIFY_CLIENT_KEY
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /signin/google', () => {
|
||||
test('should redirect directly to Google via Auth0 with connection param', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/signin/google'
|
||||
});
|
||||
const redirectUrl = new URL(res.headers.location!);
|
||||
expect(redirectUrl.host).toMatch(AUTH0_DOMAIN);
|
||||
expect(redirectUrl.pathname).toBe('/authorize');
|
||||
expect(redirectUrl.searchParams.get('connection')).toBe('google-oauth2');
|
||||
expect(res.statusCode).toBe(302);
|
||||
});
|
||||
|
||||
test('sets a login-returnto cookie', async () => {
|
||||
const returnTo = 'http://localhost:3000/learn';
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/signin/google',
|
||||
headers: { referer: returnTo }
|
||||
});
|
||||
const cookie = res.cookies.find(c => c.name === 'login-returnto');
|
||||
expect(unsign(cookie!.value).value).toBe(returnTo);
|
||||
expect(cookie).toMatchObject({
|
||||
domain: COOKIE_DOMAIN,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'Lax'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fastify.prisma.$runCommandRaw({ dropDatabase: 1 });
|
||||
await fastify.close();
|
||||
});
|
||||
|
||||
describe('GET /signin', () => {
|
||||
test('should redirect to the auth0 login page', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/signin'
|
||||
});
|
||||
|
||||
const redirectUrl = new URL(res.headers.location!);
|
||||
expect(redirectUrl.host).toMatch(AUTH0_DOMAIN);
|
||||
expect(redirectUrl.pathname).toBe('/authorize');
|
||||
expect(res.statusCode).toBe(302);
|
||||
});
|
||||
|
||||
test('sets a login-returnto cookie', async () => {
|
||||
const returnTo = 'http://localhost:3000/learn';
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/signin',
|
||||
headers: {
|
||||
referer: returnTo
|
||||
}
|
||||
});
|
||||
|
||||
const cookie = res.cookies.find(c => c.name === 'login-returnto');
|
||||
expect(unsign(cookie!.value).value).toBe(returnTo);
|
||||
expect(cookie).toMatchObject({
|
||||
domain: COOKIE_DOMAIN,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'Lax'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /auth/auth0/callback', () => {
|
||||
const email = 'new@user.com';
|
||||
let getAccessTokenFromAuthorizationCodeFlowSpy: MockInstance;
|
||||
let userinfoSpy: MockInstance;
|
||||
let captureException: ReturnType<typeof vi.fn>;
|
||||
|
||||
const mockAuthSuccess = () => {
|
||||
getAccessTokenFromAuthorizationCodeFlowSpy.mockResolvedValueOnce({
|
||||
token: 'any token'
|
||||
});
|
||||
userinfoSpy.mockResolvedValueOnce(Promise.resolve({ email }));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
getAccessTokenFromAuthorizationCodeFlowSpy = vi.spyOn(
|
||||
fastify.auth0OAuth,
|
||||
'getAccessTokenFromAuthorizationCodeFlow'
|
||||
);
|
||||
userinfoSpy = vi.spyOn(fastify.auth0OAuth, 'userinfo');
|
||||
captureException = vi.fn();
|
||||
// @ts-expect-error - Only mocks part of the Sentry object.
|
||||
fastify.Sentry = { captureException };
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await fastify.prisma.user.deleteMany({ where: { email } });
|
||||
});
|
||||
|
||||
test('should redirect to the client if authentication fails', async () => {
|
||||
getAccessTokenFromAuthorizationCodeFlowSpy.mockRejectedValueOnce(
|
||||
'any error'
|
||||
);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback'
|
||||
});
|
||||
|
||||
expect(res.headers.location).toMatch(
|
||||
`${HOME_LOCATION}/?${formatMessage({ type: 'danger', content: 'flash.generic-error' })}`
|
||||
);
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('should redirect to the client if the state is invalid', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=invalid'
|
||||
});
|
||||
|
||||
expect(res.headers.location).toMatch(
|
||||
`${HOME_LOCATION}/?${formatMessage({ type: 'danger', content: 'flash.generic-error' })}`
|
||||
);
|
||||
expect(res.statusCode).toBe(302);
|
||||
});
|
||||
|
||||
test('should log a warning if the state is invalid', async () => {
|
||||
vi.spyOn(fastify.log, 'warn');
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=invalid'
|
||||
});
|
||||
|
||||
expect(fastify.log.warn).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
'Auth failed: invalid state'
|
||||
);
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should log expected Auth0 errors', async () => {
|
||||
vi.spyOn(fastify.log, 'error');
|
||||
const auth0Error = Error('Response Error: 403 Forbidden');
|
||||
// @ts-expect-error - mocking a hapi/boom error
|
||||
auth0Error.data = {
|
||||
payload: {
|
||||
error: 'invalid_grant'
|
||||
}
|
||||
};
|
||||
|
||||
getAccessTokenFromAuthorizationCodeFlowSpy.mockRejectedValueOnce(
|
||||
auth0Error
|
||||
);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=invalid'
|
||||
});
|
||||
|
||||
expect(fastify.log.error).toHaveBeenCalledWith(
|
||||
auth0Error,
|
||||
'Auth failed: invalid_grant'
|
||||
);
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should capture Auth0 errors with reason invalid_request', async () => {
|
||||
vi.spyOn(fastify.log, 'error');
|
||||
const auth0Error = Error('Response Error: 400 Bad Request');
|
||||
// @ts-expect-error - mocking a hapi/boom error
|
||||
auth0Error.data = {
|
||||
payload: {
|
||||
error: 'invalid_request'
|
||||
}
|
||||
};
|
||||
|
||||
getAccessTokenFromAuthorizationCodeFlowSpy.mockRejectedValueOnce(
|
||||
auth0Error
|
||||
);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=invalid'
|
||||
});
|
||||
|
||||
expect(fastify.log.error).toHaveBeenCalledWith(
|
||||
auth0Error,
|
||||
'Auth failed: invalid_request'
|
||||
);
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('should capture unexpected Auth0 errors', async () => {
|
||||
vi.spyOn(fastify.log, 'error');
|
||||
const auth0Error = Error('Response Error: 500 Internal Server Error');
|
||||
// @ts-expect-error - mocking a hapi/boom error
|
||||
auth0Error.data = {
|
||||
payload: {
|
||||
error: 'server_error'
|
||||
}
|
||||
};
|
||||
|
||||
getAccessTokenFromAuthorizationCodeFlowSpy.mockRejectedValueOnce(
|
||||
auth0Error
|
||||
);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=invalid'
|
||||
});
|
||||
|
||||
expect(fastify.log.error).toHaveBeenCalledWith(
|
||||
auth0Error,
|
||||
'Auth failed: server_error'
|
||||
);
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('should not create a user if the state is invalid', async () => {
|
||||
await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=invalid'
|
||||
});
|
||||
|
||||
expect(await fastify.prisma.user.count()).toBe(0);
|
||||
});
|
||||
|
||||
test('should block requests with "access_denied" error', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?error=access_denied&error_description=Access denied from your location'
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toMatch(`${HOME_LOCATION}/blocked`);
|
||||
|
||||
const resWithoutDescription = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?error=access_denied'
|
||||
});
|
||||
|
||||
expect(resWithoutDescription.statusCode).toBe(302);
|
||||
expect(resWithoutDescription.headers.location).toMatch(
|
||||
`${HOME_LOCATION}/learn?messages=`
|
||||
);
|
||||
});
|
||||
|
||||
test('creates a user if the state is valid', async () => {
|
||||
mockAuthSuccess();
|
||||
await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=valid'
|
||||
});
|
||||
|
||||
expect(await fastify.prisma.user.count()).toBe(1);
|
||||
});
|
||||
|
||||
test('handles userinfo errors', async () => {
|
||||
getAccessTokenFromAuthorizationCodeFlowSpy.mockResolvedValueOnce({
|
||||
token: 'any token'
|
||||
});
|
||||
userinfoSpy.mockResolvedValueOnce(Promise.reject(Error('any error')));
|
||||
const returnTo = 'https://www.freecodecamp.org/espanol/learn';
|
||||
const count = vi.fn();
|
||||
const distribution = vi.fn();
|
||||
// @ts-expect-error - Only mocks part of the Sentry object.
|
||||
fastify.Sentry = { ...fastify.Sentry, metrics: { count, distribution } };
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=valid',
|
||||
cookies: { 'login-returnto': sign(returnTo) }
|
||||
});
|
||||
|
||||
expect(res.headers.location).toMatch(
|
||||
returnTo +
|
||||
`?${formatMessage({ type: 'danger', content: 'flash.generic-error' })}`
|
||||
);
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(await fastify.prisma.user.count()).toBe(0);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
expect(distribution).toHaveBeenCalledWith(
|
||||
'auth.login_latency_ms',
|
||||
expect.any(Number),
|
||||
{
|
||||
unit: 'millisecond',
|
||||
attributes: { provider: 'auth0', result: 'failure' }
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('captures userinfo errors carrying innerError', async () => {
|
||||
getAccessTokenFromAuthorizationCodeFlowSpy.mockResolvedValueOnce({
|
||||
token: 'any token'
|
||||
});
|
||||
userinfoSpy.mockRejectedValueOnce(
|
||||
Object.assign(new Error('upstream'), { innerError: new Error('inner') })
|
||||
);
|
||||
const returnTo = 'https://www.freecodecamp.org/espanol/learn';
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=valid',
|
||||
cookies: { 'login-returnto': sign(returnTo) }
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('handles invalid userinfo responses', async () => {
|
||||
getAccessTokenFromAuthorizationCodeFlowSpy.mockResolvedValueOnce({
|
||||
token: 'any token'
|
||||
});
|
||||
userinfoSpy.mockResolvedValueOnce(Promise.resolve({}));
|
||||
const returnTo = 'https://www.freecodecamp.org/espanol/learn';
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=valid',
|
||||
cookies: { 'login-returnto': sign(returnTo) }
|
||||
});
|
||||
|
||||
expect(res.headers.location).toMatch(
|
||||
returnTo +
|
||||
`?${formatMessage({ type: 'danger', content: 'flash.no-email-in-userinfo' })}`
|
||||
);
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(await fastify.prisma.user.count()).toBe(0);
|
||||
});
|
||||
|
||||
test('redirects with the signin-success message on success', async () => {
|
||||
mockAuthSuccess();
|
||||
const count = vi.fn();
|
||||
const distribution = vi.fn();
|
||||
// @ts-expect-error - Only mocks part of the Sentry object.
|
||||
fastify.Sentry = { ...fastify.Sentry, metrics: { count, distribution } };
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=valid'
|
||||
});
|
||||
|
||||
expect(res.headers.location).toMatch(
|
||||
`?${formatMessage({ type: 'success', content: 'flash.signin-success' })}`
|
||||
);
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(count).toHaveBeenCalledWith('auth.login_succeeded', 1, {
|
||||
attributes: { provider: 'auth0' }
|
||||
});
|
||||
expect(distribution).toHaveBeenCalledWith(
|
||||
'auth.login_latency_ms',
|
||||
expect.any(Number),
|
||||
{
|
||||
unit: 'millisecond',
|
||||
attributes: { provider: 'auth0', result: 'success' }
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('should set the jwt_access_token cookie', async () => {
|
||||
mockAuthSuccess();
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=valid'
|
||||
});
|
||||
|
||||
expect(res.headers['set-cookie']).toEqual(
|
||||
expect.stringMatching(/jwt_access_token=/)
|
||||
);
|
||||
});
|
||||
|
||||
test('should use the login-returnto cookie if present and valid', async () => {
|
||||
mockAuthSuccess();
|
||||
await fastify.prisma.user.create({
|
||||
data: { ...createUserInput(email), acceptedPrivacyTerms: true }
|
||||
});
|
||||
const returnTo = 'https://www.freecodecamp.org/espanol/learn';
|
||||
// /signin sets the cookie
|
||||
const req = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/signin',
|
||||
headers: {
|
||||
referer: returnTo
|
||||
}
|
||||
});
|
||||
const returnToCookie = req.cookies.find(c => c.name === 'login-returnto');
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=valid',
|
||||
cookies: { 'login-returnto': returnToCookie!.value }
|
||||
});
|
||||
|
||||
expect(res.headers.location).toBe(
|
||||
`${returnTo}?${formatMessage({ type: 'success', content: 'flash.signin-success' })}`
|
||||
);
|
||||
});
|
||||
|
||||
test('should redirect to learn if the user has signed in from the landing page', async () => {
|
||||
mockAuthSuccess();
|
||||
|
||||
const returnTo = 'https://www.freecodecamp.org/';
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=valid',
|
||||
cookies: {
|
||||
'login-returnto': sign(returnTo)
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.headers.location).toEqual(
|
||||
expect.stringContaining('https://www.freecodecamp.org/learn?')
|
||||
);
|
||||
});
|
||||
|
||||
test('should redirect home if the login-returnto cookie is invalid', async () => {
|
||||
mockAuthSuccess();
|
||||
const returnTo = 'https://www.evilcodecamp.org/espanol/learn';
|
||||
// /signin sets the cookie
|
||||
const req = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/signin',
|
||||
headers: {
|
||||
referer: returnTo
|
||||
}
|
||||
});
|
||||
const returnToCookie = req.cookies.find(c => c.name === 'login-returnto');
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=valid',
|
||||
cookies: { 'login-returnto': returnToCookie!.value }
|
||||
});
|
||||
|
||||
expect(res.headers.location).toMatch(HOME_LOCATION);
|
||||
});
|
||||
|
||||
test('should populate the user with the correct data', async () => {
|
||||
mockAuthSuccess();
|
||||
|
||||
await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=valid'
|
||||
});
|
||||
|
||||
const user = await fastify.prisma.user.findFirstOrThrow({
|
||||
where: { email }
|
||||
});
|
||||
|
||||
expect(user).toEqual(newUser(email));
|
||||
expect(user.username).toBe(user.usernameDisplay);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import fastifyOauth2, { type OAuth2Namespace } from '@fastify/oauth2';
|
||||
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||
import { Type } from 'typebox';
|
||||
import { Value } from 'typebox/value';
|
||||
import fp from 'fastify-plugin';
|
||||
|
||||
import { isError } from 'lodash-es';
|
||||
import {
|
||||
API_LOCATION,
|
||||
AUTH0_CLIENT_ID,
|
||||
AUTH0_CLIENT_SECRET,
|
||||
AUTH0_DOMAIN,
|
||||
COOKIE_DOMAIN,
|
||||
HOME_LOCATION
|
||||
} from '../utils/env.js';
|
||||
import { findOrCreateUser } from '../routes/helpers/auth-helpers.js';
|
||||
import { createAccessToken } from '../utils/tokens.js';
|
||||
import { getLoginRedirectParams } from '../utils/redirection.js';
|
||||
import { clientNetInfo } from '../utils/logger.js';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
auth0OAuth: OAuth2Namespace;
|
||||
}
|
||||
}
|
||||
|
||||
const Auth0ErrorSchema = Type.Object({
|
||||
data: Type.Object({
|
||||
payload: Type.Object({
|
||||
error: Type.String()
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
/**
|
||||
* Fastify plugin for Auth0 authentication. This uses fastify-plugin to expose
|
||||
* the auth0OAuth decorator (for easier testing), but to maintain encapsulation
|
||||
* it should be registered in a plugin. That prevents auth0OAuth from being
|
||||
* available globally.
|
||||
*
|
||||
* @param fastify - The Fastify instance.
|
||||
* @param _options - The plugin options.
|
||||
* @param done - The callback function.
|
||||
*/
|
||||
export const auth0Client: FastifyPluginCallbackTypebox = fp(
|
||||
(fastify, _options, done) => {
|
||||
void fastify.register(fastifyOauth2, {
|
||||
name: 'auth0OAuth',
|
||||
scope: ['openid', 'email', 'profile'],
|
||||
credentials: {
|
||||
client: {
|
||||
id: AUTH0_CLIENT_ID,
|
||||
secret: AUTH0_CLIENT_SECRET
|
||||
}
|
||||
},
|
||||
discovery: { issuer: `https://${AUTH0_DOMAIN}` },
|
||||
callbackUri: `${API_LOCATION}/auth/auth0/callback`,
|
||||
cookie: {
|
||||
// It's important not to sign the cookie, since the OAuth2 plugin will
|
||||
// not unsign it.
|
||||
signed: false
|
||||
}
|
||||
});
|
||||
|
||||
void fastify.register(function (fastify, _options, done) {
|
||||
// TODO(Post-MVP): move this into the app, so that we add this hook once for
|
||||
// all auth routes.
|
||||
fastify.addHook('onRequest', fastify.redirectIfSignedIn);
|
||||
|
||||
fastify.get('/signin', async function (request, reply) {
|
||||
const returnTo = request.headers.referer ?? `${HOME_LOCATION}/learn`;
|
||||
void reply.setCookie('login-returnto', returnTo, {
|
||||
domain: COOKIE_DOMAIN,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
signed: true,
|
||||
sameSite: 'lax'
|
||||
});
|
||||
|
||||
const redirectUrl = await this.auth0OAuth.generateAuthorizationUri(
|
||||
request,
|
||||
reply
|
||||
);
|
||||
void reply.redirect(redirectUrl);
|
||||
});
|
||||
|
||||
fastify.get('/signin/google', async function (request, reply) {
|
||||
const returnTo = request.headers.referer ?? `${HOME_LOCATION}/learn`;
|
||||
void reply.setCookie('login-returnto', returnTo, {
|
||||
domain: COOKIE_DOMAIN,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
signed: true,
|
||||
sameSite: 'lax'
|
||||
});
|
||||
|
||||
const authorizationEndpoint =
|
||||
await this.auth0OAuth.generateAuthorizationUri(request, reply);
|
||||
|
||||
const url = new URL(authorizationEndpoint);
|
||||
url.searchParams.set('connection', 'google-oauth2');
|
||||
|
||||
void reply.redirect(url.toString());
|
||||
});
|
||||
done();
|
||||
});
|
||||
|
||||
// TODO: use a schema to validate the query params.
|
||||
fastify.get('/auth/auth0/callback', async function (req, reply) {
|
||||
const { error, error_description } = req.query as Record<string, string>;
|
||||
if (error === 'access_denied') {
|
||||
const blockedByLaw =
|
||||
error_description === 'Access denied from your location';
|
||||
if (blockedByLaw) {
|
||||
req.log.info('Access denied due to user location');
|
||||
return reply.redirect(`${HOME_LOCATION}/blocked`);
|
||||
} else {
|
||||
req.log.info(
|
||||
{ errorDescription: error_description, ...clientNetInfo(req) },
|
||||
'Authentication failed for user'
|
||||
);
|
||||
return reply.redirectWithMessage(`${HOME_LOCATION}/learn`, {
|
||||
type: 'info',
|
||||
content: error_description ?? 'Authentication failed'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { returnTo, origin } = getLoginRedirectParams(req);
|
||||
|
||||
let token;
|
||||
try {
|
||||
token = (
|
||||
await this.auth0OAuth.getAccessTokenFromAuthorizationCodeFlow(req)
|
||||
).token;
|
||||
} catch (error) {
|
||||
fastify.Sentry?.metrics?.count('auth.failed', 1, {
|
||||
attributes: { stage: 'token' }
|
||||
});
|
||||
// This is the plugin's error message. If it changes, we will either
|
||||
// have to update the test or write custom state create/verify
|
||||
// functions.
|
||||
if (error instanceof Error && error.message === 'Invalid state') {
|
||||
req.log.warn(error, 'Auth failed: invalid state');
|
||||
} else if (Value.Check(Auth0ErrorSchema, error)) {
|
||||
const errorType = error.data.payload.error;
|
||||
const expectedErrorTypes = ['invalid_grant', 'access_denied'];
|
||||
if (!expectedErrorTypes.includes(errorType)) {
|
||||
fastify.Sentry?.captureException(error);
|
||||
}
|
||||
req.log.error(error, 'Auth failed: ' + errorType);
|
||||
} else {
|
||||
fastify.Sentry?.captureException(error);
|
||||
req.log.error(error, 'Failed to get access token from Auth0');
|
||||
}
|
||||
// It's important _not_ to redirect to /signin here, as that could
|
||||
// create an infinite loop.
|
||||
return reply.redirectWithMessage(returnTo, {
|
||||
type: 'danger',
|
||||
content: 'flash.generic-error'
|
||||
});
|
||||
}
|
||||
|
||||
let email;
|
||||
const __userinfoStart = performance.now();
|
||||
try {
|
||||
const userinfo = (await fastify.auth0OAuth.userinfo(token)) as {
|
||||
email: string;
|
||||
};
|
||||
fastify.Sentry?.metrics?.distribution(
|
||||
'auth.login_latency_ms',
|
||||
performance.now() - __userinfoStart,
|
||||
{
|
||||
unit: 'millisecond',
|
||||
attributes: { provider: 'auth0', result: 'success' }
|
||||
}
|
||||
);
|
||||
req.log.debug(
|
||||
{ hasEmail: !!userinfo.email },
|
||||
'Received Auth0 userinfo'
|
||||
);
|
||||
email = userinfo.email;
|
||||
if (typeof email !== 'string') {
|
||||
req.log.warn('Auth0 userinfo missing email');
|
||||
return reply.redirectWithMessage(returnTo, {
|
||||
type: 'danger',
|
||||
content: 'flash.no-email-in-userinfo'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
fastify.Sentry?.metrics?.distribution(
|
||||
'auth.login_latency_ms',
|
||||
performance.now() - __userinfoStart,
|
||||
{
|
||||
unit: 'millisecond',
|
||||
attributes: { provider: 'auth0', result: 'failure' }
|
||||
}
|
||||
);
|
||||
fastify.Sentry?.metrics?.count('auth.failed', 1, {
|
||||
attributes: { stage: 'userinfo' }
|
||||
});
|
||||
if (isError(error) && 'innerError' in error) {
|
||||
// This is a specific error from the @fastify/oauth2 plugin.
|
||||
const innerError = error.innerError as Error;
|
||||
innerError.message = `Auth0 userinfo error: ${innerError.message}`;
|
||||
fastify.Sentry?.captureException(innerError);
|
||||
req.log.error(innerError, 'Failed to get userinfo from Auth0');
|
||||
} else {
|
||||
fastify.Sentry?.captureException(error);
|
||||
req.log.error(error, 'Failed to get userinfo from Auth0');
|
||||
}
|
||||
return reply.redirectWithMessage(returnTo, {
|
||||
type: 'danger',
|
||||
content: 'flash.generic-error'
|
||||
});
|
||||
}
|
||||
|
||||
const { id } = await findOrCreateUser(fastify, email);
|
||||
|
||||
reply.setAccessTokenCookie(createAccessToken(id));
|
||||
|
||||
fastify.Sentry?.metrics?.count('auth.login_succeeded', 1, {
|
||||
attributes: { provider: 'auth0' }
|
||||
});
|
||||
|
||||
const returnPath = new URL(returnTo).pathname;
|
||||
const returnURL = returnPath === '/' ? `${origin}/learn` : returnTo;
|
||||
|
||||
void reply.redirectWithMessage(returnURL, {
|
||||
type: 'success',
|
||||
content: 'flash.signin-success'
|
||||
});
|
||||
});
|
||||
|
||||
done();
|
||||
},
|
||||
// TODO(Post-MVP): remove bouncer dependency when moving redirectIfSignedIn
|
||||
// out of this plugin.
|
||||
{ dependencies: ['redirect-with-message', 'bouncer'] }
|
||||
);
|
||||
@@ -0,0 +1,168 @@
|
||||
/* eslint-disable @typescript-eslint/require-await */
|
||||
import {
|
||||
describe,
|
||||
test,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
vi,
|
||||
MockInstance
|
||||
} from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { type user } from '@prisma/client';
|
||||
|
||||
import { HOME_LOCATION } from '../utils/env.js';
|
||||
import bouncer from './bouncer.js';
|
||||
import auth from './auth.js';
|
||||
import cookies from './cookies.js';
|
||||
import redirectWithMessage, { formatMessage } from './redirect-with-message.js';
|
||||
|
||||
let authorizeSpy: MockInstance<FastifyInstance['authorize']>;
|
||||
|
||||
async function setupServer() {
|
||||
const fastify = Fastify();
|
||||
await fastify.register(cookies);
|
||||
await fastify.register(auth);
|
||||
authorizeSpy = vi.spyOn(fastify, 'authorize');
|
||||
|
||||
await fastify.register(redirectWithMessage);
|
||||
await fastify.register(bouncer);
|
||||
fastify.addHook('onRequest', fastify.authorize);
|
||||
fastify.get('/', (_req, reply) => {
|
||||
void reply.send({ foo: 'bar' });
|
||||
});
|
||||
return fastify;
|
||||
}
|
||||
|
||||
describe('bouncer', () => {
|
||||
let fastify: FastifyInstance;
|
||||
beforeEach(async () => {
|
||||
fastify = await setupServer();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastify.close();
|
||||
});
|
||||
|
||||
describe('send401IfNoUser', () => {
|
||||
beforeEach(() => {
|
||||
fastify.addHook('onRequest', fastify.send401IfNoUser);
|
||||
});
|
||||
|
||||
test('should return 401 if NO user is present', async () => {
|
||||
const message = {
|
||||
type: 'info' as const,
|
||||
content: 'Something undesirable occurred'
|
||||
};
|
||||
authorizeSpy.mockImplementationOnce(async req => {
|
||||
req.accessDeniedMessage = message;
|
||||
});
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/'
|
||||
});
|
||||
|
||||
expect(res.json()).toStrictEqual({
|
||||
type: message.type,
|
||||
message: message.content
|
||||
});
|
||||
expect(res.statusCode).toEqual(401);
|
||||
});
|
||||
|
||||
test('should not alter the response if a user is present', async () => {
|
||||
authorizeSpy.mockImplementationOnce(async req => {
|
||||
req.user = { id: '123' } as user;
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/'
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({ foo: 'bar' });
|
||||
expect(res.statusCode).toEqual(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redirectIfNoUser', () => {
|
||||
beforeEach(() => {
|
||||
fastify.addHook('onRequest', fastify.redirectIfNoUser);
|
||||
});
|
||||
const redirectLocation = `${HOME_LOCATION}?${formatMessage({ type: 'info', content: 'Only authenticated users can access this route. Please sign in and try again.' })}`;
|
||||
|
||||
// TODO(Post-MVP): make the redirects consistent between redirectIfNoUser
|
||||
// and redirectIfSignedIn. Either both should redirect to the referer or
|
||||
// both should redirect to HOME_LOCATION.
|
||||
test('should redirect to HOME_LOCATION if NO user is present', async () => {
|
||||
const message = {
|
||||
type: 'info' as const,
|
||||
content: 'At the moment, content is ignored'
|
||||
};
|
||||
authorizeSpy.mockImplementationOnce(async req => {
|
||||
req.accessDeniedMessage = message;
|
||||
});
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/'
|
||||
});
|
||||
|
||||
expect(res.headers.location).toBe(redirectLocation);
|
||||
expect(res.statusCode).toEqual(302);
|
||||
});
|
||||
|
||||
test('should not alter the response if a user is present', async () => {
|
||||
authorizeSpy.mockImplementationOnce(async req => {
|
||||
req.user = { id: '123' } as user;
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/'
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({ foo: 'bar' });
|
||||
expect(res.statusCode).toEqual(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redirectIfSignedIn', () => {
|
||||
beforeEach(() => {
|
||||
fastify.addHook('onRequest', fastify.redirectIfSignedIn);
|
||||
});
|
||||
|
||||
test('should redirect to the referer if a user is present', async () => {
|
||||
authorizeSpy.mockImplementationOnce(async req => {
|
||||
req.user = { id: '123' } as user;
|
||||
});
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
headers: {
|
||||
referer: 'https://www.freecodecamp.org/some/other/path'
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.headers.location).toBe(
|
||||
'https://www.freecodecamp.org/some/other/path'
|
||||
);
|
||||
expect(res.statusCode).toEqual(302);
|
||||
});
|
||||
|
||||
test('should not alter the response if NO user is present', async () => {
|
||||
const message = {
|
||||
type: 'info' as const,
|
||||
content: 'At the moment, content is ignored'
|
||||
};
|
||||
authorizeSpy.mockImplementationOnce(async req => {
|
||||
req.accessDeniedMessage = message;
|
||||
});
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/'
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({ foo: 'bar' });
|
||||
expect(res.statusCode).toEqual(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type {
|
||||
FastifyPluginCallback,
|
||||
FastifyRequest,
|
||||
FastifyReply
|
||||
} from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
import { getRedirectParams } from '../utils/redirection.js';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
send401IfNoUser: (req: FastifyRequest, reply: FastifyReply) => void;
|
||||
redirectIfNoUser: (req: FastifyRequest, reply: FastifyReply) => void;
|
||||
redirectIfSignedIn: (req: FastifyRequest, reply: FastifyReply) => void;
|
||||
}
|
||||
}
|
||||
|
||||
const plugin: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
fastify.decorate(
|
||||
'send401IfNoUser',
|
||||
async function (req: FastifyRequest, reply: FastifyReply) {
|
||||
if (!req.user) {
|
||||
req.log.trace(
|
||||
'Protected route accessed by unauthenticated user. Sent 401.'
|
||||
);
|
||||
|
||||
await reply.status(401).send({
|
||||
type: req.accessDeniedMessage?.type,
|
||||
message: req.accessDeniedMessage?.content
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
fastify.decorate(
|
||||
'redirectIfNoUser',
|
||||
async function (req: FastifyRequest, reply: FastifyReply) {
|
||||
if (!req.user) {
|
||||
req.log.trace(
|
||||
'Protected route accessed by unauthenticated user. Redirecting to login.'
|
||||
);
|
||||
const { origin } = getRedirectParams(req);
|
||||
await reply.redirectWithMessage(origin, {
|
||||
type: 'info',
|
||||
content:
|
||||
'Only authenticated users can access this route. Please sign in and try again.'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
fastify.decorate(
|
||||
'redirectIfSignedIn',
|
||||
async function (req: FastifyRequest, reply: FastifyReply) {
|
||||
if (req.user) {
|
||||
const { returnTo } = getRedirectParams(req);
|
||||
|
||||
req.log.trace({ returnTo }, 'Signed-in user redirected');
|
||||
|
||||
await reply.redirect(returnTo);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
export default fp(plugin, {
|
||||
dependencies: ['auth', 'redirect-with-message'],
|
||||
name: 'bouncer'
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { FastifyInstance } from 'fastify';
|
||||
import cookies, { type CookieSerializeOptions, sign } from './cookies.js';
|
||||
import { cookieUpdate } from './cookie-update.js';
|
||||
|
||||
vi.mock('../utils/env', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('../utils/env.js')>();
|
||||
return {
|
||||
...actual,
|
||||
COOKIE_DOMAIN: 'www.example.com',
|
||||
FREECODECAMP_NODE_ENV: 'not-development'
|
||||
};
|
||||
});
|
||||
|
||||
describe('Cookie updates', () => {
|
||||
let fastify: FastifyInstance;
|
||||
|
||||
const setup = async (attributes: CookieSerializeOptions) => {
|
||||
// Since register creates a new scope, we need to create a route inside the
|
||||
// scope for the plugin to be applied.
|
||||
await fastify.register(cookieUpdate, fastify => {
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
fastify.get('/', async () => {
|
||||
return { hello: 'world' };
|
||||
});
|
||||
return {
|
||||
cookies: ['cookie_name'],
|
||||
attributes
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
fastify = Fastify();
|
||||
await fastify.register(cookies);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastify.close();
|
||||
});
|
||||
|
||||
test('should not set cookies that are not in the request', async () => {
|
||||
await setup({});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
headers: {
|
||||
cookie: 'cookie_name_two=cookie_value'
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.headers['set-cookie']).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should update the cookie's attributes without changing the value", async () => {
|
||||
await setup({ sameSite: 'strict' });
|
||||
const signedCookie = sign('cookie_value');
|
||||
const encodedCookie = encodeURIComponent(signedCookie);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
headers: {
|
||||
cookie: `cookie_name=${signedCookie}`
|
||||
}
|
||||
});
|
||||
|
||||
const updatedCookie = res.headers['set-cookie'] as string;
|
||||
expect(updatedCookie).toEqual(
|
||||
expect.stringContaining(`cookie_name=${encodedCookie}`)
|
||||
);
|
||||
expect(updatedCookie).toEqual(expect.stringContaining('SameSite=Strict'));
|
||||
});
|
||||
|
||||
test('should unsign the cookie if required', async () => {
|
||||
await setup({ signed: false });
|
||||
const signedCookie = sign('cookie_value');
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
headers: {
|
||||
cookie: `cookie_name=${signedCookie}`
|
||||
}
|
||||
});
|
||||
|
||||
const updatedCookie = res.headers['set-cookie'] as string;
|
||||
expect(updatedCookie).toEqual(
|
||||
expect.stringContaining('cookie_name=cookie_value')
|
||||
);
|
||||
});
|
||||
|
||||
test('should respect the default cookie config if not overriden', async () => {
|
||||
await setup({});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
headers: {
|
||||
cookie: 'cookie_name=anything'
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.cookies[0]).toEqual({
|
||||
domain: 'www.example.com',
|
||||
httpOnly: true,
|
||||
name: 'cookie_name',
|
||||
path: '/',
|
||||
sameSite: 'Lax',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
value: expect.any(String),
|
||||
secure: true
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { FastifyPluginCallback } from 'fastify';
|
||||
|
||||
import type { CookieSerializeOptions } from './cookies.js';
|
||||
|
||||
type Options = { cookies: string[]; attributes: CookieSerializeOptions };
|
||||
|
||||
/**
|
||||
* Plugin that updates the attributes of cookies in the response, without
|
||||
* changing the value.
|
||||
*
|
||||
* @param fastify The Fastify instance.
|
||||
* @param options Options passed to the plugin via `fastify.register(plugin,
|
||||
* options)`.
|
||||
* @param options.cookies The names of the cookies to update.
|
||||
* @param options.attributes The attributes to update the cookies with. NOTE:
|
||||
* The attributes are merged with the default values given to \@fastify/cookie.
|
||||
* @param done Callback to signal that the logic has completed.
|
||||
*/
|
||||
export const cookieUpdate: FastifyPluginCallback<Options> = (
|
||||
fastify,
|
||||
options,
|
||||
done
|
||||
) => {
|
||||
fastify.addHook('onSend', (request, reply, _payload, next) => {
|
||||
for (const cookie of options.cookies) {
|
||||
const oldCookie = request.cookies[cookie];
|
||||
if (!oldCookie) continue;
|
||||
|
||||
const unsigned = reply.unsignCookie(oldCookie);
|
||||
const raw = unsigned.valid ? unsigned.value : oldCookie;
|
||||
void reply.setCookie(cookie, raw, options.attributes);
|
||||
}
|
||||
|
||||
request.log.trace('Updated cookies');
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
done();
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user