feat: add ownership check to evaluateAnswer, AuthenticatedRequest type

This commit is contained in:
lila 2026-04-28 14:39:13 +02:00
parent fdeb769640
commit 1e30f04e81
8 changed files with 189 additions and 39 deletions

View file

@ -1,30 +1,47 @@
import type { Request, Response, NextFunction } from "express";
import type { Response, NextFunction } from "express";
import type { AuthenticatedRequest } from "../types/express.js";
import { GameRequestSchema, AnswerSubmissionSchema } from "@lila/shared";
import { createGameSession, evaluateAnswer } from "../services/gameService.js";
import { ValidationError } from "../errors/AppError.js";
import type { GameSessionStore } from "../gameSessionStore/index.js";
export const createGameController = (store: GameSessionStore) => ({
createGame: async (req: Request, res: Response, next: NextFunction) => {
createGame: async (
req: AuthenticatedRequest,
res: Response,
next: NextFunction,
) => {
try {
const gameSettings = GameRequestSchema.safeParse(req.body);
if (!gameSettings.success) {
throw new ValidationError(gameSettings.error.message);
}
const gameQuestions = await createGameSession(gameSettings.data, store);
const gameQuestions = await createGameSession(
gameSettings.data,
store,
req.session.user.id,
);
res.json({ success: true, data: gameQuestions });
} catch (error) {
next(error);
}
},
submitAnswer: async (req: Request, res: Response, next: NextFunction) => {
submitAnswer: async (
req: AuthenticatedRequest,
res: Response,
next: NextFunction,
) => {
try {
const submission = AnswerSubmissionSchema.safeParse(req.body);
if (!submission.success) {
throw new ValidationError(submission.error.message);
}
const result = await evaluateAnswer(submission.data, store);
const result = await evaluateAnswer(
submission.data,
store,
req.session.user.id,
);
res.json({ success: true, data: result });
} catch (error) {
next(error);

View file

@ -1,4 +1,4 @@
export type GameSessionData = { answers: Map<string, number> };
export type GameSessionData = { answers: Map<string, number>; userId: string };
export interface GameSessionStore {
create(

View file

@ -14,14 +14,14 @@ describe("InMemoryGameSessionStore", () => {
});
it("returns session data after creation", async () => {
const data = { answers: new Map([["q1", 2]]) };
const data = { answers: new Map([["q1", 2]]), userId: "user-1" };
await store.create("session-1", data, 60_000);
const result = await store.get("session-1");
expect(result).toEqual(data);
});
it("returns null after the session is deleted", async () => {
const data = { answers: new Map([["q1", 2]]) };
const data = { answers: new Map([["q1", 2]]), userId: "user-1" };
await store.create("session-1", data, 60_000);
await store.delete("session-1");
const result = await store.get("session-1");
@ -29,7 +29,7 @@ describe("InMemoryGameSessionStore", () => {
});
it("returns null after TTL expires", async () => {
const data = { answers: new Map([["q1", 2]]) };
const data = { answers: new Map([["q1", 2]]), userId: "user-1" };
await store.create("session-1", data, 1);
await new Promise((resolve) => setTimeout(resolve, 10));
const result = await store.get("session-1");
@ -37,7 +37,7 @@ describe("InMemoryGameSessionStore", () => {
});
it("returns session data before TTL expires", async () => {
const data = { answers: new Map([["q1", 2]]) };
const data = { answers: new Map([["q1", 2]]), userId: "user-1" };
await store.create("session-1", data, 60_000);
const result = await store.get("session-1");
expect(result).not.toBeNull();

View file

@ -12,8 +12,8 @@ export const createGameRouter = (store: GameSessionStore): Router => {
router.use(requireAuth);
router.use(gameLimiter);
router.post("/start", controller.createGame);
router.post("/answer", controller.submitAnswer);
router.post("/start", controller.createGame as express.RequestHandler);
router.post("/answer", controller.submitAnswer as express.RequestHandler);
return router;
};

View file

@ -36,7 +36,6 @@ beforeEach(() => {
});
describe("createGameSession", () => {
let store: InMemoryGameSessionStore;
beforeEach(() => {
@ -44,14 +43,14 @@ describe("createGameSession", () => {
});
it("returns a session with the correct number of questions", async () => {
const session = await createGameSession(validRequest, store);
const session = await createGameSession(validRequest, store, "user-1");
expect(session.sessionId).toBeDefined();
expect(session.questions).toHaveLength(3);
});
it("each question has exactly 4 options", async () => {
const session = await createGameSession(validRequest, store);
const session = await createGameSession(validRequest, store, "user-1");
for (const question of session.questions) {
expect(question.options).toHaveLength(4);
@ -59,14 +58,14 @@ describe("createGameSession", () => {
});
it("each question has a unique questionId", async () => {
const session = await createGameSession(validRequest, store);
const session = await createGameSession(validRequest, store, "user-1");
const ids = session.questions.map((q) => q.questionId);
expect(new Set(ids).size).toBe(ids.length);
});
it("options have sequential optionIds 0-3", async () => {
const session = await createGameSession(validRequest, store);
const session = await createGameSession(validRequest, store, "user-1");
for (const question of session.questions) {
const optionIds = question.options.map((o) => o.optionId);
@ -75,7 +74,7 @@ describe("createGameSession", () => {
});
it("the correct answer is always among the options", async () => {
const session = await createGameSession(validRequest, store);
const session = await createGameSession(validRequest, store, "user-1");
for (let i = 0; i < session.questions.length; i++) {
const question = session.questions[i]!;
@ -87,7 +86,7 @@ describe("createGameSession", () => {
});
it("distractors are never the correct answer", async () => {
const session = await createGameSession(validRequest, store);
const session = await createGameSession(validRequest, store, "user-1");
for (let i = 0; i < session.questions.length; i++) {
const question = session.questions[i]!;
@ -103,7 +102,7 @@ describe("createGameSession", () => {
});
it("sets the prompt from the source text", async () => {
const session = await createGameSession(validRequest, store);
const session = await createGameSession(validRequest, store, "user-1");
expect(session.questions[0]!.prompt).toBe("dog");
expect(session.questions[1]!.prompt).toBe("cat");
@ -111,14 +110,14 @@ describe("createGameSession", () => {
});
it("passes gloss through (null or string)", async () => {
const session = await createGameSession(validRequest, store);
const session = await createGameSession(validRequest, store, "user-1");
expect(session.questions[0]!.gloss).toBeNull();
expect(session.questions[2]!.gloss).toBe("a building for living in");
});
it("calls getGameTerms with the correct arguments", async () => {
await createGameSession(validRequest, store);
await createGameSession(validRequest, store, "user-1");
expect(mockGetGameTerms).toHaveBeenCalledWith(
"en",
@ -130,7 +129,7 @@ describe("createGameSession", () => {
});
it("calls getDistractors once per question", async () => {
await createGameSession(validRequest, store);
await createGameSession(validRequest, store, "user-1");
expect(mockGetDistractors).toHaveBeenCalledTimes(3);
});
@ -138,23 +137,21 @@ describe("createGameSession", () => {
it("propagates unexpected errors from getGameTerms", async () => {
mockGetGameTerms.mockRejectedValue(new Error("connection refused"));
await expect(createGameSession(validRequest, store)).rejects.toThrow(
"connection refused",
);
await expect(
createGameSession(validRequest, store, "user-1"),
).rejects.toThrow("connection refused");
});
});
describe("evaluateAnswer", () => {
let store: InMemoryGameSessionStore;
beforeEach(() => {
store = new InMemoryGameSessionStore();
});
it("returns isCorrect: true when the correct option is selected", async () => {
const session = await createGameSession(validRequest, store);
const session = await createGameSession(validRequest, store, "user-1");
const question = session.questions[0]!;
const correctText = fakeTerms[0]!.targetText;
const correctOption = question.options.find((o) => o.text === correctText)!;
@ -166,6 +163,7 @@ describe("evaluateAnswer", () => {
selectedOptionId: correctOption.optionId,
},
store,
"user-1",
);
expect(result.isCorrect).toBe(true);
@ -174,7 +172,7 @@ describe("evaluateAnswer", () => {
});
it("returns isCorrect: false when a wrong option is selected", async () => {
const session = await createGameSession(validRequest, store);
const session = await createGameSession(validRequest, store, "user-1");
const question = session.questions[0]!;
const correctText = fakeTerms[0]!.targetText;
const correctOption = question.options.find((o) => o.text === correctText)!;
@ -187,6 +185,7 @@ describe("evaluateAnswer", () => {
selectedOptionId: wrongOption.optionId,
},
store,
"user-1",
);
expect(result.isCorrect).toBe(false);
@ -201,13 +200,13 @@ describe("evaluateAnswer", () => {
selectedOptionId: 0,
};
await expect(evaluateAnswer(submission, store)).rejects.toThrow(
await expect(evaluateAnswer(submission, store, "user-1")).rejects.toThrow(
"Game session not found",
);
});
it("throws NotFoundError for a non-existent question", async () => {
const session = await createGameSession(validRequest, store);
const session = await createGameSession(validRequest, store, "user-1");
const submission: AnswerSubmission = {
sessionId: session.sessionId,
@ -215,13 +214,13 @@ describe("evaluateAnswer", () => {
selectedOptionId: 0,
};
await expect(evaluateAnswer(submission, store)).rejects.toThrow(
await expect(evaluateAnswer(submission, store, "user-1")).rejects.toThrow(
"Question not found",
);
});
it("throws NotFoundError when the same question is submitted twice", async () => {
const session = await createGameSession(validRequest, store);
const session = await createGameSession(validRequest, store, "user-1");
const question = session.questions[0]!;
await evaluateAnswer(
@ -231,6 +230,7 @@ describe("evaluateAnswer", () => {
selectedOptionId: 0,
},
store,
"user-1",
);
await expect(
@ -241,12 +241,13 @@ describe("evaluateAnswer", () => {
selectedOptionId: 0,
},
store,
"user-1",
),
).rejects.toThrow("Question not found");
});
it("deletes the session after the last question is answered", async () => {
const session = await createGameSession(validRequest, store);
const session = await createGameSession(validRequest, store, "user-1");
for (const question of session.questions) {
await evaluateAnswer(
@ -256,6 +257,7 @@ describe("evaluateAnswer", () => {
selectedOptionId: 0,
},
store,
"user-1",
);
}
@ -267,6 +269,7 @@ describe("evaluateAnswer", () => {
selectedOptionId: 0,
},
store,
"user-1",
),
).rejects.toThrow("Game session not found");
});

View file

@ -15,6 +15,7 @@ import { shuffleArray } from "../lib/utils.js";
export const createGameSession = async (
request: GameRequest,
store: GameSessionStore,
userId: string,
): Promise<GameSession> => {
const terms = await getGameTerms(
request.source_language,
@ -59,7 +60,7 @@ export const createGameSession = async (
);
const sessionId = randomUUID();
await store.create(sessionId, { answers: answerKey }, 30 * 60 * 1000);
await store.create(sessionId, { answers: answerKey, userId }, 30 * 60 * 1000);
return { sessionId, questions };
};
@ -67,10 +68,11 @@ export const createGameSession = async (
export const evaluateAnswer = async (
submission: AnswerSubmission,
store: GameSessionStore,
userId: string,
): Promise<AnswerResult> => {
const session = await store.get(submission.sessionId);
if (!session) {
if (!session || session.userId !== userId) {
throw new NotFoundError(`Game session not found: ${submission.sessionId}`);
}

View file

@ -1,3 +1,4 @@
import type { Request } from "express";
import type { Session, User } from "better-auth";
declare global {
@ -14,4 +15,6 @@ declare module "ws" {
}
}
export {};
export type AuthenticatedRequest = Request & {
session: { session: Session; user: User };
};