Addresses Issue where the reset button doesn't work on options page (#1447)

Closes issue #1388. The problem here is that previously, the reset button loaded a hard coded list of default options into the component state, instead of the proper behavior which is to reset the options to default values on the back end, and then load them back into the redux store. This PR adds a ResetOptions endpoint on the server, and wires up the UI so that it triggers the endpoint, then loads the default options from the backend server.
This commit is contained in:
John Murphy 2017-03-30 18:56:11 -05:00 коммит произвёл GitHub
Родитель f4bee00b01
Коммит d533931799
19 изменённых файлов: 461 добавлений и 21 удалений

Просмотреть файл

@ -2,6 +2,7 @@ export default {
CHANGE_PASSWORD: '/v1/kolide/change_password',
CONFIG: '/v1/kolide/config',
CONFIG_OPTIONS: '/v1/kolide/options',
CONFIG_OPTIONS_RESET: '/v1/kolide/options/reset',
CONFIRM_EMAIL_CHANGE: (token) => {
return `/v1/kolide/email/change/${token}`;
},

Просмотреть файл

@ -14,5 +14,11 @@ export default (client) => {
return client.authenticatedPatch(client._endpoint(CONFIG_OPTIONS), JSON.stringify({ options }))
.then(response => response.options);
},
reset: () => {
const { CONFIG_OPTIONS_RESET } = endpoints;
return client.authenticatedGet(client._endpoint(CONFIG_OPTIONS_RESET))
.then(response => response.options);
},
};
};

Просмотреть файл

@ -8,7 +8,6 @@ import ConfigOptionsForm from 'components/forms/ConfigOptionsForm';
import Icon from 'components/icons/Icon';
import configOptionInterface from 'interfaces/config_option';
import debounce from 'utilities/debounce';
import defaultConfigOptions from 'pages/config/ConfigOptionsPage/default_config_options';
import entityGetter from 'redux/utilities/entityGetter';
import helpers from 'pages/config/ConfigOptionsPage/helpers';
import { renderFlash } from 'redux/nodes/notifications/actions';
@ -102,8 +101,17 @@ export class ConfigOptionsPage extends Component {
}
onResetConfigOptions = () => {
this.setState({ configOptions: defaultConfigOptions });
const { dispatch } = this.props;
dispatch(configOptionActions.resetOptions())
.then(() => {
dispatch(renderFlash('success', 'Options reset to defaults.'));
return false;
})
.catch(() => {
dispatch(renderFlash('error', 'Options reset failed.'));
return false;
});
return false;
}

Просмотреть файл

