diff --git a/docs/authentication.md b/docs/authentication.md index b10788e..cb9bbec 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -23,6 +23,5 @@ TODO: Add a description for this document and what readers can expect to learn f meta: requiresAuth: false # indicates whether the route requires authentication - requiresGuest: true # indicates whether the route is only available to guests ``` diff --git a/eng/scripts/populate-firebase.js b/eng/scripts/populate-firebase.js index 45c886a..10dcd9a 100644 --- a/eng/scripts/populate-firebase.js +++ b/eng/scripts/populate-firebase.js @@ -31,28 +31,7 @@ const firebaseAppDetailDefaults = { class Script { /** - * - * @param {string[]} args - * @returns {ScriptOptions} - */ - static getScriptOptionsFromArgs(args) { - /** @type {ScriptOptions} */ - const options = { - detailsFileName: defaultDetailsFileName, - forceEnabled: false, - } - - options.forceEnabled = args.includes('--force') - if (options.forceEnabled) - args = args.filter(arg => arg !== '--force') - if (args.length > 0 && args[0]) - options.detailsFileName = args[0] - - return options - } - - /** - * + * The main entry point for the script. * @param {string[]} args */ static async main(args) { @@ -77,6 +56,27 @@ class Script { const authProviderFilePath = path.join(process.cwd(), 'frontend/src/firebase/auth-provider.g.ts') FirebaseTools.updateAuthProviderTs(authProviderFilePath, fbDetails, options) } + + /** + * Attempts to parse the script options from the given command line arguments. + * @param {string[]} args The command line arguments. + * @returns {ScriptOptions} + */ + static getScriptOptionsFromArgs(args) { + /** @type {ScriptOptions} */ + const options = { + detailsFileName: defaultDetailsFileName, + forceEnabled: false, + } + + options.forceEnabled = args.includes('--force') + if (options.forceEnabled) + args = args.filter(arg => arg !== '--force') + if (args.length > 0 && args[0]) + options.detailsFileName = args[0] + + return options + } } class FirebaseTools { diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..34a918f --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,3 @@ +# Avoid commiting sensitive data +cypress.env.json +serviceAccount.json diff --git a/frontend/cypress.config.ts b/frontend/cypress.config.ts index de709e5..642e103 100644 --- a/frontend/cypress.config.ts +++ b/frontend/cypress.config.ts @@ -1,10 +1,14 @@ import { defineConfig } from 'cypress' +import admin from 'firebase-admin' +import { plugin as cypressFirebasePlugin } from 'cypress-firebase' export default defineConfig({ e2e: { baseUrl: 'http://localhost:3333', chromeWebSecurity: false, specPattern: 'cypress/e2e/**/*.spec.*', - supportFile: false, + setupNodeEvents(on, config) { + cypressFirebasePlugin(on, config, admin) + }, }, }) diff --git a/frontend/cypress.env.json.example b/frontend/cypress.env.json.example new file mode 100644 index 0000000..4f0218c --- /dev/null +++ b/frontend/cypress.env.json.example @@ -0,0 +1,3 @@ +{ + "TEST_UID": "0000000000000000000000000000" +} diff --git a/frontend/cypress/e2e/api-test-page.spec.ts b/frontend/cypress/e2e/api-test-page.spec.ts new file mode 100644 index 0000000..c4394a9 --- /dev/null +++ b/frontend/cypress/e2e/api-test-page.spec.ts @@ -0,0 +1,69 @@ +const Locators = { + welcomeMessageRefreshButton: '[data-test=welcome-message-refresh-button]', + welcomeMessageResult: '[data-test=welcome-message-result]', + remoteMathInput: '[data-test=remote-math-input]', + remoteMathSubmitButton: '[data-test=remote-math-submit-button]', + remoteMathResultOutput: '[data-test=remote-math-result-output]', + remoteMathResultWhen: '[data-test=remote-math-result-when]', +} + +function normalizeNumberString(str: string) { + return str.replace(/[^0-9.]/g, '') +} + +context('API Test Page', () => { + before(() => { + cy.login() + .visit('/api-test') + }) + + context('Welcome Message', () => { + it('Can refresh welcome message', () => { + cy.intercept('POST', '/*/*/getWelcomeMessage') + .as('getWelcomeMessage') + + // The refresh button for the welcome message should be visible + cy.get(Locators.welcomeMessageRefreshButton) + .should('be.visible') + + // Clicking the refresh button should trigger a request to the API + cy.get(Locators.welcomeMessageRefreshButton) + .click() + .wait('@getWelcomeMessage') + + // The result should be visible + cy.get(Locators.welcomeMessageResult) + .should('be.visible') + }) + }) + + context('Remote Math', () => { + it('Can get accurate results', () => { + const inputs = [123, 456] + + cy.intercept('POST', '/*/*/calculateSquare') + .as('calculateSquare') + + for (const input of inputs) { + cy.get(Locators.remoteMathInput) + .clear() + .type(input.toString()) + + // Clicking the submit button should trigger a request to the API + cy.get(Locators.remoteMathSubmitButton) + .click() + .wait('@calculateSquare') + + // The result should be visible + cy.get(Locators.remoteMathResultWhen) + .should('be.visible') + cy.get(Locators.remoteMathResultOutput) + .should('be.visible') + .should((output) => { + // The result should be accurate + expect(normalizeNumberString(output.text())).to.eq((input * input).toString()) + }) + } + }) + }) +}) diff --git a/frontend/cypress/e2e/basic.spec.ts b/frontend/cypress/e2e/basic.spec.ts deleted file mode 100644 index 55a838f..0000000 --- a/frontend/cypress/e2e/basic.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -context('Basic', () => { - beforeEach(() => { - cy.visit('/') - }) - - it('basic nav', () => { - cy.url() - .should('eq', 'http://localhost:3333/') - - cy.contains('[Home Layout]') - .should('exist') - - cy.get('#input') - .type('Vitesse{Enter}') - .url() - .should('eq', 'http://localhost:3333/hi/Vitesse') - - cy.contains('[Default Layout]') - .should('exist') - - cy.get('[btn]') - .click() - .url() - .should('eq', 'http://localhost:3333/') - }) - - it('markdown', () => { - cy.get('[title="About"]') - .click() - .url() - .should('eq', 'http://localhost:3333/about') - - cy.get('.shiki') - .should('exist') - }) -}) diff --git a/frontend/cypress/e2e/login.spec.ts b/frontend/cypress/e2e/login.spec.ts new file mode 100644 index 0000000..9a12d69 --- /dev/null +++ b/frontend/cypress/e2e/login.spec.ts @@ -0,0 +1,26 @@ +context('Login', () => { + it('Detects logging in', () => { + cy.logout() + .visit('/') + .url() + .should('eq', 'http://localhost:3333/') + + cy.get('[data-test=login-button]') + .should('exist') + + // Login which app should detect + cy.login() + + cy.get('[data-test=logout-button]') + .should('exist') + }) + + it('Can log out', () => { + cy.get('[data-test=logout-button]') + .should('exist') + .click() + + cy.get('[data-test=login-button]') + .should('exist') + }) +}) diff --git a/frontend/cypress/support/e2e.ts b/frontend/cypress/support/e2e.ts new file mode 100644 index 0000000..9956610 --- /dev/null +++ b/frontend/cypress/support/e2e.ts @@ -0,0 +1,12 @@ +import firebase from 'firebase/compat/app' +import 'firebase/compat/auth' +import 'firebase/compat/database' +import 'firebase/compat/firestore' +import { attachCustomCommands } from 'cypress-firebase' +import { app } from '../../src/firebase/app.g' + +firebase.initializeApp({ + ...app.options, +}) + +attachCustomCommands({ Cypress, cy, firebase }) diff --git a/frontend/package.json b/frontend/package.json index e582cb3..22398f0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -44,8 +44,10 @@ "critters": "^0.0.16", "cross-env": "^7.0.3", "cypress": "^10.9.0", + "cypress-firebase": "^2.2.2", "eslint": "^8.24.0", "eslint-plugin-cypress": "^2.12.1", + "firebase-admin": "^10.3.0", "https-localhost": "^4.7.1", "jsdom": "^20.0.1", "markdown-it-link-attributes": "^4.0.1", diff --git a/frontend/serviceAccount.json.example b/frontend/serviceAccount.json.example new file mode 100644 index 0000000..cb683b9 --- /dev/null +++ b/frontend/serviceAccount.json.example @@ -0,0 +1,12 @@ +{ + "type": "service_account", + "project_id": "", + "private_key_id": "", + "private_key": "", + "client_email": "", + "client_id": "", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "" +} diff --git a/frontend/src/components/layout/Header.vue b/frontend/src/components/layout/Header.vue index 391d3d4..6af5463 100644 --- a/frontend/src/components/layout/Header.vue +++ b/frontend/src/components/layout/Header.vue @@ -21,6 +21,7 @@ const showLanguageSelector = computed(() => availableLocales.length > 1) :title="t('button.toggle-sidebar')" class="sidebar-toggle" :class="{ flipped: sidebarCollapsed }" + data-test="sidebar-toggle-button" @click="() => toggleSidebar()" > @@ -32,7 +33,12 @@ const showLanguageSelector = computed(() => availableLocales.length > 1) - + {{ t('button.sign-out') }} diff --git a/frontend/src/components/layout/Sidebar.vue b/frontend/src/components/layout/Sidebar.vue index 77a6362..d5dbfc0 100644 --- a/frontend/src/components/layout/Sidebar.vue +++ b/frontend/src/components/layout/Sidebar.vue @@ -100,5 +100,6 @@ function findMatchingMenuToRoute(routePath: string): MenuOption | null { :collapsed-width="sidebarCollapsedWidth" :options="menuOptions" :render-label="renderMenuLabel" + data-test="sidebar" /> diff --git a/frontend/src/components/pages/ApiTestPage.vue b/frontend/src/components/pages/ApiTestPage.vue index 46a4f87..acedd2a 100644 --- a/frontend/src/components/pages/ApiTestPage.vue +++ b/frontend/src/components/pages/ApiTestPage.vue @@ -47,14 +47,14 @@ const remoteMathErrorMessage = computed(() => { {{ welcomeErrorMessage }} -

