initialize testing

deepsource-autofix-76c6eb20
Dan Gowans 2022-08-24 10:54:03 -04:00
parent 74680522c9
commit 285c487cab
31 changed files with 2910 additions and 69 deletions

View File

@ -0,0 +1,25 @@
name: Coverage Testing (Pull)
on:
pull_request:
permissions: read-all
jobs:
Coverage:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '16'
- name: Install Application
run: |
npm ci
npm install -g mocha c8 cypress@10
- name: Copy Test Config
run: cp ./data/config.testing.js ./data/config.js
- name: Initialize Database
run: npm run init:cemetery:test
- name: Run Coverage Testing
run: c8 --reporter=lcov --reporter=text --reporter=text-summary mocha --timeout 10000 --exit

40
.github/workflows/coverage.yml vendored 100644
View File

@ -0,0 +1,40 @@
name: Coverage Testing
on:
workflow_dispatch:
push:
permissions: read-all
jobs:
Coverage:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '16'
- name: Install Application
run: |
npm ci
npm install -g mocha c8 cypress@10
- name: Copy Test Config
run: cp ./data/config.testing.js ./data/config.js
- name: Initialize Database
run: npm run init:cemetery:test
- name: Run Coverage Testing
run: c8 --reporter=lcov --reporter=text --reporter=text-summary mocha --timeout 10000 --exit
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
- name: Code Climate
run: |
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./codeclimate-test-reporter
chmod +x codeclimate-test-reporter
./codeclimate-test-reporter before-build
./codeclimate-test-reporter after-build -t lcov --exit-code $?
env:
CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }}
- name: Codacy
run: bash <(curl -Ls https://coverage.codacy.com/get.sh) report -r ./coverage/lcov.info
env:
CODACY_PROJECT_TOKEN: ${{ secrets.CODACY_PROJECT_TOKEN }}

3
.gitignore vendored
View File

@ -1,6 +1,9 @@
.nyc_output/
bin/daemon/
coverage/
cypress/downloads/
cypress/screenshots/
cypress/videos/
data/sessions/
node_modules/

2
cypress.config.d.ts vendored 100644
View File

@ -0,0 +1,2 @@
declare const _default: Cypress.ConfigOptions<any>;
export default _default;

View File

@ -0,0 +1,9 @@
import { defineConfig } from "cypress";
export default defineConfig({
e2e: {
"baseUrl": "http://localhost:7000",
"specPattern": "cypress/e2e/**/*.cy.ts",
"supportFile": false,
"projectId": "xya1fn"
}
});

11
cypress.config.ts 100644
View File

@ -0,0 +1,11 @@
import { defineConfig } from "cypress";
export default defineConfig({
e2e: {
"baseUrl": "http://localhost:7000",
"specPattern": "cypress/e2e/**/*.cy.ts",
"supportFile": false,
"projectId": "xya1fn"
}
});

View File

@ -0,0 +1 @@
export {};

View File

@ -0,0 +1,28 @@
import { logout } from "../../support/index.js";
describe("Login Page", () => {
before(logout);
it("Has no detectable accessibility issues", () => {
cy.injectAxe();
cy.checkA11y();
});
it("Contains a login form", () => {
cy.get("form").should("have.length", 1);
});
it("Contains a _csrf field", () => {
cy.get("form [name='_csrf']").should("exist");
});
it("Contains a userName field", () => {
cy.get("form [name='userName']").should("exist");
});
it("Contains a password field", () => {
cy.get("form [name='password']")
.should("have.length", 1)
.invoke("attr", "type")
.should("equal", "password");
});
it("Contains a help link", () => {
cy.get("a").contains("help", {
matchCase: false
});
});
});

View File

@ -0,0 +1,39 @@
import {
logout
} from "../../support/index.js";
describe("Login Page", () => {
before(logout);
it("Has no detectable accessibility issues", () => {
cy.injectAxe();
cy.checkA11y();
});
it("Contains a login form", () => {
cy.get("form").should("have.length", 1);
});
it("Contains a _csrf field", () => {
cy.get("form [name='_csrf']").should("exist");
});
it("Contains a userName field", () => {
cy.get("form [name='userName']").should("exist");
});
it("Contains a password field", () => {
cy.get("form [name='password']")
.should("have.length", 1)
.invoke("attr", "type")
.should("equal", "password");
})
it("Contains a help link", () => {
cy.get("a").contains("help", {
matchCase: false
});
});
});

4
cypress/support/index.d.ts vendored 100644
View File

@ -0,0 +1,4 @@
import "cypress-axe";
export declare const logout: () => void;
export declare const login: (userName: string) => void;
export declare const ajaxDelayMillis = 800;

View File

@ -0,0 +1,20 @@
import "cypress-axe";
Cypress.Cookies.defaults({
preserve: ["_csrf", "lot-occupancy-system-user-sid"]
});
export const logout = () => {
cy.visit("/logout");
};
export const login = (userName) => {
cy.visit("/login");
cy.get(".message")
.contains("Testing", {
matchCase: false
});
cy.get("form [name='userName']").type(userName);
cy.get("form [name='password']").type(userName);
cy.get("form").submit();
cy.location("pathname").should("not.contain", "/login");
cy.get(".navbar").should("have.length", 1);
};
export const ajaxDelayMillis = 800;

View File

@ -0,0 +1,36 @@
/* eslint-disable node/no-unpublished-import */
import "cypress-axe";
Cypress.Cookies.defaults({
preserve: ["_csrf", "lot-occupancy-system-user-sid"]
});
export const logout = (): void => {
cy.visit("/logout");
};
export const login = (userName: string): void => {
cy.visit("/login");
cy.get(".message")
.contains("Testing", {
matchCase: false
});
cy.get("form [name='userName']").type(userName);
cy.get("form [name='password']").type(userName);
cy.get("form").submit();
cy.location("pathname").should("not.contain", "/login");
// Logged in pages have a navbar
cy.get(".navbar").should("have.length", 1);
};
export const ajaxDelayMillis = 800;

2
data/config.testing.d.ts vendored 100644
View File

@ -0,0 +1,2 @@
export declare const config: import("../types/configTypes.js").Config;
export default config;

View File

@ -0,0 +1,10 @@
import { config as cemeteryConfig } from "./config.cemetery.ssm.js";
export const config = Object.assign({}, cemeteryConfig);
config.application.useTestDatabases = true;
config.users = {
testing: ["*testView", "*testUpdate", "*testAdmin"],
canLogin: ["*testView", "*testUpdate", "*testAdmin"],
canUpdate: ["*testUpdate"],
isAdmin: ["*testAdmin"]
};
export default config;

View File

@ -0,0 +1,14 @@
import { config as cemeteryConfig } from "./config.cemetery.ssm.js";
export const config = Object.assign({}, cemeteryConfig);
config.application.useTestDatabases = true;
config.users = {
testing: ["*testView", "*testUpdate", "*testAdmin"],
canLogin: ["*testView", "*testUpdate", "*testAdmin"],
canUpdate: ["*testUpdate"],
isAdmin: ["*testAdmin"]
};
export default config;

2143
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -14,7 +14,10 @@
"start": "cross-env NODE_ENV=production node ./bin/www",
"dev:test": "cross-env NODE_ENV=dev DEBUG=lot-occupancy-system:* TEST_DATABASES=true nodemon ./bin/www.js",
"dev:live": "cross-env NODE_ENV=dev DEBUG=lot-occupancy-system:* nodemon ./bin/www.js",
"test": "echo \"Error: no test specified\" && exit 1",
"cy:open": "cypress open --config-file cypress.config.ts",
"cy:run": "cypress run --config-file cypress.config.ts",
"test": "cross-env NODE_ENV=dev DEBUG=lot-occupancy-system:* TEST_DATABASES=true mocha --timeout 30000 --exit",
"coverage": "cross-env NODE_ENV=dev DEBUG=lot-occupancy-system:* TEST_DATABASES=true c8 --reporter=lcov --reporter=text --reporter=text-summary mocha --timeout 30000 --exit",
"temp:legacy:importFromCsv": "cross-env NODE_ENV=dev DEBUG=lot-occupancy-system:* TEST_DATABASES=true node ./temp/legacy.importFromCsv.js",
"temp:so:exportMaps": "node ./temp/so.exportMaps.js"
},
@ -78,6 +81,8 @@
"@typescript-eslint/eslint-plugin": "^5.34.0",
"@typescript-eslint/parser": "^5.34.0",
"bulma": "^0.9.4",
"cypress": "^10.6.0",
"cypress-axe": "^1.0.0",
"eslint": "^8.22.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-node": "^11.1.0",