@ -4,7 +4,6 @@ import { mount } from 'enzyme';
import { ConfigOptionsPage } from 'pages/config/ConfigOptionsPage/ConfigOptionsPage';
import { configOptionStub } from 'test/stubs';
import defaultConfigOptions from 'pages/config/ConfigOptionsPage/default_config_options';
import { fillInFormInput } from 'test/helpers';
describe('ConfigOptionsPage - component', () => {
@ -36,18 +35,6 @@ describe('ConfigOptionsPage - component', () => {
});
});
it('resets config option defaults', () => {
const page = mount(<ConfigOptionsPage {...props} />);
const buttons = page.find('Button');
const resetButton = buttons.find('.config-options-page__reset-btn');
expect(page.state('configOptions')).toEqual([]);
resetButton.simulate('click');
expect(page.state('configOptions')).toEqual(defaultConfigOptions);
});
describe('removing a config option', () => {
it('sets the option value to null in state', () => {
const page = mount(<ConfigOptionsPage configOptions={[configOptionStub]} />);

Просмотреть файл

@ -1,3 +1,37 @@
import config from './config';
import Kolide from 'kolide';
import config from 'redux/nodes/entities/config_options/config';
import { formatErrorResponse } from 'redux/nodes/entities/base/helpers';
export default config.actions;
const { actions } = config;
export const RESET_OPTIONS_START = 'RESET_OPTIONS_START';
export const RESET_OPTIONS_SUCCESS = 'RESET_OPTIONS_SUCCESS';
export const RESET_OPTIONS_FAILURE = 'RESET_OPTIONS_FAILURE';
export const resetOptionsStart = { type: RESET_OPTIONS_START };
export const resetOptionsSuccess = (configOptions) => {
return { type: RESET_OPTIONS_SUCCESS, payload: { configOptions } };
};
export const resetOptionsFailure = (errors) => {
return { type: RESET_OPTIONS_FAILURE, payload: { errors } };
};
export const resetOptions = () => {
return (dispatch) => {
dispatch(resetOptionsStart);
return Kolide.configOptions.reset()
.then((opts) => {
return dispatch(resetOptionsSuccess(opts));
})
.catch((error) => {
const formattedErrors = formatErrorResponse(error);
dispatch(resetOptionsFailure(formattedErrors));
throw formattedErrors;
});
};
};
export default {
...actions,
resetOptions,
};

Просмотреть файл

@ -0,0 +1,55 @@
import expect, { restoreSpies, spyOn } from 'expect';
import Kolide from 'kolide';
import { reduxMockStore } from 'test/helpers';
import {
resetOptions,
resetOptionsStart,
resetOptionsSuccess,
} from './actions';
const store = { entities: { config_options: {} } };
const options = [
{ id: 1, name: 'option1', type: 'int', value: 10 },
{ id: 2, name: 'option2', type: 'string', value: 'wappa' },
];
describe('Options - actions', () => {
describe('resetOptions', () => {
describe('successful request', () => {
beforeEach(() => {
spyOn(Kolide.configOptions, 'reset').andCall(() => {
return Promise.resolve(options);
});
});
afterEach(restoreSpies);
it('calls the API', () => {
const mockStore = reduxMockStore(store);
return mockStore.dispatch(resetOptions())
.then(() => {
expect(Kolide.configOptions.reset).toHaveBeenCalled();
});
});
it('dispatches the correct actions', (done) => {
const mockStore = reduxMockStore(store);
mockStore.dispatch(resetOptions())
.then(() => {
const dispatchedActions = mockStore.getActions();
expect(dispatchedActions).toEqual([
resetOptionsStart,
resetOptionsSuccess(options),
]);
done();
})
.catch(done);
});
});
});
});

Просмотреть файл

@ -1,3 +1,35 @@
import config from './config';
import {
RESET_OPTIONS_START,
RESET_OPTIONS_SUCCESS,
RESET_OPTIONS_FAILURE,
} from './actions';
export default config.reducer;
import config, { initialState } from './config';
export default (state = initialState, { type, payload }) => {
switch (type) {
case RESET_OPTIONS_START:
return {
...state,
errors: {},
loading: true,
data: {
...state.data,
},
};
case RESET_OPTIONS_SUCCESS:
return {
...state,
errors: {},
loading: false,
data: payload.configOptions,
};
case RESET_OPTIONS_FAILURE:
return {
...state,
errors: payload.errors,
};
default:
return config.reducer(state, { type, payload });
}
};

Просмотреть файл

@ -0,0 +1,28 @@
import expect from 'expect';
import reducer from './reducer';
import {
resetOptionsSuccess,
} from './actions';
const resetOptions = [
{ id: 1, name: 'option1', type: 'int', value: 10 },
{ id: 2, name: 'option2', type: 'string', value: 'original' },
];
describe('Options - reducer', () => {
describe('reset', () => {
it('should return options on success', () => {
const initState = {
loading: true,
errors: {},
data: {},
};
const newState = reducer(initState, resetOptionsSuccess(resetOptions));
expect(newState).toEqual({
...initState,
loading: false,
data: resetOptions,
});
});
});
});

Просмотреть файл

@ -22,5 +22,14 @@ export default {
});
},
},
reset: {
valid: (bearerToken) => {
return createRequestMock({
bearerToken,
endpoint: '/api/v1/kolide/options/reset',
method: 'get',
response: { options: [] },
});
},
},
};

Просмотреть файл

