diff --git a/src/main/java/tech/jhipster/lite/generator/client/angular/security/oauth2/domain/AngularOauth2ModuleFactory.java b/src/main/java/tech/jhipster/lite/generator/client/angular/security/oauth2/domain/AngularOauth2ModuleFactory.java index d1fdd830bd1..a19e0d3e9dd 100644 --- a/src/main/java/tech/jhipster/lite/generator/client/angular/security/oauth2/domain/AngularOauth2ModuleFactory.java +++ b/src/main/java/tech/jhipster/lite/generator/client/angular/security/oauth2/domain/AngularOauth2ModuleFactory.java @@ -1,7 +1,7 @@ package tech.jhipster.lite.generator.client.angular.security.oauth2.domain; import static tech.jhipster.lite.module.domain.JHipsterModule.*; -import static tech.jhipster.lite.module.domain.npm.JHLiteNpmVersionSource.ANGULAR; +import static tech.jhipster.lite.module.domain.npm.JHLiteNpmVersionSource.*; import static tech.jhipster.lite.module.domain.replacement.ReplacementCondition.notMatchingRegex; import java.util.regex.Pattern; @@ -114,7 +114,7 @@ public JHipsterModule buildModule(JHipsterModuleProperties properties) { //@formatter:off return moduleBuilder(properties) .packageJson() - .addDependency(packageName("keycloak-js"), ANGULAR) + .addDependency(packageName("keycloak-js"), COMMON) .and() .files() .batch(SOURCE.append("auth"), APP_DESTINATION.append("auth")) diff --git a/src/main/java/tech/jhipster/lite/generator/client/vue/security/oauth2_keycloak/application/VueOAuth2KeycloakApplicationService.java b/src/main/java/tech/jhipster/lite/generator/client/vue/security/oauth2_keycloak/application/VueOAuth2KeycloakApplicationService.java new file mode 100644 index 00000000000..6d74f6ee5bc --- /dev/null +++ b/src/main/java/tech/jhipster/lite/generator/client/vue/security/oauth2_keycloak/application/VueOAuth2KeycloakApplicationService.java @@ -0,0 +1,20 @@ +package tech.jhipster.lite.generator.client.vue.security.oauth2_keycloak.application; + +import org.springframework.stereotype.Service; +import tech.jhipster.lite.generator.client.vue.security.oauth2_keycloak.domain.VueOAuth2KeycloakModulesFactory; +import tech.jhipster.lite.module.domain.JHipsterModule; +import tech.jhipster.lite.module.domain.properties.JHipsterModuleProperties; + +@Service +public class VueOAuth2KeycloakApplicationService { + + private final VueOAuth2KeycloakModulesFactory factory; + + public VueOAuth2KeycloakApplicationService() { + factory = new VueOAuth2KeycloakModulesFactory(); + } + + public JHipsterModule buildModule(JHipsterModuleProperties properties) { + return factory.buildModule(properties); + } +} diff --git a/src/main/java/tech/jhipster/lite/generator/client/vue/security/oauth2_keycloak/domain/VueOAuth2KeycloakModulesFactory.java b/src/main/java/tech/jhipster/lite/generator/client/vue/security/oauth2_keycloak/domain/VueOAuth2KeycloakModulesFactory.java new file mode 100644 index 00000000000..7f5e0237b37 --- /dev/null +++ b/src/main/java/tech/jhipster/lite/generator/client/vue/security/oauth2_keycloak/domain/VueOAuth2KeycloakModulesFactory.java @@ -0,0 +1,89 @@ +package tech.jhipster.lite.generator.client.vue.security.oauth2_keycloak.domain; + +import static tech.jhipster.lite.module.domain.JHipsterModule.*; +import static tech.jhipster.lite.module.domain.npm.JHLiteNpmVersionSource.*; + +import tech.jhipster.lite.module.domain.Indentation; +import tech.jhipster.lite.module.domain.JHipsterModule; +import tech.jhipster.lite.module.domain.file.JHipsterDestination; +import tech.jhipster.lite.module.domain.file.JHipsterSource; +import tech.jhipster.lite.module.domain.properties.JHipsterModuleProperties; +import tech.jhipster.lite.shared.error.domain.Assert; + +public class VueOAuth2KeycloakModulesFactory { + + private static final JHipsterSource SOURCE = from("client/vue"); + private static final JHipsterSource APP_SOURCE = from("client/vue/webapp/app"); + private static final JHipsterSource DOCUMENTATION_SOURCE = SOURCE.append("documentation"); + + private static final JHipsterDestination MAIN_DESTINATION = to("src/main/webapp/app"); + private static final JHipsterDestination TEST_DESTINATION = to("src/test/webapp"); + + private static final String MAIN_TS_IMPORT_NEEDLE = "// jhipster-needle-main-ts-import"; + private static final String MAIN_TS_PROVIDER_NEEDLE = "// jhipster-needle-main-ts-provider"; + + private static final String KEYCLOAK_IMPORT = + """ + import { provideForAuth } from '@/auth/application/AuthProvider'; + import { KeycloakHttp } from '@/auth/infrastructure/secondary/KeycloakHttp'; + import Keycloak from 'keycloak-js';\ + """; + private static final String KEYCLOAK_SETUP = + """ + const keycloakHttp = new KeycloakHttp( + %snew Keycloak({ + %surl: 'http://localhost:9080', + %srealm: 'jhipster', + %sclientId: 'web_app', + %s}), + ); + + provideForAuth(keycloakHttp);\ + """; + + public JHipsterModule buildModule(JHipsterModuleProperties properties) { + Assert.notNull("properties", properties); + + Indentation indentation = properties.indentation(); + + //@formatter:off + return moduleBuilder(properties) + .documentation(documentationTitle("Vue Authentication Components"), + DOCUMENTATION_SOURCE.file("vue-authentication-components.md")) + .packageJson() + .addDependency(packageName("keycloak-js"), COMMON) + .and() + .files() + .batch(APP_SOURCE.append("auth"), MAIN_DESTINATION.append("auth")) + .addTemplate("application/AuthProvider.ts") + .addTemplate("domain/AuthRepository.ts") + .addTemplate("domain/AuthenticatedUser.ts") + .addTemplate("infrastructure/secondary/KeycloakAuthRepository.ts") + .addTemplate("infrastructure/secondary/KeycloakHttp.ts") + .and() + .add(APP_SOURCE.template("test/webapp/unit/auth/application/AuthProvider.spec.ts"), TEST_DESTINATION.append("unit/auth/application/AuthProvider.spec.ts")) + .batch(APP_SOURCE.append("test/webapp/unit/auth/infrastructure/secondary"), TEST_DESTINATION.append("unit/auth/infrastructure/secondary")) + .addTemplate("KeycloakAuthRepository.spec.ts") + .addTemplate("KeycloakHttp.spec.ts") + .addTemplate("KeycloakHttpStub.ts") + .addTemplate("KeycloakStub.ts") + .and() + .and() + .mandatoryReplacements() + .in(path("src/main/webapp/app/main.ts")) + .add(lineBeforeText(MAIN_TS_IMPORT_NEEDLE), + KEYCLOAK_IMPORT + ) + .add(lineBeforeText(MAIN_TS_PROVIDER_NEEDLE), + KEYCLOAK_SETUP.formatted(indentation.spaces(), + indentation.times(2), + indentation.times(2), + indentation.times(2), + indentation.spaces()) + ) + .and() + .and() + .build(); + //@formatter:on + } +} diff --git a/src/main/java/tech/jhipster/lite/generator/client/vue/security/oauth2_keycloak/infrastructure/primary/VueOAuth2KeycloakModuleConfiguration.java b/src/main/java/tech/jhipster/lite/generator/client/vue/security/oauth2_keycloak/infrastructure/primary/VueOAuth2KeycloakModuleConfiguration.java new file mode 100644 index 00000000000..7d560157864 --- /dev/null +++ b/src/main/java/tech/jhipster/lite/generator/client/vue/security/oauth2_keycloak/infrastructure/primary/VueOAuth2KeycloakModuleConfiguration.java @@ -0,0 +1,25 @@ +package tech.jhipster.lite.generator.client.vue.security.oauth2_keycloak.infrastructure.primary; + +import static tech.jhipster.lite.generator.slug.domain.JHLiteModuleSlug.*; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import tech.jhipster.lite.generator.client.vue.security.oauth2_keycloak.application.VueOAuth2KeycloakApplicationService; +import tech.jhipster.lite.module.domain.resource.JHipsterModuleOrganization; +import tech.jhipster.lite.module.domain.resource.JHipsterModulePropertiesDefinition; +import tech.jhipster.lite.module.domain.resource.JHipsterModuleResource; + +@Configuration +class VueOAuth2KeycloakModuleConfiguration { + + @Bean + JHipsterModuleResource vueOAuth2KeycloakModule(VueOAuth2KeycloakApplicationService oauth2Keycloak) { + return JHipsterModuleResource.builder() + .slug(VUE_OAUTH2_KEYCLOAK) + .propertiesDefinition(JHipsterModulePropertiesDefinition.builder().addIndentation().build()) + .apiDoc("Vue", "Add OAuth2 Keycloak authentication to Vue") + .organization(JHipsterModuleOrganization.builder().addDependency(VUE_CORE).build()) + .tags("client", "vue", "auth", "oauth2", "keycloak") + .factory(oauth2Keycloak::buildModule); + } +} diff --git a/src/main/java/tech/jhipster/lite/generator/client/vue/security/oauth2_keycloak/package-info.java b/src/main/java/tech/jhipster/lite/generator/client/vue/security/oauth2_keycloak/package-info.java new file mode 100644 index 00000000000..cb6287251a3 --- /dev/null +++ b/src/main/java/tech/jhipster/lite/generator/client/vue/security/oauth2_keycloak/package-info.java @@ -0,0 +1,2 @@ +@tech.jhipster.lite.BusinessContext +package tech.jhipster.lite.generator.client.vue.security.oauth2_keycloak; diff --git a/src/main/java/tech/jhipster/lite/generator/slug/domain/JHLiteModuleSlug.java b/src/main/java/tech/jhipster/lite/generator/slug/domain/JHLiteModuleSlug.java index 61bc37c4962..2c77b9df411 100644 --- a/src/main/java/tech/jhipster/lite/generator/slug/domain/JHLiteModuleSlug.java +++ b/src/main/java/tech/jhipster/lite/generator/slug/domain/JHLiteModuleSlug.java @@ -150,6 +150,7 @@ public enum JHLiteModuleSlug implements JHipsterModuleSlugFactory { SVELTE_CORE("svelte-core"), TYPESCRIPT("typescript"), VUE_CORE("vue-core"), + VUE_OAUTH2_KEYCLOAK("vue-oauth2-keycloak"), VUE_PINIA("vue-pinia"), TS_PAGINATION_DOMAIN("ts-pagination-domain"), TS_REST_PAGINATION("ts-rest-pagination"); diff --git a/src/main/resources/generator/client/vue/documentation/vue-authentication-components.md b/src/main/resources/generator/client/vue/documentation/vue-authentication-components.md new file mode 100644 index 00000000000..49384df281d --- /dev/null +++ b/src/main/resources/generator/client/vue/documentation/vue-authentication-components.md @@ -0,0 +1,688 @@ +# Vue Authentication Components Documentation + +This document provides an overview and demonstrates the practical usage of the vue authentication module. + +## File Structure + +``` +src/ +├── main/ +│ └── webapp/ +│ └── app/ +│ ├── auth/ +│ │ ├── application/ +│ │ │ └── AuthRouter.ts +│ │ └── infrastructure/ +│ │ └── primary/ +│ │ ├── AuthVue.component.ts +│ │ └── AuthVue.vue +│ └── shared/ +│ └── http/ +│ └── infrastructure/ +│ └── secondary/ +│ └── AxiosAuthInterceptor.ts +├── test/ +│ └── webapp/ +│ └── unit/ +│ ├── auth/ +│ │ └── infrastructure/ +│ │ └── primary/ +│ │ └── AuthVueComponent.spec.ts +│ └── shared/ +│ └── http/ +│ └── infrastructure/ +│ └── secondary/ +│ ├── AxiosAuthInterceptor.spec.ts +│ └── AxiosStub.ts +└── router.ts +``` + +## Detailed File Explanations + +### 1. AuthRouter.ts + +Location: `src/main/webapp/app/auth/application/AuthRouter.ts` + +This file defines the route for the authentication component. + +```typescript +import type { RouteRecordRaw } from 'vue-router'; +import AuthVue from '@/auth/infrastructure/primary/AuthVue.vue'; + +export const authRoutes = (): RouteRecordRaw[] => [ + { + path: '/login', + name: 'Login', + component: AuthVue, + }, +]; +``` + +### 2. AuthVue.component.ts + +Location: `src/main/webapp/app/auth/infrastructure/primary/AuthVue.component.ts` + +This file contains the component logic for the authentication view. + +```typescript +import { defineComponent, onMounted, ref } from 'vue'; +import { inject } from '@/injections'; +import { AUTH_REPOSITORY } from '@/auth/application/AuthProvider'; +import type { AuthenticatedUser } from '@/auth/domain/AuthenticatedUser'; + +export default defineComponent({ + name: 'AuthVue', + setup() { + const authRepository = inject(AUTH_REPOSITORY); + const user = ref(null); + const isLoading = ref(true); + + onMounted(() => { + init(); + }); + + const init = () => { + isLoading.value = true; + authRepository + .authenticated() + .then(authenticated => { + if (authenticated) { + return authRepository.currentUser(); + } else { + return null; + } + }) + .then(currentUser => { + user.value = currentUser; + }) + .catch(error => { + console.error('Initialization failed:', error); + user.value = null; + }) + .finally(() => { + isLoading.value = false; + }); + }; + + const login = () => { + isLoading.value = true; + authRepository + .login() + .then(() => authRepository.currentUser()) + .then(currentUser => { + user.value = currentUser.isAuthenticated ? currentUser : null; + }) + .catch(error => { + console.error('Login failed:', error); + user.value = null; + }) + .finally(() => { + isLoading.value = false; + }); + }; + + const logout = () => { + isLoading.value = true; + authRepository + .logout() + .then(() => { + user.value = null; + }) + .catch(error => { + console.error('Logout failed:', error); + }) + .finally(() => { + isLoading.value = false; + }); + }; + + return { + user, + isLoading, + login, + logout, + }; + }, +}); +``` + +### 3. AuthVue.vue + +Location: `src/main/webapp/app/auth/infrastructure/primary/AuthVue.vue` + +This file is the template for the authentication component. + +```vue + + + +``` + +### 4. AxiosAuthInterceptor.ts + +Location: `src/main/webapp/app/shared/http/infrastructure/secondary/AxiosAuthInterceptor.ts` + +This file sets up Axios interceptors to handle authentication tokens and 401 errors. + +```typescript +import type { AxiosInstance, InternalAxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'; +import { inject } from '@/injections'; +import { AUTH_REPOSITORY } from '@/auth/application/AuthProvider'; + +export const setupAxiosInterceptors = (axios: AxiosInstance): void => { + const auths = inject(AUTH_REPOSITORY); + + axios.interceptors.request.use(async (config: InternalAxiosRequestConfig) => { + if (await auths.authenticated()) { + const token = await auths.refreshToken(); + config.headers.set('Authorization', `Bearer ${token}`); + } + return config; + }); + + axios.interceptors.response.use( + (response: AxiosResponse): AxiosResponse => response, + async (error: AxiosError): Promise => { + if (error.response && error.response.status === 401) { + await auths.logout(); + //TODO: Redirect to login page or update application state + } + return Promise.reject(error); + }, + ); +}; +``` + +### 5. router.ts + +Location: `src/main/webapp/app/router.ts` + +This file sets up the main router for the application, including authentication routes. + +```typescript +import { createRouter, createWebHistory } from 'vue-router'; +import { homeRoutes } from '@/home/application/HomeRouter'; +import { authRoutes } from '@/auth/application/AuthRouter'; + +const routes = [...homeRoutes(), ...authRoutes()]; + +const router = createRouter({ + history: createWebHistory(), + routes, +}); + +export default router; +``` + +### 6. AuthVueComponent.spec.ts + +Location: `src/test/webapp/unit/auth/infrastructure/primary/AuthVueComponent.spec.ts` + +This file contains unit tests for the AuthVue component. + +```typescript +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { flushPromises, mount, VueWrapper } from '@vue/test-utils'; +import AuthVue from '@/auth/infrastructure/primary/AuthVue.vue'; +import { AUTH_REPOSITORY } from '@/auth/application/AuthProvider'; +import { provide } from '@/injections'; +import sinon from 'sinon'; +import type { SinonStub } from 'sinon'; +import type { AuthRepository } from '@/auth/domain/AuthRepository'; + +interface MockAuthRepository extends AuthRepository { + currentUser: SinonStub; + login: SinonStub; + logout: SinonStub; + authenticated: SinonStub; + refreshToken: SinonStub; +} + +const mockAuthRepository: MockAuthRepository = { + currentUser: sinon.stub(), + login: sinon.stub(), + logout: sinon.stub(), + authenticated: sinon.stub(), + refreshToken: sinon.stub(), +}; + +const wrap = () => { + provide(AUTH_REPOSITORY, mockAuthRepository); + return mount(AuthVue); +}; + +const componentVm = (wrapper: VueWrapper) => wrapper.findComponent(AuthVue).vm; + +describe('AuthVue', () => { + let wrapper: VueWrapper; + let consoleErrorSpy: any; + + beforeEach(async () => { + mockAuthRepository.authenticated.reset(); + mockAuthRepository.currentUser.reset(); + mockAuthRepository.login.reset(); + + mockAuthRepository.authenticated.resolves(false); + mockAuthRepository.currentUser.resolves({ isAuthenticated: false, username: '', token: '' }); + + wrapper = wrap(); + await flushPromises(); + + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('should render login button when user is not authenticated', async () => { + expect(wrapper.find('button').text()).toBe('Login'); + }); + + it('should render logout button and username when user is authenticated', async () => { + mockAuthRepository.authenticated.resolves(true); + mockAuthRepository.currentUser.resolves({ isAuthenticated: true, username: 'test', token: 'token' }); + + wrapper = wrap(); + await flushPromises(); + + expect(wrapper.find('p').text()).toBe('Welcome, test!'); + expect(wrapper.find('button').text()).toBe('Logout'); + }); + + describe('Login', () => { + it('should handle failed login attempt', async () => { + mockAuthRepository.login.rejects(new Error('Login failed')); + + await wrapper.find('button').trigger('click'); + await flushPromises(); + + expect(mockAuthRepository.login.called).toBe(true); + expect(componentVm(wrapper).isLoading).toBe(false); + expect(componentVm(wrapper).user).toBeNull(); + expect(wrapper.find('button').text()).toBe('Login'); + expect(consoleErrorSpy).toHaveBeenCalledWith('Login failed:', expect.any(Error)); + }); + + it('should handle successful login attempt', async () => { + mockAuthRepository.login.resolves(); + mockAuthRepository.authenticated.resolves(true); + mockAuthRepository.currentUser.resolves({ isAuthenticated: true, username: 'test', token: 'token' }); + + await wrapper.find('button').trigger('click'); + await flushPromises(); + + expect(mockAuthRepository.login.called).toBe(true); + expect(componentVm(wrapper).isLoading).toBe(false); + expect(wrapper.find('p').text()).toBe('Welcome, test!'); + expect(wrapper.find('button').text()).toBe('Logout'); + }); + + it('should set isLoading to true during login process', async () => { + mockAuthRepository.login.resolves(); + mockAuthRepository.authenticated.resolves(true); + mockAuthRepository.currentUser.resolves({ isAuthenticated: true, username: 'test', token: 'token' }); + + const loginPromise = componentVm(wrapper).login(); + expect(componentVm(wrapper).isLoading).toBe(true); + + await loginPromise; + await flushPromises(); + + expect(componentVm(wrapper).isLoading).toBe(false); + }); + + it('should set user to null when currentUser is not authenticated after login', async () => { + mockAuthRepository.login.resolves(); + mockAuthRepository.currentUser.resolves({ isAuthenticated: false, username: '', token: '' }); + + await wrapper.find('button').trigger('click'); + await flushPromises(); + + expect(mockAuthRepository.login.called).toBe(true); + expect(componentVm(wrapper).user).toBeNull(); + expect(wrapper.find('button').text()).toBe('Login'); + }); + }); + + describe('Logout', () => { + it('should handle successful logout', async () => { + mockAuthRepository.logout.resolves(true); + mockAuthRepository.authenticated.resolves(false); + + await componentVm(wrapper).logout(); + await flushPromises(); + + expect(mockAuthRepository.logout.called).toBe(true); + expect(componentVm(wrapper).isLoading).toBe(false); + expect(componentVm(wrapper).user).toBeNull(); + expect(wrapper.find('button').text()).toBe('Login'); + }); + + it('should handle failed logout attempt', async () => { + const error = new Error('Logout failed'); + mockAuthRepository.logout.rejects(error); + + await componentVm(wrapper).logout(); + await flushPromises(); + + expect(mockAuthRepository.logout.called).toBe(true); + expect(componentVm(wrapper).isLoading).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith('Logout failed:', error); + }); + + it('should set isLoading to true during logout process', async () => { + mockAuthRepository.logout.resolves(true); + const logoutPromise = componentVm(wrapper).logout(); + expect(componentVm(wrapper).isLoading).toBe(true); + + await logoutPromise; + await flushPromises(); + + expect(componentVm(wrapper).isLoading).toBe(false); + }); + + it('should set user to null after successful logout', async () => { + mockAuthRepository.logout.resolves(true); + componentVm(wrapper).user = { isAuthenticated: true, username: 'test', token: 'token' }; + + await componentVm(wrapper).logout(); + await flushPromises(); + + expect(componentVm(wrapper).user).toBeNull(); + }); + + it('should not change user state after failed logout', async () => { + const error = new Error('Logout failed'); + mockAuthRepository.logout.rejects(error); + const initialUser = { isAuthenticated: true, username: 'test', token: 'token' }; + componentVm(wrapper).user = initialUser; + + await componentVm(wrapper).logout(); + await flushPromises(); + + expect(componentVm(wrapper).user).toEqual(initialUser); + }); + }); +}); +``` + +### 7. KeycloakStub.ts + +Location: `src/test/webapp/unit/auth/infrastructure/secondary/KeycloakStub.ts` + +This file provides a stub for Keycloak to be used in tests. + +```typescript +import Keycloak from 'keycloak-js'; +import sinon from 'sinon'; +import type { SinonStub } from 'sinon'; + +export interface KeycloakStub extends Keycloak { + init: SinonStub; + login: SinonStub; + logout: SinonStub; + register: SinonStub; + accountManagement: SinonStub; + updateToken: SinonStub; + clearToken: SinonStub; + hasRealmRole: SinonStub; + hasResourceRole: SinonStub; + loadUserProfile: SinonStub; + loadUserInfo: SinonStub; + authenticated?: boolean; + token?: string; + tokenParsed?: { preferred_username?: string }; +} + +export const stubKeycloak = (): KeycloakStub => + ({ + init: sinon.stub(), + login: sinon.stub(), + logout: sinon.stub(), + register: sinon.stub(), + accountManagement: sinon.stub(), + updateToken: sinon.stub(), + clearToken: sinon.stub(), + hasRealmRole: sinon.stub(), + hasResourceRole: sinon.stub(), + loadUserProfile: sinon.stub(), + loadUserInfo: sinon.stub(), + authenticated: false, + token: undefined, + tokenParsed: undefined, + }) as KeycloakStub; +``` + +### 8. AxiosAuthInterceptor.spec.ts + +Location: `src/test/webapp/unit/shared/http/infrastructure/secondary/AxiosAuthInterceptor.spec.ts` + +This file contains unit tests for the AxiosAuthInterceptor. + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; +import type { InternalAxiosRequestConfig, AxiosError, AxiosResponse } from 'axios'; +import { setupAxiosInterceptors } from '@/shared/http/infrastructure/secondary/AxiosAuthInterceptor'; +import { AUTH_REPOSITORY } from '@/auth/application/AuthProvider'; +import { provide } from '@/injections'; +import sinon from 'sinon'; +import type { SinonStub } from 'sinon'; +import type { AuthRepository } from '@/auth/domain/AuthRepository'; +import { stubAxiosInstance, dataAxiosResponse } from './AxiosStub'; +import type { AxiosStubInstance } from './AxiosStub'; +import { AxiosHeaders } from 'axios'; + +interface MockAuthRepository extends AuthRepository { + authenticated: SinonStub; + refreshToken: SinonStub; + logout: SinonStub; +} + +describe('AxiosAuthInterceptor', () => { + let axiosInstance: AxiosStubInstance; + let mockAuthRepository: MockAuthRepository; + + beforeEach(() => { + axiosInstance = stubAxiosInstance(); + mockAuthRepository = { + currentUser: sinon.stub(), + login: sinon.stub(), + logout: sinon.stub(), + authenticated: sinon.stub(), + refreshToken: sinon.stub(), + }; + provide(AUTH_REPOSITORY, mockAuthRepository); + }); + + const setupInterceptors = () => { + setupAxiosInterceptors(axiosInstance); + }; + + it('should add Authorization header for authenticated requests', async () => { + mockAuthRepository.authenticated.resolves(true); + mockAuthRepository.refreshToken.resolves('fake-token'); + setupInterceptors(); + const config: InternalAxiosRequestConfig = { headers: new AxiosHeaders() }; + + const interceptedConfig = await axiosInstance.runInterceptors(config); + + expect(mockAuthRepository.authenticated.called).toBe(true); + expect(mockAuthRepository.refreshToken.called).toBe(true); + expect(interceptedConfig.headers.get('Authorization')).toBe('Bearer fake-token'); + }); + + it('should not add Authorization header for unauthenticated requests', async () => { + mockAuthRepository.authenticated.resolves(false); + setupInterceptors(); + const config: InternalAxiosRequestConfig = { headers: new AxiosHeaders() }; + + const interceptedConfig = await axiosInstance.runInterceptors(config); + + expect(mockAuthRepository.authenticated.called).toBe(true); + expect(mockAuthRepository.refreshToken.called).toBe(false); + expect(interceptedConfig.headers.get('Authorization')).toBeUndefined(); + }); + + it('should call logout on 401 response', async () => { + setupInterceptors(); + const error: AxiosError = { + response: { + status: 401, + data: {}, + statusText: '', + headers: {}, + config: {} as InternalAxiosRequestConfig, + }, + isAxiosError: true, + toJSON: () => ({}), + name: '', + message: '', + }; + const responseInterceptor = axiosInstance.interceptors.response.use.args[0][1]; + + const interceptorPromise = responseInterceptor(error); + + await expect(interceptorPromise).rejects.toEqual(error); + expect(mockAuthRepository.logout.called).toBe(true); + }); + + it('should not call logout for non-401 errors', async () => { + setupInterceptors(); + const error: AxiosError = { + response: { + status: 500, + data: {}, + statusText: 'Internal Server Error', + headers: {}, + config: {} as InternalAxiosRequestConfig, + }, + isAxiosError: true, + toJSON: () => ({}), + name: 'AxiosError', + message: 'Request failed with status code 500', + }; + + const responseInterceptor = axiosInstance.interceptors.response.use.args[0][1]; + + await expect(responseInterceptor(error)).rejects.toEqual(error); + + expect(mockAuthRepository.logout.called).toBe(false); + }); + + it('should not call logout for errors without response', async () => { + setupInterceptors(); + const error: AxiosError = { + isAxiosError: true, + toJSON: () => ({}), + name: 'AxiosError', + message: 'Network Error', + }; + + const responseInterceptor = axiosInstance.interceptors.response.use.args[0][1]; + + await expect(responseInterceptor(error)).rejects.toEqual(error); + + expect(mockAuthRepository.logout.called).toBe(false); + }); + + it('should pass through successful responses without modification', async () => { + setupInterceptors(); + + const mockResponse: AxiosResponse = { + data: { message: 'Success' }, + status: 200, + statusText: 'OK', + headers: {}, + config: {} as InternalAxiosRequestConfig, + }; + + const responseInterceptor = axiosInstance.interceptors.response.use.args[0][0]; + + const result = await responseInterceptor(mockResponse); + + expect(result).toEqual(mockResponse); + }); +}); +``` + +### 9. AxiosStub.ts + +Location: `src/test/webapp/unit/shared/http/infrastructure/secondary/AxiosStub.ts` + +This file provides a stub for Axios to be used in tests. + +```typescript +import type { AxiosInstance, AxiosInterceptorManager, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; +import type { SinonStub } from 'sinon'; +import sinon from 'sinon'; + +export interface AxiosStubInterceptorManager extends AxiosInterceptorManager { + use: SinonStub; + eject: SinonStub; + clear: SinonStub; +} + +export interface AxiosStubInstance extends AxiosInstance { + get: SinonStub; + put: SinonStub; + post: SinonStub; + delete: SinonStub; + interceptors: { + request: AxiosStubInterceptorManager; + response: AxiosStubInterceptorManager; + }; + runInterceptors: (config: InternalAxiosRequestConfig) => Promise; +} + +export const stubAxiosInstance = (): AxiosStubInstance => { + const instance = { + get: sinon.stub(), + put: sinon.stub(), + post: sinon.stub(), + delete: sinon.stub(), + interceptors: { + request: { + use: sinon.stub(), + eject: sinon.stub(), + clear: sinon.stub(), + }, + response: { + use: sinon.stub(), + eject: sinon.stub(), + clear: sinon.stub(), + }, + }, + runInterceptors: async (config: InternalAxiosRequestConfig) => { + let currentConfig = { ...config, headers: config.headers || {} }; + for (const interceptor of instance.interceptors.request.use.args) { + currentConfig = await interceptor[0](currentConfig); + } + return currentConfig; + }, + } as AxiosStubInstance; + return instance; +}; + +export const dataAxiosResponse = (data: T): AxiosResponse => + ({ + data, + }) as AxiosResponse; +``` + +## Conclusion + +This documentation offers a detailed overview of the authentication components and utilities within our Vue.js application. It covers both the primary application files related to routing and authentication, as well as the test files used to validate the functionality of these components. + +At the core of the authentication system is the `AuthVue` component, which manages user login and logout processes. The `AxiosAuthInterceptor` ensures that all authenticated requests are properly equipped with the necessary authorization headers. + +The accompanying test files (`AuthVueComponent.spec.ts`, `AxiosAuthInterceptor.spec.ts`) illustrate how to effectively test these components, while the stub files (`KeycloakStub.ts`, `AxiosStub.ts`) provide mock implementations of external dependencies, enabling more streamlined and reliable testing. diff --git a/src/main/resources/generator/client/vue/webapp/app/auth/application/AuthProvider.ts.mustache b/src/main/resources/generator/client/vue/webapp/app/auth/application/AuthProvider.ts.mustache new file mode 100644 index 00000000000..f88198c5d94 --- /dev/null +++ b/src/main/resources/generator/client/vue/webapp/app/auth/application/AuthProvider.ts.mustache @@ -0,0 +1,11 @@ +import { key } from 'piqure'; +import { provide } from '@/injections'; +import type { AuthRepository } from '@/auth/domain/AuthRepository'; +import { KeycloakAuthRepository } from '@/auth/infrastructure/secondary/KeycloakAuthRepository'; +import { KeycloakHttp } from '@/auth/infrastructure/secondary/KeycloakHttp'; + +export const AUTH_REPOSITORY = key('AuthRepository'); + +export const provideForAuth = (keycloakHttp: KeycloakHttp): void => { + provide(AUTH_REPOSITORY, new KeycloakAuthRepository(keycloakHttp)); +}; diff --git a/src/main/resources/generator/client/vue/webapp/app/auth/domain/AuthRepository.ts.mustache b/src/main/resources/generator/client/vue/webapp/app/auth/domain/AuthRepository.ts.mustache new file mode 100644 index 00000000000..3d90dac92e5 --- /dev/null +++ b/src/main/resources/generator/client/vue/webapp/app/auth/domain/AuthRepository.ts.mustache @@ -0,0 +1,9 @@ +import type { AuthenticatedUser } from '@/auth/domain/AuthenticatedUser'; + +export interface AuthRepository { + currentUser(): Promise; + login(): Promise; + logout(): Promise; + authenticated(): Promise; + refreshToken(): Promise; +} diff --git a/src/main/resources/generator/client/vue/webapp/app/auth/domain/AuthenticatedUser.ts.mustache b/src/main/resources/generator/client/vue/webapp/app/auth/domain/AuthenticatedUser.ts.mustache new file mode 100644 index 00000000000..beacd8c6fe3 --- /dev/null +++ b/src/main/resources/generator/client/vue/webapp/app/auth/domain/AuthenticatedUser.ts.mustache @@ -0,0 +1,8 @@ +type AuthenticatedUserName = string; +type AuthenticatedUserToken = string; + +export type AuthenticatedUser = { + isAuthenticated: boolean; + username: AuthenticatedUserName; + token: AuthenticatedUserToken; +} diff --git a/src/main/resources/generator/client/vue/webapp/app/auth/infrastructure/secondary/KeycloakAuthRepository.ts.mustache b/src/main/resources/generator/client/vue/webapp/app/auth/infrastructure/secondary/KeycloakAuthRepository.ts.mustache new file mode 100644 index 00000000000..6d9613b07dc --- /dev/null +++ b/src/main/resources/generator/client/vue/webapp/app/auth/infrastructure/secondary/KeycloakAuthRepository.ts.mustache @@ -0,0 +1,27 @@ +import type { AuthRepository } from '@/auth/domain/AuthRepository'; +import type { AuthenticatedUser } from '@/auth/domain/AuthenticatedUser'; +import { KeycloakHttp } from './KeycloakHttp'; + +export class KeycloakAuthRepository implements AuthRepository { + constructor(private readonly keycloakHttp: KeycloakHttp) {} + + currentUser(): Promise { + return this.keycloakHttp.currentUser(); + } + + login(): Promise { + return this.keycloakHttp.login(); + } + + logout(): Promise { + return this.keycloakHttp.logout(); + } + + authenticated(): Promise { + return this.keycloakHttp.authenticated(); + } + + refreshToken(): Promise { + return this.keycloakHttp.refreshToken(); + } +} diff --git a/src/main/resources/generator/client/vue/webapp/app/auth/infrastructure/secondary/KeycloakHttp.ts.mustache b/src/main/resources/generator/client/vue/webapp/app/auth/infrastructure/secondary/KeycloakHttp.ts.mustache new file mode 100644 index 00000000000..86f984b771c --- /dev/null +++ b/src/main/resources/generator/client/vue/webapp/app/auth/infrastructure/secondary/KeycloakHttp.ts.mustache @@ -0,0 +1,48 @@ +import Keycloak from 'keycloak-js'; +import type { AuthenticatedUser } from '@/auth/domain/AuthenticatedUser'; + +export class KeycloakHttp { + private initialized: boolean = false; + + constructor(private readonly keycloak: Keycloak) {} + + private async ensureInitialized(): Promise { + if (!this.initialized) { + await this.keycloak.init({ onLoad: 'check-sso', checkLoginIframe: false }); + this.initialized = true; + } + } + + async currentUser(): Promise { + await this.ensureInitialized(); + if (this.keycloak.authenticated) { + return { + isAuthenticated: true, + username: this.keycloak.tokenParsed?.preferred_username ?? '', + token: this.keycloak.token ?? '', + }; + } + return { isAuthenticated: false, username: '', token: '' }; + } + + async login(): Promise { + await this.ensureInitialized(); + return this.keycloak.login(); + } + + async logout(): Promise { + await this.ensureInitialized(); + return this.keycloak.logout(); + } + + async authenticated(): Promise { + await this.ensureInitialized(); + return !!this.keycloak.token; + } + + async refreshToken(): Promise { + await this.ensureInitialized(); + await this.keycloak.updateToken(5); + return this.keycloak.token ?? ''; + } +} diff --git a/src/main/resources/generator/client/vue/webapp/app/test/webapp/unit/auth/application/AuthProvider.spec.ts.mustache b/src/main/resources/generator/client/vue/webapp/app/test/webapp/unit/auth/application/AuthProvider.spec.ts.mustache new file mode 100644 index 00000000000..ec85b22f3a4 --- /dev/null +++ b/src/main/resources/generator/client/vue/webapp/app/test/webapp/unit/auth/application/AuthProvider.spec.ts.mustache @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import { AUTH_REPOSITORY, provideForAuth } from '@/auth/application/AuthProvider'; +import { KeycloakAuthRepository } from '@/auth/infrastructure/secondary/KeycloakAuthRepository'; +import { inject } from '@/injections'; +import { stubKeycloakHttp } from '../infrastructure/secondary/KeycloakHttpStub'; + +describe('AuthProvider', () => { + + it('should define AUTH_REPOSITORY with the correct key', () => { + expect(AUTH_REPOSITORY.description).toBe('AuthRepository'); + }); + + it('should provide KeycloakAuthRepository with KeycloakHttp', async () => { + const keycloakHttp = stubKeycloakHttp(); + provideForAuth(keycloakHttp); + + const injectedRepository = inject(AUTH_REPOSITORY); + expect(injectedRepository).toBeInstanceOf(KeycloakAuthRepository); + }); +}); diff --git a/src/main/resources/generator/client/vue/webapp/app/test/webapp/unit/auth/infrastructure/secondary/KeycloakAuthRepository.spec.ts.mustache b/src/main/resources/generator/client/vue/webapp/app/test/webapp/unit/auth/infrastructure/secondary/KeycloakAuthRepository.spec.ts.mustache new file mode 100644 index 00000000000..467de2d46d5 --- /dev/null +++ b/src/main/resources/generator/client/vue/webapp/app/test/webapp/unit/auth/infrastructure/secondary/KeycloakAuthRepository.spec.ts.mustache @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { KeycloakAuthRepository } from '@/auth/infrastructure/secondary/KeycloakAuthRepository'; +import type { KeycloakHttpStub } from './KeycloakHttpStub'; +import { stubKeycloakHttp, fakeAuthenticatedUser } from './KeycloakHttpStub'; + +describe('KeycloakAuthRepository', () => { + let keycloakHttpStub: KeycloakHttpStub; + let authRepository: KeycloakAuthRepository; + + beforeEach(() => { + keycloakHttpStub = stubKeycloakHttp(); + authRepository = new KeycloakAuthRepository(keycloakHttpStub); + }); + + it('should authenticate a user', async () => { + const mockUser = fakeAuthenticatedUser(); + keycloakHttpStub.currentUser.resolves(mockUser); + + const user = await authRepository.currentUser(); + + expect(user).toEqual(mockUser); + expect(keycloakHttpStub.currentUser.calledOnce).toBe(true); + }); + + it('should propagate error when currentUser fails', async () => { + const error = new Error('Authentication failed'); + keycloakHttpStub.currentUser.rejects(error); + + await expect(authRepository.currentUser()).rejects.toThrow('Authentication failed'); + expect(keycloakHttpStub.currentUser.calledOnce).toBe(true); + }); + + it('should login a user successfully', async () => { + keycloakHttpStub.login.resolves(); + + await authRepository.login(); + + expect(keycloakHttpStub.login.calledOnce).toBe(true); + }); + + it('should propagate error when login fails', async () => { + const error = new Error('Login failed'); + keycloakHttpStub.login.rejects(error); + + await expect(authRepository.login()).rejects.toThrow('Login failed'); + expect(keycloakHttpStub.login.calledOnce).toBe(true); + }); + + it('should logout a user', async () => { + keycloakHttpStub.logout.resolves(); + + await authRepository.logout(); + + expect(keycloakHttpStub.logout.calledOnce).toBe(true); + }); + + it('should propagate error when logout fails', async () => { + const error = new Error('Logout failed'); + keycloakHttpStub.logout.rejects(error); + + await expect(authRepository.logout()).rejects.toThrow('Logout failed'); + expect(keycloakHttpStub.logout.calledOnce).toBe(true); + }); + + it('should check if a user is authenticated', async () => { + keycloakHttpStub.authenticated.resolves(true); + + const isAuthenticated = await authRepository.authenticated(); + + expect(isAuthenticated).toBe(true); + expect(keycloakHttpStub.authenticated.calledOnce).toBe(true); + }); + + it('should propagate error when authenticated check fails', async () => { + const error = new Error('Authentication check failed'); + keycloakHttpStub.authenticated.rejects(error); + + await expect(authRepository.authenticated()).rejects.toThrow('Authentication check failed'); + expect(keycloakHttpStub.authenticated.calledOnce).toBe(true); + }); + + it('should refresh the token', async () => { + const newToken = 'new-test-token'; + keycloakHttpStub.refreshToken.resolves(newToken); + + const refreshedToken = await authRepository.refreshToken(); + + expect(refreshedToken).toBe(newToken); + expect(keycloakHttpStub.refreshToken.calledOnce).toBe(true); + }); + + it('should propagate error when refreshToken fails', async () => { + const error = new Error('Token refresh failed'); + keycloakHttpStub.refreshToken.rejects(error); + + await expect(authRepository.refreshToken()).rejects.toThrow('Token refresh failed'); + expect(keycloakHttpStub.refreshToken.calledOnce).toBe(true); + }); +}); diff --git a/src/main/resources/generator/client/vue/webapp/app/test/webapp/unit/auth/infrastructure/secondary/KeycloakHttp.spec.ts.mustache b/src/main/resources/generator/client/vue/webapp/app/test/webapp/unit/auth/infrastructure/secondary/KeycloakHttp.spec.ts.mustache new file mode 100644 index 00000000000..cd53a3c4c5e --- /dev/null +++ b/src/main/resources/generator/client/vue/webapp/app/test/webapp/unit/auth/infrastructure/secondary/KeycloakHttp.spec.ts.mustache @@ -0,0 +1,139 @@ +import { describe, it, expect } from 'vitest'; +import { KeycloakHttp } from '@/auth/infrastructure/secondary/KeycloakHttp'; +import { stubKeycloak } from './KeycloakStub'; +import { fakeAuthenticatedUser } from './KeycloakHttpStub'; + +const createKeycloakHttp = () => { + const keycloakStub = stubKeycloak(); + const keycloakHttp = new KeycloakHttp(keycloakStub); + return { keycloakStub, keycloakHttp }; +}; + +describe('KeycloakHttp', () => { + describe('Authentication', () => { + it('should authenticate successfully', async () => { + const { keycloakStub, keycloakHttp } = createKeycloakHttp(); + const fakeUser = fakeAuthenticatedUser(); + keycloakStub.init.resolves(true); + keycloakStub.authenticated = true; + keycloakStub.tokenParsed = { preferred_username: fakeUser.username }; + keycloakStub.token = fakeUser.token; + + const result = await keycloakHttp.currentUser(); + + expect(result).toEqual(fakeUser); + expect(keycloakStub.init.calledOnce).toBe(true); + }); + + it('should handle authentication failure', async () => { + const { keycloakStub, keycloakHttp } = createKeycloakHttp(); + keycloakStub.init.resolves(false); + + const result = await keycloakHttp.currentUser(); + + expect(result).toEqual({ isAuthenticated: false, username: '', token: '' }); + expect(keycloakStub.init.calledOnce).toBe(true); + }); + }); + + describe('Initialization', () => { + it('should not reinitialize if already initialized', async () => { + const { keycloakStub, keycloakHttp } = createKeycloakHttp(); + keycloakStub.init.resolves(true); + + await keycloakHttp.currentUser(); + await keycloakHttp.currentUser(); + + expect(keycloakStub.init.calledOnce).toBe(true); + }); + + it('should initialize only once across different method calls', async () => { + const { keycloakStub, keycloakHttp } = createKeycloakHttp(); + keycloakStub.init.resolves(true); + + await keycloakHttp.currentUser(); + await keycloakHttp.login(); + await keycloakHttp.logout(); + await keycloakHttp.authenticated(); + await keycloakHttp.refreshToken(); + + expect(keycloakStub.init.calledOnce).toBe(true); + }); + }); + + it('should handle undefined preferred_username', async () => { + const { keycloakStub, keycloakHttp } = createKeycloakHttp(); + keycloakStub.init.resolves(true); + keycloakStub.authenticated = true; + keycloakStub.tokenParsed = {}; + keycloakStub.token = 'test-token'; + + const result = await keycloakHttp.currentUser(); + + expect(result).toEqual({ + isAuthenticated: true, + username: '', + token: 'test-token' + }); + }); + + it('should handle undefined token', async () => { + const { keycloakStub, keycloakHttp } = createKeycloakHttp(); + keycloakStub.init.resolves(true); + keycloakStub.authenticated = true; + keycloakStub.tokenParsed = { preferred_username: 'test' }; + keycloakStub.token = undefined; + + const result = await keycloakHttp.currentUser(); + + expect(result).toEqual({ + isAuthenticated: true, + username: 'test', + token: '' + }); + }); + + it('should logout', async () => { + const { keycloakStub, keycloakHttp } = createKeycloakHttp(); + keycloakStub.logout.resolves(); + + await keycloakHttp.logout(); + + expect(keycloakStub.logout.calledOnce).toBe(true); + }); + + it('should check if authenticated', async () => { + const { keycloakStub, keycloakHttp } = createKeycloakHttp(); + keycloakStub.init.resolves(true); + keycloakStub.authenticated = true; + keycloakStub.token = 'valid-token'; + + const result = await keycloakHttp.authenticated(); + + expect(result).toBe(true); + expect(keycloakStub.init.calledOnce).toBe(true); + }); + + it('should refresh token', async () => { + const { keycloakStub, keycloakHttp } = createKeycloakHttp(); + const newToken = 'new-test-token'; + keycloakStub.updateToken.resolves(); + keycloakStub.token = newToken; + + const result = await keycloakHttp.refreshToken(); + + expect(result).toBe(newToken); + expect(keycloakStub.updateToken.calledOnce).toBe(true); + }); +}); + +it('should login successfully', async () => { + const { keycloakStub, keycloakHttp } = createKeycloakHttp(); + keycloakStub.init.resolves(true); + keycloakStub.login.resolves(); + + await keycloakHttp.login(); + + expect(keycloakStub.init.calledOnce).toBe(true); + expect(keycloakStub.login.calledOnce).toBe(true); +}); diff --git a/src/main/resources/generator/client/vue/webapp/app/test/webapp/unit/auth/infrastructure/secondary/KeycloakHttpStub.ts.mustache b/src/main/resources/generator/client/vue/webapp/app/test/webapp/unit/auth/infrastructure/secondary/KeycloakHttpStub.ts.mustache new file mode 100644 index 00000000000..62ac11884ca --- /dev/null +++ b/src/main/resources/generator/client/vue/webapp/app/test/webapp/unit/auth/infrastructure/secondary/KeycloakHttpStub.ts.mustache @@ -0,0 +1,28 @@ +import sinon from 'sinon'; +import type { SinonStub } from 'sinon'; +import type { KeycloakHttp } from '@/auth/infrastructure/secondary/KeycloakHttp'; +import type { AuthenticatedUser } from '@/auth/domain/AuthenticatedUser'; + +export interface KeycloakHttpStub extends KeycloakHttp { +currentUser: SinonStub; +login: SinonStub; +logout: SinonStub; +authenticated: SinonStub; +refreshToken: SinonStub; +getKeycloakInstance: SinonStub; +} + +export const stubKeycloakHttp = (): KeycloakHttpStub => ({ +currentUser: sinon.stub(), +login: sinon.stub(), +logout: sinon.stub(), +authenticated: sinon.stub(), +refreshToken: sinon.stub(), +getKeycloakInstance: sinon.stub(), +}) as KeycloakHttpStub; + +export const fakeAuthenticatedUser = (): AuthenticatedUser => ({ +isAuthenticated: true, +username: 'testuser', +token: 'test-token' +}); diff --git a/src/main/resources/generator/client/vue/webapp/app/test/webapp/unit/auth/infrastructure/secondary/KeycloakStub.ts.mustache b/src/main/resources/generator/client/vue/webapp/app/test/webapp/unit/auth/infrastructure/secondary/KeycloakStub.ts.mustache new file mode 100644 index 00000000000..9938638c071 --- /dev/null +++ b/src/main/resources/generator/client/vue/webapp/app/test/webapp/unit/auth/infrastructure/secondary/KeycloakStub.ts.mustache @@ -0,0 +1,37 @@ +import Keycloak from 'keycloak-js'; +import sinon from 'sinon'; +import type{ SinonStub } from 'sinon'; + +export interface KeycloakStub extends Keycloak { + init: SinonStub; + login: SinonStub; + logout: SinonStub; + register: SinonStub; + accountManagement: SinonStub; + updateToken: SinonStub; + clearToken: SinonStub; + hasRealmRole: SinonStub; + hasResourceRole: SinonStub; + loadUserProfile: SinonStub; + loadUserInfo: SinonStub; + authenticated?: boolean; + token?: string; + tokenParsed?: { preferred_username?: string }; +} + +export const stubKeycloak = (): KeycloakStub => ({ + init: sinon.stub(), + login: sinon.stub(), + logout: sinon.stub(), + register: sinon.stub(), + accountManagement: sinon.stub(), + updateToken: sinon.stub(), + clearToken: sinon.stub(), + hasRealmRole: sinon.stub(), + hasResourceRole: sinon.stub(), + loadUserProfile: sinon.stub(), + loadUserInfo: sinon.stub(), + authenticated: false, + token: undefined, + tokenParsed: undefined, +}) as KeycloakStub; diff --git a/src/main/resources/generator/dependencies/angular/package.json b/src/main/resources/generator/dependencies/angular/package.json index baceeef66fd..a05ffcf5a62 100644 --- a/src/main/resources/generator/dependencies/angular/package.json +++ b/src/main/resources/generator/dependencies/angular/package.json @@ -4,7 +4,6 @@ "@angular/core": "18.2.5", "@angular/material": "18.2.5", "angular-eslint": "18.3.1", - "keycloak-js": "25.0.6", "rxjs": "7.8.1", "tslib": "2.7.0", "zone.js": "0.14.10" diff --git a/src/main/resources/generator/dependencies/common/package.json b/src/main/resources/generator/dependencies/common/package.json index 8ef810c6703..298b2567e17 100644 --- a/src/main/resources/generator/dependencies/common/package.json +++ b/src/main/resources/generator/dependencies/common/package.json @@ -6,7 +6,8 @@ "dependencies": { "i18next": "23.15.1", "i18next-browser-languagedetector": "8.0.0", - "i18next-http-backend": "2.6.1" + "i18next-http-backend": "2.6.1", + "keycloak-js": "25.0.5" }, "devDependencies": { "@babel/cli": "7.25.6", diff --git a/src/test/features/client/vue-oauth2-keycloak.feature b/src/test/features/client/vue-oauth2-keycloak.feature new file mode 100644 index 00000000000..2923c500aeb --- /dev/null +++ b/src/test/features/client/vue-oauth2-keycloak.feature @@ -0,0 +1,11 @@ +Feature: Vue oauth2 keycloak module + + Scenario: Should apply Vue OAuth2 Keycloak module + When I apply modules to default project + | init | + | prettier | + | typescript | + | vue-core | + | vue-oauth2-keycloak | + Then I should have files in "src/main/webapp/app/auth/domain" + | AuthRepository.ts | diff --git a/src/test/java/tech/jhipster/lite/generator/client/vue/security/oauth2_keycloak/domain/VueOAuth2KeycloakModulesFactoryTest.java b/src/test/java/tech/jhipster/lite/generator/client/vue/security/oauth2_keycloak/domain/VueOAuth2KeycloakModulesFactoryTest.java new file mode 100644 index 00000000000..410ee00f1c8 --- /dev/null +++ b/src/test/java/tech/jhipster/lite/generator/client/vue/security/oauth2_keycloak/domain/VueOAuth2KeycloakModulesFactoryTest.java @@ -0,0 +1,70 @@ +package tech.jhipster.lite.generator.client.vue.security.oauth2_keycloak.domain; + +import static tech.jhipster.lite.module.infrastructure.secondary.JHipsterModulesAssertions.*; + +import org.junit.jupiter.api.Test; +import tech.jhipster.lite.TestFileUtils; +import tech.jhipster.lite.UnitTest; +import tech.jhipster.lite.module.domain.JHipsterModule; +import tech.jhipster.lite.module.domain.JHipsterModulesFixture; +import tech.jhipster.lite.module.domain.properties.JHipsterModuleProperties; + +@UnitTest +class VueOAuth2KeycloakModulesFactoryTest { + + private static final VueOAuth2KeycloakModulesFactory factory = new VueOAuth2KeycloakModulesFactory(); + + @Test + void shouldBuildVueOAuth2KeycloakModule() { + JHipsterModuleProperties properties = JHipsterModulesFixture.propertiesBuilder(TestFileUtils.tmpDirForTest()) + .projectBaseName("jhipster") + .basePackage("tech.jhipster.jhlitest") + .build(); + + JHipsterModule module = factory.buildModule(properties); + + //@formatter:off + assertThatModuleWithFiles(module, packageJsonFile(), mainFile()) + .hasFiles("documentation/vue-authentication-components.md") + .hasFile("package.json") + .containing(nodeDependency("keycloak-js")) + .and() + .hasFiles("src/main/webapp/app/auth/application/AuthProvider.ts") + .hasFiles("src/main/webapp/app/auth/domain/AuthRepository.ts") + .hasFiles("src/main/webapp/app/auth/domain/AuthenticatedUser.ts") + .hasFiles("src/main/webapp/app/auth/infrastructure/secondary/KeycloakAuthRepository.ts") + .hasFiles("src/main/webapp/app/auth/infrastructure/secondary/KeycloakHttp.ts") + .hasFile("src/main/webapp/app/main.ts") + .containing(""" + import { provideForAuth } from '@/auth/application/AuthProvider'; + import { KeycloakHttp } from '@/auth/infrastructure/secondary/KeycloakHttp'; + import Keycloak from 'keycloak-js'; + // jhipster-needle-main-ts-import\ + """ + ) + .containing(""" + const keycloakHttp = new KeycloakHttp( + new Keycloak({ + url: 'http://localhost:9080', + realm: 'jhipster', + clientId: 'web_app', + }), + ); + + provideForAuth(keycloakHttp); + // jhipster-needle-main-ts-provider\ + """ + ) + .and() + .hasFiles("src/test/webapp/unit/auth/application/AuthProvider.spec.ts") + .hasFiles("src/test/webapp/unit/auth/infrastructure/secondary/KeycloakAuthRepository.spec.ts") + .hasFiles("src/test/webapp/unit/auth/infrastructure/secondary/KeycloakHttp.spec.ts") + .hasFiles("src/test/webapp/unit/auth/infrastructure/secondary/KeycloakHttpStub.ts") + .hasFiles("src/test/webapp/unit/auth/infrastructure/secondary/KeycloakStub.ts"); + //@formatter:on + } + + private static ModuleFile mainFile() { + return file("src/test/resources/projects/vue/main.ts.template", "src/main/webapp/app/main.ts"); + } +} diff --git a/src/test/resources/projects/vue/main.ts.template b/src/test/resources/projects/vue/main.ts.template index 0f5ea875b13..1ab21867124 100644 --- a/src/test/resources/projects/vue/main.ts.template +++ b/src/test/resources/projects/vue/main.ts.template @@ -1,7 +1,9 @@ import { createApp } from 'vue'; -import App from './common/primary/app/App.vue'; +import AppVue from './AppVue.vue'; +import router from './router'; // jhipster-needle-main-ts-import -const app = createApp(App); +const app = createApp(AppVue); +app.use(router); // jhipster-needle-main-ts-provider app.mount('#app'); diff --git a/tests-ci/generate.sh b/tests-ci/generate.sh index cc8a31acdb9..a251c441456 100755 --- a/tests-ci/generate.sh +++ b/tests-ci/generate.sh @@ -421,6 +421,7 @@ elif [[ $application == 'vueapp' ]]; then "prettier" \ "vue-core" \ "vue-pinia" \ + "vue-oauth2-keycloak" \ "playwright-component-tests" \ "cypress-e2e"