'use strict';

const assert = require('assert').strict;
const common = require('../common');
const padManager = require('../../../node/db/PadManager');
const plugins = require('../../../static/js/pluginfw/plugin_defs');
const readOnlyManager = require('../../../node/db/ReadOnlyManager');
const settings = require('../../../node/utils/Settings');
const socketIoRouter = require('../../../node/handler/SocketIORouter');

describe(__filename, function () {
  this.timeout(30000);
  let agent;
  let authorize;
  const backups = {};
  const cleanUpPads = async () => {
    const padIds = ['pad', 'other-pad', 'päd'];
    await Promise.all(padIds.map(async (padId) => {
      if (await padManager.doesPadExist(padId)) {
        const pad = await padManager.getPad(padId);
        await pad.remove();
      }
    }));
  };
  let socket;

  before(async function () { agent = await common.init(); });
  beforeEach(async function () {
    backups.hooks = {};
    for (const hookName of ['preAuthorize', 'authenticate', 'authorize']) {
      backups.hooks[hookName] = plugins.hooks[hookName];
      plugins.hooks[hookName] = [];
    }
    backups.settings = {};
    for (const setting of ['editOnly', 'requireAuthentication', 'requireAuthorization', 'users']) {
      backups.settings[setting] = settings[setting];
    }
    settings.editOnly = false;
    settings.requireAuthentication = false;
    settings.requireAuthorization = false;
    settings.users = {
      admin: {password: 'admin-password', is_admin: true},
      user: {password: 'user-password'},
    };
    assert(socket == null);
    authorize = () => true;
    plugins.hooks.authorize = [{hook_fn: (hookName, {req}, cb) => cb([authorize(req)])}];
    await cleanUpPads();
  });
  afterEach(async function () {
    if (socket) socket.close();
    socket = null;
    await cleanUpPads();
    Object.assign(plugins.hooks, backups.hooks);
    Object.assign(settings, backups.settings);
  });

  describe('Normal accesses', function () {
    it('!authn anonymous cookie /p/pad -> 200, ok', async function () {
      const res = await agent.get('/p/pad').expect(200);
      socket = await common.connect(res);
      const clientVars = await common.handshake(socket, 'pad');
      assert.equal(clientVars.type, 'CLIENT_VARS');
    });
    it('!authn !cookie -> ok', async function () {
      socket = await common.connect(null);
      const clientVars = await common.handshake(socket, 'pad');
      assert.equal(clientVars.type, 'CLIENT_VARS');
    });
    it('!authn user /p/pad -> 200, ok', async function () {
      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
      socket = await common.connect(res);
      const clientVars = await common.handshake(socket, 'pad');
      assert.equal(clientVars.type, 'CLIENT_VARS');
    });
    it('authn user /p/pad -> 200, ok', async function () {
      settings.requireAuthentication = true;
      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
      socket = await common.connect(res);
      const clientVars = await common.handshake(socket, 'pad');
      assert.equal(clientVars.type, 'CLIENT_VARS');
    });

    for (const authn of [false, true]) {
      const desc = authn ? 'authn user' : '!authn anonymous';
      it(`${desc} read-only /p/pad -> 200, ok`, async function () {
        const get = (ep) => {
          let res = agent.get(ep);
          if (authn) res = res.auth('user', 'user-password');
          return res.expect(200);
        };
        settings.requireAuthentication = authn;
        let res = await get('/p/pad');
        socket = await common.connect(res);
        let clientVars = await common.handshake(socket, 'pad');
        assert.equal(clientVars.type, 'CLIENT_VARS');
        assert.equal(clientVars.data.readonly, false);
        const readOnlyId = clientVars.data.readOnlyId;
        assert(readOnlyManager.isReadOnlyId(readOnlyId));
        socket.close();
        res = await get(`/p/${readOnlyId}`);
        socket = await common.connect(res);
        clientVars = await common.handshake(socket, readOnlyId);
        assert.equal(clientVars.type, 'CLIENT_VARS');
        assert.equal(clientVars.data.readonly, true);
      });
    }

    it('authz user /p/pad -> 200, ok', async function () {
      settings.requireAuthentication = true;
      settings.requireAuthorization = true;
      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
      socket = await common.connect(res);
      const clientVars = await common.handshake(socket, 'pad');
      assert.equal(clientVars.type, 'CLIENT_VARS');
    });
    it('supports pad names with characters that must be percent-encoded', async function () {
      settings.requireAuthentication = true;
      // requireAuthorization is set to true here to guarantee that the user's padAuthorizations
      // object is populated. Technically this isn't necessary because the user's padAuthorizations
      // is currently populated even if requireAuthorization is false, but setting this to true
      // ensures the test remains useful if the implementation ever changes.
      settings.requireAuthorization = true;
      const encodedPadId = encodeURIComponent('päd');
      const res = await agent.get(`/p/${encodedPadId}`).auth('user', 'user-password').expect(200);
      socket = await common.connect(res);
      const clientVars = await common.handshake(socket, 'päd');
      assert.equal(clientVars.type, 'CLIENT_VARS');
    });
  });

  describe('Abnormal access attempts', function () {
    it('authn anonymous /p/pad -> 401, error', async function () {
      settings.requireAuthentication = true;
      const res = await agent.get('/p/pad').expect(401);
      // Despite the 401, try to create the pad via a socket.io connection anyway.
      socket = await common.connect(res);
      const message = await common.handshake(socket, 'pad');
      assert.equal(message.accessStatus, 'deny');
    });

    it('authn anonymous read-only /p/pad -> 401, error', async function () {
      settings.requireAuthentication = true;
      let res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
      socket = await common.connect(res);
      const clientVars = await common.handshake(socket, 'pad');
      assert.equal(clientVars.type, 'CLIENT_VARS');
      const readOnlyId = clientVars.data.readOnlyId;
      assert(readOnlyManager.isReadOnlyId(readOnlyId));
      socket.close();
      res = await agent.get(`/p/${readOnlyId}`).expect(401);
      // Despite the 401, try to read the pad via a socket.io connection anyway.
      socket = await common.connect(res);
      const message = await common.handshake(socket, readOnlyId);
      assert.equal(message.accessStatus, 'deny');
    });

    it('authn !cookie -> error', async function () {
      settings.requireAuthentication = true;
      socket = await common.connect(null);
      const message = await common.handshake(socket, 'pad');
      assert.equal(message.accessStatus, 'deny');
    });
    it('authorization bypass attempt -> error', async function () {
      // Only allowed to access /p/pad.
      authorize = (req) => req.path === '/p/pad';
      settings.requireAuthentication = true;
      settings.requireAuthorization = true;
      // First authenticate and establish a session.
      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
      socket = await common.connect(res);
      // Accessing /p/other-pad should fail, despite the successful fetch of /p/pad.
      const message = await common.handshake(socket, 'other-pad');
      assert.equal(message.accessStatus, 'deny');
    });
  });

  describe('Authorization levels via authorize hook', function () {
    beforeEach(async function () {
      settings.requireAuthentication = true;
      settings.requireAuthorization = true;
    });

    it("level='create' -> can create", async function () {
      authorize = () => 'create';
      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
      socket = await common.connect(res);
      const clientVars = await common.handshake(socket, 'pad');
      assert.equal(clientVars.type, 'CLIENT_VARS');
      assert.equal(clientVars.data.readonly, false);
    });
    it('level=true -> can create', async function () {
      authorize = () => true;
      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
      socket = await common.connect(res);
      const clientVars = await common.handshake(socket, 'pad');
      assert.equal(clientVars.type, 'CLIENT_VARS');
      assert.equal(clientVars.data.readonly, false);
    });
    it("level='modify' -> can modify", async function () {
      await padManager.getPad('pad'); // Create the pad.
      authorize = () => 'modify';
      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
      socket = await common.connect(res);
      const clientVars = await common.handshake(socket, 'pad');
      assert.equal(clientVars.type, 'CLIENT_VARS');
      assert.equal(clientVars.data.readonly, false);
    });
    it("level='create' settings.editOnly=true -> unable to create", async function () {
      authorize = () => 'create';
      settings.editOnly = true;
      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
      socket = await common.connect(res);
      const message = await common.handshake(socket, 'pad');
      assert.equal(message.accessStatus, 'deny');
    });
    it("level='modify' settings.editOnly=false -> unable to create", async function () {
      authorize = () => 'modify';
      settings.editOnly = false;
      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
      socket = await common.connect(res);
      const message = await common.handshake(socket, 'pad');
      assert.equal(message.accessStatus, 'deny');
    });
    it("level='readOnly' -> unable to create", async function () {
      authorize = () => 'readOnly';
      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
      socket = await common.connect(res);
      const message = await common.handshake(socket, 'pad');
      assert.equal(message.accessStatus, 'deny');
    });
    it("level='readOnly' -> unable to modify", async function () {
      await padManager.getPad('pad'); // Create the pad.
      authorize = () => 'readOnly';
      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
      socket = await common.connect(res);
      const clientVars = await common.handshake(socket, 'pad');
      assert.equal(clientVars.type, 'CLIENT_VARS');
      assert.equal(clientVars.data.readonly, true);
    });
  });

  describe('Authorization levels via user settings', function () {
    beforeEach(async function () {
      settings.requireAuthentication = true;
    });

    it('user.canCreate = true -> can create and modify', async function () {
      settings.users.user.canCreate = true;
      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
      socket = await common.connect(res);
      const clientVars = await common.handshake(socket, 'pad');
      assert.equal(clientVars.type, 'CLIENT_VARS');
      assert.equal(clientVars.data.readonly, false);
    });
    it('user.canCreate = false -> unable to create', async function () {
      settings.users.user.canCreate = false;
      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
      socket = await common.connect(res);
      const message = await common.handshake(socket, 'pad');
      assert.equal(message.accessStatus, 'deny');
    });
    it('user.readOnly = true -> unable to create', async function () {
      settings.users.user.readOnly = true;
      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
      socket = await common.connect(res);
      const message = await common.handshake(socket, 'pad');
      assert.equal(message.accessStatus, 'deny');
    });
    it('user.readOnly = true -> unable to modify', async function () {
      await padManager.getPad('pad'); // Create the pad.
      settings.users.user.readOnly = true;
      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
      socket = await common.connect(res);
      const clientVars = await common.handshake(socket, 'pad');
      assert.equal(clientVars.type, 'CLIENT_VARS');
      assert.equal(clientVars.data.readonly, true);
    });
    it('user.readOnly = false -> can create and modify', async function () {
      settings.users.user.readOnly = false;
      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
      socket = await common.connect(res);
      const clientVars = await common.handshake(socket, 'pad');
      assert.equal(clientVars.type, 'CLIENT_VARS');
      assert.equal(clientVars.data.readonly, false);
    });
    it('user.readOnly = true, user.canCreate = true -> unable to create', async function () {
      settings.users.user.canCreate = true;
      settings.users.user.readOnly = true;
      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
      socket = await common.connect(res);
      const message = await common.handshake(socket, 'pad');
      assert.equal(message.accessStatus, 'deny');
    });
  });

  describe('Authorization level interaction between authorize hook and user settings', function () {
    beforeEach(async function () {
      settings.requireAuthentication = true;
      settings.requireAuthorization = true;
    });

    it('authorize hook does not elevate level from user settings', async function () {
      settings.users.user.readOnly = true;
      authorize = () => 'create';
      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
      socket = await common.connect(res);
      const message = await common.handshake(socket, 'pad');
      assert.equal(message.accessStatus, 'deny');
    });
    it('user settings does not elevate level from authorize hook', async function () {
      settings.users.user.readOnly = false;
      settings.users.user.canCreate = true;
      authorize = () => 'readOnly';
      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
      socket = await common.connect(res);
      const message = await common.handshake(socket, 'pad');
      assert.equal(message.accessStatus, 'deny');
    });
  });

  describe('SocketIORouter.js', function () {
    const Module = class {
      setSocketIO(io) {}
      handleConnect(socket) {}
      handleDisconnect(socket) {}
      handleMessage(socket, message) {}
    };

    afterEach(async function () {
      socketIoRouter.deleteComponent(this.test.fullTitle());
      socketIoRouter.deleteComponent(`${this.test.fullTitle()} #2`);
    });

    it('setSocketIO', async function () {
      let ioServer;
      socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module {
        setSocketIO(io) { ioServer = io; }
      }());
      assert(ioServer != null);
    });

    it('handleConnect', async function () {
      let serverSocket;
      socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module {
        handleConnect(socket) { serverSocket = socket; }
      }());
      socket = await common.connect();
      assert(serverSocket != null);
    });

    it('handleDisconnect', async function () {
      let resolveConnected;
      const connected = new Promise((resolve) => resolveConnected = resolve);
      let resolveDisconnected;
      const disconnected = new Promise((resolve) => resolveDisconnected = resolve);
      socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module {
        handleConnect(socket) {
          this._socket = socket;
          resolveConnected();
        }
        handleDisconnect(socket) {
          assert(socket != null);
          // There might be lingering disconnect events from sockets created by other tests.
          if (this._socket == null || socket.id !== this._socket.id) return;
          assert.equal(socket, this._socket);
          resolveDisconnected();
        }
      }());
      socket = await common.connect();
      await connected;
      socket.close();
      socket = null;
      await disconnected;
    });

    it('handleMessage (success)', async function () {
      let serverSocket;
      const want = {
        component: this.test.fullTitle(),
        foo: {bar: 'asdf'},
      };
      let rx;
      const got = new Promise((resolve) => { rx = resolve; });
      socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module {
        handleConnect(socket) { serverSocket = socket; }
        handleMessage(socket, message) { assert.equal(socket, serverSocket); rx(message); }
      }());
      socketIoRouter.addComponent(`${this.test.fullTitle()} #2`, new class extends Module {
        handleMessage(socket, message) { assert.fail('wrong handler called'); }
      }());
      socket = await common.connect();
      socket.send(want);
      assert.deepEqual(await got, want);
    });

    const tx = async (socket, message = {}) => await new Promise((resolve, reject) => {
      const AckErr = class extends Error {
        constructor(name, ...args) { super(...args); this.name = name; }
      };
      socket.send(message,
          (errj, val) => errj != null ? reject(new AckErr(errj.name, errj.message)) : resolve(val));
    });

    it('handleMessage with ack (success)', async function () {
      const want = 'value';
      socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module {
        handleMessage(socket, msg) { return want; }
      }());
      socket = await common.connect();
      const got = await tx(socket, {component: this.test.fullTitle()});
      assert.equal(got, want);
    });

    it('handleMessage with ack (error)', async function () {
      const InjectedError = class extends Error {
        constructor() { super('injected test error'); this.name = 'InjectedError'; }
      };
      socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module {
        handleMessage(socket, msg) { throw new InjectedError(); }
      }());
      socket = await common.connect();
      await assert.rejects(tx(socket, {component: this.test.fullTitle()}), new InjectedError());
    });
  });
});