@ -2,6 +2,7 @@ package datastore
import (
"reflect"
"sort"
"testing"
"github.com/kolide/kolide/server/datastore/internal/appstate"
@ -98,3 +99,35 @@ func testOptionsToConfig(t *testing.T, ds kolide.Datastore) {
assert.Len(t, resp, 11)
assert.Equal(t, "zip", resp["aws_profile_name"])
}
func testResetOptions(t *testing.T, ds kolide.Datastore) {
if ds.Name() == "inmem" {
t.Skip("inmem is being deprecated, test skipped")
}
require.Nil(t, ds.MigrateData())
// get originals
originals, err := ds.ListOptions()
require.Nil(t, err)
sort.SliceStable(originals, func(i, j int) bool { return originals[i].ID < originals[j].ID })
// grab and options, change it, save it, verify that saved
opt, err := ds.OptionByName("aws_profile_name")
require.Nil(t, err)
assert.False(t, opt.OptionSet())
opt.SetValue("zip")
err = ds.SaveOptions([]kolide.Option{*opt})
require.Nil(t, err)
opt, _ = ds.OptionByName("aws_profile_name")
assert.Equal(t, "zip", opt.GetValue())
resetOptions, err := ds.ResetOptions()
require.Nil(t, err)
sort.SliceStable(resetOptions, func(i, j int) bool { return resetOptions[i].ID < resetOptions[j].ID })
require.Equal(t, len(originals), len(resetOptions))
for i, _ := range originals {
require.Equal(t, originals[i].ID, resetOptions[i].ID)
require.Equal(t, originals[i].GetValue(), resetOptions[i].GetValue())
require.Equal(t, originals[i].Name, resetOptions[i].Name)
}
}

Просмотреть файл

@ -77,4 +77,5 @@ var testFunctions = [...]func(*testing.T, kolide.Datastore){
testMigrationStatus,
testUnicode,
testCountHostsInTargets,
testResetOptions,
}

Просмотреть файл

@ -7,6 +7,10 @@ import (
"github.com/patrickmn/sortutil"
)
func (d *Datastore) ResetOptions() ([]kolide.Option, error) {
panic("inmem is being deprecated")
}
func (d *Datastore) OptionByName(name string) (*kolide.Option, error) {
d.mtx.Lock()
defer d.mtx.Unlock()

Просмотреть файл

@ -2,11 +2,82 @@ package mysql
import (
"database/sql"
"fmt"
"github.com/kolide/kolide/server/datastore/internal/appstate"
"github.com/kolide/kolide/server/kolide"
"github.com/pkg/errors"
)
// ResetOptions note we use named return values so we can preserve and return
// errors in our defer function
func (d *Datastore) ResetOptions() (opts []kolide.Option, err error) {
// Atomically remove all existing options, reset auto increment so id's will be the
// same as original defaults, and re-insert defaults in option table.
var txn *sql.Tx
txn, err = d.db.Begin()
if err != nil {
return nil, errors.Wrap(err, "reset options begin transaction")
}
defer func() {
if err != nil {
if txErr := txn.Rollback(); txErr != nil {
err = errors.Wrap(err, fmt.Sprintf("reset options failed, transaction rollback failed with error: %s", txErr))
}
}
}()
_, err = txn.Exec("DELETE FROM options")
if err != nil {
return nil, errors.Wrap(err, "deleting options in reset options")
}
// Reset auto increment
_, err = txn.Exec("ALTER TABLE `options` AUTO_INCREMENT = 1")
if err != nil {
return nil, errors.Wrap(err, "resetting auto increment counter in reset options")
}
sqlStatement := `
INSERT INTO options (
name,
type,
value,
read_only
) VALUES (?, ?, ?, ?)
`
for _, defaultOpt := range appstate.Options() {
opt := kolide.Option{
Name: defaultOpt.Name,
ReadOnly: defaultOpt.ReadOnly,
Type: defaultOpt.Type,
Value: kolide.OptionValue{
Val: defaultOpt.Value,
},
}
dbResponse, err := txn.Exec(
sqlStatement,
opt.Name,
opt.Type,
opt.Value,
opt.ReadOnly,
)
if err != nil {
return nil, errors.Wrap(err, "inserting default option in reset options")
}
id, err := dbResponse.LastInsertId()
if err != nil {
return nil, errors.Wrap(err, "fetching id in reset options")
}
opt.ID = uint(id)
opts = append(opts, opt)
}
err = txn.Commit()
if err != nil {
return nil, errors.Wrap(err, "commiting reset options")
}
return opts, nil
}
func (d *Datastore) OptionByName(name string) (*kolide.Option, error) {
sqlStatement := `
SELECT *

Просмотреть файл

@ -25,6 +25,10 @@ type OptionStore interface {
// GetOsqueryConfigOptions returns options in a format that will be the options
// section of osquery configuration
GetOsqueryConfigOptions() (map[string]interface{}, error)
// ResetOptions will reset options to their initial values. This should be used
// with caution as it will remove any options or changes to defaults made by
// the user. Returns a list of default options.
ResetOptions() ([]Option, error)
}
// OptionService interface describes methods that operate on osquery options
@ -41,6 +45,8 @@ type OptionService interface {
// osqueryd agent if it is online. This is currently two times the most
// frequent check-in interval.
ExpectedCheckinInterval(ctx context.Context) (time.Duration, error)
// ResetOptions resets all options to their default values
ResetOptions(ctx context.Context) ([]Option, error)
}
const (
@ -113,7 +119,14 @@ func (ov OptionValue) Value() (dv driver.Value, err error) {
// Scan takes db string and turns it into an option type
func (ov *OptionValue) Scan(src interface{}) error {
return json.Unmarshal(src.([]byte), &ov.Val)
if err := json.Unmarshal(src.([]byte), &ov.Val); err != nil {
return err
}
switch v := ov.Val.(type) {
case float64:
ov.Val = int(v)
}
return nil
}
// MarshalJSON implements the json.Marshaler interface

Просмотреть файл

@ -34,3 +34,13 @@ func makeModifyOptionsEndpoint(svc kolide.Service) endpoint.Endpoint {
return optionsResponse{Options: opts}, nil
}
}
func makeResetOptionsEndpoint(svc kolide.Service) endpoint.Endpoint {
return func(ctx context.Context, request interface{}) (interface{}, error) {
options, err := svc.ResetOptions(ctx)
if err != nil {
return optionsResponse{Err: err}, nil
}
return optionsResponse{Options: options}, nil
}
}

Просмотреть файл

@ -77,6 +77,7 @@ type KolideEndpoints struct {
SearchTargets endpoint.Endpoint
GetOptions endpoint.Endpoint
ModifyOptions endpoint.Endpoint
ResetOptions endpoint.Endpoint
ImportConfig endpoint.Endpoint
GetCertificate endpoint.Endpoint
ChangeEmail endpoint.Endpoint
@ -154,6 +155,7 @@ func MakeKolideServerEndpoints(svc kolide.Service, jwtKey string) KolideEndpoint
SearchTargets: authenticatedUser(jwtKey, svc, makeSearchTargetsEndpoint(svc)),
GetOptions: authenticatedUser(jwtKey, svc, mustBeAdmin(makeGetOptionsEndpoint(svc))),
ModifyOptions: authenticatedUser(jwtKey, svc, mustBeAdmin(makeModifyOptionsEndpoint(svc))),
ResetOptions: authenticatedUser(jwtKey, svc, mustBeAdmin(makeResetOptionsEndpoint(svc))),
ImportConfig: authenticatedUser(jwtKey, svc, makeImportConfigEndpoint(svc)),
GetCertificate: authenticatedUser(jwtKey, svc, makeCertificateEndpoint(svc)),
ChangeEmail: authenticatedUser(jwtKey, svc, makeChangeEmailEndpoint(svc)),
@ -232,6 +234,7 @@ type kolideHandlers struct {
SearchTargets http.Handler
GetOptions http.Handler
ModifyOptions http.Handler
ResetOptions http.Handler
ImportConfig http.Handler
GetCertificate http.Handler
ChangeEmail http.Handler
@ -306,6 +309,7 @@ func makeKolideKitHandlers(e KolideEndpoints, opts []kithttp.ServerOption) *koli
SearchTargets: newServer(e.SearchTargets, decodeSearchTargetsRequest),
GetOptions: newServer(e.GetOptions, decodeNoParamsRequest),
ModifyOptions: newServer(e.ModifyOptions, decodeModifyOptionsRequest),
ResetOptions: newServer(e.ResetOptions, decodeNoParamsRequest),
ImportConfig: newServer(e.ImportConfig, decodeImportConfigRequest),
GetCertificate: newServer(e.GetCertificate, decodeNoParamsRequest),
ChangeEmail: newServer(e.ChangeEmail, decodeChangeEmailRequest),
@ -419,6 +423,7 @@ func attachKolideAPIRoutes(r *mux.Router, h *kolideHandlers) {
r.Handle("/api/v1/kolide/options", h.GetOptions).Methods("GET").Name("get_options")
r.Handle("/api/v1/kolide/options", h.ModifyOptions).Methods("PATCH").Name("modify_options")
r.Handle("/api/v1/kolide/options/reset", h.ResetOptions).Methods("GET").Name("reset_options")
r.Handle("/api/v1/kolide/targets", h.SearchTargets).Methods("POST").Name("search_targets")

Просмотреть файл

@ -0,0 +1,74 @@
package service
import (
"context"
"time"
"github.com/kolide/kolide/server/kolide"
)
func (mw loggingMiddleware) GetOptions(ctx context.Context) ([]kolide.Option, error) {
var (
options []kolide.Option
err error
)
defer func(begin time.Time) {
mw.logger.Log(
"method", "GetOptions",
"err", err,
"took", time.Since(begin),
)
}(time.Now())
options, err = mw.Service.GetOptions(ctx)
return options, err
}
func (mw loggingMiddleware) ModifyOptions(ctx context.Context, req kolide.OptionRequest) ([]kolide.Option, error) {
var (
options []kolide.Option
err error
)
defer func(begin time.Time) {
mw.logger.Log(
"method", "ModifyOptions",
"err", err,
"took", time.Since(begin),
)
}(time.Now())
options, err = mw.Service.ModifyOptions(ctx, req)
return options, err
}
func (mw loggingMiddleware) ExpectedCheckinInterval(ctx context.Context) (time.Duration, error) {
var (
interval time.Duration
err error
)
defer func(begin time.Time) {
mw.logger.Log(
"method", "ExpectedCheckinInterval",
"err", err,
"took", time.Since(begin),
)
}(time.Now())
interval, err = mw.Service.ExpectedCheckinInterval(ctx)
return interval, err
}
func (mw loggingMiddleware) ResetOptions(ctx context.Context) ([]kolide.Option, error) {
var (
options []kolide.Option
err error
)
defer func(begin time.Time) {
mw.logger.Log(
"method", "ResetOptions",
"err", err,
"took", time.Since(begin),
)
}(time.Now())
options, err = mw.Service.ResetOptions(ctx)
return options, err
}

Просмотреть файл

@ -0,0 +1,65 @@
package service
import (
"context"
"fmt"
"time"
"github.com/kolide/kolide/server/kolide"
)
func (mw metricsMiddleware) GetOptions(ctx context.Context) ([]kolide.Option, error) {
var (
options []kolide.Option
err error
)
defer func(begin time.Time) {
lvs := []string{"method", "GetOptions", "error", fmt.Sprint(err != nil)}
mw.requestCount.With(lvs...).Add(1)
mw.requestLatency.With(lvs...).Observe(time.Since(begin).Seconds())
}(time.Now())
options, err = mw.Service.GetOptions(ctx)
return options, err
}
func (mw metricsMiddleware) ModifyOptions(ctx context.Context, or kolide.OptionRequest) ([]kolide.Option, error) {
var (
options []kolide.Option
err error
)
defer func(begin time.Time) {
lvs := []string{"method", "ModifyOptions", "error", fmt.Sprint(err != nil)}
mw.requestCount.With(lvs...).Add(1)
mw.requestLatency.With(lvs...).Observe(time.Since(begin).Seconds())
}(time.Now())
options, err = mw.Service.ModifyOptions(ctx, or)
return options, err
}
func (mw metricsMiddleware) ResetOptions(ctx context.Context) ([]kolide.Option, error) {
var (
options []kolide.Option
err error
)
defer func(begin time.Time) {
lvs := []string{"method", "ResetOptions", "error", fmt.Sprint(err != nil)}
mw.requestCount.With(lvs...).Add(1)
mw.requestLatency.With(lvs...).Observe(time.Since(begin).Seconds())
}(time.Now())
options, err = mw.Service.ResetOptions(ctx)
return options, err
}
func (mw metricsMiddleware) ExpectedCheckinInterval(ctx context.Context) (time.Duration, error) {
var (
interval time.Duration
err error
)
defer func(begin time.Time) {
lvs := []string{"method", "ExpectedCheckinInterval", "error", fmt.Sprint(err != nil)}
mw.requestCount.With(lvs...).Add(1)
mw.requestLatency.With(lvs...).Observe(time.Since(begin).Seconds())
}(time.Now())
interval, err = mw.Service.ExpectedCheckinInterval(ctx)
return interval, err
}

Просмотреть файл

@ -11,6 +11,10 @@ import (
const expectedCheckinIntervalMultiplier = 2
const minimumExpectedCheckinInterval = 10 * time.Second
func (svc service) ResetOptions(ctx context.Context) ([]kolide.Option, error) {
return svc.ds.ResetOptions()
}
func (svc service) GetOptions(ctx context.Context) ([]kolide.Option, error) {
opts, err := svc.ds.ListOptions()
if err != nil {