+

{{ t('pages.api-test.welcome-test.label.server-result') }} {{ welcomeMessage.state.value.message }}

@@ -72,10 +72,11 @@ const remoteMathErrorMessage = computed(() => { :placeholder="t('pages.api-test.remote-math-test.placeholder.number-input')" clearable min-w-280px + data-test="remote-math-input" /> - + {{ t('pages.api-test.remote-math-test.button.submit') }} @@ -84,8 +85,19 @@ const remoteMathErrorMessage = computed(() => { {{ t('pages.api-test.remote-math-test.label.server-result') }} - - {{ t('pages.api-test.remote-math-test.result', { output: n(mathTest.state.value.output), when: d(mathTest.state.value.date, 'long') }) }} + + + + + {{ t('pages.api-test.remote-math-test.error.missing-result') }}

diff --git a/frontend/src/modules/auth-guard.ts b/frontend/src/modules/auth-guard.ts index eaeb997..036de76 100644 --- a/frontend/src/modules/auth-guard.ts +++ b/frontend/src/modules/auth-guard.ts @@ -3,16 +3,11 @@ import { type UserModule } from '~/types' export const install: UserModule = ({ router }) => { router.beforeEach(async (to, from, next) => { const requiresAuth = to.matched.some(x => x.meta.requiresAuth) - const requiresGuest = to.matched.some(x => x.meta.requiresGuest) if (requiresAuth && !auth.currentUser) { // console.log('Redirecting to login...') next({ name: 'login' }) } - else if (requiresGuest && auth.currentUser) { - // console.log('Redirecting to home...') - next('/') - } else { next() } diff --git a/frontend/src/pages/login.vue b/frontend/src/pages/login.vue index 00111fc..63cc5fd 100644 --- a/frontend/src/pages/login.vue +++ b/frontend/src/pages/login.vue @@ -80,6 +80,7 @@ onMounted(() => { :disabled="isSigningIn" type="primary" important-w-full + data-test="login-button" @click="signIn" > {{ t('button.sign-in') }} @@ -98,6 +99,5 @@ onMounted(() => { meta: requiresAuth: false - requiresGuest: true layout: bare diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 44d8fd1..75660bb 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -36,7 +36,7 @@ export default defineConfig({ extensions: ['vue', 'md'], extendRoute(route) { // Any route that isn't explicitly set not to public will be made to require auth - const isAnonymous = route.meta && (route.meta.requiresAuth === false || route.meta.requiresGuest === true) + const isAnonymous = route.meta && (route.meta.requiresAuth === false) if (isAnonymous) return route // Augment the route with meta that indicates that the route requires authentication. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7c94a4..504a754 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,9 +53,11 @@ importers: critters: ^0.0.16 cross-env: ^7.0.3 cypress: ^10.9.0 + cypress-firebase: ^2.2.2 eslint: ^8.24.0 eslint-plugin-cypress: ^2.12.1 firebase: ^9.10.0 + firebase-admin: ^10.3.0 https-localhost: ^4.7.1 jsdom: ^20.0.1 ky: ^0.31.3 @@ -112,8 +114,10 @@ importers: critters: 0.0.16 cross-env: 7.0.3 cypress: 10.9.0 + cypress-firebase: 2.2.2_firebase-admin@10.3.0 eslint: 8.24.0 eslint-plugin-cypress: 2.12.1_eslint@8.24.0 + firebase-admin: 10.3.0 https-localhost: 4.7.1 jsdom: 20.0.1 markdown-it-link-attributes: 4.0.1 @@ -1622,6 +1626,15 @@ packages: - utf-8-validate dev: false + /@firebase/auth-interop-types/0.1.6_@firebase+util@1.6.3: + resolution: {integrity: sha512-etIi92fW3CctsmR9e3sYM3Uqnoq861M0Id9mdOPF6PWIg38BXL5k4upCNBggGUpLIS0H1grMOvy/wn1xymwe2g==} + peerDependencies: + '@firebase/app-types': 0.x + '@firebase/util': 1.x + dependencies: + '@firebase/util': 1.6.3 + dev: true + /@firebase/auth-interop-types/0.1.6_pbfwexsq7uf6mrzcwnikj3g37m: resolution: {integrity: sha512-etIi92fW3CctsmR9e3sYM3Uqnoq861M0Id9mdOPF6PWIg38BXL5k4upCNBggGUpLIS0H1grMOvy/wn1xymwe2g==} peerDependencies: @@ -1665,6 +1678,19 @@ packages: '@firebase/util': 1.6.3 tslib: 2.4.0 + /@firebase/database-compat/0.2.6: + resolution: {integrity: sha512-Ls1BAODaiDYgeJljrIgSuC7JkFIY/HNhhNYebzZSoGQU62RuvnaO3Qgp2EH6h2LzHyRnycNadfh1suROtPaUIA==} + dependencies: + '@firebase/component': 0.5.17 + '@firebase/database': 0.13.6 + '@firebase/database-types': 0.9.13 + '@firebase/logger': 0.3.3 + '@firebase/util': 1.6.3 + tslib: 2.4.0 + transitivePeerDependencies: + - '@firebase/app-types' + dev: true + /@firebase/database-compat/0.2.6_@firebase+app-types@0.7.0: resolution: {integrity: sha512-Ls1BAODaiDYgeJljrIgSuC7JkFIY/HNhhNYebzZSoGQU62RuvnaO3Qgp2EH6h2LzHyRnycNadfh1suROtPaUIA==} dependencies: @@ -1683,6 +1709,19 @@ packages: '@firebase/app-types': 0.7.0 '@firebase/util': 1.6.3 + /@firebase/database/0.13.6: + resolution: {integrity: sha512-5IZIBw2LT50Z8mwmKYmdX37p+Gg2HgeJsrruZmRyOSVgbfoY4Pg87n1uFx6qWqDmfL6HwQgwcrrQfVIXE3C5SA==} + dependencies: + '@firebase/auth-interop-types': 0.1.6_@firebase+util@1.6.3 + '@firebase/component': 0.5.17 + '@firebase/logger': 0.3.3 + '@firebase/util': 1.6.3 + faye-websocket: 0.11.4 + tslib: 2.4.0 + transitivePeerDependencies: + - '@firebase/app-types' + dev: true + /@firebase/database/0.13.6_@firebase+app-types@0.7.0: resolution: {integrity: sha512-5IZIBw2LT50Z8mwmKYmdX37p+Gg2HgeJsrruZmRyOSVgbfoY4Pg87n1uFx6qWqDmfL6HwQgwcrrQfVIXE3C5SA==} dependencies: @@ -4031,6 +4070,14 @@ packages: resolution: {integrity: sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==} dev: false + /cypress-firebase/2.2.2_firebase-admin@10.3.0: + resolution: {integrity: sha512-EfpSJ8z8WXnIM1ezbxFUk51Kt6jaFFnEZp6uOoSgb9DF58hwxJ+MoSw1rpyX8Kwuiv/14pFpDiI1U+ZvSTK5fg==} + peerDependencies: + firebase-admin: '>=8' + dependencies: + firebase-admin: 10.3.0 + dev: true + /cypress/10.9.0: resolution: {integrity: sha512-MjIWrRpc+bQM9U4kSSdATZWZ2hUqHGFEQTF7dfeZRa4MnalMtc88FIE49USWP2ZVtfy5WPBcgfBX+YorFqGElA==} engines: {node: '>=12.0.0'} @@ -5398,6 +5445,27 @@ packages: path-exists: 4.0.0 dev: true + /firebase-admin/10.3.0: + resolution: {integrity: sha512-A0wgMLEjyVyUE+heyMJYqHRkPVjpebhOYsa47RHdrTM4ltApcx8Tn86sUmjqxlfh09gNnILAm7a8q5+FmgBYpg==} + engines: {node: '>=12.7.0'} + dependencies: + '@fastify/busboy': 1.1.0 + '@firebase/database-compat': 0.2.6 + '@firebase/database-types': 0.9.13 + '@types/node': 18.8.2 + jsonwebtoken: 8.5.1 + jwks-rsa: 2.1.4 + node-forge: 1.3.1 + uuid: 8.3.2 + optionalDependencies: + '@google-cloud/firestore': 4.15.1 + '@google-cloud/storage': 5.20.5 + transitivePeerDependencies: + - '@firebase/app-types' + - encoding + - supports-color + dev: true + /firebase-admin/10.3.0_@firebase+app-types@0.7.0: resolution: {integrity: sha512-A0wgMLEjyVyUE+heyMJYqHRkPVjpebhOYsa47RHdrTM4ltApcx8Tn86sUmjqxlfh09gNnILAm7a8q5+FmgBYpg==} engines: {node: '>=12.7.0'}