Browse Source

Maintenance: Mobile - Added selenium and frontent integration tests for the login.

Dominik Klein 2 years ago
parent
commit
4ebd9f31ce

+ 0 - 4
.storybook/config/types__react/index.d.ts

@@ -1,4 +0,0 @@
-// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
-
-// Intentionally empty to avoid loading react types from storybook which would break with our code.
-export {}

+ 6 - 37
app/frontend/apps/mobile/App.vue

@@ -1,12 +1,11 @@
 <!-- Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ -->
 
 <script setup lang="ts">
-import { onBeforeUnmount, onMounted, watch } from 'vue'
-import { useRoute, useRouter } from 'vue-router'
+import { onBeforeUnmount, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
 import CommonNotifications from '@shared/components/CommonNotifications/CommonNotifications.vue'
 import useApplicationStore from '@shared/stores/application'
 import useAuthenticationStore from '@shared/stores/authentication'
-import useSessionStore from '@shared/stores/session'
 import useMetaTitle from '@shared/composables/useMetaTitle'
 import emitter from '@shared/utils/emitter'
 import useAppMaintenanceCheck from '@shared/composables/useAppMaintenanceCheck'
@@ -14,11 +13,10 @@ import usePushMessages from '@shared/composables/usePushMessages'
 import useLocaleStore from '@shared/stores/locale'
 import useFormKitConfig from '@shared/composables/form/useFormKitConfig'
 import { useAppTheme } from '@shared/composables/useAppTheme'
+import useAuthenticationChanges from '@shared/composables/useAuthenticationUpdates'
 
 const router = useRouter()
-const route = useRoute()
 
-const session = useSessionStore()
 const authentication = useAuthenticationStore()
 
 useMetaTitle().initializeMetaTitle()
@@ -37,38 +35,9 @@ useAppMaintenanceCheck()
 usePushMessages()
 useAppTheme()
 
-// Add a watcher for authenticated changes (e.g. login/logout in a other browser tab).
-authentication.$subscribe(async (mutation, state) => {
-  if (state.authenticated && !session.id) {
-    session.checkSession().then(async (sessionId) => {
-      if (sessionId) {
-        await authentication.refreshAfterAuthentication()
-      }
-
-      if (route.name === 'Login') {
-        router.replace('/')
-      }
-    })
-  } else if (!state.authenticated && session.id) {
-    await authentication.clearAuthentication()
-    router.replace('/login')
-  }
-})
-
-watch(
-  () => application.config.maintenance_mode,
-  async (newValue, oldValue) => {
-    if (
-      !oldValue &&
-      newValue &&
-      authentication.authenticated &&
-      !session.hasPermission(['admin.maintenance', 'maintenance'])
-    ) {
-      await authentication.logout()
-      router.replace('/login')
-    }
-  },
-)
+// Add a check for authenticated changes (e.g. login/logout in a other
+// browser tab or maintenance mode switch).
+useAuthenticationChanges()
 
 // We need to trigger a manual translation update for the form related strings.
 const formConfig = useFormKitConfig()

+ 3 - 12
app/frontend/apps/mobile/main.ts

@@ -1,6 +1,6 @@
 // Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
 
-import { createApp, unref } from 'vue'
+import { createApp } from 'vue'
 import '@shared/initializer/translatableMarker'
 import App from '@mobile/App.vue'
 import useSessionStore from '@shared/stores/session'
@@ -11,12 +11,11 @@ import initializeStoreSubscriptions from '@shared/initializer/storeSubscriptions
 import initializeGlobalComponents from '@shared/initializer/globalComponents'
 import initializeRouter from '@mobile/router'
 import useApplicationStore from '@shared/stores/application'
-import { i18n } from '@shared/i18n'
 import useLocaleStore from '@shared/stores/locale'
 import useAuthenticationStore from '@shared/stores/authentication'
 import 'virtual:svg-icons-register' // eslint-disable-line import/no-unresolved
 import initializeForm from '@mobile/form'
-import { storeToRefs } from 'pinia'
+import initializeGlobalProperties from '@shared/initializer/globalProperties'
 
 export default async function mountApp(): Promise<void> {
   const app = createApp(App)
@@ -37,7 +36,6 @@ export default async function mountApp(): Promise<void> {
   await session.checkSession()
 
   const application = useApplicationStore()
-  const { config } = storeToRefs(application)
 
   const initalizeAfterSessionCheck: Array<Promise<unknown>> = [
     application.getConfig(),
@@ -55,14 +53,7 @@ export default async function mountApp(): Promise<void> {
     await locale.updateLocale()
   }
 
-  app.config.globalProperties.i18n = i18n
-  app.config.globalProperties.$t = i18n.t.bind(i18n)
-  Object.defineProperty(app.config.globalProperties, '$c', {
-    enumerable: true,
-    get: () => unref(config),
-  })
-  // eslint-disable-next-line no-underscore-dangle
-  app.config.globalProperties.__ = window.__
+  initializeGlobalProperties(app)
 
   initializeForm(app)
 

+ 44 - 0
app/frontend/apps/mobile/modules/login/__tests__/login-error-handling.spec.ts

@@ -0,0 +1,44 @@
+// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+import { LoginDocument } from '@shared/graphql/mutations/login.api'
+import { visitView } from '@tests/support/components/visitView'
+import { mockGraphQLApi } from '@tests/support/mock-graphql-api'
+
+describe('testing login error handling', () => {
+  it('check required login fields', async () => {
+    const view = await visitView('/login')
+    await view.events.click(view.getByText('Sign in'))
+    const error = view.getAllByText('This field is required.')
+
+    expect(error).toHaveLength(2)
+  })
+
+  it('check that login request error is visible', async () => {
+    mockGraphQLApi(LoginDocument).willFailWithUserError({
+      login: {
+        sessionId: null,
+        errors: [
+          {
+            message:
+              'Login failed. Have you double-checked your credentials and completed the email verification step?',
+          },
+        ],
+      },
+    })
+
+    const view = await visitView('/login')
+
+    const loginInput = view.getByPlaceholderText('Username / Email')
+    const passwordInput = view.getByPlaceholderText('Password')
+
+    await view.events.type(loginInput, 'admin@example.com')
+    await view.events.type(passwordInput, 'wrong')
+
+    await view.events.click(view.getByText('Sign in'))
+
+    expect(view.getByTestId('notification')).toBeInTheDocument()
+    expect(view.getByTestId('notification')).toHaveTextContent(
+      'Login failed. Have you double-checked your credentials and completed the email verification step?',
+    )
+  })
+})

+ 161 - 0
app/frontend/apps/mobile/modules/login/__tests__/login-maintenance.spec.ts

@@ -0,0 +1,161 @@
+// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+import useApplicationStore from '@shared/stores/application'
+import { visitView } from '@tests/support/components/visitView'
+import { mockApplicationConfig } from '@tests/support/mock-applicationConfig'
+import { mockAuthentication } from '@tests/support/mock-authentication'
+import {
+  mockGraphQLApi,
+  mockGraphQLSubscription,
+} from '@tests/support/mock-graphql-api'
+import { ConfigUpdatesDocument } from '@shared/graphql/subscriptions/configUpdates.api'
+import { LogoutDocument } from '@shared/graphql/mutations/logout.api'
+import { ApplicationConfigDocument } from '@shared/graphql/queries/applicationConfig.api'
+import { waitFor } from '@testing-library/vue'
+import useAuthenticationStore from '@shared/stores/authentication'
+import { waitForNextTick } from '@tests/support/utils'
+import { resetMockClient } from '@tests/support/mock-apollo-client'
+import { mockPermissions } from '@tests/support/mock-permissions'
+
+vi.mock('@shared/server/apollo/client', () => {
+  return {
+    clearApolloClientStore: () => {
+      return Promise.resolve()
+    },
+  }
+})
+
+describe('testing login maintenance mode', () => {
+  beforeEach(() => {
+    resetMockClient()
+  })
+
+  it('check not visible maintenance mode message, when maintenance mode is not active', async () => {
+    mockApplicationConfig({
+      maintenance_mode: false,
+    })
+
+    const view = await visitView('/login')
+
+    const maintenanceModeMessage = view.queryByText(
+      'Zammad is currently in maintenance mode. Only administrators can log in. Please wait until the maintenance window is over.',
+    )
+
+    expect(maintenanceModeMessage).not.toBeInTheDocument()
+  })
+
+  it('check for maintenance mode message', async () => {
+    mockApplicationConfig({
+      maintenance_mode: true,
+    })
+
+    const view = await visitView('/login')
+
+    const maintenanceModeMessage = view.queryByText(
+      'Zammad is currently in maintenance mode. Only administrators can log in. Please wait until the maintenance window is over.',
+    )
+
+    expect(maintenanceModeMessage).toBeInTheDocument()
+  })
+
+  it('check for maintenance mode login custom message (e.g. to announce maintenance)', async () => {
+    mockApplicationConfig({
+      maintenance_login: true,
+      maintenance_login_message: 'Custom maintenance login message.',
+    })
+
+    const view = await visitView('/login')
+
+    const maintenanceModeCustomMessage = view.queryByText(
+      'Custom maintenance login message.',
+    )
+
+    expect(maintenanceModeCustomMessage).toBeInTheDocument()
+  })
+
+  it('does not logout for admin user after maintenance mode switch', async () => {
+    mockApplicationConfig({
+      maintenance_mode: false,
+    })
+    mockAuthentication(true)
+    mockPermissions(['admin.maintenance'])
+
+    const mockSubscription = mockGraphQLSubscription(ConfigUpdatesDocument)
+
+    const application = useApplicationStore()
+    application.initializeConfigUpdateSubscription()
+
+    await visitView('/')
+
+    // Change maintenance mode to trigger the logout for non admin user.
+    mockSubscription.next({
+      data: {
+        configUpdates: {
+          setting: {
+            key: 'maintenance_mode',
+            value: true,
+          },
+        },
+      },
+    })
+
+    await waitForNextTick(true)
+
+    expect(useAuthenticationStore().authenticated).toBe(true)
+  })
+
+  it('check logout for non admin user after maintenance mode switch', async () => {
+    mockApplicationConfig({
+      maintenance_mode: false,
+    })
+    mockAuthentication(true)
+    mockPermissions(['agent'])
+
+    mockGraphQLApi(LogoutDocument).willResolve({
+      logout: {
+        success: true,
+        errors: null,
+      },
+    })
+
+    const mockSubscription = mockGraphQLSubscription(ConfigUpdatesDocument)
+
+    const application = useApplicationStore()
+    application.initializeConfigUpdateSubscription()
+
+    mockGraphQLApi(ApplicationConfigDocument).willResolve({
+      applicationConfig: [
+        {
+          key: 'maintenance_mode',
+          value: true,
+        },
+      ],
+    })
+
+    const view = await visitView('/')
+
+    // Change maintenance mode to trigger the logout for non admin user.
+    mockSubscription.next({
+      data: {
+        configUpdates: {
+          setting: {
+            key: 'maintenance_mode',
+            value: true,
+          },
+        },
+      },
+    })
+
+    await waitForNextTick(true)
+
+    expect(useAuthenticationStore().authenticated).toBe(false)
+
+    await waitFor(() => {
+      const maintenanceModeMessage = view.queryByText(
+        'Zammad is currently in maintenance mode. Only administrators can log in. Please wait until the maintenance window is over.',
+      )
+
+      expect(maintenanceModeMessage).toBeInTheDocument()
+    })
+  })
+})

+ 33 - 0
app/frontend/apps/mobile/modules/login/__tests__/login-product-branding.spec.ts

@@ -0,0 +1,33 @@
+// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+import { visitView } from '@tests/support/components/visitView'
+import { mockApplicationConfig } from '@tests/support/mock-applicationConfig'
+
+const applicationConfig = {
+  product_name: 'Zammad Example App',
+  product_logo: 'example-logo.svg',
+}
+
+describe('testing login product branding', () => {
+  beforeEach(() => {
+    mockApplicationConfig(applicationConfig)
+  })
+
+  it('check that expected product name is present', async () => {
+    const view = await visitView('/login')
+
+    expect(view.getByText(applicationConfig.product_name)).toBeInTheDocument()
+  })
+
+  it('check that expected product logo is present', async () => {
+    const view = await visitView('/login')
+
+    const logo = view.getByAltText(applicationConfig.product_name)
+
+    expect(logo).toBeInTheDocument()
+    expect(logo).toHaveAttribute(
+      'src',
+      `/assets/images/${applicationConfig.product_logo}`,
+    )
+  })
+})

+ 8 - 4
app/frontend/apps/mobile/modules/login/views/Login.vue

@@ -105,6 +105,7 @@ const login = (formData: FormData<LoginFormData>) => {
   return authentication
     .login(formData.login!, formData.password!, formData.rememberMe!)
     .then(() => {
+      // TODO: maybe we need some additional logic for the ThirtParty-Login situtation.
       const { redirect: redirectUrl } = route.query
       if (typeof redirectUrl === 'string') {
         router.replace(redirectUrl)
@@ -113,10 +114,12 @@ const login = (formData: FormData<LoginFormData>) => {
       }
     })
     .catch((errors: UserError) => {
-      notify({
-        message: errors.generalErrors[0],
-        type: NotificationTypes.Error,
-      })
+      if (errors instanceof UserError) {
+        notify({
+          message: errors.generalErrors[0],
+          type: NotificationTypes.Error,
+        })
+      }
     })
 }
 </script>
@@ -152,6 +155,7 @@ const login = (formData: FormData<LoginFormData>) => {
             ></div>
           </template>
           <Form
+            id="login"
             ref="form"
             class="text-left"
             :schema="loginScheme"

+ 1 - 5
app/frontend/shared/components/CommonLogo/CommonLogo.vue

@@ -17,9 +17,5 @@ const logoUrl = computed(() => {
 </script>
 
 <template>
-  <img
-    class="h-40 w-40"
-    :src="logoUrl"
-    :alt="(application.config.product_name as string)"
-  />
+  <img class="h-40 w-40" :src="logoUrl" :alt="($c.product_name as string)" />
 </template>

+ 7 - 6
app/frontend/shared/components/Form/Form.vue

@@ -130,10 +130,12 @@ const onSubmit = (values: FormData): Promise<void> | void => {
   // TODO: Maybe we need to handle the disabled state on submit on our own. In clarification with FormKit (https://github.com/formkit/formkit/issues/236).
   if (submitResult instanceof Promise) {
     return submitResult.catch((errors: UserError) => {
-      formNode.value?.setErrors(
-        errors.generalErrors as string[],
-        errors.getFieldErrorList(),
-      )
+      if (errors instanceof UserError) {
+        formNode.value?.setErrors(
+          errors.generalErrors as string[],
+          errors.getFieldErrorList(),
+        )
+      }
     })
   }
 
@@ -378,12 +380,11 @@ if (props.formSchemaId) {
 <template>
   <FormKit
     v-if="Object.keys(schemaData.fields).length > 0 || $slots.default"
-    :id="formId"
     type="form"
     :config="formConfig"
     :form-class="localClass"
     :actions="false"
-    :incomplete-message="false"
+    :incomplete-message="true"
     :plugins="localFormKitPlugins"
     :sections-schema="formKitSectionsSchema"
     :disabled="localDisabled"

+ 51 - 0
app/frontend/shared/composables/useAuthenticationUpdates.ts

@@ -0,0 +1,51 @@
+// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+import { watch } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import useApplicationStore from '@shared/stores/application'
+import useAuthenticationStore from '@shared/stores/authentication'
+import useSessionStore from '@shared/stores/session'
+
+// Add a watcher for authenticated changes (e.g. login/logout in a other browser tab).
+const useAuthenticationChanges = () => {
+  const session = useSessionStore()
+  const authentication = useAuthenticationStore()
+  const application = useApplicationStore()
+
+  const router = useRouter()
+  const route = useRoute()
+
+  authentication.$subscribe(async (mutation, state) => {
+    if (state.authenticated && !session.id) {
+      session.checkSession().then(async (sessionId) => {
+        if (sessionId) {
+          await authentication.refreshAfterAuthentication()
+        }
+
+        if (route.name === 'Login') {
+          router.replace('/')
+        }
+      })
+    } else if (!state.authenticated && session.id) {
+      await authentication.clearAuthentication()
+      router.replace('/login')
+    }
+  })
+
+  watch(
+    () => application.config.maintenance_mode,
+    async (newValue, oldValue) => {
+      if (
+        !oldValue &&
+        newValue &&
+        authentication.authenticated &&
+        !session.hasPermission(['admin.maintenance', 'maintenance'])
+      ) {
+        await authentication.logout()
+        router.replace('/login')
+      }
+    },
+  )
+}
+
+export default useAuthenticationChanges

Some files were not shown because too many files changed in this diff