diff --git a/.github/workflows/reactor_tests.yml b/.github/workflows/reactor_tests.yml index b182dbe6796..5c3e8779b03 100644 --- a/.github/workflows/reactor_tests.yml +++ b/.github/workflows/reactor_tests.yml @@ -22,4 +22,4 @@ jobs: ln -s "$PWD/cf-reactor/cf-reactor" "$PWD/cf-promises/cf-promises" "$RUNNER_TEMP/bin/" cd tests/reactor chmod -R go-w . - CF_BINDIR="$RUNNER_TEMP/bin" sh run.sh + CF_BINDIR="$RUNNER_TEMP/bin" sh run.sh --unsafe diff --git a/cf-reactor/Makefile.am b/cf-reactor/Makefile.am index 4036d272376..3c84df15546 100644 --- a/cf-reactor/Makefile.am +++ b/cf-reactor/Makefile.am @@ -45,6 +45,7 @@ libcf_reactor_la_SOURCES = \ reactor_context.c reactor_context.h \ reactor_transform.c reactor_transform.h \ stoppable_thread.c stoppable_thread.h \ + user_watcher.c user_watcher.h \ wakeup_channel.c wakeup_channel.h \ watcher.c watcher.h diff --git a/cf-reactor/reactor_transform.c b/cf-reactor/reactor_transform.c index 23650035ef8..451a68adf5c 100644 --- a/cf-reactor/reactor_transform.c +++ b/cf-reactor/reactor_transform.c @@ -42,6 +42,7 @@ #include // REACTOR_INTERVAL_RANGE, REACTOR_FILE_DELETED, REACTOR_*_TYPES, BundleIsReactorEventsBundle() #include // CheckConstraintTypeMatch(), PromiseTypeSyntaxGet(), BodySyntaxGetConstraintSyntax() #include +#include #include /* Resolves the agent, common or reactor bundle (looked up in that order) @@ -170,6 +171,10 @@ static const EventArg FILE_SIZE_EXCEEDS_ARGS[] = #define FileEventTypeNew(name, check) \ EventTypeNew(name, RVAL_TYPE_SCALAR, NO_EXTRA_ARGS, FileWatcherStateNew, FileWatcherStateDestroy, check) +/* A user event, about the user with the name of its constraint */ +#define UserEventTypeNew(name, check) \ + EventTypeNew(name, RVAL_TYPE_SCALAR, NO_EXTRA_ARGS, UserWatcherStateNew, UserWatcherStateDestroy, check) + static const EventType EVENT_TABLE[] = { FileEventTypeNew(REACTOR_FILE_DELETED, FileWatcherCheckFileDeleted), @@ -180,6 +185,8 @@ static const EventType EVENT_TABLE[] = FileEventTypeNew(REACTOR_FILE_OWNER_CHANGED, FileWatcherCheckFileOwnerChanged), EventTypeNew(REACTOR_FILE_SIZE_EXCEEDS, RVAL_TYPE_SCALAR, FILE_SIZE_EXCEEDS_ARGS, FileWatcherSizeStateNew, FileWatcherStateDestroy, FileWatcherCheckFileSizeExceeds), + UserEventTypeNew(REACTOR_USER_ADDED, UserWatcherCheckUserAdded), + UserEventTypeNew(REACTOR_USER_REMOVED, UserWatcherCheckUserRemoved), EventTypeNewNull() }; diff --git a/cf-reactor/user_watcher.c b/cf-reactor/user_watcher.c new file mode 100644 index 00000000000..083846a3be5 --- /dev/null +++ b/cf-reactor/user_watcher.c @@ -0,0 +1,286 @@ +/* + Copyright 2026 Northern.tech AS + + This file is part of CFEngine 3 - written and maintained by Northern.tech AS. + + This program is free software; you can redistribute it and/or modify it + under the terms of the GNU General Public License as published by the + Free Software Foundation; version 3. + + 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA + + To the extent this program is licensed as part of the Enterprise + versions of CFEngine, the applicable Commercial Open Source License + (COSL) may apply to this file if you as a licensee so wish it. See + included file COSL.txt. +*/ + +#include +#include +#include +#include +#include +#include + +#ifdef __MINGW32__ +#include +#else +#include +#endif + +/* Minimum time between two logs of a persisting failure to look up the user, + * since the user is looked up on every poll */ +#define CHECK_ERROR_LOG_INTERVAL_SECS 60 + +typedef struct +{ + char *name; + time_t last_checked; /* Time of the last successful check, 0 if none yet */ + time_t + last_error_logged; /* Time the last error was logged, 0 if no errors */ + + /* What the last successful check observed */ + bool last_exists; +} UserWatcherState; + + +/** + * @brief Creates the state of a user_added or user_removed watcher. + * + * @param ctx Unused + * @param args The arguments of the event, the name of the user to watch + * @return The new state, to be destroyed with UserWatcherStateDestroy() + */ +void *UserWatcherStateNew(ARG_UNUSED const EvalContext *ctx, const Rlist *args) +{ + assert(args != NULL); + const char *name = RlistScalarValue(args); + + UserWatcherState *state = + (UserWatcherState *) xcalloc(1, sizeof(UserWatcherState)); + state->name = xstrdup(name); + + return state; +} + +/** + * @brief Destroys the state of a user watcher. + * + * @param state The watcher state, may be NULL + */ +void UserWatcherStateDestroy(void *_state) +{ + UserWatcherState *state = _state; + if (state != NULL) + { + free(state->name); + free(state); + } +} + +/** + * @brief Logs the error message of a failed operation performed by the + * watcher. + * + * A persisting failure is logged at most once every + * CHECK_ERROR_LOG_INTERVAL_SECS. + * + * @param state The watcher state + * @param operation The operation (e.g., LookupAccountName() or getpwnam_r()) + * @param err The error code (errno on Unix, Win32 error code on Windows) + */ +static void LogCheckError( + UserWatcherState *state, const char *operation, int err) +{ + assert(state != NULL); + + const time_t now = time(NULL); + if ((state->last_error_logged == 0) + || (now - state->last_error_logged >= CHECK_ERROR_LOG_INTERVAL_SECS)) + { + Log(LOG_LEVEL_ERR, + "Unable to look up user '%s' while checking for user events (%s: %s)", + state->name, + operation, + GetErrorStrFromCode(err)); + state->last_error_logged = now; + } +} + +/** + * @brief Checks whether the user of the watcher exists. + * + * Looks up the one name only, without listing all users, so it is safe to + * call while the main thread runs bundles. On Windows, only user accounts + * count, not groups or built-in accounts such as SYSTEM. + * + * @param state The watcher state + * @param[out] exists Whether user exists + * @return false on error + */ +static bool UserLookup(UserWatcherState *state, bool *exists) +{ + assert(state != NULL); + assert(exists != NULL); + +#ifdef __MINGW32__ + char sid[CF_MAXSIDSIZE]; + SID_NAME_USE type; + DWORD error = NovaWin_LookupAccountName( + state->name, (SID *) sid, sizeof(sid), &type); + if (error == ERROR_SUCCESS) + { + *exists = (type == SidTypeUser); + } + else if (NovaWin_IsAccountNotFound(error)) + { + *exists = false; + } + else + { + LogCheckError(state, "LookupAccountName", error); + return false; + } +#else + struct passwd pwd; + struct passwd *result = NULL; + char buf[GETPW_R_SIZE_MAX]; + int ret = getpwnam_r(state->name, &pwd, buf, sizeof(buf), &result); + if (result != NULL) + { + *exists = true; + } + else if (ret == 0) + { + /* POSIX: not found is a NULL result with a return value of 0. Any + * other return value is an error, so the user is not taken as + * removed on a failing lookup. */ + *exists = false; + } + else + { + LogCheckError(state, "getpwnam_r", ret); + return false; + } +#endif + + state->last_error_logged = 0; + return true; +} + +/** + * @brief Records the result of a successful check. + * + * The first successful check is only the reference for the next ones, so it + * never fires. + * + * @param state The watcher state + * @param exists Whether the user exists + * @param happened Whether the event happened since the last check + * @param event The name of the event for logging (e.g. "removal") + * @return WATCHER_CHECK_EVENT if the event fired, WATCHER_CHECK_NO_EVENT + * otherwise + */ +static WatcherCheckResult UserWatcherRecordObservation( + UserWatcherState *state, bool exists, bool happened, const char *event) +{ + assert(state != NULL); + assert(event != NULL); + + const time_t now = time(NULL); + happened = happened && (state->last_checked != 0); + + if (happened) + { + char last_checked_str[26]; + const char *last_checked_ts = + cf_strtimestamp_local(state->last_checked, last_checked_str); + + char now_str[26]; + const char *now_ts = cf_strtimestamp_local(now, now_str); + + Log(LOG_LEVEL_VERBOSE, + "Observed %s of user '%s' at %s (last checked at %s)", + event, + state->name, + (now_ts != NULL) ? now_ts : "unknown time", + (last_checked_ts != NULL) ? last_checked_ts : "unknown time"); + } + + state->last_checked = now; + state->last_exists = exists; + return happened ? WATCHER_CHECK_EVENT : WATCHER_CHECK_NO_EVENT; +} + +/** + * @brief Checks for the user_added event. + * + * Fires once when the user goes from absent to present. + * + * @param state The watcher state + * @return WATCHER_CHECK_EVENT if the user was added since the last check, + * WATCHER_CHECK_ERROR if the lookup failed, WATCHER_CHECK_NO_EVENT + * otherwise + */ +WatcherCheckResult UserWatcherCheckUserAdded(void *_state) +{ + assert(_state != NULL); + UserWatcherState *state = _state; + + bool exists; + if (!UserLookup(state, &exists)) + { + /* Unknown state, keep the last observation as is */ + return WATCHER_CHECK_ERROR; + } + + if (!exists && (state->last_exists || state->last_checked == 0)) + { + Log(LOG_LEVEL_VERBOSE, + "User '%s' is absent, watching for its addition", + state->name); + } + + bool added = !state->last_exists && exists; + return UserWatcherRecordObservation(state, exists, added, "addition"); +} + +/** + * @brief Checks for the user_removed event. + * + * Fires once when the user goes from present to absent. + * + * @param state The watcher state + * @return WATCHER_CHECK_EVENT if the user was removed since the last check, + * WATCHER_CHECK_ERROR if the lookup failed, WATCHER_CHECK_NO_EVENT + * otherwise + */ +WatcherCheckResult UserWatcherCheckUserRemoved(void *_state) +{ + assert(_state != NULL); + UserWatcherState *state = _state; + + bool exists; + if (!UserLookup(state, &exists)) + { + /* Unknown state, keep the last observation as is */ + return WATCHER_CHECK_ERROR; + } + + if (exists && !state->last_exists) + { + Log(LOG_LEVEL_VERBOSE, + "User '%s' is present, watching for its removal", + state->name); + } + + bool removed = state->last_exists && !exists; + return UserWatcherRecordObservation(state, exists, removed, "removal"); +} diff --git a/cf-reactor/user_watcher.h b/cf-reactor/user_watcher.h new file mode 100644 index 00000000000..5c0d92be277 --- /dev/null +++ b/cf-reactor/user_watcher.h @@ -0,0 +1,37 @@ +/* + Copyright 2026 Northern.tech AS + + This file is part of CFEngine 3 - written and maintained by Northern.tech AS. + + This program is free software; you can redistribute it and/or modify it + under the terms of the GNU General Public License as published by the + Free Software Foundation; version 3. + + 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA + + To the extent this program is licensed as part of the Enterprise + versions of CFEngine, the applicable Commercial Open Source License + (COSL) may apply to this file if you as a licensee so wish it. See + included file COSL.txt. +*/ + +#ifndef CFENGINE_USER_WATCHER_H +#define CFENGINE_USER_WATCHER_H + +#include +#include + + +void *UserWatcherStateNew(const EvalContext *ctx, const Rlist *args); +WatcherCheckResult UserWatcherCheckUserAdded(void *state); +WatcherCheckResult UserWatcherCheckUserRemoved(void *state); +void UserWatcherStateDestroy(void *state); + +#endif diff --git a/libpromises/mod_reactor.c b/libpromises/mod_reactor.c index 4ad4b49f8a4..ea1f8c6b616 100644 --- a/libpromises/mod_reactor.c +++ b/libpromises/mod_reactor.c @@ -51,6 +51,8 @@ static const ConstraintSyntax when_constraints[] = ConstraintSyntaxNewString(REACTOR_FILE_OWNER_CHANGED, CF_ANYSTRING, "File to react for on change of its owning user or group", SYNTAX_STATUS_NORMAL), ConstraintSyntaxNewString(REACTOR_FILE_SIZE_EXCEEDS, CF_ANYSTRING, "File to react for when its size grows above '" REACTOR_SIZE_THRESHOLD "'", SYNTAX_STATUS_NORMAL), ConstraintSyntaxNewInt(REACTOR_SIZE_THRESHOLD, CF_VALRANGE, "Size in bytes above which '" REACTOR_FILE_SIZE_EXCEEDS "' fires", SYNTAX_STATUS_NORMAL), + ConstraintSyntaxNewString(REACTOR_USER_ADDED, CF_ANYSTRING, "User to react for on addition", SYNTAX_STATUS_NORMAL), + ConstraintSyntaxNewString(REACTOR_USER_REMOVED, CF_ANYSTRING, "User to react for on removal", SYNTAX_STATUS_NORMAL), ConstraintSyntaxNewInt("interval", REACTOR_INTERVAL_RANGE, "Seconds between two checks for the event", SYNTAX_STATUS_NORMAL), ConstraintSyntaxNewNull() }; diff --git a/libpromises/mod_reactor.h b/libpromises/mod_reactor.h index 839a72721bb..eeb5380a122 100644 --- a/libpromises/mod_reactor.h +++ b/libpromises/mod_reactor.h @@ -36,6 +36,8 @@ #define REACTOR_FILE_OWNER_CHANGED "file_owner_changed" #define REACTOR_FILE_SIZE_EXCEEDS "file_size_exceeds" #define REACTOR_SIZE_THRESHOLD "size_threshold" +#define REACTOR_USER_ADDED "user_added" +#define REACTOR_USER_REMOVED "user_removed" /* Range of the 'interval' attribute of the 'when' body of events promises */ #define REACTOR_INTERVAL_RANGE "1,99999999999" diff --git a/tests/reactor/tests/01_event_types/unsafe/008_user_events.cf b/tests/reactor/tests/01_event_types/unsafe/008_user_events.cf new file mode 100644 index 00000000000..3e2352168fa --- /dev/null +++ b/tests/reactor/tests/01_event_types/unsafe/008_user_events.cf @@ -0,0 +1,38 @@ +####################################################### +# +# Test the user_added and user_removed events +# +####################################################### +body common control +{ + inputs => { "../../../lib.cf" }; + bundlesequence => { "test" }; + version => "1.0"; +} + +body when added(user) +{ + user_added => "$(user)"; +} + +body when removed(user) +{ + user_removed => "$(user)"; +} + +bundle reactor test +{ + vars: + "test_user" string => getenv("TEST_USER", 999); + "events_file" string => getenv("EVENTS_FILE", 999); + "promise_name" string => basename("$(this.promise_filename)", ".cf"); + + events: + "$(promise_name)_added" + when => added("$(test_user)"), + then => record_event("$(events_file)", "added"); + + "$(promise_name)_removed" + when => removed("$(test_user)"), + then => record_event("$(events_file)", "removed"); +} diff --git a/tests/reactor/tests/01_event_types/unsafe/008_user_events.sh b/tests/reactor/tests/01_event_types/unsafe/008_user_events.sh new file mode 100644 index 00000000000..dd271c696e6 --- /dev/null +++ b/tests/reactor/tests/01_event_types/unsafe/008_user_events.sh @@ -0,0 +1,45 @@ +#!/bin/sh + +# check that user_added and user_removed fire once when a user is added and +# removed. Adds and removes a user with sudo. + +set -ex + +. "$(dirname "$0")/../../../lib.sh" + +POLICY_FILE="$1" + +TEST_USER="cfreactortest" +EVENTS_FILE="$WORKDIR/tmp/events" +export TEST_USER EVENTS_FILE + +# never touch an existing user +if id "$TEST_USER" >/dev/null 2>&1 +then + echo "User '$TEST_USER' already exists" >&2 + exit 1 +fi + +start_reactor "$POLICY_FILE" + +# replaces the trap of start_reactor +cleanup() { + stop_reactor + sudo userdel "$TEST_USER" 2>/dev/null || true +} +trap cleanup EXIT + +wait_for_watchers 2 + +# checked on registration +wait_for_log "User '$TEST_USER' is absent, watching for its addition" + +sudo useradd -M "$TEST_USER" +wait_for_lines "$EVENTS_FILE" 1 +check_event "$EVENTS_FILE" 1 "added" + +wait_for_log "User '$TEST_USER' is present, watching for its removal" + +sudo userdel "$TEST_USER" +wait_for_lines "$EVENTS_FILE" 2 +check_event "$EVENTS_FILE" 2 "removed" diff --git a/tests/unit/Makefile.am b/tests/unit/Makefile.am index 42e9128a522..d5084bafb22 100644 --- a/tests/unit/Makefile.am +++ b/tests/unit/Makefile.am @@ -159,6 +159,7 @@ check_PROGRAMS = \ client_code_test \ watcher_test \ file_watcher_test \ + user_watcher_test \ stoppable_thread_test \ wakeup_channel_test @@ -241,6 +242,8 @@ watcher_test_SOURCES = watcher_test.c \ file_watcher_test_SOURCES = file_watcher_test.c \ ../../cf-reactor/file_watcher.c +user_watcher_test_SOURCES = user_watcher_test.c + stoppable_thread_test_SOURCES = stoppable_thread_test.c \ ../../cf-reactor/stoppable_thread.c diff --git a/tests/unit/user_watcher_test.c b/tests/unit/user_watcher_test.c new file mode 100644 index 00000000000..e57124e443f --- /dev/null +++ b/tests/unit/user_watcher_test.c @@ -0,0 +1,252 @@ +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + +/* Unix only, like file_watcher_test. Tells what the lookup found from the + * verbose log of the first check, since the first check never fires. The + * .c file is included so that tests can fake an earlier check. */ + +#define ABSENT_USER "cfengine_user_watcher_test_absent" + +typedef struct +{ + LoggingPrivContext ctx; + LogLevel old_level; + int count; /* Number of VERBOSE messages captured */ + char message[256]; /* Last VERBOSE message captured */ +} LogCapture; + +static char *CaptureVerboseLogHook( + LoggingPrivContext *pctx, LogLevel level, const char *message) +{ + if (level == LOG_LEVEL_VERBOSE) + { + LogCapture *capture = pctx->param; + capture->count++; + strlcpy(capture->message, message, sizeof(capture->message)); + } + return (char *) message; +} + +/* Captures VERBOSE messages with the log hook. The global level is set to + * CRIT so they aren't printed. force_hook_level still runs the hook for + * them. Stop with StopCapture(), which frees the returned capture. */ +static LogCapture *StartCapture(void) +{ + LogCapture *capture = xcalloc(1, sizeof(LogCapture)); + capture->ctx.log_hook = CaptureVerboseLogHook; + capture->ctx.param = capture; + capture->ctx.force_hook_level = LOG_LEVEL_VERBOSE; + LoggingPrivSetContext(&capture->ctx); + capture->old_level = LogGetGlobalLevel(); + LogSetGlobalLevel(LOG_LEVEL_CRIT); + return capture; +} + +static void StopCapture(LogCapture *capture) +{ + LogSetGlobalLevel(capture->old_level); + LoggingPrivSetContext(NULL); + free(capture); +} + +/* Copies the name of the user running the test, which always exists, to + * name */ +static void GetPresentUser(char *name, size_t name_size) +{ + const struct passwd *pw = getpwuid(getuid()); + assert_true(pw != NULL); + strlcpy(name, pw->pw_name, name_size); +} + +/* Returns a new state of a user watcher of the user with the given name */ +static void *NewUserState(const char *name) +{ + Rlist *args = NULL; + RlistAppendScalar(&args, name); + void *state = UserWatcherStateNew(NULL, args); + RlistDestroy(args); + return state; +} + +static void test_check_user_removed_present(void) +{ + char name[256]; + GetPresentUser(name, sizeof(name)); + char expected[512]; + snprintf( + expected, + sizeof(expected), + "User '%s' is present, watching for its removal", + name); + + LogCapture *capture = StartCapture(); + void *state = NewUserState(name); + + /* The first check is the reference, so it doesn't fire */ + assert_int_equal( + UserWatcherCheckUserRemoved(state), WATCHER_CHECK_NO_EVENT); + assert_int_equal(capture->count, 1); + assert_string_equal(capture->message, expected); + + /* Still present: not removed, and not logged again */ + assert_int_equal( + UserWatcherCheckUserRemoved(state), WATCHER_CHECK_NO_EVENT); + assert_int_equal(capture->count, 1); + + UserWatcherStateDestroy(state); + StopCapture(capture); +} + +static void test_check_user_removed_absent(void) +{ + LogCapture *capture = StartCapture(); + void *state = NewUserState(ABSENT_USER); + + /* Absent, so nothing to watch for yet and nothing logged */ + assert_int_equal( + UserWatcherCheckUserRemoved(state), WATCHER_CHECK_NO_EVENT); + assert_int_equal( + UserWatcherCheckUserRemoved(state), WATCHER_CHECK_NO_EVENT); + assert_int_equal(capture->count, 0); + + UserWatcherStateDestroy(state); + StopCapture(capture); +} + +static void test_check_user_added_absent(void) +{ + LogCapture *capture = StartCapture(); + void *state = NewUserState(ABSENT_USER); + + /* The first check is the reference, so it doesn't fire */ + assert_int_equal(UserWatcherCheckUserAdded(state), WATCHER_CHECK_NO_EVENT); + assert_int_equal(capture->count, 1); + assert_string_equal( + capture->message, + "User '" ABSENT_USER "' is absent, watching for its addition"); + + /* Still absent: not added, and not logged again */ + assert_int_equal(UserWatcherCheckUserAdded(state), WATCHER_CHECK_NO_EVENT); + assert_int_equal(capture->count, 1); + + UserWatcherStateDestroy(state); + StopCapture(capture); +} + +static void test_check_user_added_present(void) +{ + char name[256]; + GetPresentUser(name, sizeof(name)); + + LogCapture *capture = StartCapture(); + void *state = NewUserState(name); + + /* Present, so nothing to watch for yet and nothing logged */ + assert_int_equal(UserWatcherCheckUserAdded(state), WATCHER_CHECK_NO_EVENT); + assert_int_equal(UserWatcherCheckUserAdded(state), WATCHER_CHECK_NO_EVENT); + assert_int_equal(capture->count, 0); + + UserWatcherStateDestroy(state); + StopCapture(capture); +} + +static void test_check_user_added_fires(void) +{ + char name[256]; + GetPresentUser(name, sizeof(name)); + char expected[512]; + snprintf( + expected, + sizeof(expected), + "Observed addition of user '%s' at ", + name); + + LogCapture *capture = StartCapture(); + UserWatcherState *state = NewUserState(name); + + /* Fake an earlier check that saw the user absent */ + state->last_checked = time(NULL); + state->last_exists = false; + + assert_int_equal(UserWatcherCheckUserAdded(state), WATCHER_CHECK_EVENT); + assert_int_equal(capture->count, 1); + assert_true(StringStartsWith(capture->message, expected)); + + /* Fires only once */ + assert_int_equal(UserWatcherCheckUserAdded(state), WATCHER_CHECK_NO_EVENT); + assert_int_equal(capture->count, 1); + + UserWatcherStateDestroy(state); + StopCapture(capture); +} + +static void test_check_user_removed_fires(void) +{ + LogCapture *capture = StartCapture(); + UserWatcherState *state = NewUserState(ABSENT_USER); + + /* Fake an earlier check that saw the user present */ + state->last_checked = time(NULL); + state->last_exists = true; + + assert_int_equal( + UserWatcherCheckUserRemoved(state), WATCHER_CHECK_EVENT); + assert_int_equal(capture->count, 1); + assert_true(StringStartsWith( + capture->message, + "Observed removal of user '" ABSENT_USER "' at ")); + + /* Fires only once */ + assert_int_equal( + UserWatcherCheckUserRemoved(state), WATCHER_CHECK_NO_EVENT); + assert_int_equal(capture->count, 1); + + UserWatcherStateDestroy(state); + StopCapture(capture); +} + +static void test_check_empty_name(void) +{ + LogCapture *capture = StartCapture(); + + /* An empty name is a user that doesn't exist, not a lookup error */ + void *state = NewUserState(""); + assert_int_equal(UserWatcherCheckUserAdded(state), WATCHER_CHECK_NO_EVENT); + assert_int_equal(capture->count, 1); + assert_string_equal( + capture->message, "User '' is absent, watching for its addition"); + UserWatcherStateDestroy(state); + + StopCapture(capture); +} + +static void test_state_destroy_null(void) +{ + UserWatcherStateDestroy(NULL); +} + +int main() +{ + PRINT_TEST_BANNER(); + const UnitTest tests[] = { + unit_test(test_check_user_removed_present), + unit_test(test_check_user_removed_absent), + unit_test(test_check_user_added_absent), + unit_test(test_check_user_added_present), + unit_test(test_check_user_added_fires), + unit_test(test_check_user_removed_fires), + unit_test(test_check_empty_name), + unit_test(test_state_destroy_null), + }; + + return run_tests(tests); +}