312 lines
		
	
	
	
		
			8.2 KiB
			
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			312 lines
		
	
	
	
		
			8.2 KiB
			
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
// Copyright 2021 Signal Messenger, LLC
 | 
						|
// SPDX-License-Identifier: AGPL-3.0-only
 | 
						|
/* eslint-disable no-await-in-loop */
 | 
						|
/* eslint-disable @typescript-eslint/no-explicit-any */
 | 
						|
 | 
						|
import { assert } from 'chai';
 | 
						|
import lodash from 'lodash';
 | 
						|
import * as sinon from 'sinon';
 | 
						|
 | 
						|
import { STORAGE_KEY, ChallengeHandler } from '../challenge.js';
 | 
						|
import type { RegisteredChallengeType } from '../challenge.js';
 | 
						|
import { DAY, SECOND } from '../util/durations/index.js';
 | 
						|
 | 
						|
const { noop } = lodash;
 | 
						|
 | 
						|
type CreateHandlerOptions = {
 | 
						|
  readonly autoSolve?: boolean;
 | 
						|
  readonly challengeError?: Error;
 | 
						|
  readonly expireAfter?: number;
 | 
						|
  readonly onChallengeSolved?: () => void;
 | 
						|
  readonly onChallengeFailed?: (retryAt?: number) => void;
 | 
						|
};
 | 
						|
 | 
						|
const NOW = Date.now();
 | 
						|
const NEVER_RETRY = NOW + DAY;
 | 
						|
const IMMEDIATE_RETRY = NOW - DAY;
 | 
						|
 | 
						|
// Various timeouts in milliseconds
 | 
						|
const DEFAULT_RETRY_AFTER = 25;
 | 
						|
const SOLVE_AFTER = 5;
 | 
						|
 | 
						|
