diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ae9badb..ff5aab7 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -33,6 +33,9 @@ jobs: - name: Run Lint run: pnpm lint + - name: Run Tests + run: pnpm test + - name: Run Build run: pnpm build diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..198a872 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,14 @@ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + roots: ["/src", "/test"], + testRegex: ".*\\.(spec|e2e-spec)\\.ts$", + testTimeout: 15000, + setupFiles: ["/test/jest.setup.ts"], + collectCoverageFrom: ["src/**/*.ts", "!src/config/index.ts"], + moduleNameMapper: { + // Matches both "src/etc/esm-fix" and the relative "./esm-fix". + "^(.*/)?esm-fix$": "/test/esm-fix-mock.ts", + "^src/(.*)$": "/src/$1", + }, +}; diff --git a/package.json b/package.json index f39fc1a..5ebd1c7 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "start:prod": "node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"", "lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "test:cov": "jest --coverage", "build": "npm run build:nest && npm run build:docs", "build:nest": "nest build", "build:docs": "typedoc", @@ -63,6 +65,7 @@ "@types/supertest": "^7.2.1", "@typescript-eslint/eslint-plugin": "^8.65.0", "@typescript-eslint/parser": "^8.65.0", + "aws-sdk-client-mock": "^4.1.0", "eslint": "^8.0.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c19336d..a5f21ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,6 +155,9 @@ importers: '@typescript-eslint/parser': specifier: ^8.65.0 version: 8.65.0(eslint@8.57.1)(typescript@6.0.3) + aws-sdk-client-mock: + specifier: ^4.1.0 + version: 4.1.0 eslint: specifier: ^8.0.0 version: 8.57.1 @@ -996,9 +999,15 @@ packages: '@sinonjs/commons@3.0.1': resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + '@sinonjs/fake-timers@11.2.2': + resolution: {integrity: sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==} + '@sinonjs/fake-timers@15.4.0': resolution: {integrity: sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==} + '@sinonjs/samsam@8.0.3': + resolution: {integrity: sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==} + '@smithy/core@3.31.0': resolution: {integrity: sha512-sylYk2l9d7CmRv8ts8p0SDQUr3VO+HMeS1nrjL6+UtbO8ktJHTOeQ1McX+aAyvGGccp5aZX9eNtdcXrSwzoZaw==} engines: {node: '>=18.0.0'} @@ -1129,6 +1138,12 @@ packages: '@types/serve-static@2.2.0': resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/sinon@17.0.4': + resolution: {integrity: sha512-RHnIrhfPO3+tJT0s7cFaXGZvsL4bbR3/k7z3P312qMS4JaS2Tk+KiwiLx1S0rQ56ERj00u1/BtdyVd0FY+Pdew==} + + '@types/sinonjs__fake-timers@15.0.1': + resolution: {integrity: sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==} + '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -1500,6 +1515,9 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + aws-sdk-client-mock@4.1.0: + resolution: {integrity: sha512-h/tOYTkXEsAcV3//6C1/7U4ifSpKyJvb6auveAepqqNJl6TdZaPFEtKjBQNf8UxQdDP850knB2i/whq4zlsxJw==} + babel-jest@30.4.1: resolution: {integrity: sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -1844,6 +1862,10 @@ packages: resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} + diff@5.2.2: + resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==} + engines: {node: '>=0.3.1'} + doctrine@3.0.0: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} @@ -2569,6 +2591,9 @@ packages: jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + just-extend@6.2.0: + resolution: {integrity: sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2811,6 +2836,9 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + nise@6.1.5: + resolution: {integrity: sha512-SnRDPDBjxZZoU2n0+gzzLtSvo1OZo7j6jnbXsoh3AFxEGhaFU7ZF0TmefuKERq79wxR2U+MPn7ArW+Tl+clC3A==} + node-abi@3.94.0: resolution: {integrity: sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==} engines: {node: '>=10'} @@ -3242,6 +3270,9 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + sinon@18.0.1: + resolution: {integrity: sha512-a2N2TDY1uGviajJ6r4D1CyRAkzE9NNVlYOV1wX5xQDuAk0ONgzgRl0EjCQuRCPxOwp13ghsMwt9Gdldujs39qw==} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -3545,6 +3576,10 @@ packages: resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} engines: {node: '>=4'} + type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + type-fest@0.20.2: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} @@ -4839,10 +4874,19 @@ snapshots: dependencies: type-detect: 4.0.8 + '@sinonjs/fake-timers@11.2.2': + dependencies: + '@sinonjs/commons': 3.0.1 + '@sinonjs/fake-timers@15.4.0': dependencies: '@sinonjs/commons': 3.0.1 + '@sinonjs/samsam@8.0.3': + dependencies: + '@sinonjs/commons': 3.0.1 + type-detect: 4.1.0 + '@smithy/core@3.31.0': dependencies: '@smithy/types': 4.16.1 @@ -5005,6 +5049,12 @@ snapshots: '@types/http-errors': 2.0.5 '@types/node': 26.1.2 + '@types/sinon@17.0.4': + dependencies: + '@types/sinonjs__fake-timers': 15.0.1 + + '@types/sinonjs__fake-timers@15.0.1': {} + '@types/stack-utils@2.0.3': {} '@types/superagent@8.1.11': @@ -5371,6 +5421,12 @@ snapshots: asynckit@0.4.0: {} + aws-sdk-client-mock@4.1.0: + dependencies: + '@types/sinon': 17.0.4 + sinon: 18.0.1 + tslib: 2.8.1 + babel-jest@30.4.1(@babel/core@7.29.7): dependencies: '@babel/core': 7.29.7 @@ -5691,6 +5747,8 @@ snapshots: diff@4.0.4: {} + diff@5.2.2: {} + doctrine@3.0.0: dependencies: esutils: 2.0.3 @@ -6630,6 +6688,8 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + just-extend@6.2.0: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -6822,6 +6882,13 @@ snapshots: neo-async@2.6.2: {} + nise@6.1.5: + dependencies: + '@sinonjs/commons': 3.0.1 + '@sinonjs/fake-timers': 15.4.0 + just-extend: 6.2.0 + path-to-regexp: 8.4.2 + node-abi@3.94.0: dependencies: semver: 7.8.5 @@ -7272,6 +7339,15 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 + sinon@18.0.1: + dependencies: + '@sinonjs/commons': 3.0.1 + '@sinonjs/fake-timers': 11.2.2 + '@sinonjs/samsam': 8.0.3 + diff: 5.2.2 + nise: 6.1.5 + supports-color: 7.2.0 + slash@3.0.0: {} source-map-support@0.5.13: @@ -7544,6 +7620,8 @@ snapshots: type-detect@4.0.8: {} + type-detect@4.1.0: {} + type-fest@0.20.2: {} type-fest@0.21.3: {} diff --git a/src/config/Helper.spec.ts b/src/config/Helper.spec.ts new file mode 100644 index 0000000..07fd3b8 --- /dev/null +++ b/src/config/Helper.spec.ts @@ -0,0 +1,36 @@ +import { $bool, $int, $list, $oneOf, $str } from "./Helper"; + +describe("config helpers", () => { + afterEach(() => { + delete process.env.TEST_HELPER_VAR; + }); + + it("returns the default when the env var is unset", () => { + expect($str("TEST_HELPER_VAR", "fallback")).toBe("fallback"); + expect($int("TEST_HELPER_VAR", 42)).toBe(42); + expect($bool("TEST_HELPER_VAR", true)).toBe(true); + expect($list("TEST_HELPER_VAR", ["a", "b"])).toEqual(["a", "b"]); + }); + + it("throws when a required env var is missing", () => { + expect(() => $str("TEST_HELPER_VAR")).toThrow( + "Missing environment variable: TEST_HELPER_VAR", + ); + }); + + it("reads and parses values from the environment", () => { + process.env.TEST_HELPER_VAR = "7"; + expect($int("TEST_HELPER_VAR", 42)).toBe(7); + process.env.TEST_HELPER_VAR = "TRUE"; + expect($bool("TEST_HELPER_VAR", false)).toBe(true); + }); + + it("$oneOf accepts listed values and rejects others", () => { + process.env.TEST_HELPER_VAR = "b"; + expect($oneOf("TEST_HELPER_VAR", ["a", "b"], "a")).toBe("b"); + process.env.TEST_HELPER_VAR = "c"; + expect(() => $oneOf("TEST_HELPER_VAR", ["a", "b"], "a")).toThrow( + "Invalid value for environment variable: TEST_HELPER_VAR", + ); + }); +}); diff --git a/src/controller/fetch.controller.ts b/src/controller/fetch.controller.ts index 9fc575b..b629df9 100644 --- a/src/controller/fetch.controller.ts +++ b/src/controller/fetch.controller.ts @@ -2,6 +2,7 @@ import { applyDecorators, Controller, Get, + HttpCode, HttpException, HttpStatus, Param, @@ -95,6 +96,8 @@ export class FetchController { if (key.length > 32) throw new HttpException("The key is too long", HttpStatus.BAD_REQUEST); const note = await this.db.getNote(id); + if (!note) + throw new HttpException("The note was not found", HttpStatus.NOT_FOUND); await this.db.createToken( await getIp(req), Buffer.byteLength(note.content, "utf8"), @@ -114,6 +117,7 @@ export class FetchController { } @Post("decrypt") + @HttpCode(HttpStatus.OK) @ApiBody({ type: String, description: "The decryption key for the note" }) @ApiConsumes("text/plain") @DecryptDecorator() diff --git a/src/controller/files.controller.ts b/src/controller/files.controller.ts index c137928..75bfd1a 100644 --- a/src/controller/files.controller.ts +++ b/src/controller/files.controller.ts @@ -4,6 +4,7 @@ import { Controller, Delete, Get, + HttpCode, HttpException, HttpStatus, Param, @@ -183,7 +184,7 @@ export class FilesController { "The part query parameter must be a positive number", HttpStatus.BAD_REQUEST, ); - this.db.updateFile(id, { part: totalPart }); + await this.db.updateFile(id, { part: totalPart }); const key = `${file.id}/${file.name}`; return { url: await this.s3.createUploadPartUrl( @@ -196,6 +197,7 @@ export class FilesController { } @Put("upload/:id") + @HttpCode(HttpStatus.NO_CONTENT) @ApiBody({ type: FileCloseUploadRequest }) @ApiResponse({ status: HttpStatus.NO_CONTENT, diff --git a/src/etc/getIp.spec.ts b/src/etc/getIp.spec.ts new file mode 100644 index 0000000..676bde8 --- /dev/null +++ b/src/etc/getIp.spec.ts @@ -0,0 +1,85 @@ +import { getIp } from "./getIp"; +import { HttpException } from "@nestjs/common"; + +const makeReq = (ip: string, headers: Record = {}) => + ({ ip, headers }) as any; + +describe("getIp", () => { + const vars = [ + "BEHIND_PROXY", + "TRUSTED_PROXIES", + "TRUSTED_PROXIES_CACHE", + "PROXY_IP_HEADER", + "IP_HEADER", + "STRIP_IPV6_ADDRESS", + ]; + beforeEach(() => { + // Bypass the module-global trust-list cache between tests. + process.env.TRUSTED_PROXIES_CACHE = "0"; + }); + afterEach(() => vars.forEach((v) => delete process.env[v])); + + it("returns the request ip when not behind a proxy", async () => { + expect(await getIp(makeReq("1.2.3.4"))).toBe("1.2.3.4"); + }); + + it("ignores forwarded headers when not behind a proxy", async () => { + expect( + await getIp(makeReq("1.2.3.4", { "x-forwarded-for": "9.9.9.9" })), + ).toBe("1.2.3.4"); + }); + + it("uses the forwarded header behind a trusted proxy", async () => { + process.env.BEHIND_PROXY = "true"; + expect( + await getIp( + makeReq("1.2.3.4", { "x-forwarded-for": "9.9.9.9, 8.8.8.8" }), + ), + ).toBe("9.9.9.9"); + }); + + it("ignores the forwarded header from an untrusted proxy", async () => { + process.env.BEHIND_PROXY = "true"; + process.env.TRUSTED_PROXIES = "10.0.0.0/8"; + expect( + await getIp(makeReq("1.2.3.4", { "x-forwarded-for": "9.9.9.9" })), + ).toBe("1.2.3.4"); + }); + + it("reads the proxy address from PROXY_IP_HEADER when configured", async () => { + process.env.BEHIND_PROXY = "true"; + process.env.TRUSTED_PROXIES = "10.0.0.0/8"; + process.env.PROXY_IP_HEADER = "X-Real-Proxy"; + expect( + await getIp( + makeReq("1.2.3.4", { + "x-real-proxy": "10.1.1.1", + "x-forwarded-for": "9.9.9.9", + }), + ), + ).toBe("9.9.9.9"); + }); + + it("strips the configured amount of bytes from ipv6 addresses", async () => { + expect(await getIp(makeReq("2001:db8:1:2:3:4:5:6"))).toBe("2001:db8:1:2::"); + }); + + it("keeps full ipv6 addresses when stripping is disabled", async () => { + process.env.STRIP_IPV6_ADDRESS = "0"; + expect(await getIp(makeReq("2001:db8:1:2:3:4:5:6"))).toBe( + "2001:db8:1:2:3:4:5:6", + ); + }); + + it("throws 511 for an invalid ip", async () => { + await expect(getIp(makeReq("not-an-ip"))).rejects.toThrow(HttpException); + }); + + it("caches the resolved ip on the request object", async () => { + const req = makeReq("1.2.3.4"); + await getIp(req); + process.env.BEHIND_PROXY = "true"; + req.headers["x-forwarded-for"] = "9.9.9.9"; + expect(await getIp(req)).toBe("1.2.3.4"); + }); +}); diff --git a/src/services/crypto.service.spec.ts b/src/services/crypto.service.spec.ts new file mode 100644 index 0000000..7e3e545 --- /dev/null +++ b/src/services/crypto.service.spec.ts @@ -0,0 +1,24 @@ +import CryptoJS from "crypto-js"; +import { CryptoService } from "./crypto.service"; +import { HttpException } from "@nestjs/common"; + +describe("CryptoService", () => { + const svc = new CryptoService(); + + it("decrypts content that was encrypted with the given key", () => { + const ciphertext = CryptoJS.AES.encrypt("secret note", "pw123").toString(); + // Same argument order as the fetch controller: (note content, user key) + expect(svc.decrypt(ciphertext, "pw123")).toBe("secret note"); + }); + + it("throws 401 for a wrong key", () => { + const ciphertext = CryptoJS.AES.encrypt("secret note", "pw123").toString(); + expect(() => svc.decrypt(ciphertext, "wrong")).toThrow(HttpException); + }); + + it("throws 401 for garbage input", () => { + expect(() => svc.decrypt("not-a-ciphertext", "pw123")).toThrow( + HttpException, + ); + }); +}); diff --git a/src/services/crypto.service.ts b/src/services/crypto.service.ts index c0a87d8..8dbb761 100644 --- a/src/services/crypto.service.ts +++ b/src/services/crypto.service.ts @@ -3,9 +3,13 @@ import CryptoJS from "crypto-js"; @Injectable() export class CryptoService { - decrypt(key: string, data: string): string { + decrypt(content: string, key: string): string { try { - return CryptoJS.AES.decrypt(data, key).toString(CryptoJS.enc.Utf8); + const result = CryptoJS.AES.decrypt(content, key).toString( + CryptoJS.enc.Utf8, + ); + if (!result) throw new Error("empty result"); + return result; } catch { throw new HttpException( "The decryption key is invalid", diff --git a/src/services/database.service.ts b/src/services/database.service.ts index 61e878b..b1f67a6 100644 --- a/src/services/database.service.ts +++ b/src/services/database.service.ts @@ -218,6 +218,7 @@ export class DatabaseService async deleteFile(id: string): Promise { await this.knex("files").where("id", id).del(); + await this.cache.del(`file-${id}`); this.logger.log(`Deleted file ${id}`); } @@ -235,7 +236,7 @@ export class DatabaseService async getUploadFilesLastUpdatedBefore(timestamp: number): Promise { return await this.knex("files") - .where("upload_id", "!=", null) + .whereNotNull("upload_id") .andWhere("updated_at", "<", timestamp); } diff --git a/test/app.ts b/test/app.ts new file mode 100644 index 0000000..7abae03 --- /dev/null +++ b/test/app.ts @@ -0,0 +1,56 @@ +import { Test } from "@nestjs/testing"; +import type { NestExpressApplication } from "@nestjs/platform-express"; +import { AppModule } from "src/app.module"; + +export interface TestApp { + app: NestExpressApplication; + server: any; + close(): Promise; +} + +// BEHIND_PROXY + trust-all lets each test pick its client IP via the +// X-Forwarded-For header. TRUSTED_PROXIES_CACHE=0 disables the module-global +// trust-list cache in getIp.ts, which would otherwise leak between suites +// running in the same jest worker. +const DEFAULT_ENV: Record = { + DATABASE_MODE: "sqlite3", + DATABASE_FILE: ":memory:", + BEHIND_PROXY: "true", + TRUSTED_PROXIES_CACHE: "0", +}; + +export async function createTestApp( + env: Record = {}, +): Promise { + const applied = { ...DEFAULT_ENV, ...env }; + const previous: Record = {}; + for (const [key, value] of Object.entries(applied)) { + previous[key] = process.env[key]; + process.env[key] = value; + } + + const moduleRef = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + // Mirrors main.ts: rawBody for the text endpoints, same parser limits. + const app = moduleRef.createNestApplication({ + rawBody: true, + logger: false, + }); + app.useBodyParser("json", { limit: "10mb" }); + app.useBodyParser("text", { limit: "10mb" }); + await app.init(); + + return { + app, + server: app.getHttpServer(), + async close() { + await app.close(); + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }, + }; +} diff --git a/test/decrypt.e2e-spec.ts b/test/decrypt.e2e-spec.ts new file mode 100644 index 0000000..ea8ea76 --- /dev/null +++ b/test/decrypt.e2e-spec.ts @@ -0,0 +1,70 @@ +import request from "supertest"; +import CryptoJS from "crypto-js"; +import { createTestApp, TestApp } from "./app"; + +const IP = "10.0.1.1"; + +describe("note decryption", () => { + let t: TestApp; + let id: string; + + beforeAll(async () => { + t = await createTestApp(); + const res = await request(t.server) + .post("/note/json") + .set("X-Forwarded-For", IP) + .send({ + content: CryptoJS.AES.encrypt("top secret", "pw123").toString(), + mime: null, + selfDestruct: false, + expiresIn: 3600, + }); + id = res.body.id; + }); + afterAll(async () => { + await t.close(); + }); + + it("decrypts via query parameter", async () => { + const res = await request(t.server) + .get(`/note/${id}/decrypt`) + .query({ key: "pw123" }) + .set("X-Forwarded-For", IP) + .expect(200); + expect(res.text).toBe("top secret"); + }); + + it("decrypts via raw body", async () => { + const res = await request(t.server) + .post(`/note/${id}/decrypt`) + .set("X-Forwarded-For", IP) + .type("text") + .send("pw123") + .expect(200); + expect(res.text).toBe("top secret"); + }); + + it("rejects a wrong key with 401", async () => { + await request(t.server) + .get(`/note/${id}/decrypt`) + .query({ key: "wrong" }) + .set("X-Forwarded-For", IP) + .expect(401); + }); + + it("rejects keys longer than 32 characters with 400", async () => { + await request(t.server) + .get(`/note/${id}/decrypt`) + .query({ key: "x".repeat(33) }) + .set("X-Forwarded-For", IP) + .expect(400); + }); + + it("404s for an unknown note id", async () => { + await request(t.server) + .get("/note/does-not-exist/decrypt") + .query({ key: "pw123" }) + .set("X-Forwarded-For", IP) + .expect(404); + }); +}); diff --git a/test/esm-fix-mock.ts b/test/esm-fix-mock.ts new file mode 100644 index 0000000..63e256d --- /dev/null +++ b/test/esm-fix-mock.ts @@ -0,0 +1,44 @@ +// Deterministic replacements for the fix-esm loaded modules in +// src/etc/esm-fix.ts — fix-esm's runtime require hooks do not work under jest. +import { randomBytes } from "crypto"; +import * as ipaddr from "ipaddr.js"; + +const alphabet = + "useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict"; + +export const nanoId = (size = 21): string => { + const bytes = randomBytes(size); + let id = ""; + for (let i = 0; i < size; i++) id += alphabet[bytes[i] & 63]; + return id; +}; + +export const pRetry = async ( + fn: () => Promise, + opts?: { retries?: number }, +): Promise => { + const retries = opts?.retries ?? 3; + let lastError: unknown; + for (let i = 0; i <= retries; i++) { + try { + return await fn(); + } catch (e) { + lastError = e; + } + } + throw lastError; +}; + +// getIp.ts calls ipRegex.v4({exact:true}).test(ip) / ipRegex.v6(...).test(ip), +// so the mock must be a callable with v4/v6 members. Validation is delegated +// to ipaddr.js (a regular CJS dependency of the app). +const matcher = (validate: (s: string) => boolean) => (): RegExp => + ({ test: validate }) as unknown as RegExp; + +export const ipRegex = Object.assign( + matcher((s) => ipaddr.isValid(s)), + { + v4: matcher((s) => ipaddr.IPv4.isValid(s)), + v6: matcher((s) => ipaddr.IPv6.isValid(s)), + }, +); diff --git a/test/files.e2e-spec.ts b/test/files.e2e-spec.ts new file mode 100644 index 0000000..0f5b5c3 --- /dev/null +++ b/test/files.e2e-spec.ts @@ -0,0 +1,190 @@ +import request from "supertest"; +import { mockClient } from "aws-sdk-client-mock"; +import { + S3Client, + CreateMultipartUploadCommand, + CompleteMultipartUploadCommand, + AbortMultipartUploadCommand, + DeleteObjectCommand, + HeadObjectCommand, +} from "@aws-sdk/client-s3"; +import { createTestApp, TestApp } from "./app"; + +const s3Mock = mockClient(S3Client); + +const FILE_ENV = { + FILE_TRANSFER_ENABLED: "true", + FILE_TRANSFER_MAX_SIZE_MB: "10", + FILE_TRANSFER_SIMULTANEOUS_FILES_PER_IP: "1", + FILE_TRANSFER_GLOBAL_MAXIMUM_SIMULTANEOUS_FILES: "25", +}; + +// The behavior registered first for a command wins (sinon `withArgs`), so a +// later `on(SameCommand)` cannot override it — re-arm the whole mock instead. +const resetS3 = ({ completionFails = false } = {}) => { + s3Mock.reset(); + s3Mock.on(CreateMultipartUploadCommand).resolves({ UploadId: "upl-1" }); + if (completionFails) + s3Mock.on(CompleteMultipartUploadCommand).rejects(new Error("bad etags")); + else s3Mock.on(CompleteMultipartUploadCommand).resolves({}); + s3Mock.on(AbortMultipartUploadCommand).resolves({}); + s3Mock.on(DeleteObjectCommand).resolves({}); + s3Mock.on(HeadObjectCommand).resolves({ ContentLength: 123 }); +}; + +describe("file transfer disabled", () => { + let t: TestApp; + beforeAll(async () => { + t = await createTestApp(); + }); + afterAll(async () => { + await t.close(); + }); + + it("rejects all file routes with 403", async () => { + await request(t.server) + .get("/file") + .set("X-Forwarded-For", "10.2.0.1") + .expect(403); + await request(t.server) + .post("/file/upload") + .set("X-Forwarded-For", "10.2.0.1") + .send({ name: "a.txt" }) + .expect(403); + }); +}); + +describe("file transfer lifecycle", () => { + let t: TestApp; + + beforeAll(async () => { + resetS3(); + t = await createTestApp(FILE_ENV); + }); + afterAll(async () => { + await t.close(); + }); + + const start = (ip: string, name = "test.txt") => + request(t.server) + .post("/file/upload") + .set("X-Forwarded-For", ip) + .send({ name }); + + it("uploads, closes, downloads, lists and deletes a file", async () => { + const ip = "10.2.1.1"; + const { body } = await start(ip).expect(201); + const id = body.id; + + // two 5MB parts fit into the 10MB limit + for (let part = 1; part <= 2; part++) { + const res = await request(t.server) + .get(`/file/upload/${id}`) + .query({ length: 1000 }) + .set("X-Forwarded-For", ip) + .expect(200); + expect(typeof res.body.url).toBe("string"); + expect(res.body.url).toContain("http"); + } + + await request(t.server) + .put(`/file/upload/${id}`) + .set("X-Forwarded-For", ip) + .send({ etags: ["etag-1", "etag-2"] }) + .expect(204); + + const json = await request(t.server) + .get(`/file/${id}`) + .query({ json: "true" }) + .set("X-Forwarded-For", ip) + .expect(200); + expect(json.body.name).toBe("test.txt"); + expect(json.body.size).toBe(123); + + const redirect = await request(t.server) + .get(`/file/${id}`) + .set("X-Forwarded-For", ip) + .expect(302); + expect(redirect.headers.location).toContain("http"); + + const list = await request(t.server) + .get("/file") + .set("X-Forwarded-For", ip) + .expect(200); + expect(list.body.files).toHaveLength(1); + expect(list.body.files[0]).toMatchObject({ id, uploaded: true }); + + await request(t.server) + .delete(`/file/${id}`) + .set("X-Forwarded-For", ip) + .expect(204); + expect(s3Mock.commandCalls(DeleteObjectCommand).length).toBeGreaterThan(0); + }); + + it("rejects invalid file names", async () => { + await start("10.2.1.2", "bad name!.txt").expect(400); + }); + + it("enforces the per-ip simultaneous upload limit", async () => { + const ip = "10.2.1.3"; + await start(ip).expect(201); + await start(ip).expect(409); + }); + + it("validates the part length parameter", async () => { + const ip = "10.2.1.4"; + const { body } = await start(ip).expect(201); + await request(t.server) + .get(`/file/upload/${body.id}`) + .set("X-Forwarded-For", ip) + .expect(400); // length missing + await request(t.server) + .get(`/file/upload/${body.id}`) + .query({ length: 6 * 1024 * 1024 }) + .set("X-Forwarded-For", ip) + .expect(400); // part above 5MB + }); + + it("rejects part requests from a foreign ip and unknown ids", async () => { + const ip = "10.2.1.5"; + const { body } = await start(ip).expect(201); + await request(t.server) + .get(`/file/upload/${body.id}`) + .query({ length: 1000 }) + .set("X-Forwarded-For", "10.2.1.99") + .expect(401); + await request(t.server) + .get("/file/upload/does-not-exist") + .query({ length: 1000 }) + .set("X-Forwarded-For", ip) + .expect(404); + }); + + it("aborts the upload when the size limit would be exceeded", async () => { + const ip = "10.2.1.6"; + const { body } = await start(ip).expect(201); + const part = () => + request(t.server) + .get(`/file/upload/${body.id}`) + .query({ length: 1000 }) + .set("X-Forwarded-For", ip); + await part().expect(200); // part 1 -> 5MB + await part().expect(200); // part 2 -> 10MB + await part().expect(413); // part 3 would exceed 10MB -> aborted + expect( + s3Mock.commandCalls(AbortMultipartUploadCommand).length, + ).toBeGreaterThan(0); + await part().expect(404); // the file record was deleted + }); + + it("returns 417 when S3 rejects the completion", async () => { + const ip = "10.2.1.7"; + const { body } = await start(ip).expect(201); + resetS3({ completionFails: true }); + await request(t.server) + .put(`/file/upload/${body.id}`) + .set("X-Forwarded-For", ip) + .send({ etags: ["bad"] }) + .expect(417); + }); +}); diff --git a/test/jest.setup.ts b/test/jest.setup.ts new file mode 100644 index 0000000..2cc4cb3 --- /dev/null +++ b/test/jest.setup.ts @@ -0,0 +1,6 @@ +import { Logger } from "@nestjs/common"; + +// The e2e apps are created with `logger: false`, but module-global loggers +// (getIp.ts) fall back to the default console logger. Silence everything so +// test output stays readable. +Logger.overrideLogger(false); diff --git a/test/maintenance.e2e-spec.ts b/test/maintenance.e2e-spec.ts new file mode 100644 index 0000000..2a4547a --- /dev/null +++ b/test/maintenance.e2e-spec.ts @@ -0,0 +1,196 @@ +import { mockClient } from "aws-sdk-client-mock"; +import { + S3Client, + AbortMultipartUploadCommand, + DeleteObjectCommand, +} from "@aws-sdk/client-s3"; +import { createTestApp, TestApp } from "./app"; +import { DatabaseService } from "src/services/database.service"; +import { S3Service } from "src/services/s3.service"; +import { MigrationService } from "src/services/migration.service"; + +const s3Mock = mockClient(S3Client); + +describe("database cleanup cron", () => { + let t: TestApp; + let db: DatabaseService; + + beforeAll(async () => { + t = await createTestApp(); + db = t.app.get(DatabaseService); + }); + afterAll(async () => { + await t.close(); + }); + + it("removes expired rows and keeps valid ones", async () => { + const knex = db.getKnex(); + const now = Date.now(); + await knex("notes").insert([ + { + id: "expired", + content: "x", + ip: "10.3.0.1", + created_at: now - 10_000, + expires_at: now - 1000, + self_destruct: false, + }, + { + id: "valid", + content: "x", + ip: "10.3.0.1", + created_at: now, + expires_at: now + 60_000, + self_destruct: false, + }, + ]); + await knex("tokens").insert([ + { + id: "tok-old", + ip: "10.3.0.1", + used: 100, + created_at: now - 61 * 60_000, + }, + { id: "tok-new", ip: "10.3.0.1", used: 100, created_at: now }, + ]); + await knex("requests").insert([ + { + id: "req-old", + ip: "10.3.0.1", + failed: false, + created_at: now - 120_000, + }, + { + id: "req-failed-old", + ip: "10.3.0.1", + failed: true, + created_at: now - 6 * 60_000, + }, + { id: "req-new", ip: "10.3.0.1", failed: false, created_at: now }, + ]); + await knex("bans").insert([ + { ip: "10.3.0.2", created_at: now - 61 * 60_000 }, + { ip: "10.3.0.3", created_at: now }, + ]); + + await db.cleanUp(); + + expect((await knex("notes").select()).map((r: any) => r.id)).toEqual([ + "valid", + ]); + expect((await knex("tokens").select()).map((r: any) => r.id)).toEqual([ + "tok-new", + ]); + expect((await knex("requests").select()).map((r: any) => r.id)).toEqual([ + "req-new", + ]); + expect((await knex("bans").select()).map((r: any) => r.ip)).toEqual([ + "10.3.0.3", + ]); + }); +}); + +describe("s3 cleanup cron", () => { + let t: TestApp; + + beforeAll(async () => { + s3Mock.reset(); + s3Mock.on(AbortMultipartUploadCommand).resolves({}); + s3Mock.on(DeleteObjectCommand).resolves({}); + t = await createTestApp({ FILE_TRANSFER_ENABLED: "true" }); + }); + afterAll(async () => { + await t.close(); + }); + + it("aborts stale uploads and deletes expired files", async () => { + const db = t.app.get(DatabaseService); + const s3 = t.app.get(S3Service); + const knex = db.getKnex(); + const now = Date.now(); + await knex("files").insert([ + // stale upload: last part too long ago -> abort + delete row + { + id: "stale", + name: "a.txt", + ip: "10.3.1.1", + part: 1, + upload_id: "u1", + created_at: now, + updated_at: now - 11 * 60_000, + expires_at: now + 60_000, + }, + // expired finished file -> delete object + row + { + id: "gone", + name: "b.txt", + ip: "10.3.1.1", + part: 1, + upload_id: null, + created_at: now, + updated_at: now, + expires_at: now - 1000, + }, + // healthy file -> untouched + { + id: "ok", + name: "c.txt", + ip: "10.3.1.1", + part: 1, + upload_id: null, + created_at: now, + updated_at: now, + expires_at: now + 60_000, + }, + ]); + + await s3.cleanUp(); + + expect((await knex("files").select()).map((r: any) => r.id)).toEqual([ + "ok", + ]); + expect(s3Mock.commandCalls(AbortMultipartUploadCommand)).toHaveLength(1); + expect(s3Mock.commandCalls(DeleteObjectCommand)).toHaveLength(1); + }); +}); + +describe("migrations", () => { + let t: TestApp; + + beforeAll(async () => { + t = await createTestApp({ ALLOW_REVERTING_MIGRATIONS: "true" }); + }); + afterAll(async () => { + await t.close(); + }); + + it("created all tables on bootstrap", async () => { + const knex = t.app.get(DatabaseService).getKnex(); + for (const table of [ + "notes", + "tokens", + "requests", + "bans", + "files", + "migrations", + ]) + expect(await knex.schema.hasTable(table)).toBe(true); + }); + + it("is idempotent when already at the latest level", async () => { + const migrations = t.app.get(MigrationService); + await expect(migrations.onApplicationBootstrap()).resolves.not.toThrow(); + }); + + it("reverts migrations newer than the application", async () => { + const db = t.app.get(DatabaseService); + const knex = db.getKnex(); + await knex("migrations").insert({ + id: 3, + revert: "CREATE TABLE dummy_revert (id integer)", + }); + await t.app.get(MigrationService).onApplicationBootstrap(); + expect(await knex.schema.hasTable("dummy_revert")).toBe(true); + expect(await knex("migrations").where("id", 3).first()).toBeUndefined(); + }); +}); diff --git a/test/notes.e2e-spec.ts b/test/notes.e2e-spec.ts new file mode 100644 index 0000000..69f178a --- /dev/null +++ b/test/notes.e2e-spec.ts @@ -0,0 +1,197 @@ +import request from "supertest"; +import { createTestApp, TestApp } from "./app"; + +const IP_A = "10.0.0.1"; +const IP_B = "10.0.0.2"; + +describe("notes", () => { + let t: TestApp; + + beforeAll(async () => { + t = await createTestApp(); + }); + afterAll(async () => { + await t.close(); + }); + + const createNote = (over: Record = {}, ip = IP_A) => + request(t.server) + .post("/note/json") + .set("X-Forwarded-For", ip) + .send({ + content: "hello world", + mime: "text/plain", + selfDestruct: false, + expiresIn: 3600, + ...over, + }); + + describe("POST /note/json", () => { + it("creates a note and returns id, deleteToken and cost", async () => { + const res = await createNote().expect(201); + expect(res.body.id).toHaveLength(21); + expect(res.body.deleteToken).toHaveLength(8); + expect(res.body.cost).toBe(1000); // minTokensPerCreate floor + }); + + it("rejects a non-numeric expiresIn", async () => { + await createNote({ expiresIn: "abc" }).expect(400); + }); + + it("rejects expiry under one minute", async () => { + await createNote({ expiresIn: 30 }).expect(400); + }); + + it("rejects expiry above the storage limit", async () => { + await createNote({ expiresIn: 31 * 86_400 }).expect(400); + }); + }); + + describe("POST /note/text", () => { + it("creates a note from a raw text body", async () => { + const res = await request(t.server) + .post("/note/text") + .set("X-Forwarded-For", IP_A) + .type("text") + .send("raw content") + .expect(201); + const id = res.text; + const fetched = await request(t.server) + .get(`/note/${id}/json`) + .set("X-Forwarded-For", IP_A) + .expect(200); + expect(fetched.body.content).toBe("raw content"); + expect(fetched.body.mime).toBe("text/plain"); + }); + + it("rejects content types longer than 16 characters", async () => { + await request(t.server) + .post("/note/text") + .set("X-Forwarded-For", IP_A) + .set("Content-Type", "text/plain; charset=utf-8") + .send("raw content") + .expect(400); + }); + }); + + describe("GET /note/:id", () => { + it("returns the raw content", async () => { + const { body } = await createNote({ content: "raw me" }); + const res = await request(t.server) + .get(`/note/${body.id}/raw`) + .set("X-Forwarded-For", IP_A) + .expect(200); + expect(res.text).toBe("raw me"); + }); + + it("returns the json envelope with expiry", async () => { + const { body } = await createNote(); + const res = await request(t.server) + .get(`/note/${body.id}/json`) + .set("X-Forwarded-For", IP_A) + .expect(200); + expect(res.body.deleted).toBe(false); + expect(res.body.mime).toBe("text/plain"); + expect(res.body.expiresAt).toBeGreaterThan(Date.now() / 1000); + }); + + it("404s for an unknown id", async () => { + await request(t.server) + .get("/note/does-not-exist/json") + .set("X-Forwarded-For", IP_A) + .expect(404); + }); + + it("self-destruct notes are deleted on first read", async () => { + const { body } = await createNote({ selfDestruct: true }); + const first = await request(t.server) + .get(`/note/${body.id}/json`) + .set("X-Forwarded-For", IP_A) + .expect(200); + expect(first.body.deleted).toBe(true); + await request(t.server) + .get(`/note/${body.id}/json`) + .set("X-Forwarded-For", IP_A) + .expect(404); + }); + }); + + describe("DELETE /note/:id", () => { + it("allows deletion from the creator ip without a token", async () => { + const { body } = await createNote(); + await request(t.server) + .delete(`/note/${body.id}`) + .set("X-Forwarded-For", IP_A) + .send({}) + .expect(204); + }); + + it("allows deletion from another ip with the delete token", async () => { + const { body } = await createNote(); + await request(t.server) + .delete(`/note/${body.id}`) + .set("X-Forwarded-For", IP_B) + .send({ token: body.deleteToken }) + .expect(204); + }); + + it("rejects deletion from another ip with a wrong token", async () => { + const { body } = await createNote(); + await request(t.server) + .delete(`/note/${body.id}`) + .set("X-Forwarded-For", IP_B) + .send({ token: "wrong-tok" }) + .expect(401); + }); + + it("404s for an unknown id", async () => { + await request(t.server) + .delete("/note/does-not-exist") + .set("X-Forwarded-For", IP_A) + .send({}) + .expect(404); + }); + }); +}); + +describe("notes token limits", () => { + let t: TestApp; + + beforeAll(async () => { + t = await createTestApp({ LIMITS_MAX_TOKENS_PER_IP: "2500" }); + }); + afterAll(async () => { + await t.close(); + }); + + it("rejects creation once the ip token budget is exhausted", async () => { + const create = () => + request(t.server) + .post("/note/json") + .set("X-Forwarded-For", "10.0.9.9") + .send({ + content: "x", + mime: null, + selfDestruct: false, + expiresIn: 3600, + }); + await create().expect(201); // used: 1000 + await create().expect(201); // used: 2000 + await create().expect(413); // 2000 + 1000 >= 2500 + + // other ips are unaffected + await request(t.server) + .post("/note/json") + .set("X-Forwarded-For", "10.0.9.10") + .send({ content: "x", mime: null, selfDestruct: false, expiresIn: 3600 }) + .expect(201); + }); + + it("reports the remaining budget on /info", async () => { + const res = await request(t.server) + .get("/info") + .set("X-Forwarded-For", "10.0.9.9") + .expect(200); + expect(res.body.availableTokens).toBe(500); + }); +}); diff --git a/test/rate-limit.e2e-spec.ts b/test/rate-limit.e2e-spec.ts new file mode 100644 index 0000000..f613d9f --- /dev/null +++ b/test/rate-limit.e2e-spec.ts @@ -0,0 +1,81 @@ +import request from "supertest"; +import { createTestApp, TestApp } from "./app"; + +describe("request rate limiting", () => { + let t: TestApp; + + beforeAll(async () => { + t = await createTestApp({ LIMITS_MAX_REQUESTS_PER_IP_PER_MINUTE: "3" }); + }); + afterAll(async () => { + await t.close(); + }); + + it("returns 429 once the per-minute budget is used", async () => { + const ip = "10.1.0.1"; + for (let i = 0; i < 3; i++) + await request(t.server) + .get("/info") + .set("X-Forwarded-For", ip) + .expect(200); + await request(t.server).get("/info").set("X-Forwarded-For", ip).expect(429); + }); + + it("does not affect other ips", async () => { + await request(t.server) + .get("/info") + .set("X-Forwarded-For", "10.1.0.2") + .expect(200); + }); +}); + +describe("ban after failed requests", () => { + let t: TestApp; + + beforeAll(async () => { + t = await createTestApp({ LIMITS_BAN_AFTER_FAILED_REQUESTS: "2" }); + }); + afterAll(async () => { + await t.close(); + }); + + it("bans an ip after too many failed requests, then blocks with 418", async () => { + const ip = "10.1.1.1"; + const fail = () => + request(t.server) + .get("/note/does-not-exist/json") + .set("X-Forwarded-For", ip); + await fail().expect(404); // failed: 1 + await fail().expect(404); // failed: 2 + await fail().expect(429); // threshold reached -> banned + 429 + await fail().expect(418); // ban guard blocks before anything else + // other ips unaffected + await request(t.server) + .get("/info") + .set("X-Forwarded-For", "10.1.1.2") + .expect(200); + }); +}); + +describe("limits disabled", () => { + let t: TestApp; + + beforeAll(async () => { + t = await createTestApp({ + LIMITS_DISABLED: "true", + LIMITS_MAX_REQUESTS_PER_IP_PER_MINUTE: "1", + }); + }); + afterAll(async () => { + await t.close(); + }); + + it("never rate limits", async () => { + const ip = "10.1.2.1"; + for (let i = 0; i < 5; i++) + await request(t.server) + .get("/info") + .set("X-Forwarded-For", ip) + .expect(200); + }); +}); diff --git a/test/system.e2e-spec.ts b/test/system.e2e-spec.ts new file mode 100644 index 0000000..df752f9 --- /dev/null +++ b/test/system.e2e-spec.ts @@ -0,0 +1,123 @@ +import request from "supertest"; +import { createTestApp, TestApp } from "./app"; + +const IP = "10.0.2.1"; + +describe("system endpoints", () => { + let t: TestApp; + + beforeAll(async () => { + t = await createTestApp(); + }); + afterAll(async () => { + await t.close(); + }); + + it("redirects / to /swagger", async () => { + const res = await request(t.server).get("/").expect(302); + expect(res.headers.location).toBe("/swagger"); + }); + + it("serves instance info", async () => { + const res = await request(t.server) + .get("/info") + .set("X-Forwarded-For", IP) + .expect(200); + expect(res.body).toMatchObject({ + maxStorageTimeDays: 30, + fileTransferEnabled: false, + privateMode: false, + }); + expect(typeof res.body.version).toBe("string"); + expect(res.body.availableTokens).toBeGreaterThan(0); + }); + + it("serves stats reflecting created notes", async () => { + await request(t.server) + .post("/note/json") + .set("X-Forwarded-For", IP) + .send({ content: "x", mime: null, selfDestruct: false, expiresIn: 3600 }) + .expect(201); + const res = await request(t.server) + .get("/stats") + .set("X-Forwarded-For", IP) + .expect(200); + expect(res.body.totalNotes).toBeGreaterThanOrEqual(1); + expect(typeof res.body.time).toBe("number"); + expect(typeof res.body.bannedIps).toBe("number"); + }); +}); + +describe("stats password", () => { + let t: TestApp; + + beforeAll(async () => { + t = await createTestApp({ STATS_PASSWORD: "statspw" }); + }); + afterAll(async () => { + await t.close(); + }); + + it("rejects a missing or wrong password", async () => { + await request(t.server) + .get("/stats") + .set("X-Forwarded-For", IP) + .expect(403); + await request(t.server) + .get("/stats") + .query({ password: "nope" }) + .set("X-Forwarded-For", IP) + .expect(403); + }); + + it("accepts the correct password", async () => { + await request(t.server) + .get("/stats") + .query({ password: "statspw" }) + .set("X-Forwarded-For", IP) + .expect(200); + }); +}); + +describe("instance password", () => { + let t: TestApp; + + beforeAll(async () => { + t = await createTestApp({ INSTANCE_PASSWORD: "s3cret" }); + }); + afterAll(async () => { + await t.close(); + }); + + it("keeps /info public and reports private mode", async () => { + const res = await request(t.server) + .get("/info") + .set("X-Forwarded-For", IP) + .expect(200); + expect(res.body.privateMode).toBe(true); + }); + + it("rejects protected endpoints without the password", async () => { + await request(t.server) + .post("/note/json") + .set("X-Forwarded-For", IP) + .send({ content: "x", mime: null, selfDestruct: false, expiresIn: 3600 }) + .expect(401); + }); + + it("accepts the password raw and as bearer token", async () => { + for (const header of ["s3cret", "Bearer s3cret"]) { + await request(t.server) + .post("/note/json") + .set("X-Forwarded-For", IP) + .set("Authorization", header) + .send({ + content: "x", + mime: null, + selfDestruct: false, + expiresIn: 3600, + }) + .expect(201); + } + }); +}); diff --git a/tsconfig.json b/tsconfig.json index d833da5..e8b73cc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,7 @@ "experimentalDecorators": true, "allowSyntheticDefaultImports": true, "target": "ES2021", + "types": ["node", "jest"], "sourceMap": true, "outDir": "./dist", "baseUrl": "./",