mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-06 22:07:58 +03:00
Merge pull request #698 from TNG-dev/zkldi/issue-515
This commit is contained in:
@@ -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();
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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,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,
|
||||
};
|
||||
|
||||
@@ -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">;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user