diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cc80c4033..ad3444d5c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ ### Fixed * ([#????](https://github.com/realm/realm-core/issues/????), since v?.?.?) -* None. +* If a user authenticated app services (http) request is redirected, the user will be logged out since the authorization header is removed before request is sent to the new server. If an authenticated request fails, the location will now be updated prior to refreshing the access token to ensure the remote server URL is up to date. ([#8012](https://github.com/realm/realm-core/issues/8012), since v12.9.0) ### Breaking changes * Removed http 301/308 redirection support from app services operations provided by App. It is assumed that the SDK's http implementation will handle http redirects instead. ([PR #7996](https://github.com/realm/realm-core/pull/7996)) @@ -17,7 +17,7 @@ ----------- ### Internals -* None. +* Enabled curl redirect support for the test http network transport and added a test that uses the redirect server, if enabled, to validate redirections being handled by the network transport implementation. ([PR #8011](https://github.com/realm/realm-core/pull/8011)) ---------------------------------------------- diff --git a/src/realm/object-store/sync/app.cpp b/src/realm/object-store/sync/app.cpp index 6f21614415..a5f5e18391 100644 --- a/src/realm/object-store/sync/app.cpp +++ b/src/realm/object-store/sync/app.cpp @@ -358,6 +358,7 @@ std::string App::get_ws_host_url() std::string App::make_sync_route(Optional ws_host_url) { + // If not providing a new ws_host_url, then use the App's current ws_host_url return util::format("%1%2%3/%4%5", ws_host_url.value_or(m_ws_host_url), s_base_path, s_app_path, m_config.app_id, s_sync_path); } @@ -1024,6 +1025,12 @@ std::shared_ptr App::create_fake_user_for_testing(const std::string& user_ return user; } +void App::reset_location_for_testing() +{ + util::CheckedLockGuard guard(m_route_mutex); + m_location_updated = false; + configure_route(m_base_url, ""); +} void App::refresh_custom_data(const std::shared_ptr& user, UniqueFunction)>&& completion) @@ -1252,8 +1259,11 @@ void App::handle_auth_failure(const AppError& error, std::unique_ptr&& return; } - // Otherwise we may be able to request a new access token and have the request succeed with that - refresh_access_token(user, false, + // Otherwise we may be able to request a new access token and resend the request request to see + // if it will succeed with that. Also update the location beforehand to ensure the failure + // wasn't because of a redirect handled by the SDK (which strips the Authorization header + // before re-sending the request to the new server) + refresh_access_token(user, true, [self = shared_from_this(), request = std::move(request), completion = std::move(completion), response = std::move(response), user](Optional&& error) mutable { if (error) { @@ -1262,6 +1272,12 @@ void App::handle_auth_failure(const AppError& error, std::unique_ptr&& return; } + // In case the location info was updated, update the original request + // to point to the latest location URL. + auto url = util::Uri::parse(request->url); + request->url = util::format("%1%2%3%4", self->get_host_url(), url.get_path(), + url.get_query(), url.get_frag()); + // Reissue the request with the new access token request->headers = get_request_headers(user, RequestTokenType::AccessToken); self->do_request(std::move(request), [self = self, completion = std::move(completion)]( diff --git a/src/realm/object-store/sync/app.hpp b/src/realm/object-store/sync/app.hpp index ddee73aecc..6509d20ca2 100644 --- a/src/realm/object-store/sync/app.hpp +++ b/src/realm/object-store/sync/app.hpp @@ -192,6 +192,10 @@ class App : public std::enable_shared_from_this, std::shared_ptr create_fake_user_for_testing(const std::string& user_id, const std::string& access_token, const std::string& refresh_token) REQUIRES(!m_user_mutex); + // For testing only + // Reset the location_updated flag so the next App request will request the location again + void reset_location_for_testing() REQUIRES(!m_route_mutex); + // MARK: - Provider Clients /// A struct representing a user API key as returned by the App server. diff --git a/test/object-store/sync/app.cpp b/test/object-store/sync/app.cpp index a50edecf09..1a00715ca7 100644 --- a/test/object-store/sync/app.cpp +++ b/test/object-store/sync/app.cpp @@ -18,6 +18,7 @@ #include "collection_fixtures.hpp" #include "util/sync/baas_admin_api.hpp" +#include "util/sync/redirect_server.hpp" #include "util/sync/sync_test_utils.hpp" #include "util/test_path.hpp" #include "util/unit_test_transport.hpp" @@ -3248,6 +3249,237 @@ TEST_CASE("app: sync integration", "[sync][pbs][app][baas]") { } } +TEST_CASE("app: network transport handles redirection", "[sync][app][baas]") { + auto logger = util::Logger::get_default_logger(); + auto redirector = sync::RedirectingHttpServer(get_real_base_url(), logger); + + std::mutex counter_mutex; + int error_count = 0; + int location_count = 0; + int redirect_count = 0; + int wsredirect_count = 0; + using RedirectEvent = sync::RedirectingHttpServer::Event; + redirector.set_event_hook([&](RedirectEvent event, std::optional message) { + std::lock_guard lk(counter_mutex); + switch (event) { + case RedirectEvent::location: + location_count++; + logger->trace("Redirector event: location - count: %1", location_count); + return; + case RedirectEvent::redirect: + redirect_count++; + logger->trace("Redirector event: redirect - count: %1", redirect_count); + return; + case RedirectEvent::ws_redirect: + wsredirect_count++; + logger->trace("Redirector event: ws_redirect - count: %1", wsredirect_count); + return; + case RedirectEvent::error: + error_count++; + logger->trace("Redirect server received error: %1", message.value_or("unknown error")); + return; + } + }); + + auto reset_counters = [&] { + std::lock_guard lk(counter_mutex); + error_count = 0; + location_count = 0; + redirect_count = 0; + wsredirect_count = 0; + }; + + auto check_counters = [&](int locations, int redirects, int wsredirects, int errors) { + std::lock_guard lk(counter_mutex); + REQUIRE(location_count == locations); + REQUIRE(redirect_count == redirects); + REQUIRE(wsredirect_count == wsredirects); + REQUIRE(error_count == errors); + }; + + // Make sure the location response points to the actual server + redirector.force_http_redirect(false); + redirector.force_websocket_redirect(false); + + auto tas_config = TestAppSession::Config{}; + tas_config.base_url = redirector.base_url(); + + // Since this test defines its own RedirectingHttpServer, the app session doesn't + // need to be retrieved at the beginning of the test to ensure the redirect server + // is initialized. + TestAppSession session{get_runtime_app_session(), tas_config, DeleteApp{false}}; + auto app = session.app(); + + // We should have already requested the location when the user was logged in + // during the session constructor. + auto user1_a = app->current_user(); + REQUIRE(user1_a); + // Expected location requested 1 time for the original location request, + // all others 0 since location request prior to login hits actual server + check_counters(1, 0, 0, 0); + REQUIRE(app->get_base_url() == redirector.base_url()); + REQUIRE(app->get_host_url() == redirector.server_url()); + + SECTION("Appservices requests are redirected") { + // Switch the location to use the redirector's address for http requests which will + // return redirect responses to redirect the request to the actual server + redirector.force_http_redirect(true); + redirector.force_websocket_redirect(false); + reset_counters(); + // Reset the location flag and the cached location info so the app will request + // the location from the original base URL again upon the next appservices request. + app->reset_location_for_testing(); + // Email registration should complete successfully + AutoVerifiedEmailCredentials creds; + { + auto pf = util::make_promise_future(); + app->provider_client().register_email( + creds.email, creds.password, + [promise = util::CopyablePromiseHolder(std::move(pf.promise))]( + util::Optional error) mutable { + if (error) { + promise.get_promise().set_error(error->to_status()); + return; + } + promise.get_promise().emplace_value(); + }); + REQUIRE(pf.future.get_no_throw().is_ok()); + } + // Login should fail since the profile request does not complete successfully due + // to the authorization headers being stripped from the redirected request + REQUIRE_FALSE(session.log_in_user(creds).is_ok()); + // Since the login failed, the original user1 is still the App's current user + auto user1_b = app->current_user(); + REQUIRE(user1_b->is_logged_in()); + REQUIRE(user1_a == user1_b); + // Expected location requested 2 times: once for register and after first profile + // attempt fails; there are 4 redirects: register, login, get profile, and refresh + // token + check_counters(2, 4, 0, 0); + REQUIRE(app->get_base_url() == redirector.base_url()); + REQUIRE(app->get_host_url() == redirector.base_url()); + + // Revert the location to point to the actual server's address so the login + // will complete successfully. + redirector.force_http_redirect(false); + redirector.force_websocket_redirect(false); + reset_counters(); + // Log in will refresh the location prior to performing the login + auto result = session.log_in_user(creds); + REQUIRE(result.is_ok()); + // Since the log in completed successfully, app's current user was updated to + // the new user. + auto user3 = result.get_value(); + REQUIRE(user3); + REQUIRE(user3->is_logged_in()); + REQUIRE(user3 == app->current_user()); + REQUIRE(user3 != user1_b); + // Expected location requested 1 time for location after first profile attempt + // fails; and two redirects: login and the first profile attempt + check_counters(1, 2, 0, 0); + REQUIRE(app->get_base_url() == redirector.base_url()); + REQUIRE(app->get_host_url() == redirector.server_url()); + } + + SECTION("Websocket connection returns redirection") { + auto get_dogs = [](SharedRealm r) -> Results { + wait_for_upload(*r, std::chrono::seconds(10)); + wait_for_download(*r, std::chrono::seconds(10)); + return Results(r, r->read_group().get_table("class_Dog")); + }; + + auto create_one_dog = [](SharedRealm r) { + r->begin_transaction(); + CppContext c; + Object::create(c, r, "Dog", + std::any(AnyDict{{"_id", std::any(ObjectId::gen())}, + {"breed", std::string("bulldog")}, + {"name", std::string("fido")}}), + CreatePolicy::ForceCreate); + r->commit_transaction(); + }; + + const auto schema = get_default_schema(); + const auto partition = random_string(100); + // This websocket connection is not using redirection. Should connect + // directly to the actual server + { + reset_counters(); + SyncTestFile config(user1_a, partition, schema); + auto r = Realm::get_shared_realm(config); + REQUIRE(get_dogs(r).size() == 0); + create_one_dog(r); + REQUIRE(get_dogs(r).size() == 1); + // The redirect server is not expected to be used... + check_counters(0, 0, 0, 0); + } + // Switch the location to use the redirector's address for websocket requests which will + // return the 4003 redirect close code, forcing app to update the location and refresh + // the access token. + redirector.force_websocket_redirect(true); + // Since app uses the hostname value returned from the last location response to create + // the server URL for requesting the location, the first location request (due to the + // location_updated flag being reset) needs to return the redirect server for both + // hostname and ws_hostname. When the location is requested a second time due to the + // login request, the location response should include the actual server for the + // hostname (so the login is successful) and the redirect server for the ws_hostname + // so the websocket initially connects to the redirect server. + redirector.force_http_redirect(true); + { + redirector.set_event_hook([&](RedirectEvent event, std::optional message) { + std::lock_guard lk(counter_mutex); + switch (event) { + case RedirectEvent::location: + location_count++; + logger->trace("Redirector event: location - count: %1", location_count); + if (location_count == 1) + // No longer sending redirect server as location hostname value + redirector.force_http_redirect(false); + return; + case RedirectEvent::redirect: + redirect_count++; + logger->trace("Redirector event: redirect - count: %1", redirect_count); + return; + case RedirectEvent::ws_redirect: + wsredirect_count++; + logger->trace("Redirector event: ws_redirect - count: %1", wsredirect_count); + return; + case RedirectEvent::error: + error_count++; + logger->trace("Redirect server received error: %1", message.value_or("unknown error")); + return; + } + }); + } + { + reset_counters(); + // Reset the location flag and the cached location info so the app will request + // the location from the original base URL again upon the next appservices request. + app->reset_location_for_testing(); + // Create a new user and log in to update the location info + // and start with a new realm + auto result = session.create_user_and_log_in(); + REQUIRE(result.is_ok()); + // The location should have been requested twice; before register email and after + // first profile attempt fails; and three redirects: register email, login, and + // first profile attempt. + // NOTE: The ws_hostname still points to the redirect server + check_counters(2, 3, 0, 0); + reset_counters(); + SyncTestFile config(app->current_user(), partition, schema); + auto r = Realm::get_shared_realm(config); + Results dogs = get_dogs(r); + REQUIRE(dogs.size() == 1); + REQUIRE(dogs.get(0).get("breed") == "bulldog"); + REQUIRE(dogs.get(0).get("name") == "fido"); + // The websocket should have redirected one time - the location update hits the + // actual server since the hostname points to its URL after the location update + // during user log in. + check_counters(0, 0, 1, 0); + } + } +} + TEST_CASE("app: sync logs contain baas coid", "[sync][app][baas]") { class InMemoryLogger : public util::Logger { public: @@ -5135,8 +5367,10 @@ TEST_CASE("app: refresh access token unit tests", "[sync][app][user][token]") { SECTION("refresh token ensure flow is correct") { /* Expected flow: + Location - first http request since app was just created Login - this gets access and refresh tokens Get profile - throw back a 401 error + Location - return location response Refresh token - get a new token for the user Get profile - get the profile with the new token */ @@ -5168,13 +5402,13 @@ TEST_CASE("app: refresh access token unit tests", "[sync][app][user][token]") { } } else if (request.url.find("/session") != std::string::npos && request.method == HttpMethod::post) { - CHECK(state.get() == TestState::profile_1); + CHECK(state.get() == TestState::location); state.transition_to(TestState::refresh); nlohmann::json json{{"access_token", good_access_token2}}; completion({200, 0, {}, json.dump()}); } else if (request.url.find("/location") != std::string::npos) { - CHECK(state.get() == TestState::unknown); + CHECK((state.get() == TestState::unknown || state.get() == TestState::profile_1)); state.transition_to(TestState::location); CHECK(request.method == HttpMethod::get); completion({200, diff --git a/test/object-store/util/sync/baas_admin_api.cpp b/test/object-store/util/sync/baas_admin_api.cpp index 6306f279f1..c254eabdb6 100644 --- a/test/object-store/util/sync/baas_admin_api.cpp +++ b/test/object-store/util/sync/baas_admin_api.cpp @@ -332,6 +332,12 @@ app::Response do_http_request(const app::Request& request) list = curl_slist_append(list, header_str.c_str()); } + // Enable redirection, and don't revert POST to GET for 301/302/303 redirects + // Max redirects is 30 by default + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1); + curl_easy_setopt(curl, CURLOPT_POSTREDIR, CURL_REDIR_POST_ALL); + + // Set callbacks to write the response headers and data curl_easy_setopt(curl, CURLOPT_HTTPHEADER, list); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curl_write_cb); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); diff --git a/test/object-store/util/sync/redirect_server.hpp b/test/object-store/util/sync/redirect_server.hpp index 6c3adbccf6..d40ec2f07f 100644 --- a/test/object-store/util/sync/redirect_server.hpp +++ b/test/object-store/util/sync/redirect_server.hpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include @@ -35,17 +36,29 @@ namespace realm::sync { class RedirectingHttpServer { public: + enum Event { + error, + location, + redirect, + ws_redirect, + }; + + // Allow the redirecting server to choose the listen port RedirectingHttpServer(std::string redirect_to_base_url, std::shared_ptr logger) - : m_redirect_to_base_url(std::move(redirect_to_base_url)) + : m_redirect_to_base_url{redirect_to_base_url} + , m_redirect_to_base_wsurl(make_wsurl(m_redirect_to_base_url)) , m_logger(std::make_shared("HTTP Redirector ", std::move(logger))) , m_acceptor(m_service) , m_server_thread([this] { m_service.run_until_stopped(); }) { - m_acceptor.open(m_endpoint.protocol()); - m_acceptor.bind(m_endpoint); - m_endpoint = m_acceptor.local_endpoint(); + network::Endpoint ep; + m_acceptor.open(ep.protocol()); + m_acceptor.bind(ep); + ep = m_acceptor.local_endpoint(); + m_base_url = util::format("http://localhost:%1", ep.port()); + m_base_wsurl = make_wsurl(m_base_url); m_acceptor.listen(); m_service.post([this](Status status) { REALM_ASSERT(status.is_ok()); @@ -55,14 +68,50 @@ class RedirectingHttpServer { ~RedirectingHttpServer() { - m_acceptor.cancel(); - m_service.stop(); + m_service.post([this](Status status) { + if (status == ErrorCodes::OperationAborted) + return; + m_acceptor.cancel(); + m_service.stop(); + }); m_server_thread.join(); } + void set_event_hook(std::function)> hook) + { + m_hook = hook; + } + + // If true, http (app services) requests will first hit the redirect server and + // receive a redirect response which will contain the location to the actual + // server. + // NOTE: some http transport redirect implementations may strip the authorization + // header from the request after it is redirected and the user will be logged out + // from the client app as a result. + void force_http_redirect(bool remote) + { + m_http_redirect = remote; + } + + // If true, websockets will be first directed to the redirect server which will + // return a redirect close code. The client will then update the location by + // querying the actual server location endpoint (from the 'hostname' location + // value) and open a websocket conneciton to the actual server. + // NOTE: the websocket will never connect if both http and websockets are + // redirecting and will just keep getting the redirect close code. + void force_websocket_redirect(bool force) + { + m_websocket_redirect = force; + } + std::string base_url() const { - return util::format("http://localhost:%1", m_endpoint.port()); + return m_base_url; + } + + std::string server_url() const + { + return m_redirect_to_base_url; } private: @@ -173,16 +222,22 @@ class RedirectingHttpServer { std::optional websocket; }; - void send_simple_response(util::bind_ptr conn, HTTPStatus status, std::string reason, std::string body) + void send_simple_response(util::bind_ptr conn, HTTPStatus status, std::string reason, + std::optional body) + { + send_http_response(conn, status, std::move(reason), {}, std::move(body)); + } + + void send_http_response(util::bind_ptr conn, HTTPStatus status, std::string reason, HTTPHeaders headers, + std::optional body) { - m_logger->debug("sending http response %1: %2 \"%3\"", status, reason, body); - HTTPResponse resp; - resp.status = status; - resp.reason = std::move(reason); - resp.body = std::move(body); + m_logger->debug("sending http response %1: %2 '%3'", status, reason, body.value_or("")); + HTTPResponse resp{status, std::move(reason), std::move(headers), std::move(body)}; conn->http_server.async_send_response(resp, [this, conn](std::error_code ec) { if (ec && ec != util::error::operation_aborted) { - m_logger->warn("Error sending response: %1", ec); + m_logger->warn("Error sending response: [%1]: %2", ec, ec.message()); + if (m_hook) + m_hook(Event::error, ec.message()); } }); } @@ -208,7 +263,9 @@ class RedirectingHttpServer { conn->http_server.async_send_response(*maybe_resp, [this, conn](std::error_code ec) { if (ec) { if (ec != util::error::operation_aborted) { - m_logger->warn("Error sending websocket HTTP upgrade response: %1", ec); + m_logger->warn("Error sending websocket HTTP upgrade response: [%1]: %2", ec, ec.message()); + if (m_hook) + m_hook(Event::error, ec.message()); } return; } @@ -217,9 +274,11 @@ class RedirectingHttpServer { conn->websocket->initiate_server_websocket_after_handshake(); static const std::string_view msg("\x0f\xa3Permanently moved"); - conn->websocket->async_write_close(msg.data(), msg.size(), [conn](std::error_code, size_t) { + conn->websocket->async_write_close(msg.data(), msg.size(), [this, conn](std::error_code, size_t) { conn->logger->debug("Sent close frame with move code"); conn->websocket.reset(); + if (m_hook) + m_hook(Event::ws_redirect, std::nullopt); }); }); } @@ -231,31 +290,35 @@ class RedirectingHttpServer { if (ec == util::error::operation_aborted) { return; } + // Allow additonal connections to be accepted do_accept(); if (ec) { - m_logger->error("Error accepting new connection in: %1", ec); + m_logger->error("Error accepting new connection to %1 [%2]: %3", base_url(), ec, ec.message()); return; } conn->http_server.async_receive_request([this, conn](HTTPRequest req, std::error_code ec) { if (ec) { if (ec != util::error::operation_aborted) { - m_logger->error("Error receiving HTTP request to redirect: %1", ec); + m_logger->error("Error receiving HTTP request to redirect [%1]: %2", ec, ec.message()); } return; } + m_logger->debug("Received request: %1", req.path); + if (req.path.find("/location") != std::string::npos) { - std::string_view base_url(m_redirect_to_base_url); - auto scheme = base_url.find("://"); - auto ws_url = util::format("ws%1", base_url.substr(scheme)); - nlohmann::json body{{"deployment_model", "GLOBAL"}, - {"location", "US-VA"}, - {"hostname", m_redirect_to_base_url}, - {"ws_hostname", ws_url}}; + nlohmann::json body{ + {"deployment_model", "GLOBAL"}, + {"location", "US-VA"}, + {"hostname", m_http_redirect ? m_base_url : m_redirect_to_base_url}, + {"ws_hostname", m_websocket_redirect ? m_base_wsurl : m_redirect_to_base_wsurl}}; auto body_str = body.dump(); - send_simple_response(conn, HTTPStatus::Ok, "Okay", std::move(body_str)); + send_http_response(conn, HTTPStatus::Ok, "Okay", {{"Content-Type", "application/json"}}, + std::move(body_str)); + if (m_hook) + m_hook(Event::location, std::nullopt); return; } @@ -264,16 +327,51 @@ class RedirectingHttpServer { return; } - send_simple_response(conn, HTTPStatus::NotFound, "Not found", {}); + // Send redirect response for appservices calls + // Starts with 'http' and contains api path + if (req.path.find("/api/client/v2.0/") == 0) { + // Alternate sending 301 and 308 redirect status codes + auto status = m_use_301 ? HTTPStatus::MovedPermanently : HTTPStatus::PermanentRedirect; + auto reason = m_use_301 ? "Moved Permanently" : "Permanent Redirect"; + m_use_301 = !m_use_301; + auto location = m_redirect_to_base_url + req.path; + send_http_response(conn, status, reason, {{"location", location}}, std::nullopt); + if (m_hook) + m_hook(Event::redirect, std::nullopt); + return; + } + + send_simple_response(conn, HTTPStatus::NotFound, "Not found", + util::format("Not found: %1", req.path)); }); }); } + std::string make_wsurl(std::string base_url) + { + if (base_url.find("http") == 0) { + // Replace the first 4 ('http') characters with 'ws', so we get 'ws://' or 'wss://' + return base_url.replace(0, 4, "ws"); + } + else { + // If no scheme, return the original base_url + return base_url; + } + } + const std::string m_redirect_to_base_url; + const std::string m_redirect_to_base_wsurl; const std::shared_ptr m_logger; + + bool m_http_redirect = false; + bool m_websocket_redirect = false; + std::string m_base_url; + std::string m_base_wsurl; + std::function)> m_hook; + bool m_use_301 = true; + network::Service m_service; network::Acceptor m_acceptor; - network::Endpoint m_endpoint; std::thread m_server_thread; }; diff --git a/test/object-store/util/test_file.cpp b/test/object-store/util/test_file.cpp index 179192f6c5..079861dae2 100644 --- a/test/object-store/util/test_file.cpp +++ b/test/object-store/util/test_file.cpp @@ -376,8 +376,9 @@ TestAppSession::TestAppSession(AppSession session, Config config, DeleteApp dele m_app = app::App::get_app(app::App::CacheMode::Disabled, app_config); - // initialize sync client + // initialize sync client and save a local copy of the logger m_app->sync_manager()->get_sync_client(); + m_logger = m_app->sync_manager()->get_logger(); // If no user creds are supplied, then create the user and log in if (!m_config.user_creds) { auto result = create_user_and_log_in(); @@ -419,11 +420,15 @@ StatusWith TestAppSession::create_user_and_log_in() [this, &creds, promise = util::CopyablePromiseHolder(std::move(pf.promise))]( util::Optional error) mutable { if (error) { + if (m_logger) + m_logger->error("Failed to create user: %1", error->to_status()); promise.get_promise().set_error(error->to_status()); return; } auto result = log_in_user(creds); if (!result.is_ok()) { + if (m_logger) + m_logger->error("User login failed: %1", result.get_status()); promise.get_promise().set_error(result.get_status()); return; } diff --git a/test/object-store/util/test_file.hpp b/test/object-store/util/test_file.hpp index 2eb7b0bb18..a9b2026368 100644 --- a/test/object-store/util/test_file.hpp +++ b/test/object-store/util/test_file.hpp @@ -382,6 +382,7 @@ class TestAppSession { bool m_delete_app; bool m_delete_storage; std::shared_ptr m_app; + std::shared_ptr m_logger; }; #endif // REALM_ENABLE_AUTH_TESTS