diff --git a/server/src/lib/imports/imports.test.ts b/server/src/lib/imports/imports.test.ts new file mode 100644 index 000000000..c1ecfb8db --- /dev/null +++ b/server/src/lib/imports/imports.test.ts @@ -0,0 +1,56 @@ +import db from "external/mongo/db"; +import t from "tap"; +import { dmf, mkFakeImport } from "test-utils/misc"; +import ResetDBState from "test-utils/resets"; +import { TestingIIDXSPScore } from "test-utils/test-data"; +import { RevertImport } from "./imports"; + +t.test("#RevertImport", (t) => { + t.beforeEach(ResetDBState); + t.beforeEach(() => + db.scores.insert([ + dmf(TestingIIDXSPScore, { scoreID: "score_1" }), + dmf(TestingIIDXSPScore, { scoreID: "score_2" }), + dmf(TestingIIDXSPScore, { scoreID: "score_3" }), + ]) + ); + + t.test("Should revert an import and all of its contained scores.", async (t) => { + const importDoc = mkFakeImport({ + scoreIDs: ["score_1", "score_2"], + }); + + await db.imports.insert(importDoc); + + await RevertImport(importDoc); + + const dbRes = await db.imports.findOne({ importID: "fake_import" }); + + t.equal(dbRes, null, "Should have removed the import from the DB."); + + t.resolveMatch( + db.scores.findOne({ userID: 1, scoreID: "score_1" }), + // @ts-expect-error https://github.com/DefinitelyTyped/DefinitelyTyped/pull/60020 + null, + "Score_1 should be removed from the database." + ); + + t.resolveMatch( + db.scores.findOne({ userID: 1, scoreID: "score_2" }), + // @ts-expect-error see above + null, + "Score_2 should be removed from the database." + ); + + t.resolveMatch( + db.scores.findOne({ userID: 1, scoreID: "score_3" }), + // @ts-expect-error see above + { scoreID: "score_3" }, + "Score_2 should NOT be removed from the database." + ); + + t.end(); + }); + + t.end(); +}); diff --git a/server/src/lib/imports/imports.ts b/server/src/lib/imports/imports.ts new file mode 100644 index 000000000..2c976007d --- /dev/null +++ b/server/src/lib/imports/imports.ts @@ -0,0 +1,44 @@ +import db from "external/mongo/db"; +import CreateLogCtx from "lib/logger/logger"; +import { DeleteMultipleScores } from "lib/score-mutation/delete-scores"; +import { ImportDocument } from "tachi-common"; + +const logger = CreateLogCtx(__filename); + +/** + * Given an importDocument, undo it. This will remove all of the scores inside the import. + * + * It will *not* undo things like classes that were set, but it will invoke a profile recalculation. + * + * If this results in sessions being deleted, it will delete them. + */ +export async function RevertImport(importDoc: ImportDocument) { + logger.info(`Received revert-import request for import '${importDoc.importID}'`, { importDoc }); + + const scores = await GetImportScores(importDoc); + + await DeleteMultipleScores(scores); + + logger.info( + `Deleted ${scores.length} scores as part of reverting import '${importDoc.importID}'.`, + { importDoc } + ); + + try { + await db.imports.remove({ importID: importDoc.importID }); + + logger.info(`Reverted and deleted import '${importDoc.importID}'.`); + } catch (err) { + logger.severe( + `Deleted scores that were part of import, but failed to remove the actual import? There is a stale import with ID '${importDoc.importID}', which must be removed manually.`, + { importDoc } + ); + } +} + +/** + * Retrieve the scores inside this import. + */ +export function GetImportScores(importDoc: ImportDocument) { + return db.scores.find({ scoreID: { $in: importDoc.scoreIDs } }); +} diff --git a/server/src/server/router/api/v1/imports/middleware.ts b/server/src/server/router/api/v1/imports/middleware.ts new file mode 100644 index 000000000..a37f5b490 --- /dev/null +++ b/server/src/server/router/api/v1/imports/middleware.ts @@ -0,0 +1,40 @@ +import { RequestHandler } from "express"; +import db from "external/mongo/db"; +import { SYMBOL_TachiData, SYMBOL_TachiAPIAuth } from "lib/constants/tachi"; +import { AssignToReqTachiData } from "utils/req-tachi-data"; + +export const GetImportFromParam: RequestHandler = async (req, res, next) => { + const importDoc = await db.imports.findOne({ importID: req.params.importID }); + + if (!importDoc) { + return res.status(404).json({ + success: false, + description: `This import does not exist.`, + }); + } + + AssignToReqTachiData(req, { importDoc }); + + return next(); +}; + +export const RequireOwnershipOfImport: RequestHandler = (req, res, next) => { + const importDoc = req[SYMBOL_TachiData]!.importDoc!; + const userID = req[SYMBOL_TachiAPIAuth].userID; + + if (userID === null) { + return res.status(401).json({ + success: false, + description: `You are not authorised as anyone, and this endpoint requires us to know who you are.`, + }); + } + + if (importDoc.userID !== userID) { + return res.status(403).json({ + success: false, + description: `You are not authorised to perform this action.`, + }); + } + + return next(); +}; diff --git a/server/src/server/router/api/v1/imports/router.test.ts b/server/src/server/router/api/v1/imports/router.test.ts new file mode 100644 index 000000000..f39f5ac49 --- /dev/null +++ b/server/src/server/router/api/v1/imports/router.test.ts @@ -0,0 +1,114 @@ +import db from "external/mongo/db"; +import t from "tap"; +import { CreateFakeAuthCookie } from "test-utils/fake-auth"; +import { mkFakeImport } from "test-utils/misc"; +import mockApi from "test-utils/mock-api"; +import ResetDBState from "test-utils/resets"; +import { FakeImport } from "test-utils/test-data"; + +t.test("GET /api/v1/imports/:importID", (t) => { + t.beforeEach(ResetDBState); + t.beforeEach(() => db.imports.insert(FakeImport)); + + t.test("Should return the import at this ID.", async (t) => { + const res = await mockApi.get(`/api/v1/imports/${FakeImport.importID}`); + + t.equal(res.statusCode, 200, "Should return 200."); + + t.hasStrict( + res.body.body, + { + user: { + id: 1, + }, + scores: [ + { + scoreID: FakeImport.scoreIDs[0], + }, + ], + charts: [ + { + chartID: res.body.body.scores[0].chartID, + }, + ], + songs: [ + { + id: res.body.body.scores[0].songID, + }, + ], + import: { + importID: FakeImport.importID, + }, + }, + "Should return the import and some info about it." + ); + + t.end(); + }); + + t.test("Should return 404 if the import doesn't exist.", async (t) => { + const res = await mockApi.get("/api/v1/imports/bad-import"); + + t.equal(res.statusCode, 404, "Should return 404."); + + t.end(); + }); + + t.end(); +}); + +t.test("POST /api/v1/imports/:importID/revert", async (t) => { + t.beforeEach(ResetDBState); + t.beforeEach(() => db.imports.insert(FakeImport)); + + const cookie = await CreateFakeAuthCookie(mockApi); + + t.test("Should revert the import at this ID.", async (t) => { + const res = await mockApi + .post(`/api/v1/imports/${FakeImport.importID}/revert`) + .set("Cookie", cookie); + + t.equal(res.statusCode, 200, "Should return 200."); + + t.strictSame(res.body.body, {}, "The response body should be empty."); + + t.resolveMatch( + db.scores.findOne({ scoreID: FakeImport.scoreIDs[0] }), + // @ts-expect-error https://github.com/DefinitelyTyped/DefinitelyTyped/pull/60020 + null, + "The scores that were part of this import should be deleted." + ); + + t.end(); + }); + + t.test("Should return 404 if the import doesn't exist.", async (t) => { + const res = await mockApi.post(`/api/v1/imports/doesnt-exist/revert`).set("Cookie", cookie); + t.equal(res.statusCode, 404, "Should return 404."); + + t.end(); + }); + + t.test("Should return 401 if the user isn't authed.", async (t) => { + const res = await mockApi.post(`/api/v1/imports/${FakeImport.importID}/revert`); + + t.equal(res.statusCode, 401, "Should return 401."); + + t.end(); + }); + + t.test("Should return 403 if the user is authed as someone else.", async (t) => { + const someoneElsesImport = mkFakeImport({ userID: 2, importID: "someone_elses" }); + await db.imports.insert(someoneElsesImport); + + const res = await mockApi + .post(`/api/v1/imports/${someoneElsesImport.importID}/revert`) + .set("Cookie", cookie); + + t.equal(res.statusCode, 403, "Should return 403."); + + t.end(); + }); + + t.end(); +}); diff --git a/server/src/server/router/api/v1/imports/router.ts b/server/src/server/router/api/v1/imports/router.ts index a0f0e4f26..dd9f62552 100644 --- a/server/src/server/router/api/v1/imports/router.ts +++ b/server/src/server/router/api/v1/imports/router.ts @@ -1,11 +1,14 @@ import { Router } from "express"; import db from "external/mongo/db"; -import { JOB_RETRY_COUNT } from "lib/constants/tachi"; +import { JOB_RETRY_COUNT, SYMBOL_TachiData } from "lib/constants/tachi"; +import { RevertImport } from "lib/imports/imports"; import CreateLogCtx from "lib/logger/logger"; import ScoreImportQueue, { ScoreImportQueueEvents } from "lib/score-import/worker/queue"; import { ServerConfig, TachiConfig } from "lib/setup/config"; +import { RequirePermissions } from "server/middleware/auth"; import { GetRelevantSongsAndCharts } from "utils/db"; import { GetUserWithID } from "utils/user"; +import { GetImportFromParam, RequireOwnershipOfImport } from "./middleware"; const router: Router = Router({ mergeParams: true }); @@ -16,17 +19,8 @@ const logger = CreateLogCtx(__filename); * * @name GET /api/v1/imports/:importID */ -router.get("/:importID", async (req, res) => { - const importDoc = await db.imports.findOne({ - importID: req.params.importID, - }); - - if (!importDoc) { - return res.status(404).json({ - success: false, - description: `This import does not exist.`, - }); - } +router.get("/:importID", GetImportFromParam, async (req, res) => { + const importDoc = req[SYMBOL_TachiData]!.importDoc!; const scores = await db.scores.find({ scoreID: { $in: importDoc.scoreIDs }, @@ -62,7 +56,36 @@ router.get("/:importID", async (req, res) => { }); }); -// Finding jobs is slightly harder than just doing a key lookup, because of +/** + * Delete this import and revert it from having ever happened. This un-imports all + * of the scores that were imported. + * + * Must be a request from the owner of this import. + * + * Counterintuitively, this endpoint requires the "delete_score" permission. This is + * because reverting an import is actually just deleting all of its scores. + * + * @name POST /api/v1/imports/:importID/revert + */ +router.post( + "/:importID/revert", + GetImportFromParam, + RequireOwnershipOfImport, + RequirePermissions("delete_score"), + async (req, res) => { + const importDoc = req[SYMBOL_TachiData]!.importDoc!; + + await RevertImport(importDoc); + + return res.status(200).json({ + success: true, + description: `Reverted import.`, + body: {}, + }); + } +); + +// Finding jobs is slightly harder than just doing a key lookup, because of retrying. async function FindImportJob(importID: string) { const possibleImportIDs = []; diff --git a/server/src/server/router/api/v1/scores/_scoreID/middleware.ts b/server/src/server/router/api/v1/scores/_scoreID/middleware.ts index c4f233717..312f79b27 100644 --- a/server/src/server/router/api/v1/scores/_scoreID/middleware.ts +++ b/server/src/server/router/api/v1/scores/_scoreID/middleware.ts @@ -20,7 +20,14 @@ export const GetScoreFromParam: RequestHandler = async (req, res, next) => { export const RequireOwnershipOfScore: RequestHandler = (req, res, next) => { const score = req[SYMBOL_TachiData]!.scoreDoc!; - const userID = req[SYMBOL_TachiAPIAuth].userID!; + const userID = req[SYMBOL_TachiAPIAuth].userID; + + if (userID === null) { + return res.status(401).json({ + success: false, + description: `You are not authorised as anyone, and this endpoint requires us to know who you are.`, + }); + } if (score.userID !== userID) { return res.status(403).json({ diff --git a/server/src/test-utils/misc.ts b/server/src/test-utils/misc.ts index d3b3cb065..12080e339 100644 --- a/server/src/test-utils/misc.ts +++ b/server/src/test-utils/misc.ts @@ -1,6 +1,13 @@ import deepmerge from "deepmerge"; -import { Game, integer, Playtype, PublicUserDocument, UGPTSettings } from "tachi-common"; -import { FakeGameSettings, FakeOtherUser } from "./test-data"; +import { + Game, + ImportDocument, + integer, + Playtype, + PublicUserDocument, + UGPTSettings, +} from "tachi-common"; +import { FakeGameSettings, FakeImport, FakeOtherUser } from "./test-data"; /** * Async Generator To Array @@ -18,7 +25,10 @@ export async function agta(ag: AsyncIterable | Iterable) { * Deep-modify an object. This is a wrapper around deepmerge that returns proper types. */ export function dmf(base: T, modifant: Partial): T { - return deepmerge(base, modifant) as T; + return deepmerge(base, modifant, { + // The new array should replace the former one, instead of joining them together. + arrayMerge: (originalArray, newArray) => newArray, + }) as T; } /** @@ -59,3 +69,7 @@ export function mkFakeGameSettings( ) ); } + +export function mkFakeImport(modifant: Partial = {}) { + return dmf(FakeImport, modifant); +} diff --git a/server/src/test-utils/test-data.ts b/server/src/test-utils/test-data.ts index c6d0c63af..ef9d1d329 100644 --- a/server/src/test-utils/test-data.ts +++ b/server/src/test-utils/test-data.ts @@ -19,6 +19,7 @@ import { FolderDocument, GoalDocument, GoalSubscriptionDocument, + ImportDocument, MilestoneDocument, MilestoneSubscriptionDocument, PBScoreDocument, @@ -637,3 +638,21 @@ export const FakeGameSettings: UGPTSettings = { rivals: [], userID: 1, }; + +export const FakeImport: ImportDocument = { + classDeltas: [], + createdSessions: [], + errors: [], + game: "iidx", + goalInfo: [], + idStrings: ["iidx:SP"], + importID: "fake_import", + importType: "ir/direct-manual", + milestoneInfo: [], + playtypes: ["SP"], + scoreIDs: [TestingIIDXSPScore.scoreID], + timeFinished: 1000, + timeStarted: 100, + userID: 1, + userIntent: false, +}; diff --git a/server/src/utils/types.ts b/server/src/utils/types.ts index 2192e69a5..893334f39 100644 --- a/server/src/utils/types.ts +++ b/server/src/utils/types.ts @@ -17,6 +17,7 @@ import { MilestoneDocument, MilestoneSetDocument, integer, + ImportDocument, } from "tachi-common"; declare module "express-session" { @@ -73,6 +74,7 @@ export interface TachiRequestData { goalSubDoc?: GoalSubscriptionDocument; milestoneSubDoc?: MilestoneSubscriptionDocument; milestoneSetDoc?: MilestoneSetDocument; + importDoc?: ImportDocument; apiClientDoc: Omit; }