Procházet zdrojové kódy

Add a logging facility, based on ts-log

This adds...

- a `TeeLogger` which forwards records to multiple loggers,
- a `LevelLogger` which filters records depending on the applied level,
- a `TagLogger` which prefixes log records with a tag,
- an `UnveilLogger` which unveils `Confidential` log records before forwarding
  it to another logger,
- a `ConsoleLogger` which forwards records to the console,
- a `MemoryLogger` which stores records in memory for serialisation
  when needed.

Furthermore, this adds a new `Confidential` interface which should be used to
sanitise confidential data before logging. For ease of use, the following
helper classes have been added:

- A `ConfidentialArray`class which wraps an array of `Confidential`
  implementors,
- a `ConfidentialObjectValues` class which wraps an object and conceals the
  value for each key.
- a `ConfidentialWireMessage` class which wraps an ARP message and conceals
  the `args` and `data` fields.
Lennart Grahl před 6 roky
rodič
revize
fa3facec61

+ 5 - 0
package-lock.json

@@ -9653,6 +9653,11 @@
         }
       }
     },
+    "ts-log": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/ts-log/-/ts-log-2.1.4.tgz",
+      "integrity": "sha512-P1EJSoyV+N3bR/IWFeAqXzKPZwHpnLY6j7j58mAvewHRipo+BQM2Y1f9Y9BjEQznKwgqqZm7H8iuixmssU7tYQ=="
+    },
     "ts-node": {
       "version": "8.3.0",
       "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-8.3.0.tgz",

+ 1 - 0
package.json

@@ -74,6 +74,7 @@
     "sdp": "~2.9.0",
     "ts-events": "^3.3.1",
     "ts-loader": "^6",
+    "ts-log": "^2.1.4",
     "tsify": "^4.0.1",
     "tweetnacl": "^1.0.1",
     "twemoji": "^11.3.0",

+ 159 - 0
src/helpers/confidential.ts

@@ -0,0 +1,159 @@
+/**
+ * This file is part of Threema Web.
+ *
+ * Threema Web is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+/**
+ * Recursively sanitises `value` in the following way:
+ *
+ * - `null` and `undefined` will be returned as is,
+ * - an object implementing the `Confidential` interface will be sanitised,
+ * - booleans and numbers will only reveal the type,
+ * - strings will reveal the type and the length of the string,
+ * - the binary types `Uint8Array` and `Blob` will only return meta
+ *   information about the content, and
+ * - array values will be sanitised recursively as described in this list,
+ * - object values will be sanitised recursively as described in this list,
+ *   and
+ * - everything else will only reveal the value's type.
+ */
+export function censor(value: any): any {
+    // Handle `null` and `undefined` early
+    if (value === null || value === undefined) {
+        return value;
+    }
+
+    // Apply filter to confidential data
+    if (value instanceof BaseConfidential) {
+        return value.censored();
+    }
+
+    // Filter string
+    if (value.constructor === String) {
+        return `[String: length=${value.length}]`;
+    }
+
+    // Filter array
+    if (value instanceof Array) {
+        return value.map((item) => censor(item));
+    }
+
+    // Filter binary data
+    if (value instanceof ArrayBuffer) {
+        return `[ArrayBuffer: length=${value.byteLength}]`;
+    }
+    if (value instanceof Uint8Array) {
+        return `[Uint8Array: length=${value.byteLength}, offset=${value.byteOffset}]`;
+    }
+    if (value instanceof Blob) {
+        return `[Blob: length=${value.size}, type=${value.type}]`;
+    }
+
+    // Plain object
+    if (value.constructor === Object) {
+        const object = {};
+        for (const [k, v] of Object.entries(value)) {
+            // Store sanitised
+            object[k] = censor(v);
+        }
+        return object;
+    }
+
+    // Not listed
+    return `[${value.constructor.name}]`;
+}
+
+/**
+ * Abstract confidential class.
+ *
+ * Solely exists to be able to detect with the `instanceof` operator.
+ */
+export abstract class BaseConfidential<U, C> implements threema.Confidential<U, C> {
+    public abstract uncensored: U;
+    public abstract censored(): C;
+}
+
+/**
+ * Wraps an array of confidential instances.
+ *
+ * When sanitising, all items will be sanitised.
+ * When accessing uncensored, all items will be returned uncensored.
+ */
+export class ConfidentialArray<U, C, A extends threema.Confidential<U, C>>
+    extends BaseConfidential<U[], C[]> {
+    private readonly array: A[];
+
+    constructor(array: A[]) {
+        super();
+        this.array = array;
+    }
+
+    public get uncensored(): U[] {
+        return this.array.map((item) => item.uncensored);
+    }
+
+    public censored(): C[] {
+        return this.array.map((item) => item.censored());
+    }
+}
+
+/**
+ * Wraps an object.
+ *
+ * When sanitising, all key's values will be sanitised recursively by usage of
+ * the `censor` function.
+ */
+export class ConfidentialObjectValues extends BaseConfidential<object, object> {
+    public readonly uncensored: object;
+
+    constructor(object: object) {
+        super();
+        this.uncensored = object;
+    }
+
+    public censored(): object {
+        return censor(this.uncensored);
+    }
+}
+
+/**
+ * Wraps a wire message.
+ *
+ * When sanitising, this returns all data unchanged except for the `data` and
+ * `arg` keys whose value will be sanitised recursively by usage of the
+ * `censor` function.
+ */
+export class ConfidentialWireMessage extends BaseConfidential<threema.WireMessage, threema.WireMessage> {
+    public readonly uncensored: threema.WireMessage;
+
+    constructor(message: threema.WireMessage) {
+        super();
+        this.uncensored = message;
+    }
+
+    public censored(): threema.WireMessage {
+        const message = Object.assign({}, this.uncensored);
+
+        // Sanitise args and data (if existing)
+        if (message.args !== undefined) {
+            message.args = censor(message.args);
+        }
+        if (message.data !== undefined) {
+            message.data = censor(message.data);
+        }
+
+        return message;
+    }
+}

+ 302 - 0
src/helpers/logger.ts

@@ -0,0 +1,302 @@
+/**
+ * This file is part of Threema Web.
+ *
+ * Threema Web is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import {Logger} from 'ts-log';
+import {BaseConfidential} from './confidential';
+import LogType = threema.LogType;
+import LogLevel = threema.LogLevel;
+import LogRecord = threema.LogRecord;
+
+type LogFunction = (message?: any, ...args: any[]) => void;
+
+// Supported log (level) types
+const LOG_TYPES: LogType[] = ['debug', 'trace', 'info', 'warn', 'error'];
+// Types allowed for serialisation
+const ALLOWED_TYPES: any[] = [Boolean, Number, String, Array];
+
+/**
+ * Forwards log records to one or more loggers.
+ */
+export class TeeLogger implements Logger {
+    private readonly loggers: Logger[];
+    public readonly debug: LogFunction;
+    public readonly trace: LogFunction;
+    public readonly info: LogFunction;
+    public readonly warn: LogFunction;
+    public readonly error: LogFunction;
+
+    constructor(loggers: Logger[]) {
+        this.loggers = loggers;
+
+        // Bind log level type methods
+        for (const type of LOG_TYPES) {
+            this[type] = this.forward.bind(this, type);
+        }
+    }
+
+    /**
+     * Forward a log record to each underlying logger.
+     * @param type The log record level type.
+     * @param message The log message.
+     * @param args Further arguments of the log record.
+     */
+    private forward(type: LogType, message?: any, ...args: any[]): void {
+        for (const log of this.loggers) {
+            log[type](message, ...args);
+        }
+    }
+}
+
+/**
+ * Filters log messages depending on the applied log level.
+ *
+ * Wraps a normal logger and forwards all log records that have not been
+ * filtered by the log level.
+ */
+export class LevelLogger implements Logger {
+    public readonly logger: Logger;
+    public readonly level: LogLevel;
+    public readonly debug: LogFunction = this.noop;
+    public readonly trace: LogFunction = this.noop;
+    public readonly info: LogFunction = this.noop;
+    public readonly warn: LogFunction = this.noop;
+    public readonly error: LogFunction = this.noop;
+
+    constructor(logger: Logger, level: LogLevel) {
+        this.logger = logger;
+        this.level = level;
+
+        // Bind corresponding method to log level type, if enabled
+        // noinspection FallThroughInSwitchStatementJS
+        switch (level) {
+            case 'debug':
+                this.debug = this.logger.debug.bind(this.logger);
+                this.trace = this.logger.trace.bind(this.logger);
+            case 'info':
+                this.info = this.logger.info.bind(this.logger);
+            case 'warn':
+                this.warn = this.logger.warn.bind(this.warn);
+            case 'error':
+                this.error = this.logger.error.bind(this.error);
+            default:
+                break;
+        }
+    }
+
+    private noop(): void {
+        // noop
+    }
+}
+
+/**
+ * Adds a prefix before forwarding log records to another logger.
+ */
+export class TagLogger implements Logger {
+    public readonly logger: Logger;
+    public readonly debug: LogFunction;
+    public readonly trace: LogFunction;
+    public readonly info: LogFunction;
+    public readonly warn: LogFunction;
+    public readonly error: LogFunction;
+
+    constructor(logger: Logger, ...tag: string[]) {
+        this.logger = logger;
+
+        // Apply a tag to each log level type method of the logger
+        for (const type of LOG_TYPES) {
+            this[type] = logger[type].bind(logger, ...tag);
+        }
+    }
+}
+
+/**
+ * Forwards all log records to another logger while unveiling confidential
+ * log records.
+ */
+export class UnveilLogger implements Logger {
+    public readonly logger: Logger;
+    public readonly debug: LogFunction;
+    public readonly trace: LogFunction;
+    public readonly info: LogFunction;
+    public readonly warn: LogFunction;
+    public readonly error: LogFunction;
+
+    constructor(logger: Logger) {
+        this.logger = logger;
+
+        // Bind log level type methods
+        for (const type of LOG_TYPES) {
+            this[type] = this.unveil.bind(this, type);
+        }
+    }
+
+    private unveil(type: LogType, ...args: any[]): void {
+        args = args.map((item) => item instanceof BaseConfidential ? item.uncensored : item);
+        this.logger[type](...args);
+    }
+}
+
+/**
+ * Forwards all log records to the default `Console` logger.
+ */
+export class ConsoleLogger implements Logger {
+    // tslint:disable:no-console
+    public readonly debug: LogFunction = console.debug;
+    public readonly trace: LogFunction = console.trace;
+    public readonly info: LogFunction = console.info;
+    public readonly warn: LogFunction = console.warn;
+    public readonly error: LogFunction = console.error;
+    // tslint:enable:no-console
+}
+
+/**
+ * Stores log records in memory.
+ *
+ * A limit can be provided which results in a circular memory buffer, where old
+ * log records are being continuously dropped in case the limit would be
+ * exceeded by a new log record.
+ *
+ * Since serialisation can be expensive, this holds references to objects
+ * until explicit serialisation is being requested.
+ *
+ * Note: This logger will serialise confidential log arguments censored.
+ */
+export class MemoryLogger implements Logger {
+    private readonly records: LogRecord[] = [];
+    public readonly limit: number = 0;
+    public readonly debug: LogFunction;
+    public readonly trace: LogFunction;
+    public readonly info: LogFunction;
+    public readonly warn: LogFunction;
+    public readonly error: LogFunction;
+
+    constructor(limit: number = 0) {
+        this.limit = limit;
+
+        // Bind log level type methods
+        for (const type of LOG_TYPES) {
+            this[type] = this.append.bind(this, type);
+        }
+    }
+
+    /**
+     * Append a log record to the memory buffer.
+     *
+     * Drops the oldest log record if the log record limit would be exceeded.
+     *
+     * @param type The log record level type.
+     * @param message The log message.
+     * @param args Further arguments of the log record
+     */
+    private append(type: LogType, message?: any, ...args: any[]): void {
+        // Remove oldest record if needed
+        if (this.limit > 0 && this.records.length >= this.limit) {
+            this.records.shift();
+        }
+
+        // Add newest record
+        this.records.push([new Date(), type, message, ...args]);
+    }
+
+    /**
+     * Serialise all log records to JSON.
+     *
+     * While serialising, a recursive filter will be applied:
+     *
+     * - the types `null`, `string`, `number` and `boolean` will be returned
+     *   unmodified,
+     * - an object implementing the `Confidential` interface will be returned
+     *   sanitised,
+     * - an `Error` instance will be left as is,
+     * - the binary types `Uint8Array` and `Blob` will only return meta
+     *   information about the content, and
+     * - everything else will return the value's type instead of the value
+     *   itself.
+     *
+     * @param space Amount of white spaces used for nested block indentation.
+     */
+    public serialize(space: number = 2): string {
+        const records = this.records.map(([date, type, message, ...args]: LogRecord) => {
+            // Strip message formatting
+            if (message !== null && message !== undefined && message.constructor === String) {
+                let stripped = false;
+
+                // Strip style formatting string if any
+                message = message.replace(/%c/, () => {
+                    stripped = true;
+                    return '';
+                });
+
+                // Trim
+                message = message.trim();
+
+                // Remove next argument if stripped
+                if (stripped) {
+                    args.shift();
+                }
+            }
+
+            // Convert date to a timestamp with millisecond accuracy
+            const timestampMs = date.getTime();
+            return [timestampMs, type, message, ...args];
+        });
+
+        // Serialise to JSON
+        return JSON.stringify(records, (_, value) => {
+            // Handle `null` and `undefined` early
+            if (value === null || value === undefined) {
+                return value;
+            }
+
+            // Apply filter to confidential data
+            if (value instanceof BaseConfidential) {
+                return value.censored();
+            }
+
+            // Allowed (standard) types
+            for (const allowedType of ALLOWED_TYPES) {
+                if (value.constructor === allowedType) {
+                    return value;
+                }
+            }
+
+            // Allow exceptions
+            if (value instanceof Error) {
+                return value.toString();
+            }
+
+            // Filter binary data
+            if (value instanceof ArrayBuffer) {
+                return `[ArrayBuffer: length=${value.byteLength}]`;
+            }
+            if (value instanceof Uint8Array) {
+                return `[Uint8Array: length=${value.byteLength}, offset=${value.byteOffset}]`;
+            }
+            if (value instanceof Blob) {
+                return `[Blob: length=${value.size}, type=${value.type}]`;
+            }
+
+            // Plain object
+            if (value.constructor === Object) {
+                return value;
+            }
+
+            // Not listed
+            return `[${value.constructor.name}]`;
+        }, space);
+    }
+}

+ 13 - 0
src/threema.d.ts

@@ -18,6 +18,19 @@
 declare const angular: ng.IAngularStatic;
 
 declare namespace threema {
+    type LogType = 'debug' | 'trace' | 'info' | 'warn' | 'error';
+    type LogLevel = 'none' | 'debug' | 'info' | 'warn' | 'error';
+    type LogRecord = [Date, LogType, any?, ...any[]];
+
+    /**
+     * An object can be marked as confidential in which case it needs to
+     * implement the censor method. This mixin is being used for sanitising log
+     * records when using the report tool.
+     */
+    interface Confidential<U, C> {
+        uncensored: U;
+        censored(): C;
+    }
 
     interface Avatar {
         // Low resolution avatar URI

+ 243 - 0
tests/ts/confidential_helpers.ts

@@ -0,0 +1,243 @@
+/**
+ * Copyright © 2016-2019 Threema GmbH (https://threema.ch/).
+ *
+ * This file is part of Threema Web.
+ *
+ * Threema Web is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
+ */
+import {
+    censor,
+    BaseConfidential,
+    ConfidentialArray,
+    ConfidentialObjectValues,
+    ConfidentialWireMessage
+} from '../../src/helpers/confidential';
+
+// tslint:disable:no-reference
+/// <reference path="../../src/threema.d.ts" />
+
+class UnlistedClass {}
+
+/**
+ * A confidential subclass for testing purposes.
+ */
+class TestConfidential extends BaseConfidential<string, string> {
+    public readonly uncensored: string = 'uncensored';
+
+    public censored(): string {
+        return 'censored';
+    }
+}
+
+describe('Confidential Helpers', () => {
+    describe('censor function', () => {
+        it('handles null and undefined', () => {
+            expect(censor(null)).toBe(null);
+            expect(censor(undefined)).toBe(undefined);
+        });
+
+        it('handles an object implementing the Confidential interface', () => {
+            expect(censor(new TestConfidential())).toBe('censored');
+        });
+
+        it('handles booleans', () => {
+            expect(censor(true)).toBe('[Boolean]');
+            expect(censor(false)).toBe('[Boolean]');
+        });
+
+        it('handles numbers', () => {
+            expect(censor(0)).toBe('[Number]');
+            expect(censor(42)).toBe('[Number]');
+            expect(censor(-1337)).toBe('[Number]');
+        });
+
+        it('handles strings', () => {
+            expect(censor('test')).toBe('[String: length=4]');
+            expect(censor('')).toBe('[String: length=0]');
+        });
+
+        it('handles binary types', () => {
+            const buffer = new ArrayBuffer(10);
+            const array = new Uint8Array(buffer, 2, 6);
+            const blob = new Blob([JSON.stringify({ a: 10 })], { type: 'application/json'} );
+            expect(censor(buffer)).toBe('[ArrayBuffer: length=10]');
+            expect(censor(array)).toBe('[Uint8Array: length=6, offset=2]');
+            expect(censor(blob)).toBe(`[Blob: length=${blob.size}, type=application/json]`);
+        });
+
+        it('handles arrays', () => {
+            expect(censor([
+                null,
+                undefined,
+                new TestConfidential(),
+                false,
+                42,
+                'test',
+                new Uint8Array(10),
+            ])).toEqual([
+                null,
+                undefined,
+                'censored',
+                '[Boolean]',
+                '[Number]',
+                '[String: length=4]',
+                '[Uint8Array: length=10, offset=0]',
+            ]);
+        });
+
+        it('handles arrays recursively', () => {
+            expect(censor([
+                'test',
+                [1, false],
+            ])).toEqual([
+                '[String: length=4]',
+                ['[Number]', '[Boolean]'],
+            ]);
+        });
+
+        it('handles objects', () => {
+            expect(censor({
+                null: null,
+                undefined: undefined,
+                confidential: new TestConfidential(),
+                boolean: false,
+                number: 42,
+                string: 'test',
+                uint8array: new Uint8Array(10),
+            })).toEqual({
+                null: null,
+                undefined: undefined,
+                confidential: 'censored',
+                boolean: '[Boolean]',
+                number: '[Number]',
+                string: '[String: length=4]',
+                uint8array: '[Uint8Array: length=10, offset=0]',
+            });
+        });
+
+        it('handles objects recursively', () => {
+            expect(censor({
+                boolean: false,
+                object: {
+                    foo: 'bar',
+                },
+            })).toEqual({
+                boolean: '[Boolean]',
+                object: {
+                    foo: '[String: length=3]',
+                },
+            });
+        });
+
+        it('handles class instances', () => {
+            expect(censor(new UnlistedClass())).toBe('[UnlistedClass]');
+        });
+    });
+
+    describe('ConfidentialArray', () => {
+        it('subclass of BaseConfidential', () => {
+            expect(ConfidentialArray.prototype instanceof BaseConfidential).toBeTruthy();
+        });
+
+        it('sanitises all items', () => {
+            const array = new ConfidentialArray([new TestConfidential(), new TestConfidential()]);
+            expect(array.uncensored).toEqual(['uncensored', 'uncensored']);
+            expect(array.censored()).toEqual(['censored', 'censored']);
+        });
+
+        it('sanitises all items recursively', () => {
+            const array = new ConfidentialArray([
+                new TestConfidential(),
+                new ConfidentialArray([new TestConfidential()]),
+            ]);
+            expect(array.uncensored).toEqual(['uncensored', ['uncensored']]);
+            expect(array.censored()).toEqual(['censored', ['censored']]);
+        });
+    });
+
+    describe('ConfidentialObjectValues', () => {
+        it('subclass of BaseConfidential', () => {
+            expect(ConfidentialObjectValues.prototype instanceof BaseConfidential).toBeTruthy();
+        });
+
+        it('returns underlying object directly when unveiling', () => {
+            const object = {};
+            const confidential = new ConfidentialObjectValues(object);
+            expect(confidential.uncensored).toBe(object);
+        });
+
+        it('sanitises all object values', () => {
+            const object = {
+                boolean: false,
+                object: {
+                    foo: 'bar',
+                },
+            };
+            const confidential = new ConfidentialObjectValues(object);
+            expect(confidential.uncensored).toBe(object);
+            expect(confidential.censored()).toEqual({
+                boolean: '[Boolean]',
+                object: {
+                    foo: '[String: length=3]',
+                },
+            });
+        });
+    });
+
+    describe('ConfidentialWireMessage', () => {
+        it('subclass of BaseConfidential', () => {
+            expect(ConfidentialWireMessage.prototype instanceof BaseConfidential).toBeTruthy();
+        });
+
+        it('returns underlying message directly when unveiling', () => {
+            const message = {
+                type: 'request/food',
+                subType: 'dessert',
+            };
+            const confidential = new ConfidentialWireMessage(message);
+            expect(confidential.uncensored).toBe(message);
+        });
+
+        it("handles 'args' and 'data' being undefined", () => {
+            const message = {
+                type: 'request/food',
+                subType: 'dessert',
+            };
+            const confidential = new ConfidentialWireMessage(message);
+            expect(confidential.censored()).toEqual(message);
+        });
+
+        it("sanitises 'args' and 'data' fields", () => {
+            const message = {
+                type: 'request/food',
+                subType: 'dessert',
+                args: 'arrrrrrgggsss',
+                data: {
+                    preference: ['ice cream', 'chocolate'],
+                    priority: Number.POSITIVE_INFINITY,
+                },
+            };
+            const confidential = new ConfidentialWireMessage(message);
+            expect(confidential.censored()).toEqual({
+                type: 'request/food',
+                subType: 'dessert',
+                args: '[String: length=13]',
+                data: {
+                    preference: ['[String: length=9]', '[String: length=9]'],
+                    priority: '[Number]',
+                },
+            });
+        });
+    });
+});

+ 511 - 0
tests/ts/logger_helpers.ts

@@ -0,0 +1,511 @@
+/**
+ * Copyright © 2016-2019 Threema GmbH (https://threema.ch/).
+ *
+ * This file is part of Threema Web.
+ *
+ * Threema Web is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+// tslint:disable:no-reference
+/// <reference path="../../src/threema.d.ts" />
+
+import {Logger} from 'ts-log';
+
+import {BaseConfidential} from '../../src/helpers/confidential';
+import {ConsoleLogger, LevelLogger, MemoryLogger, TagLogger, TeeLogger, UnveilLogger} from '../../src/helpers/logger';
+
+import LogLevel = threema.LogLevel;
+import LogType = threema.LogType;
+
+interface EnsureLoggedArgs {
+    level: LogLevel,
+    logger: TestLogger | TestLogger[],
+    tag?: string | string[],
+}
+
+type LogRecord = [LogType, any?, ...any[]];
+type LogFunction = (message?: any, ...args: any[]) => void;
+// Supported log (level) types
+const LOG_TYPES: LogType[] = ['debug', 'trace', 'info', 'warn', 'error'];
+
+
+/**
+ * Stores all log records in memory for evaluation.
+ */
+class TestLogger implements Logger {
+    public readonly records: LogRecord[] = [];
+    public readonly debug: LogFunction;
+    public readonly trace: LogFunction;
+    public readonly info: LogFunction;
+    public readonly warn: LogFunction;
+    public readonly error: LogFunction;
+
+    constructor() {
+        // Bind log level type methods
+        for (const type of LOG_TYPES) {
+            this[type] = this.append.bind(this, type);
+        }
+    }
+
+    private append(type: LogType, message?: any, ...args: any[]): void {
+        this.records.push([type, message, ...args])
+    }
+}
+
+/**
+ * A confidential log record for testing purposes.
+ */
+class TestConfidential extends BaseConfidential<string, string> {
+    public readonly uncensored: string = 'uncensored';
+
+    public censored(): string {
+        return 'censored';
+    }
+}
+
+/**
+ * Log a record of each type.
+ */
+function logEachType(...loggers: Logger[]): void {
+    for (const logger of loggers) {
+        for (const type of LOG_TYPES) {
+            logger[type](type);
+        }
+    }
+}
+
+/**
+ * Format a log record. This splices a tag into the expected place.
+ */
+function formatLogRecord(record: LogRecord, tag?: string[]): LogRecord {
+    if (tag === undefined) {
+        return record;
+    }
+
+    // Splice tag into message
+    const [type, ...args] = record;
+    return [type, ...tag.concat(args)];
+}
+
+/**
+ * Ensure a log record of a level equal to or above has been logged.
+ */
+function expectLogged(args: EnsureLoggedArgs): void {
+    // Prepare arguments
+    if (!(args.logger instanceof Array)) {
+        args.logger = [args.logger];
+    }
+    let tag: string[];
+    if (args.tag !== undefined) {
+        tag = args.tag instanceof Array ? args.tag : [args.tag];
+    }
+
+    // Check for log records to be present, depending on the level
+    for (const logger of args.logger) {
+        // noinspection FallThroughInSwitchStatementJS
+        switch (args.level) {
+            case 'debug':
+                expect(logger.records).toContain(formatLogRecord(['debug', 'debug'], tag));
+                expect(logger.records).toContain(formatLogRecord(['trace', 'trace'], tag));
+            case 'info':
+                expect(logger.records).toContain(formatLogRecord(['info', 'info'], tag));
+            case 'warn':
+                expect(logger.records).toContain(formatLogRecord(['warn', 'warn'], tag));
+            case 'error':
+                expect(logger.records).toContain(formatLogRecord(['error', 'error'], tag));
+            default:
+                break;
+        }
+    }
+}
+
+describe('Logger Helpers', () => {
+    describe('TestLogger', () => {
+        it('stores log records', () => {
+            const logger = new TestLogger();
+
+            // Ensure a record of each type is being logged
+            logEachType(logger);
+            expectLogged({ level: 'debug', logger: logger });
+        });
+    });
+
+    describe('TeeLogger', () => {
+        it('forwards log records to each underlying logger', () => {
+            const loggers: TestLogger[] = [
+                new TestLogger(),
+                new TestLogger(),
+                new TestLogger(),
+            ];
+            const root = new TeeLogger(loggers);
+
+            // Ensure a record of each type is being logged
+            logEachType(root);
+            expectLogged({ level: 'debug', logger: loggers });
+        });
+    });
+
+    describe('LevelLogger', () => {
+        it("'none' level discards everything", () => {
+            const logger = new TestLogger();
+            const root = new LevelLogger(logger, 'none');
+
+            // Ensure a record of each expected type is being logged
+            logEachType(root);
+            expect(logger.records.length).toBe(0);
+        });
+
+        it("'debug' level discards nothing", () => {
+            const logger = new TestLogger();
+            const root = new LevelLogger(logger, 'debug');
+
+            // Ensure a record of each expected type is being logged
+            logEachType(root);
+            expectLogged({ level: 'debug', logger: logger });
+        });
+
+        it("'info' level discards 'debug' and 'trace'", () => {
+            const logger = new TestLogger();
+            const root = new LevelLogger(logger, 'info');
+
+            // Ensure a record of each expected type is being logged
+            logEachType(root);
+            expectLogged({ level: 'info', logger: logger });
+        });
+
+        it("'warn' level discards 'debug', 'trace' and 'info'", () => {
+            const logger = new TestLogger();
+            const root = new LevelLogger(logger, 'warn');
+
+            // Ensure a record of each expected type is being logged
+            logEachType(root);
+            expectLogged({ level: 'warn', logger: logger });
+        });
+
+        it("'error' level discards 'debug', 'trace', 'info' and 'warn'", () => {
+            const logger = new TestLogger();
+            const root = new LevelLogger(logger, 'error');
+
+            // Ensure a record of each expected type is being logged
+            logEachType(root);
+            expectLogged({ level: 'error', logger: logger });
+        });
+    });
+
+    describe('TagLogger', () => {
+        it('applies a tag', () => {
+            const logger = new TestLogger();
+            const root = new TagLogger(logger, 'tag');
+
+            // Ensure a record of each type is being logged with the expected tag
+            logEachType(root);
+            expectLogged({ level: 'debug', tag: 'tag', logger: logger });
+        });
+
+        it('applies multiple tags', () => {
+            const logger = new TestLogger();
+            const root = new TagLogger(logger, 'tag1', 'tag2');
+
+            // Ensure a record of each type is being logged with the expected tag
+            logEachType(root);
+            expectLogged({ level: 'debug', tag: ['tag1', 'tag2'], logger: logger });
+        });
+    });
+
+    describe('UnveilLogger', () => {
+        it('leaves normal log records untouched', () => {
+            const logger = new TestLogger();
+            const root = new UnveilLogger(logger);
+
+            // Ensure a record of each expected type is being logged
+            logEachType(root);
+            expectLogged({ level: 'debug', logger: logger });
+        });
+
+        it('unveils confidential log record', () => {
+            const logger = new TestLogger();
+            const root = new UnveilLogger(logger);
+
+            // Ensure a confidential record is being unveiled
+            root.debug(new TestConfidential());
+            expect(logger.records).toContain(['debug', 'uncensored']);
+        });
+    });
+
+    describe('ConsoleLogger', () => {
+        let backup: Logger = {} as Logger;
+        let logger: TestLogger;
+
+        beforeEach(() => {
+            logger = new TestLogger();
+
+            // Store each log level type method of console that we will override
+            for (const type of LOG_TYPES) {
+                backup[type] = console[type];
+            }
+
+            // Overwrite each log level type method of console
+            for (const type of LOG_TYPES) {
+                console[type] = logger[type];
+            }
+        });
+
+        afterEach(() => {
+            // Restore each log level type method of console that we have
+            // previously overridden.
+            for (const type of LOG_TYPES) {
+                console[type] = backup[type];
+            }
+        });
+
+        it('forwards log records to the console', () => {
+            const root = new ConsoleLogger();
+
+            // Ensure a record of each expected type is being logged
+            logEachType(root);
+            expectLogged({ level: 'debug', logger: logger });
+        });
+
+        it('leaves confidential log record untouched', () => {
+            const root = new ConsoleLogger();
+
+            // Ensure a confidential record is being left untouched
+            const message = new TestConfidential();
+            root.debug(message);
+            expect(logger.records).toContain(['debug', message]);
+        });
+    });
+
+    describe('MemoryLogger', () => {
+        it('serialises each log record with a timestamp', () => {
+            const start = Date.now();
+            const logger = new MemoryLogger();
+
+            // Ensure each log record has a timestamp.
+            for (let i = 0; i < 10; ++i) {
+                logger.debug(i);
+            }
+            const end = Date.now();
+            const timestamps = JSON
+                .parse(logger.serialize())
+                .map((entry) => entry[0]);
+            expect(timestamps.length).toBe(10);
+            for (const timestamp of timestamps) {
+                expect(timestamp).toBeGreaterThanOrEqual(start);
+                expect(timestamp).toBeLessThanOrEqual(end);
+            }
+        });
+
+        it('stores all arguments of the log record', () => {
+            const logger = new MemoryLogger();
+
+            // Ensure all arguments of the log record are being kept.
+            // Note: All of these values need to serialised loss-less.
+            const record = [
+                'debug',
+                null,
+                true,
+                1,
+                'a',
+                [2, 3],
+                { b: 4 },
+            ];
+            logger.debug(...record.slice(1));
+            const records = JSON
+                .parse(logger.serialize())
+                .map((entry) => entry.slice(1));
+            expect(records.length).toBe(1);
+            expect(records[0]).toEqual(record);
+        });
+
+        it("strips style formatting of the log record's message (tag)", () => {
+            const logger = new MemoryLogger();
+
+            // Ensure %c CSS style formatting placeholder and the following
+            // argument is being stripped.
+            const args = [
+                null,
+                true,
+                1,
+                'a',
+                [2, 3],
+                { b: 4 },
+            ];
+            logger.debug('  te%cst  ', 'color: #fff', ...args);
+            const records = JSON
+                .parse(logger.serialize())
+                .map((entry) => entry.slice(1));
+            expect(records.length).toBe(1);
+            expect(records[0]).toEqual((['debug', 'test'] as any[]).concat(args));
+        });
+
+        it("ignores style formatting beyond the log record's message (args)", () => {
+            const logger = new MemoryLogger();
+
+            // Ensure %c CSS style formatting placeholder and the following
+            // argument are not being touched.
+            const record = [
+                'debug',
+                'test',
+                '  me%cow  ',
+                'color: #fff',
+                null,
+                true,
+                '  ra%cwr  ',
+                'color: #ffa500',
+                1,
+                'a',
+                [2, 3],
+                { b: 4 },
+            ];
+            logger.debug(...record.slice(1));
+            const records = JSON
+                .parse(logger.serialize())
+                .map((entry) => entry.slice(1));
+            expect(records.length).toBe(1);
+            expect(records[0]).toEqual(record);
+        });
+
+        it('serialises standard types', () => {
+            const logger = new MemoryLogger();
+
+            // Ensure 'null', 'boolean', 'number' and 'string' are being
+            // represented loss-less.
+            const record = [
+                'debug',
+                null,
+                false,
+                1337,
+                'meow?',
+            ];
+            logger.debug(...record.slice(1));
+            const records = JSON
+                .parse(logger.serialize())
+                .map((entry) => entry.slice(1));
+            expect(records.length).toBe(1);
+            expect(records[0]).toEqual(record);
+        });
+
+        it('serialises confidential types sanitised', () => {
+            const logger = new MemoryLogger();
+
+            // Ensure 'Confidential' messages are being sanitised.
+            const confidential = new TestConfidential();
+            logger.debug(confidential, confidential, confidential);
+            const records = JSON
+                .parse(logger.serialize())
+                .map((entry) => entry.slice(1));
+            expect(records.length).toBe(1);
+            expect(records[0]).toEqual(['debug', 'censored', 'censored', 'censored']);
+        });
+
+        it('serialises exceptions', () => {
+            const logger = new MemoryLogger();
+
+            // Ensure exceptions are being represented (in their lossy string form).
+            const error = new Error('WTF!');
+            logger.error(error);
+            const records = JSON
+                .parse(logger.serialize())
+                .map((entry) => entry.slice(1));
+            expect(records.length).toBe(1);
+            expect(records[0]).toEqual(['error', error.toString()]);
+        });
+
+        it('serialises unrepresentable binary types', () => {
+            const logger = new MemoryLogger();
+
+            // Ensure 'ArrayBuffer', 'Uint8Array' and 'Blob' are being
+            // represented with metadata only.
+            const buffer = new ArrayBuffer(10);
+            const array = new Uint8Array(buffer, 2, 6);
+            const blob = new Blob([JSON.stringify({ a: 10 })], { type: 'application/json'} );
+            logger.debug(buffer, array, blob);
+            const records = JSON
+                .parse(logger.serialize())
+                .map((entry) => entry.slice(1));
+            expect(records.length).toBe(1);
+            expect(records[0]).toEqual([
+                'debug',
+                '[ArrayBuffer: length=10]',
+                '[Uint8Array: length=6, offset=2]',
+                `[Blob: length=${blob.size}, type=application/json]`,
+            ]);
+        });
+
+        it('serialises class instances', () => {
+            const logger = new MemoryLogger();
+
+            // Ensure instances are being represented with their name.
+            logger.debug(logger);
+            const records = JSON
+                .parse(logger.serialize())
+                .map((entry) => entry.slice(1));
+            expect(records.length).toBe(1);
+            expect(records[0]).toEqual(['debug', '[MemoryLogger]']);
+        });
+
+        it('serialises objects recursively', () => {
+            const logger = new MemoryLogger();
+
+            // Ensure an object's properties are being serialised recursively.
+            const object = {
+                bool: true,
+                inner: {
+                    number: 4,
+                },
+                array: ['a', 'b'],
+            };
+            logger.debug(object);
+            const records = JSON
+                .parse(logger.serialize())
+                .map((entry) => entry.slice(1));
+            expect(records.length).toBe(1);
+            expect(records[0]).toEqual(['debug', object]);
+        });
+
+        it('serialises arrays recursively', () => {
+            const logger = new MemoryLogger();
+
+            // Ensure each item of an array is being serialised recursively.
+            const array = [
+                false,
+                { null: null },
+                ['a', 'b'],
+            ];
+            logger.debug(array);
+            const records = JSON
+                .parse(logger.serialize())
+                .map((entry) => entry.slice(1));
+            expect(records.length).toBe(1);
+            expect(records[0]).toEqual(['debug', array]);
+        });
+
+        it('respects the supplied log record limit', () => {
+            const logger = new MemoryLogger(2);
+
+            // Ensure only the last two log records are being kept
+            for (let i = 0; i < 10; ++i) {
+                logger.debug(i);
+            }
+            const records = JSON
+                .parse(logger.serialize())
+                .map((entry) => entry.slice(1));
+            expect(records).toEqual([
+                ['debug', 8],
+                ['debug', 9],
+            ]);
+        });
+    });
+});

+ 2 - 0
tests/ts/main.ts

@@ -20,9 +20,11 @@
 // tslint:disable:no-reference
 /// <reference path="../../src/threema.d.ts" />
 
+import './confidential_helpers';
 import './containers';
 import './crypto_helpers';
 import './emoji_helpers';
+import './logger_helpers';
 import './helpers';
 import './markup_parser';
 import './receiver_helpers';