From 1b9d9708f7158ddb0541166dd671ecd3b5d4693f Mon Sep 17 00:00:00 2001 From: rogup Date: Wed, 3 Apr 2024 12:16:19 +0100 Subject: [PATCH] Remove duplicate /api path segment, required to make Socket.io work --- src/queries/query.ts | 2 +- src/routes/database.test.ts | 583 +++++++++++++++++++----------------- src/routes/database.ts | 16 +- src/routes/datagroups.ts | 14 +- src/routes/maps.test.ts | 4 +- src/routes/maps.ts | 38 +-- 6 files changed, 345 insertions(+), 312 deletions(-) diff --git a/src/queries/query.ts b/src/queries/query.ts index 87efc04..81022a8 100644 --- a/src/queries/query.ts +++ b/src/queries/query.ts @@ -462,7 +462,7 @@ export const createPublicMapView = async (mapId: number): Promise => { }); } - return `/api/public/map/${mapId}`; + return `/public/map/${mapId}`; }; export const createUserFeedback = async ( diff --git a/src/routes/database.test.ts b/src/routes/database.test.ts index e2feadf..ee4a864 100644 --- a/src/routes/database.test.ts +++ b/src/routes/database.test.ts @@ -20,301 +20,334 @@ const sandbox = createSandbox(); let server: Server; // Describe the feature that we're testing -describe("POST /api/token", () => { - const testUser = { - id: 123, - username: "douglas.quaid@yahoomail.com", - council_id: 0, - is_super_user: 0, - enabled: 1, - marketing: 1, - } - - // This runs before each 'it' testcase - beforeEach(async () => { - server = await init(); +describe("POST /token", () => { + const testUser = { + id: 123, + username: "douglas.quaid@yahoomail.com", + council_id: 0, + is_super_user: 0, + enabled: 1, + marketing: 1, + }; + + // This runs before each 'it' testcase + beforeEach(async () => { + server = await init(); + }); + + // Cleanup that run after each 'it' testcase + afterEach(async () => { + await server.stop(); + + // Restore all fakes that were created https://sinonjs.org/releases/latest/sandbox/ + sandbox.restore(); + }); + + // Split into contexts for each scenario that could happen when using the feature + context("Login found", () => { + // This also runs before each 'it', after the 'beforeEach' on the level above + beforeEach(() => { + // Replace the query.checkAndReturnUser method with a fake that we can control to return + // our test value. We do this because we don't want the behaviour of the query module to + // affect this unit test. + // https://sinonjs.org/releases/latest/sandbox/#sandboxreplaceobject-property-replacement + sandbox.replace( + query, + "checkAndReturnUser", + fake.resolves({ success: true, user: testUser }) + ); }); - // Cleanup that run after each 'it' testcase - afterEach(async () => { - await server.stop(); + // A testcase for an individual behaviour + it("returns status 200", async () => { + const res = await server.inject({ + method: "POST", + url: "/token", + payload: { + username: "douglas.quaid@yahoomail.com", + password: "testingtesting123", + }, + }); + + // The test passes or fails on this line + expect(res.statusCode).to.equal(200); + }); - // Restore all fakes that were created https://sinonjs.org/releases/latest/sandbox/ - sandbox.restore(); + it("returns token which expires in 365 days", async () => { + const res = await server.inject({ + method: "POST", + url: "/token", + payload: { + username: "douglas.quaid@yahoomail.com", + password: "testingtesting123", + }, + }); + + const expectedExpiresIn = 365 * 24 * 60 * 60; // 365 days is set in .env.test + expect(res.result).to.have.property("expires_in", expectedExpiresIn); + }); + }); + + context("Login not found", () => { + beforeEach(() => { + sandbox.replace( + query, + "checkAndReturnUser", + fake.resolves({ success: false, errorMessage: "error" }) + ); }); - // Split into contexts for each scenario that could happen when using the feature - context("Login found", () => { - - // This also runs before each 'it', after the 'beforeEach' on the level above - beforeEach(() => { - // Replace the query.checkAndReturnUser method with a fake that we can control to return - // our test value. We do this because we don't want the behaviour of the query module to - // affect this unit test. - // https://sinonjs.org/releases/latest/sandbox/#sandboxreplaceobject-property-replacement - sandbox.replace(query, "checkAndReturnUser", fake.resolves({ success: true, user: testUser })); - }); - - // A testcase for an individual behaviour - it("returns status 200", async () => { - const res = await server.inject({ - method: "POST", - url: "/api/token", - payload: { - username: "douglas.quaid@yahoomail.com", - password: "testingtesting123" - } - }); - - // The test passes or fails on this line - expect(res.statusCode).to.equal(200); - }); - - it("returns token which expires in 365 days", async () => { - const res = await server.inject({ - method: "POST", - url: "/api/token", - payload: { - username: "douglas.quaid@yahoomail.com", - password: "testingtesting123" - } - }); - - const expectedExpiresIn = 365 * 24 * 60 * 60; // 365 days is set in .env.test - expect(res.result).to.have.property('expires_in', expectedExpiresIn); - }); + it("returns status 401", async () => { + const res = await server.inject({ + method: "POST", + url: "/token", + payload: { + username: "douglas.quaid@yahoomail.com", + password: "bad-password", + }, + }); + + expect(res.statusCode).to.equal(401); + }); + }); + + context("JWT signing throws error", () => { + beforeEach(() => { + sandbox.replace( + query, + "checkAndReturnUser", + fake.resolves({ success: true, user: testUser }) + ); + // Fake jwt.sign() so that it throws an error + sandbox.replace(jwt, "sign", fake.throws("signing error")); }); - context("Login not found", () => { - beforeEach(() => { - sandbox.replace(query, "checkAndReturnUser", fake.resolves({ success: false, errorMessage: 'error' })); - }); - - it("returns status 401", async () => { - const res = await server.inject({ - method: "POST", - url: "/api/token", - payload: { - username: "douglas.quaid@yahoomail.com", - password: "bad-password" - } - }); - - expect(res.statusCode).to.equal(401); - }); + it("returns status 500", async () => { + const res = await server.inject({ + method: "POST", + url: "/token", + payload: { + username: "douglas.quaid@yahoomail.com", + password: "testingtesting123", + }, + }); + + expect(res.statusCode).to.equal(500); }); + }); - context("JWT signing throws error", () => { - beforeEach(() => { - sandbox.replace(query, "checkAndReturnUser", fake.resolves({ success: true, user: testUser })); - // Fake jwt.sign() so that it throws an error - sandbox.replace(jwt, "sign", fake.throws("signing error")); - }); - - it("returns status 500", async () => { - const res = await server.inject({ - method: "POST", - url: "/api/token", - payload: { - username: "douglas.quaid@yahoomail.com", - password: "testingtesting123" - } - }); - - expect(res.statusCode).to.equal(500); - }); + context("Login with password reset token", () => { + const testRandomToken = "RaNDomTokEn123"; + + beforeEach(() => { + sandbox.replace( + query, + "checkAndReturnUser", + fake.resolves({ success: true, user: testUser }) + ); }); - context("Login with password reset token", () => { - const testRandomToken = 'RaNDomTokEn123' - - beforeEach(() => { - sandbox.replace(query, "checkAndReturnUser", fake.resolves({ success: true, user: testUser })); - }); - - it("returns status 200", async () => { - const res = await server.inject({ - method: "POST", - url: "/api/token", - payload: { - username: "douglas.quaid@yahoomail.com", - reset_token: testRandomToken - } - }); - - expect(res.statusCode).to.equal(200); - }); - - it("returns token which expires in 365 days", async () => { - const res = await server.inject({ - method: "POST", - url: "/api/token", - payload: { - username: "douglas.quaid@yahoomail.com", - reset_token: testRandomToken - } - }); - - const expectedExpiresIn = 365 * 24 * 60 * 60; - expect(res.result).to.have.property('expires_in', expectedExpiresIn); - }); + it("returns status 200", async () => { + const res = await server.inject({ + method: "POST", + url: "/token", + payload: { + username: "douglas.quaid@yahoomail.com", + reset_token: testRandomToken, + }, + }); + + expect(res.statusCode).to.equal(200); }); + + it("returns token which expires in 365 days", async () => { + const res = await server.inject({ + method: "POST", + url: "/token", + payload: { + username: "douglas.quaid@yahoomail.com", + reset_token: testRandomToken, + }, + }); + + const expectedExpiresIn = 365 * 24 * 60 * 60; + expect(res.result).to.have.property("expires_in", expectedExpiresIn); + }); + }); }); -describe("POST /api/user/password-reset", () => { - const testUserId = 123; - const testEmail = 'douglas.quaid@yahoomail.com'; - const testFirstName = 'Douglas'; - const testRandomToken = 'RaNDomTokEn123' - - let fakePasswordResetTokenCreate: SinonSpy; - let fakePasswordResetTokenDestroy: SinonSpy; - let fakeSendResetPasswordEmail: SinonSpy; - - beforeEach(async () => { - server = await init(); - - // Replace these functions with fakes, so we can assert on whether they are called and the - // arguments that are passed to them - fakeSendResetPasswordEmail = sandbox.replace(mailer, "sendResetPasswordEmail", fake()); - fakePasswordResetTokenCreate = sandbox.replace(Model.PasswordResetToken, "create", fake()); - fakePasswordResetTokenDestroy = sandbox.replace(Model.PasswordResetToken, "destroy", fake()); - sandbox.replace(helper, "generateRandomToken", fake.resolves(testRandomToken)); +describe("POST /user/password-reset", () => { + const testUserId = 123; + const testEmail = "douglas.quaid@yahoomail.com"; + const testFirstName = "Douglas"; + const testRandomToken = "RaNDomTokEn123"; + + let fakePasswordResetTokenCreate: SinonSpy; + let fakePasswordResetTokenDestroy: SinonSpy; + let fakeSendResetPasswordEmail: SinonSpy; + + beforeEach(async () => { + server = await init(); + + // Replace these functions with fakes, so we can assert on whether they are called and the + // arguments that are passed to them + fakeSendResetPasswordEmail = sandbox.replace( + mailer, + "sendResetPasswordEmail", + fake() + ); + fakePasswordResetTokenCreate = sandbox.replace( + Model.PasswordResetToken, + "create", + fake() + ); + fakePasswordResetTokenDestroy = sandbox.replace( + Model.PasswordResetToken, + "destroy", + fake() + ); + sandbox.replace( + helper, + "generateRandomToken", + fake.resolves(testRandomToken) + ); + }); + + afterEach(async () => { + await server.stop(); + sandbox.restore(); + }); + + context("User exists", () => { + beforeEach(() => { + // fake User.findOne to return our test user + sandbox.replace( + Model.User, + "findOne", + fake.resolves({ + id: testUserId, + username: testEmail, + first_name: testFirstName, + }) + ); + }); + + it("sends a password reset email", async () => { + await server.inject({ + method: "POST", + url: "/user/password-reset", + payload: { + username: testEmail, + }, + }); + + // Verify that our spy called the 'sendResetPasswordEmail' function once + assert.calledOnce(fakeSendResetPasswordEmail); }); - afterEach(async () => { - await server.stop(); - sandbox.restore(); + it("deletes all password reset tokens that were previously given to the user", async () => { + await server.inject({ + method: "POST", + url: "/user/password-reset", + payload: { + username: testEmail, + }, + }); + + // Verify that our spy called the 'destroy' function once with the specified arguments + assert.calledOnceWithMatch(fakePasswordResetTokenDestroy, { + where: { + user_id: testUserId, + }, + }); }); - context("User exists", () => { - beforeEach(() => { - // fake User.findOne to return our test user - sandbox.replace(Model.User, "findOne", fake.resolves({ - id: testUserId, - username: testEmail, - first_name: testFirstName - })); - }); - - it("sends a password reset email", async () => { - await server.inject({ - method: "POST", - url: "/api/user/password-reset", - payload: { - username: testEmail - } - }); - - // Verify that our spy called the 'sendResetPasswordEmail' function once - assert.calledOnce(fakeSendResetPasswordEmail); - }); - - it("deletes all password reset tokens that were previously given to the user", async () => { - await server.inject({ - method: "POST", - url: "/api/user/password-reset", - payload: { - username: testEmail - } - }); - - // Verify that our spy called the 'destroy' function once with the specified arguments - assert.calledOnceWithMatch(fakePasswordResetTokenDestroy, { - where: { - user_id: testUserId - } - }); - }); - - it("stores new password reset token with 24 hours expiry", async () => { - // Sets the UNIX epoch to 0, and we can tick the clock exactly how much we want, so the - // test is reproducable https://sinonjs.org/releases/latest/fake-timers/ - sandbox.useFakeTimers(); - - // We don't use 'await' here, since we're faking the clock, so it would hang - server.inject({ - method: "POST", - url: "/api/user/password-reset", - payload: { - username: testEmail - } - }); - - // Now resolve all the promises - await sandbox.clock.runAllAsync(); - - assert.calledOnceWithMatch(fakePasswordResetTokenCreate, { - user_id: testUserId, - token: testRandomToken, - expires: 24 * 3600 * 1000, // 24 hours in ms - }); - }); - - it("emails the correct reset password link to the user", async () => { - const host = 'app.landexplorer.coop'; - const testUrlEncodedEmail = 'douglas.quaid%40yahoomail.com'; - const expectedResetLink = `https://${host}/auth?email=${testUrlEncodedEmail}&reset_token=${testRandomToken}`; - - await server.inject({ - method: "POST", - url: "/api/user/password-reset", - authority: host, - payload: { - username: testEmail - } - }); - - // We get the arguments that were passed to the spy in the 'sendResetPasswordEmail' call - // and make assertions on them - const actualResetLink = fakeSendResetPasswordEmail.getCall(0).args[2]; - expect(actualResetLink).to.equal(expectedResetLink); - }); - - it("returns status 200", async () => { - const res = await server.inject({ - method: "POST", - url: "/api/user/password-reset", - payload: { - username: testEmail - } - }); - - expect(res.statusCode).to.equal(200); - }); + it("stores new password reset token with 24 hours expiry", async () => { + // Sets the UNIX epoch to 0, and we can tick the clock exactly how much we want, so the + // test is reproducable https://sinonjs.org/releases/latest/fake-timers/ + sandbox.useFakeTimers(); + + // We don't use 'await' here, since we're faking the clock, so it would hang + server.inject({ + method: "POST", + url: "/user/password-reset", + payload: { + username: testEmail, + }, + }); + + // Now resolve all the promises + await sandbox.clock.runAllAsync(); + + assert.calledOnceWithMatch(fakePasswordResetTokenCreate, { + user_id: testUserId, + token: testRandomToken, + expires: 24 * 3600 * 1000, // 24 hours in ms + }); }); - context("User doesn't exist", async () => { - - beforeEach(() => { - // fake User.findOne to return no results - sandbox.replace(Model.User, "findOne", fake.resolves(null)); - }); - - it("doesn't send a password reset email", async () => { - await server.inject({ - method: "POST", - url: "/api/user/password-reset", - payload: { - username: testEmail - } - }); - - assert.notCalled(fakeSendResetPasswordEmail); - }); - - // To avoid username guesses by hacker - it("returns status 200", async () => { - const res = await server.inject({ - method: "POST", - url: "/api/user/password-reset", - payload: { - username: testEmail - } - }); - - expect(res.statusCode).to.equal(200); - }); + it("emails the correct reset password link to the user", async () => { + const host = "app.landexplorer.coop"; + const testUrlEncodedEmail = "douglas.quaid%40yahoomail.com"; + const expectedResetLink = `https://${host}/auth?email=${testUrlEncodedEmail}&reset_token=${testRandomToken}`; + + await server.inject({ + method: "POST", + url: "/user/password-reset", + authority: host, + payload: { + username: testEmail, + }, + }); + + // We get the arguments that were passed to the spy in the 'sendResetPasswordEmail' call + // and make assertions on them + const actualResetLink = fakeSendResetPasswordEmail.getCall(0).args[2]; + expect(actualResetLink).to.equal(expectedResetLink); + }); + + it("returns status 200", async () => { + const res = await server.inject({ + method: "POST", + url: "/user/password-reset", + payload: { + username: testEmail, + }, + }); + + expect(res.statusCode).to.equal(200); + }); + }); + + context("User doesn't exist", async () => { + beforeEach(() => { + // fake User.findOne to return no results + sandbox.replace(Model.User, "findOne", fake.resolves(null)); + }); + + it("doesn't send a password reset email", async () => { + await server.inject({ + method: "POST", + url: "/user/password-reset", + payload: { + username: testEmail, + }, + }); + + assert.notCalled(fakeSendResetPasswordEmail); + }); + // To avoid username guesses by hacker + it("returns status 200", async () => { + const res = await server.inject({ + method: "POST", + url: "/user/password-reset", + payload: { + username: testEmail, + }, + }); + + expect(res.statusCode).to.equal(200); }); + }); }); diff --git a/src/routes/database.ts b/src/routes/database.ts index 4388450..9816997 100644 --- a/src/routes/database.ts +++ b/src/routes/database.ts @@ -409,34 +409,34 @@ export const databaseRoutes: ServerRoute[] = [ // Register a new account { method: "POST", - path: "/api/user/register", + path: "/user/register", handler: registerUser, options: { auth: false }, }, // Request a password reset for an email address { method: "POST", - path: "/api/user/password-reset", + path: "/user/password-reset", handler: resetPassword, options: { auth: false }, }, // Login user and retrieve a token { method: "POST", - path: "/api/token", + path: "/token", handler: loginUser, options: { auth: false }, }, /** Authenticated users only */ // Return logged in user's details - { method: "GET", path: "/api/user/details", handler: getAuthUserDetails }, + { method: "GET", path: "/user/details", handler: getAuthUserDetails }, // Allow user to change their email address - { method: "POST", path: "/api/user/email", handler: changeEmail }, + { method: "POST", path: "/user/email", handler: changeEmail }, // Allow user to change their details - { method: "POST", path: "/api/user/details", handler: changeUserDetail }, + { method: "POST", path: "/user/details", handler: changeUserDetail }, // Allow logged in user to change their password - { method: "POST", path: "/api/user/password", handler: changePassword }, + { method: "POST", path: "/user/password", handler: changePassword }, // Allow logged in user to submit feedback - { method: "POST", path: "/api/user/feedback", handler: userFeedback }, + { method: "POST", path: "/user/feedback", handler: userFeedback }, ]; diff --git a/src/routes/datagroups.ts b/src/routes/datagroups.ts index c18674f..6791f9b 100644 --- a/src/routes/datagroups.ts +++ b/src/routes/datagroups.ts @@ -213,37 +213,37 @@ async function editDataGroupLine( export const dataGroupRoutes: ServerRoute[] = [ // Get data groups that the user can access and their data - { method: "GET", path: "/api/user/datagroups", handler: getUserDataGroups }, + { method: "GET", path: "/user/datagroups", handler: getUserDataGroups }, // Save an object to a data group { method: "POST", - path: "/api/user/datagroup/save/marker", + path: "/user/datagroup/save/marker", handler: saveDataGroupMarker, }, { method: "POST", - path: "/api/user/datagroup/save/polygon", + path: "/user/datagroup/save/polygon", handler: saveDataGroupPolygon, }, { method: "POST", - path: "/api/user/datagroup/save/line", + path: "/user/datagroup/save/line", handler: saveDataGroupLine, }, // Edit a datagroup object { method: "POST", - path: "/api/user/datagroup/edit/marker", + path: "/user/datagroup/edit/marker", handler: editDataGroupMarker, }, { method: "POST", - path: "/api/user/datagroup/edit/polygon", + path: "/user/datagroup/edit/polygon", handler: editDataGroupPolygon, }, { method: "POST", - path: "/api/user/datagroup/edit/line", + path: "/user/datagroup/edit/line", handler: editDataGroupLine, }, ]; diff --git a/src/routes/maps.test.ts b/src/routes/maps.test.ts index 999fbed..7a963f8 100644 --- a/src/routes/maps.test.ts +++ b/src/routes/maps.test.ts @@ -9,11 +9,11 @@ const Model = require("../queries/database"); const sandbox = createSandbox(); -describe("GET /api/user/maps", () => { +describe("GET /user/maps", () => { let server: Server; const getUserMapsRequest = { method: "GET", - url: "/api/user/maps", + url: "/user/maps", auth: { strategy: "simple", credentials: { diff --git a/src/routes/maps.ts b/src/routes/maps.ts index 8d5c5e3..fd99365 100644 --- a/src/routes/maps.ts +++ b/src/routes/maps.ts @@ -936,53 +936,53 @@ async function getPublicMap( export const mapRoutes: ServerRoute[] = [ // Create or update a map - { method: "POST", path: "/api/user/map/save", handler: saveMap }, + { method: "POST", path: "/user/map/save", handler: saveMap }, // Save an object to a map - { method: "POST", path: "/api/user/map/save/marker", handler: saveMapMarker }, + { method: "POST", path: "/user/map/save/marker", handler: saveMapMarker }, { method: "POST", - path: "/api/user/map/save/polygon", + path: "/user/map/save/polygon", handler: saveMapPolygon, }, - { method: "POST", path: "/api/user/map/save/line", handler: saveMapLine }, + { method: "POST", path: "/user/map/save/line", handler: saveMapLine }, // Save the zoom level of a map - { method: "POST", path: "/api/user/map/save/zoom", handler: saveMapZoom }, + { method: "POST", path: "/user/map/save/zoom", handler: saveMapZoom }, // Save the longitude and latitude of a map (i.e. when the frame is moved) - { method: "POST", path: "/api/user/map/save/lngLat", handler: saveMapLngLat }, + { method: "POST", path: "/user/map/save/lngLat", handler: saveMapLngLat }, // Edit an object - { method: "POST", path: "/api/user/map/edit/marker", handler: editMapMarker }, + { method: "POST", path: "/user/map/edit/marker", handler: editMapMarker }, { method: "POST", - path: "/api/user/map/edit/polygon", + path: "/user/map/edit/polygon", handler: editMapPolygon, }, - { method: "POST", path: "/api/user/map/edit/line", handler: editMapLine }, + { method: "POST", path: "/user/map/edit/line", handler: editMapLine }, // Record that the user has viewed a map - { method: "POST", path: "/api/user/map/view", handler: setMapAsViewed }, + { method: "POST", path: "/user/map/view", handler: setMapAsViewed }, // Get the email addresses and their access level that a map is shared to - { method: "GET", path: "/api/user/map/share", handler: getMapSharedTo }, + { method: "GET", path: "/user/map/share", handler: getMapSharedTo }, // Share access of a map to a list of email addresses - { method: "POST", path: "/api/user/map/share/sync", handler: shareMap }, + { method: "POST", path: "/user/map/share/sync", handler: shareMap }, // Delete a map - { method: "POST", path: "/api/user/map/delete", handler: deleteMap }, + { method: "POST", path: "/user/map/delete", handler: deleteMap }, // Make a map accessible to the public - { method: "POST", path: "/api/user/map/share/public", handler: setMapPublic }, + { method: "POST", path: "/user/map/share/public", handler: setMapPublic }, // Returns a map converted to shapefile format { method: "GET", - path: "/api/user/map/download/{mapId}", + path: "/user/map/download/{mapId}", handler: downloadMap, }, // Returns a list of all maps that the user has access to - { method: "GET", path: "/api/user/maps", handler: getUserMaps }, + { method: "GET", path: "/user/maps", handler: getUserMaps }, // Get the geojson polygons of land ownership within a given bounding box area - { method: "GET", path: "/api/ownership", handler: getLandOwnershipPolygons }, + { method: "GET", path: "/ownership", handler: getLandOwnershipPolygons }, // search the public ownership information - { method: "GET", path: "/api/search", handler: searchOwnership }, + { method: "GET", path: "/search", handler: searchOwnership }, // Get a public map { method: "GET", - path: "/api/public/map/{mapId}", + path: "/public/map/{mapId}", handler: getPublicMap, options: { auth: false }, },