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:
Mrugesh Mohapatra
2026-07-02 18:07:34 +05:30
committed by Mrugesh Mohapatra
parent 1f1f8a296c
commit cd94fbf5b9
77 changed files with 6664 additions and 1477 deletions
+37 -22
View File
@@ -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"
+29 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect, vi } from 'vitest';
import { timeOperation } from './query-timing.js';
describe('timeOperation', () => {
it('returns the result and emits success with a numeric duration', async () => {
const emit = vi.fn();
const result = await timeOperation(() => Promise.resolve('ok'), emit);
expect(result).toBe('ok');
expect(emit).toHaveBeenCalledWith('success', expect.any(Number));
});
it('re-throws and emits failure when the operation rejects', async () => {
const emit = vi.fn();
const boom = new Error('boom');
await expect(timeOperation(() => Promise.reject(boom), emit)).rejects.toBe(
boom
);
expect(emit).toHaveBeenCalledWith('failure', expect.any(Number));
});
});
+17
View File
@@ -0,0 +1,17 @@
import { performance } from 'node:perf_hooks';
// eslint-disable-next-line jsdoc/require-jsdoc
export const timeOperation = async <T>(
op: () => Promise<T>,
emit: (result: 'success' | 'failure', durationMs: number) => void
): Promise<T> => {
const start = performance.now();
try {
const result = await op();
emit('success', performance.now() - start);
return result;
} catch (err) {
emit('failure', performance.now() - start);
throw err;
}
};
@@ -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
View File
@@ -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
});
+183 -1
View File
@@ -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
View File
@@ -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);
+116 -4
View File
@@ -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
View File
@@ -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;
+3 -8
View File
@@ -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);
}
+1 -3
View File
@@ -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();
});
+1 -2
View File
@@ -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();
+34 -7
View File
@@ -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'
);
});
});
+3 -4
View File
@@ -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');
}
}
+1 -2
View File
@@ -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',
+63 -5
View File
@@ -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);
});
});
+18 -4
View File
@@ -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 =
+4 -11
View File
@@ -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);
});
});
+3 -4
View File
@@ -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);
}
}
+24
View File
@@ -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' }
});
});
});
+9 -3
View File
@@ -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();
+1 -2
View File
@@ -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') {
+33
View File
@@ -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();
});
+4 -1
View File
@@ -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' });
+42 -2
View File
@@ -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' })
);
});
});
});
+11 -2
View File
@@ -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' });
}
}
+63 -17
View File
@@ -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;
+22 -10
View File
@@ -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' }
});
}
}
+130 -10
View File
@@ -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);
});
+31 -11
View File
@@ -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: {
+386
View File
@@ -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;
});
});
});
+216 -104
View File
@@ -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,
+170 -2
View File
@@ -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;
});
});
+60 -19
View File
@@ -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.'
+397
View File
@@ -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 () => {
+175 -69
View File
@@ -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);
}
);
+186 -3
View File
@@ -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 () => {
+79 -5
View File
@@ -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.'
+408 -11
View File
@@ -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 () => {
+137 -73
View File
@@ -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.');
+36
View File
@@ -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 () => {
+26 -6
View File
@@ -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));
});
+62
View File
@@ -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;
});
});
});
+45 -12
View File
@@ -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,
+205
View File
@@ -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;
});
});
});
});
+96 -28
View File
@@ -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;
});
});
});
+37 -21
View File
@@ -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',
+16 -1
View File
@@ -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;
});
});
+2 -3
View File
@@ -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({});
}
+40 -1
View File
@@ -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 -4
View File
@@ -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();
};
+9 -11
View File
@@ -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
View File
@@ -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);
}
};
+27
View File
@@ -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;
+88
View File
@@ -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();
});
});
+27
View File
@@ -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();
};
+312
View File
@@ -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
View File
@@ -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);
};
+1 -1
View File
@@ -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;
});
+1 -3
View File
@@ -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`,
+768
View File
@@ -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);
});
});
+320
View File
@@ -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);
+2 -1
View File
@@ -3,7 +3,8 @@
"compilerOptions": {
"outDir": "dist",
"rootDir": "../",
"noEmit": false
"noEmit": false,
"sourceMap": true
},
"include": ["src"],
"exclude": ["**/*.test.*"]
+10
View File
@@ -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
View File
@@ -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
+161 -619
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -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