A few remaining unit tests. Ready to go structurally

pull/493/head
Rick Carlino 2017-10-10 16:07:23 -05:00
parent 9dfc1c52b9
commit 3a95f86714
7 changed files with 116 additions and 76 deletions

View File

@ -23,7 +23,7 @@ export function didLogin(authState: AuthState, dispatch: Function) {
dispatch(fetchReleases(authState.token.unencoded.os_update_server));
dispatch(loginOk(authState));
Sync.fetchSyncData(dispatch);
dispatch(connectDevice(authState.token.encoded));
dispatch(connectDevice(authState));
}
// We need to handle OK logins for numerous use cases (Ex: login & registration)

View File

@ -5,12 +5,24 @@ jest.mock("farmbot-toastr", () => ({
warning: jest.fn()
}));
jest.mock("../../index", () => ({
dispatchNetworkUp: jest.fn()
}));
import { HardwareState } from "../../../devices/interfaces";
import { incomingStatus, ifToastWorthy, showLogOnScreen, TITLE } from "../../connect_device";
import {
incomingStatus,
ifToastWorthy,
showLogOnScreen,
TITLE,
bothUp,
initLog
} from "../../connect_device";
import { Actions } from "../../../constants";
import { Log } from "../../../interfaces";
import { ALLOWED_CHANNEL_NAMES, ALLOWED_MESSAGE_TYPES } from "farmbot";
import { success, error, info } from "farmbot-toastr";
import { dispatchNetworkUp } from "../../index";
describe("incomingStatus", () => {
it("creates an action", () => {
@ -69,3 +81,25 @@ describe("showLogOnScreen", () => {
assertToastr(["success"], success);
});
});
describe("initLog", () => {
it("creates a Redux action (new log)", () => {
const log = fakeLog("error");
const action = initLog(log);
expect(action.payload.kind).toBe("logs");
// expect(action.payload.specialStatus).toBe(undefined);
if (action.payload.kind === "logs") {
expect(action.payload.body.message).toBe(log.message);
} else {
fail();
}
});
});
describe("bothUp()", () => {
it("marks MQTT and API as up", () => {
bothUp();
expect(dispatchNetworkUp).toHaveBeenCalledWith("user.mqtt");
expect(dispatchNetworkUp).toHaveBeenCalledWith("bot.mqtt");
});
});

View File

@ -1,45 +1,45 @@
import { setDevice, getDevice } from "../device";
import { fetchNewDevice, getDevice } from "../device";
import { dispatchNetworkUp, dispatchNetworkDown } from "./index";
import { Log } from "../interfaces";
import { ALLOWED_CHANNEL_NAMES, Farmbot, BotStateTree } from "farmbot";
import { get, throttle, noop } from "lodash";
import { success, error, info, warning } from "farmbot-toastr";
import { HardwareState } from "../devices/interfaces";
import { GetState } from "../redux/interfaces";
import { maybeRefreshToken } from "../refresh_token";
import { GetState, ReduxAction } from "../redux/interfaces";
import { Content, Actions } from "../constants";
import { t } from "i18next";
import { isLog, EXPECTED_MAJOR, EXPECTED_MINOR, commandOK, badVersion } from "../devices/actions";
import { oneOf, bail } from "../util";
import {
isLog,
EXPECTED_MAJOR,
EXPECTED_MINOR,
commandOK,
badVersion
} from "../devices/actions";
import { init } from "../api/crud";
import { versionOK } from "../devices/reducer";
import { Token } from "../auth/interfaces";
import { AuthState } from "../auth/interfaces";
import { TaggedResource } from "../resources/tagged_resources";
/** Welcome to one of the oldest pieces of FarmBot's software stack. */
const AUTH_NOT_READY = "Somehow managed to get here before auth was ready.";
const CHANNELS: keyof Log = "channels";
const TOAST: ALLOWED_CHANNEL_NAMES = "toast";
export const TITLE = "New message from bot";
let NEED_VERSION_CHECK = true;
// Already filtering messages in FarmBot OS and the API- this is just for
// an additional layer of safety. If sensitive data ever hits a client, it will
// be reported to Rollbar for investigation.
type ConnectDeviceReturn = {} | ((dispatch: Function) => void);
const BAD_WORDS = ["WPA", "PSK", "PASSWORD", "NERVES"];
let alreadyToldYou = false;
const secure = location.protocol === "https:";
/** TODO: This ought to be stored in Redux. It is here because of historical
* reasons. Feel free to factor out when time allows. -RC, 10 OCT 17 */
const HACKY_FLAGS = {
needVersionCheck: true,
alreadyToldUserAboutMalformedMsg: false
};
/** Action creator that is called when FarmBot OS emits a status update.
* Coordinate updates, movement, etc.*/
export function incomingStatus(statusMessage: HardwareState) {
return { type: Actions.BOT_CHANGE, payload: statusMessage };
}
export let incomingStatus = (statusMessage: HardwareState) =>
({ type: Actions.BOT_CHANGE, payload: statusMessage });
/** Determine if an incoming log is a toast message. If it is, execute the
* supplied callback */
export function ifToastWorthy(log: Log | undefined,
cb: (log: Log) => void) {
const chanList = get(log || {}, CHANNELS, ["ERROR FETCHING CHANNELS"]);
* supplied callback. */
export function ifToastWorthy(log: Log, cb: (log: Log) => void) {
const CHANNELS: keyof Log = "channels";
const TOAST: ALLOWED_CHANNEL_NAMES = "toast";
const chanList = get(log, CHANNELS, ["ERROR FETCHING CHANNELS"]);
return log && chanList.includes(TOAST) ? cb(log) : noop();
}
@ -60,10 +60,14 @@ export function showLogOnScreen(log: Log) {
}
}
const maybeShowLog =
(mystery: Log | undefined) => ifToastWorthy(mystery, showLogOnScreen);
export const initLog = (log: Log): ReduxAction<TaggedResource> => init({
kind: "logs",
specialStatus: undefined,
uuid: "MUST_CHANGE",
body: log
});
const bothUp = () => {
export const bothUp = () => {
dispatchNetworkUp("user.mqtt");
dispatchNetworkUp("bot.mqtt");
};
@ -90,14 +94,14 @@ const onStatus = (dispatch: Function, getState: GetState) =>
(throttle(function (msg: BotStateTree) {
bothUp();
dispatch(incomingStatus(msg));
if (NEED_VERSION_CHECK) {
if (HACKY_FLAGS.needVersionCheck) {
const IS_OK = versionOK(getState()
.bot
.hardware
.informational_settings
.controller_version, EXPECTED_MAJOR, EXPECTED_MINOR);
if (!IS_OK) { badVersion(); }
NEED_VERSION_CHECK = false;
HACKY_FLAGS.needVersionCheck = false;
}
}, 500));
@ -109,14 +113,9 @@ const onSent = (/** The MQTT Client Object (bot.client) */ client: {}) =>
const onLogs = (dispatch: Function) => (msg: Log) => {
bothUp();
if (isLog(msg) && !oneOf(BAD_WORDS, msg.message.toUpperCase())) {
maybeShowLog(msg);
dispatch(init({
kind: "logs",
specialStatus: undefined,
uuid: "MUST_CHANGE",
body: msg
}));
if (isLog(msg)) {
ifToastWorthy(msg, showLogOnScreen);
dispatch(initLog(msg));
// CORRECT SOLUTION: Give each device its own topic for publishing
// MQTT last will message.
// FAST SOLUTION: We would need to re-publish FBJS and FBOS to
@ -126,48 +125,31 @@ const onLogs = (dispatch: Function) => (msg: Log) => {
const died =
msg.message.includes("is offline") && msg.meta.type === "error";
died && dispatchNetworkDown("bot.mqtt");
} else {
throw new Error("Refusing to display log: " + JSON.stringify(msg));
}
};
function onMalformed() {
bothUp();
if (!alreadyToldYou) {
if (!HACKY_FLAGS.alreadyToldUserAboutMalformedMsg) {
warning(t(Content.MALFORMED_MESSAGE_REC_UPGRADE));
alreadyToldYou = true;
HACKY_FLAGS.alreadyToldUserAboutMalformedMsg = true;
}
}
const onOnline = () => dispatchNetworkUp("user.mqtt");
const attachEventListeners =
(bot: Farmbot, dispatch: Function, getState: GetState) => () => {
readStatus().then(changeLastClientConnected(bot), noop);
bot.on("online", onOnline);
bot.on("offline", onOffline);
bot.on("sent", onSent(bot.client));
bot.on("logs", onLogs(dispatch));
bot.on("status", onStatus(dispatch, getState));
bot.on("malformed", onMalformed);
readStatus().then(changeLastClientConnected(bot), noop);
};
const onOnline = () => dispatchNetworkUp("user.mqtt");
const doConnect = (dispatch: Function, getState: GetState) =>
({ token }: { token: Token }) => {
const bot = setDevice(new Farmbot({ token: token.encoded, secure }));
return bot
.connect()
.then(attachEventListeners(bot, dispatch, getState), onOffline);
};
export function connectDevice(oldToken: string): ConnectDeviceReturn {
return (dispatch: Function, getState: GetState) => {
const ath = getState().auth;
const ok = doConnect(dispatch, getState);
ath ? (maybeRefreshToken(ath).then(ok)) : bail(AUTH_NOT_READY);
};
}
// 1. Refresh the token, maybe.
//
/** Connect to MQTT and attach all relevant event handlers. */
export let connectDevice = (token: AuthState) =>
(dispatch: Function, getState: GetState) => fetchNewDevice(token)
.then(bot => attachEventListeners(bot, dispatch, getState), onOffline);

View File

@ -1,12 +1,20 @@
import { Farmbot } from "farmbot";
import { bail } from "./util";
import { set } from "lodash";
import { AuthState } from "./auth/interfaces";
import { maybeRefreshToken } from "./refresh_token";
let device: Farmbot;
const secure = location.protocol === "https:"; // :(
export const getDevice = (): Farmbot => (device || bail("NO DEVICE SET"));
export function setDevice(bot: Farmbot): Farmbot {
set(window, "current_bot", bot);
return device = bot;
export function fetchNewDevice(oldToken: AuthState): Promise<Farmbot> {
return maybeRefreshToken(oldToken)
.then(({ token }) => {
device = new Farmbot({ token: token.encoded, secure });
set(window, "current_bot", device);
return device;
});
}

View File

@ -19,7 +19,7 @@ import { User } from "../auth/interfaces";
import { getDeviceAccountSettings } from "../resources/selectors";
import { TaggedDevice } from "../resources/tagged_resources";
import { versionOK } from "./reducer";
import { HttpData } from "../util";
import { HttpData, oneOf } from "../util";
import { Actions, Content } from "../constants";
import { mcuParamValidator } from "./update_interceptor";
@ -27,9 +27,22 @@ const ON = 1, OFF = 0;
export type ConfigKey = keyof McuParams;
export const EXPECTED_MAJOR = 5;
export const EXPECTED_MINOR = 0;
// Already filtering messages in FarmBot OS and the API- this is just for
// an additional layer of safety. If sensitive data ever hits a client, it will
// be reported to Rollbar for investigation.
const BAD_WORDS = ["WPA", "PSK", "PASSWORD", "NERVES"];
export function isLog(x: object): x is Log {
return _.isObject(x) && _.isString(_.get(x, "message" as keyof Log));
// tslint:disable-next-line:no-any
export function isLog(x: any): x is Log {
const yup = _.isObject(x) && _.isString(_.get(x, "message" as keyof Log));
if (yup) {
if (oneOf(BAD_WORDS, x.message.toUpperCase())) {// SECURITY CRITICAL CODE.
throw new Error("Refusing to display log: " + JSON.stringify(x));
}
return true;
} else {
return false;
}
}
const commandErr = (noun = "Command") => (x: {}) => {
console.dir(x);

View File

@ -29,7 +29,7 @@ export let TickerList = (props: TickerListProps) => {
const firstTicker: Log = props.logs.filter(
log => !log.message.toLowerCase().includes("filtered"))[0];
const noLogs: Log = {
message: "No logs yet.", meta: { type: "success" }, channels: [], created_at: NaN
message: "No logs yet.", meta: { type: "debug" }, channels: [], created_at: NaN
};
return (
<div

View File

@ -1,7 +1,10 @@
import axios from "axios";
import { API } from "./api/index";
import { AuthState } from "./auth/interfaces";
import { HttpData } from "./util";
export let maybeRefreshToken = (old: AuthState) => axios
.get(API.current.tokensPath)
.then(x => x.data, () => (old));
export let maybeRefreshToken = (old: AuthState): Promise<AuthState> => {
return axios
.get(API.current.tokensPath)
.then((x: HttpData<AuthState>) => x.data, () => (old));
};