mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-08-31 01:14:08 +08:00
feat: move logs to sentry
refactor(api): logging taxonomy and sentry o11y (#68508) refactor(api): harden sentry o11y and narrow catches (#68526) feat(api): logging review and improve metrics(#68579)
This commit is contained in:
committed by
Mrugesh Mohapatra
parent
1f1f8a296c
commit
cd94fbf5b9
@@ -4,7 +4,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
api_log_lvl:
|
||||
description: "Log level for the API"
|
||||
description: 'Log level for the API'
|
||||
type: choice
|
||||
options:
|
||||
- debug
|
||||
@@ -12,7 +12,7 @@ on:
|
||||
- warn
|
||||
default: info
|
||||
show_upcoming_changes:
|
||||
description: "Show upcoming changes (enables upcoming certifications and challenges)"
|
||||
description: 'Show upcoming changes (enables upcoming certifications and challenges)'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
@@ -42,23 +42,23 @@ jobs:
|
||||
|
||||
# Convert boolean input to string 'true' or 'false'
|
||||
if [[ "$SHOW_UPCOMING_CHANGES" == "true" ]]; then
|
||||
echo "show_upcoming_changes=true" >> $GITHUB_OUTPUT
|
||||
echo "show_upcoming_changes=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "show_upcoming_changes=false" >> $GITHUB_OUTPUT
|
||||
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=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
|
||||
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
|
||||
|
||||
@@ -73,6 +73,7 @@ jobs:
|
||||
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 }}
|
||||
@@ -134,22 +135,22 @@ jobs:
|
||||
|
||||
if ! tailscale status | grep -q "$machine_name"; then
|
||||
echo "Machine $machine_name not found in Tailscale network"
|
||||
if [ $attempt -eq $max_retries ]; then
|
||||
if [ "$attempt" -eq "$max_retries" ]; then
|
||||
return 1
|
||||
fi
|
||||
sleep $retry_delay
|
||||
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
|
||||
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
|
||||
if [ "$attempt" -lt "$max_retries" ]; then
|
||||
sleep "$retry_delay"
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -189,7 +190,6 @@ jobs:
|
||||
# SOCRATES_API_KEY
|
||||
# SOCRATES_ENDPOINT
|
||||
# STRIPE_SECRET_KEY
|
||||
# LOKI_URL
|
||||
# Variables set from SetupJob
|
||||
DEPLOYMENT_VERSION: ${{ needs.build.outputs.tagname }}
|
||||
DEPLOYMENT_ENV: ${{ needs.setup-jobs.outputs.tgt_env_long }}
|
||||
@@ -260,7 +260,6 @@ jobs:
|
||||
\"SOCRATES_API_KEY\"
|
||||
\"SOCRATES_ENDPOINT\"
|
||||
\"STRIPE_SECRET_KEY\"
|
||||
\"LOKI_URL\"
|
||||
\"DEPLOYMENT_VERSION\"
|
||||
\"DEPLOYMENT_TLD\"
|
||||
\"DEPLOYMENT_ENV\"
|
||||
@@ -311,5 +310,21 @@ jobs:
|
||||
|
||||
echo -e '\nLOG:Finished deployment.'
|
||||
"
|
||||
MACHINE_IP=$(tailscale ip -4 $TS_MACHINE_NAME)
|
||||
ssh $TS_USERNAME@$MACHINE_IP "$REMOTE_SCRIPT"
|
||||
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"
|
||||
|
||||
# Source maps for this release were uploaded (unfinalized) during the
|
||||
# build job. Finalize the release once the stack deploy succeeds.
|
||||
# TODO: gate on a rollback-safe convergence check (docker service state);
|
||||
# the public-edge probe was removed because Cloudflare challenges the CI IP.
|
||||
- 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"
|
||||
|
||||
@@ -43,6 +43,9 @@ on:
|
||||
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'
|
||||
@@ -65,8 +68,8 @@ jobs:
|
||||
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
|
||||
echo "tagname=$tagname" >> "$GITHUB_ENV"
|
||||
echo "tagname=$tagname" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
@@ -92,3 +95,27 @@ jobs:
|
||||
registry.digitalocean.com/${{ secrets.DOCR_NAME }}/${{ inputs.site_tld }}/learn-${{ inputs.app }}:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Extract the compiled dist (with source maps) from the image we just
|
||||
# pushed, so the maps match the exact artifact that ships. The release is
|
||||
# uploaded here but left UNFINALIZED — deploy-api.yml finalizes it only
|
||||
# after the rollout is verified live.
|
||||
- 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/dist
|
||||
|
||||
+2
-1
@@ -14,7 +14,8 @@
|
||||
"@freecodecamp/shared": "workspace:*",
|
||||
"@growthbook/growthbook": "1.6.5",
|
||||
"@prisma/client": "6.19.3",
|
||||
"@sentry/node": "9.47.1",
|
||||
"@sentry/node": "10.55.0",
|
||||
"@sentry/profiling-node": "10.55.0",
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1",
|
||||
"bson": "7.2.0",
|
||||
|
||||
+7
-15
@@ -1,4 +1,3 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import fastifyAccepts from '@fastify/accepts';
|
||||
import fastifySwagger from '@fastify/swagger';
|
||||
import fastifySwaggerUI from '@fastify/swagger-ui';
|
||||
@@ -25,6 +24,7 @@ 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';
|
||||
@@ -47,7 +47,8 @@ import {
|
||||
GROWTHBOOK_FASTIFY_CLIENT_KEY
|
||||
} from './utils/env.js';
|
||||
import { isObjectID } from './utils/validation.js';
|
||||
import { getLogger } from './utils/logger.js';
|
||||
import { bindRouteToLogger, genReqId, getLogger } from './utils/logger.js';
|
||||
import { recordHttpMetrics } from './utils/http-metrics.js';
|
||||
import {
|
||||
examEnvironmentOpenRoutes,
|
||||
examEnvironmentValidatedTokenRoutes
|
||||
@@ -86,9 +87,7 @@ export const buildOptions: FastifyHttpOptions<
|
||||
FastifyBaseLogger
|
||||
> = {
|
||||
loggerInstance: getLogger(),
|
||||
genReqId: () => randomBytes(8).toString('hex'),
|
||||
// disabled so we can customise the request/response logging
|
||||
disableRequestLogging: true,
|
||||
genReqId,
|
||||
// destroy all connections on close to avoid EADDRINUSE
|
||||
// on restart, in development. Leave default in production.
|
||||
forceCloseConnections:
|
||||
@@ -110,22 +109,15 @@ export const build = async (
|
||||
const fastify = Fastify(options).withTypeProvider<TypeBoxTypeProvider>();
|
||||
|
||||
fastify.setValidatorCompiler(({ schema }) => ajv.compile(schema));
|
||||
fastify.addHook('onRequest', (req, _reply, done) => {
|
||||
const logger = fastify.log.child({ req });
|
||||
logger.debug({ req }, 'received request');
|
||||
done();
|
||||
});
|
||||
|
||||
fastify.addHook('onResponse', (req, reply, done) => {
|
||||
const logger = fastify.log.child({ res: reply });
|
||||
logger.debug({ req, res: reply }, 'responding to request');
|
||||
done();
|
||||
});
|
||||
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);
|
||||
|
||||
@@ -122,6 +122,13 @@ describe('/daily-coding-challenge', () => {
|
||||
});
|
||||
|
||||
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}`,
|
||||
{
|
||||
@@ -134,9 +141,21 @@ describe('/daily-coding-challenge', () => {
|
||||
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}`,
|
||||
{
|
||||
@@ -149,6 +168,11 @@ describe('/daily-coding-challenge', () => {
|
||||
...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 () => {
|
||||
@@ -177,6 +201,13 @@ describe('/daily-coding-challenge', () => {
|
||||
});
|
||||
|
||||
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({});
|
||||
@@ -186,11 +217,23 @@ describe('/daily-coding-challenge', () => {
|
||||
...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({});
|
||||
@@ -200,6 +243,11 @@ describe('/daily-coding-challenge', () => {
|
||||
type: 'error',
|
||||
message: 'Challenge not found.'
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('dcc.challenge_not_found', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/today' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -234,6 +282,13 @@ describe('/daily-coding-challenge', () => {
|
||||
});
|
||||
|
||||
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({});
|
||||
@@ -256,6 +311,11 @@ describe('/daily-coding-challenge', () => {
|
||||
|
||||
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 () => {
|
||||
@@ -280,6 +340,13 @@ describe('/daily-coding-challenge', () => {
|
||||
});
|
||||
|
||||
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({});
|
||||
@@ -289,6 +356,11 @@ describe('/daily-coding-challenge', () => {
|
||||
type: 'error',
|
||||
message: 'No challenges found.'
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('dcc.challenge_not_found', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/month/:month' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -304,6 +376,13 @@ describe('/daily-coding-challenge', () => {
|
||||
});
|
||||
|
||||
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({});
|
||||
@@ -329,11 +408,23 @@ describe('/daily-coding-challenge', () => {
|
||||
|
||||
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({});
|
||||
@@ -343,6 +434,11 @@ describe('/daily-coding-challenge', () => {
|
||||
type: 'error',
|
||||
message: 'No challenges found.'
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('dcc.challenge_not_found', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/all' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -358,6 +454,13 @@ describe('/daily-coding-challenge', () => {
|
||||
});
|
||||
|
||||
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({});
|
||||
@@ -366,11 +469,23 @@ describe('/daily-coding-challenge', () => {
|
||||
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({});
|
||||
@@ -380,6 +495,153 @@ describe('/daily-coding-challenge', () => {
|
||||
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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,8 +27,7 @@ export const dailyCodingChallengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
schema: schemas.dailyCodingChallenge.date
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
logger.info(
|
||||
req.log.info(
|
||||
{ date: req.params.date },
|
||||
'Received request for daily coding challenge'
|
||||
);
|
||||
@@ -39,7 +38,7 @@ export const dailyCodingChallengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
const parsedDate = dateStringToUtcMidnight(date);
|
||||
|
||||
if (!parsedDate) {
|
||||
logger.warn({ date }, 'Invalid date format requested');
|
||||
req.log.warn({ date }, 'Invalid date format requested');
|
||||
return reply.status(400).send({
|
||||
type: 'error',
|
||||
message: 'Invalid date format. Please use YYYY-MM-DD.'
|
||||
@@ -54,18 +53,28 @@ export const dailyCodingChallengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
|
||||
// don't return challenges > today US Central
|
||||
if (!challenge || challenge.date > getUtcMidnight(getNowUsCentral())) {
|
||||
logger.warn({ date: parsedDate }, 'Challenge not found for date');
|
||||
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) {
|
||||
logger.error(error, 'Failed to get daily coding challenge.');
|
||||
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.' });
|
||||
@@ -79,8 +88,7 @@ export const dailyCodingChallengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
schema: schemas.dailyCodingChallenge.today
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
logger.info("Received request for today's daily coding challenge");
|
||||
req.log.info("Received request for today's daily coding challenge");
|
||||
|
||||
const today = getUtcMidnight(getNowUsCentral());
|
||||
|
||||
@@ -93,18 +101,28 @@ export const dailyCodingChallengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
});
|
||||
|
||||
if (!todaysChallenge) {
|
||||
logger.warn({ date: today }, 'Challenge not found for today');
|
||||
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) {
|
||||
logger.error(error, "Failed to get today's daily coding challenge.");
|
||||
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.' });
|
||||
@@ -118,8 +136,7 @@ export const dailyCodingChallengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
schema: schemas.dailyCodingChallenge.month
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
logger.info(
|
||||
req.log.info(
|
||||
{ month: req.params.month },
|
||||
'Received request for month of daily coding challenges'
|
||||
);
|
||||
@@ -134,7 +151,7 @@ export const dailyCodingChallengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
|
||||
// Validate month range
|
||||
if (parsedMonth < 1 || parsedMonth > 12) {
|
||||
logger.warn({ month }, 'Invalid month value requested');
|
||||
req.log.warn({ month }, 'Invalid month value requested');
|
||||
return reply.status(400).send({
|
||||
type: 'error',
|
||||
message: 'Invalid date format. Please use YYYY-MM.'
|
||||
@@ -165,7 +182,10 @@ export const dailyCodingChallengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
});
|
||||
|
||||
if (!challenges || challenges.length === 0) {
|
||||
logger.warn({ month }, 'No challenges found for month');
|
||||
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.' });
|
||||
@@ -176,9 +196,16 @@ export const dailyCodingChallengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
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) {
|
||||
logger.error(error, 'Failed to get monthly daily coding challenges.');
|
||||
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.' });
|
||||
@@ -192,8 +219,7 @@ export const dailyCodingChallengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
schema: schemas.dailyCodingChallenge.all
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
logger.info('Received request for all daily coding challenges');
|
||||
req.log.info('Received request for all daily coding challenges');
|
||||
|
||||
const today = getUtcMidnight(getNowUsCentral());
|
||||
|
||||
@@ -218,7 +244,10 @@ export const dailyCodingChallengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
});
|
||||
|
||||
if (!allChallenges || allChallenges.length === 0) {
|
||||
logger.warn({ date: today }, 'No challenges found.');
|
||||
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.' });
|
||||
@@ -229,9 +258,16 @@ export const dailyCodingChallengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
date: challenge.date.toISOString()
|
||||
}));
|
||||
|
||||
fastify.Sentry?.metrics?.count('dcc.challenge_viewed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/all' }
|
||||
});
|
||||
return reply.send(response);
|
||||
} catch (error) {
|
||||
logger.error(error, 'Failed to get all daily coding challenges.');
|
||||
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.' });
|
||||
@@ -245,8 +281,7 @@ export const dailyCodingChallengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
schema: schemas.dailyCodingChallenge.newest
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
logger.info('Received request for newest daily coding challenge');
|
||||
req.log.info('Received request for newest daily coding challenge');
|
||||
|
||||
try {
|
||||
const newestChallenge =
|
||||
@@ -260,15 +295,25 @@ export const dailyCodingChallengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
});
|
||||
|
||||
if (!newestChallenge) {
|
||||
logger.warn('No challenges found.');
|
||||
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) {
|
||||
logger.error(error, 'Failed to get newest daily coding challenge.');
|
||||
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.' });
|
||||
|
||||
+47
-20
@@ -1,9 +1,11 @@
|
||||
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 {
|
||||
@@ -22,7 +24,11 @@ const prismaPlugin: FastifyPluginAsync = fp(async (server, _options) => {
|
||||
})
|
||||
);
|
||||
|
||||
await prisma.$connect();
|
||||
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);
|
||||
|
||||
@@ -36,27 +42,48 @@ const prismaPlugin: FastifyPluginAsync = fp(async (server, _options) => {
|
||||
// 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);
|
||||
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 }) {}
|
||||
}
|
||||
// NOTE: raw ops are untouched, as it is meant to be a direct passthrough to mongodb
|
||||
// async findRaw({ model, operation, args, query }) {}
|
||||
// async aggregateRaw({ model, operation, args, query }) {}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.$extends({
|
||||
query: {
|
||||
$allModels: {
|
||||
$allOperations({ model, operation, args, query }) {
|
||||
return timeOperation(
|
||||
() => query(args),
|
||||
(result, durationMs) =>
|
||||
Sentry.metrics.distribution(
|
||||
'db.query_duration_ms',
|
||||
durationMs,
|
||||
{
|
||||
unit: 'millisecond',
|
||||
attributes: { model: model ?? 'raw', operation, result }
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default prismaPlugin;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import { timeOperation } from './query-timing.js';
|
||||
|
||||
describe('timeOperation', () => {
|
||||
it('returns the result and emits success with a numeric duration', async () => {
|
||||
const emit = vi.fn();
|
||||
|
||||
const result = await timeOperation(() => Promise.resolve('ok'), emit);
|
||||
|
||||
expect(result).toBe('ok');
|
||||
expect(emit).toHaveBeenCalledWith('success', expect.any(Number));
|
||||
});
|
||||
|
||||
it('re-throws and emits failure when the operation rejects', async () => {
|
||||
const emit = vi.fn();
|
||||
const boom = new Error('boom');
|
||||
|
||||
await expect(timeOperation(() => Promise.reject(boom), emit)).rejects.toBe(
|
||||
boom
|
||||
);
|
||||
expect(emit).toHaveBeenCalledWith('failure', expect.any(Number));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
// eslint-disable-next-line jsdoc/require-jsdoc
|
||||
export const timeOperation = async <T>(
|
||||
op: () => Promise<T>,
|
||||
emit: (result: 'success' | 'failure', durationMs: number) => void
|
||||
): Promise<T> => {
|
||||
const start = performance.now();
|
||||
try {
|
||||
const result = await op();
|
||||
emit('success', performance.now() - start);
|
||||
return result;
|
||||
} catch (err) {
|
||||
emit('failure', performance.now() - start);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
vi
|
||||
} from 'vitest';
|
||||
import { ExamEnvironmentExamModerationStatus } from '@prisma/client';
|
||||
import { PrismaClientValidationError } from '@prisma/client/runtime/library.js';
|
||||
import { Static } from '@fastify/type-provider-typebox';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
} from '../schemas/index.js';
|
||||
import * as mock from '../../../__fixtures__/exam-environment-exam.js';
|
||||
import { constructUserExam } from '../utils/exam-environment.js';
|
||||
import { getExamAttemptsHandler } from './exam-environment.js';
|
||||
import { JWT_SECRET } from '../../utils/env.js';
|
||||
import { ExamAttemptStatus } from '../schemas/exam-environment-exam-attempt.js';
|
||||
|
||||
@@ -137,6 +139,13 @@ describe('/exam-environment/', () => {
|
||||
});
|
||||
|
||||
it('should return an error if the attempt has expired', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
// Create exam attempt with expired time
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: {
|
||||
@@ -165,6 +174,13 @@ describe('/exam-environment/', () => {
|
||||
message: expect.any(String)
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'exam.attempt_submission_expired',
|
||||
1
|
||||
);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('should return an error if there is no matching generated exam', async () => {
|
||||
@@ -199,6 +215,13 @@ describe('/exam-environment/', () => {
|
||||
});
|
||||
|
||||
it('should return an error if the attempt does not match the generated exam', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const attempt =
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: { ...mock.examAttempt, userId: defaultUserId }
|
||||
@@ -235,6 +258,10 @@ describe('/exam-environment/', () => {
|
||||
}
|
||||
);
|
||||
expect(examModeration).not.toBeNull();
|
||||
|
||||
expect(count).toHaveBeenCalledWith('exam.moderation_flagged', 1);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('should not error if an invalid attempt is submitted when the attempt is already linked to a moderation record', async () => {
|
||||
@@ -407,7 +434,60 @@ describe('/exam-environment/', () => {
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('should track a metric when an attempt is blocked due to pending moderation', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const attempt =
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: mock.examAttempt
|
||||
});
|
||||
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamModeration.create({
|
||||
data: {
|
||||
examAttemptId: attempt.id,
|
||||
status: ExamEnvironmentExamModerationStatus.Pending
|
||||
}
|
||||
});
|
||||
|
||||
const body: Static<typeof examEnvironmentPostExamGeneratedExam.body> = {
|
||||
examId: mock.examId
|
||||
};
|
||||
|
||||
const res = await superPost('/exam-environment/exam/generated-exam')
|
||||
.send(body)
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res).toMatchObject({
|
||||
status: 403,
|
||||
body: {
|
||||
code: 'FCC_EINVAL_EXAM_ENVIRONMENT_EXAM_ATTEMPT'
|
||||
}
|
||||
});
|
||||
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'exam.attempt_blocked_pending_moderation',
|
||||
1
|
||||
);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('should return an error if the exam has been attempted too recently to retake', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const examTotalTimeInMS = mock.exam.config.totalTimeInS * 1000;
|
||||
|
||||
const recentExamAttempt = {
|
||||
@@ -469,6 +549,10 @@ describe('/exam-environment/', () => {
|
||||
code: 'FCC_EINVAL_EXAM_ENVIRONMENT_PREREQUISITES'
|
||||
}
|
||||
});
|
||||
|
||||
expect(count).toHaveBeenCalledWith('exam.retake_cooldown_blocked', 1);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('should use a new exam attempt if all previous attempts were started > 24 hours ago', async () => {
|
||||
@@ -515,6 +599,13 @@ describe('/exam-environment/', () => {
|
||||
});
|
||||
|
||||
it('should return the current attempt if it is still ongoing', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const latestAttempt =
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: mock.examAttempt
|
||||
@@ -537,6 +628,10 @@ describe('/exam-environment/', () => {
|
||||
examAttempt: serializeDates(latestAttempt)
|
||||
}
|
||||
});
|
||||
|
||||
expect(count).toHaveBeenCalledWith('exam.attempt_resumed', 1);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('should prioritise not-yet-taken generated exams, and reuse completed ones if necessary', async () => {
|
||||
@@ -631,6 +726,13 @@ describe('/exam-environment/', () => {
|
||||
});
|
||||
|
||||
it('should record the fact the user has started an exam by creating an exam attempt', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const body: Static<typeof examEnvironmentPostExamGeneratedExam.body> = {
|
||||
examId: mock.examId
|
||||
};
|
||||
@@ -672,9 +774,17 @@ describe('/exam-environment/', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
version: expect.any(Number)
|
||||
});
|
||||
|
||||
expect(count).toHaveBeenCalledWith('exam.attempt_created', 1);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('should unwind (delete) the exam attempt if the user exam cannot be constructed', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = { ...originalSentry, captureException };
|
||||
|
||||
const _mockConstructUserExam = vi
|
||||
.spyOn(
|
||||
await import('../utils/exam-environment.js'),
|
||||
@@ -695,6 +805,7 @@ describe('/exam-environment/', () => {
|
||||
);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
|
||||
const examAttempt =
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.findFirst(
|
||||
@@ -704,6 +815,39 @@ describe('/exam-environment/', () => {
|
||||
);
|
||||
|
||||
expect(examAttempt).toBeNull();
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('should track a metric when the generated exam pool is exhausted', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
await fastifyTestInstance.prisma.examEnvironmentGeneratedExam.deleteMany(
|
||||
{}
|
||||
);
|
||||
|
||||
const body: Static<typeof examEnvironmentPostExamGeneratedExam.body> = {
|
||||
examId: mock.examId
|
||||
};
|
||||
const res = await superPost('/exam-environment/exam/generated-exam')
|
||||
.send(body)
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'exam.generated_exam_pool_exhausted',
|
||||
1
|
||||
);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('should return the user exam with the exam attempt', async () => {
|
||||
@@ -1348,6 +1492,87 @@ describe('/exam-environment/', () => {
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sentry Issue reporting', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('captures unexpected errors when querying exams fails', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = { ...originalSentry, captureException };
|
||||
vi.spyOn(
|
||||
fastifyTestInstance.prisma.examEnvironmentExam,
|
||||
'findMany'
|
||||
).mockRejectedValueOnce(new Error('DB error'));
|
||||
|
||||
const res = await superGet('/exam-environment/exams').set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('does not capture an expected invalid exam id error', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = { ...originalSentry, captureException };
|
||||
vi.spyOn(
|
||||
fastifyTestInstance.prisma.examEnvironmentExam,
|
||||
'findUnique'
|
||||
).mockRejectedValueOnce(
|
||||
new PrismaClientValidationError('Invalid exam id', {
|
||||
clientVersion: '5.0.0'
|
||||
})
|
||||
);
|
||||
|
||||
const body: Static<typeof examEnvironmentPostExamGeneratedExam.body> = {
|
||||
examId: mock.examId
|
||||
};
|
||||
const res = await superPost('/exam-environment/exam/generated-exam')
|
||||
.send(body)
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('captures an exception when no user is present on the request', async () => {
|
||||
const captureException = vi.fn();
|
||||
const fastify = {
|
||||
...fastifyTestInstance,
|
||||
Sentry: { ...fastifyTestInstance.Sentry, captureException }
|
||||
};
|
||||
const req = {
|
||||
user: null,
|
||||
log: fastifyTestInstance.log
|
||||
} as unknown as Parameters<typeof getExamAttemptsHandler>[0];
|
||||
const send = vi.fn();
|
||||
const reply = {
|
||||
code: vi.fn(),
|
||||
send
|
||||
} as unknown as Parameters<typeof getExamAttemptsHandler>[1];
|
||||
|
||||
await getExamAttemptsHandler.call(fastify, req, reply);
|
||||
|
||||
expect(captureException).toHaveBeenCalledWith(
|
||||
'No user found in request.'
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
expect(reply.code).toHaveBeenCalledWith(500);
|
||||
expect(send).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authenticated user without exam environment authorization token', () => {
|
||||
|
||||
@@ -30,8 +30,8 @@ export const examEnvironmentValidatedTokenRoutes: FastifyPluginCallbackTypebox =
|
||||
!Object.hasOwnProperty.call(error, 'code') ||
|
||||
!Object.hasOwnProperty.call(error, 'message')
|
||||
) {
|
||||
const logger = fastify.log.child({ req, res });
|
||||
logger.error(error, 'Unhandled error in exam environment routes.');
|
||||
fastify.Sentry?.captureException(error);
|
||||
req.log.error(error, 'Unhandled error in exam environment routes.');
|
||||
const str = JSON.stringify(error);
|
||||
res.code(500);
|
||||
res.send(ERRORS.FCC_ERR_UNKNOWN_STATE(str));
|
||||
@@ -127,19 +127,15 @@ async function tokenMetaHandler(
|
||||
req: UpdateReqType<typeof schemas.examEnvironmentTokenMeta>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const logger = this.log.child({ req });
|
||||
const { 'exam-environment-authorization-token': encodedToken } = req.headers;
|
||||
logger.info({ encodedToken });
|
||||
req.log.debug('Received exam environment token meta request.');
|
||||
|
||||
let payload: JwtPayload;
|
||||
try {
|
||||
payload = jwt.verify(encodedToken, JWT_SECRET) as JwtPayload;
|
||||
} catch (e) {
|
||||
// Server refuses to brew (verify) coffee (jwts) with a teapot (random strings)
|
||||
logger.warn(
|
||||
{ examEnvironmentAuthorizationTokenError: e },
|
||||
'Invalid token provided.'
|
||||
);
|
||||
req.log.warn(e, 'Invalid token provided.');
|
||||
void reply.code(418);
|
||||
return reply.send(
|
||||
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN(JSON.stringify(e))
|
||||
@@ -147,13 +143,7 @@ async function tokenMetaHandler(
|
||||
}
|
||||
|
||||
if (!isObjectID(payload.examEnvironmentAuthorizationToken)) {
|
||||
logger.warn(
|
||||
{
|
||||
examEnvironmentAuthorizationToken:
|
||||
payload.examEnvironmentAuthorizationToken
|
||||
},
|
||||
'Token is not an object id.'
|
||||
);
|
||||
req.log.warn('Token is not an object id.');
|
||||
void reply.code(418);
|
||||
return reply.send(
|
||||
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN(
|
||||
@@ -170,7 +160,7 @@ async function tokenMetaHandler(
|
||||
|
||||
if (!token) {
|
||||
// Endpoint is valid, but resource does not exists
|
||||
logger.warn('Token does not appear to exist.');
|
||||
req.log.warn('Token does not appear to exist.');
|
||||
void reply.code(404);
|
||||
return reply.send(
|
||||
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN(
|
||||
@@ -195,17 +185,16 @@ async function postExamGeneratedExamHandler(
|
||||
req: UpdateReqType<typeof schemas.examEnvironmentPostExamGeneratedExam>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const logger = this.log.child({ req });
|
||||
const user = req.user;
|
||||
|
||||
if (!user) {
|
||||
logger.error('No user found in request.');
|
||||
this.Sentry.captureException('No user found in request.');
|
||||
this.Sentry?.captureException('No user found in request.');
|
||||
req.log.error('No user found in request.');
|
||||
void reply.code(500);
|
||||
return reply.send(ERRORS.FCC_ERR_UNKNOWN_STATE('No user found.'));
|
||||
}
|
||||
|
||||
logger.info({ userId: user.id });
|
||||
req.log.debug('Generating exam for user.');
|
||||
// Get exam from DB
|
||||
const examId = req.body.examId;
|
||||
const maybeExam = await mapErr(
|
||||
@@ -217,13 +206,13 @@ async function postExamGeneratedExamHandler(
|
||||
);
|
||||
if (maybeExam.hasError) {
|
||||
if (maybeExam.error instanceof PrismaClientValidationError) {
|
||||
logger.warn(maybeExam.error, 'Invalid exam id given.');
|
||||
req.log.warn(maybeExam.error, 'Invalid exam id given.');
|
||||
void reply.code(400);
|
||||
return reply.send(ERRORS.FCC_EINVAL_EXAM_ID(maybeExam.error.message));
|
||||
}
|
||||
|
||||
logger.error(maybeExam.error);
|
||||
this.Sentry.captureException(maybeExam.error);
|
||||
this.Sentry?.captureException(maybeExam.error);
|
||||
req.log.error(maybeExam.error, 'Unable to query exam.');
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeExam.error))
|
||||
@@ -233,7 +222,7 @@ async function postExamGeneratedExamHandler(
|
||||
const exam = maybeExam.data;
|
||||
|
||||
if (!exam) {
|
||||
logger.warn({ examId }, 'No exam with given id.');
|
||||
req.log.warn({ examId }, 'No exam with given id.');
|
||||
void reply.code(404);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ENOENT_EXAM_ENVIRONMENT_MISSING_EXAM('Invalid exam id given.')
|
||||
@@ -244,7 +233,7 @@ async function postExamGeneratedExamHandler(
|
||||
const isExamPrerequisitesMet = checkPrerequisites(user, exam.prerequisites);
|
||||
|
||||
if (!isExamPrerequisitesMet) {
|
||||
logger.warn(
|
||||
req.log.warn(
|
||||
{ examId: exam.id },
|
||||
'User has not completed prerequisites to take exam.'
|
||||
);
|
||||
@@ -269,8 +258,8 @@ async function postExamGeneratedExamHandler(
|
||||
);
|
||||
|
||||
if (maybeExamAttempts.hasError) {
|
||||
logger.error(maybeExamAttempts.error, 'Unable to query exam attempts.');
|
||||
this.Sentry.captureException(maybeExamAttempts.error);
|
||||
this.Sentry?.captureException(maybeExamAttempts.error);
|
||||
req.log.error(maybeExamAttempts.error, 'Unable to query exam attempts.');
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeExamAttempts.error))
|
||||
@@ -299,8 +288,8 @@ async function postExamGeneratedExamHandler(
|
||||
);
|
||||
|
||||
if (maybeMod.hasError) {
|
||||
logger.error(maybeMod.error);
|
||||
this.Sentry.captureException(maybeMod.error);
|
||||
this.Sentry?.captureException(maybeMod.error);
|
||||
req.log.error(maybeMod.error, 'Unable to query exam moderation.');
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeMod.error))
|
||||
@@ -310,10 +299,11 @@ async function postExamGeneratedExamHandler(
|
||||
const moderation = maybeMod.data;
|
||||
|
||||
if (moderation !== null) {
|
||||
logger.warn(
|
||||
req.log.warn(
|
||||
{ examAttemptId: lastAttempt.id },
|
||||
'User has an exam attempt awaiting grading.'
|
||||
);
|
||||
this.Sentry?.metrics?.count('exam.attempt_blocked_pending_moderation', 1);
|
||||
void reply.code(403);
|
||||
return reply.send(
|
||||
// TODO: Better error type
|
||||
@@ -333,10 +323,11 @@ async function postExamGeneratedExamHandler(
|
||||
examExpirationTime + examRetakeTimeInMS < Date.now();
|
||||
|
||||
if (!retakeAllowed) {
|
||||
logger.warn(
|
||||
req.log.warn(
|
||||
{ examExpirationTime },
|
||||
'User has completed exam too recently to retake.'
|
||||
);
|
||||
this.Sentry?.metrics?.count('exam.retake_cooldown_blocked', 1);
|
||||
void reply.code(429);
|
||||
// TODO: Consider sending last completed time
|
||||
return reply.send(
|
||||
@@ -358,8 +349,8 @@ async function postExamGeneratedExamHandler(
|
||||
);
|
||||
|
||||
if (generated.hasError) {
|
||||
logger.error(generated.error, 'Unable to query generated exam.');
|
||||
this.Sentry.captureException(generated.error);
|
||||
this.Sentry?.captureException(generated.error);
|
||||
req.log.error(generated.error, 'Unable to query generated exam.');
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(generated.error))
|
||||
@@ -367,18 +358,24 @@ async function postExamGeneratedExamHandler(
|
||||
}
|
||||
|
||||
if (generated.data === null) {
|
||||
const error = {
|
||||
data: { generatedExamId: lastAttempt.generatedExamId },
|
||||
message: 'Unreachable. Generated exam not found.'
|
||||
};
|
||||
logger.error(error.data, error.message);
|
||||
this.Sentry.captureException(error.data);
|
||||
this.Sentry?.captureException({
|
||||
generatedExamId: lastAttempt.generatedExamId
|
||||
});
|
||||
req.log.error(
|
||||
{ generatedExamId: lastAttempt.generatedExamId },
|
||||
'Unreachable. Generated exam not found.'
|
||||
);
|
||||
void reply.code(500);
|
||||
return reply.send(ERRORS.FCC_ERR_EXAM_ENVIRONMENT(error.message));
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(
|
||||
'Unreachable. Generated exam not found.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const userExam = constructUserExam(generated.data, exam);
|
||||
|
||||
this.Sentry?.metrics?.count('exam.attempt_resumed', 1);
|
||||
return reply.send({
|
||||
exam: userExam,
|
||||
examAttempt: lastAttempt
|
||||
@@ -400,8 +397,11 @@ async function postExamGeneratedExamHandler(
|
||||
);
|
||||
|
||||
if (maybeGeneratedExams.hasError) {
|
||||
logger.error(maybeGeneratedExams.error);
|
||||
this.Sentry.captureException(maybeGeneratedExams.error);
|
||||
this.Sentry?.captureException(maybeGeneratedExams.error);
|
||||
req.log.error(
|
||||
maybeGeneratedExams.error,
|
||||
'Unable to query generated exams.'
|
||||
);
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(maybeGeneratedExams.error)
|
||||
@@ -411,14 +411,13 @@ async function postExamGeneratedExamHandler(
|
||||
const generatedExams = maybeGeneratedExams.data;
|
||||
|
||||
if (generatedExams.length === 0) {
|
||||
const error = {
|
||||
data: { examId: exam.id },
|
||||
message: `Unable to provide a generated exam. Either no generations exist, or all generated exams are deprecated.`
|
||||
};
|
||||
logger.error(error.data, error.message);
|
||||
this.Sentry.captureException(error);
|
||||
const message =
|
||||
'Unable to provide a generated exam. Either no generations exist, or all generated exams are deprecated.';
|
||||
this.Sentry?.captureException({ data: { examId: exam.id }, message });
|
||||
req.log.error({ examId: exam.id }, message);
|
||||
this.Sentry?.metrics?.count('exam.generated_exam_pool_exhausted', 1);
|
||||
void reply.code(500);
|
||||
return reply.send(ERRORS.FCC_ERR_EXAM_ENVIRONMENT(error.message));
|
||||
return reply.send(ERRORS.FCC_ERR_EXAM_ENVIRONMENT(message));
|
||||
}
|
||||
|
||||
// Randomly pick an exam from available generations, prioritising generations not already taken
|
||||
@@ -427,9 +426,9 @@ async function postExamGeneratedExamHandler(
|
||||
);
|
||||
let randomGeneratedExamId: string;
|
||||
if (untakenGeneratedExams.length === 0) {
|
||||
logger.info(
|
||||
`User has taken all generated exams. Reusing previously taken generated exams.`
|
||||
);
|
||||
this.Sentry?.metrics?.count('exam.generated_exam_reused', 1, {
|
||||
attributes: { examId: exam.id }
|
||||
});
|
||||
randomGeneratedExamId =
|
||||
generatedExams[Math.floor(Math.random() * generatedExams.length)]!.id;
|
||||
} else {
|
||||
@@ -448,8 +447,8 @@ async function postExamGeneratedExamHandler(
|
||||
);
|
||||
|
||||
if (maybeGeneratedExam.hasError) {
|
||||
logger.error(maybeGeneratedExam.error);
|
||||
this.Sentry.captureException(maybeGeneratedExam.error);
|
||||
this.Sentry?.captureException(maybeGeneratedExam.error);
|
||||
req.log.error(maybeGeneratedExam.error, 'Unable to query generated exam.');
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
// TODO: Consider more specific code
|
||||
@@ -463,14 +462,18 @@ async function postExamGeneratedExamHandler(
|
||||
const generatedExam = maybeGeneratedExam.data;
|
||||
|
||||
if (generatedExam === null) {
|
||||
const error = {
|
||||
this.Sentry?.captureException({
|
||||
data: { generatedExamId: randomGeneratedExamId },
|
||||
message: 'Unreachable. Generated exam not found.'
|
||||
};
|
||||
logger.error(error.data, 'Unreachable. Generated exam not found.');
|
||||
this.Sentry.captureException(error);
|
||||
});
|
||||
req.log.error(
|
||||
{ generatedExamId: randomGeneratedExamId },
|
||||
'Unreachable. Generated exam not found.'
|
||||
);
|
||||
void reply.code(500);
|
||||
return reply.send(ERRORS.FCC_ERR_EXAM_ENVIRONMENT(error.message));
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT('Unreachable. Generated exam not found.')
|
||||
);
|
||||
}
|
||||
|
||||
// Create exam attempt so, even if user disconnects, their attempt is still recorded:
|
||||
@@ -488,8 +491,8 @@ async function postExamGeneratedExamHandler(
|
||||
);
|
||||
|
||||
if (attempt.hasError) {
|
||||
logger.error(attempt.error);
|
||||
this.Sentry.captureException(attempt.error);
|
||||
this.Sentry?.captureException(attempt.error);
|
||||
req.log.error(attempt.error, 'Unable to create exam attempt.');
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT_CREATE_EXAM_ATTEMPT(
|
||||
@@ -504,14 +507,14 @@ async function postExamGeneratedExamHandler(
|
||||
);
|
||||
|
||||
if (maybeUserExam.hasError) {
|
||||
logger.error(maybeUserExam.error);
|
||||
this.Sentry?.captureException(maybeUserExam.error);
|
||||
req.log.error(maybeUserExam.error, 'Unable to construct user exam.');
|
||||
// TODO: Consider handling this failing
|
||||
await this.prisma.examEnvironmentExamAttempt.delete({
|
||||
where: {
|
||||
id: attempt.data.id
|
||||
}
|
||||
});
|
||||
this.Sentry.captureException(maybeUserExam.error);
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeUserExam.error))
|
||||
@@ -520,6 +523,7 @@ async function postExamGeneratedExamHandler(
|
||||
|
||||
const userExam = maybeUserExam.data;
|
||||
|
||||
this.Sentry?.metrics?.count('exam.attempt_created', 1);
|
||||
void reply.code(200);
|
||||
return reply.send({
|
||||
exam: userExam,
|
||||
@@ -542,17 +546,16 @@ async function postExamAttemptHandler(
|
||||
req: UpdateReqType<typeof schemas.examEnvironmentPostExamAttempt>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const logger = this.log.child({ req });
|
||||
const user = req.user;
|
||||
|
||||
if (!user) {
|
||||
logger.error('No user found in request.');
|
||||
this.Sentry.captureException('No user found in request.');
|
||||
this.Sentry?.captureException('No user found in request.');
|
||||
req.log.error('No user found in request.');
|
||||
void reply.code(500);
|
||||
return reply.send(ERRORS.FCC_ERR_UNKNOWN_STATE('No user found.'));
|
||||
}
|
||||
|
||||
logger.info({ userId: user.id });
|
||||
req.log.debug('Updating exam attempt for user.');
|
||||
|
||||
const { attempt } = req.body;
|
||||
|
||||
@@ -566,8 +569,8 @@ async function postExamAttemptHandler(
|
||||
);
|
||||
|
||||
if (maybeAttempts.hasError) {
|
||||
logger.error(maybeAttempts.error);
|
||||
this.Sentry.captureException(maybeAttempts.error);
|
||||
this.Sentry?.captureException(maybeAttempts.error);
|
||||
req.log.error(maybeAttempts.error, 'Unable to query exam attempts.');
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeAttempts.error))
|
||||
@@ -577,7 +580,7 @@ async function postExamAttemptHandler(
|
||||
const attempts = maybeAttempts.data;
|
||||
|
||||
if (attempts.length === 0) {
|
||||
logger.warn({ examId: attempt.examId }, 'No attempts found for user.');
|
||||
req.log.warn({ examId: attempt.examId }, 'No attempts found for user.');
|
||||
void reply.code(404);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT_EXAM_ATTEMPT(
|
||||
@@ -604,7 +607,8 @@ async function postExamAttemptHandler(
|
||||
);
|
||||
|
||||
if (maybeExam.hasError) {
|
||||
logger.error(maybeExam.error);
|
||||
this.Sentry?.captureException(maybeExam.error);
|
||||
req.log.error(maybeExam.error, 'Unable to query exam.');
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeExam.error))
|
||||
@@ -614,7 +618,7 @@ async function postExamAttemptHandler(
|
||||
const exam = maybeExam.data;
|
||||
|
||||
if (exam === null) {
|
||||
logger.warn({ examId: attempt.examId }, 'Invalid exam id given.');
|
||||
req.log.warn({ examId: attempt.examId }, 'Invalid exam id given.');
|
||||
void reply.code(404);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ENOENT_EXAM_ENVIRONMENT_MISSING_EXAM('Invalid exam id given.')
|
||||
@@ -627,10 +631,11 @@ async function postExamAttemptHandler(
|
||||
latestAttemptStartTime + examTotalTimeInMS < Date.now();
|
||||
|
||||
if (isAttemptExpired) {
|
||||
logger.warn(
|
||||
req.log.warn(
|
||||
{ examAttemptId: latestAttempt.id },
|
||||
'Attempt has exceeded submission time.'
|
||||
);
|
||||
this.Sentry?.metrics?.count('exam.attempt_submission_expired', 1);
|
||||
void reply.code(403);
|
||||
return reply.send(
|
||||
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_EXAM_ATTEMPT(
|
||||
@@ -649,7 +654,8 @@ async function postExamAttemptHandler(
|
||||
);
|
||||
|
||||
if (maybeGeneratedExam.hasError) {
|
||||
logger.error(maybeGeneratedExam.error);
|
||||
this.Sentry?.captureException(maybeGeneratedExam.error);
|
||||
req.log.error(maybeGeneratedExam.error, 'Unable to query generated exam.');
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeGeneratedExam.error))
|
||||
@@ -659,7 +665,7 @@ async function postExamAttemptHandler(
|
||||
const generatedExam = maybeGeneratedExam.data;
|
||||
|
||||
if (generatedExam === null) {
|
||||
logger.warn(
|
||||
req.log.warn(
|
||||
{ generatedExamId: latestAttempt.generatedExamId },
|
||||
'Generated exam not found.'
|
||||
);
|
||||
@@ -685,7 +691,7 @@ async function postExamAttemptHandler(
|
||||
maybeValidExamAttempt.error instanceof Error
|
||||
? maybeValidExamAttempt.error.message
|
||||
: 'Unknown attempt validation error';
|
||||
logger.warn({ validExamAttemptError: message }, 'Invalid exam attempt.');
|
||||
req.log.warn({ validExamAttemptError: message }, 'Invalid exam attempt.');
|
||||
// As attempt is invalid, create moderation record to investigate or update existing record
|
||||
const moderation = await this.prisma.examEnvironmentExamModeration.upsert({
|
||||
where: { examAttemptId: latestAttempt.id },
|
||||
@@ -699,6 +705,8 @@ async function postExamAttemptHandler(
|
||||
}
|
||||
});
|
||||
|
||||
this.Sentry?.metrics?.count('exam.moderation_flagged', 1);
|
||||
|
||||
// Link attempt with moderation id if it has not already been done
|
||||
await this.prisma.examEnvironmentExamAttempt.updateMany({
|
||||
where: {
|
||||
@@ -727,13 +735,17 @@ async function postExamAttemptHandler(
|
||||
);
|
||||
|
||||
if (maybeUpdatedAttempt.hasError) {
|
||||
logger.error({ updatedAttemptError: maybeUpdatedAttempt.error });
|
||||
this.Sentry?.captureException(maybeUpdatedAttempt.error);
|
||||
req.log.error(maybeUpdatedAttempt.error, 'Unable to update exam attempt.');
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeUpdatedAttempt.error))
|
||||
);
|
||||
}
|
||||
|
||||
this.Sentry?.metrics?.count('exam.submitted', 1, {
|
||||
attributes: { examId: attempt.examId }
|
||||
});
|
||||
return reply.code(200).send();
|
||||
}
|
||||
|
||||
@@ -746,17 +758,16 @@ export async function getExams(
|
||||
req: UpdateReqType<typeof schemas.examEnvironmentExams>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const logger = this.log.child({ req });
|
||||
const user = req.user;
|
||||
|
||||
if (!user) {
|
||||
logger.error('No user found in request.');
|
||||
this.Sentry.captureException('No user found in request.');
|
||||
this.Sentry?.captureException('No user found in request.');
|
||||
req.log.error('No user found in request.');
|
||||
void reply.code(500);
|
||||
return reply.send(ERRORS.FCC_ERR_UNKNOWN_STATE('No user found.'));
|
||||
}
|
||||
|
||||
logger.info({ userId: user.id });
|
||||
req.log.debug('Fetching available exams for user.');
|
||||
|
||||
const maybeExams = await mapErr(
|
||||
this.prisma.examEnvironmentExam.findMany({
|
||||
@@ -772,8 +783,8 @@ export async function getExams(
|
||||
);
|
||||
|
||||
if (maybeExams.hasError) {
|
||||
logger.error(maybeExams.error);
|
||||
this.Sentry.captureException(maybeExams.error);
|
||||
this.Sentry?.captureException(maybeExams.error);
|
||||
req.log.error(maybeExams.error, 'Unable to query exams.');
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeExams.error))
|
||||
@@ -796,8 +807,8 @@ export async function getExams(
|
||||
);
|
||||
|
||||
if (maybeAttempts.hasError) {
|
||||
logger.error(maybeAttempts.error);
|
||||
this.Sentry.captureException(maybeAttempts.error);
|
||||
this.Sentry?.captureException(maybeAttempts.error);
|
||||
req.log.error(maybeAttempts.error, 'Unable to query exam attempts.');
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeAttempts.error))
|
||||
@@ -823,8 +834,9 @@ export async function getExams(
|
||||
};
|
||||
|
||||
const isExamPrerequisitesMet = checkPrerequisites(user, exam.prerequisites);
|
||||
logger.info(
|
||||
`Prerequisites for exam ${exam.id} ${isExamPrerequisitesMet ? 'met' : 'unmet'}.`
|
||||
req.log.debug(
|
||||
{ examId: exam.id, isExamPrerequisitesMet },
|
||||
'Evaluated exam prerequisites.'
|
||||
);
|
||||
|
||||
if (!isExamPrerequisitesMet) {
|
||||
@@ -846,7 +858,7 @@ export async function getExams(
|
||||
: null;
|
||||
|
||||
if (!lastAttempt) {
|
||||
logger.info(`No prior attempts for exam ${exam.id}`);
|
||||
req.log.debug({ examId: exam.id }, 'No prior attempts for exam.');
|
||||
availableExam.canTake = true;
|
||||
availableExams.push(availableExam);
|
||||
continue;
|
||||
@@ -861,7 +873,7 @@ export async function getExams(
|
||||
const lastAttemptExpired =
|
||||
Date.now() > lastAttemptStartTime + examTotalTimeInMS;
|
||||
if (!lastAttemptExpired) {
|
||||
logger.info(`Exam ${exam.id} in progress.`);
|
||||
req.log.debug({ examId: exam.id }, 'Exam in progress.');
|
||||
availableExam.canTake = true;
|
||||
availableExams.push(availableExam);
|
||||
continue;
|
||||
@@ -869,7 +881,10 @@ export async function getExams(
|
||||
|
||||
const isRetakeTimePassed = Date.now() > retakeDateInMS;
|
||||
if (!isRetakeTimePassed) {
|
||||
logger.info(`Time until retake: ${retakeDateInMS - Date.now()} [ms]`);
|
||||
req.log.debug(
|
||||
{ examId: exam.id, retakeInMs: retakeDateInMS - Date.now() },
|
||||
'Exam retake time has not yet passed.'
|
||||
);
|
||||
availableExam.canTake = false;
|
||||
availableExams.push(availableExam);
|
||||
continue;
|
||||
@@ -885,8 +900,11 @@ export async function getExams(
|
||||
);
|
||||
|
||||
if (maybeModerations.hasError) {
|
||||
logger.error(maybeModerations.error);
|
||||
this.Sentry.captureException(maybeModerations.error);
|
||||
this.Sentry?.captureException(maybeModerations.error);
|
||||
req.log.error(
|
||||
maybeModerations.error,
|
||||
'Unable to query exam moderations.'
|
||||
);
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeModerations.error))
|
||||
@@ -896,7 +914,10 @@ export async function getExams(
|
||||
const moderations = maybeModerations.data;
|
||||
|
||||
if (moderations.length > 0) {
|
||||
logger.info(`Exam Moderation records found: ${moderations.length}`);
|
||||
req.log.debug(
|
||||
{ examId: exam.id, count: moderations.length },
|
||||
'Exam moderation records found.'
|
||||
);
|
||||
availableExam.canTake = false;
|
||||
availableExams.push(availableExam);
|
||||
continue;
|
||||
@@ -919,17 +940,16 @@ export async function getExamAttemptsHandler(
|
||||
req: UpdateReqType<typeof schemas.examEnvironmentGetExamAttempts>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const logger = this.log.child({ req });
|
||||
const user = req.user;
|
||||
|
||||
if (!user) {
|
||||
logger.error('No user found in request.');
|
||||
this.Sentry.captureException('No user found in request.');
|
||||
this.Sentry?.captureException('No user found in request.');
|
||||
req.log.error('No user found in request.');
|
||||
void reply.code(500);
|
||||
return reply.send(ERRORS.FCC_ERR_UNKNOWN_STATE('No user found.'));
|
||||
}
|
||||
|
||||
logger.info({ userId: user.id });
|
||||
req.log.debug('Fetching exam attempts for user.');
|
||||
|
||||
// Send all relevant exam attempts
|
||||
const envExamAttempts = [];
|
||||
@@ -942,8 +962,8 @@ export async function getExamAttemptsHandler(
|
||||
);
|
||||
|
||||
if (maybeAttempts.hasError) {
|
||||
logger.error(maybeAttempts.error);
|
||||
this.Sentry.captureException(maybeAttempts.error);
|
||||
this.Sentry?.captureException(maybeAttempts.error);
|
||||
req.log.error(maybeAttempts.error, 'Unable to query exam attempts.');
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeAttempts.error))
|
||||
@@ -953,7 +973,7 @@ export async function getExamAttemptsHandler(
|
||||
const attempts = maybeAttempts.data;
|
||||
|
||||
if (!attempts.length) {
|
||||
logger.warn({ userId: user.id }, 'No exam attempts found.');
|
||||
req.log.warn('No exam attempts found.');
|
||||
void reply.code(404);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ENOENT_EXAM_ENVIRONMENT_EXAM_ATTEMPT('No exam attempt found.')
|
||||
@@ -964,7 +984,7 @@ export async function getExamAttemptsHandler(
|
||||
const { error, examEnvironmentExamAttempt } = await constructEnvExamAttempt(
|
||||
this,
|
||||
attempt,
|
||||
logger
|
||||
req.log
|
||||
);
|
||||
if (error) {
|
||||
void reply.code(error.code);
|
||||
@@ -986,16 +1006,15 @@ export async function getExamAttemptHandler(
|
||||
req: UpdateReqType<typeof schemas.examEnvironmentGetExamAttempt>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const logger = this.log.child({ req });
|
||||
const user = req.user;
|
||||
|
||||
if (!user) {
|
||||
logger.error('No user found in request.');
|
||||
this.Sentry.captureException('No user found in request.');
|
||||
this.Sentry?.captureException('No user found in request.');
|
||||
req.log.error('No user found in request.');
|
||||
void reply.code(500);
|
||||
return reply.send(ERRORS.FCC_ERR_UNKNOWN_STATE('No user found.'));
|
||||
}
|
||||
logger.info({ userId: user.id });
|
||||
req.log.debug('Fetching exam attempt for user.');
|
||||
|
||||
const { attemptId } = req.params;
|
||||
|
||||
@@ -1010,8 +1029,8 @@ export async function getExamAttemptHandler(
|
||||
);
|
||||
|
||||
if (maybeAttempt.hasError) {
|
||||
logger.error(maybeAttempt.error);
|
||||
this.Sentry.captureException(maybeAttempt.error);
|
||||
this.Sentry?.captureException(maybeAttempt.error);
|
||||
req.log.error(maybeAttempt.error, 'Unable to query exam attempt.');
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeAttempt.error))
|
||||
@@ -1021,7 +1040,7 @@ export async function getExamAttemptHandler(
|
||||
const attempt = maybeAttempt.data;
|
||||
|
||||
if (!attempt) {
|
||||
logger.warn({ attemptId }, 'No exam attempt found.');
|
||||
req.log.warn({ attemptId }, 'No exam attempt found.');
|
||||
void reply.code(404);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ENOENT_EXAM_ENVIRONMENT_EXAM_ATTEMPT('No exam attempt found.')
|
||||
@@ -1031,7 +1050,7 @@ export async function getExamAttemptHandler(
|
||||
const { error, examEnvironmentExamAttempt } = await constructEnvExamAttempt(
|
||||
this,
|
||||
attempt,
|
||||
logger
|
||||
req.log
|
||||
);
|
||||
|
||||
if (error) {
|
||||
@@ -1052,19 +1071,18 @@ export async function getExamAttemptsByExamIdHandler(
|
||||
req: UpdateReqType<typeof schemas.examEnvironmentGetExamAttemptsByExamId>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const logger = this.log.child({ req });
|
||||
const user = req.user;
|
||||
|
||||
if (!user) {
|
||||
logger.error('No user found in request.');
|
||||
this.Sentry.captureException('No user found in request.');
|
||||
this.Sentry?.captureException('No user found in request.');
|
||||
req.log.error('No user found in request.');
|
||||
void reply.code(500);
|
||||
return reply.send(ERRORS.FCC_ERR_UNKNOWN_STATE('No user found.'));
|
||||
}
|
||||
|
||||
const { examId } = req.params;
|
||||
|
||||
logger.info({ examId, userId: user.id });
|
||||
req.log.debug({ examId }, 'Fetching exam attempts by exam id.');
|
||||
|
||||
// If attempt id is given, only return that attempt
|
||||
const maybeAttempts = await mapErr(
|
||||
@@ -1077,8 +1095,8 @@ export async function getExamAttemptsByExamIdHandler(
|
||||
);
|
||||
|
||||
if (maybeAttempts.hasError) {
|
||||
logger.error(maybeAttempts.error);
|
||||
this.Sentry.captureException(maybeAttempts.error);
|
||||
this.Sentry?.captureException(maybeAttempts.error);
|
||||
req.log.error(maybeAttempts.error, 'Unable to query exam attempts.');
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeAttempts.error))
|
||||
@@ -1092,7 +1110,7 @@ export async function getExamAttemptsByExamIdHandler(
|
||||
const { error, examEnvironmentExamAttempt } = await constructEnvExamAttempt(
|
||||
this,
|
||||
attempt,
|
||||
logger
|
||||
req.log
|
||||
);
|
||||
|
||||
if (error) {
|
||||
@@ -1114,13 +1132,12 @@ export async function getExamChallenge(
|
||||
req: UpdateReqType<typeof schemas.examEnvironmentGetExamChallenge>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const logger = this.log.child({ req });
|
||||
const { challengeId, examId } = req.query;
|
||||
|
||||
logger.info({ challengeId, examId });
|
||||
req.log.debug({ challengeId, examId }, 'Fetching exam challenge relations.');
|
||||
|
||||
if (!challengeId && !examId) {
|
||||
logger.warn('No challenge or exam id provided.');
|
||||
req.log.warn('No challenge or exam id provided.');
|
||||
void reply.code(400);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(
|
||||
@@ -1139,8 +1156,8 @@ export async function getExamChallenge(
|
||||
);
|
||||
|
||||
if (maybeData.hasError) {
|
||||
logger.error(maybeData.error);
|
||||
this.Sentry.captureException(maybeData.error);
|
||||
this.Sentry?.captureException(maybeData.error);
|
||||
req.log.error(maybeData.error, 'Unable to query exam challenge relations.');
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeData.error))
|
||||
|
||||
@@ -446,8 +446,11 @@ export async function constructEnvExamAttempt(
|
||||
);
|
||||
|
||||
if (maybeExam.hasError) {
|
||||
logger.error(maybeExam.error);
|
||||
fastify.Sentry.captureException(maybeExam.error);
|
||||
fastify.Sentry?.captureException(maybeExam.error);
|
||||
logger.error(
|
||||
{ err: maybeExam.error, attemptId: attempt.id, examId: attempt.examId },
|
||||
'Unable to query exam.'
|
||||
);
|
||||
return {
|
||||
error: {
|
||||
code: 500,
|
||||
@@ -459,17 +462,21 @@ export async function constructEnvExamAttempt(
|
||||
const exam = maybeExam.data;
|
||||
|
||||
if (exam === null) {
|
||||
const error = {
|
||||
fastify.Sentry?.captureException({
|
||||
data: { examId: attempt.examId, attemptId: attempt.id },
|
||||
message: 'Unreachable. Invalid exam id in attempt.'
|
||||
};
|
||||
logger.error(error.data, error.message);
|
||||
fastify.Sentry.captureException(error);
|
||||
});
|
||||
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(error.message)
|
||||
data: ERRORS.FCC_ENOENT_EXAM_ENVIRONMENT_MISSING_EXAM(
|
||||
'Unreachable. Invalid exam id in attempt.'
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -499,8 +506,11 @@ export async function constructEnvExamAttempt(
|
||||
);
|
||||
|
||||
if (maybeMod.hasError) {
|
||||
logger.error(maybeMod.error);
|
||||
fastify.Sentry.captureException(maybeMod.error);
|
||||
fastify.Sentry?.captureException(maybeMod.error);
|
||||
logger.error(
|
||||
{ err: maybeMod.error, attemptId: attempt.id },
|
||||
'Unable to query exam moderation.'
|
||||
);
|
||||
return {
|
||||
error: {
|
||||
code: 500,
|
||||
@@ -557,8 +567,11 @@ export async function constructEnvExamAttempt(
|
||||
);
|
||||
|
||||
if (maybeGeneratedExam.hasError) {
|
||||
logger.error(maybeGeneratedExam.error);
|
||||
fastify.Sentry.captureException(maybeGeneratedExam.error);
|
||||
fastify.Sentry?.captureException(maybeGeneratedExam.error);
|
||||
logger.error(
|
||||
{ err: maybeGeneratedExam.error, attemptId: attempt.id },
|
||||
'Unable to query generated exam.'
|
||||
);
|
||||
return {
|
||||
error: {
|
||||
code: 500,
|
||||
@@ -572,17 +585,24 @@ export async function constructEnvExamAttempt(
|
||||
const generatedExam = maybeGeneratedExam.data;
|
||||
|
||||
if (!generatedExam) {
|
||||
const error = {
|
||||
data: { attemptId: attempt.id, generatedExamId: attempt.generatedExamId },
|
||||
fastify.Sentry?.captureException({
|
||||
data: {
|
||||
attemptId: attempt.id,
|
||||
generatedExamId: attempt.generatedExamId
|
||||
},
|
||||
message:
|
||||
'Unreachable. Unable to find generated exam associated with exam attempt'
|
||||
};
|
||||
logger.error(error.data, error.message);
|
||||
fastify.Sentry.captureException(error);
|
||||
});
|
||||
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(error.message)
|
||||
data: ERRORS.FCC_ERR_EXAM_ENVIRONMENT(
|
||||
'Unreachable. Unable to find generated exam associated with exam attempt'
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
+32
-8
@@ -1,22 +1,46 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import type { FastifyError } from 'fastify';
|
||||
import { nodeProfilingIntegration } from '@sentry/profiling-node';
|
||||
|
||||
import {
|
||||
DEPLOYMENT_VERSION,
|
||||
SENTRY_DSN,
|
||||
SENTRY_ENVIRONMENT
|
||||
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 shouldIgnoreError = (error: FastifyError): boolean => {
|
||||
return !!error.statusCode && error.statusCode < 500;
|
||||
};
|
||||
const shouldSendLog = makeShouldSendLog(
|
||||
SENTRY_LOGS_DEBUG_SAMPLE_RATE,
|
||||
SENTRY_LOGS_INFO_SAMPLE_RATE
|
||||
);
|
||||
|
||||
// Ensure to call this before importing any other modules!
|
||||
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,
|
||||
beforeSend: (event, hint) =>
|
||||
shouldIgnoreError(hint.originalException as FastifyError) ? null : event
|
||||
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
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'vitest';
|
||||
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
|
||||
@@ -236,6 +239,34 @@ describe('auth', () => {
|
||||
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', () => {
|
||||
@@ -379,6 +410,52 @@ describe('auth', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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';
|
||||
@@ -420,4 +497,109 @@ describe('auth', () => {
|
||||
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' }
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+31
-3
@@ -68,8 +68,17 @@ const auth: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
const TOKEN_INVALID = 'Your access token is invalid';
|
||||
const TOKEN_EXPIRED = 'Access token is no longer valid';
|
||||
|
||||
const setAccessDenied = (req: FastifyRequest, content: string) =>
|
||||
(req.accessDeniedMessage = { type: 'info', content });
|
||||
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;
|
||||
@@ -100,18 +109,26 @@ const auth: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
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): Promise<void> => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -135,6 +152,7 @@ const auth: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
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(
|
||||
@@ -146,6 +164,14 @@ const auth: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
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(
|
||||
@@ -197,7 +223,9 @@ const auth: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
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);
|
||||
|
||||
@@ -40,6 +40,9 @@ describe('auth0 plugin', () => {
|
||||
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);
|
||||
@@ -126,6 +129,7 @@ describe('auth0 plugin', () => {
|
||||
const email = 'new@user.com';
|
||||
let getAccessTokenFromAuthorizationCodeFlowSpy: MockInstance;
|
||||
let userinfoSpy: MockInstance;
|
||||
let captureException: ReturnType<typeof vi.fn>;
|
||||
|
||||
const mockAuthSuccess = () => {
|
||||
getAccessTokenFromAuthorizationCodeFlowSpy.mockResolvedValueOnce({
|
||||
@@ -140,8 +144,9 @@ describe('auth0 plugin', () => {
|
||||
'getAccessTokenFromAuthorizationCodeFlow'
|
||||
);
|
||||
userinfoSpy = vi.spyOn(fastify.auth0OAuth, 'userinfo');
|
||||
captureException = vi.fn();
|
||||
// @ts-expect-error - Only mocks part of the Sentry object.
|
||||
fastify.Sentry = { captureException: () => '' };
|
||||
fastify.Sentry = { captureException };
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -163,6 +168,7 @@ describe('auth0 plugin', () => {
|
||||
`${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 () => {
|
||||
@@ -177,17 +183,19 @@ describe('auth0 plugin', () => {
|
||||
expect(res.statusCode).toBe(302);
|
||||
});
|
||||
|
||||
test('should log an error if the state is invalid', async () => {
|
||||
vi.spyOn(fastify.log, 'error');
|
||||
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.error).toHaveBeenCalledWith(
|
||||
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 () => {
|
||||
@@ -215,6 +223,63 @@ describe('auth0 plugin', () => {
|
||||
);
|
||||
|
||||
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 () => {
|
||||
@@ -262,6 +327,10 @@ describe('auth0 plugin', () => {
|
||||
});
|
||||
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',
|
||||
@@ -275,6 +344,34 @@ describe('auth0 plugin', () => {
|
||||
);
|
||||
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 () => {
|
||||
@@ -300,6 +397,10 @@ describe('auth0 plugin', () => {
|
||||
|
||||
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',
|
||||
@@ -310,6 +411,17 @@ describe('auth0 plugin', () => {
|
||||
`?${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 () => {
|
||||
|
||||
+51
-12
@@ -1,3 +1,4 @@
|
||||
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';
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
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 {
|
||||
@@ -106,17 +108,18 @@ export const auth0Client: FastifyPluginCallbackTypebox = fp(
|
||||
|
||||
// TODO: use a schema to validate the query params.
|
||||
fastify.get('/auth/auth0/callback', async function (req, reply) {
|
||||
const logger = fastify.log.child({ req, res: 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) {
|
||||
logger.info('Access denied due to user location');
|
||||
req.log.info('Access denied due to user location');
|
||||
return reply.redirect(`${HOME_LOCATION}/blocked`);
|
||||
} else {
|
||||
logger.info('Authentication failed for user:' + error_description);
|
||||
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'
|
||||
@@ -132,17 +135,24 @@ export const auth0Client: FastifyPluginCallbackTypebox = fp(
|
||||
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') {
|
||||
logger.error('Auth failed: invalid state');
|
||||
req.log.warn(error, 'Auth failed: invalid state');
|
||||
} else if (Value.Check(Auth0ErrorSchema, error)) {
|
||||
const errorType = error.data.payload.error;
|
||||
logger.error(error, 'Auth failed: ' + errorType);
|
||||
const expectedErrorTypes = ['invalid_grant', 'access_denied'];
|
||||
if (!expectedErrorTypes.includes(errorType)) {
|
||||
fastify.Sentry?.captureException(error);
|
||||
}
|
||||
req.log.error(error, 'Auth failed: ' + errorType);
|
||||
} else {
|
||||
logger.error(error, 'Failed to get access token from Auth0');
|
||||
fastify.Sentry.captureException(error);
|
||||
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.
|
||||
@@ -153,27 +163,52 @@ export const auth0Client: FastifyPluginCallbackTypebox = fp(
|
||||
}
|
||||
|
||||
let email;
|
||||
const __userinfoStart = performance.now();
|
||||
try {
|
||||
const userinfo = (await fastify.auth0OAuth.userinfo(token)) as {
|
||||
email: string;
|
||||
};
|
||||
logger.info(`Auth0 userinfo: ${JSON.stringify(userinfo)}`);
|
||||
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) {
|
||||
logger.error(error, 'Failed to get userinfo from Auth0');
|
||||
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(error.innerError);
|
||||
fastify.Sentry?.captureException(innerError);
|
||||
req.log.error(innerError, 'Failed to get userinfo from Auth0');
|
||||
} else {
|
||||
fastify.Sentry.captureException(error);
|
||||
fastify.Sentry?.captureException(error);
|
||||
req.log.error(error, 'Failed to get userinfo from Auth0');
|
||||
}
|
||||
return reply.redirectWithMessage(returnTo, {
|
||||
type: 'danger',
|
||||
@@ -185,6 +220,10 @@ export const auth0Client: FastifyPluginCallbackTypebox = fp(
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -19,9 +19,7 @@ const plugin: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
'send401IfNoUser',
|
||||
async function (req: FastifyRequest, reply: FastifyReply) {
|
||||
if (!req.user) {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
|
||||
logger.trace(
|
||||
req.log.trace(
|
||||
'Protected route accessed by unauthenticated user. Sent 401.'
|
||||
);
|
||||
|
||||
@@ -36,9 +34,8 @@ const plugin: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
fastify.decorate(
|
||||
'redirectIfNoUser',
|
||||
async function (req: FastifyRequest, reply: FastifyReply) {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
if (!req.user) {
|
||||
logger.trace(
|
||||
req.log.trace(
|
||||
'Protected route accessed by unauthenticated user. Redirecting to login.'
|
||||
);
|
||||
const { origin } = getRedirectParams(req);
|
||||
@@ -55,11 +52,9 @@ const plugin: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
'redirectIfSignedIn',
|
||||
async function (req: FastifyRequest, reply: FastifyReply) {
|
||||
if (req.user) {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
|
||||
const { returnTo } = getRedirectParams(req);
|
||||
|
||||
logger.trace(`User ${req.user?.id} redirected to: ${returnTo}`);
|
||||
req.log.trace({ returnTo }, 'Signed-in user redirected');
|
||||
|
||||
await reply.redirect(returnTo);
|
||||
}
|
||||
|
||||
@@ -22,8 +22,6 @@ export const cookieUpdate: FastifyPluginCallback<Options> = (
|
||||
done
|
||||
) => {
|
||||
fastify.addHook('onSend', (request, reply, _payload, next) => {
|
||||
const logger = fastify.log.child({ request });
|
||||
|
||||
for (const cookie of options.cookies) {
|
||||
const oldCookie = request.cookies[cookie];
|
||||
if (!oldCookie) continue;
|
||||
@@ -33,7 +31,7 @@ export const cookieUpdate: FastifyPluginCallback<Options> = (
|
||||
void reply.setCookie(cookie, raw, options.attributes);
|
||||
}
|
||||
|
||||
logger.trace(`Updated cookies for user ${request.user?.id}.`);
|
||||
request.log.trace('Updated cookies');
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
@@ -50,7 +50,6 @@ export const unsign = (rawValue: string): UnsignResult => {
|
||||
* @param done Callback to signal that the logic has completed.
|
||||
*/
|
||||
const cookies: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
const logger = fastify.log.child({});
|
||||
void fastify.register(fastifyCookie, {
|
||||
secret: {
|
||||
sign,
|
||||
@@ -73,7 +72,7 @@ const cookies: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
void this.clearCookie(CSRF_SECRET_COOKIE);
|
||||
void this.clearCookie(CSRF_COOKIE);
|
||||
|
||||
logger.trace('Clearing cookies for user.');
|
||||
this.request.log.trace('Clearing cookies for user');
|
||||
});
|
||||
|
||||
done();
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { describe, test, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import {
|
||||
describe,
|
||||
test,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
afterEach,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import Fastify, { FastifyInstance, LogLevel } from 'fastify';
|
||||
import cors from './cors.js';
|
||||
|
||||
@@ -21,10 +29,15 @@ describe('cors', () => {
|
||||
await fastify.close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('should only debug log for /status/* routes', async () => {
|
||||
const logger = fastify.log.child({ req: { url: '/status/ping' } });
|
||||
const spies = NON_DEBUG_LOG_LEVELS.map(level => vi.spyOn(logger, level));
|
||||
const debugSpy = vi.spyOn(logger, 'debug');
|
||||
const spies = NON_DEBUG_LOG_LEVELS.map(level =>
|
||||
vi.spyOn(fastify.log, level)
|
||||
);
|
||||
const debugSpy = vi.spyOn(fastify.log, 'debug');
|
||||
await fastify.inject({
|
||||
url: '/status/ping'
|
||||
});
|
||||
@@ -36,9 +49,10 @@ describe('cors', () => {
|
||||
});
|
||||
|
||||
test('should debug log if the origin is undefined', async () => {
|
||||
const logger = fastify.log.child({ req: { url: '/api/some-endpoint' } });
|
||||
const spies = NON_DEBUG_LOG_LEVELS.map(level => vi.spyOn(logger, level));
|
||||
const debugSpy = vi.spyOn(logger, 'debug');
|
||||
const spies = NON_DEBUG_LOG_LEVELS.map(level =>
|
||||
vi.spyOn(fastify.log, level)
|
||||
);
|
||||
const debugSpy = vi.spyOn(fastify.log, 'debug');
|
||||
await fastify.inject({
|
||||
url: '/api/some-endpoint'
|
||||
});
|
||||
@@ -48,4 +62,17 @@ describe('cors', () => {
|
||||
});
|
||||
expect(debugSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should warn on a request from a disallowed origin', async () => {
|
||||
const warnSpy = vi.spyOn(fastify.log, 'warn');
|
||||
await fastify.inject({
|
||||
url: '/api/some-endpoint',
|
||||
headers: { origin: 'https://disallowed.example.com' }
|
||||
});
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
{ origin: 'https://disallowed.example.com' },
|
||||
'Received request from disallowed origin'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,10 +10,9 @@ const cors: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
});
|
||||
|
||||
fastify.addHook('onRequest', async (req, reply) => {
|
||||
const logger = fastify.log.child({ req });
|
||||
const origin = req.headers.origin;
|
||||
if (origin && allowedOrigins.includes(origin)) {
|
||||
logger.debug(`Allowing access to origin: ${origin}`);
|
||||
req.log.debug({ origin }, 'Allowing access to origin');
|
||||
void reply.header('Access-Control-Allow-Origin', origin);
|
||||
} else {
|
||||
// TODO: Discuss if this is the correct approach. Standard practice is to
|
||||
@@ -23,9 +22,9 @@ const cors: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
void reply.header('Access-Control-Allow-Origin', HOME_LOCATION);
|
||||
|
||||
if (origin && !req.url?.startsWith('/status/')) {
|
||||
logger.info(`Received request from disallowed origin: ${origin}`);
|
||||
req.log.warn({ origin }, 'Received request from disallowed origin');
|
||||
} else {
|
||||
logger.debug(`Unknown or missing origin: ${origin}`);
|
||||
req.log.debug({ origin }, 'Unknown or missing origin');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,11 +28,10 @@ const csrf: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
|
||||
// All routes except signout should add a CSRF token to the response
|
||||
fastify.addHook('onRequest', (req, reply, done) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
const isSignout = req.url === '/signout' || req.url === '/signout/';
|
||||
|
||||
if (!isSignout) {
|
||||
logger.trace('Adding CSRF token to response');
|
||||
req.log.trace('Adding CSRF token to response');
|
||||
const token = reply.generateCsrf();
|
||||
void reply.setCookie(CSRF_COOKIE, token, {
|
||||
sameSite: 'strict',
|
||||
|
||||
@@ -22,7 +22,7 @@ vi.mock('../utils/env.js', async importOriginal => {
|
||||
});
|
||||
|
||||
import '../instrument';
|
||||
import errorHandling from './error-handling.js';
|
||||
import errorHandling, { isExpectedClientError } from './error-handling.js';
|
||||
import redirectWithMessage, { formatMessage } from './redirect-with-message.js';
|
||||
|
||||
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
@@ -175,12 +175,20 @@ describe('errorHandling', () => {
|
||||
|
||||
await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test'
|
||||
url: '/test',
|
||||
headers: {
|
||||
'x-forwarded-for': '203.0.113.7',
|
||||
'cf-ipcountry': 'US'
|
||||
}
|
||||
});
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'a very bad thing happened'
|
||||
err: expect.objectContaining({
|
||||
message: 'a very bad thing happened'
|
||||
}) as unknown,
|
||||
ip: '203.0.113.7',
|
||||
country: 'US'
|
||||
}),
|
||||
'Error in request'
|
||||
);
|
||||
@@ -196,9 +204,11 @@ describe('errorHandling', () => {
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'a very bad thing happened'
|
||||
err: expect.objectContaining({
|
||||
message: 'a very bad thing happened'
|
||||
}) as unknown
|
||||
}),
|
||||
'CSRF error in request'
|
||||
'Client error in request'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -223,6 +233,20 @@ describe('errorHandling', () => {
|
||||
expect(warnLogSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('counts a security.csrf_rejected metric with the error code as reason', async () => {
|
||||
const count = vi.fn();
|
||||
fastify.Sentry = {
|
||||
...fastify.Sentry,
|
||||
metrics: { ...fastify.Sentry.metrics, count }
|
||||
};
|
||||
|
||||
await fastify.inject({ method: 'GET', url: '/test-csrf-token' });
|
||||
|
||||
expect(count).toHaveBeenCalledWith('security.csrf_rejected', 1, {
|
||||
attributes: { reason: 'FST_CSRF_INVALID_TOKEN' }
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sentry integration', () => {
|
||||
let mockServer: ReturnType<typeof setupServer>;
|
||||
|
||||
@@ -296,3 +320,37 @@ describe('errorHandling', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExpectedClientError', () => {
|
||||
test('should return true for a 404 status code', () => {
|
||||
expect(isExpectedClientError({ statusCode: 404 })).toBe(true);
|
||||
});
|
||||
|
||||
test('should return true for a 400 status code', () => {
|
||||
expect(isExpectedClientError({ statusCode: 400 })).toBe(true);
|
||||
});
|
||||
|
||||
test('should return false for a 500 status code', () => {
|
||||
expect(isExpectedClientError({ statusCode: 500 })).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false for a 503 status code', () => {
|
||||
expect(isExpectedClientError({ statusCode: 503 })).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false for an error with no status code', () => {
|
||||
expect(isExpectedClientError(new Error())).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false for null', () => {
|
||||
expect(isExpectedClientError(null)).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false for undefined', () => {
|
||||
expect(isExpectedClientError(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false for a non-numeric status code', () => {
|
||||
expect(isExpectedClientError({ statusCode: '404' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as Sentry from '@sentry/node';
|
||||
import fp from 'fastify-plugin';
|
||||
|
||||
import { getRedirectParams } from '../utils/redirection.js';
|
||||
import { clientNetInfo } from '../utils/logger.js';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
@@ -17,13 +18,21 @@ declare module 'fastify' {
|
||||
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
|
||||
* @param done Callback to signal that the logic has completed.
|
||||
*/
|
||||
export const isExpectedClientError = (error: unknown): boolean =>
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'statusCode' in error &&
|
||||
typeof (error as { statusCode?: unknown }).statusCode === 'number' &&
|
||||
(error as { statusCode: number }).statusCode < 500;
|
||||
|
||||
const errorHandling: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
Sentry.setupFastifyErrorHandler(fastify);
|
||||
Sentry.setupFastifyErrorHandler(fastify, {
|
||||
shouldHandleError: error => !isExpectedClientError(error)
|
||||
});
|
||||
|
||||
fastify.decorate('Sentry', Sentry);
|
||||
|
||||
fastify.setErrorHandler((error: FastifyError, request, reply) => {
|
||||
const logger = fastify.log.child({ req: request });
|
||||
const accepts = request.accepts().type(['json', 'html']);
|
||||
const { returnTo } = getRedirectParams(request);
|
||||
|
||||
@@ -38,11 +47,16 @@ const errorHandling: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
error.code === 'FST_CSRF_MISSING_SECRET';
|
||||
|
||||
if (!isCSRFError) {
|
||||
const context = { err: error, ...clientNetInfo(request) };
|
||||
if (reply.statusCode >= 500) {
|
||||
logger.error(error, 'Error in request');
|
||||
request.log.error(context, 'Error in request');
|
||||
} else {
|
||||
logger.warn(error, 'CSRF error in request');
|
||||
request.log.warn(context, 'Client error in request');
|
||||
}
|
||||
} else {
|
||||
fastify.Sentry?.metrics?.count('security.csrf_rejected', 1, {
|
||||
attributes: { reason: error.code }
|
||||
});
|
||||
}
|
||||
|
||||
const message =
|
||||
|
||||
@@ -2,30 +2,22 @@ import { describe, test, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import growthBook from './growth-book.js';
|
||||
|
||||
vi.mock('../utils/env', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('../utils/env.js')>();
|
||||
return {
|
||||
...actual,
|
||||
// We're only interested in the production behaviour
|
||||
FREECODECAMP_NODE_ENV: 'production'
|
||||
};
|
||||
});
|
||||
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
|
||||
describe('growth-book', () => {
|
||||
let fastify: FastifyInstance;
|
||||
beforeAll(() => {
|
||||
fastify = Fastify();
|
||||
// @ts-expect-error we're mocking the Sentry plugin
|
||||
fastify.Sentry = { captureException };
|
||||
fastify.Sentry = { captureException, metrics: { count } };
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fastify.close();
|
||||
});
|
||||
|
||||
test('should log the error if the GrowthBook initialization fails', async () => {
|
||||
test('should log and capture the error if the GrowthBook initialization fails', async () => {
|
||||
const spy = vi.spyOn(fastify.log, 'error');
|
||||
|
||||
await fastify.register(growthBook, {
|
||||
@@ -35,5 +27,6 @@ describe('growth-book', () => {
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(captureException).toHaveBeenCalled();
|
||||
expect(count).toHaveBeenCalledWith('growthbook.init_failed', 1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,6 @@ import { GrowthBook, Options } from '@growthbook/growthbook';
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
|
||||
import { FREECODECAMP_NODE_ENV } from '../utils/env.js';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
gb: GrowthBook;
|
||||
@@ -18,9 +16,10 @@ const growthBook: FastifyPluginAsync<Options> = async (fastify, options) => {
|
||||
if (hasRequiredConfig) {
|
||||
const res = await gb.init({ timeout: 3000 });
|
||||
|
||||
if (res.error && FREECODECAMP_NODE_ENV === 'production') {
|
||||
if (res.error) {
|
||||
fastify.log.error(res.error, 'Failed to initialize GrowthBook');
|
||||
fastify.Sentry.captureException(res.error);
|
||||
fastify.Sentry?.captureException(res.error);
|
||||
fastify.Sentry?.metrics?.count('growthbook.init_failed', 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,4 +20,28 @@ describe('mailer', () => {
|
||||
|
||||
expect(send).toHaveBeenCalledWith(data);
|
||||
});
|
||||
|
||||
test('should emit a Sentry counter and re-throw when the provider fails to send', async () => {
|
||||
const fastify = Fastify();
|
||||
const sendError = new Error('send failed');
|
||||
const send = vi.fn().mockRejectedValue(sendError);
|
||||
await fastify.register(mailer, { provider: { send } });
|
||||
|
||||
const count = vi.fn();
|
||||
// @ts-expect-error - Only mocks part of the Sentry object.
|
||||
fastify.Sentry = { metrics: { count } };
|
||||
|
||||
const data = {
|
||||
to: 'test@add.ress',
|
||||
from: 'team@freecodecamp.org',
|
||||
subject: 'test',
|
||||
text: 'test'
|
||||
};
|
||||
|
||||
await expect(fastify.sendEmail(data)).rejects.toThrow(sendError);
|
||||
|
||||
expect(count).toHaveBeenCalledWith('mailer.send_failed', 1, {
|
||||
attributes: { result: 'error' }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,9 +29,15 @@ const plugin: FastifyPluginCallback<{ provider: MailProvider }> = (
|
||||
const { provider } = options;
|
||||
|
||||
fastify.decorate('sendEmail', async (args: SendEmailArgs) => {
|
||||
const logger = fastify.log.child({ args });
|
||||
logger.info('Sending Email');
|
||||
return await provider.send(args);
|
||||
fastify.log.info({ subject: args.subject }, 'Sending email');
|
||||
try {
|
||||
return await provider.send(args);
|
||||
} catch (error) {
|
||||
fastify.Sentry?.metrics?.count('mailer.send_failed', 1, {
|
||||
attributes: { result: 'error' }
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
done();
|
||||
|
||||
@@ -15,8 +15,7 @@ const fourOhFour: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
// If the request accepts JSON and does not specifically prefer text/html,
|
||||
// this will return a 404 JSON response. Everything else will be redirected.
|
||||
fastify.setNotFoundHandler((req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
logger.info('User requested path that does not exist');
|
||||
req.log.debug('User requested path that does not exist');
|
||||
|
||||
const accepted = req.accepts().type(['json', 'html']);
|
||||
if (accepted == 'json') {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { monitorEventLoopDelay } from 'node:perf_hooks';
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
|
||||
const SAMPLE_INTERVAL_MS = 15_000;
|
||||
|
||||
const runtimeMetrics: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
const loopDelay = monitorEventLoopDelay({ resolution: 20 });
|
||||
loopDelay.enable();
|
||||
|
||||
const timer = setInterval(() => {
|
||||
fastify.Sentry?.metrics?.gauge(
|
||||
'runtime.memory_rss_bytes',
|
||||
process.memoryUsage().rss
|
||||
);
|
||||
fastify.Sentry?.metrics?.gauge(
|
||||
'runtime.event_loop_delay_p99_ms',
|
||||
loopDelay.percentile(99) / 1e6
|
||||
);
|
||||
loopDelay.reset();
|
||||
}, SAMPLE_INTERVAL_MS);
|
||||
timer.unref();
|
||||
|
||||
fastify.addHook('onClose', (_instance, hookDone) => {
|
||||
clearInterval(timer);
|
||||
loopDelay.disable();
|
||||
hookDone();
|
||||
});
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
export default fp(runtimeMetrics, { name: 'runtime-metrics' });
|
||||
@@ -14,9 +14,14 @@ import serviceBearerAuth from './service-bearer-auth.js';
|
||||
describe('service-bearer-auth plugin', () => {
|
||||
let fastify: FastifyInstance;
|
||||
|
||||
let captureException: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
fastify = Fastify();
|
||||
await fastify.register(serviceBearerAuth);
|
||||
captureException = vi.fn();
|
||||
// @ts-expect-error Sentry isn't decorated in this minimal test app.
|
||||
fastify.Sentry = { captureException };
|
||||
fastify.addHook('onRequest', fastify.validateBearerToken);
|
||||
fastify.get('/test', (_req, reply) => {
|
||||
void reply.send({ ok: true });
|
||||
@@ -38,6 +43,7 @@ describe('service-bearer-auth plugin', () => {
|
||||
|
||||
expect(res.statusCode).toEqual(200);
|
||||
expect(res.json()).toEqual({ ok: true });
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should return 401 when authorization header is missing', async () => {
|
||||
@@ -120,6 +126,9 @@ describe('service-bearer-auth plugin without a configured token', () => {
|
||||
const { default: plugin } = await import('./service-bearer-auth.js');
|
||||
const fastify = Fastify();
|
||||
await fastify.register(plugin);
|
||||
const captureException = vi.fn();
|
||||
// @ts-expect-error Sentry isn't decorated in this minimal test app.
|
||||
fastify.Sentry = { captureException };
|
||||
fastify.addHook('onRequest', fastify.validateBearerToken);
|
||||
fastify.get('/test', (_req, reply) => {
|
||||
void reply.send({ ok: true });
|
||||
@@ -137,6 +146,10 @@ describe('service-bearer-auth plugin without a configured token', () => {
|
||||
expect(res.json()).toEqual({
|
||||
error: 'Service authentication not configured'
|
||||
});
|
||||
expect(captureException).toHaveBeenCalledTimes(1);
|
||||
expect(captureException).toHaveBeenCalledWith(
|
||||
new Error('TPA_API_BEARER_TOKEN is not configured')
|
||||
);
|
||||
|
||||
await fastify.close();
|
||||
});
|
||||
|
||||
@@ -23,7 +23,10 @@ const plugin: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
async function (req: FastifyRequest, reply: FastifyReply) {
|
||||
const secret = TPA_API_BEARER_TOKEN ?? '';
|
||||
if (secret.length === 0) {
|
||||
fastify.log.error('TPA_API_BEARER_TOKEN is not configured');
|
||||
req.log.error('TPA_API_BEARER_TOKEN is not configured');
|
||||
fastify.Sentry?.captureException(
|
||||
new Error('TPA_API_BEARER_TOKEN is not configured')
|
||||
);
|
||||
await reply
|
||||
.status(500)
|
||||
.send({ error: 'Service authentication not configured' });
|
||||
|
||||
@@ -104,12 +104,24 @@ describe('classroom routes', () => {
|
||||
});
|
||||
|
||||
test('returns 200 with empty userId when no classroom account matches email', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await post('/apps/classroom/get-user-id').send({
|
||||
email: defaultUserEmail
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toStrictEqual({ userId: '' });
|
||||
expect(count).toHaveBeenCalledWith('classroom.user_looked_up', 1, {
|
||||
attributes: { result: 'not_found' }
|
||||
});
|
||||
});
|
||||
|
||||
test('returns 200 with userId for a classroom account', async () => {
|
||||
@@ -118,15 +130,31 @@ describe('classroom routes', () => {
|
||||
data: { isClassroomAccount: true }
|
||||
});
|
||||
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await post('/apps/classroom/get-user-id').send({
|
||||
email: defaultUserEmail
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toStrictEqual({ userId: defaultUserId });
|
||||
expect(count).toHaveBeenCalledWith('classroom.user_looked_up', 1, {
|
||||
attributes: { result: 'found' }
|
||||
});
|
||||
});
|
||||
|
||||
test('returns 500 when the database query fails', async () => {
|
||||
test('returns 500 and captures when the database query fails', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = { ...originalSentry, captureException };
|
||||
|
||||
const original = fastifyTestInstance.prisma.user.findFirst;
|
||||
fastifyTestInstance.prisma.user.findFirst = vi
|
||||
.fn()
|
||||
@@ -137,11 +165,15 @@ describe('classroom routes', () => {
|
||||
});
|
||||
|
||||
fastifyTestInstance.prisma.user.findFirst = original;
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body).toStrictEqual({
|
||||
error: 'Failed to retrieve user id'
|
||||
});
|
||||
expect(captureException).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ message: 'test' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -272,7 +304,11 @@ describe('classroom routes', () => {
|
||||
expect(Object.keys(challenge)).toStrictEqual(['id', 'completedDate']);
|
||||
});
|
||||
|
||||
test('returns 500 when the database query fails', async () => {
|
||||
test('returns 500 and captures when the database query fails', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = { ...originalSentry, captureException };
|
||||
|
||||
const original = fastifyTestInstance.prisma.user.findMany;
|
||||
fastifyTestInstance.prisma.user.findMany = vi
|
||||
.fn()
|
||||
@@ -283,11 +319,15 @@ describe('classroom routes', () => {
|
||||
});
|
||||
|
||||
fastifyTestInstance.prisma.user.findMany = original;
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body).toStrictEqual({
|
||||
error: 'Failed to retrieve user data'
|
||||
});
|
||||
expect(captureException).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ message: 'test' })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,14 +29,22 @@ export const classroomRoutes: FastifyPluginCallbackTypebox = (
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
fastify.Sentry?.metrics?.count('classroom.user_looked_up', 1, {
|
||||
attributes: { result: 'not_found' }
|
||||
});
|
||||
return reply.send({ userId: '' });
|
||||
}
|
||||
|
||||
fastify.Sentry?.metrics?.count('classroom.user_looked_up', 1, {
|
||||
attributes: { result: 'found' }
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
userId: user.id
|
||||
});
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
fastify.Sentry?.captureException(error);
|
||||
request.log.error(error, 'Failed to retrieve user id');
|
||||
return reply.code(500).send({ error: 'Failed to retrieve user id' });
|
||||
}
|
||||
}
|
||||
@@ -78,7 +86,8 @@ export const classroomRoutes: FastifyPluginCallbackTypebox = (
|
||||
data: userData
|
||||
});
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
fastify.Sentry?.captureException(error);
|
||||
request.log.error(error, 'Failed to retrieve user data');
|
||||
return reply.code(500).send({ error: 'Failed to retrieve user data' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,14 +20,10 @@ import {
|
||||
GROWTHBOOK_FASTIFY_CLIENT_KEY
|
||||
} from '../../utils/env.js';
|
||||
|
||||
const captureException = vi.fn();
|
||||
|
||||
async function setupServer() {
|
||||
const fastify = Fastify();
|
||||
await fastify.register(db);
|
||||
await checkCanConnectToDb(fastify.prisma);
|
||||
// @ts-expect-error we're mocking the Sentry plugin
|
||||
fastify.Sentry = { captureException };
|
||||
await fastify.register(growthBook, {
|
||||
apiHost: GROWTHBOOK_FASTIFY_API_HOST,
|
||||
clientKey: GROWTHBOOK_FASTIFY_CLIENT_KEY
|
||||
@@ -51,10 +47,9 @@ describe('findOrCreateUser', () => {
|
||||
await fastify.prisma.user.deleteMany({ where: { email } });
|
||||
await fastify.prisma.dripCampaign.deleteMany({ where: { email } });
|
||||
vi.restoreAllMocks();
|
||||
captureException.mockReset();
|
||||
});
|
||||
|
||||
test('should send a message to Sentry if there are multiple users with the same email', async () => {
|
||||
test('should log an error and capture an exception if there are multiple users with the same email', async () => {
|
||||
const user1 = await fastify.prisma.user.create({
|
||||
data: createUserInput(email)
|
||||
});
|
||||
@@ -62,34 +57,62 @@ describe('findOrCreateUser', () => {
|
||||
data: createUserInput(email)
|
||||
});
|
||||
|
||||
const ids = [user1.id, user2.id];
|
||||
const userIds = [user1.id, user2.id];
|
||||
|
||||
const logError = vi.spyOn(fastify.log, 'error');
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
// @ts-expect-error - Only mocks part of the Sentry object.
|
||||
fastify.Sentry = { captureException, metrics: { count } };
|
||||
|
||||
await findOrCreateUser(fastify, email);
|
||||
|
||||
expect(captureException).toHaveBeenCalledTimes(1);
|
||||
expect(captureException).toHaveBeenCalledWith(
|
||||
new Error(`Multiple user records found for: ${ids.join(', ')}`)
|
||||
expect(logError).toHaveBeenCalledWith(
|
||||
{ audit: true, userIds, email },
|
||||
'Multiple user records found'
|
||||
);
|
||||
expect(captureException).toHaveBeenCalledWith(
|
||||
new Error('Multiple user records found for: ' + userIds.join(', '))
|
||||
);
|
||||
expect(count).toHaveBeenCalledWith('user.duplicate_email_detected', 1);
|
||||
});
|
||||
|
||||
test('should NOT send a message if there is only one user with the email', async () => {
|
||||
test('should NOT log an error or capture an exception if there is only one user with the email', async () => {
|
||||
await fastify.prisma.user.create({ data: createUserInput(email) });
|
||||
|
||||
const logError = vi.spyOn(fastify.log, 'error');
|
||||
const captureException = vi.fn();
|
||||
// @ts-expect-error - Only mocks part of the Sentry object.
|
||||
fastify.Sentry = { captureException };
|
||||
|
||||
await findOrCreateUser(fastify, email);
|
||||
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should NOT send a message if there are no users with the email', async () => {
|
||||
test('should NOT log an error or capture an exception if there are no users with the email', async () => {
|
||||
const logError = vi.spyOn(fastify.log, 'error');
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
// @ts-expect-error - Only mocks part of the Sentry object.
|
||||
fastify.Sentry = { captureException, metrics: { count } };
|
||||
|
||||
await findOrCreateUser(fastify, email);
|
||||
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
expect(count).toHaveBeenCalledWith('user.created', 1);
|
||||
});
|
||||
|
||||
describe('drip campaign logic', () => {
|
||||
test('should create a drip campaign record when a new user is created and feature flag is enabled', async () => {
|
||||
vi.spyOn(fastify.gb, 'isOn').mockImplementationOnce(() => true);
|
||||
|
||||
const count = vi.fn();
|
||||
// @ts-expect-error - Only mocks part of the Sentry object.
|
||||
fastify.Sentry = { ...fastify.Sentry, metrics: { count } };
|
||||
|
||||
const user = await findOrCreateUser(fastify, email);
|
||||
|
||||
const dripCampaign = await fastify.prisma.dripCampaign.findFirst({
|
||||
@@ -100,6 +123,13 @@ describe('findOrCreateUser', () => {
|
||||
expect(dripCampaign?.userId).toBe(user.id);
|
||||
expect(dripCampaign?.email).toBe(email);
|
||||
expect(['A', 'B']).toContain(dripCampaign?.variant);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'growthbook.signup_flag_evaluated',
|
||||
1,
|
||||
{
|
||||
attributes: { flag: 'drip-campaign', result: 'success' }
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('should assign a consistent variant based on userId', async () => {
|
||||
@@ -130,22 +160,38 @@ describe('findOrCreateUser', () => {
|
||||
test('should not prevent user creation if drip campaign record creation fails', async () => {
|
||||
vi.spyOn(fastify.gb, 'isOn').mockImplementationOnce(() => true);
|
||||
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
// @ts-expect-error - Only mocks part of the Sentry object.
|
||||
fastify.Sentry = { captureException, metrics: { count } };
|
||||
|
||||
const originalCreate = fastify.prisma.dripCampaign.create;
|
||||
|
||||
fastify.prisma.dripCampaign.create = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('Database error'));
|
||||
|
||||
const logError = vi.spyOn(fastify.log, 'error');
|
||||
|
||||
const user = await findOrCreateUser(fastify, email);
|
||||
|
||||
expect(user).toBeDefined();
|
||||
expect(user.id).toBeTruthy();
|
||||
|
||||
expect(captureException).toHaveBeenCalledTimes(1);
|
||||
expect(captureException).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Database error'
|
||||
})
|
||||
const dbError: unknown = expect.objectContaining({
|
||||
message: 'Database error'
|
||||
});
|
||||
expect(logError).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: user.id },
|
||||
'Failed to create drip campaign record for user'
|
||||
);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'growthbook.signup_flag_evaluated',
|
||||
1,
|
||||
{
|
||||
attributes: { flag: 'drip-campaign', result: 'failed' }
|
||||
}
|
||||
);
|
||||
|
||||
fastify.prisma.dripCampaign.create = originalCreate;
|
||||
|
||||
@@ -19,11 +19,15 @@ export const findOrCreateUser = async (
|
||||
select: { id: true, acceptedPrivacyTerms: true }
|
||||
});
|
||||
if (existingUser.length > 1) {
|
||||
fastify.Sentry.captureException(
|
||||
new Error(
|
||||
`Multiple user records found for: ${existingUser.map(user => user.id).join(', ')}`
|
||||
)
|
||||
const userIds = existingUser.map(user => user.id);
|
||||
fastify.log.error(
|
||||
{ audit: true, userIds, email },
|
||||
'Multiple user records found'
|
||||
);
|
||||
fastify.Sentry?.captureException(
|
||||
new Error('Multiple user records found for: ' + userIds.join(', '))
|
||||
);
|
||||
fastify.Sentry?.metrics?.count('user.duplicate_email_detected', 1);
|
||||
}
|
||||
|
||||
if (existingUser[0]) {
|
||||
@@ -36,6 +40,8 @@ export const findOrCreateUser = async (
|
||||
select: { id: true, acceptedPrivacyTerms: true }
|
||||
});
|
||||
|
||||
fastify.Sentry?.metrics?.count('user.created', 1);
|
||||
|
||||
// Create drip campaign record if feature flag is enabled
|
||||
if (fastify.gb.isOn('drip-campaign')) {
|
||||
try {
|
||||
@@ -48,15 +54,21 @@ export const findOrCreateUser = async (
|
||||
}
|
||||
});
|
||||
fastify.log.info(
|
||||
`Drip campaign record created for user ${newUser.id} with variant ${variant}`
|
||||
{ userId: newUser.id, variant },
|
||||
'Drip campaign record created for user'
|
||||
);
|
||||
} catch (error) {
|
||||
// Log the error but don't fail user creation
|
||||
fastify.Sentry?.metrics?.count('growthbook.signup_flag_evaluated', 1, {
|
||||
attributes: { flag: 'drip-campaign', result: 'success' }
|
||||
});
|
||||
} catch (err) {
|
||||
fastify.Sentry?.captureException(err);
|
||||
fastify.log.error(
|
||||
error,
|
||||
`Failed to create drip campaign record for user ${newUser.id}`
|
||||
{ err, userId: newUser.id },
|
||||
'Failed to create drip campaign record for user'
|
||||
);
|
||||
fastify.Sentry.captureException(error);
|
||||
fastify.Sentry?.metrics?.count('growthbook.signup_flag_evaluated', 1, {
|
||||
attributes: { flag: 'drip-campaign', result: 'failed' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,12 +63,22 @@ describe('certificate routes', () => {
|
||||
});
|
||||
|
||||
test('should return 400 if certSlug is invalid', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superRequest('/certificate/verify', {
|
||||
method: 'PUT',
|
||||
setCookies
|
||||
}).send({
|
||||
certSlug: 'non-existant'
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
response: {
|
||||
message: 'flash.wrong-name',
|
||||
@@ -76,20 +86,32 @@ describe('certificate routes', () => {
|
||||
}
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect(count).toHaveBeenCalledWith('certificate.claim_blocked', 1, {
|
||||
attributes: { reason: 'unknown_slug' }
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: Revisit this test after deciding if we need/want to fetch the
|
||||
// entire user during authorization or just the user id.
|
||||
test.todo('should return 500 if user not found in db', async () => {
|
||||
vi.spyOn(
|
||||
fastifyTestInstance.prisma.user,
|
||||
'findUnique'
|
||||
).mockImplementation(
|
||||
() =>
|
||||
Promise.resolve(null) as ReturnType<
|
||||
typeof fastifyTestInstance.prisma.user.findUnique
|
||||
>
|
||||
);
|
||||
test('should return 500 and capture an exception if user not found in db', async () => {
|
||||
const findUniqueForAuth =
|
||||
fastifyTestInstance.prisma.user.findUnique.bind(
|
||||
fastifyTestInstance.prisma.user
|
||||
);
|
||||
|
||||
vi.spyOn(fastifyTestInstance.prisma.user, 'findUnique')
|
||||
.mockImplementationOnce(findUniqueForAuth)
|
||||
.mockResolvedValueOnce(null);
|
||||
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superRequest('/certificate/verify', {
|
||||
method: 'PUT',
|
||||
setCookies
|
||||
@@ -97,11 +119,15 @@ describe('certificate routes', () => {
|
||||
certSlug: Certification.RespWebDesign
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.body).toStrictEqual({
|
||||
message: 'flash.went-wrong',
|
||||
type: 'danger'
|
||||
});
|
||||
expect(response.status).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
expect(count).toHaveBeenCalledWith('certificate.claim_user_missing', 1);
|
||||
});
|
||||
|
||||
test('should return 400 if user has not set a `name`', async () => {
|
||||
@@ -112,6 +138,13 @@ describe('certificate routes', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superRequest('/certificate/verify', {
|
||||
method: 'PUT',
|
||||
setCookies
|
||||
@@ -119,6 +152,8 @@ describe('certificate routes', () => {
|
||||
certSlug: Certification.RespWebDesign
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
response: {
|
||||
type: 'info',
|
||||
@@ -153,6 +188,12 @@ describe('certificate routes', () => {
|
||||
completedChallenges: []
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect(count).toHaveBeenCalledWith('certificate.claim_blocked', 1, {
|
||||
attributes: {
|
||||
certSlug: Certification.RespWebDesign,
|
||||
reason: 'name_missing'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('should return 200 if user already claimed cert', async () => {
|
||||
@@ -162,6 +203,14 @@ describe('certificate routes', () => {
|
||||
isRespWebDesignCert: true
|
||||
}
|
||||
});
|
||||
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superRequest('/certificate/verify', {
|
||||
method: 'PUT',
|
||||
setCookies
|
||||
@@ -169,6 +218,8 @@ describe('certificate routes', () => {
|
||||
certSlug: Certification.RespWebDesign
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
expect(response.body.response).toStrictEqual({
|
||||
type: 'info',
|
||||
@@ -179,6 +230,12 @@ describe('certificate routes', () => {
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(count).toHaveBeenCalledWith('certificate.claim_blocked', 1, {
|
||||
attributes: {
|
||||
certSlug: Certification.RespWebDesign,
|
||||
reason: 'already_claimed'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('should return 400 if not all requirements have been met to claim', async () => {
|
||||
@@ -194,6 +251,14 @@ describe('certificate routes', () => {
|
||||
isRespWebDesignCert: false
|
||||
}
|
||||
});
|
||||
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superRequest('/certificate/verify', {
|
||||
method: 'PUT',
|
||||
setCookies
|
||||
@@ -201,6 +266,8 @@ describe('certificate routes', () => {
|
||||
certSlug: Certification.RespWebDesign
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
expect(response.body.response).toStrictEqual({
|
||||
message: 'flash.incomplete-steps',
|
||||
@@ -208,6 +275,12 @@ describe('certificate routes', () => {
|
||||
variables: { name: 'Legacy Responsive Web Design V8' }
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect(count).toHaveBeenCalledWith('certificate.claim_blocked', 1, {
|
||||
attributes: {
|
||||
certSlug: Certification.RespWebDesign,
|
||||
reason: 'incomplete_steps'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Note: Email does not actually send (work) in development, but status should still be 200.
|
||||
@@ -239,6 +312,41 @@ describe('certificate routes', () => {
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test('should capture an exception if the congratulations email fails to send', async () => {
|
||||
await fastifyTestInstance.prisma.user.updateMany({
|
||||
where: { email: defaultUserEmail },
|
||||
data: {
|
||||
completedChallenges: [
|
||||
{ id: 'bd7158d8c442eddfaeb5bd18', completedDate: 123456789 },
|
||||
{ id: '587d78af367417b2b2512b03', completedDate: 123456789 },
|
||||
{ id: '587d78af367417b2b2512b04', completedDate: 123456789 },
|
||||
{ id: '587d78b0367417b2b2512b05', completedDate: 123456789 },
|
||||
{ id: 'bd7158d8c242eddfaeb5bd13', completedDate: 123456789 }
|
||||
],
|
||||
isFullStackDeveloperCertV9: true
|
||||
}
|
||||
});
|
||||
|
||||
vi.spyOn(fastifyTestInstance, 'sendEmail').mockRejectedValueOnce(
|
||||
new Error('send failed')
|
||||
);
|
||||
const captureException = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = { ...originalSentry, captureException };
|
||||
|
||||
const response = await superRequest('/certificate/verify', {
|
||||
method: 'PUT',
|
||||
setCookies
|
||||
}).send({
|
||||
certSlug: Certification.RespWebDesign
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test('should return 200 if all went well', async () => {
|
||||
await fastifyTestInstance.prisma.user.updateMany({
|
||||
where: { email: defaultUserEmail },
|
||||
@@ -254,6 +362,13 @@ describe('certificate routes', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superRequest('/certificate/verify', {
|
||||
method: 'PUT',
|
||||
setCookies
|
||||
@@ -261,6 +376,8 @@ describe('certificate routes', () => {
|
||||
certSlug: Certification.RespWebDesign
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
const user = await fastifyTestInstance.prisma.user.findFirst({
|
||||
where: { email: defaultUserEmail }
|
||||
});
|
||||
@@ -344,6 +461,9 @@ describe('certificate routes', () => {
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('certificate.claimed', 1, {
|
||||
attributes: { certSlug: Certification.RespWebDesign }
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
|
||||
@@ -225,11 +225,13 @@ export const protectedCertificateRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
const { certSlug } = req.body;
|
||||
|
||||
if (!isKnownCertSlug(certSlug) || !isCertAllowed(certSlug)) {
|
||||
logger.warn(`Unknown certificate slug "${certSlug}"`);
|
||||
req.log.warn({ certSlug }, 'Unknown certificate slug');
|
||||
fastify.Sentry?.metrics?.count('certificate.claim_blocked', 1, {
|
||||
attributes: { reason: 'unknown_slug' }
|
||||
});
|
||||
void reply.code(400);
|
||||
return {
|
||||
response: {
|
||||
@@ -250,8 +252,11 @@ export const protectedCertificateRoutes: FastifyPluginCallbackTypebox = (
|
||||
|
||||
if (!user) {
|
||||
void reply.code(500);
|
||||
logger.error(`User with id ${req.user?.id} not found`);
|
||||
fastify.Sentry.captureException(Error('User not found'));
|
||||
fastify.Sentry?.captureException(
|
||||
new Error('User not found when claiming certificate')
|
||||
);
|
||||
fastify.Sentry?.metrics?.count('certificate.claim_user_missing', 1);
|
||||
req.log.error('User not found');
|
||||
return {
|
||||
type: 'danger',
|
||||
// message: 'User not found'
|
||||
@@ -263,7 +268,10 @@ export const protectedCertificateRoutes: FastifyPluginCallbackTypebox = (
|
||||
|
||||
// TODO: Discuss if this is a requirement still
|
||||
if (!user.name) {
|
||||
logger.warn(`${user.id} does not have a name property`);
|
||||
req.log.warn('User does not have a name property');
|
||||
fastify.Sentry?.metrics?.count('certificate.claim_blocked', 1, {
|
||||
attributes: { certSlug, reason: 'name_missing' }
|
||||
});
|
||||
void reply.code(400);
|
||||
return {
|
||||
response: {
|
||||
@@ -276,7 +284,10 @@ export const protectedCertificateRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
|
||||
if (user[certType]) {
|
||||
logger.info(`${user.id} has already claimed ${certName}`);
|
||||
req.log.debug({ certName }, 'User has already claimed certificate');
|
||||
fastify.Sentry?.metrics?.count('certificate.claim_blocked', 1, {
|
||||
attributes: { certSlug, reason: 'already_claimed' }
|
||||
});
|
||||
void reply.code(200);
|
||||
return {
|
||||
response: {
|
||||
@@ -298,7 +309,13 @@ export const protectedCertificateRoutes: FastifyPluginCallbackTypebox = (
|
||||
);
|
||||
|
||||
if (!hasCompletedTestRequirements) {
|
||||
logger.info(`${user.id} has not completed the tests for ${certName}`);
|
||||
req.log.warn(
|
||||
{ certName },
|
||||
'User has not completed the tests for certificate'
|
||||
);
|
||||
fastify.Sentry?.metrics?.count('certificate.claim_blocked', 1, {
|
||||
attributes: { certSlug, reason: 'incomplete_steps' }
|
||||
});
|
||||
void reply.code(400);
|
||||
return {
|
||||
response: {
|
||||
@@ -390,16 +407,19 @@ export const protectedCertificateRoutes: FastifyPluginCallbackTypebox = (
|
||||
|
||||
// Failed email should not prevent successful response.
|
||||
try {
|
||||
logger.info(`Sending congratulations email to ${user.id}`);
|
||||
req.log.debug('Sending congratulations email');
|
||||
// TODO(POST-MVP): Ensure Camper knows they **have** claimed the cert, but the email failed to send.
|
||||
await fastify.sendEmail(notifyUser);
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
fastify.Sentry.captureException(e);
|
||||
req.log.error(e, 'Failed to send congratulations email');
|
||||
fastify.Sentry?.captureException(e);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`${user.id} has claimed ${certName}`);
|
||||
req.log.info({ certName, audit: true }, 'User has claimed certificate');
|
||||
fastify.Sentry?.metrics?.count('certificate.claimed', 1, {
|
||||
attributes: { certSlug }
|
||||
});
|
||||
void reply.code(200);
|
||||
return {
|
||||
response: {
|
||||
|
||||
@@ -21,6 +21,18 @@ vi.mock('../helpers/challenge-helpers', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../utils/exam.js', async () => {
|
||||
const originalModule = await vi.importActual<
|
||||
typeof import('../../utils/exam.js')
|
||||
>('../../utils/exam.js');
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
...originalModule,
|
||||
generateRandomExam: vi.fn(originalModule.generateRandomExam)
|
||||
};
|
||||
});
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { omit } from 'lodash-es';
|
||||
@@ -45,6 +57,7 @@ import {
|
||||
completedExamChallengeAllCorrect,
|
||||
completedTrophyChallenges,
|
||||
examChallengeId,
|
||||
examJson,
|
||||
mockResultsZeroCorrect,
|
||||
mockResultsTwoCorrect,
|
||||
mockResultsAllCorrect,
|
||||
@@ -58,8 +71,10 @@ import { Answer } from '../../utils/exam-types.js';
|
||||
import type { getSessionUser } from '../../schemas/user/get-session-user.js';
|
||||
import { verifyTrophyWithMicrosoft } from '../helpers/challenge-helpers.js';
|
||||
import { encodeUserToken } from '../../utils/tokens.js';
|
||||
import { generateRandomExam } from '../../utils/exam.js';
|
||||
|
||||
const mockVerifyTrophyWithMicrosoft = vi.mocked(verifyTrophyWithMicrosoft);
|
||||
const mockGenerateRandomExam = vi.mocked(generateRandomExam);
|
||||
|
||||
const EXISTING_COMPLETED_DATE = new Date('2024-11-08').getTime();
|
||||
const DATE_NOW = Date.now();
|
||||
@@ -281,6 +296,13 @@ describe('challengeRoutes', () => {
|
||||
});
|
||||
|
||||
test('should return 400 for invalid user tokens', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPost('/coderoad-challenge-completed')
|
||||
.set('coderoad-user-token', 'invalid')
|
||||
.send({
|
||||
@@ -292,6 +314,10 @@ describe('challengeRoutes', () => {
|
||||
type: 'error'
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect(count).toHaveBeenCalledWith('coderoad.request_rejected', 1, {
|
||||
attributes: { reason: 'invalid_token' }
|
||||
});
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('should return 400 for nonsensical user tokens', async () => {
|
||||
@@ -318,6 +344,13 @@ describe('challengeRoutes', () => {
|
||||
|
||||
const token = (tokenResponse.body as { userToken: string }).userToken;
|
||||
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPost('/coderoad-challenge-completed')
|
||||
.set('coderoad-user-token', token)
|
||||
.send({ tutorialId: 'invalid' });
|
||||
@@ -327,6 +360,10 @@ describe('challengeRoutes', () => {
|
||||
type: 'error'
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect(count).toHaveBeenCalledWith('coderoad.request_rejected', 1, {
|
||||
attributes: { reason: 'untrusted_org' }
|
||||
});
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('should return 400 if invalid tutorialId but is hosted on freeCodeCamp', async () => {
|
||||
@@ -336,6 +373,13 @@ describe('challengeRoutes', () => {
|
||||
|
||||
const token = (tokenResponse.body as { userToken: string }).userToken;
|
||||
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPost('/coderoad-challenge-completed')
|
||||
.set('coderoad-user-token', token)
|
||||
.send({ tutorialId: 'freeCodeCamp/invalid:V1.0.0' });
|
||||
@@ -345,6 +389,10 @@ describe('challengeRoutes', () => {
|
||||
type: 'error'
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect(count).toHaveBeenCalledWith('coderoad.request_rejected', 1, {
|
||||
attributes: { reason: 'invalid_tutorial' }
|
||||
});
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('Should complete challenge with code 200', async () => {
|
||||
@@ -354,6 +402,13 @@ describe('challengeRoutes', () => {
|
||||
|
||||
const token = (tokenResponse.body as { userToken: string }).userToken;
|
||||
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
// This route is special since it does not have CSRF protection OR authN
|
||||
// protection. As such, we use a normal `request` to send the bare
|
||||
// minimum (no extra headers or cookies).
|
||||
@@ -380,6 +435,10 @@ describe('challengeRoutes', () => {
|
||||
|
||||
expect(challengeCompleted).toBe(true);
|
||||
expect(response.status).toBe(200);
|
||||
expect(count).toHaveBeenCalledWith('coderoad.challenge_completed', 1, {
|
||||
attributes: { result: 'completed' }
|
||||
});
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('Should complete project with code 200', async () => {
|
||||
@@ -389,6 +448,13 @@ describe('challengeRoutes', () => {
|
||||
|
||||
const token = (tokenResponse.body as { userToken: string }).userToken;
|
||||
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPost('/coderoad-challenge-completed')
|
||||
.set('coderoad-user-token', token)
|
||||
.send({
|
||||
@@ -409,6 +475,10 @@ describe('challengeRoutes', () => {
|
||||
});
|
||||
expect(projectCompleted).toBe(true);
|
||||
expect(response.status).toBe(200);
|
||||
expect(count).toHaveBeenCalledWith('coderoad.challenge_completed', 1, {
|
||||
attributes: { result: 'partial' }
|
||||
});
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
// This has to be the last test since vi.mockRestore replaces the original
|
||||
@@ -416,6 +486,12 @@ describe('challengeRoutes', () => {
|
||||
// reason)
|
||||
test('Should return an error response if something goes wrong', async () => {
|
||||
const originalUserToken = fastifyTestInstance.prisma.userToken;
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
|
||||
vi.spyOn(
|
||||
fastifyTestInstance.prisma,
|
||||
@@ -441,6 +517,9 @@ describe('challengeRoutes', () => {
|
||||
type: 'error'
|
||||
});
|
||||
expect(response.status).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -565,6 +644,40 @@ describe('challengeRoutes', () => {
|
||||
expect(response_2.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
test('POST does not log the raw solution or githubLink on backEndProject validation failure', async () => {
|
||||
const spy = vi.spyOn(fastifyTestInstance.log, 'warn');
|
||||
spy.mockClear();
|
||||
|
||||
const leakySolution =
|
||||
'https://example.com/solution?api_key=super-secret';
|
||||
const leakyGithubLink = 'not-a-valid-url-with-token-abc123';
|
||||
|
||||
const response = await superPost('/project-completed').send({
|
||||
id: id1,
|
||||
challengeType: challengeTypes.backEndProject,
|
||||
solution: leakySolution,
|
||||
githubLink: leakyGithubLink
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
|
||||
const call = spy.mock.calls.find(
|
||||
([, msg]) => msg === 'Invalid backEndProject submission'
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
const [logObject] = call!;
|
||||
expect(JSON.stringify(logObject)).not.toContain(leakySolution);
|
||||
expect(JSON.stringify(logObject)).not.toContain(leakyGithubLink);
|
||||
expect(JSON.stringify(logObject)).not.toContain('super-secret');
|
||||
expect(JSON.stringify(logObject)).not.toContain('token-abc123');
|
||||
expect(logObject).toEqual({
|
||||
hasSolution: true,
|
||||
solutionLength: leakySolution.length,
|
||||
hasGithubLink: true,
|
||||
githubLinkLength: leakyGithubLink.length
|
||||
});
|
||||
});
|
||||
|
||||
test('POST rejects CodeRoad/CodeAlly projects when the user has not completed the required challenges', async () => {
|
||||
const response = await superPost('/project-completed').send({
|
||||
id: id1, // not a codeally challenge id, but does not matter
|
||||
@@ -611,6 +724,13 @@ describe('challengeRoutes', () => {
|
||||
|
||||
test('POST accepts CodeRoad/CodeAlly projects when the user has completed the required challenges', async () => {
|
||||
const now = Date.now();
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response =
|
||||
await superPost('/project-completed').send(codeallyProject);
|
||||
|
||||
@@ -641,6 +761,10 @@ describe('challengeRoutes', () => {
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(count).toHaveBeenCalledWith('challenge.completed', 1, {
|
||||
attributes: { result: 'completed' }
|
||||
});
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('POST accepts backend projects', async () => {
|
||||
@@ -785,6 +909,12 @@ describe('challengeRoutes', () => {
|
||||
|
||||
test('POST accepts backend challenges', async () => {
|
||||
const now = Date.now();
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPost('/backend-challenge-completed').send(
|
||||
backendChallengeBody1
|
||||
@@ -813,6 +943,10 @@ describe('challengeRoutes', () => {
|
||||
completedDate
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(count).toHaveBeenCalledWith('challenge.completed', 1, {
|
||||
attributes: { result: 'completed' }
|
||||
});
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('POST correctly handles multiple requests', async () => {
|
||||
@@ -928,6 +1062,12 @@ describe('challengeRoutes', () => {
|
||||
// HTML(0), JS(1), Modern(6), Video(11), The Odin Project(15)
|
||||
test('POST accepts challenges without files present', async () => {
|
||||
const now = Date.now();
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPost('/modern-challenge-completed').send(
|
||||
HtmlChallengeBody
|
||||
@@ -957,6 +1097,10 @@ describe('challengeRoutes', () => {
|
||||
completedDate,
|
||||
savedChallenges: []
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('challenge.completed', 1, {
|
||||
attributes: { result: 'completed' }
|
||||
});
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
// JS Project(5), Multi-file Cert Project(14)
|
||||
@@ -1590,6 +1734,13 @@ describe('challengeRoutes', () => {
|
||||
});
|
||||
|
||||
test('rejects requests for challenges that cannot be saved', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPost('/save-challenge').send({
|
||||
id: '66ebd4ae2812430bb883c786',
|
||||
files: multiFiles
|
||||
@@ -1603,9 +1754,20 @@ describe('challengeRoutes', () => {
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.text).toEqual('That challenge type is not saveable.');
|
||||
expect(savedChallenges).toHaveLength(0);
|
||||
expect(count).toHaveBeenCalledWith('challenge.saved', 1, {
|
||||
attributes: { result: 'not_saveable' }
|
||||
});
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('update the user savedchallenges and return them', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPost('/save-challenge').send({
|
||||
id: multiFileCertProjectId,
|
||||
files: updatedMultiFiles
|
||||
@@ -1636,6 +1798,10 @@ describe('challengeRoutes', () => {
|
||||
]
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(count).toHaveBeenCalledWith('challenge.saved', 1, {
|
||||
attributes: { result: 'saved' }
|
||||
});
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1716,12 +1882,22 @@ describe('challengeRoutes', () => {
|
||||
});
|
||||
|
||||
test('GET rejects requests with non-existent id param', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
|
||||
const response = await superGet('/exam/123412341234123412341234');
|
||||
|
||||
expect(response.body).toStrictEqual({
|
||||
error: 'An error occurred trying to get the exam from the database.'
|
||||
});
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('GET rejects requests where camper has not completed prerequisites', async () => {
|
||||
@@ -1771,6 +1947,33 @@ describe('challengeRoutes', () => {
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
test('GET captures unexpected errors when the generated exam fails validation', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
|
||||
mockGenerateRandomExam.mockReturnValueOnce(
|
||||
Array.from({ length: 3 }, (_, i) => ({
|
||||
id: 'abcdefghij',
|
||||
question: `Malformed question ${i}`,
|
||||
answers: [{ id: 'abcdefghij', answer: 'Only one answer' }]
|
||||
}))
|
||||
);
|
||||
|
||||
const response = await superGet('/exam/647e22d18acb466c97ccbef8');
|
||||
|
||||
expect(response.body).toStrictEqual({
|
||||
error: 'An error occurred trying to randomize the exam.'
|
||||
});
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
describe('/ms-trophy-challenge-completed', () => {
|
||||
const msUserId = 'abc123';
|
||||
@@ -1844,15 +2047,38 @@ describe('challengeRoutes', () => {
|
||||
});
|
||||
|
||||
test('POST rejects requests if the user does not have a Microsoft username', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superPost('/ms-trophy-challenge-completed').send({
|
||||
id: trophyChallengeId
|
||||
});
|
||||
|
||||
expect(res.body).toStrictEqual(userHasNotLinkedTheirAccount);
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'ms_trophy.verify_completed',
|
||||
1,
|
||||
{
|
||||
attributes: { result: 'no_ms_username' }
|
||||
}
|
||||
);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test("POST rejects requests if Microsoft's api responds with an error", async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const msUsername = 'ANRandom';
|
||||
await createMSUsernameRecord(msUsername);
|
||||
// This can be any error that the route can serialize. Other than
|
||||
@@ -1875,9 +2101,27 @@ describe('challengeRoutes', () => {
|
||||
|
||||
expect(res.body).toStrictEqual(verifyError);
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'ms_trophy.verify_completed',
|
||||
1,
|
||||
{
|
||||
attributes: { result: 'verify_failed' }
|
||||
}
|
||||
);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('POST handles unexpected errors', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
const distribution = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException,
|
||||
metrics: { ...originalSentry.metrics, distribution }
|
||||
};
|
||||
|
||||
mockVerifyTrophyWithMicrosoft.mockImplementationOnce(() => {
|
||||
throw new Error('Network error');
|
||||
});
|
||||
@@ -1890,9 +2134,25 @@ describe('challengeRoutes', () => {
|
||||
|
||||
expect(res.body).toStrictEqual(unexpectedError);
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
expect(distribution).toHaveBeenCalledWith(
|
||||
'ms_trophy.verify_latency_ms',
|
||||
expect.any(Number),
|
||||
{ unit: 'millisecond', attributes: { result: 'failure' } }
|
||||
);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('POST updates the user record with a new completed challenge', async () => {
|
||||
const count = vi.fn();
|
||||
const distribution = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count, distribution }
|
||||
};
|
||||
|
||||
mockVerifyTrophyWithMicrosoft.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
type: 'success',
|
||||
@@ -1932,6 +2192,20 @@ describe('challengeRoutes', () => {
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'ms_trophy.verify_completed',
|
||||
1,
|
||||
{
|
||||
attributes: { result: 'verified' }
|
||||
}
|
||||
);
|
||||
expect(distribution).toHaveBeenCalledWith(
|
||||
'ms_trophy.verify_latency_ms',
|
||||
expect.any(Number),
|
||||
{ unit: 'millisecond', attributes: { result: 'success' } }
|
||||
);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('POST correctly handles multiple requests', async () => {
|
||||
@@ -2182,6 +2456,13 @@ describe('challengeRoutes', () => {
|
||||
});
|
||||
|
||||
test('POST rejects requests with invalid userCompletedExam values', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
|
||||
await fastifyTestInstance.prisma.user.updateMany({
|
||||
where: { email: 'foo@bar.com' },
|
||||
data: {
|
||||
@@ -2214,6 +2495,57 @@ describe('challengeRoutes', () => {
|
||||
error: `An error occurred trying to submit your exam.`
|
||||
});
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('POST captures an exception when the exam from the database fails schema validation', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
|
||||
const examSpy = vi
|
||||
.spyOn(fastifyTestInstance.prisma.exam, 'findUnique')
|
||||
.mockResolvedValueOnce({
|
||||
...examJson,
|
||||
numberOfQuestionsInExam: 999
|
||||
} as never);
|
||||
|
||||
const response = await superRequest('/exam-challenge-completed', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}).send({
|
||||
id: examChallengeId,
|
||||
challengeType: 17,
|
||||
userCompletedExam: {
|
||||
examTimeInSeconds: 111,
|
||||
userExamQuestions: [
|
||||
{
|
||||
id: 'q-id',
|
||||
question: '?',
|
||||
answer: {
|
||||
id: 'a-id',
|
||||
answer: 'a'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
examSpy.mockRestore();
|
||||
|
||||
expect(response.body).toStrictEqual({
|
||||
error:
|
||||
'An error occurred validating the exam information from the database.'
|
||||
});
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2251,6 +2583,12 @@ describe('challengeRoutes', () => {
|
||||
|
||||
test('POST handles submitting a failing exam', async () => {
|
||||
const now = Date.now();
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
// Submit exam with 0 correct answers
|
||||
const response = await submitExam(examWithZeroCorrect);
|
||||
@@ -2285,6 +2623,10 @@ describe('challengeRoutes', () => {
|
||||
examResults: mockResultsZeroCorrect
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(count).toHaveBeenCalledWith('curriculum_exam.completed', 1, {
|
||||
attributes: { result: 'failed' }
|
||||
});
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test("POST always adds to the user's completedExams", async () => {
|
||||
@@ -2326,6 +2668,13 @@ describe('challengeRoutes', () => {
|
||||
test('POST updates user progress if they have not completed the exam before', async () => {
|
||||
// Submit exam with 2/3 correct answers
|
||||
const now = Date.now();
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await submitExam(examWithTwoCorrect);
|
||||
|
||||
const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
|
||||
@@ -2351,6 +2700,10 @@ describe('challengeRoutes', () => {
|
||||
examResults: mockResultsTwoCorrect
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(count).toHaveBeenCalledWith('curriculum_exam.completed', 1, {
|
||||
attributes: { result: 'completed' }
|
||||
});
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('POST does not update user progress if new exam is not an improvement', async () => {
|
||||
@@ -2361,6 +2714,13 @@ describe('challengeRoutes', () => {
|
||||
where: { id: defaultUserId }
|
||||
});
|
||||
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
// Submit exam with 2/3 correct answers (no improvement)
|
||||
const res2 = await submitExam(examWithTwoCorrect);
|
||||
|
||||
@@ -2378,6 +2738,10 @@ describe('challengeRoutes', () => {
|
||||
examResults: mockResultsTwoCorrect
|
||||
});
|
||||
expect(res2.statusCode).toBe(200);
|
||||
expect(count).toHaveBeenCalledWith('curriculum_exam.completed', 1, {
|
||||
attributes: { result: 'already_completed' }
|
||||
});
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('POST updates user progress if exam is an improvement', async () => {
|
||||
@@ -2485,6 +2849,13 @@ describe('challengeRoutes', () => {
|
||||
});
|
||||
|
||||
test('POST adds new attempt to quizAttempts', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPost('/submit-quiz-attempt').send({
|
||||
challengeId: '66df3b712c41c499e9d31e5b',
|
||||
quizId: '0'
|
||||
@@ -2506,6 +2877,10 @@ describe('challengeRoutes', () => {
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toStrictEqual({});
|
||||
expect(count).toHaveBeenCalledWith('quiz.attempt_submitted', 1, {
|
||||
attributes: { result: 'created' }
|
||||
});
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('POST updates the timestamp of the existing attempt', async () => {
|
||||
@@ -2527,6 +2902,13 @@ describe('challengeRoutes', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPost('/submit-quiz-attempt').send({
|
||||
challengeId: '66df3b712c41c499e9d31e5b',
|
||||
quizId: '1'
|
||||
@@ -2553,6 +2935,10 @@ describe('challengeRoutes', () => {
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toStrictEqual({});
|
||||
expect(count).toHaveBeenCalledWith('quiz.attempt_submitted', 1, {
|
||||
attributes: { result: 'updated' }
|
||||
});
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { CompletedExam, ExamResults, SavedChallengeFile } from '@prisma/client';
|
||||
@@ -78,9 +80,11 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
{
|
||||
schema: schemas.projectCompleted,
|
||||
errorHandler(error, req, reply) {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
if (error.validation) {
|
||||
logger.warn({ validationError: error.validation });
|
||||
req.log.warn(
|
||||
{ validationError: error.validation },
|
||||
'Project submission validation failed'
|
||||
);
|
||||
void reply.code(400);
|
||||
return formatProjectCompletedValidation(error.validation);
|
||||
} else {
|
||||
@@ -89,15 +93,14 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req: req });
|
||||
logger.info(`User ${req.user?.id} submitted a project`);
|
||||
req.log.info('User submitted a project');
|
||||
// TODO: considering validation is determined by `challengeType`, it should not come from the client
|
||||
// Determine `challengeType` by `id`
|
||||
const { id: projectId, challengeType, solution, githubLink } = req.body;
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (isExamId(req.body.id)) {
|
||||
logger.warn('User attempted to submit an exam');
|
||||
req.log.warn('User attempted to submit an exam');
|
||||
void reply.code(403);
|
||||
return reply.send({
|
||||
type: 'error',
|
||||
@@ -110,8 +113,13 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
// - `githubLink` needs to exist and be valid URL
|
||||
if (challengeType === challengeTypes.backEndProject) {
|
||||
if (!solution || !validator.default.isURL(githubLink + '')) {
|
||||
logger.warn(
|
||||
{ solution, githubLink },
|
||||
req.log.warn(
|
||||
{
|
||||
hasSolution: !!solution,
|
||||
solutionLength: solution.length,
|
||||
hasGithubLink: !!githubLink,
|
||||
githubLinkLength: githubLink?.length
|
||||
},
|
||||
'Invalid backEndProject submission'
|
||||
);
|
||||
return void reply.code(403).send({
|
||||
@@ -120,7 +128,10 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
});
|
||||
}
|
||||
} else if (solution && !validator.default.isURL(solution + '')) {
|
||||
logger.warn({ solution }, 'Invalid solution URL');
|
||||
req.log.warn(
|
||||
{ hasSolution: !!solution, solutionLength: solution.length },
|
||||
'Invalid solution URL'
|
||||
);
|
||||
return void reply.code(403).send({
|
||||
type: 'error',
|
||||
message: 'That does not appear to be a valid challenge submission.'
|
||||
@@ -136,8 +147,8 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
challengeType === challengeTypes.codeAllyCert &&
|
||||
!canSubmitCodeRoadCertProject(projectId, user)
|
||||
) {
|
||||
logger.warn(
|
||||
{ projectId, user },
|
||||
req.log.warn(
|
||||
{ projectId },
|
||||
'User tried to submit a codeRoad cert project before completing the required challenges'
|
||||
);
|
||||
void reply.code(403);
|
||||
@@ -164,6 +175,12 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
challenge
|
||||
);
|
||||
|
||||
fastify.Sentry?.metrics?.count('challenge.completed', 1, {
|
||||
attributes: {
|
||||
result: alreadyCompleted ? 'already_completed' : 'completed'
|
||||
}
|
||||
});
|
||||
|
||||
reply.send({
|
||||
alreadyCompleted,
|
||||
// TODO(Post-MVP): audit the client and remove this if the client does
|
||||
@@ -179,9 +196,11 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
{
|
||||
schema: schemas.backendChallengeCompleted,
|
||||
errorHandler(error, request, reply) {
|
||||
const logger = fastify.log.child({ req: request });
|
||||
if (error.validation) {
|
||||
logger.warn({ validationError: error.validation });
|
||||
request.log.warn(
|
||||
{ validationError: error.validation },
|
||||
'Backend challenge submission validation failed'
|
||||
);
|
||||
void reply.code(400);
|
||||
return formatProjectCompletedValidation(error.validation);
|
||||
} else {
|
||||
@@ -190,14 +209,10 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
logger.info(
|
||||
{ userId: req.user?.id },
|
||||
`User submitted a backend challenge`
|
||||
);
|
||||
req.log.info('User submitted a backend challenge');
|
||||
|
||||
if (isExamId(req.body.id)) {
|
||||
logger.warn('User attempted to submit an exam');
|
||||
req.log.warn('User attempted to submit an exam');
|
||||
void reply.code(403);
|
||||
return reply.send({
|
||||
type: 'error',
|
||||
@@ -227,6 +242,12 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
completedChallenge
|
||||
);
|
||||
|
||||
fastify.Sentry?.metrics?.count('challenge.completed', 1, {
|
||||
attributes: {
|
||||
result: alreadyCompleted ? 'already_completed' : 'completed'
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
alreadyCompleted,
|
||||
points: alreadyCompleted ? points : points + 1,
|
||||
@@ -241,10 +262,12 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
schema: schemas.modernChallengeCompleted,
|
||||
errorHandler(error, req, reply) {
|
||||
if (error.validation) {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
// This is another highly used route, so debug log level is used to
|
||||
// avoid excessive logging
|
||||
logger.debug({ validationError: error.validation });
|
||||
req.log.debug(
|
||||
{ validationError: error.validation },
|
||||
'Request validation failed'
|
||||
);
|
||||
void reply.code(400);
|
||||
return formatProjectCompletedValidation(error.validation);
|
||||
} else {
|
||||
@@ -253,18 +276,14 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
// This is another highly used route, so debug log level is used to
|
||||
// avoid excessive logging
|
||||
logger.debug(
|
||||
{ userId: req.user?.id },
|
||||
'User submitted a modern challenge'
|
||||
);
|
||||
req.log.debug('User submitted a modern challenge');
|
||||
|
||||
const { id, files, challengeType } = req.body;
|
||||
|
||||
if (isExamId(id)) {
|
||||
logger.warn('User attempted to submit an exam');
|
||||
req.log.warn('User attempted to submit an exam');
|
||||
void reply.code(403);
|
||||
return reply.send({
|
||||
type: 'error',
|
||||
@@ -287,10 +306,12 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
schema: schemas.modernChallengeCompleted,
|
||||
errorHandler(error, req, reply) {
|
||||
if (error.validation) {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
// This is another highly used route, so debug log level is used to
|
||||
// avoid excessive logging
|
||||
logger.debug({ validationError: error.validation });
|
||||
req.log.debug(
|
||||
{ validationError: error.validation },
|
||||
'Request validation failed'
|
||||
);
|
||||
void reply.code(400);
|
||||
return formatProjectCompletedValidation(error.validation);
|
||||
} else {
|
||||
@@ -299,18 +320,14 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
// This is another highly used route, so debug log level is used to
|
||||
// avoid excessive logging
|
||||
logger.debug(
|
||||
{ userId: req.user?.id },
|
||||
'User submitted a modern challenge'
|
||||
);
|
||||
req.log.debug('User submitted a modern challenge');
|
||||
|
||||
const { id, files: encodedFiles, challengeType } = req.body;
|
||||
|
||||
if (isExamId(id)) {
|
||||
logger.warn('User attempted to submit an exam');
|
||||
req.log.warn('User attempted to submit an exam');
|
||||
void reply.code(403);
|
||||
return reply.send({
|
||||
type: 'error',
|
||||
@@ -333,9 +350,11 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
{
|
||||
schema: schemas.dailyCodingChallengeCompleted,
|
||||
errorHandler(error, req, reply) {
|
||||
const logger = fastify.log.child({ req });
|
||||
if (error.validation) {
|
||||
logger.warn({ validationError: error.validation });
|
||||
req.log.warn(
|
||||
{ validationError: error.validation },
|
||||
'Request validation failed'
|
||||
);
|
||||
void reply.code(400);
|
||||
void reply.send({
|
||||
type: 'error',
|
||||
@@ -354,9 +373,11 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
{
|
||||
schema: schemas.saveChallenge,
|
||||
errorHandler(error, req, reply) {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
if (error.validation) {
|
||||
logger.warn({ validationError: error.validation });
|
||||
req.log.warn(
|
||||
{ validationError: error.validation },
|
||||
'Request validation failed'
|
||||
);
|
||||
void reply.code(400);
|
||||
return formatProjectCompletedValidation(error.validation);
|
||||
} else {
|
||||
@@ -365,14 +386,13 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
logger.info({ userId: req.user?.id }, 'User saved a challenge');
|
||||
req.log.debug('User saved a challenge');
|
||||
|
||||
const { files, id: challengeId } = req.body;
|
||||
await postSaveChallenge(
|
||||
fastify,
|
||||
{ challengeId, files, userId: req.user!.id },
|
||||
logger,
|
||||
req.log,
|
||||
reply
|
||||
);
|
||||
}
|
||||
@@ -383,9 +403,11 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
{
|
||||
schema: schemas.saveChallenge,
|
||||
errorHandler(error, req, reply) {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
if (error.validation) {
|
||||
logger.warn({ validationError: error.validation });
|
||||
req.log.warn(
|
||||
{ validationError: error.validation },
|
||||
'Request validation failed'
|
||||
);
|
||||
void reply.code(400);
|
||||
return formatProjectCompletedValidation(error.validation);
|
||||
} else {
|
||||
@@ -394,15 +416,14 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
logger.info({ userId: req.user?.id }, 'User saved a challenge');
|
||||
req.log.debug('User saved a challenge');
|
||||
|
||||
const { files: encodedFiles, id: challengeId } = req.body;
|
||||
const files = decodeFiles(encodedFiles);
|
||||
await postSaveChallenge(
|
||||
fastify,
|
||||
{ challengeId, files, userId: req.user!.id },
|
||||
logger,
|
||||
req.log,
|
||||
reply
|
||||
);
|
||||
}
|
||||
@@ -413,9 +434,11 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
{
|
||||
schema: schemas.exam,
|
||||
errorHandler(error, req, reply) {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
if (error.validation) {
|
||||
logger.warn({ validationError: error.validation });
|
||||
req.log.warn(
|
||||
{ validationError: error.validation },
|
||||
'Request validation failed'
|
||||
);
|
||||
void reply.code(400);
|
||||
return { error: `Valid 'id' not found in request parameters.` };
|
||||
} else {
|
||||
@@ -424,11 +447,7 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
logger.info(
|
||||
{ userId: req.user?.id, examId: req.params.id },
|
||||
'User requested an exam'
|
||||
);
|
||||
req.log.info({ examId: req.params.id }, 'User requested an exam');
|
||||
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -443,10 +462,13 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
});
|
||||
|
||||
if (!examFromDb) {
|
||||
logger.warn(
|
||||
req.log.warn(
|
||||
{ examId: id },
|
||||
'User requested an exam that does not exist'
|
||||
);
|
||||
fastify.Sentry?.captureException(
|
||||
new Error(`Exam ${id} not found in database`)
|
||||
);
|
||||
void reply.code(500);
|
||||
return {
|
||||
error: 'An error occurred trying to get the exam from the database.'
|
||||
@@ -456,10 +478,13 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
const validExamFromDbSchema = validateExamFromDbSchema(examFromDb);
|
||||
|
||||
if ('error' in validExamFromDbSchema) {
|
||||
logger.warn(
|
||||
req.log.error(
|
||||
{ examId: id, validationError: validExamFromDbSchema.error },
|
||||
'Error validating exam from database'
|
||||
);
|
||||
fastify.Sentry?.captureException(
|
||||
new Error(`Exam ${id} failed database schema validation`)
|
||||
);
|
||||
void reply.code(500);
|
||||
return {
|
||||
error:
|
||||
@@ -476,7 +501,7 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
);
|
||||
|
||||
if (completedPrerequisites.length !== prerequisiteIds.length) {
|
||||
logger.warn(
|
||||
req.log.warn(
|
||||
{ examId: id, prerequisites, completedPrerequisites },
|
||||
'User has not completed all prerequisites for exam'
|
||||
);
|
||||
@@ -493,11 +518,11 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
);
|
||||
|
||||
if (validGeneratedExamSchema.error) {
|
||||
logger.error(
|
||||
req.log.error(
|
||||
validGeneratedExamSchema.error,
|
||||
'Error validating generated exam'
|
||||
);
|
||||
fastify.Sentry.captureException(validGeneratedExamSchema.error);
|
||||
fastify.Sentry?.captureException(validGeneratedExamSchema.error);
|
||||
void reply.code(500);
|
||||
return { error: 'An error occurred trying to randomize the exam.' };
|
||||
}
|
||||
@@ -513,9 +538,11 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
{
|
||||
schema: schemas.msTrophyChallengeCompleted,
|
||||
errorHandler(error, req, reply) {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
if (error.validation) {
|
||||
logger.warn({ validationError: error.validation });
|
||||
req.log.warn(
|
||||
{ validationError: error.validation },
|
||||
'Request validation failed'
|
||||
);
|
||||
void reply.code(400);
|
||||
void reply.send({ type: 'error', message: 'flash.ms.trophy.err-2' });
|
||||
} else {
|
||||
@@ -524,11 +551,7 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
logger.info(
|
||||
{ userId: req.user?.id },
|
||||
'User submitted a Microsoft trophy challenge'
|
||||
);
|
||||
req.log.info('User submitted a Microsoft trophy challenge');
|
||||
try {
|
||||
const challengeId = req.body.id;
|
||||
const challenge = msTrophyChallenges.find(
|
||||
@@ -536,7 +559,7 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
);
|
||||
|
||||
if (!challenge) {
|
||||
logger.warn(
|
||||
req.log.warn(
|
||||
{ challengeId },
|
||||
'User tried to submit a Microsoft trophy challenge that does not exist'
|
||||
);
|
||||
@@ -550,10 +573,13 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
});
|
||||
|
||||
if (!msUser || !msUser.msUsername) {
|
||||
logger.warn(
|
||||
req.log.warn(
|
||||
{ hasMsUser: !!msUser },
|
||||
'User tried to submit a Microsoft trophy challenge without a Microsoft username'
|
||||
);
|
||||
fastify.Sentry?.metrics?.count('ms_trophy.verify_completed', 1, {
|
||||
attributes: { result: 'no_ms_username' }
|
||||
});
|
||||
return reply
|
||||
.code(403)
|
||||
.send({ type: 'error', message: 'flash.ms.trophy.err-1' });
|
||||
@@ -564,13 +590,32 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
// TODO: log error if msTrophyId not found?
|
||||
const msTrophyId = challenge.msTrophyId ?? '';
|
||||
|
||||
const msTrophyStatus = await verifyTrophyWithMicrosoft({
|
||||
msUsername,
|
||||
msTrophyId
|
||||
});
|
||||
const verifyTrophyStart = performance.now();
|
||||
let msTrophyStatus;
|
||||
try {
|
||||
msTrophyStatus = await verifyTrophyWithMicrosoft({
|
||||
msUsername,
|
||||
msTrophyId
|
||||
});
|
||||
fastify.Sentry?.metrics?.distribution(
|
||||
'ms_trophy.verify_latency_ms',
|
||||
performance.now() - verifyTrophyStart,
|
||||
{ unit: 'millisecond', attributes: { result: 'success' } }
|
||||
);
|
||||
} catch (verifyError) {
|
||||
fastify.Sentry?.metrics?.distribution(
|
||||
'ms_trophy.verify_latency_ms',
|
||||
performance.now() - verifyTrophyStart,
|
||||
{ unit: 'millisecond', attributes: { result: 'failure' } }
|
||||
);
|
||||
throw verifyError;
|
||||
}
|
||||
|
||||
if (msTrophyStatus.type === 'error') {
|
||||
logger.warn('Error verifying trophy with Microsoft');
|
||||
req.log.warn('Error verifying trophy with Microsoft');
|
||||
fastify.Sentry?.metrics?.count('ms_trophy.verify_completed', 1, {
|
||||
attributes: { result: 'verify_failed' }
|
||||
});
|
||||
return reply.code(403).send(msTrophyStatus);
|
||||
}
|
||||
|
||||
@@ -596,14 +641,20 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
completedChallenge
|
||||
);
|
||||
|
||||
fastify.Sentry?.metrics?.count('ms_trophy.verify_completed', 1, {
|
||||
attributes: {
|
||||
result: alreadyCompleted ? 'already_claimed' : 'verified'
|
||||
}
|
||||
});
|
||||
|
||||
reply.send({
|
||||
alreadyCompleted,
|
||||
points: getPoints(progressTimestamps) + (alreadyCompleted ? 0 : 1),
|
||||
completedDate: normalizeDate(completedDate)
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error, 'Error submitting Microsoft trophy challenge');
|
||||
fastify.Sentry.captureException(error);
|
||||
fastify.Sentry?.captureException(error);
|
||||
req.log.error(error, 'Error submitting Microsoft trophy challenge');
|
||||
void reply.code(500);
|
||||
return {
|
||||
type: 'error',
|
||||
@@ -618,9 +669,11 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
{
|
||||
schema: schemas.examChallengeCompleted,
|
||||
errorHandler(error, req, reply) {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
if (error.validation) {
|
||||
logger.warn({ validationError: error.validation });
|
||||
req.log.warn(
|
||||
{ validationError: error.validation },
|
||||
'Request validation failed'
|
||||
);
|
||||
void reply.code(400);
|
||||
void reply.send({
|
||||
error: 'Valid request body not found in attempt to submit exam.'
|
||||
@@ -631,16 +684,14 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
|
||||
logger.info({ userId: req.user?.id }, 'User submitted an exam challenge');
|
||||
req.log.info('User submitted an exam challenge');
|
||||
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
const { userCompletedExam, id, challengeType } = req.body;
|
||||
|
||||
if (isExamId(id)) {
|
||||
logger.warn('User attempted to submit an exam');
|
||||
req.log.warn('User attempted to submit an exam');
|
||||
void reply.code(403);
|
||||
return reply.send({
|
||||
error: 'Exam submissions are not allowed on this endpoint.'
|
||||
@@ -662,7 +713,7 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
});
|
||||
|
||||
if (!examFromDb) {
|
||||
logger.warn(
|
||||
req.log.warn(
|
||||
{ examId: id },
|
||||
'User tried to submit an exam that does not exist'
|
||||
);
|
||||
@@ -674,10 +725,13 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
|
||||
const validExamFromDbSchema = validateExamFromDbSchema(examFromDb);
|
||||
if ('error' in validExamFromDbSchema) {
|
||||
logger.warn(
|
||||
req.log.error(
|
||||
{ examId: id, validationError: validExamFromDbSchema.error },
|
||||
'Error validating exam from database'
|
||||
);
|
||||
fastify.Sentry?.captureException(
|
||||
new Error(`Exam ${id} failed database schema validation`)
|
||||
);
|
||||
void reply.code(500);
|
||||
return {
|
||||
error:
|
||||
@@ -693,7 +747,7 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
);
|
||||
|
||||
if (completedPrerequisites.length !== prerequisiteIds.length) {
|
||||
logger.warn(
|
||||
req.log.warn(
|
||||
{ examId: id, prerequisites, completedPrerequisites },
|
||||
'User has not completed all prerequisites for exam'
|
||||
);
|
||||
@@ -708,7 +762,10 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
numberOfQuestionsInExam
|
||||
);
|
||||
if ('error' in validUserCompletedExam) {
|
||||
logger.error(validUserCompletedExam.error);
|
||||
req.log.warn(
|
||||
{ validationError: validUserCompletedExam.error },
|
||||
'Error validating submitted exam'
|
||||
);
|
||||
void reply.code(400);
|
||||
return {
|
||||
error: 'An error occurred validating the submitted exam.'
|
||||
@@ -719,7 +776,11 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
|
||||
const validExamResults = validateExamResultsSchema(examResults);
|
||||
if ('error' in validExamResults) {
|
||||
logger.error(validExamResults.error);
|
||||
req.log.error(
|
||||
validExamResults.error,
|
||||
'Error validating generated exam results'
|
||||
);
|
||||
fastify.Sentry?.captureException(validExamResults.error);
|
||||
void reply.code(500);
|
||||
return {
|
||||
error: 'An error occurred validating the submitted exam.'
|
||||
@@ -826,6 +887,16 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
|
||||
const points = getPoints(newProgressTimeStamps);
|
||||
|
||||
fastify.Sentry?.metrics?.count('curriculum_exam.completed', 1, {
|
||||
attributes: {
|
||||
result: !examResults.passed
|
||||
? 'failed'
|
||||
: alreadyCompleted
|
||||
? 'already_completed'
|
||||
: 'completed'
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
alreadyCompleted,
|
||||
points: addPoint ? points + 1 : points,
|
||||
@@ -833,8 +904,8 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
examResults
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(error, 'Error submitting exam challenge');
|
||||
fastify.Sentry.captureException(error);
|
||||
fastify.Sentry?.captureException(error);
|
||||
req.log.error(error, 'Error submitting exam challenge');
|
||||
void reply.code(500);
|
||||
return {
|
||||
error: 'An error occurred trying to submit your exam.'
|
||||
@@ -848,9 +919,11 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
{
|
||||
schema: schemas.submitQuizAttempt,
|
||||
errorHandler(error, req, reply) {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
if (error.validation) {
|
||||
logger.warn({ validationError: error.validation });
|
||||
req.log.warn(
|
||||
{ validationError: error.validation },
|
||||
'Request validation failed'
|
||||
);
|
||||
void reply.code(400);
|
||||
void reply.send({
|
||||
type: 'error',
|
||||
@@ -892,6 +965,10 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
});
|
||||
|
||||
fastify.Sentry?.metrics?.count('quiz.attempt_submitted', 1, {
|
||||
attributes: { result: existingAttempt ? 'updated' : 'created' }
|
||||
});
|
||||
|
||||
return {};
|
||||
}
|
||||
);
|
||||
@@ -916,9 +993,11 @@ export const challengeTokenRoutes: FastifyPluginCallbackTypebox = (
|
||||
{
|
||||
schema: schemas.coderoadChallengeCompleted,
|
||||
errorHandler(error, req, reply) {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
if (error.validation) {
|
||||
logger.warn({ validationError: error.validation });
|
||||
req.log.warn(
|
||||
{ validationError: error.validation },
|
||||
'Request validation failed'
|
||||
);
|
||||
void reply.code(400);
|
||||
return formatCoderoadChallengeCompletedValidation(error.validation);
|
||||
} else {
|
||||
@@ -937,8 +1016,7 @@ async function postCoderoadChallengeCompleted(
|
||||
req: UpdateReqType<typeof schemas.coderoadChallengeCompleted>,
|
||||
reply: UpdateReplyType<typeof schemas.coderoadChallengeCompleted>
|
||||
) {
|
||||
const logger = this.log.child({ req, res: reply });
|
||||
logger.info({ userId: req.user?.id }, 'User submitted a coderoad challenge');
|
||||
req.log.info('User submitted a coderoad challenge');
|
||||
|
||||
const { 'coderoad-user-token': encodedUserToken } = req.headers;
|
||||
const { tutorialId } = req.body;
|
||||
@@ -949,8 +1027,11 @@ async function postCoderoadChallengeCompleted(
|
||||
userToken = payload.userToken;
|
||||
if (!userToken || typeof userToken !== 'string') throw Error();
|
||||
} catch {
|
||||
logger.warn('Invalid user token');
|
||||
req.log.warn('Invalid user token');
|
||||
void reply.code(400);
|
||||
this.Sentry?.metrics?.count('coderoad.request_rejected', 1, {
|
||||
attributes: { reason: 'invalid_token' }
|
||||
});
|
||||
return reply.send({ type: 'error', msg: `invalid user token` });
|
||||
}
|
||||
|
||||
@@ -958,11 +1039,14 @@ async function postCoderoadChallengeCompleted(
|
||||
const tutorialOrg = tutorialRepo?.split('/')?.[0];
|
||||
|
||||
if (tutorialOrg !== 'freeCodeCamp') {
|
||||
logger.warn(
|
||||
req.log.warn(
|
||||
{ tutorialId },
|
||||
'Tutorial not hosted on freeCodeCamp GitHub account'
|
||||
);
|
||||
void reply.code(400);
|
||||
this.Sentry?.metrics?.count('coderoad.request_rejected', 1, {
|
||||
attributes: { reason: 'untrusted_org' }
|
||||
});
|
||||
return reply.send({
|
||||
type: 'error',
|
||||
msg: `Tutorial not hosted on freeCodeCamp GitHub account`
|
||||
@@ -980,8 +1064,11 @@ async function postCoderoadChallengeCompleted(
|
||||
});
|
||||
|
||||
if (!challenge) {
|
||||
logger.warn({ tutorialRepo }, 'Tutorial repo is not valid');
|
||||
req.log.warn({ tutorialRepo }, 'Tutorial repo is not valid');
|
||||
void reply.code(400);
|
||||
this.Sentry?.metrics?.count('coderoad.request_rejected', 1, {
|
||||
attributes: { reason: 'invalid_tutorial' }
|
||||
});
|
||||
return reply.send({ type: 'error', msg: 'Tutorial name is not valid' });
|
||||
}
|
||||
|
||||
@@ -992,8 +1079,11 @@ async function postCoderoadChallengeCompleted(
|
||||
});
|
||||
|
||||
if (!tokenInfo) {
|
||||
logger.warn('User token not found');
|
||||
req.log.warn('User token not found');
|
||||
void reply.code(400);
|
||||
this.Sentry?.metrics?.count('coderoad.request_rejected', 1, {
|
||||
attributes: { reason: 'token_not_found' }
|
||||
});
|
||||
return reply.send({ type: 'error', msg: 'User token not found' });
|
||||
}
|
||||
|
||||
@@ -1004,8 +1094,11 @@ async function postCoderoadChallengeCompleted(
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
logger.warn('User not found');
|
||||
req.log.warn('User not found');
|
||||
void reply.code(400);
|
||||
this.Sentry?.metrics?.count('coderoad.request_rejected', 1, {
|
||||
attributes: { reason: 'user_not_found' }
|
||||
});
|
||||
return {
|
||||
type: 'error',
|
||||
msg: 'User for user token not found'
|
||||
@@ -1035,16 +1128,23 @@ async function postCoderoadChallengeCompleted(
|
||||
)
|
||||
}
|
||||
});
|
||||
|
||||
this.Sentry?.metrics?.count('coderoad.challenge_completed', 1, {
|
||||
attributes: { result: 'partial' }
|
||||
});
|
||||
} else {
|
||||
await updateUserChallengeData(this, user, challengeId, {
|
||||
id: challengeId,
|
||||
completedDate
|
||||
});
|
||||
|
||||
this.Sentry?.metrics?.count('coderoad.challenge_completed', 1, {
|
||||
attributes: { result: 'completed' }
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// TODO(Post-MVP): don't catch, just let Sentry handle this.
|
||||
logger.error(error, 'Error submitting coderoad challenge');
|
||||
this.Sentry.captureException(error);
|
||||
this.Sentry?.captureException(error);
|
||||
req.log.error(error, 'Error submitting coderoad challenge');
|
||||
void reply.code(500);
|
||||
return reply.send({
|
||||
type: 'error',
|
||||
@@ -1062,13 +1162,12 @@ async function postDailyCodingChallengeCompleted(
|
||||
req: UpdateReqType<typeof schemas.dailyCodingChallengeCompleted>,
|
||||
reply: UpdateReplyType<typeof schemas.dailyCodingChallengeCompleted>
|
||||
) {
|
||||
const logger = this.log.child({ req });
|
||||
logger.info(`User ${req.user?.id} submitted a daily coding challenge`);
|
||||
req.log.info('User submitted a daily coding challenge');
|
||||
|
||||
const { id, language } = req.body;
|
||||
|
||||
if (isExamId(id)) {
|
||||
logger.warn('User attempted to submit an exam');
|
||||
req.log.warn('User attempted to submit an exam');
|
||||
void reply.code(403);
|
||||
return reply.send({
|
||||
type: 'error',
|
||||
@@ -1195,6 +1294,9 @@ async function postSaveChallenge(
|
||||
},
|
||||
'User tried to save a challenge that is not saveable'
|
||||
);
|
||||
fastify.Sentry?.metrics?.count('challenge.saved', 1, {
|
||||
attributes: { result: 'not_saveable' }
|
||||
});
|
||||
return void reply.code(400).send('That challenge type is not saveable.');
|
||||
}
|
||||
|
||||
@@ -1211,6 +1313,10 @@ async function postSaveChallenge(
|
||||
}
|
||||
});
|
||||
|
||||
fastify.Sentry?.metrics?.count('challenge.saved', 1, {
|
||||
attributes: { result: 'saved' }
|
||||
});
|
||||
|
||||
void reply.send({ savedChallenges: userSavedChallenges });
|
||||
}
|
||||
|
||||
@@ -1259,6 +1365,12 @@ async function postModernChallengeCompleted(
|
||||
const { alreadyCompleted, userSavedChallenges: savedChallenges } =
|
||||
await updateUserChallengeData(fastify, user, id, completedChallenge);
|
||||
|
||||
fastify.Sentry?.metrics?.count('challenge.completed', 1, {
|
||||
attributes: {
|
||||
result: alreadyCompleted ? 'already_completed' : 'completed'
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
alreadyCompleted,
|
||||
points: alreadyCompleted ? points : points + 1,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, test, expect, beforeEach, vi } from 'vitest';
|
||||
import Stripe from 'stripe';
|
||||
import {
|
||||
createSuperRequest,
|
||||
devLogin,
|
||||
@@ -124,8 +125,33 @@ const generateMockSubCreate = (status: string) => () =>
|
||||
const defaultError = () =>
|
||||
Promise.reject(new Error('Stripe encountered an error'));
|
||||
|
||||
const {
|
||||
StripeError,
|
||||
StripeCardError,
|
||||
StripeInvalidRequestError,
|
||||
StripeAuthenticationError
|
||||
} = vi.hoisted(() => {
|
||||
class StripeError extends Error {}
|
||||
class StripeCardError extends StripeError {}
|
||||
class StripeInvalidRequestError extends StripeError {}
|
||||
class StripeAuthenticationError extends StripeError {}
|
||||
return {
|
||||
StripeError,
|
||||
StripeCardError,
|
||||
StripeInvalidRequestError,
|
||||
StripeAuthenticationError
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('stripe', () => ({
|
||||
default: class {
|
||||
static errors = {
|
||||
StripeError,
|
||||
StripeCardError,
|
||||
StripeInvalidRequestError,
|
||||
StripeAuthenticationError
|
||||
};
|
||||
|
||||
constructor() {}
|
||||
|
||||
customers = {
|
||||
@@ -216,6 +242,13 @@ describe('Donate', () => {
|
||||
});
|
||||
|
||||
test('should return 402 with client_secret if subscription status requires source action', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
mockSubCreate.mockImplementationOnce(
|
||||
generateMockSubCreate('requires_source_action')
|
||||
);
|
||||
@@ -231,6 +264,9 @@ describe('Donate', () => {
|
||||
}
|
||||
});
|
||||
expect(response.status).toBe(402);
|
||||
expect(count).toHaveBeenCalledWith('donation.action_required', 1);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('should return 402 if subscription status requires source', async () => {
|
||||
@@ -291,6 +327,13 @@ describe('Donate', () => {
|
||||
});
|
||||
|
||||
test('should return 500 if Stripe encountes an error', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
|
||||
mockSubCreate.mockImplementationOnce(defaultError);
|
||||
const response = await superPost('/donate/charge-stripe-card').send(
|
||||
chargeStripeCardReqBody
|
||||
@@ -300,6 +343,79 @@ describe('Donate', () => {
|
||||
expect(response.body).toEqual({
|
||||
error: 'Donation failed due to a server error.'
|
||||
});
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('should not capture Stripe card decline errors', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
|
||||
const CardError = Stripe.errors.StripeCardError as unknown as new (
|
||||
m?: string
|
||||
) => Error;
|
||||
mockSubCreate.mockImplementationOnce(() =>
|
||||
Promise.reject(new CardError('card_declined'))
|
||||
);
|
||||
const response = await superPost('/donate/charge-stripe-card').send(
|
||||
chargeStripeCardReqBody
|
||||
);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('should not capture Stripe invalid request errors', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
|
||||
const InvalidRequestError = Stripe.errors
|
||||
.StripeInvalidRequestError as unknown as new (m?: string) => Error;
|
||||
mockSubCreate.mockImplementationOnce(() =>
|
||||
Promise.reject(new InvalidRequestError('invalid_request'))
|
||||
);
|
||||
const response = await superPost('/donate/charge-stripe-card').send(
|
||||
chargeStripeCardReqBody
|
||||
);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('should capture Stripe infra errors', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
|
||||
const AuthError = Stripe.errors
|
||||
.StripeAuthenticationError as unknown as new (m?: string) => Error;
|
||||
mockSubCreate.mockImplementationOnce(() =>
|
||||
Promise.reject(new AuthError('invalid api key'))
|
||||
);
|
||||
const response = await superPost('/donate/charge-stripe-card').send(
|
||||
chargeStripeCardReqBody
|
||||
);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('should return 400 if user has not completed challenges', async () => {
|
||||
@@ -345,10 +461,37 @@ describe('Donate', () => {
|
||||
const failResponse = await superPost('/donate/add-donation').send({});
|
||||
expect(failResponse.status).toBe(400);
|
||||
});
|
||||
|
||||
test('should capture unexpected errors', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
const updateSpy = vi
|
||||
.spyOn(fastifyTestInstance.prisma.user, 'update')
|
||||
.mockRejectedValueOnce(new Error('DB error'));
|
||||
|
||||
const response = await superPost('/donate/add-donation').send({});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
|
||||
updateSpy.mockRestore();
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /donate/update-stripe-card', () => {
|
||||
test('should return 200 and return session id', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
await fastifyTestInstance.prisma.donation.create({
|
||||
data: donationMock
|
||||
});
|
||||
@@ -369,14 +512,39 @@ describe('Donate', () => {
|
||||
});
|
||||
expect(response.body).toEqual({ sessionId: 'checkout_session_id' });
|
||||
expect(response.status).toBe(200);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'donation.card_update_requested',
|
||||
1,
|
||||
{
|
||||
attributes: { result: 'success' }
|
||||
}
|
||||
);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
test('should return 500 if there is no donation record', async () => {
|
||||
test('should return 404 if there is no donation record', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPut('/donate/update-stripe-card').send({});
|
||||
expect(response.body).toEqual({
|
||||
message: 'flash.generic-error',
|
||||
type: 'danger'
|
||||
});
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.status).toBe(404);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'donation.card_update_requested',
|
||||
1,
|
||||
{
|
||||
attributes: { result: 'not_found' }
|
||||
}
|
||||
);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import Stripe from 'stripe';
|
||||
import * as schemas from '../../schemas.js';
|
||||
import { donationSubscriptionConfig } from '@freecodecamp/shared/config/donation-settings';
|
||||
import { STRIPE_SECRET_KEY, HOME_LOCATION } from '../../utils/env.js';
|
||||
import { clientNetInfo } from '../../utils/logger.js';
|
||||
|
||||
/**
|
||||
* Plugin for the donation endpoints requiring auth.
|
||||
@@ -29,13 +30,19 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
|
||||
schema: schemas.updateStripeCard
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
const donation = await fastify.prisma.donation.findFirst({
|
||||
where: { userId: req.user?.id, provider: 'stripe' }
|
||||
});
|
||||
if (!donation) {
|
||||
logger.error(`Stripe donation record not found: ${req.user?.id}`);
|
||||
throw Error(`Stripe donation record not found: ${req.user?.id}`);
|
||||
req.log.warn(
|
||||
{ userId: req.user?.id },
|
||||
'Stripe donation record not found'
|
||||
);
|
||||
fastify.Sentry?.metrics?.count('donation.card_update_requested', 1, {
|
||||
attributes: { result: 'not_found' }
|
||||
});
|
||||
void reply.code(404);
|
||||
return { message: 'flash.generic-error', type: 'danger' } as const;
|
||||
}
|
||||
const { customerId, subscriptionId } = donation;
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
@@ -51,7 +58,10 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
|
||||
success_url: `${HOME_LOCATION}/update-stripe-card?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${HOME_LOCATION}/update-stripe-card`
|
||||
});
|
||||
logger.info(`Stripe session created for user: ${req.user?.id}`);
|
||||
req.log.info('Stripe session created');
|
||||
fastify.Sentry?.metrics?.count('donation.card_update_requested', 1, {
|
||||
attributes: { result: 'success' }
|
||||
});
|
||||
return { sessionId: session.id } as const;
|
||||
}
|
||||
);
|
||||
@@ -62,14 +72,13 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
|
||||
schema: schemas.addDonation
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
try {
|
||||
const user = await fastify.prisma.user.findUnique({
|
||||
where: { id: req.user?.id }
|
||||
});
|
||||
|
||||
if (user?.isDonating) {
|
||||
logger.info(`User ${req.user?.id} is already donating.`);
|
||||
req.log.warn('User is already donating');
|
||||
void reply.code(400);
|
||||
return {
|
||||
type: 'info',
|
||||
@@ -84,14 +93,17 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`User ${req.user?.id} is now donating`);
|
||||
req.log.info({ audit: true }, 'User is now donating');
|
||||
|
||||
return {
|
||||
isDonating: true
|
||||
} as const;
|
||||
} catch (error) {
|
||||
logger.error(error, `User ${req.user?.id} failed to donate`);
|
||||
fastify.Sentry.captureException(error);
|
||||
fastify.Sentry?.captureException(error);
|
||||
req.log.error(
|
||||
{ err: error, userId: req.user?.id, ...clientNetInfo(req) },
|
||||
'User failed to donate'
|
||||
);
|
||||
void reply.code(500);
|
||||
return {
|
||||
type: 'danger',
|
||||
@@ -107,7 +119,6 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
|
||||
schema: schemas.chargeStripeCard
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
try {
|
||||
const { paymentMethodId, amount, duration } = req.body;
|
||||
const id = req.user!.id;
|
||||
@@ -119,7 +130,7 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
|
||||
const { email, name } = user;
|
||||
|
||||
if (!email) {
|
||||
logger.warn(`User ${id} has no email`);
|
||||
req.log.warn('User has no email');
|
||||
void reply.code(403);
|
||||
return reply.send({
|
||||
error: {
|
||||
@@ -131,8 +142,8 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
|
||||
const threeChallengesCompleted = user.completedChallenges.length >= 3;
|
||||
|
||||
if (!threeChallengesCompleted) {
|
||||
logger.warn(
|
||||
`User ${id} has tried to donate before completing 3 challenges`
|
||||
req.log.warn(
|
||||
'User has tried to donate before completing 3 challenges'
|
||||
);
|
||||
void reply.code(400);
|
||||
return {
|
||||
@@ -144,7 +155,7 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
|
||||
if (user.isDonating) {
|
||||
logger.info(`User ${id} is already donating.`);
|
||||
req.log.warn('User is already donating');
|
||||
void reply.code(400);
|
||||
return reply.send({
|
||||
error: {
|
||||
@@ -182,7 +193,8 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
|
||||
expand: ['latest_invoice.payment_intent']
|
||||
});
|
||||
if (status === 'requires_source_action') {
|
||||
logger.info(`User ${id} payment requires user action`);
|
||||
req.log.info('User payment requires user action');
|
||||
fastify.Sentry?.metrics?.count('donation.action_required', 1);
|
||||
void reply.code(402);
|
||||
return reply.send({
|
||||
error: {
|
||||
@@ -193,7 +205,10 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
});
|
||||
} else if (status === 'requires_source') {
|
||||
logger.info(`User ${id} payment declined`);
|
||||
req.log.warn('User payment declined');
|
||||
fastify.Sentry?.metrics?.count('donation.declined', 1, {
|
||||
attributes: { flow: 'charge-stripe-card' }
|
||||
});
|
||||
void reply.code(402);
|
||||
return reply.send({
|
||||
error: {
|
||||
@@ -230,15 +245,41 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`User ${id} has successfully donated`);
|
||||
req.log.info(
|
||||
{
|
||||
audit: true,
|
||||
userId: id,
|
||||
email,
|
||||
amount,
|
||||
duration,
|
||||
subscriptionId,
|
||||
...clientNetInfo(req)
|
||||
},
|
||||
'User has successfully donated'
|
||||
);
|
||||
fastify.Sentry?.metrics?.count('donation.created', 1, {
|
||||
attributes: { flow: 'charge-stripe-card' }
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
type: 'success',
|
||||
isDonating: true
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error, `User ${req.user?.id} failed to donate`);
|
||||
fastify.Sentry.captureException(error);
|
||||
const ctx = {
|
||||
err: error,
|
||||
userId: req.user?.id,
|
||||
...clientNetInfo(req)
|
||||
};
|
||||
if (
|
||||
error instanceof Stripe.errors.StripeCardError ||
|
||||
error instanceof Stripe.errors.StripeInvalidRequestError
|
||||
) {
|
||||
req.log.warn(ctx, 'Stripe upstream error charging card');
|
||||
} else {
|
||||
fastify.Sentry?.captureException(error);
|
||||
req.log.error(ctx, 'User failed to donate');
|
||||
}
|
||||
void reply.code(500);
|
||||
return reply.send({
|
||||
error: 'Donation failed due to a server error.'
|
||||
|
||||
@@ -229,25 +229,53 @@ describe('settingRoutes', () => {
|
||||
});
|
||||
|
||||
test('should reject requests which have an invalid email param', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superGet(
|
||||
`/confirm-email?email=${notEmail}&token=${validToken}`
|
||||
);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(res.headers.location).toBe(
|
||||
`${HOME_LOCATION}?` + formatMessage(defaultErrorMessage)
|
||||
);
|
||||
expect(res.status).toBe(302);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'settings.email_confirm_rejected',
|
||||
1,
|
||||
{ attributes: { reason: 'invalid_email' } }
|
||||
);
|
||||
});
|
||||
|
||||
test('should reject requests when the auth token is not in the database', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superGet(
|
||||
`/confirm-email?email=${encodedEmail}&token=${validButMissingToken}`
|
||||
);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(res.headers.location).toBe(
|
||||
`${HOME_LOCATION}?` + formatMessage(defaultErrorMessage)
|
||||
);
|
||||
expect(res.status).toBe(302);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'settings.email_confirm_rejected',
|
||||
1,
|
||||
{ attributes: { reason: 'no_token' } }
|
||||
);
|
||||
});
|
||||
|
||||
test('should reject requests when the auth token exists, but the user does not', async () => {
|
||||
@@ -264,15 +292,28 @@ describe('settingRoutes', () => {
|
||||
test('should reject requests when the target user does not match the signed in user', async () => {
|
||||
// The signed in user is the default (foo@bar.com), but the token is for
|
||||
// a different user (another@user.com).
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superGet(
|
||||
`/confirm-email?email=${encodedEmail}&token=${tokenWithDifferentUser}`
|
||||
);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(res.headers.location).toBe(
|
||||
`${HOME_LOCATION}?` + formatMessage(defaultErrorMessage)
|
||||
);
|
||||
expect(res.status).toBe(302);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'settings.email_confirm_rejected',
|
||||
1,
|
||||
{ attributes: { reason: 'user_mismatch' } }
|
||||
);
|
||||
});
|
||||
|
||||
// TODO(Post-MVP): there's no need to keep the auth token around if,
|
||||
@@ -287,21 +328,44 @@ describe('settingRoutes', () => {
|
||||
data: { newEmail: 'an@oth.er' }
|
||||
});
|
||||
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superGet(
|
||||
`/confirm-email?email=${encodedEmail}&token=${validToken}`
|
||||
);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(res.headers.location).toBe(
|
||||
`${HOME_LOCATION}?` + formatMessage(defaultErrorMessage)
|
||||
);
|
||||
expect(res.status).toBe(302);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'settings.email_confirm_rejected',
|
||||
1,
|
||||
{ attributes: { reason: 'email_mismatch' } }
|
||||
);
|
||||
});
|
||||
|
||||
test('should reject requests if the auth token has expired', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superGet(
|
||||
`/confirm-email?email=${encodedEmail}&token=${expiredToken}`
|
||||
);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(res.headers.location).toBe(
|
||||
`${HOME_LOCATION}?` +
|
||||
formatMessage({
|
||||
@@ -311,9 +375,21 @@ describe('settingRoutes', () => {
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(302);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'settings.email_confirm_rejected',
|
||||
1,
|
||||
{ attributes: { reason: 'expired' } }
|
||||
);
|
||||
});
|
||||
|
||||
test('should update the user email', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superGet(
|
||||
`/confirm-email?email=${encodedEmail}&token=${validToken}`
|
||||
);
|
||||
@@ -321,10 +397,13 @@ describe('settingRoutes', () => {
|
||||
where: { id: defaultUserId }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(res.headers.location).toBe(
|
||||
`${HOME_LOCATION}?` + formatMessage(successMessage)
|
||||
);
|
||||
expect(user.email).toBe(newEmail);
|
||||
expect(count).toHaveBeenCalledWith('settings.email_confirmed', 1);
|
||||
});
|
||||
|
||||
test('should clean up the user record', async () => {
|
||||
@@ -359,10 +438,19 @@ describe('settingRoutes', () => {
|
||||
|
||||
describe('/update-my-profileui', () => {
|
||||
test('PUT returns 200 status code with "success" message', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPut('/update-my-profileui').send({
|
||||
profileUI
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
const user = await fastifyTestInstance.prisma.user.findFirst({
|
||||
where: { email: developerUserEmail }
|
||||
});
|
||||
@@ -373,6 +461,9 @@ describe('settingRoutes', () => {
|
||||
});
|
||||
expect(user?.profileUI).toEqual(profileUI);
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(count).toHaveBeenCalledWith('settings.updated', 1, {
|
||||
attributes: { field: 'profile_ui' }
|
||||
});
|
||||
});
|
||||
|
||||
test('PUT ignores invalid keys', async () => {
|
||||
@@ -432,16 +523,29 @@ describe('settingRoutes', () => {
|
||||
});
|
||||
});
|
||||
test('PUT returns 200 status code with "info" message', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPut('/update-my-email').send({
|
||||
email: 'foo@foo.com'
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.body).toEqual({
|
||||
message:
|
||||
'Check your email and click the link we sent you to confirm your new email address.',
|
||||
type: 'info'
|
||||
});
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'settings.email_change_requested',
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
test("PUT updates the user's record in preparation for receiving auth email", async () => {
|
||||
@@ -637,15 +741,27 @@ Happy coding!
|
||||
|
||||
describe('/update-my-theme', () => {
|
||||
test('PUT returns 200 status code with "success" message', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPut('/update-my-theme').send({
|
||||
theme: 'night'
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.body).toEqual({
|
||||
message: 'flash.updated-themes',
|
||||
type: 'success'
|
||||
});
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(count).toHaveBeenCalledWith('settings.updated', 1, {
|
||||
attributes: { field: 'theme' }
|
||||
});
|
||||
});
|
||||
|
||||
test('PUT returns 400 status code with invalid theme', async () => {
|
||||
@@ -656,31 +772,83 @@ Happy coding!
|
||||
expect(response.body).toEqual(updateErrorResponse);
|
||||
expect(response.statusCode).toEqual(400);
|
||||
});
|
||||
|
||||
test('PUT captures a Sentry Issue and returns 500 when the update fails', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = { ...originalSentry, captureException };
|
||||
|
||||
const original = fastifyTestInstance.prisma.user.update;
|
||||
fastifyTestInstance.prisma.user.update = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('db down')) as typeof original;
|
||||
|
||||
const response = await superPut('/update-my-theme').send({
|
||||
theme: 'night'
|
||||
});
|
||||
|
||||
fastifyTestInstance.prisma.user.update = original;
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.statusCode).toEqual(500);
|
||||
expect(response.body).toEqual(updateErrorResponse);
|
||||
expect(captureException).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ message: 'db down' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/update-my-username', () => {
|
||||
test('PUT returns an error when the username uses special characters', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPut('/update-my-username').send({
|
||||
username: 'twaha@'
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.body).toEqual({
|
||||
message: 'Username twaha@ contains invalid characters',
|
||||
type: 'info'
|
||||
});
|
||||
expect(response.statusCode).toEqual(400);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'settings.username_change_rejected',
|
||||
1,
|
||||
{ attributes: { reason: 'invalid' } }
|
||||
);
|
||||
});
|
||||
|
||||
test('PUT returns an error when the username is an endpoint', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPut('/update-my-username').send({
|
||||
username: 'german'
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.body).toEqual({
|
||||
message: 'flash.username-taken',
|
||||
type: 'info'
|
||||
});
|
||||
expect(response.statusCode).toEqual(400);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'settings.username_change_rejected',
|
||||
1,
|
||||
{ attributes: { reason: 'taken_or_restricted' } }
|
||||
);
|
||||
});
|
||||
|
||||
test('PUT returns an error when the username is a bad word', async () => {
|
||||
@@ -720,9 +888,19 @@ Happy coding!
|
||||
});
|
||||
|
||||
test('PUT returns 200 status code with "success" message', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPut('/update-my-username').send({
|
||||
username: 'TwaHa1'
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
const user = await fastifyTestInstance.prisma.user.findFirst({
|
||||
where: { email: 'foo@bar.com' }
|
||||
});
|
||||
@@ -734,6 +912,9 @@ Happy coding!
|
||||
variables: { username: 'TwaHa1' }
|
||||
});
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(count).toHaveBeenCalledWith('settings.updated', 1, {
|
||||
attributes: { field: 'username' }
|
||||
});
|
||||
});
|
||||
|
||||
test('PUT returns an error when the username is already used', async () => {
|
||||
@@ -753,15 +934,29 @@ Happy coding!
|
||||
});
|
||||
await superPut('/update-my-username').send({ username: 'twaha2' });
|
||||
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const secondUpdate = await superPut('/update-my-username').send({
|
||||
username: 'twaha2'
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(secondUpdate.body).toEqual({
|
||||
message: 'flash.username-used',
|
||||
type: 'info'
|
||||
});
|
||||
expect(secondUpdate.statusCode).toEqual(400);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'settings.username_change_rejected',
|
||||
1,
|
||||
{ attributes: { reason: 'unchanged' } }
|
||||
);
|
||||
|
||||
// Not allowed because, while the usernameDisplay is different, the
|
||||
// username is not
|
||||
@@ -792,15 +987,27 @@ Happy coding!
|
||||
|
||||
describe('/update-my-keyboard-shortcuts', () => {
|
||||
test('PUT returns 200 status code with "success" message', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPut('/update-my-keyboard-shortcuts').send({
|
||||
keyboardShortcuts: true
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.body).toEqual({
|
||||
message: 'flash.keyboard-shortcut-updated',
|
||||
type: 'success'
|
||||
});
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(count).toHaveBeenCalledWith('settings.updated', 1, {
|
||||
attributes: { field: 'keyboard_shortcuts' }
|
||||
});
|
||||
});
|
||||
|
||||
test('PUT returns 400 status code with invalid shortcuts setting', async () => {
|
||||
@@ -815,6 +1022,13 @@ Happy coding!
|
||||
|
||||
describe('/update-my-socials', () => {
|
||||
test('PUT returns 200 status code with "success" message', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPut('/update-my-socials').send({
|
||||
website: 'https://www.freecodecamp.org/',
|
||||
twitter: 'https://twitter.com/ossia',
|
||||
@@ -823,11 +1037,16 @@ Happy coding!
|
||||
githubProfile: 'https://github.com/QuincyLarson'
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.body).toEqual({
|
||||
message: 'flash.updated-socials',
|
||||
type: 'success'
|
||||
});
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(count).toHaveBeenCalledWith('settings.updated', 1, {
|
||||
attributes: { field: 'socials' }
|
||||
});
|
||||
});
|
||||
|
||||
test('PUT accepts empty strings for socials', async () => {
|
||||
@@ -860,6 +1079,13 @@ Happy coding!
|
||||
});
|
||||
|
||||
test('PUT only accepts urls to certain domains', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPut('/update-my-socials').send({
|
||||
website: '',
|
||||
twitter: '',
|
||||
@@ -868,22 +1094,62 @@ Happy coding!
|
||||
githubProfile: 'https://x.com/should-be-github'
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.body).toEqual(updateErrorResponse);
|
||||
expect(response.statusCode).toEqual(400);
|
||||
expect(count).toHaveBeenCalledWith('settings.social_url_rejected', 1, {
|
||||
attributes: { provider: 'githubProfile' }
|
||||
});
|
||||
});
|
||||
|
||||
test('PUT does not log raw social URLs on validation failure', async () => {
|
||||
const spy = vi.spyOn(fastifyTestInstance.log, 'warn');
|
||||
const leakyUrl = 'https://x.com/should-be-github?api_key=super-secret';
|
||||
|
||||
const response = await superPut('/update-my-socials').send({
|
||||
website: '',
|
||||
twitter: '',
|
||||
bluesky: '',
|
||||
linkedin: '',
|
||||
githubProfile: leakyUrl
|
||||
});
|
||||
|
||||
expect(response.statusCode).toEqual(400);
|
||||
const call = spy.mock.calls.find(
|
||||
([, msg]) => msg === 'Invalid social URL'
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
const [logObject] = call!;
|
||||
expect(JSON.stringify(logObject)).not.toContain(leakyUrl);
|
||||
expect(JSON.stringify(logObject)).not.toContain('super-secret');
|
||||
expect(logObject).toEqual({ invalidSocials: ['githubProfile'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('/update-my-quincy-email', () => {
|
||||
test('PUT returns 200 status code with "success" message', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPut('/update-my-quincy-email').send({
|
||||
sendQuincyEmail: true
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.body).toEqual({
|
||||
message: 'flash.subscribe-to-quincy-updated',
|
||||
type: 'success'
|
||||
});
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(count).toHaveBeenCalledWith('settings.updated', 1, {
|
||||
attributes: { field: 'quincy_email' }
|
||||
});
|
||||
});
|
||||
|
||||
test('PUT returns 400 status code with invalid sendQuincyEmail', async () => {
|
||||
@@ -896,8 +1162,41 @@ Happy coding!
|
||||
});
|
||||
});
|
||||
|
||||
describe('/update-socrates', () => {
|
||||
test('PUT returns 200 status code with "success" message', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPut('/update-socrates').send({
|
||||
socrates: true
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.body).toEqual({
|
||||
message: 'flash.socrates-updated',
|
||||
type: 'success'
|
||||
});
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(count).toHaveBeenCalledWith('settings.updated', 1, {
|
||||
attributes: { field: 'socrates' }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/update-my-about', () => {
|
||||
test('PUT updates the values in about settings', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPut('/update-my-about').send({
|
||||
about: 'Teacher at freeCodeCamp',
|
||||
name: 'Quincy Larson',
|
||||
@@ -906,6 +1205,8 @@ Happy coding!
|
||||
'https://cdn.freecodecamp.org/platform/english/images/quincy-larson-signature.svg'
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.body).toEqual({
|
||||
message: 'flash.updated-about-me',
|
||||
type: 'success'
|
||||
@@ -922,6 +1223,9 @@ Happy coding!
|
||||
'https://cdn.freecodecamp.org/platform/english/images/quincy-larson-signature.svg'
|
||||
);
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(count).toHaveBeenCalledWith('settings.updated', 1, {
|
||||
attributes: { field: 'about' }
|
||||
});
|
||||
});
|
||||
|
||||
test('PUT returns 400 if the URL is invalid', async () => {
|
||||
@@ -969,6 +1273,32 @@ Happy coding!
|
||||
expect(response.statusCode).toEqual(400);
|
||||
});
|
||||
|
||||
test('PUT does not log the raw picture URL on validation failure', async () => {
|
||||
const spy = vi.spyOn(fastifyTestInstance.log, 'warn');
|
||||
spy.mockClear();
|
||||
const leakyUrl = 'https://example.com/file.txt?api_key=super-secret';
|
||||
|
||||
const response = await superPut('/update-my-about').send({
|
||||
about: 'Teacher at freeCodeCamp',
|
||||
name: 'Quincy Larson',
|
||||
location: 'USA',
|
||||
picture: leakyUrl
|
||||
});
|
||||
|
||||
expect(response.statusCode).toEqual(400);
|
||||
const call = spy.mock.calls.find(
|
||||
([, msg]) => msg === 'Invalid picture URL'
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
const [logObject] = call!;
|
||||
expect(JSON.stringify(logObject)).not.toContain(leakyUrl);
|
||||
expect(JSON.stringify(logObject)).not.toContain('super-secret');
|
||||
expect(logObject).toEqual({
|
||||
hasPicture: true,
|
||||
pictureLength: leakyUrl.length
|
||||
});
|
||||
});
|
||||
|
||||
test('PUT accepts an image URL with query string', async () => {
|
||||
const response = await superPut('/update-my-about').send({
|
||||
about: 'Teacher at freeCodeCamp',
|
||||
@@ -1117,15 +1447,27 @@ Happy coding!
|
||||
|
||||
describe('/update-my-honesty', () => {
|
||||
test('PUT returns 200 status code with "success" message', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPut('/update-my-honesty').send({
|
||||
isHonest: true
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.body).toEqual({
|
||||
message: 'buttons.accepted-honesty',
|
||||
type: 'success'
|
||||
});
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(count).toHaveBeenCalledWith('settings.updated', 1, {
|
||||
attributes: { field: 'honesty' }
|
||||
});
|
||||
});
|
||||
|
||||
test('PUT returns 400 status code with invalid honesty', async () => {
|
||||
@@ -1140,15 +1482,27 @@ Happy coding!
|
||||
|
||||
describe('/update-privacy-terms', () => {
|
||||
test('PUT returns 200 status code with "success" message', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPut('/update-privacy-terms').send({
|
||||
quincyEmails: true
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.body).toEqual({
|
||||
message: 'flash.privacy-updated',
|
||||
type: 'success'
|
||||
});
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(count).toHaveBeenCalledWith('settings.updated', 1, {
|
||||
attributes: { field: 'privacy_terms' }
|
||||
});
|
||||
});
|
||||
|
||||
test('PUT returns 400 status code with non-boolean data', async () => {
|
||||
@@ -1163,15 +1517,27 @@ Happy coding!
|
||||
|
||||
describe('/update-my-portfolio', () => {
|
||||
test('PUT returns 200 status code with "success" message', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPut('/update-my-portfolio').send({
|
||||
portfolio: [{}]
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.body).toEqual({
|
||||
message: 'flash.portfolio-item-updated',
|
||||
type: 'success'
|
||||
});
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(count).toHaveBeenCalledWith('settings.updated', 1, {
|
||||
attributes: { field: 'portfolio' }
|
||||
});
|
||||
});
|
||||
|
||||
test('PUT returns 400 status code when the portfolio property is missing', async () => {
|
||||
@@ -1210,13 +1576,25 @@ Happy coding!
|
||||
]
|
||||
};
|
||||
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPut('/update-my-experience').send(payload);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.body).toEqual({
|
||||
message: 'flash.experience-updated',
|
||||
type: 'success'
|
||||
});
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(count).toHaveBeenCalledWith('settings.updated', 1, {
|
||||
attributes: { field: 'experience' }
|
||||
});
|
||||
|
||||
const user = await fastifyTestInstance.prisma.user.findFirst({
|
||||
where: { email: developerUserEmail },
|
||||
@@ -1424,16 +1802,35 @@ Happy coding!
|
||||
|
||||
describe('/update-my-classroom-mode', () => {
|
||||
test('PUT returns 200 status code with "success" message', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPut('/update-my-classroom-mode').send({
|
||||
isClassroomAccount: true
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.body).toEqual({
|
||||
message: 'flash.classroom-mode-updated',
|
||||
type: 'success'
|
||||
});
|
||||
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'settings.classroom_mode_toggled',
|
||||
1,
|
||||
{
|
||||
attributes: { enabled: true }
|
||||
}
|
||||
);
|
||||
expect(count).toHaveBeenCalledWith('settings.updated', 1, {
|
||||
attributes: { field: 'classroom_mode' }
|
||||
});
|
||||
});
|
||||
|
||||
test('PUT returns 400 status code with invalid classroom mode', async () => {
|
||||
|
||||
@@ -135,9 +135,11 @@ export const settingRoutes: FastifyPluginCallbackTypebox = (
|
||||
done
|
||||
) => {
|
||||
fastify.setErrorHandler((error: FastifyError, request, reply) => {
|
||||
const logger = fastify.log.child({ req: request });
|
||||
if (error.validation) {
|
||||
logger.warn({ validationError: error.validation });
|
||||
request.log.warn(
|
||||
{ validationError: error.validation },
|
||||
'Request validation failed'
|
||||
);
|
||||
void reply.code(400);
|
||||
void reply.send({ message: 'flash.wrong-updating', type: 'danger' });
|
||||
} else {
|
||||
@@ -151,7 +153,6 @@ export const settingRoutes: FastifyPluginCallbackTypebox = (
|
||||
schema: schemas.updateMyProfileUI
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
try {
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: req.user?.id },
|
||||
@@ -172,14 +173,17 @@ export const settingRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
});
|
||||
|
||||
fastify.Sentry?.metrics?.count('settings.updated', 1, {
|
||||
attributes: { field: 'profile_ui' }
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'flash.privacy-updated',
|
||||
type: 'success'
|
||||
} as const;
|
||||
} catch (err) {
|
||||
logger.error('Error updating profileUI');
|
||||
logger.error(err);
|
||||
fastify.Sentry.captureException(err);
|
||||
fastify.Sentry?.captureException(err);
|
||||
req.log.error(err, 'Error updating profileUI');
|
||||
void reply.code(500);
|
||||
return { message: 'flash.wrong-updating', type: 'danger' } as const;
|
||||
}
|
||||
@@ -206,9 +210,8 @@ Happy coding!
|
||||
attachValidation: true
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
if (req.validationError) {
|
||||
logger.warn(`Invalid email ${req.body.email}`);
|
||||
req.log.warn('Invalid email format');
|
||||
void reply.code(400);
|
||||
return { message: 'Email format is invalid', type: 'danger' } as const;
|
||||
}
|
||||
@@ -229,7 +232,7 @@ Happy coding!
|
||||
const isVerifiedEmail = user.emailVerified;
|
||||
const isOwnEmail = newEmail === currentEmailFormatted;
|
||||
if (isOwnEmail && isVerifiedEmail) {
|
||||
logger.warn(
|
||||
req.log.warn(
|
||||
'New email address is already associated with this account'
|
||||
);
|
||||
void reply.code(400);
|
||||
@@ -247,7 +250,7 @@ You can update a new email address instead.`
|
||||
});
|
||||
|
||||
if (isResendUpdateToSameEmail && isLinkSentWithinLimitTTL) {
|
||||
logger.warn(
|
||||
req.log.warn(
|
||||
'Email confirmation link has been sent within the last 5 minutes'
|
||||
);
|
||||
void reply.code(429);
|
||||
@@ -262,7 +265,7 @@ ${isLinkSentWithinLimitTTL}`
|
||||
(await fastify.prisma.user.count({ where: { email: newEmail } })) > 0;
|
||||
|
||||
if (isEmailAlreadyTaken && !isOwnEmail) {
|
||||
logger.warn(
|
||||
req.log.warn(
|
||||
'New email address is already associated with another account'
|
||||
);
|
||||
void reply.code(400);
|
||||
@@ -292,7 +295,7 @@ ${isLinkSentWithinLimitTTL}`
|
||||
});
|
||||
|
||||
if (tooManyRequestsMessage) {
|
||||
logger.warn(
|
||||
req.log.warn(
|
||||
'Email confirmation link has been sent within the last 5 minutes'
|
||||
);
|
||||
void reply.code(429);
|
||||
@@ -326,15 +329,16 @@ ${isLinkSentWithinLimitTTL}`
|
||||
text: createUpdateEmailText({ email: newEmail, id })
|
||||
});
|
||||
|
||||
fastify.Sentry?.metrics?.count('settings.email_change_requested', 1);
|
||||
|
||||
await reply.send({
|
||||
message:
|
||||
'Check your email and click the link we sent you to confirm your new email address.',
|
||||
type: 'info'
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`Error updating user ${user.id}'s email address`);
|
||||
logger.error(err);
|
||||
fastify.Sentry.captureException(err);
|
||||
fastify.Sentry?.captureException(err);
|
||||
req.log.error(err, 'Error updating email address');
|
||||
void reply.code(500);
|
||||
await reply.send({ message: 'flash.wrong-updating', type: 'danger' });
|
||||
}
|
||||
@@ -347,7 +351,6 @@ ${isLinkSentWithinLimitTTL}`
|
||||
schema: schemas.updateMyTheme
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
try {
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: req.user?.id },
|
||||
@@ -356,14 +359,17 @@ ${isLinkSentWithinLimitTTL}`
|
||||
}
|
||||
});
|
||||
|
||||
fastify.Sentry?.metrics?.count('settings.updated', 1, {
|
||||
attributes: { field: 'theme' }
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'flash.updated-themes',
|
||||
type: 'success'
|
||||
} as const;
|
||||
} catch (err) {
|
||||
logger.error(`Error updating user ${req.user?.id}'s theme`);
|
||||
logger.error(err);
|
||||
fastify.Sentry.captureException(err);
|
||||
fastify.Sentry?.captureException(err);
|
||||
req.log.error(err, 'Error updating theme');
|
||||
void reply.code(500);
|
||||
return { message: 'flash.wrong-updating', type: 'danger' } as const;
|
||||
}
|
||||
@@ -376,8 +382,6 @@ ${isLinkSentWithinLimitTTL}`
|
||||
schema: schemas.updateMySocials
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
|
||||
const socials = {
|
||||
twitter: req.body.twitter,
|
||||
bluesky: req.body.bluesky,
|
||||
@@ -391,7 +395,21 @@ ${isLinkSentWithinLimitTTL}`
|
||||
).every(key => validateSocialUrl(socials[key], key));
|
||||
|
||||
if (!valid) {
|
||||
logger.warn({ socials }, `Invalid social URL`);
|
||||
req.log.warn(
|
||||
{
|
||||
invalidSocials: (
|
||||
['twitter', 'bluesky', 'githubProfile', 'linkedin'] as const
|
||||
).filter(key => !validateSocialUrl(socials[key], key))
|
||||
},
|
||||
'Invalid social URL'
|
||||
);
|
||||
(['twitter', 'bluesky', 'githubProfile', 'linkedin'] as const)
|
||||
.filter(key => !validateSocialUrl(socials[key], key))
|
||||
.forEach(provider => {
|
||||
fastify.Sentry?.metrics?.count('settings.social_url_rejected', 1, {
|
||||
attributes: { provider }
|
||||
});
|
||||
});
|
||||
void reply.code(400);
|
||||
return reply.send({
|
||||
message: 'flash.wrong-updating',
|
||||
@@ -411,14 +429,17 @@ ${isLinkSentWithinLimitTTL}`
|
||||
}
|
||||
});
|
||||
|
||||
fastify.Sentry?.metrics?.count('settings.updated', 1, {
|
||||
attributes: { field: 'socials' }
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'flash.updated-socials',
|
||||
type: 'success'
|
||||
} as const;
|
||||
} catch (err) {
|
||||
logger.error(`Error updating user ${req.user?.id}'s socials`);
|
||||
logger.error(err);
|
||||
fastify.Sentry.captureException(err);
|
||||
fastify.Sentry?.captureException(err);
|
||||
req.log.error(err, 'Error updating socials');
|
||||
void reply.code(500);
|
||||
return { message: 'flash.wrong-updating', type: 'danger' } as const;
|
||||
}
|
||||
@@ -432,8 +453,6 @@ ${isLinkSentWithinLimitTTL}`
|
||||
attachValidation: true
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
|
||||
try {
|
||||
const user = await fastify.prisma.user.findFirstOrThrow({
|
||||
where: { id: req.user?.id }
|
||||
@@ -450,7 +469,14 @@ ${isLinkSentWithinLimitTTL}`
|
||||
newUsernameDisplay === oldUsernameDisplay;
|
||||
|
||||
if (usernameUnchanged) {
|
||||
logger.warn('Username is unchanged');
|
||||
req.log.warn('Username is unchanged');
|
||||
fastify.Sentry?.metrics?.count(
|
||||
'settings.username_change_rejected',
|
||||
1,
|
||||
{
|
||||
attributes: { reason: 'unchanged' }
|
||||
}
|
||||
);
|
||||
void reply.code(400);
|
||||
return {
|
||||
message: 'flash.username-used',
|
||||
@@ -459,7 +485,10 @@ ${isLinkSentWithinLimitTTL}`
|
||||
}
|
||||
|
||||
if (req.validationError) {
|
||||
logger.warn(`Bad request. Supplied username: ${req.body.username}`);
|
||||
req.log.warn(
|
||||
{ username: req.body.username },
|
||||
'Bad request. Invalid username supplied'
|
||||
);
|
||||
void reply.code(400);
|
||||
return {
|
||||
message: req.validationError.message,
|
||||
@@ -470,7 +499,17 @@ ${isLinkSentWithinLimitTTL}`
|
||||
const validation = isValidUsername(newUsername);
|
||||
|
||||
if (!validation.valid) {
|
||||
logger.warn(`Invalid username ${newUsername}. ${validation.error}`);
|
||||
req.log.warn(
|
||||
{ username: newUsername, validationError: validation.error },
|
||||
'Invalid username'
|
||||
);
|
||||
fastify.Sentry?.metrics?.count(
|
||||
'settings.username_change_rejected',
|
||||
1,
|
||||
{
|
||||
attributes: { reason: 'invalid' }
|
||||
}
|
||||
);
|
||||
void reply.code(400);
|
||||
return reply.send({
|
||||
// TODO(Post-MVP): custom validation errors.
|
||||
@@ -487,7 +526,17 @@ ${isLinkSentWithinLimitTTL}`
|
||||
});
|
||||
|
||||
if (usernameTaken || isRestricted(newUsername)) {
|
||||
logger.warn(`Username ${newUsername} is taken or restricted`);
|
||||
req.log.warn(
|
||||
{ username: newUsername },
|
||||
'Username is taken or restricted'
|
||||
);
|
||||
fastify.Sentry?.metrics?.count(
|
||||
'settings.username_change_rejected',
|
||||
1,
|
||||
{
|
||||
attributes: { reason: 'taken_or_restricted' }
|
||||
}
|
||||
);
|
||||
void reply.code(400);
|
||||
return reply.send({
|
||||
message: 'flash.username-taken',
|
||||
@@ -503,14 +552,18 @@ ${isLinkSentWithinLimitTTL}`
|
||||
}
|
||||
});
|
||||
|
||||
fastify.Sentry?.metrics?.count('settings.updated', 1, {
|
||||
attributes: { field: 'username' }
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
message: 'flash.username-updated',
|
||||
type: 'success',
|
||||
variables: { username: newUsernameDisplay }
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
fastify.Sentry.captureException(err);
|
||||
fastify.Sentry?.captureException(err);
|
||||
req.log.error(err, 'Error updating username');
|
||||
void reply.code(500);
|
||||
await reply.send({ message: 'flash.wrong-updating', type: 'danger' });
|
||||
}
|
||||
@@ -523,13 +576,17 @@ ${isLinkSentWithinLimitTTL}`
|
||||
schema: schemas.updateMyAbout
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
|
||||
// No need to validate if picture is being deleted.
|
||||
if (req.body.picture) {
|
||||
if (req.body.picture !== req.user!.picture) {
|
||||
if (!isValidPictureUrl(req.body.picture)) {
|
||||
logger.warn(`Invalid picture URL: ${req.body.picture}`);
|
||||
req.log.warn(
|
||||
{
|
||||
hasPicture: !!req.body.picture,
|
||||
pictureLength: req.body.picture?.length
|
||||
},
|
||||
'Invalid picture URL'
|
||||
);
|
||||
void reply.code(400);
|
||||
return { message: 'flash.wrong-updating', type: 'danger' } as const;
|
||||
}
|
||||
@@ -547,13 +604,17 @@ ${isLinkSentWithinLimitTTL}`
|
||||
}
|
||||
});
|
||||
|
||||
fastify.Sentry?.metrics?.count('settings.updated', 1, {
|
||||
attributes: { field: 'about' }
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'flash.updated-about-me',
|
||||
type: 'success'
|
||||
} as const;
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
fastify.Sentry.captureException(err);
|
||||
fastify.Sentry?.captureException(err);
|
||||
req.log.error(err, 'Error updating about');
|
||||
void reply.code(500);
|
||||
return { message: 'flash.wrong-updating', type: 'danger' } as const;
|
||||
}
|
||||
@@ -566,7 +627,6 @@ ${isLinkSentWithinLimitTTL}`
|
||||
schema: schemas.updateMyKeyboardShortcuts
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
try {
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: req.user?.id },
|
||||
@@ -575,13 +635,17 @@ ${isLinkSentWithinLimitTTL}`
|
||||
}
|
||||
});
|
||||
|
||||
fastify.Sentry?.metrics?.count('settings.updated', 1, {
|
||||
attributes: { field: 'keyboard_shortcuts' }
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'flash.keyboard-shortcut-updated',
|
||||
type: 'success'
|
||||
} as const;
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
fastify.Sentry.captureException(err);
|
||||
fastify.Sentry?.captureException(err);
|
||||
req.log.error(err, 'Error updating keyboard shortcuts');
|
||||
void reply.code(500);
|
||||
return { message: 'flash.wrong-updating', type: 'danger' } as const;
|
||||
}
|
||||
@@ -594,7 +658,6 @@ ${isLinkSentWithinLimitTTL}`
|
||||
schema: schemas.updateMyQuincyEmail
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
try {
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: req.user?.id },
|
||||
@@ -603,13 +666,17 @@ ${isLinkSentWithinLimitTTL}`
|
||||
}
|
||||
});
|
||||
|
||||
fastify.Sentry?.metrics?.count('settings.updated', 1, {
|
||||
attributes: { field: 'quincy_email' }
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'flash.subscribe-to-quincy-updated',
|
||||
type: 'success'
|
||||
} as const;
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
fastify.Sentry.captureException(err);
|
||||
fastify.Sentry?.captureException(err);
|
||||
req.log.error(err, 'Error updating Quincy email preference');
|
||||
void reply.code(500);
|
||||
return { message: 'flash.wrong-updating', type: 'danger' } as const;
|
||||
}
|
||||
@@ -622,7 +689,6 @@ ${isLinkSentWithinLimitTTL}`
|
||||
schema: schemas.updateSocrates
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
try {
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: req.user?.id },
|
||||
@@ -631,13 +697,17 @@ ${isLinkSentWithinLimitTTL}`
|
||||
}
|
||||
});
|
||||
|
||||
fastify.Sentry?.metrics?.count('settings.updated', 1, {
|
||||
attributes: { field: 'socrates' }
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'flash.socrates-updated',
|
||||
type: 'success'
|
||||
} as const;
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
fastify.Sentry.captureException(err);
|
||||
fastify.Sentry?.captureException(err);
|
||||
req.log.error(err, 'Error updating Socrates preference');
|
||||
void reply.code(500);
|
||||
return { message: 'flash.wrong-updating', type: 'danger' } as const;
|
||||
}
|
||||
@@ -650,7 +720,6 @@ ${isLinkSentWithinLimitTTL}`
|
||||
schema: schemas.updateMyHonesty
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
try {
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: req.user?.id },
|
||||
@@ -659,13 +728,17 @@ ${isLinkSentWithinLimitTTL}`
|
||||
}
|
||||
});
|
||||
|
||||
fastify.Sentry?.metrics?.count('settings.updated', 1, {
|
||||
attributes: { field: 'honesty' }
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'buttons.accepted-honesty',
|
||||
type: 'success'
|
||||
} as const;
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
fastify.Sentry.captureException(err);
|
||||
fastify.Sentry?.captureException(err);
|
||||
req.log.error(err, 'Error updating honesty');
|
||||
void reply.code(500);
|
||||
return { message: 'flash.wrong-updating', type: 'danger' } as const;
|
||||
}
|
||||
@@ -678,7 +751,6 @@ ${isLinkSentWithinLimitTTL}`
|
||||
schema: schemas.updateMyPrivacyTerms
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
try {
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: req.user?.id },
|
||||
@@ -688,13 +760,17 @@ ${isLinkSentWithinLimitTTL}`
|
||||
}
|
||||
});
|
||||
|
||||
fastify.Sentry?.metrics?.count('settings.updated', 1, {
|
||||
attributes: { field: 'privacy_terms' }
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'flash.privacy-updated',
|
||||
type: 'success'
|
||||
} as const;
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
fastify.Sentry.captureException(err);
|
||||
fastify.Sentry?.captureException(err);
|
||||
req.log.error(err, 'Error updating privacy terms');
|
||||
void reply.code(500);
|
||||
return { message: 'flash.wrong-updating', type: 'danger' } as const;
|
||||
}
|
||||
@@ -707,7 +783,6 @@ ${isLinkSentWithinLimitTTL}`
|
||||
schema: schemas.updateMyPortfolio
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
try {
|
||||
// TODO(Post-MVP): make all properties required in the schema and use
|
||||
// req.body.portfolio directly.
|
||||
@@ -727,13 +802,17 @@ ${isLinkSentWithinLimitTTL}`
|
||||
}
|
||||
});
|
||||
|
||||
fastify.Sentry?.metrics?.count('settings.updated', 1, {
|
||||
attributes: { field: 'portfolio' }
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'flash.portfolio-item-updated',
|
||||
type: 'success'
|
||||
} as const;
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
fastify.Sentry.captureException(err);
|
||||
fastify.Sentry?.captureException(err);
|
||||
req.log.error(err, 'Error updating portfolio');
|
||||
void reply.code(500);
|
||||
return { message: 'flash.wrong-updating', type: 'danger' } as const;
|
||||
}
|
||||
@@ -746,7 +825,6 @@ ${isLinkSentWithinLimitTTL}`
|
||||
schema: schemas.updateMyExperience
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
try {
|
||||
const { experience } = req.body;
|
||||
|
||||
@@ -757,13 +835,17 @@ ${isLinkSentWithinLimitTTL}`
|
||||
}
|
||||
});
|
||||
|
||||
fastify.Sentry?.metrics?.count('settings.updated', 1, {
|
||||
attributes: { field: 'experience' }
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'flash.experience-updated',
|
||||
type: 'success'
|
||||
} as const;
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
fastify.Sentry.captureException(err);
|
||||
fastify.Sentry?.captureException(err);
|
||||
req.log.error(err, 'Error updating experience');
|
||||
void reply.code(500);
|
||||
return { message: 'flash.wrong-updating', type: 'danger' } as const;
|
||||
}
|
||||
@@ -777,7 +859,6 @@ ${isLinkSentWithinLimitTTL}`
|
||||
schema: schemas.updateMyClassroomMode
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
try {
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: req.user?.id },
|
||||
@@ -786,13 +867,20 @@ ${isLinkSentWithinLimitTTL}`
|
||||
}
|
||||
});
|
||||
|
||||
fastify.Sentry?.metrics?.count('settings.classroom_mode_toggled', 1, {
|
||||
attributes: { enabled: req.body.isClassroomAccount }
|
||||
});
|
||||
fastify.Sentry?.metrics?.count('settings.updated', 1, {
|
||||
attributes: { field: 'classroom_mode' }
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'flash.classroom-mode-updated',
|
||||
type: 'success'
|
||||
} as const;
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
fastify.Sentry.captureException(err);
|
||||
fastify.Sentry?.captureException(err);
|
||||
req.log.error(err, 'Error updating classroom mode');
|
||||
void reply.code(500);
|
||||
return { message: 'flash.wrong-updating', type: 'danger' } as const;
|
||||
}
|
||||
@@ -861,9 +949,11 @@ export const settingRedirectRoutes: FastifyPluginCallbackTypebox = (
|
||||
{
|
||||
schema: schemas.confirmEmail,
|
||||
errorHandler(error, request, reply) {
|
||||
const logger = fastify.log.child({ req: request });
|
||||
if (error.validation) {
|
||||
logger.warn({ validationError: error.validation });
|
||||
request.log.warn(
|
||||
{ validationError: error.validation },
|
||||
'Request validation failed'
|
||||
);
|
||||
const { origin } = getRedirectParams(request);
|
||||
void reply.redirectWithMessage(origin, redirectMessage);
|
||||
} else {
|
||||
@@ -872,12 +962,14 @@ export const settingRedirectRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
const email = Buffer.from(req.query.email, 'base64').toString();
|
||||
|
||||
const { origin } = getRedirectParams(req);
|
||||
if (!validator.default.isEmail(email)) {
|
||||
logger.warn(`Invalid email ${email}`);
|
||||
req.log.warn({ userId: req.user?.id }, 'Invalid email format');
|
||||
fastify.Sentry?.metrics?.count('settings.email_confirm_rejected', 1, {
|
||||
attributes: { reason: 'invalid_email' }
|
||||
});
|
||||
return reply.redirectWithMessage(origin, redirectMessage);
|
||||
}
|
||||
|
||||
@@ -886,13 +978,19 @@ export const settingRedirectRoutes: FastifyPluginCallbackTypebox = (
|
||||
});
|
||||
|
||||
if (!authToken) {
|
||||
logger.warn('No token found');
|
||||
req.log.warn('No token found');
|
||||
fastify.Sentry?.metrics?.count('settings.email_confirm_rejected', 1, {
|
||||
attributes: { reason: 'no_token' }
|
||||
});
|
||||
return reply.redirectWithMessage(origin, redirectMessage);
|
||||
}
|
||||
|
||||
// TODO(Post-MVP): clean up expired auth tokens.
|
||||
if (isExpired(authToken)) {
|
||||
logger.info('Token expired');
|
||||
req.log.warn('Token expired');
|
||||
fastify.Sentry?.metrics?.count('settings.email_confirm_rejected', 1, {
|
||||
attributes: { reason: 'expired' }
|
||||
});
|
||||
return reply.redirectWithMessage(origin, expirationMessage);
|
||||
}
|
||||
|
||||
@@ -901,11 +999,17 @@ export const settingRedirectRoutes: FastifyPluginCallbackTypebox = (
|
||||
});
|
||||
|
||||
if (targetUser?.id !== req.user?.id) {
|
||||
logger.warn('Target user does not match signed in user');
|
||||
req.log.warn('Target user does not match signed in user');
|
||||
fastify.Sentry?.metrics?.count('settings.email_confirm_rejected', 1, {
|
||||
attributes: { reason: 'user_mismatch' }
|
||||
});
|
||||
return reply.redirectWithMessage(origin, redirectMessage);
|
||||
}
|
||||
|
||||
if (targetUser?.newEmail !== email) {
|
||||
fastify.Sentry?.metrics?.count('settings.email_confirm_rejected', 1, {
|
||||
attributes: { reason: 'email_mismatch' }
|
||||
});
|
||||
return reply.redirectWithMessage(origin, redirectMessage);
|
||||
}
|
||||
|
||||
@@ -916,6 +1020,8 @@ export const settingRedirectRoutes: FastifyPluginCallbackTypebox = (
|
||||
deleteAuthToken(fastify, { id: authToken.id })
|
||||
]);
|
||||
|
||||
fastify.Sentry?.metrics?.count('settings.email_confirmed', 1);
|
||||
|
||||
return reply.redirectWithMessage(origin, successMessage);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -94,6 +94,14 @@ describe('socratesRoutes', () => {
|
||||
});
|
||||
|
||||
test('should return hint on successful Socrates API response', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const count = vi.fn();
|
||||
const distribution = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count, distribution }
|
||||
};
|
||||
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
@@ -106,12 +114,22 @@ describe('socratesRoutes', () => {
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toStrictEqual({
|
||||
hint: 'Try adding a closing tag.',
|
||||
attempts: 1,
|
||||
limit: 3
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('socrates.hint_granted', 1, {
|
||||
attributes: { donorStatus: 'non-donor' }
|
||||
});
|
||||
expect(distribution).toHaveBeenCalledWith(
|
||||
'socrates.upstream_latency_ms',
|
||||
expect.any(Number),
|
||||
{ unit: 'millisecond', attributes: { result: 'success' } }
|
||||
);
|
||||
});
|
||||
|
||||
test('should pass session userId, not client-supplied userId', async () => {
|
||||
@@ -153,6 +171,13 @@ describe('socratesRoutes', () => {
|
||||
});
|
||||
|
||||
test('should return 429 when Socrates API rate limits', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 429,
|
||||
@@ -162,6 +187,8 @@ describe('socratesRoutes', () => {
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
expect(response.body).toStrictEqual({
|
||||
error: 'socrates-rate-limit',
|
||||
@@ -169,9 +196,19 @@ describe('socratesRoutes', () => {
|
||||
attempts: 0,
|
||||
limit: 3
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('socrates.rate_limit_hit', 1, {
|
||||
attributes: { source: 'upstream', donorStatus: 'non-donor' }
|
||||
});
|
||||
});
|
||||
|
||||
test('should forward upstream error message on 400', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
@@ -184,6 +221,8 @@ describe('socratesRoutes', () => {
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toStrictEqual({
|
||||
error: 'Input too short for analysis.',
|
||||
@@ -191,6 +230,11 @@ describe('socratesRoutes', () => {
|
||||
attempts: 0,
|
||||
limit: 3
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'socrates.upstream_call_failed',
|
||||
1,
|
||||
{ attributes: { reason: 'bad_status' } }
|
||||
);
|
||||
});
|
||||
|
||||
test('should use fallback message on 400 with no upstream error', async () => {
|
||||
@@ -212,7 +256,16 @@ describe('socratesRoutes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('should return 500 on other Socrates API errors', async () => {
|
||||
test('should return 500 and capture on other Socrates API errors', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
@@ -222,6 +275,8 @@ describe('socratesRoutes', () => {
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toStrictEqual({
|
||||
error: 'socrates-unavailable',
|
||||
@@ -229,9 +284,28 @@ describe('socratesRoutes', () => {
|
||||
attempts: 0,
|
||||
limit: 3
|
||||
});
|
||||
expect(captureException).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({
|
||||
message: 'Socrates API returned status 503'
|
||||
})
|
||||
);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'socrates.upstream_call_failed',
|
||||
1,
|
||||
{ attributes: { reason: 'bad_status' } }
|
||||
);
|
||||
});
|
||||
|
||||
test('should return 500 when Socrates API returns invalid JSON', async () => {
|
||||
test('should return 500 and capture when Socrates API returns invalid JSON', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
@@ -241,13 +315,32 @@ describe('socratesRoutes', () => {
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.type).toBe('danger');
|
||||
expect(response.body.attempts).toBe(0);
|
||||
expect(response.body.limit).toBe(3);
|
||||
expect(captureException).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.any(Error)
|
||||
);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'socrates.upstream_call_failed',
|
||||
1,
|
||||
{ attributes: { reason: 'invalid_response' } }
|
||||
);
|
||||
});
|
||||
|
||||
test('should return 500 when Socrates API returns no hint', async () => {
|
||||
test('should return 500 and capture when Socrates API returns no hint', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
@@ -257,10 +350,22 @@ describe('socratesRoutes', () => {
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.type).toBe('danger');
|
||||
expect(response.body.attempts).toBe(0);
|
||||
expect(response.body.limit).toBe(3);
|
||||
expect(captureException).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({
|
||||
message: 'Socrates API did not return a hint'
|
||||
})
|
||||
);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'socrates.upstream_call_failed',
|
||||
1,
|
||||
{ attributes: { reason: 'missing_hint' } }
|
||||
);
|
||||
});
|
||||
|
||||
test('should return 500 when fetch throws', async () => {
|
||||
@@ -277,6 +382,72 @@ describe('socratesRoutes', () => {
|
||||
limit: 3
|
||||
});
|
||||
});
|
||||
|
||||
test('should not capture a fetch network failure', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
const distribution = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException,
|
||||
metrics: { ...originalSentry.metrics, count, distribution }
|
||||
};
|
||||
|
||||
const networkError = Object.assign(new TypeError('fetch failed'), {
|
||||
cause: Object.assign(new Error('connect ECONNREFUSED'), {
|
||||
code: 'ECONNREFUSED'
|
||||
})
|
||||
});
|
||||
mockedFetch.mockRejectedValueOnce(networkError);
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'socrates.upstream_call_failed',
|
||||
1,
|
||||
{ attributes: { reason: 'network' } }
|
||||
);
|
||||
expect(distribution).toHaveBeenCalledWith(
|
||||
'socrates.upstream_latency_ms',
|
||||
expect.any(Number),
|
||||
{ unit: 'millisecond', attributes: { result: 'failure' } }
|
||||
);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('should capture a genuine TypeError bug from the handler', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const bugError = new TypeError(
|
||||
"Cannot read properties of undefined (reading 'foo')"
|
||||
);
|
||||
mockedFetch.mockRejectedValueOnce(bugError);
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledExactlyOnceWith(bugError);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'socrates.upstream_call_failed',
|
||||
1,
|
||||
{ attributes: { reason: 'exception' } }
|
||||
);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
|
||||
describe('daily usage entitlements', () => {
|
||||
@@ -325,6 +496,13 @@ describe('socratesRoutes', () => {
|
||||
});
|
||||
|
||||
test('should return 429 when non-donor exceeds 3 hints/day', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
mockedFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
@@ -338,11 +516,16 @@ describe('socratesRoutes', () => {
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
expect(response.body.attempts).toBe(3);
|
||||
expect(response.body.limit).toBe(3);
|
||||
expect(response.body.error).toBe('socrates-daily-limit');
|
||||
expect(mockedFetch).toHaveBeenCalledTimes(3);
|
||||
expect(count).toHaveBeenCalledWith('socrates.rate_limit_hit', 1, {
|
||||
attributes: { source: 'local', donorStatus: 'non-donor' }
|
||||
});
|
||||
});
|
||||
|
||||
test('should not inflate count beyond limit on repeated 429s', async () => {
|
||||
|
||||
@@ -9,6 +9,29 @@ function getDailyLimit(isDonating: boolean): number {
|
||||
return isDonating ? DAILY_LIMITS.donor : DAILY_LIMITS.nonDonor;
|
||||
}
|
||||
|
||||
const NETWORK_ERROR_CODES = new Set([
|
||||
'ENOTFOUND',
|
||||
'ECONNREFUSED',
|
||||
'ECONNRESET',
|
||||
'ETIMEDOUT',
|
||||
'EAI_AGAIN'
|
||||
]);
|
||||
|
||||
function isFetchNetworkError(error: unknown): boolean {
|
||||
if (!(error instanceof TypeError)) {
|
||||
return false;
|
||||
}
|
||||
const cause = (error as { cause?: unknown }).cause;
|
||||
const code =
|
||||
cause && typeof cause === 'object' && 'code' in cause
|
||||
? (cause as { code?: unknown }).code
|
||||
: undefined;
|
||||
if (typeof code === 'string') {
|
||||
return code.startsWith('UND_ERR_') || NETWORK_ERROR_CODES.has(code);
|
||||
}
|
||||
return error.message === 'fetch failed';
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param fastify The Fastify instance.
|
||||
@@ -49,6 +72,7 @@ export const socratesRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
|
||||
const limit = getDailyLimit(req.user.isDonating);
|
||||
const donorStatus = req.user.isDonating ? 'donor' : 'non-donor';
|
||||
const now = new Date();
|
||||
const todayUTC = new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
|
||||
@@ -61,6 +85,9 @@ export const socratesRoutes: FastifyPluginCallbackTypebox = (
|
||||
});
|
||||
|
||||
if (existing && existing.count >= limit) {
|
||||
fastify.Sentry?.metrics?.count('socrates.rate_limit_hit', 1, {
|
||||
attributes: { source: 'local', donorStatus }
|
||||
});
|
||||
return reply.status(429).send({
|
||||
error: 'socrates-daily-limit',
|
||||
type: 'info',
|
||||
@@ -94,6 +121,8 @@ export const socratesRoutes: FastifyPluginCallbackTypebox = (
|
||||
});
|
||||
};
|
||||
|
||||
const upstreamFetchStart = performance.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${SOCRATES_ENDPOINT}/hint`, {
|
||||
method: 'POST',
|
||||
@@ -110,13 +139,19 @@ export const socratesRoutes: FastifyPluginCallbackTypebox = (
|
||||
})
|
||||
});
|
||||
|
||||
fastify.Sentry?.metrics?.distribution(
|
||||
'socrates.upstream_latency_ms',
|
||||
performance.now() - upstreamFetchStart,
|
||||
{ unit: 'millisecond', attributes: { result: 'success' } }
|
||||
);
|
||||
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
req.log.error(
|
||||
{
|
||||
status: response.status,
|
||||
response: responseText || undefined
|
||||
upstreamBody: responseText.slice(0, 500)
|
||||
},
|
||||
'Socrates API returned an error response.'
|
||||
);
|
||||
@@ -124,6 +159,9 @@ export const socratesRoutes: FastifyPluginCallbackTypebox = (
|
||||
await rollbackUsage();
|
||||
|
||||
if (response.status === 429) {
|
||||
fastify.Sentry?.metrics?.count('socrates.rate_limit_hit', 1, {
|
||||
attributes: { source: 'upstream', donorStatus }
|
||||
});
|
||||
return reply.status(429).send({
|
||||
error: 'socrates-rate-limit',
|
||||
type: 'info',
|
||||
@@ -142,6 +180,9 @@ export const socratesRoutes: FastifyPluginCallbackTypebox = (
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
fastify.Sentry?.metrics?.count('socrates.upstream_call_failed', 1, {
|
||||
attributes: { reason: 'bad_status' }
|
||||
});
|
||||
return reply.status(400).send({
|
||||
error: upstreamMessage || 'socrates-unable-to-generate',
|
||||
type: 'info',
|
||||
@@ -150,6 +191,12 @@ export const socratesRoutes: FastifyPluginCallbackTypebox = (
|
||||
});
|
||||
}
|
||||
|
||||
fastify.Sentry?.captureException(
|
||||
new Error(`Socrates API returned status ${response.status}`)
|
||||
);
|
||||
fastify.Sentry?.metrics?.count('socrates.upstream_call_failed', 1, {
|
||||
attributes: { reason: 'bad_status' }
|
||||
});
|
||||
return reply.status(500).send({
|
||||
error: 'socrates-unavailable',
|
||||
type: 'danger',
|
||||
@@ -162,10 +209,14 @@ export const socratesRoutes: FastifyPluginCallbackTypebox = (
|
||||
try {
|
||||
payload = responseText ? JSON.parse(responseText) : null;
|
||||
} catch (error) {
|
||||
req.log.error({
|
||||
err: error,
|
||||
response: responseText || undefined
|
||||
fastify.Sentry?.captureException(error);
|
||||
fastify.Sentry?.metrics?.count('socrates.upstream_call_failed', 1, {
|
||||
attributes: { reason: 'invalid_response' }
|
||||
});
|
||||
req.log.error(
|
||||
{ err: error },
|
||||
'Failed to parse Socrates API response.'
|
||||
);
|
||||
await rollbackUsage();
|
||||
return reply.status(500).send({
|
||||
error: 'socrates-unavailable',
|
||||
@@ -180,9 +231,16 @@ export const socratesRoutes: FastifyPluginCallbackTypebox = (
|
||||
typeof payload !== 'object' ||
|
||||
typeof (payload as { hint?: unknown }).hint !== 'string'
|
||||
) {
|
||||
fastify.Sentry?.captureException(
|
||||
new Error('Socrates API did not return a hint')
|
||||
);
|
||||
fastify.Sentry?.metrics?.count('socrates.upstream_call_failed', 1, {
|
||||
attributes: { reason: 'missing_hint' }
|
||||
});
|
||||
req.log.error(
|
||||
{
|
||||
response: payload
|
||||
payloadType: payload === null ? 'null' : typeof payload,
|
||||
hintType: typeof (payload as { hint?: unknown } | null)?.hint
|
||||
},
|
||||
'Socrates API did not return a hint.'
|
||||
);
|
||||
@@ -197,8 +255,24 @@ export const socratesRoutes: FastifyPluginCallbackTypebox = (
|
||||
|
||||
const { hint } = payload as { hint: string };
|
||||
|
||||
fastify.Sentry?.metrics?.count('socrates.hint_granted', 1, {
|
||||
attributes: { donorStatus }
|
||||
});
|
||||
return { hint, attempts, limit } as const;
|
||||
} catch (error) {
|
||||
fastify.Sentry?.metrics?.distribution(
|
||||
'socrates.upstream_latency_ms',
|
||||
performance.now() - upstreamFetchStart,
|
||||
{ unit: 'millisecond', attributes: { result: 'failure' } }
|
||||
);
|
||||
if (!isFetchNetworkError(error)) {
|
||||
fastify.Sentry?.captureException(error);
|
||||
}
|
||||
fastify.Sentry?.metrics?.count('socrates.upstream_call_failed', 1, {
|
||||
attributes: {
|
||||
reason: isFetchNetworkError(error) ? 'network' : 'exception'
|
||||
}
|
||||
});
|
||||
req.log.error(
|
||||
{ err: error },
|
||||
'Failed to fetch hint from Socrates API.'
|
||||
|
||||
@@ -439,6 +439,13 @@ describe('userRoutes', () => {
|
||||
});
|
||||
|
||||
test('POST returns 200 status code with empty object', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const initialCount = await fastifyTestInstance.prisma.user.count();
|
||||
const response = await superPost('/account/delete');
|
||||
const finalCount = await fastifyTestInstance.prisma.user.count();
|
||||
@@ -450,6 +457,36 @@ describe('userRoutes', () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(finalCount).toBe(initialCount - 1);
|
||||
expect(deletedUser).toBeNull();
|
||||
expect(count).toHaveBeenCalledWith('account.deleted', 1, {
|
||||
attributes: { endpoint: '/account/delete' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('POST emits account.deleted_while_donating when a donating user is deleted', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
await fastifyTestInstance.prisma.user.updateMany({
|
||||
where: { email: testUserData.email },
|
||||
data: { isDonating: true }
|
||||
});
|
||||
|
||||
const response = await superPost('/account/delete');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'account.deleted_while_donating',
|
||||
1,
|
||||
{ attributes: { endpoint: '/account/delete' } }
|
||||
);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('POST deletes Microsoft usernames associated with the user', async () => {
|
||||
@@ -575,11 +612,9 @@ describe('userRoutes', () => {
|
||||
superPost('/account/delete')
|
||||
);
|
||||
await Promise.all(deletePromises);
|
||||
const messages: string[] = spy.mock.calls.map(call =>
|
||||
call.map(part => String(part)).join(' ')
|
||||
);
|
||||
const found = messages.some(m =>
|
||||
m.includes(`User with id ${defaultUserId} not found for deletion.`)
|
||||
// userId is auto-bound onto req.log by the auth plugin, not passed explicitly.
|
||||
const found = spy.mock.calls.some(
|
||||
([firstArg]) => firstArg === 'User not found for deletion'
|
||||
);
|
||||
expect(found).toBe(true);
|
||||
});
|
||||
@@ -597,6 +632,13 @@ describe('userRoutes', () => {
|
||||
});
|
||||
|
||||
test('DELETE returns 204 status code with empty object', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superDelete(`/users/${defaultUserId}`);
|
||||
const userCount = await fastifyTestInstance.prisma.user.count({
|
||||
where: { email: testUserData.email }
|
||||
@@ -605,6 +647,36 @@ describe('userRoutes', () => {
|
||||
expect(response.body).toStrictEqual({});
|
||||
expect(response.status).toBe(204);
|
||||
expect(userCount).toBe(0);
|
||||
expect(count).toHaveBeenCalledWith('account.deleted', 1, {
|
||||
attributes: { endpoint: '/users/:userId' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('DELETE emits account.deleted_while_donating when a donating user is deleted', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
await fastifyTestInstance.prisma.user.updateMany({
|
||||
where: { email: testUserData.email },
|
||||
data: { isDonating: true }
|
||||
});
|
||||
|
||||
const response = await superDelete(`/users/${defaultUserId}`);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'account.deleted_while_donating',
|
||||
1,
|
||||
{ attributes: { endpoint: '/users/:userId' } }
|
||||
);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('DELETE deletes Microsoft usernames associated with the user', async () => {
|
||||
@@ -705,12 +777,11 @@ describe('userRoutes', () => {
|
||||
|
||||
await Promise.all(deletePromises);
|
||||
|
||||
const messages = spy.mock.calls.flat().map(String);
|
||||
expect(
|
||||
messages.some(m =>
|
||||
m.includes(`User with id ${defaultUserId} not found for deletion.`)
|
||||
)
|
||||
).toBe(true);
|
||||
// userId is auto-bound onto req.log by the auth plugin, not passed explicitly.
|
||||
const found = spy.mock.calls.some(
|
||||
([firstArg]) => firstArg === 'User not found for deletion'
|
||||
);
|
||||
expect(found).toBe(true);
|
||||
});
|
||||
|
||||
test('returns 403 if attempting to delete a different user', async () => {
|
||||
@@ -747,6 +818,21 @@ describe('userRoutes', () => {
|
||||
expect(user).toMatchObject(baseProgressData);
|
||||
});
|
||||
|
||||
test('POST emits account.progress_reset metric', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
await superPost('/account/reset-progress');
|
||||
|
||||
expect(count).toHaveBeenCalledWith('account.progress_reset', 1);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('POST deletes Microsoft usernames associated with the user', async () => {
|
||||
await fastifyTestInstance.prisma.msUsername.createMany({
|
||||
data: msUsernameData
|
||||
@@ -1241,6 +1327,47 @@ describe('userRoutes', () => {
|
||||
expect(response.statusCode).toBe(500);
|
||||
});
|
||||
|
||||
test('GET captures an exception if the username is missing', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
|
||||
await fastifyTestInstance.prisma.user.updateMany({
|
||||
where: { email: testUserData.email },
|
||||
data: { username: '' }
|
||||
});
|
||||
|
||||
const response = await superGet('/user/session-user');
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('GET captures unexpected errors', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
const spy = vi
|
||||
.spyOn(fastifyTestInstance.prisma.survey, 'findMany')
|
||||
.mockRejectedValueOnce(new Error('DB error'));
|
||||
|
||||
const response = await superGet('/user/session-user');
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
|
||||
spy.mockRestore();
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
// This should help debugging, since this the route returns this if
|
||||
// anything throws in the handler.
|
||||
test('GET does not return the error response if the request is valid', async () => {
|
||||
@@ -1428,6 +1555,13 @@ describe('userRoutes', () => {
|
||||
});
|
||||
|
||||
test('POST returns 400 for empty username', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPost('/user/report-user').send({
|
||||
username: '',
|
||||
reportDescription: 'Test Report'
|
||||
@@ -1438,6 +1572,11 @@ describe('userRoutes', () => {
|
||||
type: 'danger',
|
||||
message: 'flash.report-error'
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('user.report_submitted', 1, {
|
||||
attributes: { result: 'not_found' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('POST returns 400 for empty report', async () => {
|
||||
@@ -1449,7 +1588,42 @@ describe('userRoutes', () => {
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
test('POST captures unexpected errors when looking up the reported user', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
const spy = vi
|
||||
.spyOn(fastifyTestInstance.prisma.user, 'findMany')
|
||||
.mockRejectedValueOnce(new Error('DB error'));
|
||||
|
||||
const response = await superPost('/user/report-user').send({
|
||||
username: testUserData.username,
|
||||
reportDescription: 'Test Report'
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
expect(count).toHaveBeenCalledWith('user.report_submitted', 1, {
|
||||
attributes: { result: 'lookup_error' }
|
||||
});
|
||||
|
||||
spy.mockRestore();
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('POST returns 403 for users with no email', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
await fastifyTestInstance.prisma.user.updateMany({
|
||||
where: { email: testUserData.email },
|
||||
data: { email: null }
|
||||
@@ -1465,6 +1639,11 @@ describe('userRoutes', () => {
|
||||
type: 'danger',
|
||||
message: 'flash.report-error'
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('user.report_submitted', 1, {
|
||||
attributes: { result: 'no_email' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('POST sanitises report description', async () => {
|
||||
@@ -1485,6 +1664,13 @@ describe('userRoutes', () => {
|
||||
});
|
||||
|
||||
test('POST returns 200 status code with "success" message', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const testUser = await fastifyTestInstance.prisma.user.findFirstOrThrow(
|
||||
{
|
||||
where: { email: testUserData.email }
|
||||
@@ -1527,6 +1713,11 @@ Thanks and regards,
|
||||
message: 'flash.report-sent',
|
||||
variables: { email: 'foo@bar.com' }
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('user.report_submitted', 1, {
|
||||
attributes: { result: 'success' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1571,6 +1762,26 @@ Thanks and regards,
|
||||
|
||||
expect(msUsernames).toBe(1);
|
||||
});
|
||||
|
||||
test('captures unexpected errors', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
const spy = vi
|
||||
.spyOn(fastifyTestInstance.prisma.msUsername, 'deleteMany')
|
||||
.mockRejectedValueOnce(new Error('DB error'));
|
||||
|
||||
const response = await superDelete('/user/ms-username');
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
|
||||
spy.mockRestore();
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST', () => {
|
||||
@@ -1599,6 +1810,13 @@ Thanks and regards,
|
||||
});
|
||||
|
||||
test('handles invalid transcript urls', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPost('/user/ms-username').send({
|
||||
msTranscriptUrl: 'https://www.example.com'
|
||||
});
|
||||
@@ -1608,9 +1826,77 @@ Thanks and regards,
|
||||
message: 'flash.ms.transcript.link-err-1'
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(count).toHaveBeenCalledWith('ms_username.link_completed', 1, {
|
||||
attributes: { result: 'invalid_url' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('emits ms_username.link_completed with result fetch_failed when the Microsoft API request fails', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
mockedFetch.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 404
|
||||
})
|
||||
);
|
||||
|
||||
const response = await superPost('/user/ms-username').send({
|
||||
msTranscriptUrl:
|
||||
'https://learn.microsoft.com/en-us/users/mot01/transcript/8u6awert43q1plo'
|
||||
});
|
||||
|
||||
expect(response.body).toStrictEqual({
|
||||
type: 'error',
|
||||
message: 'flash.ms.transcript.link-err-2'
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(count).toHaveBeenCalledWith('ms_username.link_completed', 1, {
|
||||
attributes: { result: 'fetch_failed' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('emits ms_username.transcript_fetch_latency_ms distribution when the Microsoft API request throws', async () => {
|
||||
const distribution = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, distribution }
|
||||
};
|
||||
mockedFetch.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error('network error'))
|
||||
);
|
||||
|
||||
const response = await superPost('/user/ms-username').send({
|
||||
msTranscriptUrl:
|
||||
'https://learn.microsoft.com/en-us/users/mot01/transcript/8u6awert43q1plo'
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(distribution).toHaveBeenCalledWith(
|
||||
'ms_username.transcript_fetch_latency_ms',
|
||||
expect.any(Number),
|
||||
{ unit: 'millisecond' }
|
||||
);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('handles the case that MS does not return a username', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
mockedFetch.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
@@ -1628,9 +1914,20 @@ Thanks and regards,
|
||||
message: 'flash.ms.transcript.link-err-3'
|
||||
});
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(count).toHaveBeenCalledWith('ms_username.link_completed', 1, {
|
||||
attributes: { result: 'missing_username' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('handles duplicate Microsoft usernames', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
mockedFetch.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
@@ -1660,9 +1957,21 @@ Thanks and regards,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(count).toHaveBeenCalledWith('ms_username.link_completed', 1, {
|
||||
attributes: { result: 'username_taken' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('returns the username on success', async () => {
|
||||
const count = vi.fn();
|
||||
const distribution = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count, distribution }
|
||||
};
|
||||
const msUsername = 'ms-user';
|
||||
mockedFetch.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
@@ -1682,6 +1991,16 @@ Thanks and regards,
|
||||
msUsername
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(count).toHaveBeenCalledWith('ms_username.link_completed', 1, {
|
||||
attributes: { result: 'success' }
|
||||
});
|
||||
expect(distribution).toHaveBeenCalledWith(
|
||||
'ms_username.transcript_fetch_latency_ms',
|
||||
expect.any(Number),
|
||||
{ unit: 'millisecond' }
|
||||
);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('creates a record of the linked account', async () => {
|
||||
@@ -1774,6 +2093,40 @@ Thanks and regards,
|
||||
|
||||
expect(mockedFetch).toHaveBeenCalledWith(msTranscriptApiUrl);
|
||||
});
|
||||
|
||||
test('captures unexpected errors', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
mockedFetch.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ userName: 'super-user' })
|
||||
})
|
||||
);
|
||||
const spy = vi
|
||||
.spyOn(fastifyTestInstance.prisma.msUsername, 'create')
|
||||
.mockRejectedValueOnce(new Error('DB error'));
|
||||
|
||||
const response = await superPost('/user/ms-username').send({
|
||||
msTranscriptUrl:
|
||||
'https://learn.microsoft.com/en-us/users/mot01/transcript/12345'
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
expect(count).toHaveBeenCalledWith('ms_username.link_completed', 1, {
|
||||
attributes: { result: 'error' }
|
||||
});
|
||||
|
||||
spy.mockRestore();
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1815,6 +2168,13 @@ Thanks and regards,
|
||||
});
|
||||
|
||||
test('POST returns 200 status code with "success" message', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPost('/user/submit-survey').send({
|
||||
surveyResults: mockSurveyResults
|
||||
});
|
||||
@@ -1824,6 +2184,33 @@ Thanks and regards,
|
||||
type: 'success',
|
||||
message: 'flash.survey.success'
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('survey.submitted', 1, {
|
||||
attributes: { surveyTitle: mockSurveyResults.title }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('POST captures unexpected errors', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
const spy = vi
|
||||
.spyOn(fastifyTestInstance.prisma.survey, 'create')
|
||||
.mockRejectedValueOnce(new Error('DB error'));
|
||||
|
||||
const response = await superPost('/user/submit-survey').send({
|
||||
surveyResults: mockSurveyResults
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
|
||||
spy.mockRestore();
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1845,6 +2232,13 @@ Thanks and regards,
|
||||
});
|
||||
|
||||
test('POST generates a new token if one does not exist', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
mockDeploymentEnv = 'production';
|
||||
const response = await superPost('/user/exam-environment/token');
|
||||
const { examEnvironmentAuthorizationToken } = response.body;
|
||||
@@ -1865,6 +2259,9 @@ Thanks and regards,
|
||||
).not.toThrow();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(count).toHaveBeenCalledWith('exam.token_minted', 1);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('POST only allows for one token per user id', async () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import type { FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||
import { ObjectId } from 'bson';
|
||||
import { FastifyInstance, FastifyReply } from 'fastify';
|
||||
@@ -87,8 +88,7 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
schema: schemas.deleteMyAccount
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
logger.info(`User ${req.user?.id} requested account deletion`);
|
||||
req.log.info({ audit: true }, 'User requested account deletion');
|
||||
await fastify.prisma.userToken.deleteMany({
|
||||
where: { userId: req.user!.id }
|
||||
});
|
||||
@@ -99,20 +99,29 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
where: { userId: req.user!.id }
|
||||
});
|
||||
try {
|
||||
const userBeforeDelete = await fastify.prisma.user.findUnique({
|
||||
where: { id: req.user!.id },
|
||||
select: { isDonating: true }
|
||||
});
|
||||
if (userBeforeDelete?.isDonating) {
|
||||
fastify.Sentry?.metrics?.count('account.deleted_while_donating', 1, {
|
||||
attributes: { endpoint: '/account/delete' }
|
||||
});
|
||||
}
|
||||
await fastify.prisma.user.delete({
|
||||
where: { id: req.user!.id }
|
||||
});
|
||||
fastify.Sentry?.metrics?.count('account.deleted', 1, {
|
||||
attributes: { endpoint: '/account/delete' }
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof PrismaClientKnownRequestError &&
|
||||
err.code === 'P2025'
|
||||
) {
|
||||
logger.warn(
|
||||
err,
|
||||
`User with id ${req.user?.id} not found for deletion.`
|
||||
);
|
||||
req.log.warn('User not found for deletion');
|
||||
} else {
|
||||
logger.error(err, 'Error deleting user account');
|
||||
req.log.error(err, 'Error deleting user account');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -128,19 +137,18 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
schema: schemas.deleteUser
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
const { userId } = req.params;
|
||||
|
||||
if (userId !== req.user?.id) {
|
||||
logger.warn(
|
||||
{ requestedUserId: userId, authUserId: req.user?.id },
|
||||
req.log.warn(
|
||||
{ requestedUserId: userId },
|
||||
'User attempted to delete an account they do not have authorization to.'
|
||||
);
|
||||
void reply.code(403);
|
||||
return { type: 'error', message: 'forbidden' } as const;
|
||||
}
|
||||
|
||||
logger.info(`User ${req.user.id} requested account deletion`);
|
||||
req.log.info({ audit: true }, 'User requested account deletion');
|
||||
try {
|
||||
await fastify.prisma.userToken.deleteMany({
|
||||
where: { userId: req.user.id }
|
||||
@@ -151,22 +159,31 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
await fastify.prisma.survey.deleteMany({
|
||||
where: { userId: req.user.id }
|
||||
});
|
||||
const userBeforeDelete = await fastify.prisma.user.findUnique({
|
||||
where: { id: req.user.id },
|
||||
select: { isDonating: true }
|
||||
});
|
||||
if (userBeforeDelete?.isDonating) {
|
||||
fastify.Sentry?.metrics?.count('account.deleted_while_donating', 1, {
|
||||
attributes: { endpoint: '/users/:userId' }
|
||||
});
|
||||
}
|
||||
await fastify.prisma.user.delete({
|
||||
where: { id: req.user.id }
|
||||
});
|
||||
fastify.Sentry?.metrics?.count('account.deleted', 1, {
|
||||
attributes: { endpoint: '/users/:userId' }
|
||||
});
|
||||
} catch (err) {
|
||||
// Whilst this is behind auth, this should never happen
|
||||
if (
|
||||
err instanceof PrismaClientKnownRequestError &&
|
||||
err.code === 'P2025'
|
||||
) {
|
||||
logger.warn(
|
||||
err,
|
||||
`User with id ${req.user?.id} not found for deletion.`
|
||||
);
|
||||
req.log.warn('User not found for deletion');
|
||||
return reply.code(404).send({ type: 'error', message: 'not found' });
|
||||
} else {
|
||||
logger.error(err, 'Error deleting user account');
|
||||
req.log.error(err, 'Error deleting user account');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -181,9 +198,8 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
{
|
||||
schema: schemas.resetMyProgress
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
logger.info(`User ${req.user?.id} requested progress reset`);
|
||||
async (req, _reply) => {
|
||||
req.log.info({ audit: true }, 'User requested progress reset');
|
||||
await fastify.prisma.userToken.deleteMany({
|
||||
where: { userId: req.user!.id }
|
||||
});
|
||||
@@ -198,6 +214,8 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
data: createResetProperties()
|
||||
});
|
||||
|
||||
fastify.Sentry?.metrics?.count('account.progress_reset', 1);
|
||||
|
||||
return {};
|
||||
}
|
||||
);
|
||||
@@ -210,9 +228,8 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
deleteResetModule
|
||||
);
|
||||
// TODO(Post-MVP): POST -> PUT
|
||||
fastify.post('/user/user-token', async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
logger.info(`User ${req.user?.id} requested a new user token`);
|
||||
fastify.post('/user/user-token', async (req, _reply) => {
|
||||
req.log.info({ audit: true }, 'User requested a new user token');
|
||||
|
||||
await fastify.prisma.userToken.deleteMany({
|
||||
where: { userId: req.user?.id }
|
||||
@@ -239,15 +256,14 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
schema: schemas.deleteUserToken
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
logger.info(`User ${req.user?.id} requested token deletion`);
|
||||
req.log.info({ audit: true }, 'User requested token deletion');
|
||||
|
||||
const { count } = await fastify.prisma.userToken.deleteMany({
|
||||
where: { userId: req.user?.id }
|
||||
});
|
||||
|
||||
if (count === 0) {
|
||||
logger.warn('No userToken found for deletion');
|
||||
req.log.warn('No userToken found for deletion');
|
||||
void reply.code(404);
|
||||
return {
|
||||
message: 'userToken not found',
|
||||
@@ -268,16 +284,21 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
logger.info(`User ${req.user?.id} reported user ${req.body.username}`);
|
||||
req.log.info(
|
||||
{ reportedUsername: req.body.username },
|
||||
'User reported another user'
|
||||
);
|
||||
|
||||
const user = await fastify.prisma.user.findUniqueOrThrow({
|
||||
where: { id: req.user?.id }
|
||||
});
|
||||
|
||||
if (!user.email) {
|
||||
logger.warn('User has no email');
|
||||
req.log.warn('User has no email');
|
||||
void reply.code(403);
|
||||
fastify.Sentry?.metrics?.count('user.report_submitted', 1, {
|
||||
attributes: { result: 'no_email' }
|
||||
});
|
||||
return reply.send({
|
||||
type: 'danger',
|
||||
message: 'flash.report-error'
|
||||
@@ -294,12 +315,15 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
);
|
||||
|
||||
if (maybeReportedUsers.hasError) {
|
||||
logger.error(
|
||||
{ error: maybeReportedUsers.error, username },
|
||||
req.log.error(
|
||||
{ err: maybeReportedUsers.error, username },
|
||||
'Error finding reported user.'
|
||||
);
|
||||
fastify.Sentry.captureException(maybeReportedUsers.error);
|
||||
fastify.Sentry?.captureException(maybeReportedUsers.error);
|
||||
void reply.code(500);
|
||||
fastify.Sentry?.metrics?.count('user.report_submitted', 1, {
|
||||
attributes: { result: 'lookup_error' }
|
||||
});
|
||||
return {
|
||||
type: 'danger',
|
||||
message: 'flash.generic-error'
|
||||
@@ -309,8 +333,11 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
const reportedUsers = maybeReportedUsers.data;
|
||||
|
||||
if (reportedUsers.length !== 1) {
|
||||
logger.warn({ username }, 'Reported user not found');
|
||||
req.log.warn({ username }, 'Reported user not found');
|
||||
void reply.code(404);
|
||||
fastify.Sentry?.metrics?.count('user.report_submitted', 1, {
|
||||
attributes: { result: 'not_found' }
|
||||
});
|
||||
return {
|
||||
type: 'danger',
|
||||
message: 'flash.report-error'
|
||||
@@ -327,6 +354,10 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
text: generateReportEmail(user, reportedUser, report)
|
||||
});
|
||||
|
||||
fastify.Sentry?.metrics?.count('user.report_submitted', 1, {
|
||||
attributes: { result: 'success' }
|
||||
});
|
||||
|
||||
reply.send({
|
||||
type: 'info',
|
||||
message: 'flash.report-sent',
|
||||
@@ -341,8 +372,7 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
schema: schemas.deleteMsUsername
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
logger.info(`User ${req.user?.id} requested unlinking of msUsername`);
|
||||
req.log.info({ audit: true }, 'User requested unlinking of msUsername');
|
||||
|
||||
try {
|
||||
await fastify.prisma.msUsername.deleteMany({
|
||||
@@ -352,8 +382,8 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
// TODO(Post-MVP): return a generic success message.
|
||||
return { msUsername: null };
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
fastify.Sentry.captureException(err);
|
||||
fastify.Sentry?.captureException(err);
|
||||
req.log.error(err, 'Error unlinking msUsername');
|
||||
void reply.code(500);
|
||||
void reply.send({
|
||||
message: 'flash.ms.transcript.unlink-err',
|
||||
@@ -368,9 +398,11 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
{
|
||||
schema: schemas.postMsUsername,
|
||||
errorHandler(error, req, reply) {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
if (error.validation) {
|
||||
logger.warn({ validationError: error.validation });
|
||||
req.log.warn(
|
||||
{ validationError: error.validation },
|
||||
'Request validation failed'
|
||||
);
|
||||
void reply.code(400).send({
|
||||
message: 'flash.ms.transcript.link-err-1',
|
||||
type: 'error'
|
||||
@@ -381,10 +413,7 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
logger.info(
|
||||
`User ${req.user?.id} requested linking of msUsername "${req.body.msTranscriptUrl}"`
|
||||
);
|
||||
req.log.info({ audit: true }, 'User requested linking of msUsername');
|
||||
|
||||
try {
|
||||
const user = await fastify.prisma.user.findUniqueOrThrow({
|
||||
@@ -396,10 +425,10 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
);
|
||||
|
||||
if (maybeTranscriptUrl.error !== null) {
|
||||
logger.warn(
|
||||
{ error: maybeTranscriptUrl.error },
|
||||
'Unable to parse Microsoft transcript URL'
|
||||
);
|
||||
req.log.warn('Unable to parse Microsoft transcript URL');
|
||||
fastify.Sentry?.metrics?.count('ms_username.link_completed', 1, {
|
||||
attributes: { result: 'invalid_url' }
|
||||
});
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ type: 'error', message: 'flash.ms.transcript.link-err-1' });
|
||||
@@ -407,13 +436,29 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
|
||||
const transcriptUrl = maybeTranscriptUrl.data;
|
||||
|
||||
const msApiRes = await fetch(transcriptUrl);
|
||||
const startTime = performance.now();
|
||||
const msApiRes = await fetch(transcriptUrl).catch(err => {
|
||||
fastify.Sentry?.metrics?.distribution(
|
||||
'ms_username.transcript_fetch_latency_ms',
|
||||
performance.now() - startTime,
|
||||
{ unit: 'millisecond' }
|
||||
);
|
||||
throw err;
|
||||
});
|
||||
fastify.Sentry?.metrics?.distribution(
|
||||
'ms_username.transcript_fetch_latency_ms',
|
||||
performance.now() - startTime,
|
||||
{ unit: 'millisecond' }
|
||||
);
|
||||
|
||||
if (!msApiRes.ok) {
|
||||
logger.warn(
|
||||
req.log.warn(
|
||||
{ status: msApiRes.status },
|
||||
"Unable to fetch user's Microsoft transcript"
|
||||
);
|
||||
fastify.Sentry?.metrics?.count('ms_username.link_completed', 1, {
|
||||
attributes: { result: 'fetch_failed' }
|
||||
});
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ type: 'error', message: 'flash.ms.transcript.link-err-2' });
|
||||
@@ -422,7 +467,13 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
const { userName } = (await msApiRes.json()) as { userName: string };
|
||||
|
||||
if (!userName) {
|
||||
logger.warn('No userName found in msApiRes');
|
||||
fastify.Sentry?.captureException(
|
||||
new Error('No userName found in Microsoft transcript response')
|
||||
);
|
||||
req.log.error('No userName found in Microsoft transcript response');
|
||||
fastify.Sentry?.metrics?.count('ms_username.link_completed', 1, {
|
||||
attributes: { result: 'missing_username' }
|
||||
});
|
||||
return reply.status(500).send({
|
||||
type: 'error',
|
||||
message: 'flash.ms.transcript.link-err-3'
|
||||
@@ -438,7 +489,10 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
}));
|
||||
|
||||
if (usernameUsed) {
|
||||
logger.warn('msUsername already in use');
|
||||
req.log.warn('msUsername already in use');
|
||||
fastify.Sentry?.metrics?.count('ms_username.link_completed', 1, {
|
||||
attributes: { result: 'username_taken' }
|
||||
});
|
||||
return reply.status(403).send({
|
||||
type: 'error',
|
||||
message: 'flash.ms.transcript.link-err-4'
|
||||
@@ -465,10 +519,17 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
});
|
||||
|
||||
fastify.Sentry?.metrics?.count('ms_username.link_completed', 1, {
|
||||
attributes: { result: 'success' }
|
||||
});
|
||||
|
||||
return { msUsername: userName };
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
fastify.Sentry.captureException(err);
|
||||
fastify.Sentry?.captureException(err);
|
||||
req.log.error(err, 'Error linking msUsername');
|
||||
fastify.Sentry?.metrics?.count('ms_username.link_completed', 1, {
|
||||
attributes: { result: 'error' }
|
||||
});
|
||||
return reply.code(500).send({
|
||||
type: 'error',
|
||||
message: 'flash.ms.transcript.link-err-6'
|
||||
@@ -493,8 +554,7 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
logger.info(`User ${req.user?.id} submitted a survey`);
|
||||
req.log.info('User submitted a survey');
|
||||
try {
|
||||
const user = await fastify.prisma.user.findUniqueOrThrow({
|
||||
where: { id: req.user?.id }
|
||||
@@ -510,7 +570,7 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
s => s.title === title
|
||||
);
|
||||
if (surveyAlreadyTaken) {
|
||||
logger.warn('Survey already taken');
|
||||
req.log.warn('Survey already taken');
|
||||
return reply.code(400).send({
|
||||
type: 'error',
|
||||
message: 'flash.survey.err-2'
|
||||
@@ -526,13 +586,17 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
data: newSurvey
|
||||
});
|
||||
|
||||
fastify.Sentry?.metrics?.count('survey.submitted', 1, {
|
||||
attributes: { surveyTitle: title }
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'success',
|
||||
message: 'flash.survey.success'
|
||||
} as const;
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
fastify.Sentry.captureException(err);
|
||||
fastify.Sentry?.captureException(err);
|
||||
req.log.error(err, 'Error submitting survey');
|
||||
void reply.code(500);
|
||||
return {
|
||||
type: 'error',
|
||||
@@ -595,11 +659,10 @@ async function deleteResetModule(
|
||||
req: UpdateReqType<typeof schemas.resetModule>,
|
||||
reply: UpdateReplyType<typeof schemas.resetModule>
|
||||
) {
|
||||
const logger = this.log.child({ req, res: reply });
|
||||
|
||||
const { blockIds } = req.body;
|
||||
logger.info(
|
||||
`User ${req.user?.id} requested module reset for blocks: ${blockIds.join(', ')}`
|
||||
req.log.info(
|
||||
{ audit: true, blockIds },
|
||||
'User requested module reset for blocks'
|
||||
);
|
||||
|
||||
const resetSet = new Set(blockIds.flatMap(getChallengeIdsByBlock));
|
||||
@@ -654,8 +717,7 @@ async function examEnvironmentTokenHandler(
|
||||
req: UpdateReqType<typeof schemas.userExamEnvironmentToken>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const logger = this.log.child({ req });
|
||||
logger.info(`User ${req.user?.id} requested a new exam environment token`);
|
||||
req.log.info({ audit: true }, 'User requested a new exam environment token');
|
||||
const userId = req.user?.id;
|
||||
if (!userId) {
|
||||
throw new Error('Unreachable. User should be authenticated.');
|
||||
@@ -667,8 +729,9 @@ async function examEnvironmentTokenHandler(
|
||||
(!req.user?.email?.endsWith('@freecodecamp.org') ||
|
||||
!req.user?.emailVerified)
|
||||
) {
|
||||
logger.info(
|
||||
`User not allowed to generate authorization token on ${DEPLOYMENT_ENV}.`
|
||||
req.log.warn(
|
||||
{ deploymentEnv: DEPLOYMENT_ENV },
|
||||
'User not allowed to generate authorization token'
|
||||
);
|
||||
void reply.code(403);
|
||||
return reply.send(
|
||||
@@ -694,6 +757,8 @@ async function examEnvironmentTokenHandler(
|
||||
}
|
||||
});
|
||||
|
||||
this.Sentry?.metrics?.count('exam.token_minted', 1);
|
||||
|
||||
const examEnvironmentAuthorizationToken = jwt.sign(
|
||||
{ examEnvironmentAuthorizationToken: token.id },
|
||||
JWT_SECRET
|
||||
@@ -722,15 +787,14 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = (
|
||||
req: UpdateReqType<typeof schemas.getSessionUser>,
|
||||
res: FastifyReply
|
||||
) => {
|
||||
const logger = fastify.log.child({ req, res });
|
||||
// This is one of the most requested routes. To avoid spamming the logs
|
||||
// with this route, we'll log requests at the debug level.
|
||||
logger.debug({ userId: req.user?.id });
|
||||
req.log.debug('User requested session');
|
||||
|
||||
// Handle unauthenticated users - this is not an error, it's how the client
|
||||
// determines if they are signed in or not
|
||||
if (!req.user?.id) {
|
||||
logger.debug('Unauthenticated user requested session');
|
||||
req.log.debug('Unauthenticated user requested session');
|
||||
return { user: {}, result: '' };
|
||||
}
|
||||
|
||||
@@ -825,7 +889,8 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = (
|
||||
);
|
||||
|
||||
if (!user?.username) {
|
||||
logger.error(`User ${req.user?.id} has no username`);
|
||||
fastify.Sentry?.captureException(new Error('User has no username'));
|
||||
req.log.error('User has no username');
|
||||
void res.code(500);
|
||||
return { user: {}, result: '' };
|
||||
}
|
||||
@@ -898,8 +963,8 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = (
|
||||
result: user.username
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
fastify.Sentry.captureException(err);
|
||||
fastify.Sentry?.captureException(err);
|
||||
req.log.error(err, 'Error fetching session user');
|
||||
void res.code(500);
|
||||
return { user: {}, result: '' };
|
||||
}
|
||||
@@ -921,8 +986,7 @@ async function getExamEnvironmentToken(
|
||||
req: UpdateReqType<typeof schemas.getUserExamEnvironmentToken>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const logger = this.log.child({ req, res: reply });
|
||||
logger.info(`User ${req.user?.id} requested their exam environment token`);
|
||||
req.log.info('User requested their exam environment token');
|
||||
const userId = req.user?.id;
|
||||
if (!userId) {
|
||||
throw new Error('Unreachable. User should be authenticated.');
|
||||
|
||||
@@ -65,6 +65,13 @@ describe('auth0 routes', () => {
|
||||
|
||||
it('should return 401 if the authorization header is invalid', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(mockAuth0NotOk());
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superGet('/mobile-login').set(
|
||||
'Authorization',
|
||||
'Bearer invalid-token'
|
||||
@@ -75,10 +82,22 @@ describe('auth0 routes', () => {
|
||||
message: 'We could not log you in, please try again in a moment.'
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(count).toHaveBeenCalledWith('auth.mobile_login_attempted', 1, {
|
||||
attributes: { result: 'failure', reason: 'no_email' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('should return 400 if the email is not valid', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(mockAuth0InvalidEmail());
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superGet('/mobile-login').set(
|
||||
'Authorization',
|
||||
'Bearer valid-token'
|
||||
@@ -89,10 +108,22 @@ describe('auth0 routes', () => {
|
||||
message: 'The email is incorrectly formatted'
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(count).toHaveBeenCalledWith('auth.mobile_login_attempted', 1, {
|
||||
attributes: { result: 'failure', reason: 'invalid_format' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('should set the jwt_access_token cookie if the authorization header is valid', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(mockAuth0ValidEmail());
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superGet('/mobile-login').set(
|
||||
'Authorization',
|
||||
'Bearer valid-token'
|
||||
@@ -102,6 +133,11 @@ describe('auth0 routes', () => {
|
||||
expect(res.get('Set-Cookie')).toEqual(
|
||||
expect.arrayContaining([expect.stringMatching(/jwt_access_token=/)])
|
||||
);
|
||||
expect(count).toHaveBeenCalledWith('auth.mobile_login_attempted', 1, {
|
||||
attributes: { result: 'success' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('should create a user if they do not exist', async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AUTH0_DOMAIN } from '../../utils/env.js';
|
||||
import { auth0Client } from '../../plugins/auth0.js';
|
||||
import { createAccessToken } from '../../utils/tokens.js';
|
||||
import { findOrCreateUser } from '../helpers/auth-helpers.js';
|
||||
import { clientNetInfo } from '../../utils/logger.js';
|
||||
|
||||
const getEmailFromAuth0 = async (
|
||||
req: FastifyRequest
|
||||
@@ -15,7 +16,10 @@ const getEmailFromAuth0 = async (
|
||||
}
|
||||
});
|
||||
|
||||
if (!auth0Res.ok) return null;
|
||||
if (!auth0Res.ok) {
|
||||
req.log.warn({ status: auth0Res.status }, 'Auth0 userinfo request failed');
|
||||
return null;
|
||||
}
|
||||
|
||||
// For now, we assume the response is a JSON object. If not, we can't proceed
|
||||
// and the only safe thing to do is to throw.
|
||||
@@ -43,12 +47,17 @@ export const mobileAuth0Routes: FastifyPluginCallback = (
|
||||
fastify.get('/mobile-login', async (req, reply) => {
|
||||
const email = await getEmailFromAuth0(req);
|
||||
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
|
||||
logger.info('Mobile app login attempt');
|
||||
req.log.debug('Mobile app login attempt');
|
||||
|
||||
if (!email) {
|
||||
logger.error('Could not get email from Auth0 to log in');
|
||||
req.log.error(
|
||||
clientNetInfo(req),
|
||||
'Could not get email from Auth0 to log in'
|
||||
);
|
||||
|
||||
fastify.Sentry?.metrics?.count('auth.mobile_login_attempted', 1, {
|
||||
attributes: { result: 'failure', reason: 'no_email' }
|
||||
});
|
||||
|
||||
return reply.status(401).send({
|
||||
message: 'We could not log you in, please try again in a moment.',
|
||||
@@ -56,7 +65,14 @@ export const mobileAuth0Routes: FastifyPluginCallback = (
|
||||
});
|
||||
}
|
||||
if (!validator.default.isEmail(email)) {
|
||||
logger.error('Email is incorrectly formatted for login');
|
||||
req.log.warn(
|
||||
clientNetInfo(req),
|
||||
'Email is incorrectly formatted for login'
|
||||
);
|
||||
|
||||
fastify.Sentry?.metrics?.count('auth.mobile_login_attempted', 1, {
|
||||
attributes: { result: 'failure', reason: 'invalid_format' }
|
||||
});
|
||||
|
||||
return reply.status(400).send({
|
||||
message: 'The email is incorrectly formatted',
|
||||
@@ -66,6 +82,10 @@ export const mobileAuth0Routes: FastifyPluginCallback = (
|
||||
|
||||
const { id } = await findOrCreateUser(fastify, email);
|
||||
|
||||
fastify.Sentry?.metrics?.count('auth.mobile_login_attempted', 1, {
|
||||
attributes: { result: 'success' }
|
||||
});
|
||||
|
||||
reply.setAccessTokenCookie(createAccessToken(id));
|
||||
});
|
||||
|
||||
|
||||
@@ -49,6 +49,13 @@ describe('certificate routes', () => {
|
||||
});
|
||||
});
|
||||
test('should return user not found if the user cannot be found', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superRequest(
|
||||
'/certificate/showCert/not-a-valid-user-name/javascript-algorithms-and-data-structures',
|
||||
{
|
||||
@@ -65,6 +72,15 @@ describe('certificate routes', () => {
|
||||
]
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'certificate.public_view_blocked',
|
||||
1,
|
||||
{
|
||||
attributes: { reason: 'user_not_found' }
|
||||
}
|
||||
);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
test('should ask user to add name if there is no name', async () => {
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
@@ -226,6 +242,13 @@ describe('certificate routes', () => {
|
||||
});
|
||||
|
||||
test('should not return user full name if `showName` is `false`', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: {
|
||||
@@ -256,9 +279,24 @@ describe('certificate routes', () => {
|
||||
expect(response.body).toHaveProperty('username', 'foobar');
|
||||
expect(response.body).not.toHaveProperty('name');
|
||||
expect(response.status).toBe(200);
|
||||
expect(count).toHaveBeenCalledWith('certificate.public_viewed', 1, {
|
||||
attributes: {
|
||||
certSlug: 'javascript-algorithms-and-data-structures',
|
||||
nameVisibility: 'hidden'
|
||||
}
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('should return user full name if `showName` is `true`', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: {
|
||||
@@ -287,9 +325,24 @@ describe('certificate routes', () => {
|
||||
|
||||
expect(response.body).toHaveProperty('name', 'foobar');
|
||||
expect(response.status).toBe(200);
|
||||
expect(count).toHaveBeenCalledWith('certificate.public_viewed', 1, {
|
||||
attributes: {
|
||||
certSlug: 'javascript-algorithms-and-data-structures',
|
||||
nameVisibility: 'shown'
|
||||
}
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('should return cert-not-found if there is no cert with that slug', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superRequest(
|
||||
'/certificate/showCert/foobar/not-a-valid-cert-slug',
|
||||
{
|
||||
@@ -306,6 +359,15 @@ describe('certificate routes', () => {
|
||||
]
|
||||
});
|
||||
expect(response.status).toBe(404);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'certificate.public_view_blocked',
|
||||
1,
|
||||
{
|
||||
attributes: { reason: 'unknown_cert_slug' }
|
||||
}
|
||||
);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,12 +33,14 @@ export const unprotectedCertificateRoutes: FastifyPluginCallbackTypebox = (
|
||||
schema: schemas.certSlug
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
const username = req.params.username.toLowerCase();
|
||||
const certSlug = req.params.certSlug;
|
||||
|
||||
if (!isKnownCertSlug(certSlug)) {
|
||||
logger.warn(`Unknown certSlug: ${certSlug}`);
|
||||
fastify.Sentry?.metrics?.count('certificate.public_view_blocked', 1, {
|
||||
attributes: { reason: 'unknown_cert_slug' }
|
||||
});
|
||||
req.log.warn({ certSlug }, 'Unknown certSlug');
|
||||
void reply.code(404);
|
||||
return reply.send({
|
||||
messages: [
|
||||
@@ -100,7 +102,10 @@ export const unprotectedCertificateRoutes: FastifyPluginCallbackTypebox = (
|
||||
});
|
||||
|
||||
if (user === null) {
|
||||
logger.info(`User ${username} not found.`);
|
||||
fastify.Sentry?.metrics?.count('certificate.public_view_blocked', 1, {
|
||||
attributes: { reason: 'user_not_found' }
|
||||
});
|
||||
req.log.debug({ username }, 'User not found');
|
||||
return reply.send({
|
||||
messages: [
|
||||
{
|
||||
@@ -113,7 +118,10 @@ export const unprotectedCertificateRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
|
||||
if (user.isCheater || user.isBanned) {
|
||||
logger.info(`User ${username} is banned or a cheater.`);
|
||||
fastify.Sentry?.metrics?.count('certificate.public_view_blocked', 1, {
|
||||
attributes: { reason: 'user_ineligible' }
|
||||
});
|
||||
req.log.debug({ username }, 'User is banned or a cheater');
|
||||
return reply.send({
|
||||
messages: [
|
||||
{
|
||||
@@ -125,7 +133,10 @@ export const unprotectedCertificateRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
|
||||
if (!user.isHonest) {
|
||||
logger.info(`User ${username} has not accepted honesty policy.`);
|
||||
fastify.Sentry?.metrics?.count('certificate.public_view_blocked', 1, {
|
||||
attributes: { reason: 'not_honest' }
|
||||
});
|
||||
req.log.debug({ username }, 'User has not accepted honesty policy');
|
||||
return reply.send({
|
||||
messages: [
|
||||
{
|
||||
@@ -138,7 +149,10 @@ export const unprotectedCertificateRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
|
||||
if (user.profileUI?.isLocked) {
|
||||
logger.info(`User ${username} has a locked profile.`);
|
||||
fastify.Sentry?.metrics?.count('certificate.public_view_blocked', 1, {
|
||||
attributes: { reason: 'profile_locked' }
|
||||
});
|
||||
req.log.debug({ username }, 'User has a locked profile');
|
||||
return reply.send({
|
||||
messages: [
|
||||
{
|
||||
@@ -151,7 +165,10 @@ export const unprotectedCertificateRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
|
||||
if (!user.name) {
|
||||
logger.info(`User ${username} has not added a name.`);
|
||||
fastify.Sentry?.metrics?.count('certificate.public_view_blocked', 1, {
|
||||
attributes: { reason: 'missing_name' }
|
||||
});
|
||||
req.log.debug({ username }, 'User has not added a name');
|
||||
return reply.send({
|
||||
messages: [
|
||||
{
|
||||
@@ -163,7 +180,10 @@ export const unprotectedCertificateRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
|
||||
if (!user.profileUI?.showCerts) {
|
||||
logger.info(`User ${username} has private certs.`);
|
||||
fastify.Sentry?.metrics?.count('certificate.public_view_blocked', 1, {
|
||||
attributes: { reason: 'certs_private' }
|
||||
});
|
||||
req.log.debug({ username }, 'User has private certs');
|
||||
return reply.send({
|
||||
messages: [
|
||||
{
|
||||
@@ -176,7 +196,10 @@ export const unprotectedCertificateRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
|
||||
if (!user.profileUI?.showTimeLine) {
|
||||
logger.info(`User ${username} has private timeline.`);
|
||||
fastify.Sentry?.metrics?.count('certificate.public_view_blocked', 1, {
|
||||
attributes: { reason: 'timeline_private' }
|
||||
});
|
||||
req.log.debug({ username }, 'User has private timeline');
|
||||
return reply.send({
|
||||
messages: [
|
||||
{
|
||||
@@ -189,8 +212,12 @@ export const unprotectedCertificateRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
|
||||
if (!user[certType]) {
|
||||
logger.info(
|
||||
`User ${username} has not completed the ${certTitle} certification.`
|
||||
fastify.Sentry?.metrics?.count('certificate.public_view_blocked', 1, {
|
||||
attributes: { reason: 'cert_not_completed' }
|
||||
});
|
||||
req.log.debug(
|
||||
{ username, certTitle },
|
||||
'User has not completed the certification'
|
||||
);
|
||||
return reply.send({
|
||||
messages: [
|
||||
@@ -234,7 +261,10 @@ export const unprotectedCertificateRoutes: FastifyPluginCallbackTypebox = (
|
||||
const { name } = user;
|
||||
|
||||
if (!user.profileUI.showName) {
|
||||
logger.info(`User ${username} has private name.`);
|
||||
req.log.debug({ username }, 'User has private name');
|
||||
fastify.Sentry?.metrics?.count('certificate.public_viewed', 1, {
|
||||
attributes: { certSlug, nameVisibility: 'hidden' }
|
||||
});
|
||||
void reply.code(200);
|
||||
return reply.send({
|
||||
certSlug,
|
||||
@@ -245,6 +275,9 @@ export const unprotectedCertificateRoutes: FastifyPluginCallbackTypebox = (
|
||||
});
|
||||
}
|
||||
|
||||
fastify.Sentry?.metrics?.count('certificate.public_viewed', 1, {
|
||||
attributes: { certSlug, nameVisibility: 'shown' }
|
||||
});
|
||||
void reply.code(200);
|
||||
return reply.send({
|
||||
certSlug,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, test, expect, beforeAll, vi } from 'vitest';
|
||||
import Stripe from 'stripe';
|
||||
import { setupServer, superRequest } from '../../../vitest.utils.js';
|
||||
|
||||
const testEWalletEmail = 'baz@bar.com';
|
||||
@@ -66,8 +67,32 @@ const generateMockSubCreate = (status: string) => () =>
|
||||
}
|
||||
}
|
||||
});
|
||||
const {
|
||||
StripeError,
|
||||
StripeCardError,
|
||||
StripeInvalidRequestError,
|
||||
StripeAuthenticationError
|
||||
} = vi.hoisted(() => {
|
||||
class StripeError extends Error {}
|
||||
class StripeCardError extends StripeError {}
|
||||
class StripeInvalidRequestError extends StripeError {}
|
||||
class StripeAuthenticationError extends StripeError {}
|
||||
return {
|
||||
StripeError,
|
||||
StripeCardError,
|
||||
StripeInvalidRequestError,
|
||||
StripeAuthenticationError
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('stripe', () => ({
|
||||
default: class {
|
||||
static errors = {
|
||||
StripeError,
|
||||
StripeCardError,
|
||||
StripeInvalidRequestError,
|
||||
StripeAuthenticationError
|
||||
};
|
||||
constructor() {}
|
||||
customers = {
|
||||
create: mockCustomerCreate,
|
||||
@@ -134,5 +159,185 @@ describe('Donate', () => {
|
||||
}).send(chargeStripeReqBody);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
describe('Sentry Issue reporting', () => {
|
||||
test('create-stripe-payment-intent captures unexpected errors', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
|
||||
mockCustomerCreate.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error('Stripe unavailable'))
|
||||
);
|
||||
const response = await superRequest(
|
||||
'/donate/create-stripe-payment-intent',
|
||||
{
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}
|
||||
).send(createStripePaymentIntentReqBody);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('create-stripe-payment-intent rejects invalid amount for duration', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superRequest(
|
||||
'/donate/create-stripe-payment-intent',
|
||||
{
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}
|
||||
).send({ ...createStripePaymentIntentReqBody, amount: 999 });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(count).toHaveBeenCalledWith('donation.intent_rejected', 1, {
|
||||
attributes: { reason: 'invalid_amount' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('charge-stripe captures each subscription-validation failure', async () => {
|
||||
const invalidSubscriptions: unknown[] = [
|
||||
{ ...mockSubRetrieveObj, status: 'incomplete' },
|
||||
{
|
||||
...mockSubRetrieveObj,
|
||||
items: { data: [{ plan: { product: 'not_a_real_product' } }] }
|
||||
},
|
||||
{ ...mockSubRetrieveObj, current_period_start: 0 },
|
||||
{ ...mockSubRetrieveObj, customer: 12345 }
|
||||
];
|
||||
|
||||
for (const sub of invalidSubscriptions) {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
|
||||
mockSubRetrieve.mockImplementationOnce(() =>
|
||||
Promise.resolve(sub as typeof mockSubRetrieveObj)
|
||||
);
|
||||
const response = await superRequest('/donate/charge-stripe', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}).send(chargeStripeReqBody);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
}
|
||||
});
|
||||
|
||||
test('charge-stripe captures unexpected errors', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
|
||||
mockSubRetrieve.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error('Stripe unavailable'))
|
||||
);
|
||||
const response = await superRequest('/donate/charge-stripe', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}).send(chargeStripeReqBody);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('charge-stripe does not capture Stripe card decline errors', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
|
||||
const CardError = Stripe.errors.StripeCardError as unknown as new (
|
||||
m?: string
|
||||
) => Error;
|
||||
mockSubRetrieve.mockImplementationOnce(() =>
|
||||
Promise.reject(new CardError('card_declined'))
|
||||
);
|
||||
const response = await superRequest('/donate/charge-stripe', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}).send(chargeStripeReqBody);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('charge-stripe does not capture Stripe invalid request errors', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
|
||||
const InvalidRequestError = Stripe.errors
|
||||
.StripeInvalidRequestError as unknown as new (m?: string) => Error;
|
||||
mockSubRetrieve.mockImplementationOnce(() =>
|
||||
Promise.reject(new InvalidRequestError('invalid_request'))
|
||||
);
|
||||
const response = await superRequest('/donate/charge-stripe', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}).send(chargeStripeReqBody);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('charge-stripe captures Stripe infra errors', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
|
||||
const AuthError = Stripe.errors
|
||||
.StripeAuthenticationError as unknown as new (m?: string) => Error;
|
||||
mockSubRetrieve.mockImplementationOnce(() =>
|
||||
Promise.reject(new AuthError('invalid api key'))
|
||||
);
|
||||
const response = await superRequest('/donate/charge-stripe', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}).send(chargeStripeReqBody);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import * as schemas from '../../schemas.js';
|
||||
import { inLastFiveMinutes } from '../../utils/validate-donation.js';
|
||||
import { findOrCreateUser } from '../helpers/auth-helpers.js';
|
||||
import { clientNetInfo } from '../../utils/logger.js';
|
||||
|
||||
/**
|
||||
* Plugin for public donation endpoints.
|
||||
@@ -35,10 +36,13 @@ export const chargeStripeRoute: FastifyPluginCallbackTypebox = (
|
||||
},
|
||||
async (req, reply) => {
|
||||
const { email, name, amount, duration } = req.body;
|
||||
const log = fastify.log.child({ req, email, amount, duration });
|
||||
log.debug('Creating Stripe payment intent');
|
||||
fastify.Sentry?.setUser({ email });
|
||||
req.log.debug({ amount, duration }, 'Creating Stripe payment intent');
|
||||
|
||||
if (!donationSubscriptionConfig.plans[duration].includes(amount)) {
|
||||
fastify.Sentry?.metrics?.count('donation.intent_rejected', 1, {
|
||||
attributes: { reason: 'invalid_amount' }
|
||||
});
|
||||
void reply.code(400);
|
||||
return {
|
||||
error: 'The donation form had invalid values for this submission.'
|
||||
@@ -74,7 +78,7 @@ export const chargeStripeRoute: FastifyPluginCallbackTypebox = (
|
||||
) {
|
||||
const clientSecret =
|
||||
stripeSubscription.latest_invoice.payment_intent.client_secret;
|
||||
log.info('Successfully created payment intent');
|
||||
req.log.debug('Successfully created payment intent');
|
||||
return reply.send({
|
||||
subscriptionId: stripeSubscription.id,
|
||||
clientSecret
|
||||
@@ -82,9 +86,24 @@ export const chargeStripeRoute: FastifyPluginCallbackTypebox = (
|
||||
} else {
|
||||
throw new Error('Stripe payment intent client secret is missing');
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(error, 'Failed to create payment intent');
|
||||
fastify.Sentry.captureException(error);
|
||||
} catch (err) {
|
||||
const ctx = {
|
||||
audit: true,
|
||||
err,
|
||||
email: req.body.email,
|
||||
amount,
|
||||
duration,
|
||||
...clientNetInfo(req)
|
||||
};
|
||||
if (
|
||||
err instanceof Stripe.errors.StripeCardError ||
|
||||
err instanceof Stripe.errors.StripeInvalidRequestError
|
||||
) {
|
||||
req.log.warn(ctx, 'Stripe upstream error creating payment intent');
|
||||
} else {
|
||||
fastify.Sentry?.captureException(err);
|
||||
req.log.error(ctx, 'Failed to create payment intent');
|
||||
}
|
||||
void reply.code(500);
|
||||
return reply.send({
|
||||
error: 'Donation failed due to a server error.'
|
||||
@@ -101,14 +120,11 @@ export const chargeStripeRoute: FastifyPluginCallbackTypebox = (
|
||||
async (req, reply) => {
|
||||
try {
|
||||
const { email, amount, duration, subscriptionId } = req.body;
|
||||
const log = fastify.log.child({
|
||||
req,
|
||||
email,
|
||||
amount,
|
||||
duration,
|
||||
subscriptionId
|
||||
});
|
||||
log.debug('Processing Stripe charge');
|
||||
fastify.Sentry?.setUser({ email });
|
||||
req.log.debug(
|
||||
{ amount, duration, subscriptionId },
|
||||
'Processing Stripe charge'
|
||||
);
|
||||
|
||||
const subscription =
|
||||
await stripe.subscriptions.retrieve(subscriptionId);
|
||||
@@ -123,35 +139,59 @@ export const chargeStripeRoute: FastifyPluginCallbackTypebox = (
|
||||
const isValidCustomer = typeof subscription.customer === 'string';
|
||||
|
||||
if (!isSubscriptionActive) {
|
||||
log.warn(
|
||||
req.log.warn(
|
||||
{ status: subscription.status },
|
||||
'Invalid subscription status'
|
||||
);
|
||||
throw new Error(
|
||||
`Stripe subscription information is invalid: ${subscriptionId}`
|
||||
fastify.Sentry?.captureException(
|
||||
new Error(
|
||||
`Stripe subscription information is invalid: ${subscriptionId}`
|
||||
)
|
||||
);
|
||||
void reply.code(500);
|
||||
return {
|
||||
error: 'Donation failed due to a server error.'
|
||||
} as const;
|
||||
}
|
||||
if (!isProductIdValid) {
|
||||
log.warn({ productId }, 'Invalid product ID');
|
||||
throw new Error(`Product ID is invalid: ${subscriptionId}`);
|
||||
req.log.warn({ productId }, 'Invalid product ID');
|
||||
fastify.Sentry?.captureException(
|
||||
new Error(`Product ID is invalid: ${subscriptionId}`)
|
||||
);
|
||||
void reply.code(500);
|
||||
return {
|
||||
error: 'Donation failed due to a server error.'
|
||||
} as const;
|
||||
}
|
||||
if (!isStartedRecently) {
|
||||
log.warn(
|
||||
req.log.warn(
|
||||
{ startTime: subscription.current_period_start },
|
||||
'Subscription not recent'
|
||||
);
|
||||
throw new Error(`Subscription is not recent: ${subscriptionId}`);
|
||||
fastify.Sentry?.captureException(
|
||||
new Error(`Subscription is not recent: ${subscriptionId}`)
|
||||
);
|
||||
void reply.code(500);
|
||||
return {
|
||||
error: 'Donation failed due to a server error.'
|
||||
} as const;
|
||||
}
|
||||
if (!isValidCustomer) {
|
||||
log.warn(
|
||||
req.log.warn(
|
||||
{ customerId: subscription.customer },
|
||||
'Invalid customer ID'
|
||||
);
|
||||
throw new Error(`Customer ID is invalid: ${subscriptionId}`);
|
||||
fastify.Sentry?.captureException(
|
||||
new Error(`Customer ID is invalid: ${subscriptionId}`)
|
||||
);
|
||||
void reply.code(500);
|
||||
return {
|
||||
error: 'Donation failed due to a server error.'
|
||||
} as const;
|
||||
}
|
||||
|
||||
const user = await findOrCreateUser(fastify, email);
|
||||
log.debug({ userId: user.id }, 'Found or created user');
|
||||
req.log.debug({ userId: user.id }, 'Found or created user');
|
||||
|
||||
const donation = {
|
||||
userId: user.id,
|
||||
@@ -178,14 +218,42 @@ export const chargeStripeRoute: FastifyPluginCallbackTypebox = (
|
||||
isDonating: true
|
||||
}
|
||||
});
|
||||
log.info('Successfully processed donation');
|
||||
req.log.info(
|
||||
{
|
||||
audit: true,
|
||||
userId: user.id,
|
||||
email,
|
||||
amount,
|
||||
duration,
|
||||
subscriptionId,
|
||||
...clientNetInfo(req)
|
||||
},
|
||||
'Successfully processed donation'
|
||||
);
|
||||
fastify.Sentry?.metrics?.count('donation.created', 1, {
|
||||
attributes: { flow: 'charge-stripe' }
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
isDonating: true
|
||||
});
|
||||
} catch (error) {
|
||||
fastify.log.error(error, 'Failed to process Stripe charge');
|
||||
fastify.Sentry.captureException(error);
|
||||
} catch (err) {
|
||||
const ctx = {
|
||||
audit: true,
|
||||
err,
|
||||
email: req.body.email,
|
||||
subscriptionId: req.body.subscriptionId,
|
||||
...clientNetInfo(req)
|
||||
};
|
||||
if (
|
||||
err instanceof Stripe.errors.StripeCardError ||
|
||||
err instanceof Stripe.errors.StripeInvalidRequestError
|
||||
) {
|
||||
req.log.warn(ctx, 'Stripe upstream error processing charge');
|
||||
} else {
|
||||
fastify.Sentry?.captureException(err);
|
||||
req.log.error(ctx, 'Failed to process Stripe charge');
|
||||
}
|
||||
void reply.code(500);
|
||||
return {
|
||||
error: 'Donation failed due to a server error.'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { describe, test, expect, vi } from 'vitest';
|
||||
import { setupServer, superRequest } from '../../../vitest.utils.js';
|
||||
import { HOME_LOCATION } from '../../utils/env.js';
|
||||
import { createUserInput } from '../../utils/create-user.js';
|
||||
@@ -74,11 +74,23 @@ describe('Email Subscription endpoints', () => {
|
||||
});
|
||||
|
||||
test('should 302 redirect with info message if bad ID', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superRequest('/ue/54321edcba', { method: 'GET' });
|
||||
expect(response.headers.location).toStrictEqual(
|
||||
`${HOME_LOCATION}${urlEncodedInfoMessage1}`
|
||||
);
|
||||
expect(response.status).toBe(302);
|
||||
expect(count).toHaveBeenCalledWith('email_subscription.unsubscribed', 1, {
|
||||
attributes: { result: 'not_found' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test("1: should set 'sendQuincyEmail' to 'false' for users with matching email and 302 redirect with success message", async () => {
|
||||
@@ -86,6 +98,13 @@ describe('Email Subscription endpoints', () => {
|
||||
data: testUserData1
|
||||
});
|
||||
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superRequest(`/ue/${unsubscribeId1}`, {
|
||||
method: 'GET'
|
||||
});
|
||||
@@ -123,6 +142,10 @@ describe('Email Subscription endpoints', () => {
|
||||
);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(count).toHaveBeenCalledWith('email_subscription.unsubscribed', 1, {
|
||||
attributes: { result: 'success' }
|
||||
});
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
// TODO: If any assertions fail before this call, other tests will fail for no actual reason.
|
||||
await fastifyTestInstance.prisma.user.deleteMany({
|
||||
where: {
|
||||
@@ -205,6 +228,13 @@ describe('Email Subscription endpoints', () => {
|
||||
});
|
||||
|
||||
test('should 302 redirect with info message if bad ID', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superRequest('/resubscribe/54321edcba', {
|
||||
method: 'GET'
|
||||
});
|
||||
@@ -212,6 +242,11 @@ describe('Email Subscription endpoints', () => {
|
||||
`${HOME_LOCATION}${urlEncodedInfoMessage3}`
|
||||
);
|
||||
expect(response.status).toBe(302);
|
||||
expect(count).toHaveBeenCalledWith('email_subscription.resubscribed', 1, {
|
||||
attributes: { result: 'not_found' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test("should set 'sendQuincyEmail' to 'true' for user with matching ID and 302 redirect with success message", async () => {
|
||||
@@ -219,6 +254,13 @@ describe('Email Subscription endpoints', () => {
|
||||
data: testUserData2
|
||||
});
|
||||
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superRequest(`/resubscribe/${unsubscribeId1}`, {
|
||||
method: 'GET'
|
||||
});
|
||||
@@ -255,6 +297,10 @@ describe('Email Subscription endpoints', () => {
|
||||
);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(count).toHaveBeenCalledWith('email_subscription.resubscribed', 1, {
|
||||
attributes: { result: 'success' }
|
||||
});
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
await fastifyTestInstance.prisma.user.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
@@ -316,4 +362,60 @@ describe('Email Subscription endpoints', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sentry Issue reporting', () => {
|
||||
test('unsubscribe captures unexpected errors', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
const spy = vi
|
||||
.spyOn(fastifyTestInstance.prisma.user, 'findMany')
|
||||
.mockRejectedValueOnce(new Error('DB error'));
|
||||
|
||||
const response = await superRequest(`/ue/${unsubscribeId1}`, {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
expect(count).toHaveBeenCalledWith('email_subscription.unsubscribed', 1, {
|
||||
attributes: { result: 'error' }
|
||||
});
|
||||
|
||||
spy.mockRestore();
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('resubscribe captures unexpected errors', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
const spy = vi
|
||||
.spyOn(fastifyTestInstance.prisma.user, 'findFirst')
|
||||
.mockRejectedValueOnce(new Error('DB error'));
|
||||
|
||||
const response = await superRequest(`/resubscribe/${unsubscribeId1}`, {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
expect(count).toHaveBeenCalledWith('email_subscription.resubscribed', 1, {
|
||||
attributes: { result: 'error' }
|
||||
});
|
||||
|
||||
spy.mockRestore();
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,16 +33,18 @@ export const emailSubscribtionRoutes: FastifyPluginCallbackTypebox = (
|
||||
},
|
||||
async (req, reply) => {
|
||||
const { origin } = getRedirectParams(req);
|
||||
try {
|
||||
const { unsubscribeId } = req.params;
|
||||
const log = fastify.log.child({ req, unsubscribeId });
|
||||
const { unsubscribeId } = req.params;
|
||||
|
||||
try {
|
||||
const unsubUsers = await fastify.prisma.user.findMany({
|
||||
where: { unsubscribeId }
|
||||
});
|
||||
|
||||
if (!unsubUsers.length) {
|
||||
log.warn('No users found for unsubscribe request');
|
||||
req.log.warn('No users found for unsubscribe request');
|
||||
fastify.Sentry?.metrics?.count('email_subscription.unsubscribed', 1, {
|
||||
attributes: { result: 'not_found' }
|
||||
});
|
||||
void reply.code(302);
|
||||
return reply.redirectWithMessage(origin, {
|
||||
type: 'info',
|
||||
@@ -50,7 +52,6 @@ export const emailSubscribtionRoutes: FastifyPluginCallbackTypebox = (
|
||||
});
|
||||
}
|
||||
|
||||
log.info(`Found ${unsubUsers.length} user(s) to unsubscribe`);
|
||||
const userUpdatePromises = unsubUsers.map(user =>
|
||||
fastify.prisma.user.updateMany({
|
||||
where: { email: user.email },
|
||||
@@ -61,10 +62,13 @@ export const emailSubscribtionRoutes: FastifyPluginCallbackTypebox = (
|
||||
);
|
||||
|
||||
await Promise.all(userUpdatePromises);
|
||||
log.info(
|
||||
{ emails: unsubUsers.map(u => u.email) },
|
||||
'Successfully unsubscribed users from email.'
|
||||
req.log.info(
|
||||
{ matchedUsers: unsubUsers.length, audit: true },
|
||||
'Successfully unsubscribed users from email'
|
||||
);
|
||||
fastify.Sentry?.metrics?.count('email_subscription.unsubscribed', 1, {
|
||||
attributes: { result: 'success' }
|
||||
});
|
||||
|
||||
return reply.redirectWithMessage(
|
||||
`${origin}/unsubscribed/${unsubscribeId}`,
|
||||
@@ -73,9 +77,12 @@ export const emailSubscribtionRoutes: FastifyPluginCallbackTypebox = (
|
||||
content: "We've successfully updated your email preferences."
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
fastify.log.error(error, 'Failed to unsubscribe user from email');
|
||||
fastify.Sentry.captureException(error);
|
||||
} catch (err) {
|
||||
fastify.Sentry?.captureException(err);
|
||||
req.log.error(err, 'Failed to unsubscribe user from email');
|
||||
fastify.Sentry?.metrics?.count('email_subscription.unsubscribed', 1, {
|
||||
attributes: { result: 'error' }
|
||||
});
|
||||
void reply.code(302);
|
||||
return reply.redirectWithMessage(origin, {
|
||||
type: 'danger',
|
||||
@@ -105,16 +112,18 @@ export const emailSubscribtionRoutes: FastifyPluginCallbackTypebox = (
|
||||
},
|
||||
async (req, reply) => {
|
||||
const { origin } = getRedirectParams(req);
|
||||
try {
|
||||
const { unsubscribeId } = req.params;
|
||||
const log = fastify.log.child({ req, unsubscribeId });
|
||||
const { unsubscribeId } = req.params;
|
||||
|
||||
try {
|
||||
const user = await fastify.prisma.user.findFirst({
|
||||
where: { unsubscribeId }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
log.warn('No user found for resubscribe request');
|
||||
req.log.warn('No user found for resubscribe request');
|
||||
fastify.Sentry?.metrics?.count('email_subscription.resubscribed', 1, {
|
||||
attributes: { result: 'not_found' }
|
||||
});
|
||||
void reply.code(302);
|
||||
return reply.redirectWithMessage(origin, {
|
||||
type: 'info',
|
||||
@@ -122,25 +131,32 @@ export const emailSubscribtionRoutes: FastifyPluginCallbackTypebox = (
|
||||
});
|
||||
}
|
||||
|
||||
log.info(`Found user ${user.id} to resubscribe`);
|
||||
req.log.debug({ userId: user.id }, 'Found user to resubscribe');
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
sendQuincyEmail: true
|
||||
}
|
||||
});
|
||||
log.info(
|
||||
`Successfully resubscribed user ${user.id} to email: ${user.email}`
|
||||
req.log.info(
|
||||
{ userId: user.id, audit: true },
|
||||
'Successfully resubscribed user'
|
||||
);
|
||||
fastify.Sentry?.metrics?.count('email_subscription.resubscribed', 1, {
|
||||
attributes: { result: 'success' }
|
||||
});
|
||||
|
||||
return reply.redirectWithMessage(origin, {
|
||||
type: 'success',
|
||||
content:
|
||||
"We've successfully updated your email preferences. Thank you for resubscribing."
|
||||
});
|
||||
} catch (error) {
|
||||
fastify.log.error(error, 'Failed to resubscribe user to email');
|
||||
fastify.Sentry.captureException(error);
|
||||
} catch (err) {
|
||||
fastify.Sentry?.captureException(err);
|
||||
req.log.error(err, 'Failed to resubscribe user to email');
|
||||
fastify.Sentry?.metrics?.count('email_subscription.resubscribed', 1, {
|
||||
attributes: { result: 'error' }
|
||||
});
|
||||
void reply.code(302);
|
||||
return reply.redirectWithMessage(origin, {
|
||||
type: 'danger',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { devLogin, setupServer, superRequest } from '../../../vitest.utils.js';
|
||||
|
||||
describe('GET /signout', () => {
|
||||
@@ -33,4 +33,19 @@ describe('GET /signout', () => {
|
||||
expect(res.body).toEqual({});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('counts an auth.signed_out metric', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
await superRequest('/signout', { method: 'GET' });
|
||||
|
||||
expect(count).toHaveBeenCalledWith('auth.signed_out', 1);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,10 +21,9 @@ export const signoutRoute: FastifyPluginCallback = (
|
||||
schema: signout
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
|
||||
void reply.clearOurCookies();
|
||||
logger.info('User signed out');
|
||||
fastify.Sentry?.metrics?.count('auth.signed_out', 1);
|
||||
req.log.info({ audit: true }, 'User signed out');
|
||||
|
||||
await reply.send({});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { describe, test, expect, vi } from 'vitest';
|
||||
import { setupServer, superRequest } from '../../../vitest.utils.js';
|
||||
import { DEPLOYMENT_VERSION } from '../../utils/env.js';
|
||||
|
||||
@@ -22,4 +22,43 @@ describe('/status', () => {
|
||||
expect(response.body).toStrictEqual({ version: DEPLOYMENT_VERSION });
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test('GET /status/ready returns 200 when the database is reachable', async () => {
|
||||
const response = await superRequest('/status/ready', { method: 'GET' });
|
||||
|
||||
expect(response.body).toStrictEqual({ status: 'ready' });
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test('GET /status/ready returns 503 when the database is unreachable', async () => {
|
||||
const spy = vi
|
||||
.spyOn(fastifyTestInstance.prisma, '$runCommandRaw')
|
||||
.mockRejectedValueOnce(new Error('db down'));
|
||||
|
||||
const response = await superRequest('/status/ready', { method: 'GET' });
|
||||
|
||||
expect(response.body).toStrictEqual({ status: 'unavailable' });
|
||||
expect(response.status).toBe(503);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test('counts a readiness.check_failed metric when the database is unreachable', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
const dbSpy = vi
|
||||
.spyOn(fastifyTestInstance.prisma, '$runCommandRaw')
|
||||
.mockRejectedValueOnce(new Error('db down'));
|
||||
|
||||
await superRequest('/status/ready', { method: 'GET' });
|
||||
|
||||
expect(count).toHaveBeenCalledWith('readiness.check_failed', 1);
|
||||
|
||||
dbSpy.mockRestore();
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,15 +15,26 @@ export const statusRoute: FastifyPluginCallbackTypebox = (
|
||||
_options,
|
||||
done
|
||||
) => {
|
||||
fastify.get('/status/ping', async (req, res) => {
|
||||
fastify.log.child({ req, res }).debug({ what: 'pong' }, 'Replying to ping');
|
||||
fastify.get('/status/ping', async (req, _res) => {
|
||||
req.log.debug({ what: 'pong' }, 'Replying to ping');
|
||||
return { msg: 'pong' };
|
||||
});
|
||||
|
||||
fastify.get('/status/version', async (req, res) => {
|
||||
fastify.log.child({ req, res }).debug('Sending version');
|
||||
fastify.get('/status/version', async (req, _res) => {
|
||||
req.log.debug('Sending version');
|
||||
return { version: DEPLOYMENT_VERSION };
|
||||
});
|
||||
|
||||
fastify.get('/status/ready', async (req, reply) => {
|
||||
try {
|
||||
await fastify.prisma.$runCommandRaw({ ping: 1 });
|
||||
return { status: 'ready' };
|
||||
} catch (err) {
|
||||
fastify.Sentry?.metrics?.count('readiness.check_failed', 1);
|
||||
req.log.error(err, 'Readiness check failed: database unreachable');
|
||||
return reply.code(503).send({ status: 'unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
@@ -126,8 +126,10 @@ export const userPublicGetRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
logger.info({ username: req.query.username });
|
||||
req.log.debug(
|
||||
{ username: req.query.username },
|
||||
'Fetching public profile'
|
||||
);
|
||||
// TODO(Post-MVP): look for duplicates unless we can make username unique in the db.
|
||||
const user = await fastify.prisma.user.findFirst({
|
||||
where: { username: req.query.username }
|
||||
@@ -136,7 +138,7 @@ export const userPublicGetRoutes: FastifyPluginCallbackTypebox = (
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
logger.warn('User not found');
|
||||
req.log.warn('User not found');
|
||||
void reply.code(404);
|
||||
return reply.send({});
|
||||
}
|
||||
@@ -229,13 +231,9 @@ export const userPublicGetRoutes: FastifyPluginCallbackTypebox = (
|
||||
attachValidation: true
|
||||
},
|
||||
async (req, reply) => {
|
||||
const logger = fastify.log.child({ req, res: reply });
|
||||
|
||||
if (req.validationError) {
|
||||
void reply.code(400);
|
||||
logger
|
||||
.child({ res: reply })
|
||||
.warn('Validation error: No username provided');
|
||||
req.log.warn('Validation error: No username provided');
|
||||
return await reply.send({
|
||||
type: 'danger',
|
||||
message: 'username parameter is required'
|
||||
@@ -245,7 +243,7 @@ export const userPublicGetRoutes: FastifyPluginCallbackTypebox = (
|
||||
const username = req.query.username.toLowerCase();
|
||||
|
||||
if (isRestricted(username)) {
|
||||
logger.info(`Restricted username: ${username}`);
|
||||
req.log.debug({ username }, 'Restricted username');
|
||||
return await reply.send({ exists: true });
|
||||
}
|
||||
|
||||
@@ -255,9 +253,9 @@ export const userPublicGetRoutes: FastifyPluginCallbackTypebox = (
|
||||
})) > 0;
|
||||
|
||||
if (exists) {
|
||||
logger.info(`User exists for username: ${username}`);
|
||||
req.log.debug({ username }, 'User exists for username');
|
||||
} else {
|
||||
logger.info(`User does not exist for username: ${username}`);
|
||||
req.log.debug({ username }, 'User does not exist for username');
|
||||
}
|
||||
await reply.send({ exists });
|
||||
}
|
||||
|
||||
+58
-23
@@ -1,34 +1,69 @@
|
||||
import './instrument.js';
|
||||
|
||||
import os from 'node:os';
|
||||
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { build, buildOptions } from './app.js';
|
||||
import { HOST, PORT } from './utils/env.js';
|
||||
import {
|
||||
DEPLOYMENT_VERSION,
|
||||
HOST,
|
||||
PORT,
|
||||
SENTRY_SERVER_NAME
|
||||
} from './utils/env.js';
|
||||
|
||||
const start = async () => {
|
||||
const fastify = await build(buildOptions);
|
||||
|
||||
const stop = async (signal: NodeJS.Signals) => {
|
||||
fastify.log.info(`Received ${signal}, shutting down.`);
|
||||
|
||||
fastify.server.closeAllConnections();
|
||||
await new Promise<void>(resolve => {
|
||||
fastify.server.close(() => resolve());
|
||||
});
|
||||
|
||||
// Yield one tick so libuv can finalize uv_close() on the TCP handle
|
||||
// before pino's autoEnd blocks the event loop via Atomics.wait().
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
|
||||
await fastify.close();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGINT', signal => void stop(signal));
|
||||
process.on('SIGTERM', signal => void stop(signal));
|
||||
let fastify: Awaited<ReturnType<typeof build>> | undefined;
|
||||
|
||||
try {
|
||||
await fastify.listen({ port: Number(PORT), host: HOST });
|
||||
fastify = await build(buildOptions);
|
||||
|
||||
const stop = async (signal: NodeJS.Signals) => {
|
||||
fastify!.log.info({ signal }, 'Received signal, shutting down');
|
||||
|
||||
fastify!.server.closeAllConnections();
|
||||
await new Promise<void>(resolve => {
|
||||
fastify!.server.close(() => resolve());
|
||||
});
|
||||
|
||||
// Yield one tick so libuv can finalize uv_close() on the TCP handle
|
||||
// before pino's autoEnd blocks the event loop via Atomics.wait().
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
|
||||
await fastify!.close();
|
||||
Sentry.metrics.count('server.shutdown_completed', 1, {
|
||||
attributes: { signal }
|
||||
});
|
||||
await fastify!.Sentry.close(2000);
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGINT', signal => void stop(signal));
|
||||
process.on('SIGTERM', signal => void stop(signal));
|
||||
|
||||
const address = await fastify.listen({ port: Number(PORT), host: HOST });
|
||||
fastify.log.info(
|
||||
{
|
||||
audit: true,
|
||||
version: DEPLOYMENT_VERSION,
|
||||
instanceId: SENTRY_SERVER_NAME ?? os.hostname(),
|
||||
address
|
||||
},
|
||||
'API server started'
|
||||
);
|
||||
Sentry.metrics.count('server.boot', 1, {
|
||||
attributes: { result: 'success' }
|
||||
});
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
if (fastify) {
|
||||
fastify.log.error(err, 'Failed to start server');
|
||||
} else {
|
||||
console.error('Failed to start server', err);
|
||||
}
|
||||
Sentry.metrics.count('server.boot', 1, {
|
||||
attributes: { result: 'failure' }
|
||||
});
|
||||
Sentry.captureException(err);
|
||||
await (fastify?.Sentry ?? Sentry).close(2000);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -226,6 +226,33 @@ export const SENTRY_ENVIRONMENT =
|
||||
process.env.SENTRY_ENVIRONMENT === 'development'
|
||||
? ''
|
||||
: process.env.SENTRY_ENVIRONMENT;
|
||||
export const SENTRY_SERVER_NAME = process.env.SENTRY_SERVER_NAME;
|
||||
function parseUnitRate(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (raw == null || raw.trim() === '') return fallback;
|
||||
const value = Number(raw);
|
||||
assert.ok(
|
||||
Number.isFinite(value) && value >= 0 && value <= 1,
|
||||
`${name} must be a number between 0 and 1. Found ${raw}`
|
||||
);
|
||||
return value;
|
||||
}
|
||||
export const SENTRY_TRACES_SAMPLE_RATE = parseUnitRate(
|
||||
'SENTRY_TRACES_SAMPLE_RATE',
|
||||
0.1
|
||||
);
|
||||
export const SENTRY_PROFILE_SESSION_SAMPLE_RATE = parseUnitRate(
|
||||
'SENTRY_PROFILE_SESSION_SAMPLE_RATE',
|
||||
0.1
|
||||
);
|
||||
export const SENTRY_LOGS_DEBUG_SAMPLE_RATE = parseUnitRate(
|
||||
'SENTRY_LOGS_DEBUG_SAMPLE_RATE',
|
||||
0.05
|
||||
);
|
||||
export const SENTRY_LOGS_INFO_SAMPLE_RATE = parseUnitRate(
|
||||
'SENTRY_LOGS_INFO_SAMPLE_RATE',
|
||||
1.0
|
||||
);
|
||||
export const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN;
|
||||
export const COOKIE_SECRET = process.env.COOKIE_SECRET;
|
||||
export const JWT_SECRET = process.env.JWT_SECRET;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { recordHttpMetrics } from './http-metrics.js';
|
||||
|
||||
const invoke = ({
|
||||
method = 'GET',
|
||||
url = '/users/:id',
|
||||
matched = true,
|
||||
statusCode = 200,
|
||||
elapsedTime = 12.5,
|
||||
withMetrics = true
|
||||
} = {}) => {
|
||||
const count = vi.fn();
|
||||
const distribution = vi.fn();
|
||||
const done = vi.fn();
|
||||
const req = {
|
||||
method,
|
||||
routeOptions: matched ? { url } : {},
|
||||
server: {
|
||||
Sentry: withMetrics ? { metrics: { count, distribution } } : {}
|
||||
}
|
||||
} as unknown as FastifyRequest;
|
||||
const reply = { statusCode, elapsedTime } as unknown as FastifyReply;
|
||||
|
||||
recordHttpMetrics(req, reply, done);
|
||||
|
||||
return { count, distribution, done };
|
||||
};
|
||||
|
||||
describe('recordHttpMetrics', () => {
|
||||
it('counts the response by route pattern, method and status class', () => {
|
||||
const { count } = invoke({
|
||||
method: 'POST',
|
||||
url: '/users/:id',
|
||||
statusCode: 201
|
||||
});
|
||||
|
||||
expect(count).toHaveBeenCalledWith('http.response', 1, {
|
||||
attributes: { route: '/users/:id', method: 'POST', statusClass: '2xx' }
|
||||
});
|
||||
});
|
||||
|
||||
it('records request duration in milliseconds with the same attributes', () => {
|
||||
const { distribution } = invoke({ statusCode: 200, elapsedTime: 42 });
|
||||
|
||||
expect(distribution).toHaveBeenCalledWith('http.request_duration_ms', 42, {
|
||||
unit: 'millisecond',
|
||||
attributes: { route: '/users/:id', method: 'GET', statusClass: '2xx' }
|
||||
});
|
||||
});
|
||||
|
||||
it('derives a 4xx status class from the numeric status code', () => {
|
||||
expect(invoke({ statusCode: 404 }).count).toHaveBeenCalledWith(
|
||||
'http.response',
|
||||
1,
|
||||
{ attributes: { route: '/users/:id', method: 'GET', statusClass: '4xx' } }
|
||||
);
|
||||
});
|
||||
|
||||
it('derives a 5xx status class from the numeric status code', () => {
|
||||
expect(invoke({ statusCode: 503 }).count).toHaveBeenCalledWith(
|
||||
'http.response',
|
||||
1,
|
||||
{ attributes: { route: '/users/:id', method: 'GET', statusClass: '5xx' } }
|
||||
);
|
||||
});
|
||||
|
||||
it('labels an unmatched route rather than emitting an interpolated path', () => {
|
||||
expect(
|
||||
invoke({ matched: false, statusCode: 404 }).count
|
||||
).toHaveBeenCalledWith('http.response', 1, {
|
||||
attributes: { route: 'unmatched', method: 'GET', statusClass: '4xx' }
|
||||
});
|
||||
});
|
||||
|
||||
it('always completes the hook', () => {
|
||||
expect(invoke().done).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not throw and still completes when metrics are unavailable', () => {
|
||||
const { count, distribution, done } = invoke({ withMetrics: false });
|
||||
|
||||
expect(count).not.toHaveBeenCalled();
|
||||
expect(distribution).not.toHaveBeenCalled();
|
||||
expect(done).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type {
|
||||
FastifyReply,
|
||||
FastifyRequest,
|
||||
HookHandlerDoneFunction
|
||||
} from 'fastify';
|
||||
|
||||
// eslint-disable-next-line jsdoc/require-jsdoc
|
||||
export const recordHttpMetrics = (
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
done: HookHandlerDoneFunction
|
||||
): void => {
|
||||
const metrics = req.server.Sentry?.metrics;
|
||||
if (metrics) {
|
||||
const attributes = {
|
||||
route: req.routeOptions?.url ?? 'unmatched',
|
||||
method: req.method,
|
||||
statusClass: `${Math.floor(reply.statusCode / 100)}xx`
|
||||
};
|
||||
metrics.count('http.response', 1, { attributes });
|
||||
metrics.distribution('http.request_duration_ms', reply.elapsedTime, {
|
||||
unit: 'millisecond',
|
||||
attributes
|
||||
});
|
||||
}
|
||||
done();
|
||||
};
|
||||
@@ -0,0 +1,312 @@
|
||||
import { Writable } from 'stream';
|
||||
import { pino, type Logger } from 'pino';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
|
||||
|
||||
import {
|
||||
bindRouteToLogger,
|
||||
genReqId,
|
||||
getLoggerOptions,
|
||||
serializers
|
||||
} from './logger.js';
|
||||
|
||||
const UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
const fakeRequest = (overrides: {
|
||||
headers?: Record<string, string | string[] | undefined>;
|
||||
query?: unknown;
|
||||
id?: string;
|
||||
ip?: string;
|
||||
url?: string;
|
||||
routeUrl?: string;
|
||||
}): FastifyRequest =>
|
||||
({
|
||||
id: overrides.id ?? 'req-1',
|
||||
method: 'GET',
|
||||
url: overrides.url ?? '/status/ping',
|
||||
ip: overrides.ip ?? '127.0.0.1',
|
||||
headers: overrides.headers ?? {},
|
||||
query: overrides.query ?? {},
|
||||
routeOptions: { url: overrides.routeUrl }
|
||||
}) as unknown as FastifyRequest;
|
||||
|
||||
describe('serializers.req', () => {
|
||||
it('emits lowercase camelCase keys', () => {
|
||||
const result = serializers.req(
|
||||
fakeRequest({
|
||||
headers: {
|
||||
'user-agent': 'vitest',
|
||||
'cf-ipcountry': 'NL'
|
||||
},
|
||||
query: { page: '2' }
|
||||
})
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'GET',
|
||||
url: '/status/ping',
|
||||
ip: '127.0.0.1',
|
||||
userAgent: 'vitest',
|
||||
country: 'NL',
|
||||
query: { page: '2' }
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers cf-connecting-ip over other ip sources', () => {
|
||||
const result = serializers.req(
|
||||
fakeRequest({
|
||||
headers: {
|
||||
'cf-connecting-ip': '1.1.1.1',
|
||||
'x-forwarded-for': '2.2.2.2',
|
||||
'x-real-ip': '3.3.3.3'
|
||||
}
|
||||
})
|
||||
);
|
||||
expect(result.ip).toBe('1.1.1.1');
|
||||
});
|
||||
|
||||
it('uses the first x-forwarded-for value when it is an array', () => {
|
||||
const result = serializers.req(
|
||||
fakeRequest({
|
||||
headers: { 'x-forwarded-for': ['2.2.2.2', '9.9.9.9'] }
|
||||
})
|
||||
);
|
||||
expect(result.ip).toBe('2.2.2.2');
|
||||
});
|
||||
|
||||
it('uses the first hop of a comma-separated x-forwarded-for chain', () => {
|
||||
const result = serializers.req(
|
||||
fakeRequest({
|
||||
headers: { 'x-forwarded-for': '2.2.2.2, 10.0.0.1, 172.16.0.1' }
|
||||
})
|
||||
);
|
||||
expect(result.ip).toBe('2.2.2.2');
|
||||
});
|
||||
|
||||
it('falls back to req.ip when no proxy headers are present', () => {
|
||||
const result = serializers.req(fakeRequest({ ip: '10.0.0.5' }));
|
||||
expect(result.ip).toBe('10.0.0.5');
|
||||
});
|
||||
|
||||
it('omits the query property when the query is empty', () => {
|
||||
const result = serializers.req(fakeRequest({ query: {} }));
|
||||
expect(result).not.toHaveProperty('query');
|
||||
});
|
||||
|
||||
it('strips the query string from the logged url', () => {
|
||||
const result = serializers.req(
|
||||
fakeRequest({ url: '/status/ping?token=supersecret&page=1' })
|
||||
);
|
||||
expect(result.url).toBe('/status/ping');
|
||||
expect(JSON.stringify(result)).not.toContain('supersecret');
|
||||
});
|
||||
|
||||
it('includes the templated route pattern when resolved', () => {
|
||||
const result = serializers.req(
|
||||
fakeRequest({ url: '/users/abc123', routeUrl: '/users/:id' })
|
||||
);
|
||||
expect(result.route).toBe('/users/:id');
|
||||
});
|
||||
|
||||
it('omits the route property when no route matched', () => {
|
||||
const result = serializers.req(fakeRequest({}));
|
||||
expect(result).not.toHaveProperty('route');
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializers.res', () => {
|
||||
it('emits only statusCode', () => {
|
||||
const result = serializers.res({
|
||||
statusCode: 200,
|
||||
elapsedTime: 12.5
|
||||
} as unknown as FastifyReply);
|
||||
|
||||
expect(result).toEqual({ statusCode: 200 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializers.err', () => {
|
||||
it('whitelists safe fields and drops secret/payment payloads', () => {
|
||||
const stripeErr = Object.assign(new Error('Your card was declined.'), {
|
||||
code: 'card_declined',
|
||||
statusCode: 402,
|
||||
requestId: 'req_123',
|
||||
raw: { payment_intent: { client_secret: 'pi_secret_LEAK' } },
|
||||
headers: { authorization: 'Bearer sk_live_LEAK' },
|
||||
payment_method: { card: { number: '4242424242424242' } }
|
||||
});
|
||||
|
||||
const result = serializers.err(stripeErr);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: 'Error',
|
||||
message: 'Your card was declined.',
|
||||
code: 'card_declined',
|
||||
statusCode: 402,
|
||||
requestId: 'req_123'
|
||||
});
|
||||
expect(result).not.toHaveProperty('raw');
|
||||
expect(result).not.toHaveProperty('headers');
|
||||
expect(result).not.toHaveProperty('payment_method');
|
||||
const serialized = JSON.stringify(result);
|
||||
expect(serialized).not.toContain('pi_secret_LEAK');
|
||||
expect(serialized).not.toContain('sk_live_LEAK');
|
||||
expect(serialized).not.toContain('4242424242424242');
|
||||
});
|
||||
|
||||
it('recursively whitelists the cause chain', () => {
|
||||
const cause = Object.assign(new Error('inner'), {
|
||||
raw: { client_secret: 'cause_LEAK' }
|
||||
});
|
||||
const err = Object.assign(new Error('outer'), { cause });
|
||||
|
||||
const result = serializers.err(err);
|
||||
|
||||
expect(JSON.stringify(result)).not.toContain('cause_LEAK');
|
||||
expect((result.cause as { message: string }).message).toBe('inner');
|
||||
});
|
||||
|
||||
it('handles non-object errors', () => {
|
||||
expect(serializers.err('boom')).toEqual({ message: 'boom' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('bindRouteToLogger', () => {
|
||||
it('binds the matched route onto request logs', 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)
|
||||
});
|
||||
app.addHook('onRequest', bindRouteToLogger);
|
||||
app.get('/widgets/:id', () => ({ ok: true }));
|
||||
|
||||
await app.inject({ method: 'GET', url: '/widgets/42' });
|
||||
await app.close();
|
||||
|
||||
const completed = lines
|
||||
.map(line => JSON.parse(line) as Record<string, unknown>)
|
||||
.find(entry => entry.msg === 'request completed');
|
||||
expect(completed?.route).toBe('/widgets/:id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('genReqId', () => {
|
||||
it('passes through a valid cf-ray header', () => {
|
||||
expect(genReqId({ headers: { 'cf-ray': 'abc-123_DEF' } })).toBe(
|
||||
'abc-123_DEF'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the first value of an array header', () => {
|
||||
expect(genReqId({ headers: { 'cf-ray': ['first', 'second'] } })).toBe(
|
||||
'first'
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores a client-supplied x-request-id', () => {
|
||||
expect(genReqId({ headers: { 'x-request-id': 'client-spoofed' } })).toMatch(
|
||||
UUID_PATTERN
|
||||
);
|
||||
});
|
||||
|
||||
it('generates a uuid when the header is missing', () => {
|
||||
expect(genReqId({ headers: {} })).toMatch(UUID_PATTERN);
|
||||
});
|
||||
|
||||
it('rejects headers longer than 64 characters', () => {
|
||||
expect(genReqId({ headers: { 'cf-ray': 'a'.repeat(65) } })).toMatch(
|
||||
UUID_PATTERN
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects headers with characters outside [A-Za-z0-9_-]', () => {
|
||||
for (const dirty of ['abc def', 'abc\ndef', 'abc"def', 'abc{def']) {
|
||||
expect(genReqId({ headers: { 'cf-ray': dirty } })).toMatch(UUID_PATTERN);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLoggerOptions', () => {
|
||||
const captureLog = (write: (logger: Logger) => void): string => {
|
||||
const lines: string[] = [];
|
||||
const sink = new Writable({
|
||||
write(chunk: Buffer, _enc, cb) {
|
||||
lines.push(chunk.toString());
|
||||
cb();
|
||||
}
|
||||
});
|
||||
const logger = pino(getLoggerOptions('info'), sink);
|
||||
write(logger);
|
||||
return lines.join('');
|
||||
};
|
||||
|
||||
it('sets the requested level', () => {
|
||||
expect(getLoggerOptions('warn').level).toBe('warn');
|
||||
});
|
||||
|
||||
it('redacts sensitive query parameters in serialized requests', () => {
|
||||
const output = captureLog(logger =>
|
||||
logger.info(
|
||||
{
|
||||
req: fakeRequest({
|
||||
query: { token: 'super-secret', page: '1' }
|
||||
})
|
||||
},
|
||||
'incoming request'
|
||||
)
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(output) as {
|
||||
req: { query: { token: string; page: string } };
|
||||
};
|
||||
expect(parsed.req.query.token).toBe('[REDACTED]');
|
||||
expect(parsed.req.query.page).toBe('1');
|
||||
});
|
||||
|
||||
it('redacts oauth state and id_token query parameters', () => {
|
||||
const output = captureLog(logger =>
|
||||
logger.info(
|
||||
{
|
||||
req: fakeRequest({
|
||||
query: { state: 'csrf-state', id_token: 'jwt-secret', page: '1' }
|
||||
})
|
||||
},
|
||||
'incoming request'
|
||||
)
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(output) as {
|
||||
req: { query: { state: string; id_token: string; page: string } };
|
||||
};
|
||||
expect(parsed.req.query.state).toBe('[REDACTED]');
|
||||
expect(parsed.req.query.id_token).toBe('[REDACTED]');
|
||||
expect(parsed.req.query.page).toBe('1');
|
||||
});
|
||||
|
||||
it('exposes a mixin that is safe to call without an active Sentry span', () => {
|
||||
const { mixin } = getLoggerOptions('info');
|
||||
expect(mixin).toBeTypeOf('function');
|
||||
expect(mixin!({}, 30, pino({ level: 'info' }))).toEqual({});
|
||||
});
|
||||
|
||||
it('serializes req with the standard lowercase shape in log output', () => {
|
||||
const output = captureLog(logger =>
|
||||
logger.info(
|
||||
{ req: fakeRequest({ headers: { 'user-agent': 'vitest' } }) },
|
||||
'incoming request'
|
||||
)
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(output) as { req: Record<string, unknown> };
|
||||
expect(parsed.req.method).toBe('GET');
|
||||
expect(parsed.req.url).toBe('/status/ping');
|
||||
expect(parsed.req).not.toHaveProperty('REQ_METHOD');
|
||||
});
|
||||
});
|
||||
+156
-100
@@ -1,118 +1,174 @@
|
||||
import { Transform, TransformCallback, TransformOptions } from 'stream';
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { FastifyRequest, FastifyReply, HookHandlerDoneFunction } from 'fastify';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import type {
|
||||
TransportTargetOptions,
|
||||
// DestinationStream,
|
||||
LoggerOptions,
|
||||
DestinationStream
|
||||
} from 'pino';
|
||||
import { pino, transport } from 'pino';
|
||||
import type { Logger, LoggerOptions } from 'pino';
|
||||
import { pino } from 'pino';
|
||||
|
||||
import { FCC_API_LOG_LEVEL, FCC_API_LOG_TRANSPORT } from './env.js';
|
||||
|
||||
const serializers = {
|
||||
req: (req: FastifyRequest) => {
|
||||
const id = req.id || 'ID not found';
|
||||
const method = req.method || 'METHOD not found';
|
||||
const url = req.url || 'URL not found';
|
||||
const xForwardedFor = Array.isArray(req.headers['x-forwarded-for'])
|
||||
? req.headers['x-forwarded-for'][0]
|
||||
? req.headers['x-forwarded-for'][0]
|
||||
: req.headers['x-forwarded-for']
|
||||
: req.headers['x-forwarded-for'];
|
||||
const ip =
|
||||
req.headers['cf-connecting-ip'] ||
|
||||
xForwardedFor ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.ip ||
|
||||
'IP not found';
|
||||
const userAgent = req.headers['user-agent'] || 'USER_AGENT not found';
|
||||
const country = req.headers['cf-ipcountry'] || 'COUNTRY not found';
|
||||
const query = isEmpty(req.query) ? 'QUERY not found' : req.query;
|
||||
const firstValue = (
|
||||
value: string | string[] | undefined
|
||||
): string | undefined => (Array.isArray(value) ? value[0] : value);
|
||||
|
||||
const firstHop = (value: string | string[] | undefined): string | undefined =>
|
||||
firstValue(value)?.split(',')[0]?.trim();
|
||||
|
||||
const clientIp = (req: FastifyRequest): string | undefined =>
|
||||
firstValue(req.headers['cf-connecting-ip']) ??
|
||||
firstHop(req.headers['x-forwarded-for']) ??
|
||||
firstValue(req.headers['x-real-ip']) ??
|
||||
req.ip;
|
||||
|
||||
/**
|
||||
* Extract the client IP and country from proxy headers for fraud triage.
|
||||
*
|
||||
* @param req The incoming request.
|
||||
* @returns The client IP and ISO country code, when present.
|
||||
*/
|
||||
export const clientNetInfo = (
|
||||
req: FastifyRequest
|
||||
): { ip: string | undefined; country: string | undefined } => ({
|
||||
ip: clientIp(req),
|
||||
country: firstValue(req.headers['cf-ipcountry'])
|
||||
});
|
||||
|
||||
type SerializedRequest = {
|
||||
method: string;
|
||||
url: string;
|
||||
route?: string;
|
||||
ip: string | undefined;
|
||||
userAgent: string | undefined;
|
||||
country: string | undefined;
|
||||
query?: unknown;
|
||||
};
|
||||
|
||||
const errSerializer = (err: unknown, depth = 0): Record<string, unknown> => {
|
||||
if (typeof err !== 'object' || err === null) return { message: String(err) };
|
||||
const e = err as Record<string, unknown>;
|
||||
const safe: Record<string, unknown> = {
|
||||
type: (e.constructor as { name?: string } | undefined)?.name ?? e.name,
|
||||
message: e.message,
|
||||
stack: e.stack
|
||||
};
|
||||
for (const key of ['code', 'statusCode', 'requestId'] as const) {
|
||||
if (e[key] !== undefined) safe[key] = e[key];
|
||||
}
|
||||
if (depth < 3 && e.cause != null)
|
||||
safe.cause = errSerializer(e.cause, depth + 1);
|
||||
return safe;
|
||||
};
|
||||
|
||||
export const serializers = {
|
||||
req: (req: FastifyRequest): SerializedRequest => ({
|
||||
method: req.method,
|
||||
url: req.url.split('?')[0] ?? req.url,
|
||||
...(req.routeOptions?.url ? { route: req.routeOptions.url } : {}),
|
||||
ip: clientIp(req),
|
||||
userAgent: firstValue(req.headers['user-agent']),
|
||||
country: firstValue(req.headers['cf-ipcountry']),
|
||||
...(isEmpty(req.query) ? {} : { query: req.query })
|
||||
}),
|
||||
res: (reply: FastifyReply): { statusCode: number } => ({
|
||||
statusCode: reply.statusCode
|
||||
}),
|
||||
err: errSerializer
|
||||
};
|
||||
|
||||
const REQUEST_ID_PATTERN = /^[\w-]{1,64}$/;
|
||||
|
||||
/**
|
||||
* Generate a request id, preferring the Cloudflare edge ray id. A
|
||||
* client-supplied x-request-id is not trusted (spoofable / collidable).
|
||||
*
|
||||
* @param req The incoming request.
|
||||
* @returns The edge-set ray id when valid, otherwise a random UUID.
|
||||
*/
|
||||
export const genReqId = (req: {
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
}): string => {
|
||||
const edgeId = firstValue(req.headers['cf-ray']);
|
||||
return edgeId && REQUEST_ID_PATTERN.test(edgeId) ? edgeId : randomUUID();
|
||||
};
|
||||
|
||||
const SENSITIVE_QUERY_PARAMS = [
|
||||
'token',
|
||||
'email',
|
||||
'code',
|
||||
'key',
|
||||
'state',
|
||||
'id_token',
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
'password',
|
||||
'secret',
|
||||
'authorization'
|
||||
];
|
||||
|
||||
/**
|
||||
* Build the pino options shared by all logger instances.
|
||||
*
|
||||
* @param level The minimum log level.
|
||||
* @returns The pino logger options.
|
||||
*/
|
||||
export const getLoggerOptions = (level: string): LoggerOptions => ({
|
||||
level,
|
||||
serializers,
|
||||
mixin: () => {
|
||||
const spanContext = Sentry.getActiveSpan()?.spanContext();
|
||||
if (!spanContext) return {};
|
||||
return {
|
||||
REQ_METHOD: method,
|
||||
REQ_URL: url,
|
||||
REQ_IP: ip,
|
||||
REQ_USER_AGENT: userAgent,
|
||||
REQ_COUNTRY: country,
|
||||
REQ_QUERY: query,
|
||||
REQ_ID: id
|
||||
traceId: spanContext.traceId,
|
||||
traceSampled: (spanContext.traceFlags & 0x1) === 1
|
||||
};
|
||||
},
|
||||
res: (res: FastifyReply) => {
|
||||
return {
|
||||
RES_STATUS_CODE: res.statusCode,
|
||||
RES_ELAPSED_TIME: res.elapsedTime
|
||||
};
|
||||
redact: {
|
||||
paths: SENSITIVE_QUERY_PARAMS.map(param => `req.query.${param}`),
|
||||
censor: '[REDACTED]'
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Bind the matched route onto the request logger so per-route policies apply.
|
||||
*
|
||||
* @param req The incoming request.
|
||||
* @param reply The reply whose logger is rebound alongside the request logger.
|
||||
* @param done The hook completion callback.
|
||||
*/
|
||||
export const bindRouteToLogger = (
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
done: HookHandlerDoneFunction
|
||||
): void => {
|
||||
const route = req.routeOptions?.url;
|
||||
if (route) {
|
||||
req.log = reply.log = req.log.child({ route });
|
||||
}
|
||||
done();
|
||||
};
|
||||
|
||||
const prettyTarget: TransportTargetOptions = {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
singleLine: true,
|
||||
translateTime: 'HH:MM:ss Z',
|
||||
ignore: 'pid,hostname',
|
||||
colorize: true
|
||||
}
|
||||
};
|
||||
|
||||
class DeduplicatingTransform extends Transform {
|
||||
constructor(options?: TransformOptions) {
|
||||
super({ ...options, objectMode: false });
|
||||
}
|
||||
|
||||
_transform(
|
||||
chunk: Buffer | string,
|
||||
encoding: BufferEncoding,
|
||||
callback: TransformCallback
|
||||
): void {
|
||||
try {
|
||||
const logString = Buffer.isBuffer(chunk) ? chunk.toString() : chunk;
|
||||
logString.split('\n').forEach(line => {
|
||||
if (line.trim() === '') return;
|
||||
const logObject = JSON.parse(line);
|
||||
const processedLog = JSON.parse(JSON.stringify(logObject));
|
||||
this.push(JSON.stringify(processedLog) + '\n');
|
||||
});
|
||||
callback();
|
||||
} catch (_err) {
|
||||
// If parsing or processing fails, pass the original chunk through
|
||||
// In a production scenario, you might want to log this internal error
|
||||
// to a different stream or use a fallback mechanism.
|
||||
this.push(chunk);
|
||||
callback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a logger instance.
|
||||
*
|
||||
* @returns A logger instance.
|
||||
*/
|
||||
export const getLogger = () => {
|
||||
const isPretty = FCC_API_LOG_TRANSPORT === 'pretty';
|
||||
const options: LoggerOptions = {
|
||||
level: FCC_API_LOG_LEVEL || 'info',
|
||||
serializers
|
||||
};
|
||||
export const getLogger = (): Logger => {
|
||||
const options = getLoggerOptions(FCC_API_LOG_LEVEL || 'info');
|
||||
|
||||
if (isPretty) {
|
||||
const stream = transport({ targets: [prettyTarget] }) as
|
||||
| DestinationStream
|
||||
| undefined;
|
||||
|
||||
return pino(options, stream);
|
||||
} else {
|
||||
// For non-pretty, use the custom de-duplicating transform stream
|
||||
// This logger will write to a stream that then pipes to our de-duplicator
|
||||
const deduplicator = new DeduplicatingTransform();
|
||||
// Pino writes NDJSON, so our transform needs to handle that.
|
||||
// The pino instance itself doesn't need a complex transport, it writes to the stream.
|
||||
const logger = pino(options, deduplicator);
|
||||
deduplicator.pipe(process.stdout);
|
||||
return logger;
|
||||
if (FCC_API_LOG_TRANSPORT === 'pretty') {
|
||||
return pino({
|
||||
...options,
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
singleLine: true,
|
||||
translateTime: 'HH:MM:ss Z',
|
||||
ignore: 'pid,hostname',
|
||||
colorize: true
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return pino(options);
|
||||
};
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('redirection', () => {
|
||||
expect(
|
||||
getReturnTo(encryptedReturnTo, validJWTSecret, defaultOrigin)
|
||||
).toStrictEqual(defaultObject);
|
||||
expect(console.log).toHaveBeenCalled();
|
||||
expect(console.log).not.toHaveBeenCalled();
|
||||
console.log = oldLog;
|
||||
});
|
||||
|
||||
|
||||
@@ -23,9 +23,7 @@ export function getReturnTo(
|
||||
let params;
|
||||
try {
|
||||
params = jwt.verify(encryptedParams, secret);
|
||||
} catch (e) {
|
||||
// TODO: report to Sentry? Probably not. Remove entirely?
|
||||
console.log(e);
|
||||
} catch {
|
||||
// something went wrong, use default params
|
||||
params = {
|
||||
returnTo: `${_homeLocation}/learn`,
|
||||
|
||||
@@ -0,0 +1,768 @@
|
||||
import type { ErrorEvent, Log } from '@sentry/node';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
makeShouldSendLog,
|
||||
makeTracesSampler,
|
||||
scrubRedundantLogAttributes,
|
||||
scrubRequestPii
|
||||
} from './sentry.js';
|
||||
|
||||
const makeLog = (overrides: Partial<Log> = {}): Log => ({
|
||||
level: 'info',
|
||||
message: 'something happened',
|
||||
attributes: {},
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe('shouldSendLog', () => {
|
||||
const shouldSendLog = makeShouldSendLog(1);
|
||||
|
||||
it('drops the incoming request message regardless of level', () => {
|
||||
expect(shouldSendLog(makeLog({ message: 'incoming request' }))).toBe(false);
|
||||
expect(
|
||||
shouldSendLog(makeLog({ message: 'incoming request', level: 'error' }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('drops the fastify per-interface boot line by message prefix', () => {
|
||||
expect(
|
||||
shouldSendLog(
|
||||
makeLog({ message: 'Server listening at http://127.0.0.1:3000' })
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSendLog(
|
||||
makeLog({ message: 'Server listening at http://[::1]:3000' })
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the custom "API server started" boot log', () => {
|
||||
expect(
|
||||
shouldSendLog(
|
||||
makeLog({ message: 'API server started', attributes: { audit: true } })
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('drops debug logs from suppressed routes', () => {
|
||||
expect(
|
||||
shouldSendLog(
|
||||
makeLog({ level: 'debug', attributes: { route: '/user/session-user' } })
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps debug logs on other routes', () => {
|
||||
expect(
|
||||
shouldSendLog(
|
||||
makeLog({ level: 'debug', attributes: { route: '/some/route' } })
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps debug logs without a route', () => {
|
||||
expect(shouldSendLog(makeLog({ level: 'debug' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('drops per-request framework lifecycle chatter regardless of route', () => {
|
||||
for (const message of ['request completed', 'stream closed prematurely']) {
|
||||
expect(shouldSendLog(makeLog({ message }))).toBe(false);
|
||||
expect(
|
||||
shouldSendLog(
|
||||
makeLog({ message, attributes: { route: '/some/route' } })
|
||||
)
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps request errored so response failures still reach Sentry', () => {
|
||||
expect(
|
||||
shouldSendLog(makeLog({ message: 'request errored', level: 'error' }))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps non-info levels', () => {
|
||||
for (const level of ['warn', 'error', 'fatal'] as const) {
|
||||
expect(shouldSendLog(makeLog({ level }))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps non-info levels even on a suppressed route', () => {
|
||||
expect(
|
||||
shouldSendLog(
|
||||
makeLog({ level: 'error', attributes: { route: '/user/session-user' } })
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('drops info logs from suppressed routes', () => {
|
||||
expect(
|
||||
shouldSendLog(makeLog({ attributes: { route: '/user/session-user' } }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps info logs on other routes', () => {
|
||||
expect(
|
||||
shouldSendLog(makeLog({ attributes: { route: '/some/route' } }))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps info logs without a route', () => {
|
||||
expect(shouldSendLog(makeLog())).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeShouldSendLog — debug sampling', () => {
|
||||
const debugLog = (overrides: Partial<Log> = {}): Log =>
|
||||
makeLog({ level: 'debug', ...overrides });
|
||||
|
||||
it('drops debug from suppressed routes even when the trace is sampled', () => {
|
||||
expect(
|
||||
makeShouldSendLog(1)(
|
||||
debugLog({
|
||||
attributes: { route: '/status/ping', traceSampled: true }
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps debug whose trace is sampled regardless of rate', () => {
|
||||
expect(
|
||||
makeShouldSendLog(0)(
|
||||
debugLog({ attributes: { traceId: 'abc', traceSampled: true } })
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('drops ambient (unsampled-trace) debug at rate 0', () => {
|
||||
expect(
|
||||
makeShouldSendLog(0)(debugLog({ attributes: { traceId: 'abc' } }))
|
||||
).toBe(false);
|
||||
expect(makeShouldSendLog(0)(debugLog())).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps ambient debug at rate 1', () => {
|
||||
expect(
|
||||
makeShouldSendLog(1)(debugLog({ attributes: { traceId: 'abc' } }))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('samples debug deterministically by traceId', () => {
|
||||
const log = debugLog({ attributes: { traceId: 'deadbeef' } });
|
||||
const first = makeShouldSendLog(0.5)(log);
|
||||
const second = makeShouldSendLog(0.5)(log);
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it('keeps warn/error/fatal regardless of the debug rate', () => {
|
||||
for (const level of ['warn', 'error', 'fatal'] as const) {
|
||||
expect(makeShouldSendLog(0)(makeLog({ level }))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeShouldSendLog — info sampling', () => {
|
||||
it('always keeps audit info logs even at a zero sample rate', () => {
|
||||
expect(
|
||||
makeShouldSendLog(1, 0)(makeLog({ attributes: { audit: true } }))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('always keeps audit info logs on an otherwise suppressed route', () => {
|
||||
expect(
|
||||
makeShouldSendLog(
|
||||
1,
|
||||
0
|
||||
)(
|
||||
makeLog({
|
||||
attributes: { audit: true, route: '/some/route' }
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps audit info logs on a real suppressed route even at zero sample rates', () => {
|
||||
expect(
|
||||
makeShouldSendLog(
|
||||
0,
|
||||
0
|
||||
)(
|
||||
makeLog({
|
||||
message: 'audit',
|
||||
attributes: { audit: true, route: '/user/session-user' }
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps audit logs regardless of level, including debug on a suppressed route', () => {
|
||||
expect(
|
||||
makeShouldSendLog(
|
||||
0,
|
||||
0
|
||||
)(
|
||||
makeLog({
|
||||
level: 'debug',
|
||||
message: 'audit',
|
||||
attributes: { audit: true, route: '/user/session-user' }
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('drops non-audit info logs at a zero sample rate', () => {
|
||||
expect(
|
||||
makeShouldSendLog(1, 0)(makeLog({ attributes: { traceId: 'abc' } }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps non-audit info logs at a sample rate of 1', () => {
|
||||
expect(
|
||||
makeShouldSendLog(1, 1)(makeLog({ attributes: { traceId: 'abc' } }))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps info whose trace is sampled regardless of the info rate', () => {
|
||||
expect(
|
||||
makeShouldSendLog(
|
||||
1,
|
||||
0
|
||||
)(makeLog({ attributes: { traceId: 'abc', traceSampled: true } }))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('samples non-audit info deterministically by traceId', () => {
|
||||
const log = makeLog({ attributes: { traceId: 'deadbeef' } });
|
||||
const first = makeShouldSendLog(1, 0.5)(log);
|
||||
const second = makeShouldSendLog(1, 0.5)(log);
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it('never touches warn/error/fatal regardless of the info rate', () => {
|
||||
for (const level of ['warn', 'error', 'fatal'] as const) {
|
||||
expect(makeShouldSendLog(1, 0)(makeLog({ level }))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults to a sample rate of 1 when none is given', () => {
|
||||
expect(
|
||||
makeShouldSendLog(1)(makeLog({ attributes: { traceId: 'abc' } }))
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scrubRedundantLogAttributes', () => {
|
||||
it('drops pino bindings duplicated by Sentry-native fields', () => {
|
||||
const result = scrubRedundantLogAttributes(
|
||||
makeLog({
|
||||
attributes: {
|
||||
msg: 'hello',
|
||||
'pino.logger.level': 30,
|
||||
message: 'hello',
|
||||
trace_id: 'abc',
|
||||
level: 'info',
|
||||
severity_number: 9,
|
||||
userId: 'user-42'
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.attributes).toEqual({
|
||||
message: 'hello',
|
||||
trace_id: 'abc',
|
||||
level: 'info',
|
||||
severity_number: 9,
|
||||
userId: 'user-42'
|
||||
});
|
||||
});
|
||||
|
||||
it('is a no-op when there are no attributes', () => {
|
||||
expect(
|
||||
scrubRedundantLogAttributes(makeLog({ attributes: undefined }))
|
||||
).toEqual(makeLog({ attributes: undefined }));
|
||||
});
|
||||
|
||||
it('redacts secret- and payment-credential-shaped attribute keys', () => {
|
||||
const result = scrubRedundantLogAttributes(
|
||||
makeLog({
|
||||
attributes: {
|
||||
audit: true,
|
||||
userId: 'user-42',
|
||||
email: 'a@b.com',
|
||||
client_secret: 'pi_secret_LEAK',
|
||||
authorization: 'Bearer sk_live_LEAK',
|
||||
password: 'hunter2',
|
||||
api_key: 'key_LEAK',
|
||||
token: 'tok_LEAK',
|
||||
'err.raw.client_secret': 'nested_LEAK'
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.attributes).toEqual({
|
||||
audit: true,
|
||||
userId: 'user-42',
|
||||
email: 'a@b.com',
|
||||
client_secret: '[REDACTED]',
|
||||
authorization: '[REDACTED]',
|
||||
password: '[REDACTED]',
|
||||
api_key: '[REDACTED]',
|
||||
token: '[REDACTED]',
|
||||
'err.raw.client_secret': '[REDACTED]'
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts secrets nested inside object attribute values', () => {
|
||||
const result = scrubRedundantLogAttributes(
|
||||
makeLog({
|
||||
attributes: {
|
||||
err: { message: 'boom', raw: { client_secret: 'LEAK' } }
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
expect(JSON.stringify(result.attributes)).not.toContain('LEAK');
|
||||
});
|
||||
|
||||
it('keeps intentional PII (email, ip, country) on an audit log', () => {
|
||||
const result = scrubRedundantLogAttributes(
|
||||
makeLog({
|
||||
attributes: {
|
||||
audit: true,
|
||||
email: 'a@b.com',
|
||||
ip: '1.2.3.4',
|
||||
country: 'US'
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.attributes).toEqual({
|
||||
audit: true,
|
||||
email: 'a@b.com',
|
||||
ip: '1.2.3.4',
|
||||
country: 'US'
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts email but keeps ip/country on a non-audit log', () => {
|
||||
const result = scrubRedundantLogAttributes(
|
||||
makeLog({
|
||||
attributes: { email: 'a@b.com', ip: '1.2.3.4', country: 'US' }
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.attributes).toEqual({
|
||||
email: '[REDACTED]',
|
||||
ip: '1.2.3.4',
|
||||
country: 'US'
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts secret-token-shaped substrings found inside a string value', () => {
|
||||
const result = scrubRedundantLogAttributes(
|
||||
makeLog({
|
||||
attributes: { note: 'leaked key sk_live_ABC123 in the log' }
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.attributes?.note).toBe('leaked key [REDACTED] in the log');
|
||||
});
|
||||
|
||||
it('redacts an email substring in a non-audit log string value', () => {
|
||||
const result = scrubRedundantLogAttributes(
|
||||
makeLog({ attributes: { note: 'contact a@b.com for help' } })
|
||||
);
|
||||
|
||||
expect(result.attributes?.note).toBe('contact [REDACTED] for help');
|
||||
});
|
||||
|
||||
it('keeps an email substring in an audit log string value', () => {
|
||||
const result = scrubRedundantLogAttributes(
|
||||
makeLog({ attributes: { audit: true, note: 'contact a@b.com for help' } })
|
||||
);
|
||||
|
||||
expect(result.attributes?.note).toBe('contact a@b.com for help');
|
||||
});
|
||||
|
||||
it('redacts a bare email attribute value on a non-audit log', () => {
|
||||
const result = scrubRedundantLogAttributes(
|
||||
makeLog({ attributes: { email: 'keep@me.com' } })
|
||||
);
|
||||
|
||||
expect(result.attributes?.email).toBe('[REDACTED]');
|
||||
});
|
||||
|
||||
it('redacts a bare JWT substring in a log string value', () => {
|
||||
const result = scrubRedundantLogAttributes(
|
||||
makeLog({
|
||||
attributes: {
|
||||
note: 'token eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.dozjgNryP4J3jVmNHl0w5 rest'
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.attributes?.note).toBe('token [REDACTED] rest');
|
||||
});
|
||||
|
||||
it('redacts a lowercase bearer token substring in a log string value', () => {
|
||||
const result = scrubRedundantLogAttributes(
|
||||
makeLog({
|
||||
attributes: {
|
||||
note: 'auth bearer abcdefghijklmnopqrstuvwxyz012345 done'
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.attributes?.note).toBe('auth [REDACTED] done');
|
||||
});
|
||||
|
||||
it('fail-safe redacts a Logs attribute value nested past the depth cap', () => {
|
||||
const buildNested = (depth: number, leaf: unknown): unknown =>
|
||||
depth <= 0 ? leaf : { nested: buildNested(depth - 1, leaf) };
|
||||
|
||||
const result = scrubRedundantLogAttributes(
|
||||
makeLog({
|
||||
attributes: buildNested(9, 'deep-plain-value') as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
})
|
||||
);
|
||||
|
||||
const serialized = JSON.stringify(result.attributes);
|
||||
expect(serialized).toContain('[REDACTED]');
|
||||
expect(serialized).not.toContain('deep-plain-value');
|
||||
});
|
||||
|
||||
it('redacts a secret-shaped substring in the log message', () => {
|
||||
const result = scrubRedundantLogAttributes(
|
||||
makeLog({
|
||||
message: 'auth bearer abcdefghijklmnopqrstuvwxyz012345 failed'
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.message).toBe('auth [REDACTED] failed');
|
||||
});
|
||||
|
||||
it('redacts an email in a non-audit log message', () => {
|
||||
const result = scrubRedundantLogAttributes(
|
||||
makeLog({ message: 'error for user@example.com occurred' })
|
||||
);
|
||||
|
||||
expect(result.message).toBe('error for [REDACTED] occurred');
|
||||
});
|
||||
|
||||
it('keeps an email in an audit log message', () => {
|
||||
const result = scrubRedundantLogAttributes(
|
||||
makeLog({
|
||||
message: 'donation outreach to user@example.com queued',
|
||||
attributes: { audit: true }
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.message).toBe('donation outreach to user@example.com queued');
|
||||
});
|
||||
|
||||
it('redacts a Stripe webhook secret in an attribute value', () => {
|
||||
const result = scrubRedundantLogAttributes(
|
||||
makeLog({ attributes: { note: 'sig whsec_abcdef0123456789 ok' } })
|
||||
);
|
||||
|
||||
expect(result.attributes?.note).toBe('sig [REDACTED] ok');
|
||||
});
|
||||
|
||||
it('redacts a Basic auth credential in an attribute value', () => {
|
||||
const result = scrubRedundantLogAttributes(
|
||||
makeLog({
|
||||
attributes: { note: 'auth Basic dXNlcjpodW50ZXIyc2VjcmV0 end' }
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.attributes?.note).toBe('auth [REDACTED] end');
|
||||
});
|
||||
|
||||
it('scrubs a pathological JWT-prefix string without catastrophic backtracking', () => {
|
||||
const attack = 'eyJ'.repeat(80000);
|
||||
const result = scrubRedundantLogAttributes(
|
||||
makeLog({ attributes: { note: attack } })
|
||||
);
|
||||
|
||||
expect(typeof result.attributes?.note).toBe('string');
|
||||
});
|
||||
|
||||
it('redacts a secret in a boxed-String (parameterized) message', () => {
|
||||
const message = Object.assign(
|
||||
new String('issued token sk_live_BOXEDLEAK now'),
|
||||
{
|
||||
__sentry_template_string__: 'issued token %s now',
|
||||
__sentry_template_values__: ['sk_live_BOXEDLEAK']
|
||||
}
|
||||
) as unknown as Log['message'];
|
||||
|
||||
const result = scrubRedundantLogAttributes(makeLog({ message }));
|
||||
|
||||
expect(String(result.message)).toBe('issued token [REDACTED] now');
|
||||
});
|
||||
|
||||
it('redacts secrets nested inside an array attribute value', () => {
|
||||
const result = scrubRedundantLogAttributes(
|
||||
makeLog({
|
||||
attributes: { items: [{ list: [{ client_secret: 'ARRLEAK' }] }] }
|
||||
})
|
||||
);
|
||||
|
||||
expect(JSON.stringify(result.attributes)).not.toContain('ARRLEAK');
|
||||
});
|
||||
});
|
||||
|
||||
describe('scrubRequestPii', () => {
|
||||
const eventWithRequest = (request: ErrorEvent['request']): ErrorEvent => ({
|
||||
type: undefined,
|
||||
request
|
||||
});
|
||||
|
||||
it('is a no-op when the event has no request', () => {
|
||||
const event = eventWithRequest(undefined);
|
||||
expect(scrubRequestPii(event)).toEqual(event);
|
||||
});
|
||||
|
||||
it('strips the query string entirely and the query portion of the url', () => {
|
||||
const result = scrubRequestPii(
|
||||
eventWithRequest({
|
||||
url: 'https://api.freecodecamp.org/donate?code=abc123',
|
||||
query_string: 'code=abc123'
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.request?.query_string).toBeUndefined();
|
||||
expect(result.request?.url).toBe('https://api.freecodecamp.org/donate');
|
||||
});
|
||||
|
||||
it('redacts an email embedded in the url path', () => {
|
||||
const result = scrubRequestPii(
|
||||
eventWithRequest({ url: 'https://api.freecodecamp.org/u/foo@bar.com' })
|
||||
);
|
||||
|
||||
expect(result.request?.url).toBe(
|
||||
'https://api.freecodecamp.org/u/[REDACTED]'
|
||||
);
|
||||
});
|
||||
|
||||
it('redacts PII- and secret-shaped keys from the request body', () => {
|
||||
const result = scrubRequestPii(
|
||||
eventWithRequest({
|
||||
data: {
|
||||
paymentMethodId: 'pm_12345',
|
||||
email: 'a@b.com',
|
||||
name: 'Camper Bot',
|
||||
code: 'oauth-code',
|
||||
state: 'oauth-state',
|
||||
amount: 500
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.request?.data).toEqual({
|
||||
paymentMethodId: '[REDACTED]',
|
||||
email: '[REDACTED]',
|
||||
name: '[REDACTED]',
|
||||
code: '[REDACTED]',
|
||||
state: '[REDACTED]',
|
||||
amount: 500
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts sensitive request headers', () => {
|
||||
const result = scrubRequestPii(
|
||||
eventWithRequest({
|
||||
headers: { authorization: 'Bearer sk_live_LEAK', 'user-agent': 'x' }
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.request?.headers).toEqual({
|
||||
authorization: '[REDACTED]',
|
||||
'user-agent': 'x'
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts email and paymentMethodId when request data is a raw JSON string', () => {
|
||||
const result = scrubRequestPii(
|
||||
eventWithRequest({
|
||||
data: JSON.stringify({
|
||||
email: 'a@b.com',
|
||||
paymentMethodId: 'pm_123',
|
||||
amount: 5
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
const data = result.request?.data;
|
||||
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
|
||||
|
||||
expect(parsed).toEqual({
|
||||
email: '[REDACTED]',
|
||||
paymentMethodId: '[REDACTED]',
|
||||
amount: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('always strips cookies regardless of their content', () => {
|
||||
const result = scrubRequestPii(
|
||||
eventWithRequest({ cookies: { jwt_access_token: 'secret' } })
|
||||
);
|
||||
|
||||
expect(result.request?.cookies).toBeUndefined();
|
||||
});
|
||||
|
||||
it('redacts an email found inside a free-text data value', () => {
|
||||
const result = scrubRequestPii(
|
||||
eventWithRequest({ data: { about: 'reach me at me@example.com' } })
|
||||
);
|
||||
|
||||
expect(result.request?.data).toEqual({
|
||||
about: 'reach me at [REDACTED]'
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts a secret-shaped value in a header whose key is not sensitive', () => {
|
||||
const result = scrubRequestPii(
|
||||
eventWithRequest({
|
||||
headers: { 'x-custom': 'Bearer abcdefghijklmnopqrstuvwxyz012345' }
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.request?.headers).toEqual({ 'x-custom': '[REDACTED]' });
|
||||
});
|
||||
|
||||
it('fail-safe redacts a value nested past the depth cap', () => {
|
||||
const buildNested = (depth: number, leaf: unknown): unknown =>
|
||||
depth <= 0 ? leaf : { nested: buildNested(depth - 1, leaf) };
|
||||
|
||||
const result = scrubRequestPii(
|
||||
eventWithRequest({ data: buildNested(9, 'just-a-plain-value') })
|
||||
);
|
||||
|
||||
const serialized = JSON.stringify(result.request?.data);
|
||||
expect(serialized).toContain('[REDACTED]');
|
||||
expect(serialized).not.toContain('just-a-plain-value');
|
||||
});
|
||||
|
||||
it('redacts an email in the exception message and in extra', () => {
|
||||
const event: ErrorEvent = {
|
||||
type: undefined,
|
||||
exception: { values: [{ value: 'failed for user x@y.com' }] },
|
||||
extra: { email: 'z@z.com' }
|
||||
};
|
||||
|
||||
const result = scrubRequestPii(event);
|
||||
|
||||
expect(result.exception?.values?.[0]?.value).toBe(
|
||||
'failed for user [REDACTED]'
|
||||
);
|
||||
expect(result.extra?.email).toBe('[REDACTED]');
|
||||
});
|
||||
|
||||
it('redacts secret-shaped local variables captured in stack frames', () => {
|
||||
const event: ErrorEvent = {
|
||||
type: undefined,
|
||||
exception: {
|
||||
values: [
|
||||
{
|
||||
value: 'boom',
|
||||
stacktrace: {
|
||||
frames: [
|
||||
{
|
||||
function: 'doThing',
|
||||
vars: {
|
||||
jwt_access_token: 'signed-cookie-value',
|
||||
note: 'holding sk_live_FRAMELEAK here',
|
||||
userId: 'u1'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const result = scrubRequestPii(event);
|
||||
const vars = result.exception?.values?.[0]?.stacktrace?.frames?.[0]?.vars;
|
||||
|
||||
expect(vars?.jwt_access_token).toBe('[REDACTED]');
|
||||
expect(vars?.note).toBe('holding [REDACTED] here');
|
||||
expect(vars?.userId).toBe('u1');
|
||||
});
|
||||
|
||||
it('redacts secret-shaped fields on event.user but keeps id and email', () => {
|
||||
const event: ErrorEvent = {
|
||||
type: undefined,
|
||||
user: { id: 'u1', email: 'donor@example.com', apiKey: 'sk_live_USERLEAK' }
|
||||
};
|
||||
|
||||
const result = scrubRequestPii(event);
|
||||
|
||||
expect(result.user?.id).toBe('u1');
|
||||
expect(result.user?.email).toBe('donor@example.com');
|
||||
expect((result.user as Record<string, unknown>).apiKey).toBe('[REDACTED]');
|
||||
});
|
||||
|
||||
it('strips request.env entirely', () => {
|
||||
const result = scrubRequestPii(
|
||||
eventWithRequest({
|
||||
env: { REMOTE_USER: 'a@b.com', SERVER_SECRET: 'sk_live_ENVLEAK' }
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.request?.env).toBeUndefined();
|
||||
});
|
||||
|
||||
it('redacts secret-shaped data in breadcrumbs', () => {
|
||||
const event: ErrorEvent = {
|
||||
type: undefined,
|
||||
breadcrumbs: [
|
||||
{
|
||||
category: 'http',
|
||||
data: {
|
||||
url: 'https://api.stripe.com?key=sk_live_BCLEAK',
|
||||
token: 'ghp_BCLEAK'
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const result = scrubRequestPii(event);
|
||||
const data = result.breadcrumbs?.[0]?.data;
|
||||
|
||||
expect(data?.token).toBe('[REDACTED]');
|
||||
expect(data?.url).toBe('https://api.stripe.com?key=[REDACTED]');
|
||||
});
|
||||
|
||||
it('scrubs a 1MB JWT-prefix body within the ReDoS budget', () => {
|
||||
const result = scrubRequestPii(
|
||||
eventWithRequest({ data: 'eyJ'.repeat(333333) })
|
||||
);
|
||||
|
||||
expect(typeof result.request?.data).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeTracesSampler', () => {
|
||||
const context = (name: string) => ({
|
||||
name,
|
||||
inheritOrSampleWith: vi.fn((fallback: number) => fallback)
|
||||
});
|
||||
|
||||
it('drops health check transactions', () => {
|
||||
expect(makeTracesSampler(0.1)(context('GET /status/ping'))).toBe(0);
|
||||
expect(makeTracesSampler(0.1)(context('GET /status/ready'))).toBe(0);
|
||||
});
|
||||
|
||||
it('samples other transactions with the configured rate', () => {
|
||||
const ctx = context('GET /user/session-user');
|
||||
expect(makeTracesSampler(0.1)(ctx)).toBe(0.1);
|
||||
expect(ctx.inheritOrSampleWith).toHaveBeenCalledWith(0.1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
import type { ErrorEvent, Log, RequestEventData } from '@sentry/node';
|
||||
|
||||
const DROPPED_LOG_MESSAGES = new Set([
|
||||
'incoming request',
|
||||
'request completed',
|
||||
'stream closed prematurely'
|
||||
]);
|
||||
|
||||
const DROPPED_LOG_MESSAGE_PREFIXES = ['Server listening at'];
|
||||
|
||||
const isDroppedLogMessage = (message: Log['message']): boolean =>
|
||||
typeof message === 'string' &&
|
||||
(DROPPED_LOG_MESSAGES.has(message) ||
|
||||
DROPPED_LOG_MESSAGE_PREFIXES.some(prefix => message.startsWith(prefix)));
|
||||
|
||||
// Hot / health routes whose routine info+debug chatter is filtered out of
|
||||
// Sentry entirely (replaces the old per-route sample rates). warn/error/fatal
|
||||
// on these routes is still forwarded.
|
||||
const DROPPED_LOG_ROUTES = new Set([
|
||||
'/user/session-user',
|
||||
'/status/ping',
|
||||
'/status/ready'
|
||||
]);
|
||||
|
||||
const routeOf = (log: Log): string | undefined =>
|
||||
typeof log.attributes?.route === 'string' ? log.attributes.route : undefined;
|
||||
|
||||
const traceIdOf = (log: Log): string | undefined =>
|
||||
typeof log.attributes?.traceId === 'string'
|
||||
? log.attributes.traceId
|
||||
: undefined;
|
||||
|
||||
const traceIsSampled = (log: Log): boolean =>
|
||||
log.attributes?.traceSampled === true;
|
||||
|
||||
const isAuditLog = (log: Log): boolean => log.attributes?.audit === true;
|
||||
|
||||
const hashUnit = (value: string): number => {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash ^= value.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0) / 2 ** 32;
|
||||
};
|
||||
|
||||
const shouldSendDebug = (log: Log, debugRate: number): boolean => {
|
||||
const route = routeOf(log);
|
||||
if (route !== undefined && DROPPED_LOG_ROUTES.has(route)) return false;
|
||||
if (traceIsSampled(log)) return true;
|
||||
const traceId = traceIdOf(log);
|
||||
const roll = traceId !== undefined ? hashUnit(traceId) : Math.random();
|
||||
return roll < debugRate;
|
||||
};
|
||||
|
||||
const shouldSendInfo = (log: Log, infoRate: number): boolean => {
|
||||
if (traceIsSampled(log)) return true;
|
||||
const traceId = traceIdOf(log);
|
||||
const roll = traceId !== undefined ? hashUnit(traceId) : Math.random();
|
||||
return roll < infoRate;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the beforeSendLog filter. Warn, error and fatal always pass. Info
|
||||
* passes unless it is on a hot or health route, and is otherwise sampled at
|
||||
* the given info rate — unless it carries `audit: true`, which always
|
||||
* passes. Debug is trace-aware: it is kept in full for a sampled trace,
|
||||
* otherwise sampled deterministically by trace id at the given debug rate.
|
||||
*
|
||||
* @param debugRate The sample rate for debug logs not on a sampled trace.
|
||||
* @param infoRate The sample rate for non-audit info logs not on a sampled
|
||||
* trace. Defaults to 1 (send all), matching the pre-sampling behavior.
|
||||
* @returns A predicate deciding whether a log is forwarded to Sentry.
|
||||
*/
|
||||
export const makeShouldSendLog =
|
||||
(debugRate: number, infoRate = 1) =>
|
||||
(log: Log): boolean => {
|
||||
if (isDroppedLogMessage(log.message)) return false;
|
||||
if (isAuditLog(log)) return true;
|
||||
if (log.level === 'debug') return shouldSendDebug(log, debugRate);
|
||||
if (log.level !== 'info') return true;
|
||||
const route = routeOf(log);
|
||||
if (route !== undefined && DROPPED_LOG_ROUTES.has(route)) return false;
|
||||
return shouldSendInfo(log, infoRate);
|
||||
};
|
||||
|
||||
const REDUNDANT_LOG_ATTRIBUTES = ['msg', 'pino.logger.level'] as const;
|
||||
|
||||
const SECRET_KEY_PATTERN =
|
||||
/(client_?secret|secret|passwd|password|authorization|cookie|api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|\bjwt\b|session[-_]?id|card[-_]?number|\bcvc\b|\bcvv\b|\btoken\b)/i;
|
||||
|
||||
const ISSUE_REQUEST_KEY_PATTERN =
|
||||
/(client_?secret|secret|passwd|password|authorization|cookie|api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|\bjwt\b|session[-_]?id|card[-_]?number|\bcvc\b|\bcvv\b|\btoken\b|email|name|payment_?method_?id|\bcode\b|\bstate\b)/i;
|
||||
|
||||
const VALUE_SECRET_PATTERN =
|
||||
/\bsk_(?:live|test)_[A-Za-z0-9]+\b|\bwhsec_[A-Za-z0-9]+\b|\bghp_[A-Za-z0-9]+\b|\bgithub_pat_[A-Za-z0-9_]+\b|\bxox[baprs]-[A-Za-z0-9-]+\b|\beyJ[A-Za-z0-9_-]{1,1024}\.[A-Za-z0-9_-]{1,8192}\.[A-Za-z0-9_-]{1,1024}|[Bb](?:earer|asic) [A-Za-z0-9._~+/=-]{16,4096}/g;
|
||||
|
||||
const EMAIL_PATTERN =
|
||||
/\b[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}/g;
|
||||
|
||||
const MAX_SCRUB_LENGTH = 200_000;
|
||||
|
||||
const redactSecretSubstrings = (value: string): string =>
|
||||
value.length > MAX_SCRUB_LENGTH
|
||||
? value
|
||||
: value.replace(VALUE_SECRET_PATTERN, '[REDACTED]');
|
||||
|
||||
const redactIssueSubstrings = (value: string): string =>
|
||||
value.length > MAX_SCRUB_LENGTH
|
||||
? value
|
||||
: value
|
||||
.replace(VALUE_SECRET_PATTERN, '[REDACTED]')
|
||||
.replace(EMAIL_PATTERN, '[REDACTED]');
|
||||
|
||||
const redactDeep = (
|
||||
value: unknown,
|
||||
keyPattern: RegExp,
|
||||
scrubValue: (v: string) => string,
|
||||
depth = 0,
|
||||
redactOnDepthCap = false
|
||||
): unknown => {
|
||||
if (value === null) return value;
|
||||
if (depth > 6) return redactOnDepthCap ? '[REDACTED]' : value;
|
||||
if (typeof value === 'string') return scrubValue(value);
|
||||
if (typeof value !== 'object') return value;
|
||||
if (Array.isArray(value))
|
||||
return value.map(entry =>
|
||||
redactDeep(entry, keyPattern, scrubValue, depth + 1, redactOnDepthCap)
|
||||
);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[key] = keyPattern.test(key)
|
||||
? '[REDACTED]'
|
||||
: redactDeep(entry, keyPattern, scrubValue, depth + 1, redactOnDepthCap);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove pino bindings that Sentry already records as native log fields, then
|
||||
* redact secret- or payment-credential-shaped values. Email addresses are also
|
||||
* redacted unless the log carries `audit: true`, the marker for the sanctioned
|
||||
* identifier buckets (donation outreach, duplicate-account) where support needs
|
||||
* the email to resolve the case.
|
||||
*
|
||||
* @param log The log entry from the SDK.
|
||||
* @returns The same log with redundant attributes removed and secrets redacted.
|
||||
*/
|
||||
export const scrubRedundantLogAttributes = (log: Log): Log => {
|
||||
const scrubValue =
|
||||
log.attributes?.audit === true
|
||||
? redactSecretSubstrings
|
||||
: redactIssueSubstrings;
|
||||
if (log.message != null) {
|
||||
log.message = scrubValue(String(log.message));
|
||||
}
|
||||
if (log.attributes == null) return log;
|
||||
for (const key of REDUNDANT_LOG_ATTRIBUTES) {
|
||||
delete log.attributes[key];
|
||||
}
|
||||
log.attributes = redactDeep(
|
||||
log.attributes,
|
||||
SECRET_KEY_PATTERN,
|
||||
scrubValue,
|
||||
0,
|
||||
true
|
||||
) as typeof log.attributes;
|
||||
return log;
|
||||
};
|
||||
|
||||
const stripQueryFromUrl = (url: string): string => url.split('?', 1)[0] ?? url;
|
||||
|
||||
const redactHeaders = (
|
||||
headers: Record<string, string>
|
||||
): Record<string, string> => {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
out[key] = ISSUE_REQUEST_KEY_PATTERN.test(key)
|
||||
? '[REDACTED]'
|
||||
: redactIssueSubstrings(value);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const scrubIssueBody = (data: unknown): unknown => {
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(data);
|
||||
return JSON.stringify(
|
||||
redactDeep(
|
||||
parsed,
|
||||
ISSUE_REQUEST_KEY_PATTERN,
|
||||
redactIssueSubstrings,
|
||||
0,
|
||||
true
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
return redactIssueSubstrings(data);
|
||||
}
|
||||
}
|
||||
return redactDeep(
|
||||
data,
|
||||
ISSUE_REQUEST_KEY_PATTERN,
|
||||
redactIssueSubstrings,
|
||||
0,
|
||||
true
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line jsdoc/require-jsdoc
|
||||
export const scrubRequestPii = (event: ErrorEvent): ErrorEvent => {
|
||||
const out: ErrorEvent = { ...event };
|
||||
const { request } = event;
|
||||
|
||||
if (request != null) {
|
||||
const scrubbed: RequestEventData = { ...request };
|
||||
delete scrubbed.query_string;
|
||||
delete scrubbed.cookies;
|
||||
delete scrubbed.env;
|
||||
if (scrubbed.url != null) {
|
||||
scrubbed.url = redactIssueSubstrings(stripQueryFromUrl(scrubbed.url));
|
||||
}
|
||||
if (scrubbed.data != null) {
|
||||
scrubbed.data = scrubIssueBody(scrubbed.data);
|
||||
}
|
||||
if (scrubbed.headers != null) {
|
||||
scrubbed.headers = redactHeaders(scrubbed.headers);
|
||||
}
|
||||
out.request = scrubbed;
|
||||
}
|
||||
|
||||
if (out.exception?.values) {
|
||||
out.exception = {
|
||||
...out.exception,
|
||||
values: out.exception.values.map(value => {
|
||||
const next = { ...value };
|
||||
if (typeof next.value === 'string') {
|
||||
next.value = redactIssueSubstrings(next.value);
|
||||
}
|
||||
if (next.stacktrace?.frames) {
|
||||
next.stacktrace = {
|
||||
...next.stacktrace,
|
||||
frames: next.stacktrace.frames.map(frame =>
|
||||
frame.vars == null
|
||||
? frame
|
||||
: {
|
||||
...frame,
|
||||
vars: redactDeep(
|
||||
frame.vars,
|
||||
ISSUE_REQUEST_KEY_PATTERN,
|
||||
redactIssueSubstrings,
|
||||
0,
|
||||
true
|
||||
) as typeof frame.vars
|
||||
}
|
||||
)
|
||||
};
|
||||
}
|
||||
return next;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
if (out.extra !== undefined) {
|
||||
out.extra = redactDeep(
|
||||
out.extra,
|
||||
ISSUE_REQUEST_KEY_PATTERN,
|
||||
redactIssueSubstrings,
|
||||
0,
|
||||
true
|
||||
) as ErrorEvent['extra'];
|
||||
}
|
||||
|
||||
if (out.user != null) {
|
||||
out.user = redactDeep(
|
||||
out.user,
|
||||
SECRET_KEY_PATTERN,
|
||||
redactSecretSubstrings,
|
||||
0,
|
||||
true
|
||||
) as ErrorEvent['user'];
|
||||
}
|
||||
|
||||
if (out.breadcrumbs) {
|
||||
out.breadcrumbs = out.breadcrumbs.map(breadcrumb =>
|
||||
breadcrumb.data == null
|
||||
? breadcrumb
|
||||
: {
|
||||
...breadcrumb,
|
||||
data: redactDeep(
|
||||
breadcrumb.data,
|
||||
ISSUE_REQUEST_KEY_PATTERN,
|
||||
redactIssueSubstrings,
|
||||
0,
|
||||
true
|
||||
) as typeof breadcrumb.data
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a traces sampler that drops health check transactions.
|
||||
*
|
||||
* @param rate The sample rate for all other transactions.
|
||||
* @returns The sampler for Sentry.init.
|
||||
*/
|
||||
export const makeTracesSampler =
|
||||
(rate: number) =>
|
||||
(context: {
|
||||
name: string;
|
||||
inheritOrSampleWith: (fallbackSampleRate: number) => number;
|
||||
}): number =>
|
||||
context.name.includes('/status/ping') ||
|
||||
context.name.includes('/status/ready')
|
||||
? 0
|
||||
: context.inheritOrSampleWith(rate);
|
||||
@@ -3,7 +3,8 @@
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "../",
|
||||
"noEmit": false
|
||||
"noEmit": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["**/*.test.*"]
|
||||
|
||||
@@ -32,6 +32,11 @@
|
||||
"PORT",
|
||||
"SENTRY_DSN",
|
||||
"SENTRY_ENVIRONMENT",
|
||||
"SENTRY_LOGS_DEBUG_SAMPLE_RATE",
|
||||
"SENTRY_LOGS_INFO_SAMPLE_RATE",
|
||||
"SENTRY_PROFILE_SESSION_SAMPLE_RATE",
|
||||
"SENTRY_SERVER_NAME",
|
||||
"SENTRY_TRACES_SAMPLE_RATE",
|
||||
"SES_ID",
|
||||
"SES_REGION",
|
||||
"SES_SECRET",
|
||||
@@ -76,6 +81,11 @@
|
||||
"PORT",
|
||||
"SENTRY_DSN",
|
||||
"SENTRY_ENVIRONMENT",
|
||||
"SENTRY_LOGS_DEBUG_SAMPLE_RATE",
|
||||
"SENTRY_LOGS_INFO_SAMPLE_RATE",
|
||||
"SENTRY_PROFILE_SESSION_SAMPLE_RATE",
|
||||
"SENTRY_SERVER_NAME",
|
||||
"SENTRY_TRACES_SAMPLE_RATE",
|
||||
"SES_ID",
|
||||
"SES_REGION",
|
||||
"SES_SECRET",
|
||||
|
||||
@@ -3,6 +3,7 @@ RUN apt-get update && apt-get install -y jq
|
||||
# global installs need root permissions, so have to happen before we switch to
|
||||
# the node user
|
||||
RUN npm i -g pnpm@10
|
||||
RUN npm i -g @sentry/cli@3.6.0
|
||||
# node images create a non-root user that we can use
|
||||
USER node
|
||||
WORKDIR /home/node/build
|
||||
@@ -31,6 +32,8 @@ ENV CURRICULUM_LOCALE=$CURRICULUM_LOCALE
|
||||
|
||||
RUN pnpm turbo -F=@freecodecamp/api build
|
||||
|
||||
RUN sentry-cli sourcemaps inject api/dist
|
||||
|
||||
FROM node:24-bookworm AS deps
|
||||
RUN apt-get update && apt-get install -y jq
|
||||
|
||||
|
||||
Generated
+161
-619
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,11 @@ MONGOHQ_URL=mongodb://127.0.0.1:27017/freecodecamp?directConnection=true
|
||||
SENTRY_DSN=dsn_from_sentry_dashboard
|
||||
SENTRY_CLIENT_DSN=dsn_from_sentry_dashboard
|
||||
SENTRY_ENVIRONMENT=development
|
||||
SENTRY_SERVER_NAME=fcc-api
|
||||
SENTRY_TRACES_SAMPLE_RATE=0.1
|
||||
SENTRY_PROFILE_SESSION_SAMPLE_RATE=0.1
|
||||
SENTRY_LOGS_DEBUG_SAMPLE_RATE=0.05
|
||||
SENTRY_LOGS_INFO_SAMPLE_RATE=1.0
|
||||
|
||||
# Auth0 - OAuth 2.0 Credentials
|
||||
AUTH0_CLIENT_ID=client_id_from_auth0_dashboard
|
||||
|
||||
Reference in New Issue
Block a user