# Automated Headless Browser Testing ### Global Jest Setup 可以在進行所有測試前對 mongoose 做最基本的 setup。 Setup ```javascript // test/setup // 設定每個 test 的逾時秒數,超過該時現限者會被判定為失敗 jest.setTimeout(30000) require('../models/User') const mongoose = require('mongoose') const keys = require('../config/keys') mongoose.Promise = global.Promise mongoose.connect(keys.mongoURI, { useMongoClient: true }) ``` package.json ```json { "jest": { "setupTestFrameworkScriptFile": "./test/setup.js" } } ``` ### Session Signatures 簽章 sig 的存在主要是避免使用者隨意篡改 session 的資料,若有惡意使用者在不知道 sig 是透過什麼 secret 被 hash 出來的情況下竄改 session,他終究會在審核身份的 middleware 被阻擋。 ``` set-cookie:session=SESSION; path=/; expires=Fri 23, July 2021 21:40:35 GTM; httponly set-cookie:session.sig=SESSION_SIGNATURE; path=/; expires=Fri 23, July 2021 21:40:35 GTM; httponly ``` Session + Cookie Signing Key = Session Signature ```javascript const Keygrip = require('keygrip') // 考量安全性,cookieSigningKey 通常會被放在專案的 secret 避免被駭 const cookieSigningKey = 'this is a secret' const keygrip = new Keygrip([cookieSigningKey]) const session = 'SESSION' const sessionSignature = keygrip.sign('session=' + session) const maliciousSessionSignature = 'surprise motherf*cker' // 判斷是否為合法使用者 keygrip.verify('session=' + session, sessionSignature) // true keygrip.verify('session=' + session, maliciousSessionSignature) // false ``` ### Factory Functions User Factory - 利用 mongoose 製造假的使用者 ```javascript // test/factories/userFactory const mongoose = require('mongoose') // 由於 test setup 已處理過 mongo 連線且載入 User model 所以不會報錯 const User = mongoose.model('User') module.export = () => new User({}).save() ``` Session Factory - 利用假的使用者製造假的 sig 和 session ```javascript // test/factories/sessionFactory const Buffer = require('safe-buffer').Buffer const Keygrip = require('keygrip') const kes = require('../../config/keys') const keygrip = new Keygrip([keys.cookieKey]) module.export = (user) => { const sessionObject = { passport: { user: user._id.toString() } } const session = Buffer .from(JSON.stringify(sessionObject)) .toString('base64') const sig = keygrip.sign('session=' + session) return { session, sig } } ``` ### Helper Functions 若想**避免劫持造成第三方套件的 `Class` 被污染**,可以嘗試使用 `Class` 並回傳一個 `Proxy` 決定讀取 property 的優先級。BTW,原型鏈其實也可以達到相近的效果,不過最大的差別是用 Proxy 你可以**自訂義物件的優先級**,即使沒上下關係的物件也可以被列入參考;反之,原型鏈的話必須順著繼承的順序尋找 property。 Page ```javascript // test/helpers/page const puppeteer = require('puppeteer') const userFactory = require('../factories/userFactory') const sessionFactory = require('../factories/sessionFactory') class CustomPage { static async build () { const browser = await puppeteer.launch({ headless: false }) const page = await browser.newPage() const customPage = new CustomPage(page) return new Proxy(customPage, { get: function (target, property) { return customPage[property] || browser[property] || page[property] } }) } constructor (page) { this.page = page } login () { const user = await userFactory() const { session, sig } = sessionFactory(user) await this.page.setCookie({ name: 'session', value: session }) await this.page.setCookie({ name: 'session.sig', value: sig }) await this.page.goto('localhost:3000/blogs') // 等指定 selector 的 element 生成後才繼續執行 await this.page.waitFor('a[href="/auth/logout"]') } async getContentsOf (selector) { return this.page.$eval(selector, el => el.innerHTML) } get (path) { return this.page.evaluate( _path => fetch({ path: _path, method: 'GET', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' } }) .then(res => res.json()), path ) } post (path, data) { return this.page.evaluate( (_path, _data) => fetch({ path: _path, method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(_data) }) .then(res => res.json()), path, data ) } execRequests (actions) { return Promise.all( actions.map(({ method, path, data }) => this[method](path, data)) ) } } module.export = CustomPage ``` ### Practice Header Test ```javascript // test/helpers/header.test const Page = require('./helpers/page') let page beforeEach(async () => { page = await Page.build() await page.goto('localhost:3000') }) afterEach(async () => { await page.close() }) test('the header has the correct text', async () => { const text = await page.getContentsOf('a.brand-logo') expect(text).toEqual('Blogster') }) test('clicking login starts oauth flow', async () => { await page.click('.right a') const url = await page.url() expect(url).toMatch(/accounts\.google\.com/) }) test('When signed in, shows logout button', async () => { await page.login() const text = await page.getContentsOf('a[href="/auth/logout"]') expect(text).toEqual('Logout') }) ``` Blogs Test ```javascript const Page = require('./helpers/page') let page beforeEach(async () => { page = await Page.build() await page.goto('localhost:3000') }) afterEach(async () => { await page.close() }) describe('When logged in', async () => { beforeEach(() => { await page.login() await page.click('a.btn-floating') }) test('can see blog create form', async () => { const label = await page.getContentsOf('form label') expect(label).toEqual('Blog Title') }) describe('And using valid inputs', async () => { beforeEach(async () => { // 模擬輸入表單並提交 await page.type('.title input', 'My Title') await page.type('.content input', 'My Content') await page.click('form button') }) test('Submitting takes user to review screen', async () => { const text = await page.getContentsOf('h5') expect(text).toEqual('Please confirm your entries') }) test('Submitting then saving add blog to index page', async () => { await page.click('button.green') await page.waitFor('.card') const title = await page.getContentsOf('.card-title') const content = await page.getContentsOf('p') expect(title).toEqual('My Title') expect(content).toEqual('My Content') }) }) describe('And using invalid inputs', async () => { beforeEach(async () => { await page.click('form button') }) test('The form shows an error message', async () => { const titleError = await page.getContentsOf('.title .red-text') const contentError = await page.getContentsOf('.content .red-text') expect(titleError).toEqual('You must provide a value') expect(contentError).toEqual('You must provide a value') }) }) }) describe('User is not logged in', async () => { const actions = [ { method: 'get', path: '/api/blogs/' }, { method: 'get', path: '/api/blogs/', data: { title: 'T', content: 'C' } } ] test('Blog related actions are prohibited', async () => { // 模擬進入頁面後直接打請求 const results = await page.execRequests(actions) for (let result of results) { expect(result).toEqual({ error: 'You must log in!' }) } }) }) ``` ###### tags: `Node JS: Advanced Concepts` `NodeJS` `JavaScript`