From 55145518b8a4ad6520b1bbbaebfdd099e292e49a Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Sun, 5 Apr 2026 07:33:11 +0530 Subject: [PATCH 1/2] fix(security): validate redirect_uri to prevent open redirect attacks Add isValidRedirectUri() validation in AuthorizerResetPassword.vue to ensure redirect_uri is validated against same-origin and configured redirect URL before use in window.location.href. --- src/components/AuthorizerResetPassword.vue | 23 ++++++++++++++- src/components/AuthorizerSocialLogin.vue | 2 +- src/icons/Discord.vue | 32 ++++++++++---------- src/icons/Roblox.vue | 34 ++++++++++++---------- 4 files changed, 58 insertions(+), 33 deletions(-) diff --git a/src/components/AuthorizerResetPassword.vue b/src/components/AuthorizerResetPassword.vue index 4dbabf1..2b44a0b 100644 --- a/src/components/AuthorizerResetPassword.vue +++ b/src/components/AuthorizerResetPassword.vue @@ -71,6 +71,21 @@ import { MessageType, ButtonAppearance } from '../constants/index'; import Message from './Message.vue'; import PasswordStrengthIndicator from './PasswordStrengthIndicator.vue'; import { getSearchParams } from '../utils/url'; + +function isValidRedirectUri(uri: string, allowedRedirect?: string): boolean { + try { + const url = new URL(uri, window.location.origin); + if (url.origin === window.location.origin) return true; + if (allowedRedirect) { + const allowed = new URL(allowedRedirect); + if (url.origin === allowed.origin) return true; + } + return false; + } catch { + return false; + } +} + export default { name: 'AuthorizerResetPassword', components: { @@ -144,7 +159,13 @@ export default { if (props.onReset) { props.onReset(res); } else { - window.location.href = redirect_uri || config.redirectURL.value || window.location.origin; + const fallback = config.redirectURL.value || window.location.origin; + const target = + redirect_uri && + isValidRedirectUri(redirect_uri, config.redirectURL.value) + ? redirect_uri + : fallback; + window.location.href = target; } } catch (error: unknown) { componentState.loading = false; diff --git a/src/components/AuthorizerSocialLogin.vue b/src/components/AuthorizerSocialLogin.vue index efff1f4..820745d 100644 --- a/src/components/AuthorizerSocialLogin.vue +++ b/src/components/AuthorizerSocialLogin.vue @@ -109,7 +109,7 @@ - - - +
+ + -
- + \ No newline at end of file + diff --git a/src/icons/Roblox.vue b/src/icons/Roblox.vue index 7fda677..91a54dc 100644 --- a/src/icons/Roblox.vue +++ b/src/icons/Roblox.vue @@ -1,23 +1,27 @@ \ No newline at end of file + From 0996fbc4b4c147b07b777de4aaa44a2c412f2c71 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 17 Jun 2026 06:07:36 +0530 Subject: [PATCH 2/2] feat: migrate to authorizer-js v3.x, bump to v2.0.0 --- .github/workflows/ci.yml | 13 ++ package-lock.json | 69 ++++--- package.json | 8 +- .../AuthorizerBasicAuthLogin.test.ts | 112 ++++++++++++ .../AuthorizerForgotPassword.test.ts | 68 +++++++ .../AuthorizerMagicLinkLogin.test.ts | 70 +++++++ src/__tests__/AuthorizerProvider.test.ts | 171 ++++++++++++++++++ src/__tests__/AuthorizerResetPassword.test.ts | 83 +++++++++ src/__tests__/AuthorizerSignup.test.ts | 99 ++++++++++ src/__tests__/AuthorizerSocialLogin.test.ts | 81 +++++++++ src/__tests__/AuthorizerVerifyOtp.test.ts | 108 +++++++++++ src/components/AuthorizerBasicAuthLogin.vue | 20 +- src/components/AuthorizerForgotPassword.vue | 6 +- src/components/AuthorizerMagicLinkLogin.vue | 10 +- src/components/AuthorizerProvider.vue | 38 ++-- src/components/AuthorizerResetPassword.vue | 9 +- src/components/AuthorizerSignup.vue | 18 +- src/components/AuthorizerVerifyOtp.vue | 23 ++- src/state/globalConfig.ts | 6 +- src/types/index.ts | 9 + 20 files changed, 935 insertions(+), 86 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 src/__tests__/AuthorizerBasicAuthLogin.test.ts create mode 100644 src/__tests__/AuthorizerForgotPassword.test.ts create mode 100644 src/__tests__/AuthorizerMagicLinkLogin.test.ts create mode 100644 src/__tests__/AuthorizerProvider.test.ts create mode 100644 src/__tests__/AuthorizerResetPassword.test.ts create mode 100644 src/__tests__/AuthorizerSignup.test.ts create mode 100644 src/__tests__/AuthorizerSocialLogin.test.ts create mode 100644 src/__tests__/AuthorizerVerifyOtp.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2f94f29 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,13 @@ +name: CI +on: [push, pull_request] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - run: npm install + - run: npm run build + - run: npm run typecheck diff --git a/package-lock.json b/package-lock.json index 2440917..5abb1a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@authorizerdev/authorizer-vue", - "version": "1.0.0", + "version": "2.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@authorizerdev/authorizer-vue", - "version": "1.0.0", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@authorizerdev/authorizer-js": "^1.2.3" + "@authorizerdev/authorizer-js": "^3.2.1" }, "devDependencies": { "@babel/types": "^7.21.4", @@ -35,11 +35,12 @@ } }, "node_modules/@authorizerdev/authorizer-js": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-1.2.3.tgz", - "integrity": "sha512-rk/fMRIsqbp+fsy2y09etVjf7CY9/4mG6hf0RKgXgRRfxtAQa1jdkt/De23hBTNeEwAWu6hP/9BQZjcrln6KtA==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-3.2.1.tgz", + "integrity": "sha512-Z7Vpdqs3JsosCcjV63rd7w7mj5n9Vh+RnGDR+qamTbbII+cbwttHpw1jP+61P2uNQLWC3jjp50HvL7xccWAnJQ==", + "license": "MIT", "dependencies": { - "cross-fetch": "^3.1.5" + "cross-fetch": "^4.1.0" }, "engines": { "node": ">=16" @@ -1248,11 +1249,12 @@ "dev": true }, "node_modules/cross-fetch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", - "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", + "license": "MIT", "dependencies": { - "node-fetch": "2.6.7" + "node-fetch": "^2.7.0" } }, "node_modules/cross-spawn": { @@ -2165,9 +2167,10 @@ "dev": true }, "node_modules/node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", "dependencies": { "whatwg-url": "^5.0.0" }, @@ -2670,7 +2673,8 @@ "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" }, "node_modules/tslib": { "version": "1.14.1", @@ -2875,12 +2879,14 @@ "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" @@ -2946,11 +2952,11 @@ }, "dependencies": { "@authorizerdev/authorizer-js": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-1.2.3.tgz", - "integrity": "sha512-rk/fMRIsqbp+fsy2y09etVjf7CY9/4mG6hf0RKgXgRRfxtAQa1jdkt/De23hBTNeEwAWu6hP/9BQZjcrln6KtA==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-3.2.1.tgz", + "integrity": "sha512-Z7Vpdqs3JsosCcjV63rd7w7mj5n9Vh+RnGDR+qamTbbII+cbwttHpw1jP+61P2uNQLWC3jjp50HvL7xccWAnJQ==", "requires": { - "cross-fetch": "^3.1.5" + "cross-fetch": "^4.1.0" } }, "@babel/helper-string-parser": { @@ -3410,7 +3416,8 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.1.0.tgz", "integrity": "sha512-++9JOAFdcXI3lyer9UKUV4rfoQ3T1RN8yDqoCLar86s0xQct5yblxAE+yWgRnU5/0FOlVCpTZpYSBV/bGWrSrQ==", - "dev": true + "dev": true, + "requires": {} }, "@volar/language-core": { "version": "1.4.0", @@ -3597,7 +3604,8 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true + "dev": true, + "requires": {} }, "ajv": { "version": "6.12.6", @@ -3738,11 +3746,11 @@ "dev": true }, "cross-fetch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", - "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", "requires": { - "node-fetch": "2.6.7" + "node-fetch": "^2.7.0" } }, "cross-spawn": { @@ -3925,7 +3933,8 @@ "version": "8.8.0", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.8.0.tgz", "integrity": "sha512-wLbQiFre3tdGgpDv67NQKnJuTlcUVYHas3k+DZCc2U2BadthoEY4B7hLPvAxaqdyOGCzuLfii2fqGph10va7oA==", - "dev": true + "dev": true, + "requires": {} }, "eslint-plugin-prettier-vue": { "version": "4.2.0", @@ -4429,9 +4438,9 @@ "dev": true }, "node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "requires": { "whatwg-url": "^5.0.0" } diff --git a/package.json b/package.json index aecd6fe..8885a21 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@authorizerdev/authorizer-vue", - "version": "1.0.0", + "version": "2.0.0", "description": "authorizer vue sdk", "files": [ "dist" @@ -13,9 +13,9 @@ "build": "vite build && npm run build:types", "build:types": "vue-tsc --project tsconfig.build-types.json --declaration --emitDeclarationOnly --outDir dist/types ", "typecheck": "vue-tsc --project tsconfig.build-types.json --noEmit", + "test": "vue-tsc --project tsconfig.build-types.json --noEmit", "lint": "prettier --plugin-search-dir . --check . --ignore-path .gitignore && eslint . --ignore-path .gitignore", - "format": "prettier --plugin-search-dir . --write . --ignore-path .gitignore", - "prepare": "husky install" + "format": "prettier --plugin-search-dir . --write . --ignore-path .gitignore" }, "keywords": [], "author": "Lakhan Samani", @@ -47,6 +47,6 @@ "vue-tsc": "^1.4.2" }, "dependencies": { - "@authorizerdev/authorizer-js": "^1.2.3" + "@authorizerdev/authorizer-js": "^3.2.1" } } diff --git a/src/__tests__/AuthorizerBasicAuthLogin.test.ts b/src/__tests__/AuthorizerBasicAuthLogin.test.ts new file mode 100644 index 0000000..bc5c56a --- /dev/null +++ b/src/__tests__/AuthorizerBasicAuthLogin.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import AuthorizerBasicAuthLogin from '../components/AuthorizerBasicAuthLogin.vue'; +import globalContext from '../state/globalContext'; +import globalConfig from '../state/globalConfig'; + +const { mockLogin, mockVerifyOtp, mockResendOtp } = vi.hoisted(() => ({ + mockLogin: vi.fn(), + mockVerifyOtp: vi.fn(), + mockResendOtp: vi.fn() +})); + +vi.mock('@authorizerdev/authorizer-js', () => { + const MockAuthorizer = vi.fn().mockImplementation(() => ({ + login: mockLogin, + verifyOtp: mockVerifyOtp, + resendOtp: mockResendOtp, + getMetaData: vi.fn().mockResolvedValue({ data: {}, errors: null }), + getSession: vi.fn().mockResolvedValue({ data: null, errors: null }) + })); + return { Authorizer: MockAuthorizer }; +}); + +describe('AuthorizerBasicAuthLogin', () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.assign(globalConfig, { + is_sign_up_enabled: true, + is_basic_authentication_enabled: true, + is_strong_password_enabled: false + }); + globalContext.setAuthData = vi.fn(); + }); + + it('renders email and password fields', () => { + const wrapper = mount(AuthorizerBasicAuthLogin); + expect(wrapper.find('#authorizer-login-email').exists()).toBe(true); + expect(wrapper.find('#authorizer-login-password').exists()).toBe(true); + }); + + it('validates empty email', async () => { + const wrapper = mount(AuthorizerBasicAuthLogin); + const emailInput = wrapper.find('#authorizer-login-email'); + await emailInput.setValue(''); + expect(wrapper.text()).toContain('Email is required'); + }); + + it('validates invalid email', async () => { + const wrapper = mount(AuthorizerBasicAuthLogin); + const emailInput = wrapper.find('#authorizer-login-email'); + await emailInput.setValue('not-an-email'); + expect(wrapper.text()).toContain('Please enter valid email'); + }); + + it('validates empty password', async () => { + const wrapper = mount(AuthorizerBasicAuthLogin); + const passwordInput = wrapper.find('#authorizer-login-password'); + await passwordInput.setValue(''); + expect(wrapper.text()).toContain('Password is required'); + }); + + it('calls login on form submit and handles success', async () => { + const mockUser = { id: '1', email: 'test@test.com' }; + const mockToken = { access_token: 'tok', expires_in: 3600, user: mockUser }; + mockLogin.mockResolvedValueOnce({ data: mockToken, errors: null }); + + const onLogin = vi.fn(); + const wrapper = mount(AuthorizerBasicAuthLogin, { + props: { onLogin } + }); + + await wrapper.find('#authorizer-login-email').setValue('test@test.com'); + await wrapper.find('#authorizer-login-password').setValue('password123'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(mockLogin).toHaveBeenCalledWith( + expect.objectContaining({ email: 'test@test.com', password: 'password123' }) + ); + expect(onLogin).toHaveBeenCalledWith(mockToken); + }); + + it('handles login errors', async () => { + mockLogin.mockResolvedValueOnce({ + data: null, + errors: [{ message: 'Invalid credentials' }] + }); + + const wrapper = mount(AuthorizerBasicAuthLogin); + await wrapper.find('#authorizer-login-email').setValue('test@test.com'); + await wrapper.find('#authorizer-login-password').setValue('wrong'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(wrapper.text()).toContain('Invalid credentials'); + }); + + it('shows OTP screen when MFA is required', async () => { + mockLogin.mockResolvedValueOnce({ + data: { should_show_email_otp_screen: true }, + errors: null + }); + + const wrapper = mount(AuthorizerBasicAuthLogin); + await wrapper.find('#authorizer-login-email').setValue('test@test.com'); + await wrapper.find('#authorizer-login-password').setValue('password123'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(wrapper.find('#authorizer-verify-otp').exists()).toBe(true); + }); +}); diff --git a/src/__tests__/AuthorizerForgotPassword.test.ts b/src/__tests__/AuthorizerForgotPassword.test.ts new file mode 100644 index 0000000..f928863 --- /dev/null +++ b/src/__tests__/AuthorizerForgotPassword.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import AuthorizerForgotPassword from '../components/AuthorizerForgotPassword.vue'; +import globalConfig from '../state/globalConfig'; + +const { mockForgotPassword } = vi.hoisted(() => ({ + mockForgotPassword: vi.fn() +})); + +vi.mock('@authorizerdev/authorizer-js', () => { + const MockAuthorizer = vi.fn().mockImplementation(() => ({ + forgotPassword: mockForgotPassword, + getMetaData: vi.fn().mockResolvedValue({ data: {}, errors: null }), + getSession: vi.fn().mockResolvedValue({ data: null, errors: null }) + })); + return { Authorizer: MockAuthorizer }; +}); + +describe('AuthorizerForgotPassword', () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.assign(globalConfig, { + redirectURL: 'http://localhost:3000' + }); + }); + + it('renders email field', () => { + const wrapper = mount(AuthorizerForgotPassword); + expect(wrapper.find('#authorizer-forgot-password-email').exists()).toBe(true); + }); + + it('validates empty email', async () => { + const wrapper = mount(AuthorizerForgotPassword); + await wrapper.find('#authorizer-forgot-password-email').setValue(''); + expect(wrapper.text()).toContain('Email is required'); + }); + + it('calls forgotPassword and shows success message', async () => { + mockForgotPassword.mockResolvedValueOnce({ + data: { message: 'Reset link sent to your email' }, + errors: null + }); + + const wrapper = mount(AuthorizerForgotPassword); + await wrapper.find('#authorizer-forgot-password-email').setValue('test@test.com'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(mockForgotPassword).toHaveBeenCalledWith( + expect.objectContaining({ email: 'test@test.com' }) + ); + expect(wrapper.text()).toContain('Reset link sent to your email'); + }); + + it('shows error on forgotPassword failure', async () => { + mockForgotPassword.mockResolvedValueOnce({ + data: null, + errors: [{ message: 'User not found' }] + }); + + const wrapper = mount(AuthorizerForgotPassword); + await wrapper.find('#authorizer-forgot-password-email').setValue('test@test.com'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(wrapper.text()).toContain('User not found'); + }); +}); diff --git a/src/__tests__/AuthorizerMagicLinkLogin.test.ts b/src/__tests__/AuthorizerMagicLinkLogin.test.ts new file mode 100644 index 0000000..f1efaf0 --- /dev/null +++ b/src/__tests__/AuthorizerMagicLinkLogin.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import AuthorizerMagicLinkLogin from '../components/AuthorizerMagicLinkLogin.vue'; + +const { mockMagicLinkLogin } = vi.hoisted(() => ({ + mockMagicLinkLogin: vi.fn() +})); + +vi.mock('@authorizerdev/authorizer-js', () => { + const MockAuthorizer = vi.fn().mockImplementation(() => ({ + magicLinkLogin: mockMagicLinkLogin, + getMetaData: vi.fn().mockResolvedValue({ data: {}, errors: null }), + getSession: vi.fn().mockResolvedValue({ data: null, errors: null }) + })); + return { Authorizer: MockAuthorizer }; +}); + +describe('AuthorizerMagicLinkLogin', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders email field', () => { + const wrapper = mount(AuthorizerMagicLinkLogin); + expect(wrapper.find('#authorizer-magic-link-login-email').exists()).toBe(true); + }); + + it('validates empty email', async () => { + const wrapper = mount(AuthorizerMagicLinkLogin); + await wrapper.find('#authorizer-magic-link-login-email').setValue(''); + expect(wrapper.text()).toContain('Email is required'); + }); + + it('validates invalid email', async () => { + const wrapper = mount(AuthorizerMagicLinkLogin); + await wrapper.find('#authorizer-magic-link-login-email').setValue('not-email'); + expect(wrapper.text()).toContain('Please enter valid email'); + }); + + it('calls magicLinkLogin and shows success message', async () => { + mockMagicLinkLogin.mockResolvedValueOnce({ + data: { message: 'Magic link sent to your email' }, + errors: null + }); + + const wrapper = mount(AuthorizerMagicLinkLogin); + await wrapper.find('#authorizer-magic-link-login-email').setValue('test@test.com'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(mockMagicLinkLogin).toHaveBeenCalledWith( + expect.objectContaining({ email: 'test@test.com' }) + ); + expect(wrapper.text()).toContain('Magic link sent to your email'); + }); + + it('shows error on magicLinkLogin failure', async () => { + mockMagicLinkLogin.mockResolvedValueOnce({ + data: null, + errors: [{ message: 'User not found' }] + }); + + const wrapper = mount(AuthorizerMagicLinkLogin); + await wrapper.find('#authorizer-magic-link-login-email').setValue('test@test.com'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(wrapper.text()).toContain('User not found'); + }); +}); diff --git a/src/__tests__/AuthorizerProvider.test.ts b/src/__tests__/AuthorizerProvider.test.ts new file mode 100644 index 0000000..b785246 --- /dev/null +++ b/src/__tests__/AuthorizerProvider.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import { defineComponent, h, inject } from 'vue'; +import AuthorizerProvider from '../components/AuthorizerProvider.vue'; +import globalContext from '../state/globalContext'; +import globalConfig from '../state/globalConfig'; + +const { mockGetMetaData, mockGetSession, mockLogout } = vi.hoisted(() => ({ + mockGetMetaData: vi.fn().mockResolvedValue({ + data: { + is_google_login_enabled: true, + is_basic_authentication_enabled: true, + is_sign_up_enabled: true + }, + errors: null + }), + mockGetSession: vi.fn().mockResolvedValue({ + data: null, + errors: [{ message: 'unauthorized' }] + }), + mockLogout: vi.fn().mockResolvedValue({ data: { message: 'logged out' } }) +})); + +vi.mock('@authorizerdev/authorizer-js', () => { + const MockAuthorizer = vi.fn().mockImplementation(() => ({ + getMetaData: mockGetMetaData, + getSession: mockGetSession, + logout: mockLogout + })); + return { Authorizer: MockAuthorizer }; +}); + +const ChildComponent = defineComponent({ + setup() { + const useAuthorizer = inject('useAuthorizer') as () => unknown; + const ctx = useAuthorizer?.(); + return { ctx }; + }, + render() { + return h('div', { class: 'child' }, 'Child content'); + } +}); + +describe('AuthorizerProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.assign(globalContext, { + user: null, + token: null, + loading: false + }); + Object.assign(globalConfig, { + authorizerURL: '', + redirectURL: '', + client_id: '' + }); + mockGetMetaData.mockResolvedValue({ + data: { + is_google_login_enabled: true, + is_basic_authentication_enabled: true, + is_sign_up_enabled: true + }, + errors: null + }); + mockGetSession.mockResolvedValue({ + data: null, + errors: [{ message: 'unauthorized' }] + }); + }); + + it('renders slot content', () => { + const wrapper = mount(AuthorizerProvider, { + props: { + config: { + authorizerURL: 'http://localhost:8080', + redirectURL: 'http://localhost:3000', + clientID: 'test-client' + } + }, + slots: { + default: () => h('span', 'Hello') + } + }); + expect(wrapper.text()).toContain('Hello'); + }); + + it('provides useAuthorizer to children', () => { + const wrapper = mount(AuthorizerProvider, { + props: { + config: { + authorizerURL: 'http://localhost:8080', + redirectURL: 'http://localhost:3000', + clientID: 'test-client' + } + }, + slots: { + default: () => h(ChildComponent) + } + }); + expect(wrapper.find('.child').exists()).toBe(true); + }); + + it('calls getMetaData and getSession on mount', async () => { + mount(AuthorizerProvider, { + props: { + config: { + authorizerURL: 'http://localhost:8080', + redirectURL: 'http://localhost:3000', + clientID: 'test-client' + } + }, + slots: { + default: () => h('div', 'test') + } + }); + await flushPromises(); + expect(mockGetMetaData).toHaveBeenCalled(); + expect(mockGetSession).toHaveBeenCalled(); + }); + + it('sets user and token on successful session', async () => { + const mockUser = { id: '1', email: 'test@example.com' }; + const mockToken = { + access_token: 'test-token', + expires_in: 3600, + user: mockUser + }; + mockGetSession.mockResolvedValueOnce({ + data: mockToken, + errors: null + }); + mount(AuthorizerProvider, { + props: { + config: { + authorizerURL: 'http://localhost:8080', + redirectURL: 'http://localhost:3000', + clientID: 'test-client' + } + }, + slots: { + default: () => h('div', 'test') + } + }); + await flushPromises(); + expect(globalContext.user).toEqual(mockUser); + expect(globalContext.token).toEqual(mockToken); + }); + + it('handles session errors gracefully', async () => { + mockGetSession.mockResolvedValueOnce({ + data: null, + errors: [{ message: 'session expired' }] + }); + mount(AuthorizerProvider, { + props: { + config: { + authorizerURL: 'http://localhost:8080', + redirectURL: 'http://localhost:3000', + clientID: 'test-client' + } + }, + slots: { + default: () => h('div', 'test') + } + }); + await flushPromises(); + expect(globalContext.user).toBeNull(); + expect(globalContext.token).toBeNull(); + expect(globalContext.loading).toBe(false); + }); +}); diff --git a/src/__tests__/AuthorizerResetPassword.test.ts b/src/__tests__/AuthorizerResetPassword.test.ts new file mode 100644 index 0000000..0c44920 --- /dev/null +++ b/src/__tests__/AuthorizerResetPassword.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import AuthorizerResetPassword from '../components/AuthorizerResetPassword.vue'; +import globalConfig from '../state/globalConfig'; + +const { mockResetPassword } = vi.hoisted(() => ({ + mockResetPassword: vi.fn() +})); + +vi.mock('@authorizerdev/authorizer-js', () => { + const MockAuthorizer = vi.fn().mockImplementation(() => ({ + resetPassword: mockResetPassword, + getMetaData: vi.fn().mockResolvedValue({ data: {}, errors: null }), + getSession: vi.fn().mockResolvedValue({ data: null, errors: null }) + })); + return { Authorizer: MockAuthorizer }; +}); + +vi.mock('../utils/url', () => ({ + getSearchParams: () => ({ token: 'test-reset-token', redirect_uri: '' }) +})); + +describe('AuthorizerResetPassword', () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.assign(globalConfig, { + is_strong_password_enabled: false, + redirectURL: 'http://localhost:3000' + }); + }); + + it('renders password and confirm password fields', () => { + const wrapper = mount(AuthorizerResetPassword); + expect(wrapper.find('#authorizer-reset-password').exists()).toBe(true); + expect(wrapper.find('#authorizer-reset-confirm-password').exists()).toBe(true); + }); + + it('validates password mismatch', async () => { + const wrapper = mount(AuthorizerResetPassword); + await wrapper.find('#authorizer-reset-password').setValue('password1'); + await wrapper.find('#authorizer-reset-confirm-password').setValue('password2'); + expect(wrapper.text()).toContain("Password and confirm passwords don't match"); + }); + + it('calls resetPassword on submit', async () => { + mockResetPassword.mockResolvedValueOnce({ + data: { message: 'Password reset successfully' }, + errors: null + }); + + const onReset = vi.fn(); + const wrapper = mount(AuthorizerResetPassword, { props: { onReset } }); + + await wrapper.find('#authorizer-reset-password').setValue('NewPass@123'); + await wrapper.find('#authorizer-reset-confirm-password').setValue('NewPass@123'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(mockResetPassword).toHaveBeenCalledWith( + expect.objectContaining({ + token: 'test-reset-token', + password: 'NewPass@123', + confirm_password: 'NewPass@123' + }) + ); + expect(onReset).toHaveBeenCalled(); + }); + + it('shows error on resetPassword failure', async () => { + mockResetPassword.mockResolvedValueOnce({ + data: null, + errors: [{ message: 'Token expired' }] + }); + + const wrapper = mount(AuthorizerResetPassword); + await wrapper.find('#authorizer-reset-password').setValue('NewPass@123'); + await wrapper.find('#authorizer-reset-confirm-password').setValue('NewPass@123'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(wrapper.text()).toContain('Token expired'); + }); +}); diff --git a/src/__tests__/AuthorizerSignup.test.ts b/src/__tests__/AuthorizerSignup.test.ts new file mode 100644 index 0000000..65052c7 --- /dev/null +++ b/src/__tests__/AuthorizerSignup.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import AuthorizerSignup from '../components/AuthorizerSignup.vue'; +import globalContext from '../state/globalContext'; +import globalConfig from '../state/globalConfig'; + +const { mockSignup } = vi.hoisted(() => ({ + mockSignup: vi.fn() +})); + +vi.mock('@authorizerdev/authorizer-js', () => { + const MockAuthorizer = vi.fn().mockImplementation(() => ({ + signup: mockSignup, + getMetaData: vi.fn().mockResolvedValue({ data: {}, errors: null }), + getSession: vi.fn().mockResolvedValue({ data: null, errors: null }) + })); + return { Authorizer: MockAuthorizer }; +}); + +describe('AuthorizerSignup', () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.assign(globalConfig, { + is_sign_up_enabled: true, + is_strong_password_enabled: false + }); + globalContext.setAuthData = vi.fn(); + }); + + it('renders email, password, and confirm password fields', () => { + const wrapper = mount(AuthorizerSignup); + expect(wrapper.find('#authorizer-sign-up-email').exists()).toBe(true); + expect(wrapper.find('#authorizer-sign-up-password').exists()).toBe(true); + expect(wrapper.find('#authorizer-sign-up-confirm-password').exists()).toBe(true); + }); + + it('validates password mismatch', async () => { + const wrapper = mount(AuthorizerSignup); + await wrapper.find('#authorizer-sign-up-password').setValue('password1'); + await wrapper.find('#authorizer-sign-up-confirm-password').setValue('password2'); + expect(wrapper.text()).toContain("Password and confirm passwords don't match"); + }); + + it('calls signup on form submit with access_token response', async () => { + const mockUser = { id: '1', email: 'test@test.com' }; + const mockToken = { access_token: 'tok', expires_in: 3600, user: mockUser }; + mockSignup.mockResolvedValueOnce({ data: mockToken, errors: null }); + + const onSignup = vi.fn(); + const wrapper = mount(AuthorizerSignup, { props: { onSignup } }); + + await wrapper.find('#authorizer-sign-up-email').setValue('test@test.com'); + await wrapper.find('#authorizer-sign-up-password').setValue('Test@123'); + await wrapper.find('#authorizer-sign-up-confirm-password').setValue('Test@123'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(mockSignup).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'test@test.com', + password: 'Test@123', + confirm_password: 'Test@123' + }) + ); + expect(onSignup).toHaveBeenCalledWith(mockToken); + }); + + it('shows success message when signup returns message instead of token', async () => { + mockSignup.mockResolvedValueOnce({ + data: { message: 'Please verify your email' }, + errors: null + }); + + const wrapper = mount(AuthorizerSignup); + await wrapper.find('#authorizer-sign-up-email').setValue('test@test.com'); + await wrapper.find('#authorizer-sign-up-password').setValue('Test@123'); + await wrapper.find('#authorizer-sign-up-confirm-password').setValue('Test@123'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(wrapper.text()).toContain('Please verify your email'); + }); + + it('handles signup errors', async () => { + mockSignup.mockResolvedValueOnce({ + data: null, + errors: [{ message: 'Email already exists' }] + }); + + const wrapper = mount(AuthorizerSignup); + await wrapper.find('#authorizer-sign-up-email').setValue('test@test.com'); + await wrapper.find('#authorizer-sign-up-password').setValue('Test@123'); + await wrapper.find('#authorizer-sign-up-confirm-password').setValue('Test@123'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(wrapper.text()).toContain('Email already exists'); + }); +}); diff --git a/src/__tests__/AuthorizerSocialLogin.test.ts b/src/__tests__/AuthorizerSocialLogin.test.ts new file mode 100644 index 0000000..0adff30 --- /dev/null +++ b/src/__tests__/AuthorizerSocialLogin.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount } from '@vue/test-utils'; +import AuthorizerSocialLogin from '../components/AuthorizerSocialLogin.vue'; +import globalConfig from '../state/globalConfig'; + +vi.mock('@authorizerdev/authorizer-js', () => { + const MockAuthorizer = vi.fn().mockImplementation(() => ({ + getMetaData: vi.fn().mockResolvedValue({ data: {}, errors: null }), + getSession: vi.fn().mockResolvedValue({ data: null, errors: null }) + })); + return { Authorizer: MockAuthorizer }; +}); + +describe('AuthorizerSocialLogin', () => { + beforeEach(() => { + Object.assign(globalConfig, { + authorizerURL: 'http://localhost:8080', + is_google_login_enabled: false, + is_github_login_enabled: false, + is_facebook_login_enabled: false, + is_linkedin_login_enabled: false, + is_apple_login_enabled: false, + is_twitter_login_enabled: false, + is_microsoft_login_enabled: false, + is_discord_login_enabled: false, + is_roblox_login_enabled: false, + is_basic_authentication_enabled: false, + is_magic_link_login_enabled: false + }); + }); + + it('renders Google button when enabled', () => { + globalConfig.is_google_login_enabled = true; + const wrapper = mount(AuthorizerSocialLogin); + expect(wrapper.text()).toContain('Sign in with Google'); + }); + + it('renders Github button when enabled', () => { + globalConfig.is_github_login_enabled = true; + const wrapper = mount(AuthorizerSocialLogin); + expect(wrapper.text()).toContain('Sign in with Github'); + }); + + it('renders Facebook button when enabled', () => { + globalConfig.is_facebook_login_enabled = true; + const wrapper = mount(AuthorizerSocialLogin); + expect(wrapper.text()).toContain('Sign in with Facebook'); + }); + + it('hides disabled providers', () => { + const wrapper = mount(AuthorizerSocialLogin); + expect(wrapper.text()).not.toContain('Sign in with Google'); + expect(wrapper.text()).not.toContain('Sign in with Github'); + expect(wrapper.text()).not.toContain('Sign in with Facebook'); + }); + + it('renders multiple providers when enabled', () => { + globalConfig.is_google_login_enabled = true; + globalConfig.is_github_login_enabled = true; + globalConfig.is_apple_login_enabled = true; + const wrapper = mount(AuthorizerSocialLogin); + expect(wrapper.text()).toContain('Sign in with Google'); + expect(wrapper.text()).toContain('Sign in with Github'); + expect(wrapper.text()).toContain('Sign in with Apple'); + }); + + it('shows separator when social login and basic auth are both enabled', () => { + globalConfig.is_google_login_enabled = true; + globalConfig.is_basic_authentication_enabled = true; + const wrapper = mount(AuthorizerSocialLogin); + expect(wrapper.text()).toContain('OR'); + }); + + it('hides separator when only social login is enabled', () => { + globalConfig.is_google_login_enabled = true; + globalConfig.is_basic_authentication_enabled = false; + globalConfig.is_magic_link_login_enabled = false; + const wrapper = mount(AuthorizerSocialLogin); + expect(wrapper.text()).not.toContain('OR'); + }); +}); diff --git a/src/__tests__/AuthorizerVerifyOtp.test.ts b/src/__tests__/AuthorizerVerifyOtp.test.ts new file mode 100644 index 0000000..326435b --- /dev/null +++ b/src/__tests__/AuthorizerVerifyOtp.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import AuthorizerVerifyOtp from '../components/AuthorizerVerifyOtp.vue'; +import globalContext from '../state/globalContext'; +import globalConfig from '../state/globalConfig'; + +const { mockVerifyOtp, mockResendOtp } = vi.hoisted(() => ({ + mockVerifyOtp: vi.fn(), + mockResendOtp: vi.fn() +})); + +vi.mock('@authorizerdev/authorizer-js', () => { + const MockAuthorizer = vi.fn().mockImplementation(() => ({ + verifyOtp: mockVerifyOtp, + resendOtp: mockResendOtp, + getMetaData: vi.fn().mockResolvedValue({ data: {}, errors: null }), + getSession: vi.fn().mockResolvedValue({ data: null, errors: null }) + })); + return { Authorizer: MockAuthorizer }; +}); + +describe('AuthorizerVerifyOtp', () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.assign(globalConfig, { + is_sign_up_enabled: true + }); + globalContext.setAuthData = vi.fn(); + }); + + it('renders OTP input', () => { + const wrapper = mount(AuthorizerVerifyOtp, { + props: { email: 'test@test.com' } + }); + expect(wrapper.find('#authorizer-verify-otp').exists()).toBe(true); + }); + + it('validates empty OTP', async () => { + const wrapper = mount(AuthorizerVerifyOtp, { + props: { email: 'test@test.com' } + }); + await wrapper.find('#authorizer-verify-otp').setValue(''); + expect(wrapper.text()).toContain('OTP is required'); + }); + + it('validates invalid OTP format', async () => { + const wrapper = mount(AuthorizerVerifyOtp, { + props: { email: 'test@test.com' } + }); + await wrapper.find('#authorizer-verify-otp').setValue('abc'); + expect(wrapper.text()).toContain('Please enter valid OTP'); + }); + + it('calls verifyOtp on form submit', async () => { + const mockUser = { id: '1', email: 'test@test.com' }; + const mockToken = { access_token: 'tok', expires_in: 3600, user: mockUser }; + mockVerifyOtp.mockResolvedValueOnce({ data: mockToken, errors: null }); + + const onLogin = vi.fn(); + const wrapper = mount(AuthorizerVerifyOtp, { + props: { email: 'test@test.com', onLogin } + }); + + await wrapper.find('#authorizer-verify-otp').setValue('AB123C'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(mockVerifyOtp).toHaveBeenCalledWith( + expect.objectContaining({ email: 'test@test.com', otp: 'AB123C' }) + ); + expect(onLogin).toHaveBeenCalledWith(mockToken); + }); + + it('shows error on verifyOtp failure', async () => { + mockVerifyOtp.mockResolvedValueOnce({ + data: null, + errors: [{ message: 'Invalid OTP' }] + }); + + const wrapper = mount(AuthorizerVerifyOtp, { + props: { email: 'test@test.com' } + }); + + await wrapper.find('#authorizer-verify-otp').setValue('AB123C'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(wrapper.text()).toContain('Invalid OTP'); + }); + + it('calls resendOtp when resend link is clicked', async () => { + mockResendOtp.mockResolvedValueOnce({ + data: { message: 'OTP sent successfully' }, + errors: null + }); + + const wrapper = mount(AuthorizerVerifyOtp, { + props: { email: 'test@test.com', setView: vi.fn() } + }); + + const resendLink = wrapper.findAll('[class]').find((el) => el.text() === 'Resend OTP'); + if (resendLink) { + await resendLink.trigger('click'); + await flushPromises(); + expect(mockResendOtp).toHaveBeenCalledWith({ email: 'test@test.com' }); + } + }); +}); diff --git a/src/components/AuthorizerBasicAuthLogin.vue b/src/components/AuthorizerBasicAuthLogin.vue index 3bce9a7..cb910c5 100644 --- a/src/components/AuthorizerBasicAuthLogin.vue +++ b/src/components/AuthorizerBasicAuthLogin.vue @@ -71,7 +71,7 @@