text: /* eslint-env node, mocha, browser */
  /* global browser,
            bufferEncode,
            bufferDecode,
            last_post_request,
            last_put_request */
  
  const { join } = require('path');
  const { readFile } = require('fs').promises;
  const mod_fastify = require('fastify');
  const fastify_static = require('fastify-static');
  const makeWebAuthn = require('..');
  const { expect } = require('chai');
  const crypto = require('crypto');
  const { promisify } = require('util');
  
  const port = 3000;
  const origin = `https://localhost:${port}`;
  const username = 'foo@bar.com';
  const username2 = 'foo2@bar.com';
  const username3 = 'foo3@bar.com';
  
  const config = {
      RPDisplayName: 'WebAuthnJS',
      RPID: 'localhost',
      RPOrigin: origin,
      RPIcon: `${origin}/test/logo.png`,
      AuthenticatorSelection: {
          userVerification: 'preferred'
      }
  };
  
  class ErrorWithStatus extends Error {
      constructor(message, statusCode) {
          super(message);
          this.statusCode = statusCode;
      }
  }
  
  function b64url(b64) {
      return b64
          .replace(/\+/g, "-")
          .replace(/\//g, "_")
          .replace(/=/g, "");
  }
  
  const users = new Map();
  
  before(async function () {
      const keys_dir = join(__dirname, 'keys');
  
      const fastify = mod_fastify({
          logger: true,
          https: {
              key: await readFile(join(keys_dir, 'server.key')),
              cert: await readFile(join(keys_dir, 'server.crt'))
          }
      });
  
      fastify.register(fastify_static, {
          root: join(__dirname, 'fixtures'),
          prefix: '/test'
      });
  
      const webAuthn = await makeWebAuthn(config);
  
      const {
          make_secret_session_data,
          verify_secret_session_data
      } = await import('./example/session.mjs');
  
      const schemas = await import('./example/schemas.mjs');
  
      async function register(fastify) {
          fastify.get('/:username', {
              schema: schemas.register.get
          }, async request => {
              let user = users.get(request.params.username);
              if (!user) {
                  user = {
                      id: `user${users.size}`,
                      name: request.params.username,
                      displayName: request.params.username.split('@')[0],
                      iconURL: '',
                      credentials: []
                  };
                  if (request.params.username === username2) {
                      user.id = Buffer.from(user.id);
                  }
                  users.set(request.params.username, user);
              }
              const excludeCredentials = user.credentials.map(c => ({
                  type: 'public-key',
                  id: c.ID
              }));
              const { options, sessionData } = await webAuthn.beginRegistration(
                  user,
                  cco => {
                      cco.excludeCredentials = excludeCredentials;
                      return cco;
                  });
              return {
                  options,
                  session_data: await make_secret_session_data(
                      request.params.username, 'registration', sessionData)
              };
          });
  
          fastify.put('/:username', {
              schema: schemas.register.put
          }, async (request, reply) => {
              const user = users.get(request.params.username);
              if (!user) {
                  throw new ErrorWithStatus('no user', 404);
              }
              const session_data = await verify_secret_session_data(
                  request.params.username, 'registration', request.body.session_data);
              let credential;
              try {
                  credential = await webAuthn.finishRegistration(
                      user, session_data, request.body.ccr);
              } catch (ex) {
                  ex.statusCode = 400;
                  throw ex;
              }
              for (const u of users.values()) {
                  if (u.credentials.find(c => c.ID === credential.ID)) {
                      throw new ErrorWithStatus('credential in use', 409);
                  }
              }
              user.credentials.push(credential);
              reply.code(204);
          });
      }
  
      async function login(fastify) {
          fastify.get('/:username', {
              schema: schemas.login.get
          }, async request => {
              const user = users.get(request.params.username);
              if (!user) {
                  throw new ErrorWithStatus('no user', 404);
              }
              const { options, sessionData } = await webAuthn.beginLogin(user);
              return {
                  options,
                  session_data: await make_secret_session_data(
                      request.params.username, 'login', sessionData)
              };
          });
  
          fastify.post('/:username', {
              schema: schemas.login.post
          }, async (request, reply) => {
              const user = users.get(request.params.username);
              if (!user) {
                  throw new ErrorWithStatus('no user', 404);
              }
              const session_data = await verify_secret_session_data(
                  request.params.username, 'login', request.body.session_data);
              let credential;
              try {
                  credential = await webAuthn.finishLogin(
                      user, session_data, request.body.car);
              } catch (ex) {
                  ex.statusCode = 400;
                  throw ex;
              }
              if (credential.Authenticator.CloneWarning) {
                  throw new ErrorWithStatus('credential appears to be cloned', 403);
              }
              const user_cred = user.credentials.find(c => c.ID === credential.ID);
              /* istanbul ignore if */
              if (!user_cred) {
                  // Should have been checked already in Go by webAuthn.finishLogin
                  throw new ErrorWithStatus('no credential', 500);
              }
              user_cred.Authenticator.SignCount = credential.Authenticator.SignCount;
              reply.code(204);
          });
      }
  
      fastify.register(register, {
          prefix: '/register/'
      });
  
      fastify.register(login, {
          prefix: '/login/'
      });
  
      await fastify.listen(port);
  
      browser.config.after.push(async function () {
          await fastify.close();
          webAuthn.exit();
  
          // Check Go exit isn't called twice by our beforeExit handler and
          // prevent an error being throw before exit due to Go complaining
          // that it hasn't exited. Note async-exit-hook stops our beforeExit
          // handlers being called as they normally would.
          const handlers = process.listeners('beforeExit');
          // First handler is async-exit-hook
          for (let i = 1; i < handlers.length; ++i) {
              handlers[i]();
          }
      });
  
      await browser.url(`${origin}/test/test.html`);
  });
  
  /* istanbul ignore next */
  async function executeAsync(f, ...args) {
      const r = await browser.executeAsync(function (f, ...args) {
          (async function () {
              let done = args[args.length - 1];
              window.bufferDecode = function (value) {
                  return Uint8Array.from(atob(value), c => c.charCodeAt(0));
              };
              window.bufferEncode = function (value) {
                  return btoa(String.fromCharCode.apply(null, new Uint8Array(value)))
                      .replace(/\+/g, "-")
                      .replace(/\//g, "_")
                      .replace(/=/g, "");
              };
              try {
                  done(await eval(f)(...args.slice(0, -1)));
              } catch (ex) {
                  done({ err: ex.message });
              }
          })();
      }, f.toString(), ...args);
  
      if (r && r.err) {
          throw new Error(r.err);
      }
  
      return r;
  }
  
  /* istanbul ignore next */
  async function register(username, opts) {
      return await executeAsync(async (username, opts) => {
          opts = Object.assign({
              alter_challenge: false
          }, opts);
          const get_response = await fetch(`/register/${username}`);
          if (!get_response.ok) {
              throw new Error(`Registration GET failed with ${get_response.status} ${await get_response.text()}`);
          }
          const { options, session_data } = await get_response.json();
          const { publicKey } = options;
          publicKey.challenge = bufferDecode(publicKey.challenge);
          publicKey.user.id = bufferDecode(publicKey.user.id);
          if (publicKey.excludeCredentials) {
              for (const c of publicKey.excludeCredentials) {
                  c.id = bufferDecode(c.id);
              }
          }
          if (opts.alter_challenge) {
              publicKey.challenge[0] ^= 0xff;
          }
          const credential = await navigator.credentials.create(options);
          const { id, rawId, type, response: cred_response } = credential;
          const { attestationObject, clientDataJSON } = cred_response;
          const put_request =  {
              method: 'PUT',
              headers: {
                  'Content-Type': 'application/json'
              },
              body: JSON.stringify({
                  ccr: {
                      id,
                      rawId: bufferEncode(rawId),
                      type,
                      response: {
                          attestationObject: bufferEncode(attestationObject),
                          clientDataJSON: bufferEncode(clientDataJSON)
                      }
                  },
                  session_data
              })
          };
          const put_response = await fetch(`/register/${username}`, put_request);
          if (!put_response.ok) {
              throw new Error(`Registration PUT failed with ${put_response.status} ${await put_response.text()}`);
          }
          window.last_put_request = put_request;
          return { id, type };
      }, username, opts);
  }
  
  /* istanbul ignore next */
  async function login(username, opts) {
      return await executeAsync(async (username, opts) => {
          opts = Object.assign({
              post_username: username,
              session_username: username
          }, opts);
          const get_response = await fetch(`/login/${username}`);
          if (!get_response.ok) {
              throw new Error(`Login GET failed with ${get_response.status} ${await get_response.text()}`);
          }
          let { options, session_data } = await get_response.json();
          const { publicKey } = options;
          publicKey.challenge = bufferDecode(publicKey.challenge);
          for (const c of publicKey.allowCredentials) {
              c.id = bufferDecode(c.id);
          }
          const assertion = await navigator.credentials.get(options);
          const { id, rawId, type, response: assertion_response } = assertion;
          const { authenticatorData, clientDataJSON, signature, userHandle } = assertion_response;
          if (opts.session_username !== username) {
              const get_response = await fetch(`/login/${opts.session_username}`);
              if (!get_response.ok) {
                  throw new Error(`Login GET failed with ${get_response.status} ${await get_response.text()}`);
              }
              ({ session_data } = await get_response.json());
          }
          if (opts.modify_sig) {
              const sigview = new Uint8Array(signature);
              for (let i = 0; i < sigview.length; ++i) {
                  sigview[i] ^= 1;
              }
          }
          const post_request = {
              method: 'POST',
              headers: {
                  'Content-Type': 'application/json'
              },
              body: JSON.stringify({
                  car: {
                      id,
                      rawId: bufferEncode(rawId),
                      type,
                      response: {
                          authenticatorData: bufferEncode(authenticatorData),
                          clientDataJSON: bufferEncode(clientDataJSON),
                          signature: bufferEncode(signature),
                          userHandle: bufferEncode(userHandle)
                      }
                  },
                  session_data
              })
          };
          const post_response = await fetch(`/login/${opts.post_username}`, post_request);
          if (!post_response.ok) {
              throw new Error(`Login POST failed with ${post_response.status} ${await post_response.text()}`);
          }
          window.last_post_request = post_request;
          return { id, type }; 
      }, username, opts);
  }
  
  let cred_id;
  let cred_pubkey;
  
  describe('register', function () {
      it('should register credential', async function () {
          const { id, type } = await register(username);
  
          expect(users.size).to.equal(1);
  
          expect(type).to.equal('public-key');
  
          const user = users.get(username);
  
          expect(user.id).to.equal('user0');
          expect(user.name).to.equal(username);
          expect(user.displayName).to.equal('foo');
          expect(user.iconURL).to.equal('');
  
          expect(user.credentials.length).to.equal(1);
  
          const cred = user.credentials[0];
          // id returned from credentials.create is b64url encoded
          // ID from Go is b64 encoded
          expect(b64url(cred.ID)).to.equal(id);
          expect(cred.AttestationType).to.equal('none');
          expect(cred.Authenticator.SignCount).to.equal(0);
          expect(cred.Authenticator.CloneWarning).to.be.false;
  
          cred_id = id;
          cred_pubkey = cred.PublicKey;
      });
  
      it('should fail to use same credential', async function () {
          let ex;
          try {
              await register(username);
          } catch (e) {
              ex = e;
          }
          expect(ex.message).to.equal('An attempt was made to use an object that is not, or is no longer, usable');
      });
  
      it('should register second user', async function () {
          const { id, type } = await register(username2);
  
          expect(users.size).to.equal(2);
  
          expect(type).to.equal('public-key');
  
          const user = users.get(username);
  
          expect(user.id).to.equal('user0');
          expect(user.name).to.equal(username);
          expect(user.displayName).to.equal('foo');
          expect(user.iconURL).to.equal('');
  
          expect(user.credentials.length).to.equal(1);
  
          const cred = user.credentials[0];
          expect(b64url(cred.ID)).to.equal(cred_id);
          expect(cred.AttestationType).to.equal('none');
          expect(cred.Authenticator.SignCount).to.equal(0);
          expect(cred.Authenticator.CloneWarning).to.be.false;
          expect(cred.PublicKey).to.equal(cred_pubkey);
  
          const user2 = users.get(username2);
  
          expect(user2.id.toString()).to.equal('user1');
          expect(user2.name).to.equal(username2);
          expect(user2.displayName).to.equal('foo2');
          expect(user2.iconURL).to.equal('');
  
          expect(user2.credentials.length).to.equal(1);
  
          const cred2 = user2.credentials[0];
          expect(b64url(cred2.ID)).to.equal(id);
          expect(cred2.AttestationType).to.equal('none');
          expect(cred2.Authenticator.SignCount).to.equal(0);
          expect(cred2.Authenticator.CloneWarning).to.be.false;
          expect(b64url(cred2.ID)).not.to.equal(cred_id);
          expect(cred2.PublicKey).not.to.equal(cred_pubkey);
      });
  
      it('should fail to register with duplicate attestation', async function () {
          let ex;
          try {
              /* istanbul ignore next */
              await executeAsync(async username => {
                  const put_response = await fetch(`/register/${username}`, last_put_request);
                  if (!put_response.ok) {
                      throw new Error(`Registration PUT failed with ${put_response.status} ${await put_response.text()}`);
                  }
              }, username2);
          } catch (e) {
              ex = e;
          }
          expect(ex.message).to.equal('Registration PUT failed with 409 {"statusCode":409,"error":"Conflict","message":"credential in use"}');
      });
  
      it('should fail to register with wrong challenge', async function () {
          let ex;
          try {
              await register(username3, { alter_challenge: true });
          } catch (e) {
              ex = e;
          }
          expect(ex.message).to.equal('Registration PUT failed with 400 {"statusCode":400,"error":"Bad Request","message":"Error validating challenge"}');
      });
  });
  
  describe('login', function () {
      it('should login', async function () {
          const { id, type } = await login(username);
  
          // username3 although not registered is still in the DB
          expect(users.size).to.equal(3);
  
          expect(type).to.equal('public-key');
  
          const user = users.get(username);
  
          expect(user.id).to.equal('user0');
          expect(user.name).to.equal(username);
          expect(user.displayName).to.equal('foo');
          expect(user.iconURL).to.equal('');
  
          expect(user.credentials.length).to.equal(1);
  
          const cred = user.credentials[0];
          expect(b64url(cred.ID)).to.equal(id);
          expect(cred.AttestationType).to.equal('none');
          expect(cred.Authenticator.SignCount).to.equal(1);
          expect(cred.Authenticator.CloneWarning).to.be.false;
      });
  
      it('should fail to login with duplicate assertion', async function () {
          let ex;
          try {
              /* istanbul ignore next */
              await executeAsync(async username => {
                  const post_response = await fetch(`/login/${username}`, last_post_request);
                  if (!post_response.ok) {
                      throw new Error(`Login POST failed with ${post_response.status} ${await post_response.text()}`);
                  }
              }, username);
          } catch (e) {
              ex = e;
          }
          expect(ex.message).to.equal('Login POST failed with 403 {"statusCode":403,"error":"Forbidden","message":"credential appears to be cloned"}');
      });
  
      it('should fail to login as different user', async function () {
          let ex;
          try {
              await login(username, {
                  post_username: username2,
                  session_username: username2
              });
          } catch (e) {
              ex = e;
          }
          expect(ex.message).to.equal('Login POST failed with 400 {"statusCode":400,"error":"Bad Request","message":"User does not own the credential returned"}');
      });
  
      it('should check username in session', async function () {
          let ex;
          try {
              await login(username, { post_username: username2 });
          } catch (e) {
              ex = e;
          }
          expect(ex.message).to.equal('Login POST failed with 400 {"statusCode":400,"error":"Bad Request","message":"wrong username"}');
      });
  
      it('should check session type', async function () {
          let ex;
          try {
              /* istanbul ignore next */
              await executeAsync(async username => {
                  const get_response = await fetch(`/register/${username}`);
                  if (!get_response.ok) {
                      throw new Error(`Registration GET failed with ${get_response.status} ${await get_response.text()}`);
                  }
                  const { session_data } = await get_response.json();
                  const request = Object.assign({}, last_post_request);
                  const body = JSON.parse(request.body);
                  body.session_data = session_data;
                  request.body = JSON.stringify(body);
                  const post_response = await fetch(`/login/${username}`, request);
                  if (!post_response.ok) {
                      throw new Error(`Login POST failed with ${post_response.status} ${await post_response.text()}`);
                  }
              }, username);
          } catch (e) {
              ex = e;
          }
          expect(ex.message).to.equal('Login POST failed with 400 {"statusCode":400,"error":"Bad Request","message":"wrong type"}');
      });
  
      it('should check user exists', async function () {
          let ex;
          try {
              /* istanbul ignore next */
              await executeAsync(async username => {
                  const put_response = await fetch(`/register/${username}`, last_put_request);
                  if (!put_response.ok) {
                      throw new Error(`Registration PUT failed with ${put_response.status} ${await put_response.text()}`);
                  }
              }, 'bar@foo.com');
          } catch (e) {
              ex = e;
          }
          expect(ex.message).to.equal('Registration PUT failed with 404 {"statusCode":404,"error":"Not Found","message":"no user"}');
  
          try {
              /* istanbul ignore next */
              await executeAsync(async username => {
                  const get_response = await fetch(`/login/${username}`);
                  if (!get_response.ok) {
                      throw new Error(`Login GET failed with ${get_response.status} ${await get_response.text()}`);
                  }
              }, 'bar@foo.com');
          } catch (e) {
              ex = e;
          }
          expect(ex.message).to.equal('Login GET failed with 404 {"statusCode":404,"error":"Not Found","message":"no user"}');
  
          try {
              /* istanbul ignore next */
              await executeAsync(async username => {
                  const post_response = await fetch(`/login/${username}`, last_post_request);
                  if (!post_response.ok) {
                      throw new Error(`Login POST failed with ${post_response.status} ${await post_response.text()}`);
                  }
              }, 'bar@foo.com');
          } catch (e) {
              ex = e;
          }
          expect(ex.message).to.equal('Login POST failed with 404 {"statusCode":404,"error":"Not Found","message":"no user"}');
      });
  
      it('should check timestamp', async function () {
          let ex;
          const orig_now = Date.now;
          Date.now = function (dummy) {
              let now = orig_now.call(this);
              if (dummy === 'dummy') {
                  now += 60000;
              }
              return now;
          };
          try {
              /* istanbul ignore next */
              await executeAsync(async username => {
                  const get_response = await fetch(`/login/${username}`);
                  if (!get_response.ok) {
                      throw new Error(`Login GET failed with ${get_response.status} ${await get_response.text()}`);
                  }
                  const { session_data } = await get_response.json();
                  const request = Object.assign({}, last_post_request);
                  const body = JSON.parse(request.body);
                  body.session_data = session_data;
                  request.body = JSON.stringify(body);
                  const post_response = await fetch(`/login/${username}`, request);
                  if (!post_response.ok) {
                      throw new Error(`Login POST failed with ${post_response.status} ${await post_response.text()}`);
                  }
              }, username);
          } catch (e) {
              ex = e;
          } finally {
              Date.now = orig_now;
          }
          expect(ex.message).to.equal('Login POST failed with 400 {"statusCode":400,"error":"Bad Request","message":"session timed out"}');
      });
  
      it('should login second user', async function () {
          const { id, type } = await login(username2);
  
          // username3 although not registered is still in the DB
          expect(users.size).to.equal(3);
  
          expect(type).to.equal('public-key');
  
          const user = users.get(username2);
  
          expect(user.id.toString()).to.equal('user1');
          expect(user.name).to.equal(username2);
          expect(user.displayName).to.equal('foo2');
          expect(user.iconURL).to.equal('');
  
          expect(user.credentials.length).to.equal(1);
  
          const cred = user.credentials[0];
          expect(b64url(cred.ID)).to.equal(id);
          expect(cred.AttestationType).to.equal('none');
          expect(cred.Authenticator.SignCount).to.equal(4);
          expect(cred.Authenticator.CloneWarning).to.be.false;
      });
  
      it('should fail to login third user', async function () {
          let ex;
          try {
              await login(username3);
          } catch (e) {
              ex = e;
          }
          expect(ex.message).to.equal('Login GET failed with 500 {"statusCode":500,"error":"Internal Server Error","message":"Found no credentials for user"}');
      });
  
      it('should fail to verify bad signature', async function () {
          let ex;
          try {
              await login(username, { modify_sig: true });
          } catch (e) {
              ex = e;
          }
          expect(ex.message).to.equal('Login POST failed with 400 {"statusCode":400,"error":"Bad Request","message":"Error validating the assertion signature: Signature invalid or not provided\\n"}');
          await login(username);
      });
  });
  
  describe('init', function () {
      it('should error with invalid config', async function () {
          let ex;
          try {
              await makeWebAuthn({});
          } catch (e) {
              ex = e;
          }
          expect(ex.message).to.equal('Configuration error: Missing RPDisplayName');
          // Note: also tests exit without webauthn being made
      });
  
      it('should error if it fails to generate random bytes', async function () {
          let ex;
          const orig_randomBytes = crypto.randomBytes;
          crypto.randomBytes = (n, cb) => cb(new Error('dummy'));
          try {
              await makeWebAuthn({});
          } catch (e) {
              ex = e;
          } finally {
              crypto.randomBytes = orig_randomBytes;
          }
          expect(ex.message).to.equal('dummy');
      });
  
      it('should emit exit event', async function () {
          const webAuthn = await makeWebAuthn(config);
          await promisify(cb => {
              webAuthn.on('exit', n => {
                  expect(n).to.equal(0);
                  cb();
              });
              webAuthn.exit();
          })();
      });
  
      it('should pass through exit code', async function () {
          const webAuthn = await makeWebAuthn(config);
          await promisify(cb => {
              webAuthn.on('error', err => {
                  expect(err.message).to.equal('wasm_exec exit with code 123');
              });
              webAuthn.on('exit', n => {
                  expect(n).to.equal(123);
                  cb();
              });
              webAuthn.exit(123);
          })();
      });
  
      it('should catch errors instantiating wasm', async function () {
          const p = promisify(cb => {
              const handler = evname => {
                  if (evname === 'beforeExit') {
                      process.removeListener('newListener', handler);
                      cb();
                  }
              };
              process.on('newListener', handler);
          })();
          const Mod = require('module');
          const req = Mod.prototype.require;
          Mod.prototype.require = function (f) {
              const r = req.call(this, f);
              if (f === './wasm_exec.js') {
                  return (...args) => {
                      r(...args);
                      throw new Error('dummy');
                  };
              }
              return r;
          };
          let ex;
          try {
              await makeWebAuthn(config);
          } catch (e) {
              ex = e;
          } finally {
              Mod.prototype.require = req;
          }
          expect(ex.message).to.equal('dummy');
          // Wait for beforeExit handler to be registered so it gets cleaned up
          // in browser.config.after
          await p;
      });
  });
  