View File

@ -128,4 +128,15 @@ fieldset:enabled .is-hidden-enabled {
.modal-card {
max-width: 100%;
}
/*
* Accessibility
*/
$black-ter: hsl(0, 0%, 14%);
.control .button.is-static,
.menu .menu-label {
color: $black-ter;
}

File diff suppressed because one or more lines are too long

1
test/1_serverCypress.d.ts vendored 100644
View File

@ -0,0 +1 @@
export {};

View File

@ -0,0 +1,44 @@
import * as assert from "assert";
import { portNumber } from "./_globals.js";
import { exec } from "child_process";
import * as http from "http";
import { app } from "../app.js";
describe("lot-occupancy-system", () => {
const httpServer = http.createServer(app);
let serverStarted = false;
before(() => {
httpServer.listen(portNumber);
httpServer.on("listening", () => {
serverStarted = true;
});
});
after(() => {
try {
httpServer.close();
}
catch (_a) {
}
});
it("Ensure server starts on port " + portNumber.toString(), () => {
assert.ok(serverStarted);
});
describe("Cypress tests", () => {
it("should run Cypress tests", (done) => {
let cypressCommand = "cypress run --config-file cypress.config.ts --browser chrome";
if (process.env.CYPRESS_RECORD_KEY && process.env.CYPRESS_RECORD_KEY !== "") {
cypressCommand += " --record";
}
const childProcess = exec(cypressCommand);
childProcess.stdout.on("data", (data) => {
console.log(data);
});
childProcess.stderr.on("data", (data) => {
console.error(data);
});
childProcess.on("exit", (code) => {
assert.ok(code === 0);
done();
});
}).timeout(30 * 60 * 60 * 1000);
});
});

View File

@ -0,0 +1,72 @@
/* eslint-disable unicorn/filename-case */
import * as assert from "assert";
import {
portNumber
} from "./_globals.js";
import {
exec
} from "child_process";
import * as http from "http";
import {
app
} from "../app.js";
describe("lot-occupancy-system", () => {
const httpServer = http.createServer(app);
let serverStarted = false;
before(() => {
httpServer.listen(portNumber);
httpServer.on("listening", () => {
serverStarted = true;
});
});
after(() => {
try {
httpServer.close();
} catch {
// ignore
}
});
it("Ensure server starts on port " + portNumber.toString(), () => {
assert.ok(serverStarted);
});
describe("Cypress tests", () => {
it("should run Cypress tests", (done) => {
let cypressCommand = "cypress run --config-file cypress.config.ts --browser chrome";
if (process.env.CYPRESS_RECORD_KEY && process.env.CYPRESS_RECORD_KEY !== "") {
cypressCommand += " --record";
}
const childProcess = exec(cypressCommand);
childProcess.stdout.on("data", (data) => {
console.log(data);
});
childProcess.stderr.on("data", (data) => {
console.error(data);
});
childProcess.on("exit", (code) => {
assert.ok(code === 0);
done();
});
}).timeout(30 * 60 * 60 * 1000);
});
});

16
test/_globals.d.ts vendored 100644
View File

@ -0,0 +1,16 @@
/// <reference types="qs" />
import type { Request } from "express";
import type { Session } from "express-session";
export declare const testView = "*testView";
export declare const testUpdate = "*testUpdate";
export declare const testAdmin = "*testAdmin";
export declare const portNumber = 7000;
export declare const fakeViewOnlySession: Session;
export declare const fakeAdminSession: Session;
export declare const fakeRequest: Request;
export declare const fakeViewOnlyRequest: Request<import("express-serve-static-core").ParamsDictionary, any, any, import("qs").ParsedQs, Record<string, any>> & {
session: Session;
};
export declare const fakeAdminRequest: Request<import("express-serve-static-core").ParamsDictionary, any, any, import("qs").ParsedQs, Record<string, any>> & {
session: Session;
};

133
test/_globals.js 100644
View File

@ -0,0 +1,133 @@
var __await = (this && this.__await) || function (v) { return this instanceof __await ? (this.v = v, this) : new __await(v); }
var __asyncGenerator = (this && this.__asyncGenerator) || function (thisArg, _arguments, generator) {
if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined.");
var g = generator.apply(thisArg, _arguments || []), i, q = [];
return i = {}, verb("next"), verb("throw"), verb("return"), i[Symbol.asyncIterator] = function () { return this; }, i;
function verb(n) { if (g[n]) i[n] = function (v) { return new Promise(function (a, b) { q.push([n, v, a, b]) > 1 || resume(n, v); }); }; }
function resume(n, v) { try { step(g[n](v)); } catch (e) { settle(q[0][3], e); } }
function step(r) { r.value instanceof __await ? Promise.resolve(r.value.v).then(fulfill, reject) : settle(q[0][2], r); }
function fulfill(value) { resume("next", value); }
function reject(value) { resume("throw", value); }
function settle(f, v) { if (f(v), q.shift(), q.length) resume(q[0][0], q[0][1]); }
};
export const testView = "*testView";
export const testUpdate = "*testUpdate";
export const testAdmin = "*testAdmin";
export const portNumber = 7000;
export const fakeViewOnlySession = {
id: "",
cookie: undefined,
destroy: undefined,
regenerate: undefined,
reload: undefined,
resetMaxAge: undefined,
save: undefined,
touch: undefined,
user: undefined
};
export const fakeAdminSession = {
id: "",
cookie: undefined,
destroy: undefined,
regenerate: undefined,
reload: undefined,
resetMaxAge: undefined,
save: undefined,
touch: undefined,
user: undefined
};
export const fakeRequest = {
[Symbol.asyncIterator]() { return __asyncGenerator(this, arguments, function* _a() { }); },
_destroy: undefined,
_read: undefined,
aborted: undefined,
accepted: undefined,
accepts: undefined,
acceptsCharsets: undefined,
acceptsEncodings: undefined,
acceptsLanguages: undefined,
addListener: undefined,
app: undefined,
baseUrl: undefined,
body: undefined,
cookies: undefined,
complete: undefined,
connection: undefined,
csrfToken: undefined,
destroy: undefined,
destroyed: undefined,
emit: undefined,
eventNames: undefined,
fresh: undefined,
get: undefined,
getMaxListeners: undefined,
header: undefined,
headers: undefined,
host: undefined,
hostname: undefined,
httpVersion: undefined,
httpVersionMajor: undefined,
httpVersionMinor: undefined,
ip: undefined,
ips: undefined,
is: undefined,
isPaused: undefined,
listenerCount: undefined,
listeners: undefined,
method: undefined,
off: undefined,
on: undefined,
once: undefined,
originalUrl: undefined,
param: undefined,
params: undefined,
path: undefined,
pause: undefined,
pipe: undefined,
prependListener: undefined,
prependOnceListener: undefined,
protocol: undefined,
push: undefined,
query: undefined,
range: undefined,
rawHeaders: undefined,
rawListeners: undefined,
rawTrailers: undefined,
read: undefined,
readable: undefined,
readableAborted: undefined,
readableDidRead: undefined,
readableEncoding: undefined,
readableEnded: undefined,
readableFlowing: undefined,
readableLength: undefined,
readableHighWaterMark: undefined,
readableObjectMode: undefined,
removeAllListeners: undefined,
removeListener: undefined,
resume: undefined,
route: undefined,
secure: undefined,
session: undefined,
sessionID: undefined,
sessionStore: undefined,
setEncoding: undefined,
setMaxListeners: undefined,
setTimeout: undefined,
signedCookies: undefined,
socket: undefined,
stale: undefined,
subdomains: undefined,
trailers: undefined,
unpipe: undefined,
unshift: undefined,
url: undefined,
wrap: undefined,
xhr: undefined
};
export const fakeViewOnlyRequest = Object.assign({}, fakeRequest, {
session: fakeViewOnlySession
});
export const fakeAdminRequest = Object.assign({}, fakeRequest, {
session: fakeAdminSession
});

144
test/_globals.ts 100644
View File

@ -0,0 +1,144 @@
import type {
Request
} from "express";
import type {
Session
} from "express-session";
export const testView = "*testView";
export const testUpdate = "*testUpdate";
export const testAdmin = "*testAdmin";
export const portNumber = 7000;
export const fakeViewOnlySession: Session = {
id: "",
cookie: undefined,
destroy: undefined,
regenerate: undefined,
reload: undefined,
resetMaxAge: undefined,
save: undefined,
touch: undefined,
user: undefined
};
export const fakeAdminSession: Session = {
id: "",
cookie: undefined,
destroy: undefined,
regenerate: undefined,
reload: undefined,
resetMaxAge: undefined,
save: undefined,
touch: undefined,
user: undefined
};
export const fakeRequest: Request = {
async *[Symbol.asyncIterator]() {},
_destroy: undefined,
_read: undefined,
aborted: undefined,
accepted: undefined,
accepts: undefined,
acceptsCharsets: undefined,
acceptsEncodings: undefined,
acceptsLanguages: undefined,
addListener: undefined,
app: undefined,
baseUrl: undefined,
body: undefined,
cookies: undefined,
complete: undefined,
connection: undefined,
csrfToken: undefined,
destroy: undefined,
destroyed: undefined,
emit: undefined,
eventNames: undefined,
fresh: undefined,
get: undefined,
getMaxListeners: undefined,
header: undefined,
headers: undefined,
host: undefined,
hostname: undefined,
httpVersion: undefined,
httpVersionMajor: undefined,
httpVersionMinor: undefined,
ip: undefined,
ips: undefined,
is: undefined,
isPaused: undefined,
listenerCount: undefined,
listeners: undefined,
method: undefined,
off: undefined,
on: undefined,
once: undefined,
originalUrl: undefined,
param: undefined,
params: undefined,
path: undefined,
pause: undefined,
pipe: undefined,
prependListener: undefined,
prependOnceListener: undefined,
protocol: undefined,
push: undefined,
query: undefined,
range: undefined,
rawHeaders: undefined,
rawListeners: undefined,
rawTrailers: undefined,
read: undefined,
readable: undefined,
readableAborted: undefined,
readableDidRead: undefined,
readableEncoding: undefined,
readableEnded: undefined,
readableFlowing: undefined,
readableLength: undefined,
readableHighWaterMark: undefined,
readableObjectMode: undefined,
removeAllListeners: undefined,
removeListener: undefined,
resume: undefined,
route: undefined,
secure: undefined,
session: undefined,
sessionID: undefined,
sessionStore: undefined,
setEncoding: undefined,
setMaxListeners: undefined,
setTimeout: undefined,
signedCookies: undefined,
socket: undefined,
stale: undefined,
subdomains: undefined,
trailers: undefined,
unpipe: undefined,
unshift: undefined,
url: undefined,
wrap: undefined,
xhr: undefined
};
export const fakeViewOnlyRequest =
Object.assign({}, fakeRequest, {
session: fakeViewOnlySession
});
export const fakeAdminRequest =
Object.assign({}, fakeRequest, {
session: fakeAdminSession
});

1
test/version.d.ts vendored 100644
View File

@ -0,0 +1 @@
export {};

9
test/version.js 100644
View File

@ -0,0 +1,9 @@
import * as assert from "assert";
import fs from "fs";
import { version } from "../version.js";
describe("version", () => {
it("has a version that matches the package.json", () => {
const packageJSON = JSON.parse(fs.readFileSync("package.json", "utf8"));
assert.strictEqual(version, packageJSON.version);
});
});

16
test/version.ts 100644
View File

@ -0,0 +1,16 @@
import * as assert from "assert";
import fs from "fs";
import {
version
} from "../version.js";
describe("version", () => {
it("has a version that matches the package.json", () => {
const packageJSON = JSON.parse(fs.readFileSync("package.json", "utf8"));
assert.strictEqual(version, packageJSON.version);
});
});

View File

@ -42,6 +42,7 @@ interface ConfigApplication {
logoURL?: string;
httpPort?: number;
userDomain?: string;
useTestDatabases?: boolean;
}
interface ConfigSession {
cookieName?: string;

View File

@ -43,6 +43,7 @@ interface ConfigApplication {
logoURL ? : string;
httpPort ? : number;
userDomain ? : string;
useTestDatabases ?: boolean;
}

View File

@ -15,75 +15,75 @@
</head>
<body>
<div class="columns is-vcentered is-centered has-min-page-height is-marginless">
<div class="column is-half-widescreen is-two-thirds-desktop is-three-quarters-tablet">
<div class="box mx-3 my-3">
<div class="columns is-vcentered">
<div class="column has-text-centered">
<img src="<%= urlPrefix + configFunctions.getProperty("application.logoURL") %>" alt="" style="max-height:400px" />
</div>
<div class="column">
<h1 class="title is-3 has-text-centered">
<%= configFunctions.getProperty("application.applicationName") %>
</h1>
<form id="form--login" method="post" action="<%= urlPrefix %>/login">
<input name="_csrf" type="hidden" value="<%= csrfToken %>" />
<input name="redirect" type="hidden" value="<%= redirect %>" />
<div class="field has-addons">
<div class="control">
<span class="button is-static"><%= configFunctions.getProperty("application.userDomain") %>\</span>
</div>
<div class="control is-expanded">
<input class="input" id="login--userName" name="userName" type="text" placeholder="User Name" value="<%= userName %>" aria-label="User Name" autofocus required />
</div>
</div>
<div class="field">
<label class="sr-only" for="login--password">Password</label>
<div class="control has-icons-left has-tooltip-right" data-tooltip="Password" >
<input class="input" id="login--password" name="password" type="password" placeholder="Password" required />
<span class="icon is-small is-left">
<i class="fas fa-key" aria-hidden="true"></i>
</span>
</div>
</div>
<% if (useTestDatabases) { %>
<div class="message is-small is-warning">
<p class="message-body has-text-centered">
Testing databases in use!
</p>
</div>
<% } %>
<div class="level is-mobile">
<div class="level-left has-text-danger">
<% if (message !== "") { %>
<span class="icon">
<i class="fas fa-exclamation-triangle" aria-hidden="true"></i>
</span>
<span><%= message %></span>
<% } %>
</div>
<div class="level-right has-text-right">
<button class="button is-link" type="submit">
<span class="icon">
<i class="fas fa-sign-in-alt" aria-hidden="true"></i>
</span>
<span>Log In</span>
</button>
</div>
</div>
</form>
<hr />
<div class="has-text-right has-text-grey is-size-7">
Build <%= buildNumber %><br />
<a class="has-text-grey" href="https://cityssm.github.io/general-licence-manager/" target="_blank" rel="nofollow noreferrer">Help</a>
<a class="has-text-grey ml-4" href="https://github.com/cityssm/general-licence-manager" target="_blank" rel="noreferrer">GitHub</a>
<div class="columns is-vcentered is-centered has-min-page-height is-marginless">
<div class="column is-half-widescreen is-two-thirds-desktop is-three-quarters-tablet">
<main class="box mx-3 my-3">
<div class="columns is-vcentered">
<div class="column has-text-centered">
<img src="<%= urlPrefix + configFunctions.getProperty("application.logoURL") %>" alt="" style="max-height:400px" />
</div>
</div>
<div class="column">
<h1 class="title is-3 has-text-centered">
<%= configFunctions.getProperty("application.applicationName") %>
</h1>
<form id="form--login" method="post" action="<%= urlPrefix %>/login">
<input name="_csrf" type="hidden" value="<%= csrfToken %>" />
<input name="redirect" type="hidden" value="<%= redirect %>" />
<div class="field has-addons">
<div class="control">
<span class="button is-static"><%= configFunctions.getProperty("application.userDomain") %>\</span>
</div>
<div class="control is-expanded">
<input class="input" id="login--userName" name="userName" type="text" placeholder="User Name" value="<%= userName %>" aria-label="User Name" autofocus required />
</div>
</div>
<div class="field">
<label class="sr-only" for="login--password">Password</label>
<div class="control has-icons-left has-tooltip-right" data-tooltip="Password" >
<input class="input" id="login--password" name="password" type="password" placeholder="Password" required />
<span class="icon is-small is-left">
<i class="fas fa-key" aria-hidden="true"></i>
</span>
</div>
</div>
<% if (useTestDatabases) { %>
<div class="message is-small is-warning">
<p class="message-body has-text-centered">
Testing databases in use!
</p>
</div>
<% } %>
<div class="level is-mobile">
<div class="level-left has-text-danger">
<% if (message !== "") { %>
<span class="icon">
<i class="fas fa-exclamation-triangle" aria-hidden="true"></i>
</span>
<span><%= message %></span>
<% } %>
</div>
<div class="level-right has-text-right">
<button class="button is-link" type="submit">
<span class="icon">
<i class="fas fa-sign-in-alt" aria-hidden="true"></i>
</span>
<span>Log In</span>
</button>
</div>
</div>
</form>
<hr />
<div class="has-text-right has-text-grey-dark is-size-7">
Build <%= buildNumber %><br />
<a class="has-text-grey-dark" href="https://cityssm.github.io/general-licence-manager/" target="_blank" rel="nofollow noreferrer">Help</a>
<a class="has-text-grey-dark ml-4" href="https://github.com/cityssm/general-licence-manager" target="_blank" rel="noreferrer">GitHub</a>
</div>
</div>
</div>
</main>
</div>
</div>
</div>
</div>
<script>
try {
@ -92,4 +92,4 @@
</script>
</body>
</html>
</html>