describe('ChallengeHandler', () => {
 | 
						|
  const storage = new Map<string, any>();
 | 
						|
  let challengeStatus = 'idle';
 | 
						|
  let queuesStarted: Array<string> = [];
 | 
						|
 | 
						|
  beforeEach(function (this: Mocha.Context) {
 | 
						|
    storage.clear();
 | 
						|
    challengeStatus = 'idle';
 | 
						|
    queuesStarted = [];
 | 
						|
 | 
						|
    this.sandbox = sinon.createSandbox();
 | 
						|
    this.clock = this.sandbox.useFakeTimers({
 | 
						|
      now: NOW,
 | 
						|
    });
 | 
						|
  });
 | 
						|
 | 
						|
  afterEach(function (this: Mocha.Context) {
 | 
						|
    this.sandbox.restore();
 | 
						|
  });
 | 
						|
 | 
						|
  const createChallenge = (
 | 
						|
    conversationId: string,
 | 
						|
    options: Partial<RegisteredChallengeType> = {}
 | 
						|
  ): RegisteredChallengeType => {
 | 
						|
    return {
 | 
						|
      conversationId,
 | 
						|
      token: '1',
 | 
						|
      retryAt: NOW + DEFAULT_RETRY_AFTER,
 | 
						|
      createdAt: NOW - SECOND,
 | 
						|
      reason: 'test',
 | 
						|
      silent: false,
 | 
						|
      ...options,
 | 
						|
    };
 | 
						|
  };
 | 
						|
 | 
						|
  const createHandler = async ({
 | 
						|
    autoSolve = false,
 | 
						|
    challengeError,
 | 
						|
    expireAfter,
 | 
						|
    onChallengeSolved = noop,
 | 
						|
    onChallengeFailed = noop,
 | 
						|
  }: CreateHandlerOptions = {}): Promise<ChallengeHandler> => {
 | 
						|
    const handler = new ChallengeHandler({
 | 
						|
      expireAfter,
 | 
						|
 | 
						|
      storage: {
 | 
						|
        get(key: string) {
 | 
						|
          return storage.get(key);
 | 
						|
        },
 | 
						|
        async put(key: string, value: unknown) {
 | 
						|
          storage.set(key, value);
 | 
						|
        },
 | 
						|
      },
 | 
						|
 | 
						|
      startQueue(conversationId: string) {
 | 
						|
        queuesStarted.push(conversationId);
 | 
						|
      },
 | 
						|
 | 
						|
      onChallengeSolved,
 | 
						|
      onChallengeFailed,
 | 
						|
 | 
						|
      requestChallenge(request) {
 | 
						|
        if (!autoSolve) {
 | 
						|
          return;
 | 
						|
        }
 | 
						|
 | 
						|
        setTimeout(() => {
 | 
						|
          handler.onResponse({
 | 
						|
            seq: request.seq,
 | 
						|
            data: { captcha: 'captcha' },
 | 
						|
          });
 | 
						|
        }, SOLVE_AFTER);
 | 
						|
      },
 | 
						|
 | 
						|
      async sendChallengeResponse() {
 | 
						|
        if (challengeError) {
 | 
						|
          throw challengeError;
 | 
						|
        }
 | 
						|
      },
 | 
						|
 | 
						|
      setChallengeStatus(status) {
 | 
						|
        challengeStatus = status;
 | 
						|
      },
 | 
						|
    });
 | 
						|
    await handler.load();
 | 
						|
    await handler.onOnline();
 | 
						|
    return handler;
 | 
						|
  };
 | 
						|
 | 
						|
  const isInStorage = (conversationId: string) => {
 | 
						|
    return (storage.get(STORAGE_KEY) || []).some(
 | 
						|
      ({ conversationId: storageId }: { conversationId: string }) => {
 | 
						|
        return storageId === conversationId;
 | 
						|
      }
 | 
						|
    );
 | 
						|
  };
 | 
						|
 | 
						|
  it('should automatically start queue after timeout', async function (this: Mocha.Context) {
 | 
						|
    const handler = await createHandler();
 | 
						|
 | 
						|
    const one = createChallenge('1');
 | 
						|
    await handler.register(one);
 | 
						|
    assert.isTrue(isInStorage(one.conversationId));
 | 
						|
    assert.equal(challengeStatus, 'required');
 | 
						|
 | 
						|
    await this.clock.nextAsync();
 | 
						|
 | 
						|
    assert.deepEqual(queuesStarted, [one.conversationId]);
 | 
						|
    assert.equal(challengeStatus, 'idle');
 | 
						|
    assert.isFalse(isInStorage(one.conversationId));
 | 
						|
  });
 | 
						|
 | 
						|
  it('should send challenge response', async function (this: Mocha.Context) {
 | 
						|
    const handler = await createHandler({ autoSolve: true });
 | 
						|
 | 
						|
    const one = createChallenge('1', {
 | 
						|
      retryAt: NEVER_RETRY,
 | 
						|
    });
 | 
						|
    await handler.register(one);
 | 
						|
    assert.equal(challengeStatus, 'required');
 | 
						|
 | 
						|
    await this.clock.nextAsync();
 | 
						|
 | 
						|
    assert.deepEqual(queuesStarted, [one.conversationId]);
 | 
						|
    assert.isFalse(isInStorage(one.conversationId));
 | 
						|
    assert.equal(challengeStatus, 'idle');
 | 
						|
  });
 | 
						|
 | 
						|
  it('should send old challenges', async function (this: Mocha.Context) {
 | 
						|
    const handler = await createHandler();
 | 
						|
 | 
						|
    const challenges = [
 | 
						|
      createChallenge('1'),
 | 
						|
      createChallenge('2'),
 | 
						|
      createChallenge('3'),
 | 
						|
    ];
 | 
						|
    for (const challenge of challenges) {
 | 
						|
      await handler.register(challenge);
 | 
						|
    }
 | 
						|
 | 
						|
    assert.equal(challengeStatus, 'required');
 | 
						|
    assert.deepEqual(queuesStarted, []);
 | 
						|
 | 
						|
    for (const challenge of challenges) {
 | 
						|
      assert.isTrue(
 | 
						|
        isInStorage(challenge.conversationId),
 | 
						|
        `${challenge.conversationId} should be in storage`
 | 
						|
      );
 | 
						|
    }
 | 
						|
 | 
						|
    await handler.onOffline();
 | 
						|
 | 
						|
    // Wait for challenges to mature
 | 
						|
    await this.clock.nextAsync();
 | 
						|
 | 
						|
    // Create new handler to load old challenges from storage; it will start up online
 | 
						|
    await createHandler();
 | 
						|
 | 
						|
    for (const challenge of challenges) {
 | 
						|
      await handler.unregister(challenge.conversationId, 'test');
 | 
						|
    }
 | 
						|
 | 
						|
    for (const challenge of challenges) {
 | 
						|
      assert.isFalse(
 | 
						|
        isInStorage(challenge.conversationId),
 | 
						|
        `${challenge.conversationId} should not be in storage`
 | 
						|
      );
 | 
						|
    }
 | 
						|
 | 
						|
    // The order has to be correct
 | 
						|
    assert.deepEqual(queuesStarted, ['1', '2', '3']);
 | 
						|
    assert.equal(challengeStatus, 'idle');
 | 
						|
  });
 | 
						|
 | 
						|
  it('should send challenge immediately if it is ready', async () => {
 | 
						|
    const handler = await createHandler();
 | 
						|
 | 
						|
    const one = createChallenge('1', {
 | 
						|
      retryAt: IMMEDIATE_RETRY,
 | 
						|
    });
 | 
						|
    await handler.register(one);
 | 
						|
 | 
						|
    assert.equal(challengeStatus, 'idle');
 | 
						|
    assert.deepEqual(queuesStarted, [one.conversationId]);
 | 
						|
  });
 | 
						|
 | 
						|
  it('should not retry expired challenges', async function (this: Mocha.Context) {
 | 
						|
    const handler = await createHandler();
 | 
						|
 | 
						|
    const one = createChallenge('1');
 | 
						|
    await handler.register(one);
 | 
						|
    assert.isTrue(isInStorage(one.conversationId));
 | 
						|
 | 
						|
    const newHandler = await createHandler({
 | 
						|
      autoSolve: true,
 | 
						|
      expireAfter: -1,
 | 
						|
    });
 | 
						|
    await handler.unregister(one.conversationId, 'test');
 | 
						|
 | 
						|
    challengeStatus = 'idle';
 | 
						|
    await newHandler.load();
 | 
						|
 | 
						|
    assert.equal(challengeStatus, 'idle');
 | 
						|
    assert.deepEqual(queuesStarted, []);
 | 
						|
 | 
						|
    await this.clock.nextAsync();
 | 
						|
 | 
						|
    assert.equal(challengeStatus, 'idle');
 | 
						|
    assert.deepEqual(queuesStarted, []);
 | 
						|
    assert.isFalse(isInStorage(one.conversationId));
 | 
						|
  });
 | 
						|
 | 
						|
  it('should send challenges that matured while we were offline', async function (this: Mocha.Context) {
 | 
						|
    const handler = await createHandler();
 | 
						|
 | 
						|
    const one = createChallenge('1');
 | 
						|
    await handler.register(one);
 | 
						|
 | 
						|
    assert.isTrue(isInStorage(one.conversationId));
 | 
						|
    assert.deepEqual(queuesStarted, []);
 | 
						|
    assert.equal(challengeStatus, 'required');
 | 
						|
 | 
						|
    await handler.onOffline();
 | 
						|
 | 
						|
    // Let challenges mature
 | 
						|
    await this.clock.nextAsync();
 | 
						|
 | 
						|
    assert.isTrue(isInStorage(one.conversationId));
 | 
						|
    assert.deepEqual(queuesStarted, []);
 | 
						|
    assert.equal(challengeStatus, 'required');
 | 
						|
 | 
						|
    // Go back online
 | 
						|
    await handler.onOnline();
 | 
						|
 | 
						|
    // startQueue awaits this.unregister() before calling options.startQueue
 | 
						|
    await this.clock.nextAsync();
 | 
						|
 | 
						|
    assert.isFalse(isInStorage(one.conversationId));
 | 
						|
    assert.deepEqual(queuesStarted, [one.conversationId]);
 | 
						|
    assert.equal(challengeStatus, 'idle');
 | 
						|
  });
 | 
						|
 | 
						|
  it('should trigger onChallengeSolved', async function (this: Mocha.Context) {
 | 
						|
    const onChallengeSolved = sinon.stub();
 | 
						|
 | 
						|
    const handler = await createHandler({
 | 
						|
      autoSolve: true,
 | 
						|
      onChallengeSolved,
 | 
						|
    });
 | 
						|
 | 
						|
    const one = createChallenge('1', {
 | 
						|
      retryAt: NEVER_RETRY,
 | 
						|
    });
 | 
						|
    await handler.register(one);
 | 
						|
 | 
						|
    // Let the challenge go through
 | 
						|
    await this.clock.nextAsync();
 | 
						|
 | 
						|
    sinon.assert.calledOnce(onChallengeSolved);
 | 
						|
  });
 | 
						|
 | 
						|
  it('should trigger onChallengeFailed', async function (this: Mocha.Context) {
 | 
						|
    const onChallengeFailed = sinon.stub();
 | 
						|
 | 
						|
    const handler = await createHandler({
 | 
						|
      autoSolve: true,
 | 
						|
      challengeError: new Error('custom failure'),
 | 
						|
      onChallengeFailed,
 | 
						|
    });
 | 
						|
 | 
						|
    const one = createChallenge('1', {
 | 
						|
      retryAt: NEVER_RETRY,
 | 
						|
    });
 | 
						|
    await handler.register(one);
 | 
						|
 | 
						|
    // Let the challenge go through
 | 
						|
    await this.clock.nextAsync();
 | 
						|
 | 
						|
    sinon.assert.calledOnce(onChallengeFailed);
 | 
						|
  });
 | 
						|
});
 |