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

This commit is contained in:
wehub-resource-sync
2026-07-13 11:55:53 +08:00
commit dde272c4b8
19405 changed files with 2730632 additions and 0 deletions
+31
View File
@@ -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
+83
View File
@@ -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"
}
}
]
}
}
}
}
}
+41
View File
@@ -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
+11
View File
@@ -0,0 +1,11 @@
client/.cache
client/public
.env
.git
.gitignore
.dockerignore
docker/**/Dockerfile
**/*docker-compose*
**/node_modules
.eslintcache
+16
View File
@@ -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
+15
View File
@@ -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
+36
View File
@@ -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
+3
View File
@@ -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
+11
View File
@@ -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.
+14
View File
@@ -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::"
+186
View File
@@ -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 `&mdash;`.
```
---
## 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.
+26
View File
@@ -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 &amp;&amp; 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
+332
View File
@@ -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"
+381
View File
@@ -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
+63
View File
@@ -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
+35
View File
@@ -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'
+125
View File
@@ -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
+49
View File
@@ -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' }}
+190
View File
@@ -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
+166
View File
@@ -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
+71
View File
@@ -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"]
});
+24
View File
@@ -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.');
}
+170
View File
@@ -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 }}'
});
+26
View File
@@ -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
+61
View File
@@ -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."
})
+316
View File
@@ -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
View File
@@ -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
+8
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
NODE_OPTIONS=\"--max-old-space-size=7168\" pnpm lint-staged
+1
View File
@@ -0,0 +1 @@
24
+17
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
{
"endOfLine": "lf",
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true,
"tabWidth": 2,
"trailingComma": "none",
"arrowParens": "avoid"
}
+2
View File
@@ -0,0 +1,2 @@
playwright
client/public
+23
View File
@@ -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
View File
@@ -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.
+95
View File
@@ -0,0 +1,95 @@
[![freeCodeCamp Social Banner](https://cdn.freecodecamp.org/platform/universal/fcc_banner_new.png)](https://www.freecodecamp.org/)
[![first-timers-only Friendly](https://img.shields.io/badge/first--timers--only-friendly-blue.svg)](https://www.firsttimersonly.com/)
[![Discord](https://img.shields.io/discord/692816967895220344?logo=discord&label=Discord&color=5865F2)](https://discord.gg/PRyKn3Vbay)
[![LFX Active Contributors](https://insights.linuxfoundation.org/api/badge/active-contributors?project=freecodecamp&repos=https://github.com/freeCodeCamp/freeCodeCamp)](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:
![Alt](https://repobeats.axiom.co/api/embed/89be0a1a1c8f641c54f9234a7423e7755352c746.svg 'Repobeats analytics image')
### 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
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`freeCodeCamp/freeCodeCamp`
- 原始仓库:https://github.com/freeCodeCamp/freeCodeCamp
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+1
View File
@@ -0,0 +1 @@
dist/
+4
View File
@@ -0,0 +1,4 @@
/* eslint-disable filenames-simple/naming-convention */
import { createLintStagedConfig } from '@freecodecamp/eslint-config/lintstaged';
export default createLintStagedConfig(import.meta.dirname);
+400
View File
@@ -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) }
});
}
+233
View File
@@ -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
};
+36
View File
@@ -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
}
];
+95
View File
@@ -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"
}
+5
View File
@@ -0,0 +1,5 @@
import type { PrismaConfig } from 'prisma';
export default {
schema: 'prisma'
} satisfies PrismaConfig;
+58
View File
@@ -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])
}
+253
View File
@@ -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
}
+376
View File
@@ -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
View File
@@ -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;
};
+1
View File
@@ -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));
}
+109
View File
@@ -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
});
});
});
});
+89
View File
@@ -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;
+24
View File
@@ -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));
});
});
+17
View File
@@ -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
}
};
+10
View File
@@ -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
}
};
+64
View File
@@ -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']);
}
+46
View File
@@ -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
});
+109
View File
@@ -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: []
});
+156
View File
@@ -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`);
});
});
});
+48
View File
@@ -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();
};
+605
View File
@@ -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' }
});
});
});
});
+246
View File
@@ -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'] });
+522
View File
@@ -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);
});
});
});
+241
View File
@@ -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'] }
);
+168
View File
@@ -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);
});
});
});
+70
View File
@@ -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'
});
+116
View File
@@ -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
});
});
});
+40
View File
@@ -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