From d2f2d95bb5060bdd9276bed7e9b587af9c57c32d Mon Sep 17 00:00:00 2001 From: Kristaps Fabians Geikins Date: Tue, 20 May 2025 14:24:48 +0300 Subject: [PATCH] chore(server): migrate remaining tests to TS (#4772) * auth tests migrated * core tests * pwdreset * authz tests --- .gitguardian.yml | 2 + ...s.graphql.spec.js => apps.graphql.spec.ts} | 83 ++--- .../auth/tests/{apps.spec.js => apps.spec.ts} | 157 ++++---- .../core/tests/{init.spec.js => init.spec.ts} | 8 +- .../{pwdrest.spec.js => pwdrest.spec.ts} | 67 ++-- packages/server/modules/shared/authz.ts | 5 +- .../test/{authz.spec.js => authz.spec.ts} | 345 +++++++++++------- 7 files changed, 386 insertions(+), 281 deletions(-) rename packages/server/modules/auth/tests/{apps.graphql.spec.js => apps.graphql.spec.ts} (85%) rename packages/server/modules/auth/tests/{apps.spec.js => apps.spec.ts} (80%) rename packages/server/modules/core/tests/{init.spec.js => init.spec.ts} (90%) rename packages/server/modules/pwdreset/tests/{pwdrest.spec.js => pwdrest.spec.ts} (71%) rename packages/server/modules/shared/test/{authz.spec.js => authz.spec.ts} (63%) diff --git a/.gitguardian.yml b/.gitguardian.yml index 1ba03afb4..65681ac6b 100644 --- a/.gitguardian.yml +++ b/.gitguardian.yml @@ -29,5 +29,7 @@ secret: name: setup/keycloak/speckle-realm.json - secret for dev keycloak - match: 2e1b3675a4049cd39fe6db081735f747730969071528270800f00fa98720d198 name: setup/keycloak/speckle-realm.json - algorithm name + - match: 7aa3f40885a6914c798c95568f04d840f50164b4e2ba632da0edb9602ca5609b + name: apps.graphql.spec.ts - fake password version: 2 diff --git a/packages/server/modules/auth/tests/apps.graphql.spec.js b/packages/server/modules/auth/tests/apps.graphql.spec.ts similarity index 85% rename from packages/server/modules/auth/tests/apps.graphql.spec.js rename to packages/server/modules/auth/tests/apps.graphql.spec.ts index 91135eb51..f87b3188d 100644 --- a/packages/server/modules/auth/tests/apps.graphql.spec.js +++ b/packages/server/modules/auth/tests/apps.graphql.spec.ts @@ -1,68 +1,59 @@ /* eslint-disable camelcase */ /* istanbul ignore file */ -const chai = require('chai') +import chai from 'chai' const expect = chai.expect -const { +import { createBareToken, createAppTokenFactory, createPersonalAccessTokenFactory -} = require('@/modules/core/services/tokens') -const { beforeEachContext, initializeTestServer } = require('@/test/hooks') -const { Scopes } = require('@speckle/shared') -const { +} from '@/modules/core/services/tokens' +import { beforeEachContext, initializeTestServer } from '@/test/hooks' +import { Scopes } from '@speckle/shared' +import { createAuthorizationCodeFactory, getAuthorizationCodeFactory, deleteAuthorizationCodeFactory, getAppFactory, createRefreshTokenFactory -} = require('@/modules/auth/repositories/apps') -const { db } = require('@/db/knex') -const { - createAppTokenFromAccessCodeFactory -} = require('@/modules/auth/services/serverApps') -const { +} from '@/modules/auth/repositories/apps' +import { db } from '@/db/knex' +import { createAppTokenFromAccessCodeFactory } from '@/modules/auth/services/serverApps' +import { findEmailFactory, createUserEmailFactory, ensureNoPrimaryEmailForUserFactory -} = require('@/modules/core/repositories/userEmails') -const { - requestNewEmailVerificationFactory -} = require('@/modules/emails/services/verification/request') -const { +} from '@/modules/core/repositories/userEmails' +import { requestNewEmailVerificationFactory } from '@/modules/emails/services/verification/request' +import { getUserFactory, storeUserFactory, countAdminUsersFactory, storeUserAclFactory -} = require('@/modules/core/repositories/users') -const { - deleteOldAndInsertNewVerificationFactory -} = require('@/modules/emails/repositories') -const { renderEmail } = require('@/modules/emails/services/emailRendering') -const { sendEmail } = require('@/modules/emails/services/sending') -const { createUserFactory } = require('@/modules/core/services/users/management') -const { - validateAndCreateUserEmailFactory -} = require('@/modules/core/services/userEmails') -const { - finalizeInvitedServerRegistrationFactory -} = require('@/modules/serverinvites/services/processing') -const { +} from '@/modules/core/repositories/users' +import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' +import { renderEmail } from '@/modules/emails/services/emailRendering' +import { sendEmail } from '@/modules/emails/services/sending' +import { createUserFactory } from '@/modules/core/services/users/management' +import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' +import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing' +import { deleteServerOnlyInvitesFactory, updateAllInviteTargetsFactory -} = require('@/modules/serverinvites/repositories/serverInvites') -const { +} from '@/modules/serverinvites/repositories/serverInvites' +import { storeApiTokenFactory, storeTokenScopesFactory, storeTokenResourceAccessDefinitionsFactory, storeUserServerAppTokenFactory, storePersonalApiTokenFactory -} = require('@/modules/core/repositories/tokens') -const { getServerInfoFactory } = require('@/modules/core/repositories/server') -const { getEventBus } = require('@/modules/shared/services/eventBus') +} from '@/modules/core/repositories/tokens' +import { getServerInfoFactory } from '@/modules/core/repositories/server' +import { getEventBus } from '@/modules/shared/services/eventBus' +import { BasicTestUser } from '@/test/authHelper' -let sendRequest +let sendRequest: Awaited>['sendRequest'] const createAppToken = createAppTokenFactory({ storeApiToken: storeApiTokenFactory({ db }), @@ -120,10 +111,10 @@ const createPersonalAccessToken = createPersonalAccessTokenFactory({ }) describe('GraphQL @apps-api', () => { - let testUser - let testUser2 - let testToken - let testToken2 + let testUser: BasicTestUser + let testUser2: BasicTestUser + let testToken: string + let testToken2: string before(async () => { const ctx = await beforeEachContext() @@ -131,7 +122,8 @@ describe('GraphQL @apps-api', () => { testUser = { name: 'Dimitrie Stefanescu', email: 'didimitrie@example.org', - password: 'wtfwtfwtf' + password: 'wtfwtfwtf', + id: '' } testUser.id = await createUser(testUser) @@ -144,7 +136,8 @@ describe('GraphQL @apps-api', () => { testUser2 = { name: 'Mr. Mac', email: 'steve@jobs.com', - password: 'wtfwtfwtf' + password: 'wtfwtfwtf', + id: '' } testUser2.id = await createUser(testUser2) @@ -155,8 +148,8 @@ describe('GraphQL @apps-api', () => { ])}` }) - let testAppId - let testApp + let testAppId: string + let testApp: { secret: string } it('Should create an app', async () => { const query = diff --git a/packages/server/modules/auth/tests/apps.spec.js b/packages/server/modules/auth/tests/apps.spec.ts similarity index 80% rename from packages/server/modules/auth/tests/apps.spec.js rename to packages/server/modules/auth/tests/apps.spec.ts index 736b52447..52e47efc9 100644 --- a/packages/server/modules/auth/tests/apps.spec.js +++ b/packages/server/modules/auth/tests/apps.spec.ts @@ -1,17 +1,17 @@ /* istanbul ignore file */ -const expect = require('chai').expect +import { expect } from 'chai' -const { +import { createBareToken, createAppTokenFactory, validateTokenFactory -} = require(`@/modules/core/services/tokens`) -const { beforeEachContext } = require(`@/test/hooks`) +} from '@/modules/core/services/tokens' +import { beforeEachContext } from '@/test/hooks' -const { Scopes } = require('@/modules/core/helpers/mainConstants') -const { knex } = require('@/db/knex') -const cryptoRandomString = require('crypto-random-string') -const { +import { Scopes } from '@/modules/core/helpers/mainConstants' +import { knex } from '@/db/knex' +import cryptoRandomString from 'crypto-random-string' +import { getAppFactory, updateDefaultAppFactory, getAllPublicAppsFactory, @@ -26,43 +26,35 @@ const { getRefreshTokenFactory, revokeRefreshTokenFactory, getTokenAppInfoFactory -} = require('@/modules/auth/repositories/apps') -const { +} from '@/modules/auth/repositories/apps' +import { createAppTokenFromAccessCodeFactory, refreshAppTokenFactory -} = require('@/modules/auth/services/serverApps') -const { +} from '@/modules/auth/services/serverApps' +import { findEmailFactory, createUserEmailFactory, ensureNoPrimaryEmailForUserFactory -} = require('@/modules/core/repositories/userEmails') -const { - requestNewEmailVerificationFactory -} = require('@/modules/emails/services/verification/request') -const { +} from '@/modules/core/repositories/userEmails' +import { requestNewEmailVerificationFactory } from '@/modules/emails/services/verification/request' +import { getUserFactory, storeUserFactory, countAdminUsersFactory, storeUserAclFactory, getUserRoleFactory -} = require('@/modules/core/repositories/users') -const { - deleteOldAndInsertNewVerificationFactory -} = require('@/modules/emails/repositories') -const { renderEmail } = require('@/modules/emails/services/emailRendering') -const { sendEmail } = require('@/modules/emails/services/sending') -const { createUserFactory } = require('@/modules/core/services/users/management') -const { - validateAndCreateUserEmailFactory -} = require('@/modules/core/services/userEmails') -const { - finalizeInvitedServerRegistrationFactory -} = require('@/modules/serverinvites/services/processing') -const { +} from '@/modules/core/repositories/users' +import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' +import { renderEmail } from '@/modules/emails/services/emailRendering' +import { sendEmail } from '@/modules/emails/services/sending' +import { createUserFactory } from '@/modules/core/services/users/management' +import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' +import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing' +import { deleteServerOnlyInvitesFactory, updateAllInviteTargetsFactory -} = require('@/modules/serverinvites/repositories/serverInvites') -const { +} from '@/modules/serverinvites/repositories/serverInvites' +import { storeApiTokenFactory, storeTokenScopesFactory, storeTokenResourceAccessDefinitionsFactory, @@ -72,9 +64,16 @@ const { getTokenScopesByIdFactory, getTokenResourceAccessDefinitionsByIdFactory, updateApiTokenFactory -} = require('@/modules/core/repositories/tokens') -const { getServerInfoFactory } = require('@/modules/core/repositories/server') -const { getEventBus } = require('@/modules/shared/services/eventBus') +} from '@/modules/core/repositories/tokens' +import { getServerInfoFactory } from '@/modules/core/repositories/server' +import { getEventBus } from '@/modules/shared/services/eventBus' +import { BasicTestUser } from '@/test/authHelper' +import { AppScopes, ensureError } from '@speckle/shared' +import { ValidTokenResult } from '@/modules/core/helpers/types' +import { + DefaultAppIds, + DefaultAppWithUnwrappedScopes +} from '@/modules/auth/defaultApps' const db = knex const getApp = getAppFactory({ db: knex }) @@ -156,10 +155,11 @@ const validateToken = validateTokenFactory({ }) describe('Services @apps-services', () => { - const actor = { + const actor: BasicTestUser = { name: 'Dimitrie Stefanescu', email: 'didimitrie@example.org', - password: 'wtfwtfwtf' + password: 'wtfwtfwtf', + id: '' } before(async () => { @@ -173,7 +173,8 @@ describe('Services @apps-services', () => { name: testAppName, public: true, scopes: [Scopes.Streams.Read], - redirectUrl: 'http://127.0.0.1:1335' + redirectUrl: 'http://127.0.0.1:1335', + authorId: actor.id }) expect(res).to.have.property('id') @@ -183,7 +184,7 @@ describe('Services @apps-services', () => { expect(res.secret).to.be.a('string') const app = await getApp({ id: res.id }) - expect(app.id).to.equal(res.id) + expect(app?.id).to.equal(res.id) }) it('Should get all the public apps on this server', async () => { @@ -193,7 +194,12 @@ describe('Services @apps-services', () => { }) it('Should fail to register an app with no scopes', async () => { - await createApp({ name: 'test application2', redirectUrl: 'http://127.0.0.1:1335' }) + await createApp({ + name: 'test application2', + redirectUrl: 'http://127.0.0.1:1335', + authorId: actor.id, + scopes: undefined as unknown as AppScopes[] + }) .then(() => { throw new Error('this should have been rejected') }) @@ -207,7 +213,8 @@ describe('Services @apps-services', () => { name: cryptoRandomString({ length: 10 }), public: true, scopes: [Scopes.Streams.Read], - redirectUrl: 'http://127.0.0.1:1335' + redirectUrl: 'http://127.0.0.1:1335', + authorId: actor.id }) const res = await updateApp({ app: { @@ -219,10 +226,10 @@ describe('Services @apps-services', () => { expect(res).to.be.a('string') const app = await getApp({ id: myTestApp.id }) - expect(app.name).to.equal('updated test application') - expect(app.scopes).to.be.an('array') - expect(app.scopes.map((s) => s.name)).to.include(Scopes.Users.Read) - expect(app.scopes.map((s) => s.name)).to.include(Scopes.Streams.Read) + expect(app?.name).to.equal('updated test application') + expect(app?.scopes).to.be.an('array') + expect(app?.scopes.map((s) => s.name)).to.include(Scopes.Users.Read) + expect(app?.scopes.map((s) => s.name)).to.include(Scopes.Streams.Read) }) const challenge = 'random' @@ -232,7 +239,8 @@ describe('Services @apps-services', () => { name: cryptoRandomString({ length: 10 }), public: true, scopes: [Scopes.Streams.Read], - redirectUrl: 'http://127.0.0.1:1335' + redirectUrl: 'http://127.0.0.1:1335', + authorId: actor.id }) const authorizationCode = await createAuthorizationCode({ appId: myTestApp.id, @@ -247,7 +255,8 @@ describe('Services @apps-services', () => { name: cryptoRandomString({ length: 10 }), public: true, scopes: [Scopes.Streams.Read], - redirectUrl: 'http://127.0.0.1:1335' + redirectUrl: 'http://127.0.0.1:1335', + authorId: actor.id }) const authorizationCode = await createAuthorizationCode({ appId: myTestApp.id, @@ -267,7 +276,7 @@ describe('Services @apps-services', () => { expect(response).to.have.property('refreshToken') expect(response.refreshToken).to.be.a('string') - const validation = await validateToken(response.token) + const validation = (await validateToken(response.token)) as ValidTokenResult expect(validation.valid).to.equal(true) expect(validation.userId).to.equal(actor.id) expect(validation.scopes[0]).to.equal(Scopes.Streams.Read) @@ -278,7 +287,8 @@ describe('Services @apps-services', () => { name: cryptoRandomString({ length: 10 }), public: true, scopes: [Scopes.Streams.Read], - redirectUrl: 'http://127.0.0.1:1335' + redirectUrl: 'http://127.0.0.1:1335', + authorId: actor.id }) const authorizationCode = await createAuthorizationCode({ appId: myTestApp.id, @@ -301,14 +311,13 @@ describe('Services @apps-services', () => { const res = await refreshAppToken({ refreshToken: tokenCreateResponse.refreshToken, appId: myTestApp.id, - appSecret: myTestApp.secret, - userId: actor.id + appSecret: myTestApp.secret }) expect(res.token).to.be.a('string') expect(res.refreshToken).to.be.a('string') - const validation = await validateToken(res.token) + const validation = (await validateToken(res.token)) as ValidTokenResult expect(validation.valid).to.equal(true) expect(validation.userId).to.equal(actor.id) }) @@ -318,7 +327,8 @@ describe('Services @apps-services', () => { name: cryptoRandomString({ length: 10 }), public: true, scopes: [Scopes.Streams.Read], - redirectUrl: 'http://127.0.0.1:1335' + redirectUrl: 'http://127.0.0.1:1335', + authorId: actor.id }) const unusedAccessCode = await createAuthorizationCode({ appId: myTestApp.id, @@ -385,8 +395,8 @@ describe('Services @apps-services', () => { it(`Should get the default app: ${speckleAppId}`, async () => { const app = await getApp({ id: speckleAppId }) expect(app).to.be.an('object') - expect(app.redirectUrl).to.be.a('string') - expect(app.scopes).to.be.a('array') + expect(app?.redirectUrl).to.be.a('string') + expect(app?.scopes).to.be.a('array') }) it(`Should not invalidate tokens, refresh tokens and access codes for default app: ${speckleAppId}, if updated`, async () => { const [unusedAccessCode, usedAccessCode] = await Promise.all([ @@ -418,14 +428,14 @@ describe('Services @apps-services', () => { await updateDefaultApp( { name: 'updated test application', - id: speckleAppId, + id: speckleAppId as DefaultAppIds, scopes: newScopes - }, - existingApp + } as DefaultAppWithUnwrappedScopes, + existingApp! ) const updatedApp = await getApp({ id: speckleAppId }) - expect(updatedApp.scopes.map((s) => s.name)).to.equalInAnyOrder(newScopes) + expect(updatedApp?.scopes.map((s) => s.name)).to.deep.equalInAnyOrder(newScopes) const validationResponse = await validateToken(apiTokenResponse.token) expect(validationResponse.valid).to.equal(true) @@ -447,7 +457,7 @@ describe('Services @apps-services', () => { expect(appToken.token).to.exist expect(appToken.refreshToken).to.exist - const apiTokens = await knex('user_server_app_tokens') + const apiTokens = (await knex('user_server_app_tokens') .join( 'token_scopes', 'user_server_app_tokens.tokenId', @@ -456,7 +466,7 @@ describe('Services @apps-services', () => { ) .where({ appId: speckleAppId - }) + })) as { scopeName: string }[] expect(newScopes).to.include.members(apiTokens.map((t) => t.scopeName)) }) @@ -471,19 +481,21 @@ describe('Services @apps-services', () => { name: 'updated test application', id: speckleAppId, scopes: ['aWeird:Scope'] - }, - existingApp + } as unknown as DefaultAppWithUnwrappedScopes, + existingApp! ) throw new Error('This should have failed') } catch (err) { // check that the weird:Scope violates a foreign key constraint... // leaky abstractions i know, but no better way to test this for now - expect(err.message).to.contain('server_apps_scopes_scopename_foreign') + expect(ensureError(err).message).to.contain( + 'server_apps_scopes_scopename_foreign' + ) } const notUpdatedApp = await getApp({ id: speckleAppId }) // check that no harm was done - expect(notUpdatedApp.name).to.equal(existingApp.name) - expect(notUpdatedApp.scopes).to.equalInAnyOrder(existingApp.scopes) + expect(notUpdatedApp?.name).to.equal(existingApp?.name) + expect(notUpdatedApp?.scopes).to.deep.equalInAnyOrder(existingApp?.scopes) }) it('Should revoke access for a given user', async () => { @@ -491,12 +503,14 @@ describe('Services @apps-services', () => { name: cryptoRandomString({ length: 10 }), public: true, scopes: [Scopes.Streams.Read], - redirectUrl: 'http://127.0.0.1:1335' + redirectUrl: 'http://127.0.0.1:1335', + authorId: actor.id }) - const secondUser = { + const secondUser: BasicTestUser = { name: 'Dimitrie Stefanescu', email: 'didimitrie.wow@example.org', - password: 'wtfwtfwtf' + password: 'wtfwtfwtf', + id: '' } secondUser.id = await createUser(secondUser) @@ -557,7 +571,8 @@ describe('Services @apps-services', () => { name: cryptoRandomString({ length: 10 }), public: true, scopes: [Scopes.Streams.Read], - redirectUrl: 'http://127.0.0.1:1335' + redirectUrl: 'http://127.0.0.1:1335', + authorId: actor.id }) const res = await deleteApp({ id: myTestApp.id }) expect(res).to.equal(1) diff --git a/packages/server/modules/core/tests/init.spec.js b/packages/server/modules/core/tests/init.spec.ts similarity index 90% rename from packages/server/modules/core/tests/init.spec.js rename to packages/server/modules/core/tests/init.spec.ts index 1106a7e02..a124691b4 100644 --- a/packages/server/modules/core/tests/init.spec.js +++ b/packages/server/modules/core/tests/init.spec.ts @@ -1,9 +1,9 @@ /* istanbul ignore file */ -const expect = require('chai').expect +import { expect } from 'chai' -const { init } = require('@/app') -const { knex } = require('@/db/knex') -const { beforeEachContext } = require('@/test/hooks') +import { init } from '@/app' +import { knex } from '@/db/knex' +import { beforeEachContext } from '@/test/hooks' // NOTE: // These tests check that the initialization routine of the whole server diff --git a/packages/server/modules/pwdreset/tests/pwdrest.spec.js b/packages/server/modules/pwdreset/tests/pwdrest.spec.ts similarity index 71% rename from packages/server/modules/pwdreset/tests/pwdrest.spec.js rename to packages/server/modules/pwdreset/tests/pwdrest.spec.ts index 9fab02602..d9c4051d5 100644 --- a/packages/server/modules/pwdreset/tests/pwdrest.spec.js +++ b/packages/server/modules/pwdreset/tests/pwdrest.spec.ts @@ -1,45 +1,38 @@ -const request = require('supertest') +import request from 'supertest' -const { knex } = require('@/db/knex') -const ResetTokens = () => knex('pwdreset_tokens') +import { knex } from '@/db/knex' -const { beforeEachContext } = require('@/test/hooks') -const { localAuthRestApi } = require('@/modules/auth/tests/helpers/registration') -const { expectToThrow } = require('@/test/assertionHelper') -const { expect } = require('chai') -const { +import { beforeEachContext } from '@/test/hooks' +import { localAuthRestApi } from '@/modules/auth/tests/helpers/registration' +import { expectToThrow } from '@/test/assertionHelper' +import { expect } from 'chai' +import { findEmailFactory, createUserEmailFactory, ensureNoPrimaryEmailForUserFactory -} = require('@/modules/core/repositories/userEmails') -const { - requestNewEmailVerificationFactory -} = require('@/modules/emails/services/verification/request') -const { +} from '@/modules/core/repositories/userEmails' +import { requestNewEmailVerificationFactory } from '@/modules/emails/services/verification/request' +import { getUserFactory, storeUserFactory, countAdminUsersFactory, storeUserAclFactory -} = require('@/modules/core/repositories/users') -const { - deleteOldAndInsertNewVerificationFactory -} = require('@/modules/emails/repositories') -const { renderEmail } = require('@/modules/emails/services/emailRendering') -const { sendEmail } = require('@/modules/emails/services/sending') -const { createUserFactory } = require('@/modules/core/services/users/management') -const { - validateAndCreateUserEmailFactory -} = require('@/modules/core/services/userEmails') -const { - finalizeInvitedServerRegistrationFactory -} = require('@/modules/serverinvites/services/processing') -const { +} from '@/modules/core/repositories/users' +import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' +import { renderEmail } from '@/modules/emails/services/emailRendering' +import { sendEmail } from '@/modules/emails/services/sending' +import { createUserFactory } from '@/modules/core/services/users/management' +import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' +import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing' +import { deleteServerOnlyInvitesFactory, updateAllInviteTargetsFactory -} = require('@/modules/serverinvites/repositories/serverInvites') -const { getServerInfoFactory } = require('@/modules/core/repositories/server') -const { getEventBus } = require('@/modules/shared/services/eventBus') +} from '@/modules/serverinvites/repositories/serverInvites' +import { getServerInfoFactory } from '@/modules/core/repositories/server' +import { getEventBus } from '@/modules/shared/services/eventBus' +import { BasicTestUser } from '@/test/authHelper' +const ResetTokens = () => knex('pwdreset_tokens') const db = knex const getServerInfo = getServerInfoFactory({ db }) const findEmail = findEmailFactory({ db }) @@ -71,17 +64,18 @@ const createUser = createUserFactory({ }) describe('Password reset requests @passwordresets', () => { - let app + let app: Awaited>['app'] before(async () => { ;({ app } = await beforeEachContext()) }) it('Should carefully send a password request email', async () => { - const userA = { + const userA: BasicTestUser = { name: 'd1', email: 'd@speckle.systems', - password: 'wowwow8charsplease' + password: 'wowwow8charsplease', + id: '' } userA.id = await createUser(userA) @@ -108,10 +102,11 @@ describe('Password reset requests @passwordresets', () => { }) it('Should reset passwords', async () => { - const userB = { + const userB: BasicTestUser = { name: 'd2', email: 'd2@speckle.systems', - password: 'w0ww0w8charsplease' + password: 'w0ww0w8charsplease', + id: '' } userB.id = await createUser(userB) @@ -163,7 +158,7 @@ describe('Password reset requests @passwordresets', () => { async () => await authRestApi.login({ email: userB.email, - password: userB.password, + password: userB.password!, challenge: '123' }) ) diff --git a/packages/server/modules/shared/authz.ts b/packages/server/modules/shared/authz.ts index 145f5d548..1d73d151f 100644 --- a/packages/server/modules/shared/authz.ts +++ b/packages/server/modules/shared/authz.ts @@ -36,12 +36,15 @@ import { ProjectRecordVisibility } from '@/modules/core/helpers/types' import { moduleAuthLoaders } from '@/modules/index' export { AuthContext, AuthParams } -interface AuthFailedResult extends AuthResult { +export interface AuthFailedResult extends AuthResult { authorized: false error: BaseError | null fatal?: boolean } +export const isAuthFailedResult = (result: AuthResult): result is AuthFailedResult => + ('error' in result || ('fatal' in result && !!result.fatal)) && !result.authorized + interface AuthFailedData extends AuthData { authResult: AuthFailedResult } diff --git a/packages/server/modules/shared/test/authz.spec.js b/packages/server/modules/shared/test/authz.spec.ts similarity index 63% rename from packages/server/modules/shared/test/authz.spec.js rename to packages/server/modules/shared/test/authz.spec.ts index c8ac64622..a8b97a3e8 100644 --- a/packages/server/modules/shared/test/authz.spec.js +++ b/packages/server/modules/shared/test/authz.spec.ts @@ -1,5 +1,5 @@ -const expect = require('chai').expect -const { +import { expect } from 'chai' +import { authPipelineCreator, authFailed, authSuccess, @@ -9,167 +9,227 @@ const { allowForRegisteredUsersOnPublicStreamsEvenWithoutRole, allowForServerAdmins, validateResourceAccess, - validateRequiredStreamFactory -} = require('@/modules/shared/authz') -const { - ForbiddenError: SFE, - UnauthorizedError: SUE, + validateRequiredStreamFactory, + AuthContext, + isAuthFailedResult, + AuthFailedResult +} from '@/modules/shared/authz' +import { + ForbiddenError as SFE, + UnauthorizedError as SUE, UnauthorizedError, ContextError, - NotFoundError -} = require('@/modules/shared/errors') -const { Roles } = require('@speckle/shared') -const { - TokenResourceIdentifierType -} = require('@/modules/core/graph/generated/graphql') -const { ProjectRecordVisibility } = require('@/modules/core/helpers/types') + NotFoundError, + BaseError +} from '@/modules/shared/errors' +import { AvailableRoles, ensureError, Roles } from '@speckle/shared' +import { TokenResourceIdentifierType } from '@/modules/core/graph/generated/graphql' +import { ProjectRecordVisibility, StreamRecord } from '@/modules/core/helpers/types' +import { + AuthData, + AuthPipelineFunction, + AuthResult +} from '@/modules/shared/domain/authz/types' +import { UserRoleData } from '@/modules/shared/domain/rolesAndScopes/types' describe('AuthZ @shared', () => { + const buildFooAuthData = (): AuthData => + ({ + context: { foo: 'bar' } as unknown as AuthContext + } as AuthData) + const buildEmptyContext = (): AuthContext => ({} as unknown as AuthContext) + const buildEmptySuccess = () => authSuccess(buildEmptyContext()) + describe('Auth pipeline', () => { it('Empty pipeline returns no authorization', async () => { const pipeline = authPipelineCreator([]) - const { authResult } = await pipeline({ context: { foo: 'bar' } }) + const { authResult } = await pipeline(buildFooAuthData()) expect(authResult.authorized).to.equal(false) }) it('Pipeline breaks on fatal error', async () => { const errorMessage = 'dummy' - const fatalFail = async () => authFailed({}, new Error(errorMessage), true) - const shouldRescue = async () => authSuccess() + const fatalFail = async () => + authFailed(buildEmptyContext(), new BaseError(errorMessage), true) + const shouldRescue = async () => buildEmptySuccess() const pipeline = authPipelineCreator([shouldRescue, fatalFail, shouldRescue]) - const { authResult } = await pipeline({ context: { foo: 'bar' } }) + const { authResult } = await pipeline(buildFooAuthData()) + + if (!isAuthFailedResult(authResult)) { + throw new Error('AuthResult should be an auth failed result') + } + expect(authResult.authorized).to.equal(false) expect(authResult.fatal).to.equal(true) - expect(authResult.error.message).to.equal(errorMessage) + expect(authResult.error?.message).to.equal(errorMessage) }) it('Pipeline continues for non fatal errors', async () => { - const nonFatalFail = async () => authFailed({}, new Error('errorMessage'), false) - const shouldRescue = async () => authSuccess() + const nonFatalFail = async () => + authFailed(buildEmptyContext(), new BaseError('errorMessage'), false) + const shouldRescue = async () => buildEmptySuccess() const pipeline = authPipelineCreator([shouldRescue, nonFatalFail, shouldRescue]) - const { authResult } = await pipeline({ context: { foo: 'bar' } }) + const { authResult } = await pipeline(buildFooAuthData()) + + if (isAuthFailedResult(authResult)) { + throw new Error('AuthResult should not be an auth failed result') + } + expect(authResult.authorized).to.equal(true) - expect(authResult.fatal).to.not.exist - expect(authResult.error).to.not.exist }) it('Pipeline throws Error if authorized but has error', async () => { - const borkedStep = async () => ({ + const borkedStep: AuthPipelineFunction = async () => ({ authResult: { authorized: true, error: new UnauthorizedError('Weird stuff'), fatal: false - } + }, + context: undefined as unknown as AuthContext }) const pipeline = authPipelineCreator([borkedStep]) try { - await pipeline({ context: { foo: 'bar' } }) + await pipeline(buildFooAuthData()) throw new Error('This should have thrown') } catch (err) { - expect(err.message).to.equal('Auth failure') + expect(ensureError(err).message).to.equal('Auth failure') } }) }) describe('Role validation', () => { - const rolesLookup = async () => [ - { name: '1', weight: 1 }, - { name: 'server:2', weight: 2 }, - { name: '3', weight: 3 }, - { name: 'goku', weight: 9001 }, - { name: '42', weight: 42 } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rolesLookup: () => Promise[]> = async () => [ + { name: '1', weight: 1, description: '', public: false }, + { name: 'server:2', weight: 2, description: '', public: false }, + { name: '3', weight: 3, description: '', public: false }, + { name: 'goku', weight: 9001, description: '', public: false }, + { name: '42', weight: 42, description: '', public: false } ] const testData = [ { name: 'Having lower privileged role than required results auth failed', requiredRole: 'server:2', - context: { auth: true, role: '1' }, + context: { auth: true, role: '1' } as unknown as AuthContext, expectedResult: authFailed( - null, + buildEmptyContext(), new SFE('You do not have the required server role') ) }, { name: 'Not having auth fails role validation', requiredRole: 'server:2', - context: { auth: false }, - expectedResult: authFailed(null, new SUE('Must provide an auth token')) + context: { auth: false } as unknown as AuthContext, + expectedResult: authFailed( + buildEmptyContext(), + new SUE('Must provide an auth token') + ) }, { name: 'Requiring a junk role fails auth', requiredRole: 'knock knock...', - context: { auth: true, role: '1' }, - expectedResult: authFailed(null, new SFE('Invalid role requirement specified')) + context: { auth: true, role: '1' } as unknown as AuthContext, + expectedResult: authFailed( + buildEmptyContext(), + new SFE('Invalid role requirement specified') + ) }, { name: 'Having a junk role fails auth', requiredRole: 'server:2', - context: { auth: true, role: 'iddqd' }, - expectedResult: authFailed(null, new SFE('Your role is not valid')) + context: { auth: true, role: 'iddqd' } as unknown as AuthContext, + expectedResult: authFailed( + buildEmptyContext(), + new SFE('Your role is not valid') + ) }, { name: 'Not having the required level fails', requiredRole: 'goku', - context: { auth: true, role: '3' }, + context: { auth: true, role: '3' } as unknown as AuthContext, expectedResult: authFailed( - null, + buildEmptyContext(), new SFE('You do not have the required goku role') ) }, { name: 'Having the god mode role defeats even higher privilege requirement', requiredRole: 'goku', - context: { auth: true, role: '42' }, - expectedResult: authSuccess() + context: { auth: true, role: '42' } as unknown as AuthContext, + expectedResult: buildEmptySuccess() }, { name: 'Having equal role weight to required succeeds', requiredRole: '3', - context: { auth: true, role: '3' }, - expectedResult: authSuccess() + context: { auth: true, role: '3' } as unknown as AuthContext, + expectedResult: buildEmptySuccess() }, { name: 'Having bigger role weight than required succeeds', requiredRole: '3', - context: { auth: true, role: 'goku' }, - expectedResult: authSuccess() + context: { auth: true, role: 'goku' } as unknown as AuthContext, + expectedResult: buildEmptySuccess() } ] testData.forEach((testCase) => it(`${testCase.name}`, async () => { const step = validateRole({ - requiredRole: testCase.requiredRole, + requiredRole: testCase.requiredRole as unknown as AvailableRoles, rolesLookup, - iddqd: '42', - roleGetter: (context) => context.role + iddqd: '42' as AvailableRoles, + roleGetter: (context) => context.role || null }) const { authResult, context } = await step({ context: testCase.context, - authResult: authFailed() + authResult: { authorized: false } }) expect(authResult.authorized).to.exist expect(authResult.authorized).to.equal( testCase.expectedResult.authResult.authorized ) - // this also needs to check for the error type... is this how do you do that in JS???? - expect(authResult.error?.name).to.equal( - testCase.expectedResult.authResult.error?.name - ) - expect(authResult.error?.message).to.equal( - testCase.expectedResult.authResult.error?.message - ) + + if ( + isAuthFailedResult(authResult) || + isAuthFailedResult(testCase.expectedResult.authResult) + ) { + if ( + !isAuthFailedResult(authResult) || + !isAuthFailedResult(testCase.expectedResult.authResult) + ) { + throw new Error('AuthResult should be an auth failed result') + } + + // this also needs to check for the error type... is this how do you do that in JS???? + expect(authResult.error?.name).to.equal( + testCase.expectedResult.authResult.error?.name + ) + expect(authResult.error?.message).to.equal( + testCase.expectedResult.authResult.error?.message + ) + } + expect(context).to.deep.equal(testCase.context) }) ) it('Role validation fails if input authResult is already in an error state', async () => { - const step = validateRole({ requiredRole: 'goku', rolesLookup, iddqd: '42' }) + const step = validateRole({ + requiredRole: 'goku' as AvailableRoles, + rolesLookup, + iddqd: '42' as AvailableRoles, + roleGetter: (context) => context.role || null + }) const error = new SFE('This will be echoed back') const { authResult } = await step({ - context: {}, - authResult: { authorized: false, error } + context: buildEmptyContext(), + authResult: { authorized: false, error } as AuthFailedResult }) + + if (!isAuthFailedResult(authResult)) { + throw new Error('AuthResult should be an auth failed result') + } + expect(authResult.authorized).to.be.false - expect(authResult.error.message).to.equal(error.message) - expect(authResult.error.name).to.equal(error.name) + expect(authResult.error?.message).to.equal(error.message) + expect(authResult.error?.name).to.equal(error.name) }) }) @@ -178,53 +238,77 @@ describe('AuthZ @shared', () => { const step = validateScope({ requiredScope: 'play mahjong' }) const expectedError = new SFE("Scope validation doesn't rescue the auth pipeline") const { authResult } = await step({ - context: {}, - authResult: { authorized: false, error: expectedError } + context: buildEmptyContext(), + authResult: { authorized: false, error: expectedError } as AuthFailedResult }) + + if (!isAuthFailedResult(authResult)) { + throw new Error('AuthResult should be an auth failed result') + } + expect(authResult.authorized).to.be.false - expect(authResult.error.message).to.equal(expectedError.message) - expect(authResult.error.name).to.equal(expectedError.name) + expect(authResult.error?.message).to.equal(expectedError.message) + expect(authResult.error?.name).to.equal(expectedError.name) }) it('Without having any scopes on the context cannot validate scopes', async () => { const step = validateScope({ requiredScope: 'play mahjong' }) - const { authResult } = await step({ context: {}, authResult: {} }) + const { authResult } = await step({ + context: buildEmptyContext(), + authResult: { authorized: false } + }) + + if (!isAuthFailedResult(authResult)) { + throw new Error('AuthResult should be an auth failed result') + } + expect(authResult.authorized).to.equal(false) const expectedError = new SFE( 'Your auth token does not have the required scope: play mahjong.' ) - expect(authResult.error.message).to.equal(expectedError.message) - expect(authResult.error.name).to.equal(expectedError.name) + expect(authResult.error?.message).to.equal(expectedError.message) + expect(authResult.error?.name).to.equal(expectedError.name) }) it('Not having the right scopes results auth failed', async () => { const step = validateScope({ requiredScope: 'play mahjong' }) const { authResult } = await step({ - context: { scopes: ['sit around and wait', 'try to be cool'] }, - authResult: {} + context: { scopes: ['sit around and wait', 'try to be cool'] } as AuthContext, + authResult: { authorized: false } }) + + if (!isAuthFailedResult(authResult)) { + throw new Error('AuthResult should be an auth failed result') + } + expect(authResult.authorized).to.equal(false) const expectedError = new SFE( 'Your auth token does not have the required scope: play mahjong.' ) - expect(authResult.error.message).to.equal(expectedError.message) - expect(authResult.error.name).to.equal(expectedError.name) + expect(authResult.error?.message).to.equal(expectedError.message) + expect(authResult.error?.name).to.equal(expectedError.name) }) it('Having the right scopes results auth success', async () => { const step = validateScope({ requiredScope: 'play mahjong' }) const { authResult } = await step({ - context: { scopes: ['sit around and wait', 'try to be cool', 'play mahjong'] }, - authResult: {} + context: { + scopes: ['sit around and wait', 'try to be cool', 'play mahjong'] + } as AuthContext, + authResult: { authorized: false } }) + + if (isAuthFailedResult(authResult)) { + throw new Error('AuthResult should not be an auth failed result') + } + expect(authResult.authorized).to.equal(true) - expect(authResult.error).to.not.exist }) }) describe('Validate resource access', () => { it('Succeeds when no resource access rules present', async () => { const res = await validateResourceAccess({ - context: {}, - authResult: {} + context: buildEmptyContext(), + authResult: { authorized: false } }) expect(res.authResult.authorized).to.be.true @@ -236,8 +320,8 @@ describe('AuthZ @shared', () => { resourceAccessRules: [ { id: 'foo', type: TokenResourceIdentifierType.Project } ] - }, - authResult: {} + } as AuthContext, + authResult: { authorized: false } }) expect(res.authResult.authorized).to.be.true @@ -249,8 +333,8 @@ describe('AuthZ @shared', () => { resourceAccessRules: [ { id: 'foo', type: TokenResourceIdentifierType.Project } ] - }, - authResult: { authorized: false, error: new Error('dummy') } + } as AuthContext, + authResult: { authorized: false, error: new Error('dummy') } as AuthFailedResult }) expect(res.authResult.authorized).to.be.false @@ -263,12 +347,16 @@ describe('AuthZ @shared', () => { { id: 'foo', type: TokenResourceIdentifierType.Project } ], stream: { id: 'bar' } - }, - authResult: {} + } as AuthContext, + authResult: { authorized: false } }) + if (!isAuthFailedResult(res.authResult)) { + throw new Error('AuthResult should be an auth failed result') + } + expect(res.authResult.authorized).to.be.false - expect(res.authResult.error.message).to.equal( + expect(res.authResult.error?.message).to.equal( 'You are not authorized to access this resource.' ) }) @@ -281,8 +369,8 @@ describe('AuthZ @shared', () => { { id: 'bar', type: TokenResourceIdentifierType.Project } ], stream: { id: 'bar' } - }, - authResult: {} + } as AuthContext, + authResult: { authorized: false } }) expect(res.authResult.authorized).to.be.true @@ -295,8 +383,8 @@ describe('AuthZ @shared', () => { { id: 'foo', type: 'fake' }, { id: 'bar', type: 'fake' } ] - }, - authResult: {} + } as unknown as AuthContext, + authResult: { authorized: false } }) expect(res.authResult.authorized).to.be.true @@ -304,18 +392,21 @@ describe('AuthZ @shared', () => { }) describe('Context requires stream', () => { - const expectAuthError = (expectedError, authResult) => { + const expectAuthError = (expectedError: Error, authResult: AuthResult) => { + if (!isAuthFailedResult(authResult)) { + throw new Error('AuthResult should be an auth failed result') + } + expect(authResult.authorized).to.be.false expect(authResult.error).to.exist - expect(authResult.error.message).to.equal(expectedError.message) - expect(authResult.error.name).to.equal(expectedError.name) + expect(authResult.error?.message).to.equal(expectedError.message) + expect(authResult.error?.name).to.equal(expectedError.name) } it('Without streamId in the params it raises context error', async () => { const step = validateRequiredStreamFactory({ - getStream: async () => ({ ur: 'bamboozled' }), - getAutomationProject: async () => null + getStream: async () => ({ ur: 'bamboozled' } as unknown as StreamRecord) }) - const { authResult } = await step({ params: {} }) + const { authResult } = await step({ params: {} } as AuthData) expectAuthError( new ContextError("The context doesn't have a streamId"), authResult @@ -323,10 +414,9 @@ describe('AuthZ @shared', () => { }) it('If params is not defined it raises context error', async () => { const step = validateRequiredStreamFactory({ - getStream: async () => ({ ur: 'bamboozled' }), - getAutomationProject: async () => null + getStream: async () => ({ ur: 'bamboozled' } as unknown as StreamRecord) }) - const { authResult } = await step({}) + const { authResult } = await step({} as AuthData) expectAuthError( new ContextError("The context doesn't have a streamId"), authResult @@ -336,23 +426,24 @@ describe('AuthZ @shared', () => { const demoStream = { id: 'foo', name: 'bar' - } + } as StreamRecord + const step = validateRequiredStreamFactory({ - getStream: async () => demoStream, - getAutomationProject: async () => null + getStream: async () => demoStream }) const { context } = await step({ - context: {}, + context: buildEmptyContext(), params: { streamId: 'this is fake and its fine' } - }) + } as AuthData) expect(context.stream).to.deep.equal(demoStream) }) it('If context is not defined return auth failure', async () => { const step = validateRequiredStreamFactory({ - getStream: async () => {}, - getAutomationProject: async () => null + getStream: async () => undefined }) - const { authResult } = await step({ params: { streamId: 'the need for stream' } }) + const { authResult } = await step({ + params: { streamId: 'the need for stream' } + } as AuthData) expectAuthError(new ContextError('The context is not defined'), authResult) }) @@ -361,25 +452,23 @@ describe('AuthZ @shared', () => { const step = validateRequiredStreamFactory({ getStream: async () => { throw new Error(errorMessage) - }, - getAutomationProject: async () => null + } }) const { authResult } = await step({ context: {}, params: { streamId: 'the need for stream' } - }) + } as AuthData) expectAuthError(new ContextError(errorMessage), authResult) }) it("If stream getter doesn't find a stream it returns fatal auth failure", async () => { const step = validateRequiredStreamFactory({ - getStream: async () => {}, - getAutomationProject: async () => null + getStream: async () => undefined }) const { authResult } = await step({ params: { streamId: 'the need for stream' }, context: {} - }) + } as AuthData) expectAuthError( new NotFoundError( @@ -393,19 +482,25 @@ describe('AuthZ @shared', () => { describe('Escape hatches', () => { describe('Admin override', () => { it('server:admins get authSuccess', async () => { - const input = { context: { role: Roles.Server.Admin }, authResult: 'fake' } + const input = { + context: { role: Roles.Server.Admin }, + authResult: 'fake' + } as unknown as AuthData const result = await allowForServerAdmins(input) expect(result).to.deep.equal(authSuccess(input.context)) }) it('server:users get the previous authResult', async () => { - const input = { context: { role: Roles.Server.User }, authResult: 'fake' } + const input = { + context: { role: Roles.Server.User }, + authResult: 'fake' + } as unknown as AuthData const result = await allowForServerAdmins(input) expect(result).to.deep.equal(input) }) }) describe('Allow for public stream no role', () => { it('not public stream, no auth returns same context ', async () => { - const input = { context: 'dummy', authResult: 'fake' } + const input = { context: 'dummy', authResult: 'fake' } as unknown as AuthData const result = await allowForRegisteredUsersOnPublicStreamsEvenWithoutRole( input ) @@ -415,7 +510,7 @@ describe('AuthZ @shared', () => { const input = { context: { stream: { visibility: ProjectRecordVisibility.Public } }, authResult: 'fake' - } + } as unknown as AuthData const result = await allowForRegisteredUsersOnPublicStreamsEvenWithoutRole( input ) @@ -428,7 +523,7 @@ describe('AuthZ @shared', () => { stream: { visibility: ProjectRecordVisibility.Private } }, authResult: 'fake' - } + } as unknown as AuthData const result = await allowForRegisteredUsersOnPublicStreamsEvenWithoutRole( input ) @@ -441,7 +536,7 @@ describe('AuthZ @shared', () => { stream: { visibility: ProjectRecordVisibility.Public } }, authResult: 'fake' - } + } as unknown as AuthData const result = await allowForRegisteredUsersOnPublicStreamsEvenWithoutRole( input ) @@ -560,7 +655,9 @@ describe('AuthZ @shared', () => { sameContextTestData.map(([caseName, context]) => it(`${caseName} returns same context`, async () => { const result = - await allowForAllRegisteredUsersOnPublicStreamsWithPublicComments(context) + await allowForAllRegisteredUsersOnPublicStreamsWithPublicComments( + context as unknown as AuthData + ) expect(result).to.deep.equal(context) }) ) @@ -574,7 +671,7 @@ describe('AuthZ @shared', () => { } }, authResult: 'fake' - } + } as unknown as AuthData const result = await allowForAllRegisteredUsersOnPublicStreamsWithPublicComments(input) expect(result).to.deep.equal(authSuccess(input.context))