diff --git a/.github/workflows/postgres-redis-ci.yml b/.github/workflows/postgres-redis-ci.yml index 44500f8b..6bf05b08 100644 --- a/.github/workflows/postgres-redis-ci.yml +++ b/.github/workflows/postgres-redis-ci.yml @@ -26,4 +26,6 @@ jobs: env: TEST_POSTGRES: true timeout-minutes: 5 - run: npm test \ No newline at end of file + run: npx nyc --silent npm test + - name: Generate coverage report + run: npm run cover:report \ No newline at end of file diff --git a/.gitignore b/.gitignore index 25260b5d..79d8a36f 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,5 @@ working /dist/ # nyc coverage output -.nyc_output/ \ No newline at end of file +.nyc_output/ +coverage/ \ No newline at end of file diff --git a/.nycrc.json b/.nycrc.json index 76027a56..ae148ebc 100644 --- a/.nycrc.json +++ b/.nycrc.json @@ -1,5 +1,14 @@ { + "extends": "@istanbuljs/nyc-config-typescript", + "check-coverage": false, + "ski-full": true, + "reporter": ["text", "html"], + "include": [ + "src/**/*.ts" + ], "exclude": [ - "src/routes/addUnlitedVideo.ts" + "src/routes/addUnlistedVideo.ts", + "src/cronjob/downvoteSegmentArchiveJob.ts", + "src/databases/*" ] } \ No newline at end of file diff --git a/ci.json b/ci.json index 30e2f412..5a3ee2fb 100644 --- a/ci.json +++ b/ci.json @@ -4,11 +4,12 @@ "globalSalt": "testSalt", "adminUserID": "4bdfdc9cddf2c7d07a8a87b57bf6d25389fb75d1399674ee0e0938a6a60f4c3b", "newLeafURLs": ["placeholder"], - "discordReportChannelWebhookURL": "http://127.0.0.1:8081/ReportChannelWebhook", - "discordFirstTimeSubmissionsWebhookURL": "http://127.0.0.1:8081/FirstTimeSubmissionsWebhook", - "discordCompletelyIncorrectReportWebhookURL": "http://127.0.0.1:8081/CompletelyIncorrectReportWebhook", - "discordNeuralBlockRejectWebhookURL": "http://127.0.0.1:8081/NeuralBlockRejectWebhook", + "discordReportChannelWebhookURL": "http://127.0.0.1:8081/webhook/ReportChannel", + "discordFirstTimeSubmissionsWebhookURL": "http://127.0.0.1:8081/webhook/FirstTimeSubmissions", + "discordCompletelyIncorrectReportWebhookURL": "http://127.0.0.1:8081/webhook/CompletelyIncorrectReport", + "discordNeuralBlockRejectWebhookURL": "http://127.0.0.1:8081/webhook/NeuralBlockReject", "neuralBlockURL": "http://127.0.0.1:8081/NeuralBlock", + "userCounterURL": "http://127.0.0.1:8081/UserCounter", "behindProxy": true, "postgres": { "user": "ci_db_user", @@ -70,5 +71,10 @@ "statusCode": 200 } }, + "patreon": { + "clientId": "testClientID", + "clientSecret": "testClientSecret", + "redirectUri": "http://127.0.0.1/fake/callback" + }, "minReputationToSubmitFiller": -1 } diff --git a/package-lock.json b/package-lock.json index 00e507ec..a03b338e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "sync-mysql": "^3.0.1" }, "devDependencies": { + "@istanbuljs/nyc-config-typescript": "^1.0.2", "@types/better-sqlite3": "^7.5.0", "@types/cron": "^2.0.0", "@types/express": "^4.17.13", @@ -30,8 +31,10 @@ "@types/mocha": "^9.1.1", "@types/node": "^18.0.3", "@types/pg": "^8.6.5", + "@types/sinon": "^10.0.13", "@typescript-eslint/eslint-plugin": "^5.30.6", "@typescript-eslint/parser": "^5.30.6", + "axios-mock-adapter": "^1.21.2", "eslint": "^8.19.0", "mocha": "^10.0.0", "nodemon": "^2.0.19", @@ -630,6 +633,21 @@ "node": ">=8" } }, + "node_modules/@istanbuljs/nyc-config-typescript": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@istanbuljs/nyc-config-typescript/-/nyc-config-typescript-1.0.2.tgz", + "integrity": "sha512-iKGIyMoyJuFnJRSVTZ78POIRvNnwZaWIf8vG4ZS3rQq58MMDrqEX2nnzx0R28V2X8JvmKYiqY9FP2hlJsm8A0w==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "nyc": ">=15" + } + }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -963,6 +981,21 @@ "@types/node": "*" } }, + "node_modules/@types/sinon": { + "version": "10.0.13", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-10.0.13.tgz", + "integrity": "sha512-UVjDqJblVNQYvVNUsj0PuYYw0ELRmgt1Nt5Vk0pT5f16ROGfcKJY8o1HVuMOJOpD727RrGB9EGvoaTQE5tgxZQ==", + "dev": true, + "dependencies": { + "@types/sinonjs__fake-timers": "*" + } + }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.2.tgz", + "integrity": "sha512-9GcLXF0/v3t80caGs5p2rRfkB+a8VBGLJZVih6CNFkx8IZ994wiKKLSRs9nuFwk1HevWs/1mnUmkApGrSGsShA==", + "dev": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "5.30.6", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.30.6.tgz", @@ -1339,6 +1372,19 @@ "form-data": "^4.0.0" } }, + "node_modules/axios-mock-adapter": { + "version": "1.21.2", + "resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-1.21.2.tgz", + "integrity": "sha512-jzyNxU3JzB2XVhplZboUcF0YDs7xuExzoRSHXPHr+UQajaGmcTqvkkUADgkVI2WkGlpZ1zZlMVdcTMU0ejV8zQ==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "is-buffer": "^2.0.5" + }, + "peerDependencies": { + "axios": ">= 0.17.0" + } + }, "node_modules/babel-runtime": { "version": "6.26.0", "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", @@ -3094,6 +3140,29 @@ "node": ">=8" } }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "engines": { + "node": ">=4" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -6154,6 +6223,15 @@ } } }, + "@istanbuljs/nyc-config-typescript": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@istanbuljs/nyc-config-typescript/-/nyc-config-typescript-1.0.2.tgz", + "integrity": "sha512-iKGIyMoyJuFnJRSVTZ78POIRvNnwZaWIf8vG4ZS3rQq58MMDrqEX2nnzx0R28V2X8JvmKYiqY9FP2hlJsm8A0w==", + "dev": true, + "requires": { + "@istanbuljs/schema": "^0.1.2" + } + }, "@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -6453,6 +6531,21 @@ "@types/node": "*" } }, + "@types/sinon": { + "version": "10.0.13", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-10.0.13.tgz", + "integrity": "sha512-UVjDqJblVNQYvVNUsj0PuYYw0ELRmgt1Nt5Vk0pT5f16ROGfcKJY8o1HVuMOJOpD727RrGB9EGvoaTQE5tgxZQ==", + "dev": true, + "requires": { + "@types/sinonjs__fake-timers": "*" + } + }, + "@types/sinonjs__fake-timers": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.2.tgz", + "integrity": "sha512-9GcLXF0/v3t80caGs5p2rRfkB+a8VBGLJZVih6CNFkx8IZ994wiKKLSRs9nuFwk1HevWs/1mnUmkApGrSGsShA==", + "dev": true + }, "@typescript-eslint/eslint-plugin": { "version": "5.30.6", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.30.6.tgz", @@ -6698,6 +6791,16 @@ "form-data": "^4.0.0" } }, + "axios-mock-adapter": { + "version": "1.21.2", + "resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-1.21.2.tgz", + "integrity": "sha512-jzyNxU3JzB2XVhplZboUcF0YDs7xuExzoRSHXPHr+UQajaGmcTqvkkUADgkVI2WkGlpZ1zZlMVdcTMU0ejV8zQ==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.3", + "is-buffer": "^2.0.5" + } + }, "babel-runtime": { "version": "6.26.0", "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", @@ -7975,6 +8078,12 @@ "binary-extensions": "^2.0.0" } }, + "is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "dev": true + }, "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", diff --git a/package.json b/package.json index 511aa19e..1c8077cb 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "main": "src/index.ts", "scripts": { "test": "npm run tsc && ts-node test/test.ts", - "test:coverage": "nyc npm run test", + "cover": "nyc npm test", + "cover:report": "nyc report", "dev": "nodemon", "dev:bash": "nodemon -x 'npm test ; npm start'", "postgres:docker": "docker run --rm -p 5432:5432 -e POSTGRES_USER=ci_db_user -e POSTGRES_PASSWORD=ci_db_pass postgres:alpine", @@ -32,6 +33,7 @@ "sync-mysql": "^3.0.1" }, "devDependencies": { + "@istanbuljs/nyc-config-typescript": "^1.0.2", "@types/better-sqlite3": "^7.5.0", "@types/cron": "^2.0.0", "@types/express": "^4.17.13", @@ -39,8 +41,10 @@ "@types/mocha": "^9.1.1", "@types/node": "^18.0.3", "@types/pg": "^8.6.5", + "@types/sinon": "^10.0.13", "@typescript-eslint/eslint-plugin": "^5.30.6", "@typescript-eslint/parser": "^5.30.6", + "axios-mock-adapter": "^1.21.2", "eslint": "^8.19.0", "mocha": "^10.0.0", "nodemon": "^2.0.19", diff --git a/src/app.ts b/src/app.ts index f68507e6..caee4fab 100644 --- a/src/app.ts +++ b/src/app.ts @@ -200,6 +200,7 @@ function setupRoutes(router: Router) { router.get("/api/generateToken/:type", generateTokenRequest); router.get("/api/verifyToken", verifyTokenRequest); + /* istanbul ignore next */ if (config.postgres?.enabled) { router.get("/database", (req, res) => dumpDatabase(req, res, true)); router.get("/database.json", (req, res) => dumpDatabase(req, res, false)); diff --git a/src/routes/deleteLockCategories.ts b/src/routes/deleteLockCategories.ts index 10a3ec9c..3340f10a 100644 --- a/src/routes/deleteLockCategories.ts +++ b/src/routes/deleteLockCategories.ts @@ -35,6 +35,7 @@ export async function deleteLockCategoriesEndpoint(req: DeleteLockCategoriesRequ || !categories || !Array.isArray(categories) || categories.length === 0 + || actionTypes && !Array.isArray(actionTypes) || actionTypes.length === 0 ) { return res.status(400).json({ @@ -48,7 +49,7 @@ export async function deleteLockCategoriesEndpoint(req: DeleteLockCategoriesRequ if (!userIsVIP) { return res.status(403).json({ - message: "Must be a VIP to mark videos.", + message: "Must be a VIP to lock videos.", }); } diff --git a/src/routes/generateToken.ts b/src/routes/generateToken.ts index 294a9485..d617eccd 100644 --- a/src/routes/generateToken.ts +++ b/src/routes/generateToken.ts @@ -24,6 +24,7 @@ export async function generateTokenRequest(req: GenerateTokenRequest, res: Respo if (type === TokenType.patreon || (type === TokenType.local && adminUserIDHash === config.adminUserID)) { const licenseKey = await createAndSaveToken(type, code); + /* istanbul ignore else */ if (licenseKey) { return res.status(200).send(`

