diff --git a/src/routes/generateToken.ts b/src/routes/generateToken.ts index 4c0771e..8b06612 100644 --- a/src/routes/generateToken.ts +++ b/src/routes/generateToken.ts @@ -44,5 +44,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/verifyToken.ts b/src/routes/verifyToken.ts index 2b18b5a..dfbb016 100644 --- a/src/routes/verifyToken.ts +++ b/src/routes/verifyToken.ts @@ -4,7 +4,6 @@ import { config } from "../config"; import { privateDB } from "../databases/databases"; import { Logger } from "../utils/logger"; import { getPatreonIdentity, PatronStatus, refreshToken, TokenType } from "../utils/tokenUtils"; -import FormData from "form-data"; interface VerifyTokenRequest extends Request { query: { @@ -13,7 +12,7 @@ interface VerifyTokenRequest extends Request { } export const validatelicenseKeyRegex = (token: string) => - new RegExp(/[A-Za-z0-9]{40}/).test(token); + 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; @@ -26,12 +25,6 @@ export async function verifyTokenRequest(req: VerifyTokenRequest, res: Response) allowed: false }); } - const licenseRegex = new RegExp(/[a-zA-Z0-9]{40}|[A-Z0-9-]{35}/); - if (!licenseRegex.test(licenseKey)) { - return res.status(200).send({ - allowed: false - }); - } const tokens = (await privateDB.prepare("get", `SELECT "accessToken", "refreshToken", "expiresIn" from "oauthLicenseKeys" WHERE "licenseKey" = ?` , [licenseKey])) as {accessToken: string, refreshToken: string, expiresIn: number}; @@ -42,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) @@ -73,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/test/cases/generateVerifyToken.ts b/test/cases/generateVerifyToken.ts new file mode 100644 index 0000000..fc11cc5 --- /dev/null +++ b/test/cases/generateVerifyToken.ts @@ -0,0 +1,178 @@ +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(); + }); + }); + + it("Should be able to create new local token", function (done) { + createAndSaveToken(TokenType.local).then((licenseKey) => { + assert.ok(validatelicenseKeyRegex(licenseKey)); + localLicense = licenseKey; + done(); + }); + }); + + it("Should return 400 if missing code parameter", function (done) { + getGenerateToken("patreon", null, "").then(res => { + assert.strictEqual(res.status, 400); + done(); + }); + }); + + it("Should return 403 if missing adminuserID parameter", function (done) { + getGenerateToken("local", "fake-code", null).then(res => { + assert.strictEqual(res.status, 403); + done(); + }); + }); + + it("Should return 403 for invalid adminuserID parameter", function (done) { + getGenerateToken("local", "fake-code", "fakeAdminID").then(res => { + assert.strictEqual(res.status, 403); + done(); + }); + }); +}); + +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(); + }); + }); +}); + +describe("verifyToken mock tests", function() { + + beforeEach(function() { + mock = new MockAdapter(axios, { onNoMatch: "throwException" }); + }); + + 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/tokenUtils.ts b/test/cases/tokenUtils.ts index be360c5..4b3890e 100644 --- a/test/cases/tokenUtils.ts +++ b/test/cases/tokenUtils.ts @@ -8,27 +8,12 @@ let mock: MockAdapter; import * as patreon from "../mocks/patreonMock"; const validateToken = validatelicenseKeyRegex; -const fakePatreonIdentity = { - data: {}, - links: {}, - included: [ - { - attributes: { - is_monthly: true, - currently_entitled_amount_cents: 100, - patron_status: "active_patron", - }, - id: "id", - type: "campaign" - } - ], -}; 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.fakeIdentity); + mock.onGet(/identity/).reply(200, patreon.activeIdentity); }); it("Should be able to create patreon token", function (done) { @@ -47,7 +32,7 @@ describe("tokenUtils test", function() { 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.fakeIdentity); + assert.deepEqual(result, patreon.activeIdentity); done(); }); }); diff --git a/test/mocks/gumroadMock.ts b/test/mocks/gumroadMock.ts new file mode 100644 index 0000000..349e5c4 --- /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/patreonMock.ts b/test/mocks/patreonMock.ts index 3b5c9ae..aafe26e 100644 --- a/test/mocks/patreonMock.ts +++ b/test/mocks/patreonMock.ts @@ -1,4 +1,4 @@ -export const fakeIdentity = { +export const activeIdentity = { data: {}, links: {}, included: [ @@ -14,6 +14,44 @@ export const fakeIdentity = { ], }; +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",