diff --git a/.github/workflows/pr-api.yml b/.github/workflows/pr-api.yml index ebd45f3..7bd7dd1 100644 --- a/.github/workflows/pr-api.yml +++ b/.github/workflows/pr-api.yml @@ -3,6 +3,7 @@ on: pull_request: paths: - 'src/api/**' + - '.github/workflows/pr-api.yml' jobs: api-test: @@ -39,3 +40,7 @@ jobs: - name: Run tests working-directory: ./src/api run: dotnet test --verbosity normal + - name: Stop Docker containers + if: always() + working-directory: . + run: docker-compose --project-directory . -f env/local/docker-compose.local.yml down diff --git a/.github/workflows/pr-e2e.yml b/.github/workflows/pr-e2e.yml new file mode 100644 index 0000000..d60fbc5 --- /dev/null +++ b/.github/workflows/pr-e2e.yml @@ -0,0 +1,34 @@ +name: "End-to-end Tests" +on: + pull_request: + paths: + - 'src/api/**' + - 'src/spa/**' + - 'e2e/**' + - '.github/workflows/pr-e2e.yml' + +jobs: + e2e-test: + name: Test E2E + runs-on: ubuntu-latest + strategy: + matrix: + node-version: ['16.10.0'] + + steps: + - uses: actions/checkout@v2 + - name: Start Docker containers + working-directory: . + run: docker-compose --project-directory . -f env/local/docker-compose.local.yml -f env/e2e/docker-compose.e2e.yml up -d + - name: Install E2E dependencies + working-directory: ./e2e + run: | + npm install + npx playwright install chromium + - run: npx playwright test + name: Run E2E Tests + working-directory: ./e2e + - name: Stop Docker containers + if: always() + working-directory: . + run: docker-compose --project-directory . -f env/local/docker-compose.local.yml -f env/e2e/docker-compose.e2e.yml down diff --git a/.github/workflows/pr-spa.yml b/.github/workflows/pr-spa.yml index c54e595..431b17f 100644 --- a/.github/workflows/pr-spa.yml +++ b/.github/workflows/pr-spa.yml @@ -3,6 +3,7 @@ on: pull_request: paths: - 'src/spa/**' + - '.github/workflows/pr-spa.yml' jobs: spa-test: diff --git a/README.md b/README.md index 3e2d535..790e387 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Backend Tests](https://github.com/irby/secrets-sharing-tool/actions/workflows/pr-api.yml/badge.svg)](https://github.com/irby/secrets-sharing-tool/actions/workflows/pr-api.yml) [![Frontend Tests](https://github.com/irby/secrets-sharing-tool/actions/workflows/pr-spa.yml/badge.svg)](https://github.com/irby/secrets-sharing-tool/actions/workflows/pr-spa.yml) +[![Backend Tests](https://github.com/irby/secrets-sharing-tool/actions/workflows/pr-api.yml/badge.svg)](https://github.com/irby/secrets-sharing-tool/actions/workflows/pr-api.yml) [![Frontend Tests](https://github.com/irby/secrets-sharing-tool/actions/workflows/pr-spa.yml/badge.svg)](https://github.com/irby/secrets-sharing-tool/actions/workflows/pr-spa.yml) [![End-to-end Tests](https://github.com/irby/secrets-sharing-tool/actions/workflows/pr-e2e.yml/badge.svg)](https://github.com/irby/secrets-sharing-tool/actions/workflows/pr-e2e.yml) # Kronocrypt diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..75e854d --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +/test-results/ +/playwright-report/ +/playwright/.cache/ diff --git a/e2e/.nvmrc b/e2e/.nvmrc new file mode 100644 index 0000000..56bfee4 --- /dev/null +++ b/e2e/.nvmrc @@ -0,0 +1 @@ +v16.10.0 diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..de487e9 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,30 @@ +# End-to-end tests with Playwright + +Run `npm install` to install the necessary components. Use the Node version referenced in `.nvmrc`. + +## Prerequisites + +- Must have the API and Database Docker containers running +- Must be running the SPA on port `4200`. + +## Running end-to-end tests + +To run an end-to-end test with Playwright, use the following command: + +```bash +npx playwright test +``` + +If you want to run a trace on the tests (see screenshots of activity), run the following command: + +```bash +npx playwright test --trace on +``` + +## Viewing reports + +When a test fails, an HTML report will automatically open in your default browser. If all tests pass, you can view the HTML report using the following command: + +```bash +npx playwright show-report +``` diff --git a/e2e/environment.ts b/e2e/environment.ts new file mode 100644 index 0000000..ff2ec44 --- /dev/null +++ b/e2e/environment.ts @@ -0,0 +1,3 @@ +export const environment = { + frontendUrl: "localhost:4200" +}; diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..0d9d8d6 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,99 @@ +{ + "name": "e2e", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "e2e", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.35.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.35.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.35.1.tgz", + "integrity": "sha512-b5YoFe6J9exsMYg0pQAobNDR85T1nLumUYgUTtKm4d21iX2L7WqKq9dW8NGJ+2vX0etZd+Y7UeuqsxDXm9+5ZA==", + "dev": true, + "dependencies": { + "@types/node": "*", + "playwright-core": "1.35.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/@types/node": { + "version": "20.3.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.2.tgz", + "integrity": "sha512-vOBLVQeCQfIcF/2Y7eKFTqrMnizK5lRNQ7ykML/5RuwVXVWxYkgwS7xbt4B6fKCUPgbSL5FSsjHQpaGQP/dQmw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright-core": { + "version": "1.35.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.35.1.tgz", + "integrity": "sha512-pNXb6CQ7OqmGDRspEjlxE49w+4YtR6a3X6mT1hZXeJHWmsEz7SunmvZeiG/+y1yyMZdHnnn73WKYdtV1er0Xyg==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + } + }, + "dependencies": { + "@playwright/test": { + "version": "1.35.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.35.1.tgz", + "integrity": "sha512-b5YoFe6J9exsMYg0pQAobNDR85T1nLumUYgUTtKm4d21iX2L7WqKq9dW8NGJ+2vX0etZd+Y7UeuqsxDXm9+5ZA==", + "dev": true, + "requires": { + "@types/node": "*", + "fsevents": "2.3.2", + "playwright-core": "1.35.1" + } + }, + "@types/node": { + "version": "20.3.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.2.tgz", + "integrity": "sha512-vOBLVQeCQfIcF/2Y7eKFTqrMnizK5lRNQ7ykML/5RuwVXVWxYkgwS7xbt4B6fKCUPgbSL5FSsjHQpaGQP/dQmw==", + "dev": true + }, + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "optional": true + }, + "playwright-core": { + "version": "1.35.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.35.1.tgz", + "integrity": "sha512-pNXb6CQ7OqmGDRspEjlxE49w+4YtR6a3X6mT1hZXeJHWmsEz7SunmvZeiG/+y1yyMZdHnnn73WKYdtV1er0Xyg==", + "dev": true + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..7f156e9 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,13 @@ +{ + "name": "e2e", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": {}, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.35.1" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..fa96668 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,77 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/e2e/tests-examples/demo-todo-app.spec.ts b/e2e/tests-examples/demo-todo-app.spec.ts new file mode 100644 index 0000000..2fd6016 --- /dev/null +++ b/e2e/tests-examples/demo-todo-app.spec.ts @@ -0,0 +1,437 @@ +import { test, expect, type Page } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.goto('https://demo.playwright.dev/todomvc'); +}); + +const TODO_ITEMS = [ + 'buy some cheese', + 'feed the cat', + 'book a doctors appointment' +]; + +test.describe('New Todo', () => { + test('should allow me to add todo items', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create 1st todo. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + // Make sure the list only has one todo item. + await expect(page.getByTestId('todo-title')).toHaveText([ + TODO_ITEMS[0] + ]); + + // Create 2nd todo. + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press('Enter'); + + // Make sure the list now has two todo items. + await expect(page.getByTestId('todo-title')).toHaveText([ + TODO_ITEMS[0], + TODO_ITEMS[1] + ]); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); + + test('should clear text input field when an item is added', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create one todo item. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + // Check that input is empty. + await expect(newTodo).toBeEmpty(); + await checkNumberOfTodosInLocalStorage(page, 1); + }); + + test('should append new items to the bottom of the list', async ({ page }) => { + // Create 3 items. + await createDefaultTodos(page); + + // create a todo count locator + const todoCount = page.getByTestId('todo-count') + + // Check test using different methods. + await expect(page.getByText('3 items left')).toBeVisible(); + await expect(todoCount).toHaveText('3 items left'); + await expect(todoCount).toContainText('3'); + await expect(todoCount).toHaveText(/3/); + + // Check all items in one call. + await expect(page.getByTestId('todo-title')).toHaveText(TODO_ITEMS); + await checkNumberOfTodosInLocalStorage(page, 3); + }); +}); + +test.describe('Mark all as completed', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test.afterEach(async ({ page }) => { + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should allow me to mark all items as completed', async ({ page }) => { + // Complete all todos. + await page.getByLabel('Mark all as complete').check(); + + // Ensure all todos have 'completed' class. + await expect(page.getByTestId('todo-item')).toHaveClass(['completed', 'completed', 'completed']); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + }); + + test('should allow me to clear the complete state of all items', async ({ page }) => { + const toggleAll = page.getByLabel('Mark all as complete'); + // Check and then immediately uncheck. + await toggleAll.check(); + await toggleAll.uncheck(); + + // Should be no completed classes. + await expect(page.getByTestId('todo-item')).toHaveClass(['', '', '']); + }); + + test('complete all checkbox should update state when items are completed / cleared', async ({ page }) => { + const toggleAll = page.getByLabel('Mark all as complete'); + await toggleAll.check(); + await expect(toggleAll).toBeChecked(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Uncheck first todo. + const firstTodo = page.getByTestId('todo-item').nth(0); + await firstTodo.getByRole('checkbox').uncheck(); + + // Reuse toggleAll locator and make sure its not checked. + await expect(toggleAll).not.toBeChecked(); + + await firstTodo.getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Assert the toggle all is checked again. + await expect(toggleAll).toBeChecked(); + }); +}); + +test.describe('Item', () => { + + test('should allow me to mark items as complete', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + // Check first item. + const firstTodo = page.getByTestId('todo-item').nth(0); + await firstTodo.getByRole('checkbox').check(); + await expect(firstTodo).toHaveClass('completed'); + + // Check second item. + const secondTodo = page.getByTestId('todo-item').nth(1); + await expect(secondTodo).not.toHaveClass('completed'); + await secondTodo.getByRole('checkbox').check(); + + // Assert completed class. + await expect(firstTodo).toHaveClass('completed'); + await expect(secondTodo).toHaveClass('completed'); + }); + + test('should allow me to un-mark items as complete', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + const firstTodo = page.getByTestId('todo-item').nth(0); + const secondTodo = page.getByTestId('todo-item').nth(1); + const firstTodoCheckbox = firstTodo.getByRole('checkbox'); + + await firstTodoCheckbox.check(); + await expect(firstTodo).toHaveClass('completed'); + await expect(secondTodo).not.toHaveClass('completed'); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await firstTodoCheckbox.uncheck(); + await expect(firstTodo).not.toHaveClass('completed'); + await expect(secondTodo).not.toHaveClass('completed'); + await checkNumberOfCompletedTodosInLocalStorage(page, 0); + }); + + test('should allow me to edit an item', async ({ page }) => { + await createDefaultTodos(page); + + const todoItems = page.getByTestId('todo-item'); + const secondTodo = todoItems.nth(1); + await secondTodo.dblclick(); + await expect(secondTodo.getByRole('textbox', { name: 'Edit' })).toHaveValue(TODO_ITEMS[1]); + await secondTodo.getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await secondTodo.getByRole('textbox', { name: 'Edit' }).press('Enter'); + + // Explicitly assert the new text value. + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2] + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); +}); + +test.describe('Editing', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should hide other controls when editing', async ({ page }) => { + const todoItem = page.getByTestId('todo-item').nth(1); + await todoItem.dblclick(); + await expect(todoItem.getByRole('checkbox')).not.toBeVisible(); + await expect(todoItem.locator('label', { + hasText: TODO_ITEMS[1], + })).not.toBeVisible(); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should save edits on blur', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).dispatchEvent('blur'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2], + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); + + test('should trim entered text', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(' buy some sausages '); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2], + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); + + test('should remove the item if an empty text string was entered', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(''); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + TODO_ITEMS[2], + ]); + }); + + test('should cancel edits on escape', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Escape'); + await expect(todoItems).toHaveText(TODO_ITEMS); + }); +}); + +test.describe('Counter', () => { + test('should display the current number of todo items', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // create a todo count locator + const todoCount = page.getByTestId('todo-count') + + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + await expect(todoCount).toContainText('1'); + + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press('Enter'); + await expect(todoCount).toContainText('2'); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); +}); + +test.describe('Clear completed button', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + }); + + test('should display the correct text', async ({ page }) => { + await page.locator('.todo-list li .toggle').first().check(); + await expect(page.getByRole('button', { name: 'Clear completed' })).toBeVisible(); + }); + + test('should remove completed items when clicked', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).getByRole('checkbox').check(); + await page.getByRole('button', { name: 'Clear completed' }).click(); + await expect(todoItems).toHaveCount(2); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test('should be hidden when there are no items that are completed', async ({ page }) => { + await page.locator('.todo-list li .toggle').first().check(); + await page.getByRole('button', { name: 'Clear completed' }).click(); + await expect(page.getByRole('button', { name: 'Clear completed' })).toBeHidden(); + }); +}); + +test.describe('Persistence', () => { + test('should persist its data', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + const todoItems = page.getByTestId('todo-item'); + const firstTodoCheck = todoItems.nth(0).getByRole('checkbox'); + await firstTodoCheck.check(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(['completed', '']); + + // Ensure there is 1 completed item. + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + // Now reload. + await page.reload(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(['completed', '']); + }); +}); + +test.describe('Routing', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + // make sure the app had a chance to save updated todos in storage + // before navigating to a new view, otherwise the items can get lost :( + // in some frameworks like Durandal + await checkTodosInLocalStorage(page, TODO_ITEMS[0]); + }); + + test('should allow me to display active items', async ({ page }) => { + const todoItem = page.getByTestId('todo-item'); + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Active' }).click(); + await expect(todoItem).toHaveCount(2); + await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test('should respect the back button', async ({ page }) => { + const todoItem = page.getByTestId('todo-item'); + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await test.step('Showing all items', async () => { + await page.getByRole('link', { name: 'All' }).click(); + await expect(todoItem).toHaveCount(3); + }); + + await test.step('Showing active items', async () => { + await page.getByRole('link', { name: 'Active' }).click(); + }); + + await test.step('Showing completed items', async () => { + await page.getByRole('link', { name: 'Completed' }).click(); + }); + + await expect(todoItem).toHaveCount(1); + await page.goBack(); + await expect(todoItem).toHaveCount(2); + await page.goBack(); + await expect(todoItem).toHaveCount(3); + }); + + test('should allow me to display completed items', async ({ page }) => { + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Completed' }).click(); + await expect(page.getByTestId('todo-item')).toHaveCount(1); + }); + + test('should allow me to display all items', async ({ page }) => { + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Active' }).click(); + await page.getByRole('link', { name: 'Completed' }).click(); + await page.getByRole('link', { name: 'All' }).click(); + await expect(page.getByTestId('todo-item')).toHaveCount(3); + }); + + test('should highlight the currently applied filter', async ({ page }) => { + await expect(page.getByRole('link', { name: 'All' })).toHaveClass('selected'); + + //create locators for active and completed links + const activeLink = page.getByRole('link', { name: 'Active' }); + const completedLink = page.getByRole('link', { name: 'Completed' }); + await activeLink.click(); + + // Page change - active items. + await expect(activeLink).toHaveClass('selected'); + await completedLink.click(); + + // Page change - completed items. + await expect(completedLink).toHaveClass('selected'); + }); +}); + +async function createDefaultTodos(page: Page) { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + for (const item of TODO_ITEMS) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } +} + +async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) { + return await page.waitForFunction(e => { + return JSON.parse(localStorage['react-todos']).length === e; + }, expected); +} + +async function checkNumberOfCompletedTodosInLocalStorage(page: Page, expected: number) { + return await page.waitForFunction(e => { + return JSON.parse(localStorage['react-todos']).filter((todo: any) => todo.completed).length === e; + }, expected); +} + +async function checkTodosInLocalStorage(page: Page, title: string) { + return await page.waitForFunction(t => { + return JSON.parse(localStorage['react-todos']).map((todo: any) => todo.title).includes(t); + }, title); +} diff --git a/e2e/tests/secrets.spec.ts b/e2e/tests/secrets.spec.ts new file mode 100644 index 0000000..30a3ce7 --- /dev/null +++ b/e2e/tests/secrets.spec.ts @@ -0,0 +1,57 @@ +import { test, expect } from '@playwright/test'; +import { environment } from '../environment'; + +test('Submitting secret results in secret creation response', async ({page}) => { + await page.goto(environment.frontendUrl); + const textBox = await page.locator('//*[@id="secretText"]'); + const submitButton = await page.locator('//*[@id="submit"]'); + + await textBox.fill('Hello, world!'); + await submitButton.click(); + + const successMessage = await page.getByText('Secret received successfully. Use the link below to access the secret:'); + const secretUrl = await page.inputValue('input#secretUrl'); + + await expect(successMessage).toBeVisible(); + await expect(secretUrl).toContain(environment.frontendUrl); +}); + +test('Secret can be retrieved after it is created', async ({page}) => { + const secretMessage = "Ground Control to Major Tom"; + await page.goto(environment.frontendUrl); + const textBox = await page.locator('//*[@id="secretText"]'); + const submitButton = await page.locator('//*[@id="submit"]'); + + await textBox.fill(secretMessage); + await submitButton.click(); + const secretUrl = await page.inputValue('input#secretUrl'); + + await page.goto(secretUrl); + + const secretRetrieve = await page.inputValue('textarea#secretMessage', {timeout: 5000}); + await expect(secretRetrieve).toBe(secretMessage); +}); + +test('Accessing the secret twice results in error', async ({page}) => { + const secretMessage = "Your circuit's dead, is something wrong?"; + await page.goto(environment.frontendUrl); + const textBox = await page.locator('//*[@id="secretText"]'); + const submitButton = await page.locator('//*[@id="submit"]'); + + await textBox.fill(secretMessage); + await submitButton.click(); + const secretUrl = await page.inputValue('input#secretUrl'); + + await page.goto(secretUrl); + + const secretRetrieve1 = await page.getByText(secretMessage); + await expect(secretRetrieve1).toBeVisible(); + + await page.goto(secretUrl); + + const errorMessage = await page.getByText("Either the secret not found, has expired, or has already been recovered – or your key was invalid."); + const secretRetrieve2 = await page.getByText(secretMessage); + + await expect(errorMessage).toBeVisible(); + await expect(secretRetrieve2).not.toBeVisible(); +}); diff --git a/env/e2e/_down.sh b/env/e2e/_down.sh new file mode 100755 index 0000000..ba55e3b --- /dev/null +++ b/env/e2e/_down.sh @@ -0,0 +1,2 @@ +#!/bin/bash +docker-compose -f docker-compose.yml -f env/e2e/docker-compose.e2e.yml down diff --git a/env/e2e/_up.sh b/env/e2e/_up.sh new file mode 100755 index 0000000..5e065c4 --- /dev/null +++ b/env/e2e/_up.sh @@ -0,0 +1,2 @@ +#!/bin/bash +docker-compose -f docker-compose.yml -f env/e2e/docker-compose.e2e.yml up --build diff --git a/env/e2e/docker-compose.e2e.yml b/env/e2e/docker-compose.e2e.yml new file mode 100644 index 0000000..1480898 --- /dev/null +++ b/env/e2e/docker-compose.e2e.yml @@ -0,0 +1,11 @@ +version: "3" +services: + spa: + build: ./src/spa + ports: + - "4200:80" + networks: + - secrets-net + +networks: + secrets-net: diff --git a/src/api/SecretsSharingTool.Web/Program.cs b/src/api/SecretsSharingTool.Web/Program.cs index 3fb8b7c..9071773 100644 --- a/src/api/SecretsSharingTool.Web/Program.cs +++ b/src/api/SecretsSharingTool.Web/Program.cs @@ -32,8 +32,10 @@ options.AddPolicy(name: MyAllowSpecificOrigins, policy => { - policy.AllowAnyOrigin(); - policy.AllowAnyHeader(); + policy + .WithOrigins("http://localhost:4200", "http://spa") + .AllowAnyHeader() + .AllowAnyMethod(); }); }); diff --git a/src/spa/Dockerfile b/src/spa/Dockerfile new file mode 100644 index 0000000..80d2d11 --- /dev/null +++ b/src/spa/Dockerfile @@ -0,0 +1,10 @@ +FROM node:latest as node +WORKDIR /app +COPY . . +RUN npm install +RUN npm run build + +FROM nginx:alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=node /app/dist/spa /usr/share/nginx/html +EXPOSE 80 diff --git a/src/spa/angular.json b/src/spa/angular.json index cdb8488..8373b81 100644 --- a/src/spa/angular.json +++ b/src/spa/angular.json @@ -52,7 +52,7 @@ { "type": "anyComponentStyle", "maximumWarning": "2kb", - "maximumError": "4kb" + "maximumError": "10kb" } ], "fileReplacements": [ diff --git a/src/spa/nginx.conf b/src/spa/nginx.conf new file mode 100644 index 0000000..bc37580 --- /dev/null +++ b/src/spa/nginx.conf @@ -0,0 +1,12 @@ +server { + listen 80; + + root /usr/share/nginx/html; + index index.html; + + server_name _; + + location / { + try_files $uri /index.html; + } +} diff --git a/src/spa/src/app/create/create.component.html b/src/spa/src/app/create/create.component.html index 646068a..3ed1f15 100644 --- a/src/spa/src/app/create/create.component.html +++ b/src/spa/src/app/create/create.component.html @@ -18,7 +18,7 @@
-
diff --git a/src/spa/src/app/create/create.component.spec.ts b/src/spa/src/app/create/create.component.spec.ts index 0b8bcac..1851c16 100644 --- a/src/spa/src/app/create/create.component.spec.ts +++ b/src/spa/src/app/create/create.component.spec.ts @@ -327,5 +327,45 @@ describe('CreateComponent', () => { expect(component.expiryTimeInMinutes).toBe(100); }); - }) + }); + + describe('convertDateToString', () => { + it('calling with date returns formatted string', () => { + const dateTime = new Date("2023-07-05T05:06:03.4532984+00:00"); + const expected = "7/5/2023 05:06:03"; + const actual = component.convertDateToString(dateTime); + + expect(actual).toBe(expected); + }); + }); + + describe('padTimeUnit', () => { + it('pads if value is less than 10 (low)', () => { + const expected = "00"; + const actual = component.padTimeUnit(0); + + expect(actual).toBe(expected); + }); + + it('pads if value is less than 10 (high)', () => { + const expected = "09"; + const actual = component.padTimeUnit(9); + + expect(actual).toBe(expected); + }); + + it('does not pad if value is equal to 10', () => { + const expected = "10"; + const actual = component.padTimeUnit(10); + + expect(actual).toBe(expected); + }); + + it('does not pad if value is greater than 10', () => { + const expected = "11"; + const actual = component.padTimeUnit(11); + + expect(actual).toBe(expected); + }); + }); }); diff --git a/src/spa/src/app/create/create.component.ts b/src/spa/src/app/create/create.component.ts index 865c751..d3f7137 100644 --- a/src/spa/src/app/create/create.component.ts +++ b/src/spa/src/app/create/create.component.ts @@ -84,6 +84,8 @@ export class CreateComponent implements OnInit { const response = await axios.post(environment.apiUrl + '/api/secrets', new SecretSubmissionRequest(this.secretText.value, this.expiryTimeInMinutes)); this.secretCreationResponse = response.data; + const expiry = new Date(this.secretCreationResponse.expireDateTime); + this.expireDateTime = this.convertDateToString(expiry); } catch (error: any) { const err = error as AxiosError; @@ -141,4 +143,23 @@ export class CreateComponent implements OnInit { this.expiryTimeInMinutes = value; } + convertDateToString(date: Date): string { + const month = date.getUTCMonth()+1; + const day = date.getUTCDate(); + const year = date.getUTCFullYear(); + const hours = this.padTimeUnit(date.getUTCHours()); + const minutes = this.padTimeUnit(date.getUTCMinutes()); + const seconds = this.padTimeUnit(date.getUTCSeconds()); + return `${month}/${day}/${year} ${hours}:${minutes}:${seconds}`; + } + + padTimeUnit(value: number): string { + let result = ''; + if (value < 10) { + result += '0'; + } + result += value; + return result; + } + } diff --git a/src/spa/src/environments/environment.prod.ts b/src/spa/src/environments/environment.prod.ts index 3612073..b30249e 100644 --- a/src/spa/src/environments/environment.prod.ts +++ b/src/spa/src/environments/environment.prod.ts @@ -1,3 +1,5 @@ export const environment = { - production: true + production: true, + appUrl: "http://localhost:4200", + apiUrl: "http://localhost:5000" };