@@ -45,5 +46,7 @@ export async function generateTokenRequest(req: GenerateTokenRequest, res: Respo

`); } + } else { + return res.sendStatus(403); } } \ No newline at end of file diff --git a/src/routes/getDaysSavedFormatted.ts b/src/routes/getDaysSavedFormatted.ts index 61046e38..bd906a66 100644 --- a/src/routes/getDaysSavedFormatted.ts +++ b/src/routes/getDaysSavedFormatted.ts @@ -7,7 +7,11 @@ export async function getDaysSavedFormatted(req: Request, res: Response): Promis if (row !== undefined) { //send this result return res.send({ - daysSaved: row.daysSaved.toFixed(2), + daysSaved: row.daysSaved?.toFixed(2) ?? "0", + }); + } else { + return res.send({ + daysSaved: 0 }); } } diff --git a/src/routes/getIsUserVIP.ts b/src/routes/getIsUserVIP.ts index 09f43473..a9a3f3e8 100644 --- a/src/routes/getIsUserVIP.ts +++ b/src/routes/getIsUserVIP.ts @@ -21,7 +21,7 @@ export async function getIsUserVIP(req: Request, res: Response): Promise { let hashPrefix = req.params.prefix as VideoIDHash; - const actionTypes: ActionType[] = req.query.actionTypes - ? JSON.parse(req.query.actionTypes as string) - : req.query.actionType - ? Array.isArray(req.query.actionType) - ? req.query.actionType - : [req.query.actionType] - : [ActionType.Skip, ActionType.Mute]; + let actionTypes: ActionType[] = []; + try { + actionTypes = req.query.actionTypes + ? JSON.parse(req.query.actionTypes as string) + : req.query.actionType + ? Array.isArray(req.query.actionType) + ? req.query.actionType + : [req.query.actionType] + : [ActionType.Skip, ActionType.Mute]; + if (!Array.isArray(actionTypes)) { + //invalid request + return res.sendStatus(400); + } + } catch (err) { + //invalid request + return res.status(400).send("Invalid request: JSON parse error (actionTypes)"); + } if (!hashPrefixTester(req.params.prefix)) { + return res.status(400).send("Hash prefix does not match format requirements."); // Exit early on faulty prefix } hashPrefix = hashPrefix.toLowerCase() as VideoIDHash; @@ -62,7 +73,7 @@ export async function getLockCategoriesByHash(req: Request, res: Response): Prom if (lockedRows.length === 0 || !lockedRows[0]) return res.sendStatus(404); // merge all locks return res.send(mergeLocks(lockedRows, actionTypes)); - } catch (err) { + } catch (err) /* istanbul ignore next */ { Logger.error(err as string); return res.sendStatus(500); } diff --git a/src/routes/getLockReason.ts b/src/routes/getLockReason.ts index ef4e5a33..59ab5287 100644 --- a/src/routes/getLockReason.ts +++ b/src/routes/getLockReason.ts @@ -32,18 +32,24 @@ export async function getLockReason(req: Request, res: Response): Promise possibleCategories.includes(x)); - if (!videoID || !Array.isArray(actionTypes)) { - //invalid request - return res.sendStatus(400); - } - try { // Get existing lock categories markers const row = await db.prepare("all", 'SELECT "category", "reason", "actionType", "userID" from "lockCategories" where "videoID" = ?', [videoID]) as {category: Category, reason: string, actionType: ActionType, userID: string }[]; @@ -115,7 +116,7 @@ export async function getLockReason(req: Request, res: Response): Promise, pag ); if (sortBy !== SortableFields.timeSubmitted) { + /* istanbul ignore next */ filteredSegments.sort((a,b) => { const key = sortDir === "desc" ? 1 : -1; if (a[sortBy] < b[sortBy]) { @@ -187,6 +183,7 @@ async function endpoint(req: Request, res: Response): Promise { return res.send(segmentResponse); } } catch (err) { + /* istanbul ignore next */ if (err instanceof SyntaxError) { return res.status(400).send("Invalid array in parameters"); } else return res.sendStatus(500); diff --git a/src/routes/getSegmentInfo.ts b/src/routes/getSegmentInfo.ts index d7893365..d4df1aaf 100644 --- a/src/routes/getSegmentInfo.ts +++ b/src/routes/getSegmentInfo.ts @@ -7,7 +7,7 @@ const isValidSegmentUUID = (str: string): boolean => /^([a-f0-9]{64}|[a-f0-9]{8} async function getSegmentFromDBByUUID(UUID: SegmentUUID): Promise { try { return await db.prepare("get", `SELECT * FROM "sponsorTimes" WHERE "UUID" = ?`, [UUID]); - } catch (err) { + } catch (err) /* istanbul ignore next */ { return null; } } @@ -62,7 +62,7 @@ async function endpoint(req: Request, res: Response): Promise { //send result return res.send(DBSegments); } - } catch (err) { + } catch (err) /* istanbul ignore next */ { if (err instanceof SyntaxError) { // catch JSON.parse error return res.status(400).send("UUIDs parameter does not match format requirements."); } else return res.sendStatus(500); diff --git a/src/routes/getSkipSegments.ts b/src/routes/getSkipSegments.ts index 7da485e6..6c938225 100644 --- a/src/routes/getSkipSegments.ts +++ b/src/routes/getSkipSegments.ts @@ -107,7 +107,7 @@ async function getSegmentsByVideoID(req: Request, videoID: VideoID, categories: } return processedSegments; - } catch (err) { + } catch (err) /* istanbul ignore next */ { if (err) { Logger.error(err as string); return null; @@ -169,7 +169,7 @@ async function getSegmentsByHash(req: Request, hashedVideoIDPrefix: VideoIDHash, })); return segments; - } catch (err) { + } catch (err) /* istanbul ignore next */ { Logger.error(err as string); return null; } @@ -465,7 +465,7 @@ async function endpoint(req: Request, res: Response): Promise { //send result return res.send(segments); } - } catch (err) { + } catch (err) /* istanbul ignore next */ { if (err instanceof SyntaxError) { return res.status(400).send("Categories parameter does not match format requirements."); } else return res.sendStatus(500); diff --git a/src/routes/getSkipSegmentsByHash.ts b/src/routes/getSkipSegmentsByHash.ts index 663fd6dc..454f2329 100644 --- a/src/routes/getSkipSegmentsByHash.ts +++ b/src/routes/getSkipSegmentsByHash.ts @@ -67,8 +67,6 @@ export async function getSkipSegmentsByHash(req: Request, res: Response): Promis // Get all video id's that match hash prefix const segments = await getSegmentsByHash(req, hashPrefix, categories, actionTypes, requiredSegments, service); - if (!segments) return res.status(404).json([]); - const output = Object.entries(segments).map(([videoID, data]) => ({ videoID, hash: data.hash, diff --git a/src/routes/getStatus.ts b/src/routes/getStatus.ts index 3f50fa15..6315fdfe 100644 --- a/src/routes/getStatus.ts +++ b/src/routes/getStatus.ts @@ -18,7 +18,7 @@ export async function getStatus(req: Request, res: Response): Promise processTime = Date.now() - dbStartTime; return e.value; }) - .catch(e => { + .catch(e => /* istanbul ignore next */ { Logger.error(`status: SQL query timed out: ${e}`); return -1; }); @@ -28,7 +28,7 @@ export async function getStatus(req: Request, res: Response): Promise .then(e => { redisProcessTime = Date.now() - redisStartTime; return e; - }).catch(e => { + }).catch(e => /* istanbul ignore next */ { Logger.error(`status: redis increment timed out ${e}`); return [-1]; }); @@ -36,7 +36,7 @@ export async function getStatus(req: Request, res: Response): Promise const statusValues: Record = { uptime: process.uptime(), - commit: (global as any).HEADCOMMIT || "unknown", + commit: (global as any)?.HEADCOMMIT ?? "unknown", db: Number(dbVersion), startTime, processTime, @@ -48,7 +48,7 @@ export async function getStatus(req: Request, res: Response): Promise activeRedisRequests: getRedisActiveRequests(), }; return value ? res.send(JSON.stringify(statusValues[value])) : res.send(statusValues); - } catch (err) { + } catch (err) /* istanbul ignore next */ { Logger.error(err as string); return res.sendStatus(500); } diff --git a/src/routes/getTopUsers.ts b/src/routes/getTopUsers.ts index a40981ea..552aaf94 100644 --- a/src/routes/getTopUsers.ts +++ b/src/routes/getTopUsers.ts @@ -75,11 +75,6 @@ export async function getTopUsers(req: Request, res: Response): Promise { try { return db.prepare("all", `SELECT "userName", "userID" from "userNames" WHERE "userName" = ? LIMIT 10`, [userName]); - } catch (err) { + } catch (err) /* istanbul ignore next */{ return null; } } @@ -42,6 +42,7 @@ export async function getUserID(req: Request, res: Response): Promise : await getFuzzyUserID(userName); if (results === undefined || results === null) { + /* istanbul ignore next */ return res.sendStatus(500); } else if (results.length === 0) { return res.sendStatus(404); diff --git a/src/routes/getUserInfo.ts b/src/routes/getUserInfo.ts index 62ac1313..70f34641 100644 --- a/src/routes/getUserInfo.ts +++ b/src/routes/getUserInfo.ts @@ -28,7 +28,7 @@ async function dbGetSubmittedSegmentSummary(userID: HashedUserID): Promise<{ min segmentCount: 0, }; } - } catch (err) { + } catch (err) /* istanbul ignore next */ { return null; } } @@ -37,7 +37,7 @@ async function dbGetIgnoredSegmentCount(userID: HashedUserID): Promise { try { const row = await db.prepare("get", `SELECT COUNT(*) as "ignoredSegmentCount" FROM "sponsorTimes" WHERE "userID" = ? AND ( "votes" <= -2 OR "shadowHidden" = 1 )`, [userID], { useReplica: true }); return row?.ignoredSegmentCount ?? 0; - } catch (err) { + } catch (err) /* istanbul ignore next */ { return null; } } @@ -46,7 +46,7 @@ async function dbGetUsername(userID: HashedUserID) { try { const row = await db.prepare("get", `SELECT "userName" FROM "userNames" WHERE "userID" = ?`, [userID]); return row?.userName ?? userID; - } catch (err) { + } catch (err) /* istanbul ignore next */ { return false; } } @@ -55,7 +55,7 @@ async function dbGetViewsForUser(userID: HashedUserID) { try { const row = await db.prepare("get", `SELECT SUM("views") as "viewCount" FROM "sponsorTimes" WHERE "userID" = ? AND "votes" > -2 AND "shadowHidden" != 1`, [userID], { useReplica: true }); return row?.viewCount ?? 0; - } catch (err) { + } catch (err) /* istanbul ignore next */ { return false; } } @@ -64,7 +64,7 @@ async function dbGetIgnoredViewsForUser(userID: HashedUserID) { try { const row = await db.prepare("get", `SELECT SUM("views") as "ignoredViewCount" FROM "sponsorTimes" WHERE "userID" = ? AND ( "votes" <= -2 OR "shadowHidden" = 1 )`, [userID], { useReplica: true }); return row?.ignoredViewCount ?? 0; - } catch (err) { + } catch (err) /* istanbul ignore next */ { return false; } } @@ -73,7 +73,7 @@ async function dbGetWarningsForUser(userID: HashedUserID): Promise { try { const row = await db.prepare("get", `SELECT COUNT(*) as total FROM "warnings" WHERE "userID" = ? AND "enabled" = 1`, [userID], { useReplica: true }); return row?.total ?? 0; - } catch (err) { + } catch (err) /* istanbul ignore next */ { Logger.error(`Couldn't get warnings for user ${userID}. returning 0`); return 0; } @@ -83,7 +83,7 @@ async function dbGetLastSegmentForUser(userID: HashedUserID): Promise { try { const row = await db.prepare("get", `SELECT count(*) as "userCount" FROM "shadowBannedUsers" WHERE "userID" = ? LIMIT 1`, [userID], { useReplica: true }); return row?.userCount > 0 ?? false; - } catch (err) { + } catch (err) /* istanbul ignore next */ { return false; } } @@ -194,7 +194,7 @@ async function getUserInfo(req: Request, res: Response): Promise { export async function endpoint(req: Request, res: Response): Promise { try { return await getUserInfo(req, res); - } catch (err) { + } catch (err) /* istanbul ignore next */ { if (err instanceof SyntaxError) { // catch JSON.parse error return res.status(400).send("Invalid values JSON"); } else return res.sendStatus(500); diff --git a/src/routes/getUserStats.ts b/src/routes/getUserStats.ts index 38c8764a..b93cc177 100644 --- a/src/routes/getUserStats.ts +++ b/src/routes/getUserStats.ts @@ -75,7 +75,7 @@ async function dbGetUserSummary(userID: HashedUserID, fetchCategoryStats: boolea }; } return result; - } catch (err) { + } catch (err) /* istanbul ignore next */ { Logger.error(err as string); return null; } @@ -85,7 +85,7 @@ async function dbGetUsername(userID: HashedUserID) { try { const row = await db.prepare("get", `SELECT "userName" FROM "userNames" WHERE "userID" = ?`, [userID]); return row?.userName ?? userID; - } catch (err) { + } catch (err) /* istanbul ignore next */ { return false; } } diff --git a/src/routes/getUsername.ts b/src/routes/getUsername.ts index 28098fea..b3a2a7b9 100644 --- a/src/routes/getUsername.ts +++ b/src/routes/getUsername.ts @@ -27,7 +27,7 @@ export async function getUsername(req: Request, res: Response): Promise + new RegExp(/[A-Za-z0-9]{40}|[A-Za-z0-9-]{35}/).test(token); + export async function verifyTokenRequest(req: VerifyTokenRequest, res: Response): Promise { const { query: { licenseKey } } = req; if (!licenseKey) { return res.status(400).send("Invalid request"); - } - const licenseRegex = new RegExp(/[a-zA-Z0-9]{40}|[A-Z0-9-]{35}/); - if (!licenseRegex.test(licenseKey)) { + } else if (!validatelicenseKeyRegex(licenseKey)) { + // fast check for invalid licence key return res.status(200).send({ allowed: false }); @@ -34,6 +35,7 @@ export async function verifyTokenRequest(req: VerifyTokenRequest, res: Response) refreshToken(TokenType.patreon, licenseKey, tokens.refreshToken).catch(Logger.error); } + /* istanbul ignore else */ if (identity) { const membership = identity.included?.[0]?.attributes; const allowed = !!membership && ((membership.patron_status === PatronStatus.active && membership.currently_entitled_amount_cents > 0) @@ -65,20 +67,13 @@ export async function verifyTokenRequest(req: VerifyTokenRequest, res: Response) async function checkAllGumroadProducts(licenseKey: string): Promise { for (const link of config.gumroad.productPermalinks) { try { - const formData = new FormData(); - formData.append("product_permalink", link); - formData.append("license_key", licenseKey); - - const result = await axios.request({ - url: "https://api.gumroad.com/v2/licenses/verify", - data: formData, - method: "POST", - headers: formData.getHeaders() + const result = await axios.post("https://api.gumroad.com/v2/licenses/verify", { + params: { product_permalink: link, license_key: licenseKey } }); const allowed = result.status === 200 && result.data?.success; if (allowed) return allowed; - } catch (e) { + } catch (e) /* istanbul ignore next */ { Logger.error(`Gumroad fetch for ${link} failed: ${e}`); } } diff --git a/src/utils/getIP.ts b/src/utils/getIP.ts index 0d2dd908..85a71929 100644 --- a/src/utils/getIP.ts +++ b/src/utils/getIP.ts @@ -3,6 +3,9 @@ import { Request } from "express"; import { IPAddress } from "../types/segments.model"; export function getIP(req: Request): IPAddress { + // if in testing mode, return immediately + if (config.mode === "test") return "127.0.0.1" as IPAddress; + if (config.behindProxy === true || config.behindProxy === "true") { config.behindProxy = "X-Forwarded-For"; } @@ -15,6 +18,6 @@ export function getIP(req: Request): IPAddress { case "X-Real-IP": return req.headers["x-real-ip"] as IPAddress; default: - return (req.connection?.remoteAddress || req.socket?.remoteAddress) as IPAddress; + return req.socket?.remoteAddress as IPAddress; } } \ No newline at end of file diff --git a/src/utils/innerTubeAPI.ts b/src/utils/innerTubeAPI.ts index c330e92c..475cda93 100644 --- a/src/utils/innerTubeAPI.ts +++ b/src/utils/innerTubeAPI.ts @@ -18,6 +18,7 @@ async function getFromITube (videoID: string): Promise { const result = await axios.post(url, data, { timeout: 3500 }); + /* istanbul ignore else */ if (result.status === 200) { return result.data.videoDetails; } else { @@ -39,6 +40,7 @@ export async function getPlayerData (videoID: string, ignoreCache = false): Prom return data as innerTubeVideoDetails; } } catch (err) { + /* istanbul ignore next */ return Promise.reject(err); } } diff --git a/src/utils/tokenUtils.ts b/src/utils/tokenUtils.ts index 7c5ad3c0..e936b455 100644 --- a/src/utils/tokenUtils.ts +++ b/src/utils/tokenUtils.ts @@ -58,12 +58,11 @@ export async function createAndSaveToken(type: TokenType, code?: string): Promis return licenseKey; } - } catch (e) { + break; + } catch (e) /* istanbul ignore next */ { Logger.error(`token creation: ${e}`); return null; } - - break; } case TokenType.local: { const licenseKey = generateToken(); @@ -74,7 +73,6 @@ export async function createAndSaveToken(type: TokenType, code?: string): Promis return licenseKey; } } - return null; } @@ -102,15 +100,12 @@ export async function refreshToken(type: TokenType, licenseKey: string, refreshT return true; } - } catch (e) { + } catch (e) /* istanbul ignore next */ { Logger.error(`token refresh: ${e}`); return false; } - - break; } } - return false; } @@ -136,9 +131,8 @@ export async function getPatreonIdentity(accessToken: string): Promise db.prepare("get", `SELECT "userID" FROM "vipUsers" WHERE "userID" = ?`, [publicID]); + +const adminPrivateUserID = "testUserId"; +const permVIP1 = "addVIP_permaVIPOne"; +const publicPermVIP1 = getHash(permVIP1) as HashedUserID; +const permVIP2 = "addVIP_permaVIPTwo"; +const publicPermVIP2 = getHash(permVIP2) as HashedUserID; +const permVIP3 = "addVIP_permaVIPThree"; +const publicPermVIP3 = getHash(permVIP3) as HashedUserID; + +const endpoint = "/api/addUserAsVIP"; +const addUserAsVIP = (userID: string, enabled: boolean, adminUserID = adminPrivateUserID) => client({ + method: "POST", + url: endpoint, + params: { + userID, + adminUserID, + enabled: String(enabled) + } +}); + +describe("addVIP test", function() { + it("User should not already be VIP", (done) => { + checkUserVIP(publicPermVIP1) + .then(result => { + assert.ok(!result); + done(); + }) + .catch(err => done(err)); + }); + it("Should be able to add user as VIP", (done) => { + addUserAsVIP(publicPermVIP1, true) + .then(async res => { + assert.strictEqual(res.status, 200); + const row = await checkUserVIP(publicPermVIP1); + assert.ok(row); + done(); + }) + .catch(err => done(err)); + }); + it("Should be able to add second user as VIP", (done) => { + addUserAsVIP(publicPermVIP2, true) + .then(async res => { + assert.strictEqual(res.status, 200); + const row = await checkUserVIP(publicPermVIP2); + assert.ok(row); + done(); + }) + .catch(err => done(err)); + }); + it("Should return 403 with invalid adminID", (done) => { + addUserAsVIP(publicPermVIP1, true, "Invalid_Admin_User_ID") + .then(res => { + assert.strictEqual(res.status, 403); + done(); + }) + .catch(err => done(err)); + }); + it("Should return 400 with missing adminID", (done) => { + client({ + method: "POST", + url: endpoint, + params: { + userID: publicPermVIP1, + enabled: String(true) + } + }) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); + it("Should return 400 with missing userID", (done) => { + client({ + method: "POST", + url: endpoint, + params: { + enabled: String(true), + adminUserID: adminPrivateUserID + } + }) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); + it("Should be able to remove VIP", (done) => { + addUserAsVIP(publicPermVIP1, false) + .then(async res => { + assert.strictEqual(res.status, 200); + const row = await checkUserVIP(publicPermVIP1); + assert.ok(!row); + done(); + }) + .catch(err => done(err)); + }); + it("Should remove VIP if enabled is false", (done) => { + client({ + method: "POST", + url: endpoint, + params: { + userID: publicPermVIP2, + adminUserID: adminPrivateUserID, + enabled: "invalid-text" + } + }) + .then(async res => { + assert.strictEqual(res.status, 200); + const row = await checkUserVIP(publicPermVIP2); + assert.ok(!row); + done(); + }) + .catch(err => done(err)); + }); + it("Should remove VIP if enabled is missing", (done) => { + client({ + method: "POST", + url: endpoint, + params: { + userID: publicPermVIP3, + adminUserID: adminPrivateUserID + } + }) + .then(async res => { + assert.strictEqual(res.status, 200); + const row = await checkUserVIP(publicPermVIP3); + assert.ok(!row); + done(); + }) + .catch(err => done(err)); + }); +}); \ No newline at end of file diff --git a/test/cases/generateVerifyToken.ts b/test/cases/generateVerifyToken.ts new file mode 100644 index 00000000..ef02a5fc --- /dev/null +++ b/test/cases/generateVerifyToken.ts @@ -0,0 +1,189 @@ +import assert from "assert"; +import { config } from "../../src/config"; +import axios from "axios"; +import { createAndSaveToken, TokenType } from "../../src/utils/tokenUtils"; +import MockAdapter from "axios-mock-adapter"; +let mock: MockAdapter; +import * as patreon from "../mocks/patreonMock"; +import * as gumroad from "../mocks/gumroadMock"; +import { client } from "../utils/httpClient"; +import { validatelicenseKeyRegex } from "../../src/routes/verifyToken"; + +const generateEndpoint = "/api/generateToken"; +const getGenerateToken = (type: string, code: string | null, adminUserID: string | null) => client({ + url: `${generateEndpoint}/${type}`, + params: { code, adminUserID } +}); + +const verifyEndpoint = "/api/verifyToken"; +const getVerifyToken = (licenseKey: string | null) => client({ + url: verifyEndpoint, + params: { licenseKey } +}); + +let patreonLicense: string; +let localLicense: string; +const gumroadLicense = gumroad.generateLicense(); + +const extractLicenseKey = (data: string) => { + const regex = /([A-Za-z0-9]{40})/; + const match = data.match(regex); + if (!match) throw new Error("Failed to extract license key"); + return match[1]; +}; + +describe("generateToken test", function() { + + before(function() { + mock = new MockAdapter(axios, { onNoMatch: "throwException" }); + mock.onPost("https://www.patreon.com/api/oauth2/token").reply(200, patreon.fakeOauth); + }); + + after(function () { + mock.restore(); + }); + + it("Should be able to create patreon token for active patron", function (done) { + mock.onGet(/identity/).reply(200, patreon.activeIdentity); + if (!config?.patreon) this.skip(); + getGenerateToken("patreon", "patreon_code", "").then(res => { + patreonLicense = extractLicenseKey(res.data); + assert.ok(validatelicenseKeyRegex(patreonLicense)); + done(); + }).catch(err => done(err)); + }); + + it("Should create patreon token for invalid patron", function (done) { + mock.onGet(/identity/).reply(200, patreon.formerIdentityFail); + if (!config?.patreon) this.skip(); + getGenerateToken("patreon", "patreon_code", "").then(res => { + patreonLicense = extractLicenseKey(res.data); + assert.ok(validatelicenseKeyRegex(patreonLicense)); + done(); + }).catch(err => done(err)); + }); + + it("Should be able to create new local token", function (done) { + createAndSaveToken(TokenType.local).then((licenseKey) => { + assert.ok(validatelicenseKeyRegex(licenseKey)); + localLicense = licenseKey; + done(); + }).catch(err => done(err)); + }); + + it("Should return 400 if missing code parameter", function (done) { + getGenerateToken("patreon", null, "").then(res => { + assert.strictEqual(res.status, 400); + done(); + }).catch(err => done(err)); + }); + + it("Should return 403 if missing adminuserID parameter", function (done) { + getGenerateToken("local", "fake-code", null).then(res => { + assert.strictEqual(res.status, 403); + done(); + }).catch(err => done(err)); + }); + + it("Should return 403 for invalid adminuserID parameter", function (done) { + getGenerateToken("local", "fake-code", "fakeAdminID").then(res => { + assert.strictEqual(res.status, 403); + done(); + }).catch(err => done(err)); + }); +}); + +describe("verifyToken static tests", function() { + it("Should fast reject invalid token", function (done) { + getVerifyToken("00000").then(res => { + assert.strictEqual(res.status, 200); + assert.ok(!res.data.allowed); + done(); + }).catch(err => done(err)); + }); + + it("Should return 400 if missing code token", function (done) { + getVerifyToken(null).then(res => { + assert.strictEqual(res.status, 400); + done(); + }).catch(err => done(err)); + }); +}); + +describe("verifyToken mock tests", function() { + + beforeEach(function() { + mock = new MockAdapter(axios, { onNoMatch: "throwException" }); + mock.onPost("https://www.patreon.com/api/oauth2/token").reply(200, patreon.fakeOauth); + }); + + afterEach(function () { + mock.restore(); + }); + + it("Should accept current patron", function (done) { + if (!config?.patreon) this.skip(); + mock.onGet(/identity/).reply(200, patreon.activeIdentity); + getVerifyToken(patreonLicense).then(res => { + assert.strictEqual(res.status, 200); + assert.ok(res.data.allowed); + done(); + }).catch(err => done(err)); + }); + + it("Should reject nonexistent patron", function (done) { + if (!config?.patreon) this.skip(); + mock.onGet(/identity/).reply(200, patreon.invalidIdentity); + getVerifyToken(patreonLicense).then(res => { + assert.strictEqual(res.status, 200); + assert.ok(!res.data.allowed); + done(); + }).catch(err => done(err)); + }); + + it("Should accept qualitying former patron", function (done) { + if (!config?.patreon) this.skip(); + mock.onGet(/identity/).reply(200, patreon.formerIdentitySucceed); + getVerifyToken(patreonLicense).then(res => { + assert.strictEqual(res.status, 200); + assert.ok(res.data.allowed); + done(); + }).catch(err => done(err)); + }); + + it("Should reject unqualitifed former patron", function (done) { + if (!config?.patreon) this.skip(); + mock.onGet(/identity/).reply(200, patreon.formerIdentityFail); + getVerifyToken(patreonLicense).then(res => { + assert.strictEqual(res.status, 200); + assert.ok(!res.data.allowed); + done(); + }).catch(err => done(err)); + }); + + it("Should accept real gumroad key", function (done) { + mock.onPost("https://api.gumroad.com/v2/licenses/verify").reply(200, gumroad.licenseSuccess); + getVerifyToken(gumroadLicense).then(res => { + assert.strictEqual(res.status, 200); + assert.ok(res.data.allowed); + done(); + }).catch(err => done(err)); + }); + + it("Should reject fake gumroad key", function (done) { + mock.onPost("https://api.gumroad.com/v2/licenses/verify").reply(200, gumroad.licenseFail); + getVerifyToken(gumroadLicense).then(res => { + assert.strictEqual(res.status, 200); + assert.ok(!res.data.allowed); + done(); + }).catch(err => done(err)); + }); + + it("Should validate local license", function (done) { + getVerifyToken(localLicense).then(res => { + assert.strictEqual(res.status, 200); + assert.ok(res.data.allowed); + done(); + }).catch(err => done(err)); + }); +}); diff --git a/test/cases/getDaysSavedFormatted.ts b/test/cases/getDaysSavedFormatted.ts new file mode 100644 index 00000000..2b2ae230 --- /dev/null +++ b/test/cases/getDaysSavedFormatted.ts @@ -0,0 +1,27 @@ +import assert from "assert"; +import { client } from "../utils/httpClient"; +import sinon from "sinon"; +import { db } from "../../src/databases/databases"; + +const endpoint = "/api/getDaysSavedFormatted"; + +describe("getDaysSavedFormatted", () => { + it("can get days saved", async () => { + const result = await client({ url: endpoint }); + assert.ok(result.data.daysSaved >= 0); + }); + + it("returns 0 days saved if no segments", async () => { + const stub = sinon.stub(db, "prepare").resolves(undefined); + const result = await client({ url: endpoint }); + assert.ok(result.data.daysSaved >= 0); + stub.restore(); + }); + + it("returns days saved to 2 fixed points", async () => { + const stub = sinon.stub(db, "prepare").resolves({ daysSaved: 1.23456789 }); + const result = await client({ url: endpoint }); + assert.strictEqual(result.data.daysSaved, "1.23"); + stub.restore(); + }); +}); \ No newline at end of file diff --git a/test/cases/getIP.ts b/test/cases/getIP.ts new file mode 100644 index 00000000..f761e01b --- /dev/null +++ b/test/cases/getIP.ts @@ -0,0 +1,109 @@ +import sinon from "sinon"; +import { config } from "../../src/config"; +import assert from "assert"; +const mode = "production"; +let stub: sinon.SinonStub; +let stub2: sinon.SinonStub; +import { createRequest } from "../mocks/mockExpressRequest"; +import { getIP } from "../../src/utils/getIP"; + +const v4RequestOptions = { + headers: { + "x-forwarded-for": "127.0.1.1", + "cf-connecting-ip": "127.0.1.2", + "x-real-ip": "127.0.1.3", + }, + ip: "127.0.1.5", + socket: { + remoteAddress: "127.0.1.4" + } +}; +const v6RequestOptions = { + headers: { + "x-forwarded-for": "[100::1]", + "cf-connecting-ip": "[100::2]", + "x-real-ip": "[100::3]", + }, + ip: "[100::5]", + socket: { + remoteAddress: "[100::4]" + } +}; +const v4MockRequest = createRequest(v4RequestOptions); +const v6MockRequest = createRequest(v6RequestOptions); + +const expectedIP4 = { + "X-Forwarded-For": "127.0.1.1", + "Cloudflare": "127.0.1.2", + "X-Real-IP": "127.0.1.3", + "default": "127.0.1.4", +}; + +const expectedIP6 = { + "X-Forwarded-For": "[100::1]", + "Cloudflare": "[100::2]", + "X-Real-IP": "[100::3]", + "default": "[100::4]", +}; + +describe("getIP stubs", () => { + before(() => stub = sinon.stub(config, "mode").value(mode)); + after(() => stub.restore()); + + it("Should return production mode if stub worked", (done) => { + assert.strictEqual(config.mode, mode); + done(); + }); +}); + +describe("getIP array tests", () => { + beforeEach(() => stub = sinon.stub(config, "mode").value(mode)); + afterEach(() => { + stub.restore(); + stub2.restore(); + }); + + for (const [key, value] of Object.entries(expectedIP4)) { + it(`Should return correct IPv4 from ${key}`, (done) => { + stub2 = sinon.stub(config, "behindProxy").value(key); + const ip = getIP(v4MockRequest); + assert.strictEqual(config.behindProxy, key); + assert.strictEqual(ip, value); + done(); + }); + } + + for (const [key, value] of Object.entries(expectedIP6)) { + it(`Should return correct IPv6 from ${key}`, (done) => { + stub2 = sinon.stub(config, "behindProxy").value(key); + const ip = getIP(v6MockRequest); + assert.strictEqual(config.behindProxy, key); + assert.strictEqual(ip, value); + done(); + }); + } +}); + +describe("getIP true tests", () => { + before(() => stub = sinon.stub(config, "mode").value(mode)); + after(() => { + stub.restore(); + stub2.restore(); + }); + + it(`Should return correct IPv4 from with bool true`, (done) => { + stub2 = sinon.stub(config, "behindProxy").value(true); + const ip = getIP(v4MockRequest); + assert.strictEqual(config.behindProxy, "X-Forwarded-For"); + assert.strictEqual(ip, expectedIP4["X-Forwarded-For"]); + done(); + }); + + it(`Should return correct IPv4 from with string true`, (done) => { + stub2 = sinon.stub(config, "behindProxy").value("true"); + const ip = getIP(v4MockRequest); + assert.strictEqual(config.behindProxy, "X-Forwarded-For"); + assert.strictEqual(ip, expectedIP4["X-Forwarded-For"]); + done(); + }); +}); \ No newline at end of file diff --git a/test/cases/getLockCategoriesByHash.ts b/test/cases/getLockCategoriesByHash.ts index 9695731e..f6b2757a 100644 --- a/test/cases/getLockCategoriesByHash.ts +++ b/test/cases/getLockCategoriesByHash.ts @@ -166,17 +166,77 @@ describe("getLockCategoriesByHash", () => { .catch(err => done(err)); }); - it("Should be able to get by actionType", (done) => { - getLockCategories(fakeHash.substring(0,5), [ActionType.Full]) + it("should return 400 if invalid actionTypes", (done) => { + client.get(`${endpoint}/aaaa`, { params: { actionTypes: 3 } }) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); + + it("should return 400 if invalid actionTypes JSON", (done) => { + client.get(`${endpoint}/aaaa`, { params: { actionTypes: "{3}" } }) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); + + it("Should be able to get single lock", (done) => { + const videoID = "getLockHash2"; + const hash = getHash(videoID, 1); + getLockCategories(hash.substring(0,6)) .then(res => { assert.strictEqual(res.status, 200); const expected = [{ - videoID: "fakehash-2", - hash: fakeHash, + videoID, + hash, + categories: [ + "preview" + ], + reason: "2-reason" + }]; + assert.deepStrictEqual(res.data, expected); + done(); + }) + .catch(err => done(err)); + }); + + it("Should be able to get by actionType not in array", (done) => { + const videoID = "getLockHash2"; + const hash = getHash(videoID, 1); + client.get(`${endpoint}/${hash.substring(0,6)}`, { params: { actionType: ActionType.Skip } }) + .then(res => { + assert.strictEqual(res.status, 200); + const expected = [{ + videoID, + hash, + categories: [ + "preview" + ], + reason: "2-reason" + }]; + assert.deepStrictEqual(res.data, expected); + done(); + }) + .catch(err => done(err)); + }); + + it("Should be able to get by no actionType", (done) => { + const videoID = "getLockHash2"; + const hash = getHash(videoID, 1); + client.get(`${endpoint}/${hash.substring(0,6)}`) + .then(res => { + assert.strictEqual(res.status, 200); + const expected = [{ + videoID, + hash, categories: [ - "sponsor" + "preview" ], - reason: "fake2-notshown" + reason: "2-reason" }]; assert.deepStrictEqual(res.data, expected); done(); diff --git a/test/cases/getLockReason.ts b/test/cases/getLockReason.ts index 9bbb35bc..1b3c36ba 100644 --- a/test/cases/getLockReason.ts +++ b/test/cases/getLockReason.ts @@ -55,6 +55,45 @@ describe("getLockReason", () => { .catch(err => done(err)); }); + it("Should be able to get with actionTypes array", (done) => { + client.get(endpoint, { params: { videoID: "getLockReason", category: "selfpromo", actionTypes: '["full"]' } }) + .then(res => { + assert.strictEqual(res.status, 200); + const expected = [ + { category: "selfpromo", locked: 1, reason: "selfpromo-reason", userID: vipUserID2, userName: vipUserName2 } + ]; + assert.deepStrictEqual(res.data, expected); + done(); + }) + .catch(err => done(err)); + }); + + it("Should be able to get with actionType", (done) => { + client.get(endpoint, { params: { videoID: "getLockReason", category: "selfpromo", actionType: "full" } }) + .then(res => { + assert.strictEqual(res.status, 200); + const expected = [ + { category: "selfpromo", locked: 1, reason: "selfpromo-reason", userID: vipUserID2, userName: vipUserName2 } + ]; + assert.deepStrictEqual(res.data, expected); + done(); + }) + .catch(err => done(err)); + }); + + it("Should be able to get with actionType array", (done) => { + client.get(endpoint, { params: { videoID: "getLockReason", category: "selfpromo", actionType: ["full"] } }) + .then(res => { + assert.strictEqual(res.status, 200); + const expected = [ + { category: "selfpromo", locked: 1, reason: "selfpromo-reason", userID: vipUserID2, userName: vipUserName2 } + ]; + assert.deepStrictEqual(res.data, expected); + done(); + }) + .catch(err => done(err)); + }); + it("Should be able to get empty locks", (done) => { client.get(endpoint, { params: { videoID: "getLockReason", category: "intro" } }) .then(res => { @@ -118,8 +157,10 @@ describe("getLockReason", () => { }) .catch(err => done(err)); }); +}); - it("should return 400 if no videoID specified", (done) => { +describe("getLockReason 400", () => { + it("Should return 400 with missing videoID", (done) => { client.get(endpoint) .then(res => { assert.strictEqual(res.status, 400); @@ -128,15 +169,37 @@ describe("getLockReason", () => { .catch(err => done(err)); }); - it("should be able to get by actionType", (done) => { - client.get(endpoint, { params: { videoID: "getLockReason", actionType: "full" } }) + it("Should return 400 with invalid actionTypes ", (done) => { + client.get(endpoint, { params: { videoID: "valid-videoid", actionTypes: 3 } }) .then(res => { - assert.strictEqual(res.status, 200); - const expected = [ - { category: "selfpromo", locked: 1, reason: "sponsor-reason", userID: vipUserID2, userName: vipUserName2 }, - { category: "sponsor", locked: 0, reason: "", userID: "", userName: "" } - ]; - partialDeepEquals(res.data, expected); + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); + + it("Should return 400 with invalid actionTypes JSON ", (done) => { + client.get(endpoint, { params: { videoID: "valid-videoid", actionTypes: "{3}" } }) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); + + it("Should return 400 with invalid categories", (done) => { + client.get(endpoint, { params: { videoID: "valid-videoid", categories: 3 } }) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); + + it("Should return 400 with invalid categories JSON", (done) => { + client.get(endpoint, { params: { videoID: "valid-videoid", categories: "{3}" } }) + .then(res => { + assert.strictEqual(res.status, 400); done(); }) .catch(err => done(err)); diff --git a/test/cases/getSavedTimeForUser.ts b/test/cases/getSavedTimeForUser.ts index 7ed809fb..b7e538a9 100644 --- a/test/cases/getSavedTimeForUser.ts +++ b/test/cases/getSavedTimeForUser.ts @@ -2,22 +2,31 @@ import { db } from "../../src/databases/databases"; import { getHash } from "../../src/utils/getHash"; import { deepStrictEqual } from "assert"; import { client } from "../utils/httpClient"; +import assert from "assert"; + +// helpers const endpoint = "/api/getSavedTimeForUser"; +const getSavedTimeForUser = (userID: string) => client({ + url: endpoint, + params: { userID } +}); describe("getSavedTimeForUser", () => { - const user1 = "getSavedTimeForUserUser"; + const user1 = "getSavedTimeForUser1"; + const user2 = "getSavedTimeforUser2"; + const [ start, end, views ] = [1, 11, 50]; + before(async () => { const startOfQuery = 'INSERT INTO "sponsorTimes" ("videoID", "startTime", "endTime", "votes", "UUID", "userID", "timeSubmitted", "views", "shadowHidden") VALUES'; await db.prepare("run", `${startOfQuery}(?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ["getSavedTimeForUser", 1, 11, 2, "gstfu0", getHash(user1), 0, 50, 0]); + ["getSavedTimeForUser", start, end, 2, "getSavedTimeUUID0", getHash(user1), 0, views, 0]); return; }); - - it("Should be able to get a 200", (done) => { - client.get(endpoint, { params: { userID: user1 } }) + it("Should be able to get a saved time", (done) => { + getSavedTimeForUser(user1) .then(res => { // (end-start)*minute * views - const savedMinutes = ((11-1)/60) * 50; + const savedMinutes = ((end-start)/60) * views; const expected = { timeSaved: savedMinutes }; @@ -26,4 +35,20 @@ describe("getSavedTimeForUser", () => { }) .catch((err) => done(err)); }); + it("Should return 404 if no submissions", (done) => { + getSavedTimeForUser(user2) + .then(res => { + assert.strictEqual(res.status, 404); + done(); + }) + .catch((err) => done(err)); + }); + it("Should return 400 if no userID", (done) => { + client({ url: endpoint }) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch((err) => done(err)); + }); }); diff --git a/test/cases/getSearchSegments.ts b/test/cases/getSearchSegments.ts index 4e786040..6dbef4f5 100644 --- a/test/cases/getSearchSegments.ts +++ b/test/cases/getSearchSegments.ts @@ -80,6 +80,67 @@ describe("getSearchSegments", () => { .catch(err => done(err)); }); + it("Should be able to filter by category with categories string", (done) => { + client.get(endpoint, { params: { videoID: "searchTest0", categories: `["selfpromo"]` } }) + .then(res => { + assert.strictEqual(res.status, 200); + const data = res.data; + const segments = data.segments; + assert.strictEqual(data.segmentCount, 1); + assert.strictEqual(data.page, 0); + assert.strictEqual(segments[0].UUID, "search-downvote"); + done(); + }) + .catch(err => done(err)); + }); + + it("Should be able to filter by category with categories array", (done) => { + client.get(endpoint, { params: { videoID: "searchTest0", category: ["selfpromo"] } }) + .then(res => { + assert.strictEqual(res.status, 200); + const data = res.data; + const segments = data.segments; + assert.strictEqual(data.segmentCount, 1); + assert.strictEqual(data.page, 0); + assert.strictEqual(segments[0].UUID, "search-downvote"); + done(); + }) + .catch(err => done(err)); + }); + + it("Should be able to filter by category with actionTypes JSON", (done) => { + client.get(endpoint, { params: { videoID: "searchTest5", actionTypes: `["mute"]` } }) + .then(res => { + assert.strictEqual(res.status, 200); + const data = res.data; + assert.strictEqual(data.segmentCount, 1); + done(); + }) + .catch(err => done(err)); + }); + + it("Should be able to filter by category with actionType array", (done) => { + client.get(endpoint, { params: { videoID: "searchTest5", actionType: ["mute"] } }) + .then(res => { + assert.strictEqual(res.status, 200); + const data = res.data; + assert.strictEqual(data.segmentCount, 1); + done(); + }) + .catch(err => done(err)); + }); + + it("Should be able to filter by category with actionType string", (done) => { + client.get(endpoint, { params: { videoID: "searchTest5", actionType: "mute" } }) + .then(res => { + assert.strictEqual(res.status, 200); + const data = res.data; + assert.strictEqual(data.segmentCount, 1); + done(); + }) + .catch(err => done(err)); + }); + it("Should be able to filter by lock status", (done) => { client.get(endpoint, { params: { videoID: "searchTest0", locked: false } }) .then(res => { diff --git a/test/cases/getSearchSegments4xx.ts b/test/cases/getSearchSegments4xx.ts new file mode 100644 index 00000000..708a0b0a --- /dev/null +++ b/test/cases/getSearchSegments4xx.ts @@ -0,0 +1,48 @@ +import { client } from "../utils/httpClient"; +import assert from "assert"; + +describe("getSearchSegments 4xx", () => { + const endpoint = "/api/searchSegments"; + + it("Should return 400 if no videoID", (done) => { + client.get(endpoint, { params: {} }) + .then(res => { + assert.strictEqual(res.status, 400); + const data = res.data; + assert.strictEqual(data, "videoID not specified"); + done(); + }) + .catch(err => done(err)); + }); + + it("Should return 400 if invalid categories", (done) => { + client.get(endpoint, { params: { videoID: "nullVideo", categories: 3 } }) + .then(res => { + assert.strictEqual(res.status, 400); + const data = res.data; + assert.strictEqual(data, "Categories parameter does not match format requirements."); + done(); + }) + .catch(err => done(err)); + }); + + it("Should return 400 if invalid actionTypes", (done) => { + client.get(endpoint, { params: { videoID: "nullVideo", actionTypes: 3 } }) + .then(res => { + assert.strictEqual(res.status, 400); + const data = res.data; + assert.strictEqual(data, "actionTypes parameter does not match format requirements."); + done(); + }) + .catch(err => done(err)); + }); + + it("Should return 404 if no segments", (done) => { + client.get(endpoint, { params: { videoID: "nullVideo", actionType: "chapter" } }) + .then(res => { + assert.strictEqual(res.status, 404); + done(); + }) + .catch(err => done(err)); + }); +}); diff --git a/test/cases/getSkipSegmentsByHash.ts b/test/cases/getSkipSegmentsByHash.ts index 098f9c0e..3129f91f 100644 --- a/test/cases/getSkipSegmentsByHash.ts +++ b/test/cases/getSkipSegmentsByHash.ts @@ -3,7 +3,7 @@ import { partialDeepEquals, arrayPartialDeepEquals } from "../utils/partialDeepE import { getHash } from "../../src/utils/getHash"; import { ImportMock, } from "ts-mock-imports"; import * as YouTubeAPIModule from "../../src/utils/youtubeApi"; -import { YouTubeApiMock } from "../youtubeMock"; +import { YouTubeApiMock } from "../mocks/youtubeMock"; import assert from "assert"; import { client } from "../utils/httpClient"; @@ -581,4 +581,78 @@ describe("getSkipSegmentsByHash", () => { }) .catch(err => done(err)); }); + + it("Should be able to get single segment with requiredSegments", (done) => { + const requiredSegment1 = "fbf0af454059733c8822f6a4ac8ec568e0787f8c0a5ee915dd5b05e0d7a9a388"; + client.get(`${endpoint}/17bf?requiredSegment=${requiredSegment1}`) + .then(res => { + assert.strictEqual(res.status, 200); + const data = (res.data as Array).sort((a, b) => a.videoID.localeCompare(b.videoID)); + assert.strictEqual(data.length, 1); + const expected = [{ + segments: [{ + UUID: requiredSegment1 + }] + }]; + assert.ok(partialDeepEquals(data, expected)); + assert.strictEqual(data[0].segments.length, 1); + done(); + }) + .catch(err => done(err)); + }); + + it("Should return 400 if categories are is number", (done) => { + client.get(`${endpoint}/17bf?categories=3`) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); + + it("Should return 400 if actionTypes is number", (done) => { + client.get(`${endpoint}/17bf?actionTypes=3`) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); + + it("Should return 400 if actionTypes are invalid json", (done) => { + client.get(`${endpoint}/17bf?actionTypes={test}`) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); + + it("Should return 400 if requiredSegments is number", (done) => { + client.get(`${endpoint}/17bf?requiredSegments=3`) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); + + + it("Should return 404 if requiredSegments is invalid json", (done) => { + client.get(`${endpoint}/17bf?requiredSegments={test}`) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); + + it("Should return 400 if requiredSegments is not present", (done) => { + client.get(`${endpoint}/17bf?requiredSegment=${fullCategoryVidHash}`) + .then(res => { + assert.strictEqual(res.status, 404); + done(); + }) + .catch(err => done(err)); + }); }); diff --git a/test/cases/getStatus.ts b/test/cases/getStatus.ts index 7f4ffaf4..f471404b 100644 --- a/test/cases/getStatus.ts +++ b/test/cases/getStatus.ts @@ -2,6 +2,7 @@ import assert from "assert"; import { db } from "../../src/databases/databases"; import { client } from "../utils/httpClient"; import { config } from "../../src/config"; +import sinon from "sinon"; let dbVersion: number; describe("getStatus", () => { @@ -122,4 +123,16 @@ describe("getStatus", () => { }) .catch(err => done(err)); }); + + it("Should return commit unkown if not present", (done) => { + sinon.stub((global as any), "HEADCOMMIT").value(undefined); + client.get(`${endpoint}/commit`) + .then(res => { + assert.strictEqual(res.status, 200); + assert.strictEqual(res.data, "test"); // commit should be test + done(); + }) + .catch(err => done(err)); + sinon.restore(); + }); }); diff --git a/test/cases/getTopUsers.ts b/test/cases/getTopUsers.ts index 8808910d..07ef80c6 100644 --- a/test/cases/getTopUsers.ts +++ b/test/cases/getTopUsers.ts @@ -38,6 +38,15 @@ describe("getTopUsers", () => { .catch(err => done(err)); }); + it("Should return 400 if undefined sortType provided", (done) => { + client.get(endpoint) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); + it("Should be able to get by all sortTypes", (done) => { client.get(endpoint, { params: { sortType: 0 } })// minutesSaved .then(res => { diff --git a/test/cases/getTotalStats.ts b/test/cases/getTotalStats.ts new file mode 100644 index 00000000..f95c2927 --- /dev/null +++ b/test/cases/getTotalStats.ts @@ -0,0 +1,17 @@ +import assert from "assert"; +import { client } from "../utils/httpClient"; + +const endpoint = "/api/getTotalStats"; + +describe("getTotalStats", () => { + it("Can get total stats", async () => { + const result = await client({ url: endpoint }); + const data = result.data; + assert.ok(data?.userCount ?? true); + assert.ok(data.activeUsers >= 0); + assert.ok(data.apiUsers >= 0); + assert.ok(data.viewCount >= 0); + assert.ok(data.totalSubmissions >= 0); + assert.ok(data.minutesSaved >= 0); + }); +}); \ No newline at end of file diff --git a/test/cases/getUserInfo.ts b/test/cases/getUserInfo.ts index d4676c7a..9bcf0433 100644 --- a/test/cases/getUserInfo.ts +++ b/test/cases/getUserInfo.ts @@ -21,6 +21,7 @@ describe("getUserInfo", () => { await db.prepare("run", sponsorTimesQuery, ["getUserInfo0", 0, 36000, 2,"uuid000009", getHash("getuserinfo_user_03"), 8, 10, "sponsor", "skip", 0]); await db.prepare("run", sponsorTimesQuery, ["getUserInfo3", 1, 11, 2, "uuid000006", getHash("getuserinfo_user_02"), 6, 10, "sponsor", "skip", 0]); await db.prepare("run", sponsorTimesQuery, ["getUserInfo4", 1, 11, 2, "uuid000010", getHash("getuserinfo_user_04"), 9, 10, "chapter", "chapter", 0]); + await db.prepare("run", sponsorTimesQuery, ["getUserInfo5", 1, 11, 2, "uuid000011", getHash("getuserinfo_user_05"), 9, 10, "sponsor", "skip", 0]); const insertWarningQuery = 'INSERT INTO warnings ("userID", "issueTime", "issuerUserID", "enabled", "reason") VALUES (?, ?, ?, ?, ?)'; @@ -264,6 +265,15 @@ describe("getUserInfo", () => { .catch(err => done(err)); }); + it("Should throw 400 with invalid array", (done) => { + client.get(endpoint, { params: { userID: "x", values: 123 } }) + .then(res => { + assert.strictEqual(res.status, 400); + done(); // pass + }) + .catch(err => done(err)); + }); + it("Should return 200 on userID not found", (done) => { client.get(endpoint, { params: { userID: "notused-userid" } }) .then(res => { @@ -309,6 +319,30 @@ describe("getUserInfo", () => { .catch(err => done(err)); }); + it("Should be able to get permissions", (done) => { + client.get(endpoint, { params: { userID: "getuserinfo_user_01", value: "permissions" } }) + .then(res => { + assert.strictEqual(res.status, 200); + const expected = { + permissions: { + sponsor: true, + selfpromo: true, + exclusive_access: true, + interaction: true, + intro: true, + outro: true, + preview: true, + music_offtopic: true, + filler: true, + poi_highlight: true, + chapter: false, + }, + }; + assert.ok(partialDeepEquals(res.data, expected)); + done(); // pass + }); + }); + it("Should ignore chapters for saved time calculations", (done) => { client.get(endpoint, { params: { userID: "getuserinfo_user_04" } }) .then(res => { diff --git a/test/cases/getUserInfoFree.ts b/test/cases/getUserInfoFree.ts new file mode 100644 index 00000000..847a59a0 --- /dev/null +++ b/test/cases/getUserInfoFree.ts @@ -0,0 +1,76 @@ +import { db } from "../../src/databases/databases"; +import { getHash } from "../../src/utils/getHash"; +import assert from "assert"; +import { client } from "../utils/httpClient"; + +describe("getUserInfo Free Chapters", () => { + const endpoint = "/api/userInfo"; + + const newQualifyUserID = "getUserInfo-Free-newQualify"; + const vipQualifyUserID = "getUserInfo-Free-VIP"; + const repQualifyUserID = "getUserInfo-Free-RepQualify"; + const oldQualifyUserID = "getUserInfo-Free-OldQualify"; + const newNoQualityUserID = "getUserInfo-Free-newNoQualify"; + const postOldQualify = 1600000000000; + + before(async () => { + const sponsorTimesQuery = 'INSERT INTO "sponsorTimes" ("videoID", "startTime", "endTime", "votes", "UUID", "userID", "timeSubmitted", views, category, "actionType", "reputation", "shadowHidden") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'; + await db.prepare("run", sponsorTimesQuery, ["getUserInfoFree", 1, 2, 0, "uuid-guif-0", getHash(repQualifyUserID), postOldQualify, 0, "sponsor", "skip", 20, 0]); + await db.prepare("run", sponsorTimesQuery, ["getUserInfoFree", 1, 2, 0, "uuid-guif-1", getHash(oldQualifyUserID), 0, 0, "sponsor", "skip", 0, 0]); // submit at epoch + await db.prepare("run", sponsorTimesQuery, ["getUserInfoFree", 1, 2, 0, "uuid-guif-2", getHash(newQualifyUserID), postOldQualify, 0, "sponsor", "skip", 0, 0]); + + await db.prepare("run", `INSERT INTO "vipUsers" ("userID") VALUES (?)`, [getHash(vipQualifyUserID)]); + }); + + const getUserInfo = (userID: string) => client.get(endpoint, { params: { userID, value: "freeChaptersAccess" } }); + + it("Should not get free access under new rule (newNoQualify)", (done) => { + getUserInfo(newNoQualityUserID) + .then(res => { + assert.strictEqual(res.status, 200); + assert.strictEqual(res.data.freeChaptersAccess, false); + done(); + }) + .catch(err => done(err)); + }); + + it("Should get free access under new rule (newQualify)", (done) => { + getUserInfo(newQualifyUserID) + .then(res => { + assert.strictEqual(res.status, 200); + assert.strictEqual(res.data.freeChaptersAccess, true); + done(); + }) + .catch(err => done(err)); + }); + + it("Should get free access (VIP)", (done) => { + getUserInfo(vipQualifyUserID) + .then(res => { + assert.strictEqual(res.status, 200); + assert.strictEqual(res.data.freeChaptersAccess, true); + done(); + }) + .catch(err => done(err)); + }); + + it("Should get free access (rep)", (done) => { + getUserInfo(repQualifyUserID) + .then(res => { + assert.strictEqual(res.status, 200); + assert.strictEqual(res.data.freeChaptersAccess, true); + done(); + }) + .catch(err => done(err)); + }); + + it("Should get free access (old)", (done) => { + getUserInfo(oldQualifyUserID) + .then(res => { + assert.strictEqual(res.status, 200); + assert.strictEqual(res.data.freeChaptersAccess, true); + done(); + }) + .catch(err => done(err)); + }); +}); diff --git a/test/cases/getUsername.ts b/test/cases/getUsername.ts new file mode 100644 index 00000000..5394434d --- /dev/null +++ b/test/cases/getUsername.ts @@ -0,0 +1,53 @@ +import { getHash } from "../../src/utils/getHash"; +import { client } from "../utils/httpClient"; +import assert from "assert"; + +// helpers +const getUsername = (userID: string) => client({ + url: "/api/getUsername", + params: { userID } +}); + +const postSetUserName = (userID: string, username: string) => client({ + method: "POST", + url: "/api/setUsername", + params: { + userID, + username, + } +}); + +const userOnePrivate = "getUsername_0"; +const userOnePublic = getHash(userOnePrivate); +const userOneUsername = "getUsername_username"; + +describe("getUsername test", function() { + it("Should get back publicUserID if not set", (done) => { + getUsername(userOnePrivate) + .then(result => { + assert.strictEqual(result.data.userName, userOnePublic); + done(); + }) + .catch(err => done(err)); + }); + it("Should be able to get username after setting", (done) => { + postSetUserName(userOnePrivate, userOneUsername) + .then(async () => { + const result = await getUsername(userOnePrivate); + const actual = result.data.userName; + assert.strictEqual(actual, userOneUsername); + done(); + }) + .catch(err => done(err)); + }); + it("Should return 400 if no userID provided", (done) => { + client({ + url: "/api/getUsername" + }) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); +}); \ No newline at end of file diff --git a/test/cases/getViewsForUser.ts b/test/cases/getViewsForUser.ts new file mode 100644 index 00000000..9471e18c --- /dev/null +++ b/test/cases/getViewsForUser.ts @@ -0,0 +1,62 @@ +import { getHash } from "../../src/utils/getHash"; +import { db } from "../../src/databases/databases"; +import { client } from "../utils/httpClient"; +import assert from "assert"; + +// helpers +const endpoint = "/api/getViewsForUser"; +const getViewsForUser = (userID: string) => client({ + url: endpoint, + params: { userID } +}); + +const getViewUserOne = "getViewUser1"; +const userOneViewsFirst = 30; +const userOneViewsSecond = 0; + +const getViewUserTwo = "getViewUser2"; +const userTwoViews = 0; + +const getViewUserThree = "getViewUser3"; + + +describe("getViewsForUser", function() { + before(() => { + const insertSponsorTimeQuery = 'INSERT INTO "sponsorTimes" ("videoID", "startTime", "endTime", "votes", "UUID", "userID", "timeSubmitted", views, category, "actionType", "videoDuration", "shadowHidden", "hashedVideoID") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'; + db.prepare("run", insertSponsorTimeQuery, ["getViewUserVideo", 0, 1, 0, "getViewUserVideo0", getHash(getViewUserOne), 0, userOneViewsFirst, "sponsor", "skip", 0, 0, "getViewUserVideo"]); + db.prepare("run", insertSponsorTimeQuery, ["getViewUserVideo", 0, 1, 0, "getViewUserVideo1", getHash(getViewUserOne), 0, userOneViewsSecond, "sponsor", "skip", 0, 0, "getViewUserVideo"]); + db.prepare("run", insertSponsorTimeQuery, ["getViewUserVideo", 0, 1, 0, "getViewUserVideo2", getHash(getViewUserTwo), 0, userTwoViews, "sponsor", "skip", 0, 0, "getViewUserVideo"]); + }); + it("Should get back views for user one", (done) => { + getViewsForUser(getViewUserOne) + .then(result => { + assert.strictEqual(result.data.viewCount, userOneViewsFirst + userOneViewsSecond); + done(); + }) + .catch(err => done(err)); + }); + it("Should get back views for user two", (done) => { + getViewsForUser(getViewUserTwo) + .then(result => { + assert.strictEqual(result.data.viewCount, userTwoViews); + done(); + }) + .catch(err => done(err)); + }); + it("Should get 404 if no submissions", (done) => { + getViewsForUser(getViewUserThree) + .then(result => { + assert.strictEqual(result.status, 404); + done(); + }) + .catch(err => done(err)); + }); + it("Should return 400 if no userID provided", (done) => { + client({ url: endpoint }) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); +}); \ No newline at end of file diff --git a/test/cases/lockCategoriesHttp.ts b/test/cases/lockCategoriesHttp.ts new file mode 100644 index 00000000..8525d462 --- /dev/null +++ b/test/cases/lockCategoriesHttp.ts @@ -0,0 +1,252 @@ +import assert from "assert"; +import { client } from "../utils/httpClient"; +import { getHash } from "../../src/utils/getHash"; +import { db } from "../../src/databases/databases"; +import { UserID } from "../../src/types/user.model"; +import { Category, VideoID } from "../../src/types/segments.model"; + +interface LockCategory { + category: Category, + reason: string, + videoID: VideoID, + userID: UserID +} +const lockVIPUser = "lockCategoriesHttpVIPUser"; +const lockVIPUserHash = getHash(lockVIPUser); +const endpoint = "/api/lockCategories"; +const checkLockCategories = (videoID: string): Promise => db.prepare("all", 'SELECT * FROM "lockCategories" WHERE "videoID" = ?', [videoID]); + + +const goodResponse = (): any => ({ + videoID: "test-videoid", + userID: "not-vip-test-userid", + categories: ["sponsor"], + actionTypes: ["skip"] +}); + +describe("POST lockCategories HTTP submitting", () => { + before(async () => { + const insertVipUserQuery = 'INSERT INTO "vipUsers" ("userID") VALUES (?)'; + await db.prepare("run", insertVipUserQuery, [lockVIPUserHash]); + }); + + it("Should update the database version when starting the application", async () => { + const version = (await db.prepare("get", "SELECT key, value FROM config where key = ?", ["version"])).value; + assert.ok(version > 1); + }); + + it("should be able to add poi type category by type skip", (done) => { + const videoID = "add-record-poi"; + client.post(endpoint, { + videoID, + userID: lockVIPUser, + categories: ["poi_highlight"], + actionTypes: ["skip"] + }) + .then(res => { + assert.strictEqual(res.status, 200); + checkLockCategories(videoID) + .then(result => { + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0], "poi_highlight"); + }); + done(); + }) + .catch(err => done(err)); + }); + + it("Should not add lock of invalid type", (done) => { + const videoID = "add_invalid_type"; + client.post(endpoint, { + videoID, + userID: lockVIPUser, + categories: ["future_unused_invalid_type"], + actionTypes: ["skip"] + }) + .then(res => { + assert.strictEqual(res.status, 200); + checkLockCategories(videoID) + .then(result => { + assert.strictEqual(result.length, 0); + }); + done(); + }) + .catch(err => done(err)); + }); +}); + +describe("DELETE lockCategories 403/400 tests", () => { + it(" Should return 400 for no data", (done) => { + client.delete(endpoint, {}) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); + + it("Should return 400 for no categories", (done) => { + const json: any = { + videoID: "test", + userID: "test", + categories: [], + }; + client.delete(endpoint, json) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); + + it("Should return 400 for no userID", (done) => { + const json: any = { + videoID: "test", + userID: null, + categories: ["sponsor"], + }; + + client.post(endpoint, json) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); + + it("Should return 400 for no videoID", (done) => { + const json: any = { + videoID: null, + userID: "test", + categories: ["sponsor"], + }; + + client.post(endpoint, json) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); + + it("Should return 400 for invalid category array", (done) => { + const json = { + videoID: "test", + userID: "test", + categories: {}, + }; + + client.post(endpoint, json) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); + + it("Should return 400 for bad format categories", (done) => { + const json = { + videoID: "test", + userID: "test", + categories: "sponsor", + }; + + client.post(endpoint, json) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); + + it("Should return 403 if user is not VIP", (done) => { + const json = { + videoID: "test", + userID: "test", + categories: [ + "sponsor", + ], + }; + + client.post(endpoint, json) + .then(res => { + assert.strictEqual(res.status, 403); + done(); + }) + .catch(err => done(err)); + }); +}); + +describe("manual DELETE/POST lockCategories 400 tests", () => { + it("DELETE Should return 400 for no data", (done) => { + client.delete(endpoint, { data: {} }) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); + it("POST Should return 400 for no data", (done) => { + client.post(endpoint, {}) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); + it("DELETE Should return 400 for bad format categories", (done) => { + const data = goodResponse(); + data.categories = "sponsor"; + client.delete(endpoint, { data }) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); + it("POST Should return 400 for bad format categories", (done) => { + const data = goodResponse(); + data.categories = "sponsor"; + client.post(endpoint, data) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); + it("DELETE Should return 403 if user is not VIP", (done) => { + const data = goodResponse(); + client.delete(endpoint, { data }) + .then(res => { + assert.strictEqual(res.status, 403); + done(); + }) + .catch(err => done(err)); + }); + it("POST Should return 403 if user is not VIP", (done) => { + const data = goodResponse(); + client.post(endpoint, data) + .then(res => { + assert.strictEqual(res.status, 403); + done(); + }) + .catch(err => done(err)); + }); +}); + +describe("array of DELETE/POST lockCategories 400 tests", () => { + for (const key of [ "videoID", "userID", "categories" ]) { + for (const method of ["DELETE", "POST"]) { + it(`${method} - Should return 400 for invalid ${key}`, (done) => { + const data = goodResponse(); + data[key] = null; + client(endpoint, { data, method }) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); + } + } +}); \ No newline at end of file diff --git a/test/cases/lockCategoriesRecords.ts b/test/cases/lockCategoriesRecords.ts index 7d6bd4a3..ebfc4218 100644 --- a/test/cases/lockCategoriesRecords.ts +++ b/test/cases/lockCategoriesRecords.ts @@ -266,106 +266,6 @@ describe("lockCategoriesRecords", () => { .catch(err => done(err)); }); - it("Should return 400 for missing params", (done) => { - client.post(endpoint, {}) - .then(res => { - assert.strictEqual(res.status, 400); - done(); - }) - .catch(err => done(err)); - }); - - it("Should return 400 for no categories", (done) => { - const json: any = { - videoID: "test", - userID: "test", - categories: [], - }; - client.post(endpoint, json) - .then(res => { - assert.strictEqual(res.status, 400); - done(); - }) - .catch(err => done(err)); - }); - - it("Should return 400 for no userID", (done) => { - const json: any = { - videoID: "test", - userID: null, - categories: ["sponsor"], - }; - - client.post(endpoint, json) - .then(res => { - assert.strictEqual(res.status, 400); - done(); - }) - .catch(err => done(err)); - }); - - it("Should return 400 for no videoID", (done) => { - const json: any = { - videoID: null, - userID: "test", - categories: ["sponsor"], - }; - - client.post(endpoint, json) - .then(res => { - assert.strictEqual(res.status, 400); - done(); - }) - .catch(err => done(err)); - }); - - it("Should return 400 object categories", (done) => { - const json = { - videoID: "test", - userID: "test", - categories: {}, - }; - - client.post(endpoint, json) - .then(res => { - assert.strictEqual(res.status, 400); - done(); - }) - .catch(err => done(err)); - }); - - it("Should return 400 bad format categories", (done) => { - const json = { - videoID: "test", - userID: "test", - categories: "sponsor", - }; - - client.post(endpoint, json) - .then(res => { - assert.strictEqual(res.status, 400); - done(); - }) - .catch(err => done(err)); - }); - - it("Should return 403 if user is not VIP", (done) => { - const json = { - videoID: "test", - userID: "test", - categories: [ - "sponsor", - ], - }; - - client.post(endpoint, json) - .then(res => { - assert.strictEqual(res.status, 403); - done(); - }) - .catch(err => done(err)); - }); - it("Should be able to delete a lockCategories record", (done) => { const videoID = "delete-record"; const json = { diff --git a/test/cases/postSkipSegments.ts b/test/cases/postSkipSegments.ts index 17729dd4..57ebeb8a 100644 --- a/test/cases/postSkipSegments.ts +++ b/test/cases/postSkipSegments.ts @@ -4,7 +4,7 @@ import { partialDeepEquals, arrayDeepEquals } from "../utils/partialDeepEquals"; import { db } from "../../src/databases/databases"; import { ImportMock } from "ts-mock-imports"; import * as YouTubeAPIModule from "../../src/utils/youtubeApi"; -import { YouTubeApiMock } from "../youtubeMock"; +import { YouTubeApiMock } from "../mocks/youtubeMock"; import assert from "assert"; import { client } from "../utils/httpClient"; import { Feature } from "../../src/types/user.model"; diff --git a/test/cases/shadowBanUser.ts b/test/cases/shadowBanUser.ts index 834382cc..a0b321c6 100644 --- a/test/cases/shadowBanUser.ts +++ b/test/cases/shadowBanUser.ts @@ -187,10 +187,34 @@ describe("shadowBanUser", () => { }) .then(async res => { assert.strictEqual(res.status, 200); - const videoRow = await getShadowBanSegmentCategory(userID, 1); + const videoRow = await getShadowBanSegmentCategory(userID, 0); const shadowRow = await getShadowBan(userID); assert.ok(shadowRow); // ban still exists - assert.strictEqual(videoRow.length, 1); // videos should be hidden + assert.strictEqual(videoRow.length, 0); // videos should be hidden + done(); + }) + .catch(err => done(err)); + }); + + it("Should be able to un-shadowban user to restore old submissions", (done) => { + const userID = "shadowBanned4"; + client({ + method: "POST", + url: endpoint, + params: { + userID, + adminUserID: VIPuserID, + enabled: false, + categories: `["sponsor"]`, + unHideOldSubmissions: true + } + }) + .then(async res => { + assert.strictEqual(res.status, 200); + const videoRow = await getShadowBanSegmentCategory(userID, 0); + const shadowRow = await getShadowBan(userID); + assert.ok(!shadowRow); // ban still exists + assert.strictEqual(videoRow.length, 1); // videos should be visible assert.strictEqual(videoRow[0].category, "sponsor"); done(); }) diff --git a/test/cases/shadowBanUser4xx.ts b/test/cases/shadowBanUser4xx.ts new file mode 100644 index 00000000..c9cdda9c --- /dev/null +++ b/test/cases/shadowBanUser4xx.ts @@ -0,0 +1,48 @@ +import { db } from "../../src/databases/databases"; +import { getHash } from "../../src/utils/getHash"; +import assert from "assert"; +import { client } from "../utils/httpClient"; + +const endpoint = "/api/shadowBanUser"; + +const postShadowBan = (params: Record) => client({ + method: "POST", + url: endpoint, + params +}); + +describe("shadowBanUser 4xx", () => { + const VIPuserID = "shadow-ban-4xx-vip"; + + before(async () => { + await db.prepare("run", `INSERT INTO "vipUsers" ("userID") VALUES(?)`, [getHash(VIPuserID)]); + }); + + it("Should return 400 if no adminUserID", (done) => { + const userID = "shadowBanned"; + postShadowBan({ userID }) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); + + it("Should return 400 if no userID", (done) => { + postShadowBan({ adminUserID: VIPuserID }) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); + + it("Should return 403 if not authorized", (done) => { + postShadowBan({ adminUserID: "notVIPUserID", userID: "shadowBanned" }) + .then(res => { + assert.strictEqual(res.status, 403); + done(); + }) + .catch(err => done(err)); + }); +}); diff --git a/test/cases/testUtils.ts b/test/cases/testUtils.ts index 78818c87..29bfe60f 100644 --- a/test/cases/testUtils.ts +++ b/test/cases/testUtils.ts @@ -1,5 +1,5 @@ import assert from "assert"; -import { partialDeepEquals } from "../utils/partialDeepEquals"; +import { partialDeepEquals, mixedDeepEquals } from "../utils/partialDeepEquals"; describe("Test utils ", () => { it("objectContain", () => { @@ -135,4 +135,45 @@ describe("Test utils ", () => { } ), "Did not match partial child array"); }); + it("mixedDeepEquals exists", () => { + assert(!mixedDeepEquals({ + name: "lorem", + values: [{ + name: "ipsum", + }], + child: { + name: "dolor", + }, + ignore: true + }, { + name: "lorem", + values: [{ + name: "ipsum", + }], + child: { + name: "dolor", + }, + ignore: false + })); + }); + it("mixedDeepEquals noProperty", () => { + assert(!mixedDeepEquals({ + name: "lorem", + values: [{ + name: "ipsum", + }], + child: { + name: "dolor", + } + }, { + name: "lorem", + values: [{ + name: "ipsum", + }], + child: { + name: "dolor", + }, + ignore: false + })); + }); }); \ No newline at end of file diff --git a/test/cases/tokenUtils.ts b/test/cases/tokenUtils.ts new file mode 100644 index 00000000..4b3890eb --- /dev/null +++ b/test/cases/tokenUtils.ts @@ -0,0 +1,50 @@ +import assert from "assert"; +import { config } from "../../src/config"; +import axios from "axios"; +import * as tokenUtils from "../../src/utils/tokenUtils"; +import MockAdapter from "axios-mock-adapter"; +import { validatelicenseKeyRegex } from "../../src/routes/verifyToken"; +let mock: MockAdapter; +import * as patreon from "../mocks/patreonMock"; + +const validateToken = validatelicenseKeyRegex; + +describe("tokenUtils test", function() { + before(function() { + mock = new MockAdapter(axios, { onNoMatch: "throwException" }); + mock.onPost("https://www.patreon.com/api/oauth2/token").reply(200, patreon.fakeOauth); + mock.onGet(/identity/).reply(200, patreon.activeIdentity); + }); + + it("Should be able to create patreon token", function (done) { + if (!config?.patreon) this.skip(); + tokenUtils.createAndSaveToken(tokenUtils.TokenType.patreon, "test_code").then((licenseKey) => { + assert.ok(validateToken(licenseKey)); + done(); + }); + }); + it("Should be able to create local token", (done) => { + tokenUtils.createAndSaveToken(tokenUtils.TokenType.local).then((licenseKey) => { + assert.ok(validateToken(licenseKey)); + done(); + }); + }); + it("Should be able to get patreon identity", function (done) { + if (!config?.patreon) this.skip(); + tokenUtils.getPatreonIdentity("fake_access_token").then((result) => { + assert.deepEqual(result, patreon.activeIdentity); + done(); + }); + }); + it("Should be able to refresh token", function (done) { + if (!config?.patreon) this.skip(); + tokenUtils.refreshToken(tokenUtils.TokenType.patreon, "fake-licence-Key", "fake_refresh_token").then((result) => { + assert.strictEqual(result, true); + done(); + }); + }); + + after(function () { + mock.restore(); + }); +}); \ No newline at end of file diff --git a/test/cases/userCounter.ts b/test/cases/userCounter.ts index 26a1adbe..8215ce11 100644 --- a/test/cases/userCounter.ts +++ b/test/cases/userCounter.ts @@ -3,10 +3,9 @@ import assert from "assert"; import { config } from "../../src/config"; import { getHash } from "../../src/utils/getHash"; - describe("userCounter", () => { - it("Should return 200", (done) => { - if (!config.userCounterURL) return done(); // skip if no userCounterURL is set + it("Should return 200", function (done) { + if (!config.userCounterURL) this.skip(); // skip if no userCounterURL is set axios.request({ method: "POST", baseURL: config.userCounterURL, diff --git a/test/cases/voteOnSponsorTime.ts b/test/cases/voteOnSponsorTime.ts index 11126b38..755735ff 100644 --- a/test/cases/voteOnSponsorTime.ts +++ b/test/cases/voteOnSponsorTime.ts @@ -3,7 +3,7 @@ import { db, privateDB } from "../../src/databases/databases"; import { getHash } from "../../src/utils/getHash"; import { ImportMock } from "ts-mock-imports"; import * as YouTubeAPIModule from "../../src/utils/youtubeApi"; -import { YouTubeApiMock } from "../youtubeMock"; +import { YouTubeApiMock } from "../mocks/youtubeMock"; import assert from "assert"; import { client } from "../utils/httpClient"; import { arrayDeepEquals } from "../utils/partialDeepEquals"; diff --git a/test/mocks.ts b/test/mocks.ts index a5c578e9..d6673bfd 100644 --- a/test/mocks.ts +++ b/test/mocks.ts @@ -1,23 +1,24 @@ import express from "express"; import { config } from "../src/config"; import { Server } from "http"; +import { UserCounter } from "./mocks/UserCounter"; const app = express(); -app.post("/ReportChannelWebhook", (req, res) => { +app.post("/webhook/ReportChannel", (req, res) => { res.sendStatus(200); }); -app.post("/FirstTimeSubmissionsWebhook", (req, res) => { +app.post("/webhook/FirstTimeSubmissions", (req, res) => { res.sendStatus(200); }); -app.post("/CompletelyIncorrectReportWebhook", (req, res) => { +app.post("/webhook/CompletelyIncorrectReport", (req, res) => { res.sendStatus(200); }); // Testing NeuralBlock -app.post("/NeuralBlockRejectWebhook", (req, res) => { +app.post("/webhook/NeuralBlockReject", (req, res) => { res.sendStatus(200); }); @@ -47,6 +48,9 @@ app.post("/CustomWebhook", (req, res) => { res.sendStatus(200); }); +// mocks +app.use("/UserCounter", UserCounter); + export function createMockServer(callback: () => void): Server { return app.listen(config.mockPort, callback); } diff --git a/test/mocks/UserCounter.ts b/test/mocks/UserCounter.ts new file mode 100644 index 00000000..d4ba32a7 --- /dev/null +++ b/test/mocks/UserCounter.ts @@ -0,0 +1,11 @@ +import { Router } from "express"; +export const UserCounter = Router(); + +UserCounter.post("/api/v1/addIP", (req, res) => { + res.sendStatus(200); +}); +UserCounter.get("/api/v1/userCount", (req, res) => { + res.send({ + userCount: 100 + }); +}); \ No newline at end of file diff --git a/test/mocks/gumroadMock.ts b/test/mocks/gumroadMock.ts new file mode 100644 index 00000000..00ae8311 --- /dev/null +++ b/test/mocks/gumroadMock.ts @@ -0,0 +1,22 @@ +export const licenseSuccess = { + success: true, + uses: 4, + purchase: {} +}; + +export const licenseFail = { + success: false, + message: "That license does not exist for the provided product." +}; + + +const subCode = (length = 8) => { + const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let result = ""; + for (let i = 0; i < length; i++) { + result += characters[(Math.floor(Math.random() * characters.length))]; + } + return result; +}; + +export const generateLicense = (): string => `${subCode()}-${subCode()}-${subCode()}-${subCode()}`; diff --git a/test/mocks/mockExpressRequest.ts b/test/mocks/mockExpressRequest.ts new file mode 100644 index 00000000..7ad4ae08 --- /dev/null +++ b/test/mocks/mockExpressRequest.ts @@ -0,0 +1,33 @@ +const nullStub = (): any => null; + +export const createRequest = (options: any) => ({ + app: {}, + baseUrl: "", + body: {}, + cookies: {}, + fresh: true, + headers: {}, + hostname: "example.com", + ip: "", + ips: [], + method: "GET", + originalUrl: "/", + params: {}, + path: "/", + protocol: "https", + query: {}, + route: {}, + secure: true, + signedCookies: {}, + stale: false, + subdomains: [], + xhr: true, + accepts: nullStub(), + acceptsCharsets: nullStub(), + acceptsEncodings: nullStub(), + acceptsLanguages: nullStub(), + get: nullStub(), + is: nullStub(), + range: nullStub(), + ...options +}); diff --git a/test/mocks/patreonMock.ts b/test/mocks/patreonMock.ts new file mode 100644 index 00000000..aafe26e6 --- /dev/null +++ b/test/mocks/patreonMock.ts @@ -0,0 +1,59 @@ +export const activeIdentity = { + data: {}, + links: {}, + included: [ + { + attributes: { + is_monthly: true, + currently_entitled_amount_cents: 100, + patron_status: "active_patron", + }, + id: "id", + type: "campaign" + } + ], +}; + +export const invalidIdentity = { + data: {}, + links: {}, + included: [{}], +}; + +export const formerIdentitySucceed = { + data: {}, + links: {}, + included: [ + { + attributes: { + is_monthly: true, + campaign_lifetime_support_cents: 500, + patron_status: "former_patron", + }, + id: "id", + type: "campaign" + } + ], +}; + +export const formerIdentityFail = { + data: {}, + links: {}, + included: [ + { + attributes: { + is_monthly: true, + campaign_lifetime_support_cents: 1, + patron_status: "former_patron", + }, + id: "id", + type: "campaign" + } + ], +}; + +export const fakeOauth = { + access_token: "test_access_token", + refresh_token: "test_refresh_token", + expires_in: 3600, +}; \ No newline at end of file diff --git a/test/youtubeMock.ts b/test/mocks/youtubeMock.ts similarity index 97% rename from test/youtubeMock.ts rename to test/mocks/youtubeMock.ts index bb489afe..95f3d207 100644 --- a/test/youtubeMock.ts +++ b/test/mocks/youtubeMock.ts @@ -1,4 +1,4 @@ -import { APIVideoData, APIVideoInfo } from "../src/types/youtubeApi.model"; +import { APIVideoData, APIVideoInfo } from "../../src/types/youtubeApi.model"; export class YouTubeApiMock { // eslint-disable-next-line require-await