Merge pull request #698 from TNG-dev/zkldi/issue-515

This commit is contained in:
zkldi
2022-04-23 00:04:19 +00:00
committed by GitHub
9 changed files with 336 additions and 17 deletions
+56
View File
@@ -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();
});
+44
View File
@@ -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 } });
}
@@ -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();
};
@@ -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();
});
@@ -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 = [];
@@ -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({
+17 -3
View File
@@ -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<unknown> | Iterable<unknown>) {
* Deep-modify an object. This is a wrapper around deepmerge that returns proper types.
*/
export function dmf<T extends object>(base: T, modifant: Partial<T>): 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<ImportDocument> = {}) {
return dmf(FakeImport, modifant);
}
+19
View File
@@ -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,
};
+2
View File
@@ -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<TachiAPIClientDocument, "clientSecret">;
}