From edae2943bdb421b50773ac6e6249bcd8a8408d15 Mon Sep 17 00:00:00 2001 From: Averi Kitsch Date: Wed, 21 Oct 2020 16:25:39 -0700 Subject: [PATCH] [Cloud Run] Identity Platform + Cloud SQL sample (#1984) --- .kokoro/build-with-run.sh | 8 ++ .kokoro/run/idp-sql.cfg | 7 ++ run/idp-sql/.gitignore | 1 + run/idp-sql/Dockerfile | 26 ++++ run/idp-sql/README.md | 85 +++++++++++++ run/idp-sql/app.js | 119 ++++++++++++++++++ run/idp-sql/app.json | 27 ++++ run/idp-sql/cloud-sql.js | 163 +++++++++++++++++++++++++ run/idp-sql/handlebars.js | 37 ++++++ run/idp-sql/index.js | 55 +++++++++ run/idp-sql/logging.js | 37 ++++++ run/idp-sql/middleware.js | 66 ++++++++++ run/idp-sql/package.json | 38 ++++++ run/idp-sql/postcreate.sh | 28 +++++ run/idp-sql/postgres-secrets.json | 6 + run/idp-sql/secrets.js | 66 ++++++++++ run/idp-sql/setup.sh | 67 ++++++++++ run/idp-sql/static/config.js | 6 + run/idp-sql/static/firebase.js | 80 ++++++++++++ run/idp-sql/test/app.test.js | 56 +++++++++ run/idp-sql/test/e2e_test_cleanup.yaml | 22 ++++ run/idp-sql/test/e2e_test_setup.yaml | 59 +++++++++ run/idp-sql/test/retry.sh | 67 ++++++++++ run/idp-sql/test/system.test.js | 159 ++++++++++++++++++++++++ run/idp-sql/views/index.html | 66 ++++++++++ 25 files changed, 1351 insertions(+) create mode 100644 .kokoro/run/idp-sql.cfg create mode 100644 run/idp-sql/.gitignore create mode 100644 run/idp-sql/Dockerfile create mode 100644 run/idp-sql/README.md create mode 100644 run/idp-sql/app.js create mode 100644 run/idp-sql/app.json create mode 100644 run/idp-sql/cloud-sql.js create mode 100644 run/idp-sql/handlebars.js create mode 100644 run/idp-sql/index.js create mode 100644 run/idp-sql/logging.js create mode 100644 run/idp-sql/middleware.js create mode 100644 run/idp-sql/package.json create mode 100755 run/idp-sql/postcreate.sh create mode 100644 run/idp-sql/postgres-secrets.json create mode 100644 run/idp-sql/secrets.js create mode 100644 run/idp-sql/setup.sh create mode 100644 run/idp-sql/static/config.js create mode 100644 run/idp-sql/static/firebase.js create mode 100644 run/idp-sql/test/app.test.js create mode 100644 run/idp-sql/test/e2e_test_cleanup.yaml create mode 100644 run/idp-sql/test/e2e_test_setup.yaml create mode 100755 run/idp-sql/test/retry.sh create mode 100644 run/idp-sql/test/system.test.js create mode 100644 run/idp-sql/views/index.html diff --git a/.kokoro/build-with-run.sh b/.kokoro/build-with-run.sh index 97055816da..0fbb37a70e 100755 --- a/.kokoro/build-with-run.sh +++ b/.kokoro/build-with-run.sh @@ -83,5 +83,13 @@ if [[ $KOKORO_BUILD_ARTIFACTS_SUBDIR = *"release"* ]]; then trap notify_buildcop EXIT HUP fi +# Configure Cloud SQL variables for deploying idp-sql sample +export DB_NAME="kokoro_ci" +export DB_USER="kokoro_ci" +export DB_PASSWORD=$(cat $KOKORO_GFILE_DIR/secrets-sql-password.txt) +export CLOUD_SQL_CONNECTION_NAME=$(cat $KOKORO_GFILE_DIR/secrets-pg-connection-name.txt) + +export IDP_KEY=$(gcloud secrets versions access latest --secret="nodejs-docs-samples-idp-key" --project="${GOOGLE_CLOUD_PROJECT}") + npm test npm run --if-present system-test diff --git a/.kokoro/run/idp-sql.cfg b/.kokoro/run/idp-sql.cfg new file mode 100644 index 0000000000..20d261ea0d --- /dev/null +++ b/.kokoro/run/idp-sql.cfg @@ -0,0 +1,7 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Set the folder in which the tests are run +env_vars: { + key: "PROJECT" + value: "run/idp-sql" +} diff --git a/run/idp-sql/.gitignore b/run/idp-sql/.gitignore new file mode 100644 index 0000000000..c2658d7d1b --- /dev/null +++ b/run/idp-sql/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/run/idp-sql/Dockerfile b/run/idp-sql/Dockerfile new file mode 100644 index 0000000000..ae35856a85 --- /dev/null +++ b/run/idp-sql/Dockerfile @@ -0,0 +1,26 @@ +# Copyright 2020 Google LLC. All rights reserved. +# Use of this source code is governed by the Apache 2.0 +# license that can be found in the LICENSE file. + +# Use the official lightweight Node.js 10 image. +# https://hub.docker.com/_/node +FROM node:12-slim + +# Create and change to the app directory. +WORKDIR /usr/src/app + +# Copy application dependency manifests to the container image. +# A wildcard is used to ensure both package.json AND package-lock.json are copied. +# Copying this separately prevents re-running npm install on every code change. +COPY package*.json ./ + +# Install dependencies. +# If you add a package-lock.json speed your build by switching to 'npm ci'. +# RUN npm ci --only=production +RUN npm install --production + +# Copy local code to the container image. +COPY . ./ + +# Run the web service on container startup. +CMD [ "node", "index.js" ] diff --git a/run/idp-sql/README.md b/run/idp-sql/README.md new file mode 100644 index 0000000000..4b43d9464a --- /dev/null +++ b/run/idp-sql/README.md @@ -0,0 +1,85 @@ +# Cloud Run End User Authentication with PostgreSQL Database Sample + +This sample integrates with the Identity Platform to authenticate users to the +application and connects to a Cloud SQL postgreSQL database for data storage. + +Use it with the [End user Authentication for Cloud Run](http://cloud.google.com/run/docs/tutorials/identity-platform). + +For more details on how to work with this sample read the [Google Cloud Run Node.js Samples README](https://github.com/GoogleCloudPlatform/nodejs-docs-samples/tree/master/run). + +[![Run on Google Cloud](https://deploy.cloud.run/button.svg)](https://deploy.cloud.run) + +## Dependencies + +* **express**: Web server framework +* **winston**: Logging library +* **@google-cloud/secret-manager**: Google Secret Manager client library +* **firebase-admin**: Verifying JWT token +* **knex** + **pg**: A postgreSQL query builder library +* **handlebars.js**: Template engine +* **google-auth-library-nodejs**: Access [compute metadata server](https://cloud.google.com/compute/docs/storing-retrieving-metadata) for project ID +* **Firebase JavaScript SDK**: client-side library for authentication flow + +## Environment Variables + +Cloud Run services can be [configured with Environment Variables](https://cloud.google.com/run/docs/configuring/environment-variables). +Required variables for this sample include: + +* `CLOUD_SQL_CREDENTIALS_SECRET`: the resource ID of the secret, in format: `projects/PROJECT_ID/secrets/SECRET_ID/versions/VERSION`. See [postgres-secrets.json](postgres-secrets.json) for secret content. + +OR + +* `CLOUD_SQL_CONNECTION_NAME`: Cloud SQL instance name, in format: `::` +* `DB_NAME`: Cloud SQL postgreSQL database name +* `DB_USER`: database user +* `DB_PASSWORD`: database password + +Other environment variables: + +* Set `TABLE` to change the postgreSQL database table name. + +* Set `DB_HOST` to use the proxy with TCP. See instructions below. + +* Set `DB_SOCKET_PATH` to change the directory when using the proxy with Unix sockets. + See instructions below. + +## Production Considerations + +* Both `postgres-secrets.json` and `static/config.js` should not be committed to + a git repository and should be added to `.gitignore`. + +* Saving credentials directly as environment variables is convenient for local testing, + but not secure for production; therefore using `CLOUD_SQL_CREDENTIALS_SECRET` + in combination with the Cloud Secrets Manager is recommended. + +## Running Locally + +1. Set [environment variables](#environment-variables). + +1. To run this application locally, download and install the `cloud_sql_proxy` by +[following the instructions](https://cloud.google.com/sql/docs/postgres/sql-proxy#install). + +The proxy can be used with a TCP connection or a Unix Domain Socket. On Linux or +Mac OS you can use either option, but on Windows the proxy currently requires a TCP +connection. + +* [Instructions to launch proxy with Unix Domain Socket](../../cloud-sql/postgres/knex#launch-proxy-with-unix-domain-socket) + +* [Instructions to launch proxy with TCP](../../cloud-sql/postgres/knex#launch-proxy-with-tcp) + +## Testing + +Tests expect the Cloud SQL instance to already be created and environment Variables +to be set. + +### Unit tests + +``` +npm run test +``` + +### System tests + +``` +npm run system-test +``` diff --git a/run/idp-sql/app.js b/run/idp-sql/app.js new file mode 100644 index 0000000000..0b5f9f4985 --- /dev/null +++ b/run/idp-sql/app.js @@ -0,0 +1,119 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +'use strict'; + +const { getVotes, getVoteCount, insertVote } = require('./cloud-sql'); +const express = require('express'); +const { buildRenderedHtml } = require('./handlebars'); +const { authenticateJWT, requestLogger } = require('./middleware'); + +const app = express(); +app.use(express.static(__dirname + '/static')); + +// Automatically parse request body as form data. +app.use(express.urlencoded({extended: false})); +app.use(express.json()); + +// Set Content-Type for all responses for these routes. +app.use((req, res, next) => { + res.set('Content-Type', 'text/html'); + next(); +}); + +app.get('/', requestLogger, async (req, res) => { + try { + // Query the total count of "CATS" from the database. + const catsResult = await getVoteCount('CATS'); + const catsTotalVotes = parseInt(catsResult[0].count); + // Query the total count of "DOGS" from the database. + const dogsResult = await getVoteCount('DOGS'); + const dogsTotalVotes = parseInt(dogsResult[0].count); + // Query the last 5 votes from the database. + const votes = await getVotes(); + // Calculate and set leader values. + let leadTeam = ''; + let voteDiff = 0; + let leaderMessage = ''; + if (catsTotalVotes !== dogsTotalVotes) { + if (catsTotalVotes > dogsTotalVotes) { + leadTeam = 'CATS'; + voteDiff = catsTotalVotes - dogsTotalVotes; + } else { + leadTeam = 'DOGS'; + voteDiff = dogsTotalVotes - catsTotalVotes; + } + leaderMessage = `${leadTeam} are winning by ${voteDiff} vote${voteDiff > 1 ? 's' : ''}.`; + } else { + leaderMessage = 'CATS and DOGS are evenly matched!'; + } + + // Add variables to Handlebars.js template + const renderedHtml = await buildRenderedHtml({ + votes: votes, + catsCount: catsTotalVotes, + dogsCount: dogsTotalVotes, + leadTeam: leadTeam, + voteDiff: voteDiff, + leaderMessage: leaderMessage, + }); + res.status(200).send(renderedHtml); + } catch (err) { + const message = "Error while connecting to the Cloud SQL database. " + + "Check that your username and password are correct, that the Cloud SQL " + + "proxy is running (locally), and that the database/table exists and is " + + `ready for use: ${err}`; + req.logger.error(message); // request-based logger with trace support + res + .status(500) + .send('Unable to load page; see logs for more details.') + .end(); + } +}); + +app.post('/', requestLogger, authenticateJWT, async (req, res) => { + // Get decoded Id Platform user id + const uid = req.uid; + // Get the team from the request and record the time of the vote. + const {team} = req.body; + const timestamp = new Date(); + + if (!team || (team !== 'CATS' && team !== 'DOGS')) { + res.status(400).send('Invalid team specified.').end(); + return; + } + + // Create a vote record to be stored in the database. + const vote = { + candidate: team, + time_cast: timestamp, + uid, + }; + + // Save the data to the database. + try { + await insertVote(vote); + req.logger.info({message: 'vote_inserted', vote}); // request-based logger with trace support + } catch (err) { + req.logger.error(`Error while attempting to submit vote: ${err}`); + res + .status(500) + .send('Unable to cast vote; see logs for more details.') + .end(); + return; + } + res.status(200).send(`Successfully voted for ${team} at ${timestamp}`).end(); +}); + +module.exports = app; diff --git a/run/idp-sql/app.json b/run/idp-sql/app.json new file mode 100644 index 0000000000..73171bdb86 --- /dev/null +++ b/run/idp-sql/app.json @@ -0,0 +1,27 @@ +{ + "name": "idp-sql", + "env": { + "DB_PASSWORD": { + "description": "postgreSQL password for root user" + }, + "CLOUD_SQL_INSTANCE_NAME": { + "description": "Cloud SQL instance name", + "value": "idp-sql-instance" + }, + "API_KEY": { + "description": "Identity Platform API key from Application Setup Details" + } + }, + "hooks": { + "precreate": { + "commands": [ + "./setup.sh" + ] + }, + "postcreate": { + "commands": [ + "./postcreate.sh" + ] + } + } +} diff --git a/run/idp-sql/cloud-sql.js b/run/idp-sql/cloud-sql.js new file mode 100644 index 0000000000..1ddcce46dd --- /dev/null +++ b/run/idp-sql/cloud-sql.js @@ -0,0 +1,163 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const Knex = require('knex'); +const { getCredConfig } = require('./secrets'); +const { logger } = require('./logging'); + +const TABLE = process.env.TABLE || 'votes'; +let knex, credConfig; + +// Connection pooling config +// See Cloud SQL sample https://github.com/GoogleCloudPlatform/nodejs-docs-samples/tree/master/cloud-sql/postgres/knex +// to learn more about this configuration +const config = { + pool: { + max: 5, + min: 5, + acquireTimeoutMillis: 60000, + }, + createTimeoutMillis: 30000, + idleTimeoutMillis: 600000, + createRetryIntervalMillis: 200, +} + +// [START run_user_auth_sql_connect] +/** +* Connect to the Cloud SQL instance through UNIX Sockets +* +* @param {object} credConfig The Cloud SQL connection configuration from Secret Manager +* @returns {object} Knex's PostgreSQL client +*/ +const connectWithUnixSockets = async (credConfig) => { + const dbSocketPath = process.env.DB_SOCKET_PATH || "/cloudsql" + // Establish a connection to the database + return Knex({ + client: 'pg', + connection: { + user: credConfig.DB_USER, // e.g. 'my-user' + password: credConfig.DB_PASSWORD, // e.g. 'my-user-password' + database: credConfig.DB_NAME, // e.g. 'my-database' + host: `${dbSocketPath}/${credConfig.CLOUD_SQL_CONNECTION_NAME}`, + }, + ...config + }); +} +// [END run_user_auth_sql_connect] + +// Method to connect locally on Windows +const connectWithTcp = (credConfig) => { + // Extract host and port from socket address + const dbSocketAddr = process.env.DB_HOST.split(":") // e.g. '127.0.0.1:5432' + // Establish a connection to the database + return Knex({ + client: 'pg', + connection: { + user: credConfig.DB_USER, // e.g. 'my-user' + password: credConfig.DB_PASSWORD, // e.g. 'my-user-password' + database: credConfig.DB_NAME, // e.g. 'my-database' + host: dbSocketAddr[0], // e.g. '127.0.0.1' + port: dbSocketAddr[1], // e.g. '5432' + }, + ...config + }); +} + + +/** +* Connect to the Cloud SQL instance through TCP or UNIX Sockets +* dependent on DB_HOST env var +* +* @returns {object} Knex's PostgreSQL client +*/ +const connect = async () => { + if (!credConfig) credConfig = await getCredConfig(); + if (process.env.DB_HOST) { + return connectWithTcp(credConfig); + } else { + return connectWithUnixSockets(credConfig); + } +} + +/** +* Insert a vote record into the database. +* +* @param {object} vote The vote record to insert. +* @returns {Promise} +*/ +const insertVote = async (vote) => { + if (!knex) knex = await connect(); + return knex(TABLE).insert(vote); +}; + +/** +* Retrieve the latest 5 vote records from the database. +* +* @returns {Promise} +*/ +const getVotes = async () => { + if (!knex) knex = await connect(); + return knex + .select('candidate', 'time_cast', 'uid') + .from(TABLE) + .orderBy('time_cast', 'desc') + .limit(5); +}; + +/** +* Retrieve the total count of records for a given candidate +* from the database. +* +* @param {object} candidate The candidate for which to get the total vote count +* @returns {Promise} +*/ +const getVoteCount = async (candidate) => { + if (!knex) knex = await connect(); + return knex(TABLE).count('vote_id').where('candidate', candidate); +}; + +/** +* Create "votes" table in the Cloud SQL database +*/ +const createTable = async () => { + if (!knex) knex = await connect(); + const exists = await knex.schema.hasTable(TABLE); + if (!exists) { + try { + await knex.schema.createTable(TABLE, (table) => { + table.bigIncrements('vote_id').notNull(); + table.timestamp('time_cast').notNull(); + table.specificType('candidate', 'CHAR(6) NOT NULL'); + table.specificType('uid', 'VARCHAR(128) NOT NULL'); + }); + logger.info(`Successfully created ${TABLE} table.`); + } catch (err) { + const message = `Failed to create ${TABLE} table: ${err}`; + logger.error(message); + } + } +}; + +const closeConnection = () => { + if (!knex) knex.destroy(); + logger.info('DB connection closed.'); +} + +module.exports = { + getVoteCount, + getVotes, + insertVote, + createTable, + closeConnection, +} diff --git a/run/idp-sql/handlebars.js b/run/idp-sql/handlebars.js new file mode 100644 index 0000000000..a8505469ca --- /dev/null +++ b/run/idp-sql/handlebars.js @@ -0,0 +1,37 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const handlebars = require('handlebars'); +const { readFile } = require('fs').promises; + +let index, template; + +/** +* Builds and executes Handlebars.js template for rendered HTML +* +* @param {object} config The template config object. +* @returns {Promise} +*/ +const buildRenderedHtml = async (config) => { + if (!index) index = await readFile(__dirname + '/views/index.html', 'utf8'); + if (!template) template = handlebars.compile(index); + return template(config); +}; + +// Register customer Handlebars.js helper +handlebars.registerHelper('ternary', function(comp1, comp2, opt1, opt2) { + return (comp1.trim() == comp2.trim()) ? opt1 : opt2; +}); + +module.exports = { buildRenderedHtml } diff --git a/run/idp-sql/index.js b/run/idp-sql/index.js new file mode 100644 index 0000000000..f4e68fdfcc --- /dev/null +++ b/run/idp-sql/index.js @@ -0,0 +1,55 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const app = require('./app'); +const pkg = require('./package.json'); +const { logger } = require('./logging'); +const { initTracing } = require('./middleware'); +const { createTable, closeConnection } = require('./cloud-sql'); + +const { GoogleAuth } = require('google-auth-library'); +const auth = new GoogleAuth(); + +const PORT = process.env.PORT || 8080; + +const startServer = () => { + app.listen(PORT, () => logger.info(`${pkg.name}: listening on port ${PORT}`)); +}; + +const main = async () => { + let project = process.env.GOOGLE_CLOUD_PROJECT; + if (!project) { + try { + project = await auth.getProjectId(); + } catch (err) { + logger.error(`Error while retrieving Project ID: ${err}`); + } + } + initTracing(project); // pass project ID to tracing middleware + await createTable(); // Create postgreSQL table if not found + startServer(); +}; + +// Clean up resources on shutdown +process.on('SIGTERM', () => { + logger.info(`${pkg.name}: received SIGTERM`); + closeConnection(); + logger.end(); + logger.on('finish', () => { + console.log(`${pkg.name}: logs flushed`); + process.exit(0); + }) +}); + +main(); diff --git a/run/idp-sql/logging.js b/run/idp-sql/logging.js new file mode 100644 index 0000000000..521b513758 --- /dev/null +++ b/run/idp-sql/logging.js @@ -0,0 +1,37 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Create a Winston logger that streams to Stackdriver Logging. +const { createLogger, transports ,format } = require('winston'); + +// Add severity label for Stackdriver log parsing +const addSeverity = format((info, opts) => { + info.severity = info.level; + return info; +}); + +const logger = createLogger({ + level: 'info', + format: format.combine( + addSeverity(), + format.timestamp(), + format.json(), + // format.prettyPrint(), // Uncomment for local debugging + ), + transports: [new transports.Console()], +}); + +module.exports = { + logger +}; diff --git a/run/idp-sql/middleware.js b/run/idp-sql/middleware.js new file mode 100644 index 0000000000..d290756767 --- /dev/null +++ b/run/idp-sql/middleware.js @@ -0,0 +1,66 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const { logger } = require('./logging'); // Import winston logger instance + +// [START run_user_auth_jwt] +const firebase = require('firebase-admin'); +// Initialize Firebase Admin SDK +firebase.initializeApp(); + +// Extract and verify Id Token from header +const authenticateJWT = (req, res, next) => { + const authHeader = req.headers.authorization; + if (authHeader) { + const token = authHeader.split(' ')[1]; + // If the provided ID token has the correct format, is not expired, and is + // properly signed, the method returns the decoded ID token + firebase.auth().verifyIdToken(token).then(function(decodedToken) { + let uid = decodedToken.uid; + req.uid = uid; + next(); + }).catch((err) => { + req.logger.error(`Error with authentication: ${err}`); + return res.sendStatus(403); + }); + } else { + return res.sendStatus(401); + } +} +// [END run_user_auth_jwt] + + +let project; +const initTracing = (projectId) => { + project = projectId; +} + +// Add logging field with trace ID for logging correlation +// For more info, see https://cloud.google.com/run/docs/logging#correlate-logs +const requestLogger = (req, res, next) => { + const traceHeader = req.header('X-Cloud-Trace-Context'); + let trace; + if (traceHeader) { + const [traceId] = traceHeader.split("/"); + trace = `projects/${project}/traces/${traceId}`; + } + req.logger = logger.child({'logging.googleapis.com/trace': trace}); + next(); +} + +module.exports = { + authenticateJWT, + requestLogger, + initTracing +} diff --git a/run/idp-sql/package.json b/run/idp-sql/package.json new file mode 100644 index 0000000000..976f1ed634 --- /dev/null +++ b/run/idp-sql/package.json @@ -0,0 +1,38 @@ +{ + "name": "idp-sql", + "description": "Identity Platform and Cloud SQL integrations sample for Cloud Run.", + "version": "0.0.1", + "private": true, + "license": "Apache-2.0", + "author": "Google LLC", + "repository": { + "type": "git", + "url": "https://github.com/GoogleCloudPlatform/nodejs-docs-samples.git" + }, + "engines": { + "node": ">=12.0.0" + }, + "scripts": { + "start": "node index.js", + "test": "mocha test/app.test.js --timeout=60000 --exit", + "system-test": "mocha test/system.test.js --timeout=1800000 --exit" + }, + "dependencies": { + "@google-cloud/secret-manager": "^3.1.0", + "express": "^4.16.2", + "firebase-admin": "^9.1.0", + "gcp-metadata": "^4.2.0", + "google-auth-library": "^6.1.1", + "handlebars": "^4.7.6", + "knex": "^0.21.0", + "pg": "^8.0.0", + "winston": "3.3.3" + }, + "devDependencies": { + "got": "^11.7.0", + "mocha": "^8.0.0", + "short-uuid": "^4.1.0", + "sinon": "^9.2.0", + "supertest": "^4.0.0" + } +} diff --git a/run/idp-sql/postcreate.sh b/run/idp-sql/postcreate.sh new file mode 100755 index 0000000000..e19e4f7d3f --- /dev/null +++ b/run/idp-sql/postcreate.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Postcreate script for Cloud Run button + +export SECRET_NAME="idp-sql-secrets" +export SERVICE_ACCOUNT="idp-sql-identity" + +# Update Cloud Run service to include Cloud SQL instance, Secret Manager secret, +# and service account +gcloud run services update ${K_SERVICE} \ + --platform managed \ + --region ${GOOGLE_CLOUD_REGION} \ + --service-account ${SERVICE_ACCOUNT}@${GOOGLE_CLOUD_PROJECT}.iam.gserviceaccount.com \ + --add-cloudsql-instances ${GOOGLE_CLOUD_PROJECT}:${GOOGLE_CLOUD_REGION}:${CLOUD_SQL_INSTANCE_NAME} \ + --update-env-vars CLOUD_SQL_CREDENTIALS_SECRET=projects/${GOOGLE_CLOUD_PROJECT}/secrets/${SECRET_NAME}/versions/latest diff --git a/run/idp-sql/postgres-secrets.json b/run/idp-sql/postgres-secrets.json new file mode 100644 index 0000000000..73a7e7d10a --- /dev/null +++ b/run/idp-sql/postgres-secrets.json @@ -0,0 +1,6 @@ +{ + "CLOUD_SQL_CONNECTION_NAME": "PROJECT_ID:REGION:INSTANCE", + "DB_NAME": "postgres", + "DB_USER": "postgres", + "DB_PASSWORD": "PASSWORD_SECRET" +} diff --git a/run/idp-sql/secrets.js b/run/idp-sql/secrets.js new file mode 100644 index 0000000000..c67d67e997 --- /dev/null +++ b/run/idp-sql/secrets.js @@ -0,0 +1,66 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const { logger } = require('./logging'); + +// CLOUD_SQL_CREDENTIALS_SECRET is the resource ID of the secret, passed in by environment variable. +// Format: projects/PROJECT_ID/secrets/SECRET_ID/versions/VERSION +const {CLOUD_SQL_CREDENTIALS_SECRET} = process.env; + + +// [START run_user_auth_secrets] +const {SecretManagerServiceClient} = require('@google-cloud/secret-manager'); +let client; + +async function getSecrets(secretName) { + if (!client) client = new SecretManagerServiceClient(); + try { + const [version] = await client.accessSecretVersion({name: secretName}); + return version.payload.data; + } catch (err) { + throw Error(`Error accessing Secret Manager: ${err}`); + } +} +// [END run_user_auth_secrets] + +// Load the Cloud SQL config from Secret Manager +async function getCredConfig() { + if (CLOUD_SQL_CREDENTIALS_SECRET) { + const secrets = await getSecrets(CLOUD_SQL_CREDENTIALS_SECRET); + try { + // Parse the secret that has been added as a JSON string + // to retreive database credentials + return JSON.parse(secrets.toString('utf8')); + } catch (err) { + throw Error(`Unable to parse secret from Secret Manager. Make sure that the secret is JSON formatted: ${err}`) + } + } else { + logger.info('CLOUD_SQL_CREDENTIALS_SECRET env var not set. Defaulting to environment variables.'); + if (!process.env.DB_USER) throw Error('DB_USER needs to be set.'); + if (!process.env.DB_PASSWORD) throw Error('DB_PASSWORD needs to be set.'); + if (!process.env.DB_NAME) throw Error('DB_NAME needs to be set.'); + if (!process.env.CLOUD_SQL_CONNECTION_NAME) throw Error('CLOUD_SQL_CONNECTION_NAME needs to be set.'); + + return { + DB_USER: process.env.DB_USER, + DB_PASSWORD: process.env.DB_PASSWORD, + DB_NAME: process.env.DB_NAME, + CLOUD_SQL_CONNECTION_NAME: process.env.CLOUD_SQL_CONNECTION_NAME + } + } +} + +module.exports = { + getCredConfig +} diff --git a/run/idp-sql/setup.sh b/run/idp-sql/setup.sh new file mode 100644 index 0000000000..b550d3a3e5 --- /dev/null +++ b/run/idp-sql/setup.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Precreate script for Cloud Run button + +export SECRET_NAME="idp-sql-secrets" +export SERVICE_ACCOUNT="idp-sql-indentity" + +gcloud config set project $GOOGLE_CLOUD_PROJECT + +# Add Identity Platform config +sed -i "" "s/PROJECT_ID/$GOOGLE_CLOUD_PROJECT/" static/config.js +sed -i "" "s/API_KEY/$API_KEY/" static/config.js + +# Enable Cloud SQl and Secret Manager APIs +gcloud services enable sqladmin.googleapis.com secretmanager.googleapis.com + +# Create Cloud SQl instance +gcloud sql instances describe ${CLOUD_SQL_INSTANCE_NAME} +if [ $? -eq 1 ]; then + echo "Create Cloud SQL instance with postgreSQL database (this might take a few minutes)..." + gcloud sql instances create ${CLOUD_SQL_INSTANCE_NAME} \ + --database-version=POSTGRES_12 \ + --region=${GOOGLE_CLOUD_REGION} \ + --cpu=2 \ + --memory=7680MB \ + --root-password=${DB_PASSWORD} +fi + +# Add Cloud SQL config to secret file +sed -i "s/PROJECT_ID/$GOOGLE_CLOUD_PROJECT/" postgres-secrets.json +sed -i "s/REGION/$GOOGLE_CLOUD_REGION/" postgres-secrets.json +sed -i "s/PASSWORD_SECRET/$DB_PASSWORD/" postgres-secrets.json +sed -i "s/INSTANCE/$CLOUD_SQL_INSTANCE_NAME/" postgres-secrets.json + +# Add secret file to Secret Manager +gcloud secrets describe ${SECRET_NAME} +if [ $? -eq 1 ]; then + echo "Creating secret ..." + gcloud secrets create ${SECRET_NAME} \ + --replication-policy="automatic" +fi +echo "Adding secret version ..." +gcloud secrets versions add ${SECRET_NAME} --data-file=postgres-secrets.json + +# Create service account +gcloud iam service-accounts create ${SERVICE_ACCOUNT} +# Allow service account to access secret +gcloud secrets add-iam-policy-binding ${SECRET_NAME} \ + --member serviceAccount:${SERVICE_ACCOUNT}@${GOOGLE_CLOUD_PROJECT}.iam.gserviceaccount.com \ + --role roles/secretmanager.secretAccessor + # Allow service account to access Cloud SQL +gcloud projects add-iam-policy-binding $GOOGLE_CLOUD_PROJECT \ + --member serviceAccount:${SERVICE_ACCOUNT}@${GOOGLE_CLOUD_PROJECT}.iam.gserviceaccount.com \ + --role roles/cloudsql.client diff --git a/run/idp-sql/static/config.js b/run/idp-sql/static/config.js new file mode 100644 index 0000000000..31f5a1822f --- /dev/null +++ b/run/idp-sql/static/config.js @@ -0,0 +1,6 @@ +// [START run_end_user_firebase_config] +const config = { + apiKey: "API_KEY", + authDomain: "PROJECT_ID.firebaseapp.com", +}; +// [END run_end_user_firebase_config] diff --git a/run/idp-sql/static/firebase.js b/run/idp-sql/static/firebase.js new file mode 100644 index 0000000000..7e05f16cd5 --- /dev/null +++ b/run/idp-sql/static/firebase.js @@ -0,0 +1,80 @@ +firebase.initializeApp(config); + +// Watch for state change from sign in +function initApp() { + firebase.auth().onAuthStateChanged(function(user) { + if (user) { + // User is signed in. + document.getElementById('signInButton').innerText = 'Sign Out'; + document.getElementById('form').style.display = ''; + } else { + // No user is signed in. + document.getElementById('signInButton').innerText = 'Sign In with Google'; + document.getElementById('form').style.display = 'none'; + } + }); +} +window.onload = function() { + initApp(); +} + +// [START run_end_user_firebase_sign_in] +function signIn() { + var provider = new firebase.auth.GoogleAuthProvider(); + provider.addScope('https://www.googleapis.com/auth/userinfo.email'); + firebase.auth().signInWithPopup(provider).then(function(result) { + // Returns the signed in user along with the provider's credential + console.log(`${result.user.displayName} logged in.`); + window.alert(`Welcome ${result.user.displayName}!`) + }).catch((err) => { + console.log(`Error during sign in: ${err.message}`) + }); +} +// [END run_end_user_firebase_sign_in] + +function signOut() { + firebase.auth().signOut().then(function(result) { + }).catch((err) => { + console.log(`Error during sign out: ${err.message}`) + }) +} + +// Toggle Sign in/out button +function toggle() { + if (!firebase.auth().currentUser) { + signIn(); + } else { + signOut(); + } +} + +// [START run_end_user_token] +async function vote(team) { + if (firebase.auth().currentUser) { + // Retrieve JWT to identify the user to the Identity Platform service. + // Returns the current token if it has not expired. Otherwise, this will + // refresh the token and return a new one. + try { + const token = await firebase.auth().currentUser.getIdToken(); + const response = await fetch('/', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': `Bearer ${token}` + }, + body: 'team=' + team, // send application data (vote) + }); + if (response.ok) { + const text = await response.text(); + window.alert(text); + window.location.reload(); + } + } catch (err) { + console.log('Error when voting: ' + err); + window.alert('Somthing went wrong... Please try again!'); + } + } else { + window.alert('User not signed in.'); + } +} +// [END run_end_user_token] diff --git a/run/idp-sql/test/app.test.js b/run/idp-sql/test/app.test.js new file mode 100644 index 0000000000..133d622804 --- /dev/null +++ b/run/idp-sql/test/app.test.js @@ -0,0 +1,56 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +'use strict'; + +const assert = require('assert'); +const path = require('path'); +const supertest = require('supertest'); +const { buildRenderedHtml } = require('../handlebars'); + +let request; + +describe('Unit Tests', () => { + before(async () => { + const app = require(path.join(__dirname, '..', 'app')); + request = supertest(app); + }); + + it('should reject request without JWT token', async () => { + await request + .post('/') + .expect(401); + }); + + it('should reject request with invalid JWT token', async () => { + await request + .post('/') + .set('Authorization', 'Bearer iam-a-token') + .expect(403); + }); + + it('should render an html page', async () => { + const renderedHtml = await buildRenderedHtml({ + votes: [], + catsCount: 1, + dogsCount: 100, + leadTeam: "Dogs", + voteDiff: 99, + leaderMessage: "Dogs are winning", + }); + + assert(renderedHtml.includes('

100 votes

')); + }) + +}); diff --git a/run/idp-sql/test/e2e_test_cleanup.yaml b/run/idp-sql/test/e2e_test_cleanup.yaml new file mode 100644 index 0000000000..7521b55ee7 --- /dev/null +++ b/run/idp-sql/test/e2e_test_cleanup.yaml @@ -0,0 +1,22 @@ +steps: + +- id: 'Delete resources' + name: 'gcr.io/cloud-builders/gcloud' + entrypoint: '/bin/bash' + args: + - '-c' + - | + ./test/retry.sh "gcloud secrets describe ${_SERVICE}-secrets" \ + "gcloud secrets delete ${_SERVICE}-secrets --quiet" + + ./test/retry.sh "gcloud container images describe gcr.io/${PROJECT_ID}/${_SERVICE}:${_VERSION}" \ + "gcloud container images delete gcr.io/${PROJECT_ID}/${_SERVICE}:${_VERSION} --quiet" + + ./test/retry.sh "gcloud run services describe ${_SERVICE} --region ${_REGION} --platform ${_PLATFORM}" \ + "gcloud run services delete ${_SERVICE} --region ${_REGION} --platform ${_PLATFORM} --quiet" + +substitutions: + _SERVICE: idp-sql + _VERSION: manual + _REGION: us-central1 + _PLATFORM: managed diff --git a/run/idp-sql/test/e2e_test_setup.yaml b/run/idp-sql/test/e2e_test_setup.yaml new file mode 100644 index 0000000000..ad80b78c5c --- /dev/null +++ b/run/idp-sql/test/e2e_test_setup.yaml @@ -0,0 +1,59 @@ +steps: + +- id: 'Add a Secret to Secret Manager' + name: 'gcr.io/cloud-builders/gcloud' + entrypoint: '/bin/bash' + args: + - '-c' + - | + sed -i "s/PROJECT_ID:REGION:INSTANCE/${_CLOUD_SQL_CONNECTION_NAME}/" postgres-secrets.json + sed -i "s/PASSWORD_SECRET/${_DB_PASSWORD}/" postgres-secrets.json + sed -i "s/\"DB_NAME\": \"postgres\"/\"DB_NAME\": \"${_DB_NAME}\"/" postgres-secrets.json + sed -i "s/\"DB_USER\": \"postgres\"/\"DB_USER\": \"${_DB_USER}\"/" postgres-secrets.json + + ./test/retry.sh "gcloud secrets create ${_SERVICE}-secrets \ + --replication-policy="automatic" \ + --data-file=postgres-secrets.json" + +- id: 'Build Container Image' + name: 'gcr.io/cloud-builders/docker' + entrypoint: '/bin/bash' + args: + - '-c' + - | + ./test/retry.sh "docker build -t gcr.io/${PROJECT_ID}/${_SERVICE}:${_VERSION} ." + +- id: 'Push Container Image' + name: 'gcr.io/cloud-builders/docker' + entrypoint: '/bin/bash' + args: + - '-c' + - | + ./test/retry.sh "docker push gcr.io/${PROJECT_ID}/${_SERVICE}:${_VERSION}" + +- id: 'Deploy to Cloud Run' + name: 'gcr.io/cloud-builders/gcloud:latest' + entrypoint: /bin/bash + args: + - '-c' + - | + ./test/retry.sh "gcloud run deploy ${_SERVICE} \ + --image gcr.io/${PROJECT_ID}/${_SERVICE}:${_VERSION} \ + --allow-unauthenticated \ + --region ${_REGION} \ + --platform ${_PLATFORM} \ + --add-cloudsql-instances ${_CLOUD_SQL_CONNECTION_NAME} \ + --update-env-vars CLOUD_SQL_CREDENTIALS_SECRET=projects/${PROJECT_ID}/secrets/${_SERVICE}-secrets/versions/latest" + +images: +- gcr.io/${PROJECT_ID}/${_SERVICE}:${_VERSION} + +substitutions: + _SERVICE: idp-sql + _VERSION: manual + _REGION: us-central1 + _PLATFORM: managed + _CLOUD_SQL_CONNECTION_NAME: $PROJECT_ID:us-central1:idp-sql-instance + _DB_NAME: postgres + _DB_USER: postgres + _DB_PASSWORD: password1234 diff --git a/run/idp-sql/test/retry.sh b/run/idp-sql/test/retry.sh new file mode 100755 index 0000000000..78385733f0 --- /dev/null +++ b/run/idp-sql/test/retry.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +## +# retry.sh +# Provides utility function commonly needed across Cloud Build pipelines to +# retry commands on failure. +# +# Usage: +# 1. Retry single command: +# +# ./retry.sh "CMD" +# +# 2. Retry with check: +# +# ./retry.sh "gcloud RESOURCE EXISTS?" "gcloud ACTION" +# +## + +# Usage: try "cmd1" "cmd2" +# If first cmd executes successfully then execute second cmd +runIfSuccessful() { + echo "running: $1" + $($1 > /dev/null) + if [ $? -eq 0 ]; then + echo "running: $2" + $($2 > /dev/null) + fi +} + +# Define max retries +max_attempts=3; +attempt_num=1; + +arg1="$1" +arg2="$2" + +if [ $# -eq 1 ] +then + cmd="$arg1" +else + cmd="runIfSuccessful \"$arg1\" \"$arg2\"" +fi + +until eval $cmd +do + if ((attempt_num==max_attempts)) + then + echo "Attempt $attempt_num / $max_attempts failed! No more retries left!" + exit + else + echo "Attempt $attempt_num / $max_attempts failed!" + sleep $((attempt_num++)) + fi +done diff --git a/run/idp-sql/test/system.test.js b/run/idp-sql/test/system.test.js new file mode 100644 index 0000000000..30d3554ff4 --- /dev/null +++ b/run/idp-sql/test/system.test.js @@ -0,0 +1,159 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +'use strict'; + +const assert = require('assert'); +const admin = require('firebase-admin'); +const got = require('got'); +const { execSync } = require('child_process'); + +admin.initializeApp(); + +describe('System Tests', () => { + // Retrieve Cloud Run service test config + const {GOOGLE_CLOUD_PROJECT} = process.env; + if (!GOOGLE_CLOUD_PROJECT) { + throw Error('"GOOGLE_CLOUD_PROJECT" env var not found.'); + } + let {SERVICE_NAME} = process.env; + if (!SERVICE_NAME) { + console.log('"SERVICE_NAME" env var not found. Defaulting to "idp-sql"'); + SERVICE_NAME = "idp-sql"; + } + const {SAMPLE_VERSION} = process.env; + const PLATFORM = 'managed'; + const REGION = 'us-central1'; + + // Retrieve Cloud SQL test config + const {CLOUD_SQL_CONNECTION_NAME} = process.env; + if (!CLOUD_SQL_CONNECTION_NAME) { + throw Error('"CLOUD_SQL_CONNECTION_NAME" env var not found.'); + } + const {DB_PASSWORD} = process.env; + if (!DB_PASSWORD) { + throw Error('"DB_PASSWORD" env var not found.'); + } + const {DB_NAME, DB_USER} = process.env; + + // Get Firebase Key to create Id Tokens + const {IDP_KEY} = process.env; + if (!IDP_KEY) { + throw Error('"IDP_KEY" env var not found.'); + } + + let BASE_URL, ID_TOKEN; + before(async () => { + // Deploy service using Cloud Build + const buildCmd = `gcloud builds submit --project ${GOOGLE_CLOUD_PROJECT} ` + + `--config ./test/e2e_test_setup.yaml ` + + `--substitutions _SERVICE=${SERVICE_NAME},_PLATFORM=${PLATFORM},_REGION=${REGION}` + + `,_DB_PASSWORD=${DB_PASSWORD},_CLOUD_SQL_CONNECTION_NAME=${CLOUD_SQL_CONNECTION_NAME}`; + if(SAMPLE_VERSION) buildCmd + `,_VERSION=${SAMPLE_VERSION}`; + if(DB_USER) buildCmd + `,_DB_USER=${DB_USER}`; + if(DB_NAME) buildCmd + `,_DB_NAME=${DB_NAME}`; + + console.log('Starting Cloud Build...'); + execSync(buildCmd); + console.log('Cloud Build completed.'); + + // Retrieve URL of Cloud Run service + const url = execSync( + `gcloud run services describe ${SERVICE_NAME} --project=${GOOGLE_CLOUD_PROJECT} ` + + `--platform=${PLATFORM} --region=${REGION} --format='value(status.url)'`); + BASE_URL = url.toString('utf-8'); + if (!BASE_URL) throw Error('Cloud Run service URL not found'); + + // Retrieve ID token for testing + const customToken = await admin.auth().createCustomToken('a-user-id'); + const response = await got(`https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${IDP_KEY}`, { + method: 'POST', + body: JSON.stringify({ + token: customToken, + returnSecureToken: true + }), + }); + const tokens = JSON.parse(response.body); + ID_TOKEN = tokens.idToken; + if (!ID_TOKEN) throw Error('Unable to acquire an ID token.'); + }) + + after(() => { + const cleanUpCmd = `gcloud builds submit --project ${GOOGLE_CLOUD_PROJECT} ` + + `--config ./test/e2e_test_cleanup.yaml ` + + `--substitutions _SERVICE=${SERVICE_NAME},_PLATFORM=${PLATFORM},_REGION=${REGION}` + if(SAMPLE_VERSION) cleanUpCmd + `,_VERSION=${SAMPLE_VERSION}`; + + execSync(cleanUpCmd); + }) + + it('Can successfully make a request', async () => { + const options = { + prefixUrl: BASE_URL.trim(), + retry: 3 + }; + const response = await got('', options); + assert.strictEqual(response.statusCode, 200); + }); + + it('Can make a POST request with token', async () => { + const options = { + prefixUrl: BASE_URL.trim(), + method: 'POST', + form: {team: 'DOGS'}, + headers: { + Authorization: `Bearer ${ID_TOKEN.trim()}` + }, + retry: 3 + }; + const response = await got('', options); + assert.strictEqual(response.statusCode, 200); + }); + + it('Can not make a POST request with bad token', async () => { + const options = { + prefixUrl: BASE_URL.trim(), + method: 'POST', + form: {team: 'DOGS'}, + headers: { + Authorization: `Bearer iam-a-token` + }, + retry: 3 + }; + let err; + try { + const response = await got('', options); + } catch (e) { + err = e; + } + assert.strictEqual(err.response.statusCode, 403); + }); + + it('Can not make a POST request with no token', async () => { + const options = { + prefixUrl: BASE_URL.trim(), + method: 'POST', + form: {team: 'DOGS'}, + retry: 3 + }; + let err; + try { + const response = await got('', options); + } catch (e) { + err = e; + } + assert.strictEqual(err.response.statusCode, 401); + }); + +}); diff --git a/run/idp-sql/views/index.html b/run/idp-sql/views/index.html new file mode 100644 index 0000000000..8d6cf7f938 --- /dev/null +++ b/run/idp-sql/views/index.html @@ -0,0 +1,66 @@ + + + + CATS v DOGS + + + + + + + + + + + + + + +
+
+

{{leaderMessage}}

+
+
+
+
+ 🐱 +

{{catsCount}} votes

+ +
+
+
+
+ 🐶 +

{{dogsCount}} votes

+ +
+
+
+

Recent Votes

+
    + {{#each votes}} +
  • + {{ternary this.candidate 'CATS' '🐱' '🐶'}} + A vote for {{this.candidate}} +

    was cast at {{this.time_cast}}

    +
  • + {{/each}} +
+
+ + +