Files
InstanceBrick--nightcap/tests/auth_runtime_composition_test.cc
T
2026-08-12 01:58:18 +03:00

1528 lines
62 KiB
C++

// Copyright 2026 Mocktail Project Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
#include "runtime/auth_runtime_composition.h"
#include <fcntl.h>
#include <gtest/gtest.h>
#include <sys/file.h>
#include <sys/stat.h>
#include <unistd.h>
#include <atomic>
#include <filesystem>
#include <fstream>
#include <functional>
#include <map>
#include <memory>
#include <optional>
#include <string>
#include <string_view>
#include <system_error>
#include <type_traits>
#include <utility>
#include "jnivm/jnivm.h"
#include "legacy/legacy_runtime.h"
#include "mocktail/sha256.h"
#include "runtime/environment.h"
#include "runtime/runtime_paths.h"
#include "services/auth_service.h"
#include "services/http_client.h"
namespace mocktail {
namespace runtime {
namespace {
constexpr char kCredential[] = "_|test-auth-runtime-credential";
static_assert(!std::is_copy_constructible_v<SecureRobloxCredential>);
static_assert(!std::is_move_constructible_v<ScopedRobloxCredentialBinding>,
"credential provider context must keep a stable address");
static_assert(!std::is_move_assignable_v<ScopedRobloxCredentialBinding>,
"credential provider binding must not move implicitly");
static_assert(!std::is_copy_assignable_v<SecureRobloxCredential>);
static_assert(std::is_nothrow_move_constructible_v<SecureRobloxCredential>);
class MapEnvironment final : public Environment {
public:
void Set(std::string name, std::string value) {
values_[std::move(name)] = std::move(value);
}
std::optional<std::string> Get(std::string_view name) const override {
const auto found = values_.find(std::string(name));
if (found == values_.end()) {
return std::nullopt;
}
return found->second;
}
private:
std::map<std::string, std::string> values_;
};
class FakeHttpClient final : public services::HttpClient {
public:
services::HttpResponse Get(const services::HttpRequest& request) override {
++request_count;
last_request = request;
if (before_response) {
before_response();
}
return response;
}
services::HttpResponse response;
services::HttpRequest last_request;
std::function<void()> before_response;
int request_count = 0;
};
class TempDirectory final {
public:
TempDirectory() {
static std::atomic<unsigned int> serial{0};
path_ = std::filesystem::temp_directory_path() /
("mocktail-auth-composition-" + std::to_string(getpid()) + "-" +
std::to_string(serial.fetch_add(1)));
std::error_code error;
std::filesystem::create_directories(path_, error);
}
~TempDirectory() {
std::error_code error;
std::filesystem::remove_all(path_, error);
}
const std::filesystem::path& path() const { return path_; }
private:
std::filesystem::path path_;
};
bool WriteFile(const std::filesystem::path& path, const std::string& contents) {
std::error_code error;
std::filesystem::create_directories(path.parent_path(), error);
if (error) {
return false;
}
if (chmod(path.parent_path().c_str(), S_IRWXU) != 0) {
return false;
}
std::ofstream stream(path, std::ios::binary | std::ios::trunc);
stream << contents;
stream.close();
return stream.good() && chmod(path.c_str(), S_IRUSR | S_IWUSR) == 0;
}
std::string ReadFile(const std::filesystem::path& path) {
std::ifstream input(path, std::ios::binary);
return {std::istreambuf_iterator<char>(input),
std::istreambuf_iterator<char>()};
}
RuntimePaths PathsFor(const MapEnvironment& environment,
const TempDirectory& directory) {
return RuntimePaths::FromEnvironment(environment, directory.path());
}
std::filesystem::path SoberRejectionPathFor(const RuntimePaths& paths) {
return paths.default_sober_rejection_file();
}
std::string SoberRejectionMarkerFor(std::string_view credential) {
constexpr char kDomain[] = "mocktail-default-sober-rejection-v1";
std::string fingerprint_input(kDomain, sizeof(kDomain));
fingerprint_input.append(credential);
return "v1:" + foundation::ComputeSha256Hex(fingerprint_input) + "\n";
}
std::string ReadJavaString(JNIEnv* env, jstring value) {
if (env == nullptr || value == nullptr) {
return {};
}
const char* chars = env->GetStringUTFChars(value, nullptr);
if (chars == nullptr) {
return {};
}
const std::string result(chars);
env->ReleaseStringUTFChars(value, chars);
return result;
}
struct NativeCookieCallbackProbe {
int calls = 0;
std::string cookie;
std::string url;
};
void IgnoreDataModelNotification(void*, JNIEnv*, jstring, jstring) {}
void CaptureNativeCookie(void* context, JNIEnv* env, jstring cookie,
jstring url) {
auto* probe = static_cast<NativeCookieCallbackProbe*>(context);
++probe->calls;
probe->cookie = ReadJavaString(env, cookie);
probe->url = ReadJavaString(env, url);
}
TEST(AuthRuntimeCompositionTest,
ResolvesSavedCookieAndInjectsOnlyTypedIdentity) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_ROBLOX_COOKIES",
std::string(".ROBLOSECURITY=") + kCredential);
FakeHttpClient http;
http.response = {
true,
200,
R"({"id":321,"name":"RuntimeUser","displayName":"Runtime Display"})",
{}};
services::AuthService auth_service(http);
const AuthRuntimeComposition composition = ComposeAuthRuntime(
environment, PathsFor(environment, directory), auth_service);
ASSERT_TRUE(composition);
EXPECT_EQ(composition.status, AuthRuntimeStatus::kAuthenticated);
EXPECT_EQ(composition.http_status, 200);
EXPECT_TRUE(composition.error.empty());
ASSERT_EQ(http.request_count, 1);
EXPECT_FALSE(http.last_request.follow_redirects);
ASSERT_EQ(http.last_request.headers.size(), 2U);
EXPECT_EQ(http.last_request.headers[1],
std::string("Cookie: .ROBLOSECURITY=") + kCredential);
EXPECT_EQ(composition.credential.view(),
std::string(".ROBLOSECURITY=") + kCredential);
const jnivm::RobloxAuthIdentity identity =
composition.jni_vm->GetRobloxAuthIdentitySnapshot();
EXPECT_EQ(identity.user_id, 321);
EXPECT_EQ(identity.username, "RuntimeUser");
EXPECT_EQ(identity.display_name, "Runtime Display");
EXPECT_EQ(composition.account_identity.user_id, identity.user_id);
EXPECT_EQ(composition.account_identity.username, identity.username);
EXPECT_EQ(identity.username.find(kCredential), std::string::npos);
EXPECT_EQ(identity.display_name.find(kCredential), std::string::npos);
}
TEST(AuthRuntimeCompositionTest, MissingCookieRequiresExplicitGuestPolicy) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_USE_SOBER_COOKIES", "0");
FakeHttpClient http;
services::AuthService auth_service(http);
AuthRuntimeComposition composition = ComposeAuthRuntime(
environment, PathsFor(environment, directory), auth_service);
EXPECT_FALSE(composition);
EXPECT_EQ(composition.status, AuthRuntimeStatus::kInvalidCredentials);
EXPECT_EQ(composition.error, "missing .ROBLOSECURITY cookie");
EXPECT_EQ(http.request_count, 0);
EXPECT_TRUE(composition.credential.empty());
EXPECT_EQ(composition.account_identity.user_id, -1);
environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
composition = ComposeAuthRuntime(
environment, PathsFor(environment, directory), auth_service);
ASSERT_TRUE(composition);
EXPECT_EQ(composition.status, AuthRuntimeStatus::kGuest);
EXPECT_EQ(http.request_count, 0);
const jnivm::RobloxAuthIdentity identity =
composition.jni_vm->GetRobloxAuthIdentitySnapshot();
EXPECT_EQ(identity.user_id, -1);
EXPECT_TRUE(identity.username.empty());
EXPECT_TRUE(identity.display_name.empty());
}
TEST(AuthRuntimeCompositionTest,
GuestJavaCookiePersistsPrivatelyAndBecomesCurrentCredential) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_USE_SOBER_COOKIES", "0");
environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
const RuntimePaths paths = PathsFor(environment, directory);
FakeHttpClient http;
services::AuthService auth_service(http);
AuthRuntimeComposition composition =
ComposeAuthRuntime(environment, paths, auth_service);
ASSERT_TRUE(composition);
ASSERT_EQ(composition.status, AuthRuntimeStatus::kGuest);
const std::string saved = ".ROBLOSECURITY=_|embedded-login-cookie";
{
const ScopedRobloxCredentialBinding binding(composition.jni_vm.get(),
composition.credential);
ASSERT_TRUE(binding.bound());
JNIEnv* env = composition.jni_vm->GetJNIEnv();
jclass clazz =
env->FindClass("com/roblox/universalapp/cookie/JNICookieManager");
ASSERT_NE(clazz, nullptr);
jobject manager = env->AllocObject(clazz);
jmethodID setter = env->GetMethodID(
clazz, "setCookie", "(Ljava/lang/String;Ljava/lang/String;)V");
ASSERT_NE(setter, nullptr);
env->CallVoidMethod(manager, setter,
env->NewStringUTF("https://www.roblox.com/"),
env->NewStringUTF(saved.c_str()));
std::string provider_value = "must be cleared";
EXPECT_TRUE(composition.jni_vm->CopyRobloxCredentialFromProvider(
&provider_value));
EXPECT_EQ(provider_value, saved);
jmethodID getter = env->GetStaticMethodID(
clazz, "getCookie", "(Ljava/lang/String;)Ljava/lang/String;");
auto current = static_cast<jstring>(env->CallStaticObjectMethod(
clazz, getter, env->NewStringUTF("roblox.com")));
EXPECT_EQ(ReadJavaString(env, current), saved);
EXPECT_EQ(
composition.jni_vm->GetRobloxAuthIdentitySnapshot().user_id, -1);
}
std::ifstream input(paths.cookie_file(), std::ios::binary);
ASSERT_TRUE(input.good());
const std::string persisted((std::istreambuf_iterator<char>(input)),
std::istreambuf_iterator<char>());
EXPECT_EQ(persisted, saved + "\n");
struct stat auth_status = {};
struct stat cookie_status = {};
ASSERT_EQ(lstat(paths.auth_root().c_str(), &auth_status), 0);
ASSERT_EQ(lstat(paths.cookie_file().c_str(), &cookie_status), 0);
EXPECT_TRUE(S_ISDIR(auth_status.st_mode));
EXPECT_EQ(auth_status.st_mode & (S_IRWXG | S_IRWXO), 0U);
EXPECT_TRUE(S_ISREG(cookie_status.st_mode));
EXPECT_EQ(cookie_status.st_mode & (S_IRWXG | S_IRWXO), 0U);
}
TEST(AuthRuntimeCompositionTest,
NativeSignInPromotesValidatedIdentityWithoutRestart) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_USE_SOBER_COOKIES", "0");
environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
const RuntimePaths paths = PathsFor(environment, directory);
auto http = std::make_shared<FakeHttpClient>();
http->response = {
true,
200,
R"({"id":987,"name":"LiveUser","displayName":"Live User"})",
{}};
services::AuthService auth_service(*http);
AuthRuntimeComposition composition =
ComposeAuthRuntime(environment, paths, auth_service, http);
ASSERT_TRUE(composition);
ASSERT_EQ(composition.status, AuthRuntimeStatus::kGuest);
ASSERT_EQ(http->request_count, 0);
const std::string saved = ".ROBLOSECURITY=_|live-native-login-cookie";
{
const ScopedRobloxCredentialBinding binding(composition.jni_vm.get(),
composition.credential);
ASSERT_TRUE(binding.bound());
ASSERT_TRUE(composition.jni_vm->DispatchRobloxCredential(saved.data(),
saved.size()));
std::string current;
ASSERT_TRUE(
composition.jni_vm->CopyRobloxCredentialFromProvider(&current));
EXPECT_EQ(current, saved);
}
ASSERT_EQ(http->request_count, 1);
ASSERT_GE(http->last_request.headers.size(), 2U);
EXPECT_EQ(http->last_request.headers[1], "Cookie: " + saved);
const jnivm::RobloxAuthIdentity identity =
composition.jni_vm->GetRobloxAuthIdentitySnapshot();
EXPECT_EQ(identity.user_id, 987);
EXPECT_EQ(identity.username, "LiveUser");
EXPECT_EQ(identity.display_name, "Live User");
EXPECT_EQ(ReadFile(paths.cookie_file()), saved + "\n");
}
TEST(AuthRuntimeCompositionTest,
NativeCookieCallbackAuthenticatesFromManagedFileAfterRestart) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_USE_SOBER_COOKIES", "0");
environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
const RuntimePaths paths = PathsFor(environment, directory);
FakeHttpClient initial_http;
services::AuthService initial_auth_service(initial_http);
AuthRuntimeComposition composition =
ComposeAuthRuntime(environment, paths, initial_auth_service);
ASSERT_TRUE(composition);
ASSERT_EQ(composition.status, AuthRuntimeStatus::kGuest);
const std::string saved = ".ROBLOSECURITY=_|native-login-cookie";
auto callback_probe = std::make_shared<NativeCookieCallbackProbe>();
jnivm::RobloxDataModelNotificationCallbacks callbacks;
callbacks.on_notification = &IgnoreDataModelNotification;
callbacks.on_set_cookie = &CaptureNativeCookie;
composition.jni_vm->SetRobloxDataModelNotificationCallbacks(callback_probe,
callbacks);
{
const ScopedRobloxCredentialBinding binding(composition.jni_vm.get(),
composition.credential);
ASSERT_TRUE(binding.bound());
JNIEnv* env = composition.jni_vm->GetJNIEnv();
jclass handler_class = env->FindClass(
"com/roblox/universalapp/cookie/"
"JNICookieProtocol$OnSetCookieHandler");
jobject handler = env->AllocObject(handler_class);
jmethodID on_set_cookie = env->GetMethodID(
handler_class, "onSetCookie",
"([Ljava/lang/String;Ljava/lang/String;)V");
jclass string_class = env->FindClass("java/lang/String");
jobjectArray cookies = env->NewObjectArray(1, string_class, nullptr);
env->SetObjectArrayElement(
cookies, 0,
env->NewStringUTF(
".ROBLOSECURITY=_|native-login-cookie; Domain=.roblox.com; "
"Path=/; Secure; HttpOnly"));
env->CallVoidMethod(handler, on_set_cookie, cookies,
env->NewStringUTF("https://www.roblox.com/"));
}
composition.jni_vm->ClearRobloxDataModelNotificationCallbacks();
EXPECT_EQ(callback_probe->calls, 1);
EXPECT_EQ(callback_probe->cookie, saved);
EXPECT_EQ(callback_probe->url, "https://www.roblox.com/");
EXPECT_EQ(ReadFile(paths.cookie_file()), saved + "\n");
MapEnvironment restart_environment;
restart_environment.Set("HOME", directory.path().string());
restart_environment.Set("MOCKTAIL_USE_SOBER_COOKIES", "0");
FakeHttpClient restart_http;
restart_http.response = {
true,
200,
R"({"id":777,"name":"RestartUser","displayName":"Restart User"})",
{}};
services::AuthService restart_auth_service(restart_http);
const AuthRuntimeComposition restarted = ComposeAuthRuntime(
restart_environment, PathsFor(restart_environment, directory),
restart_auth_service);
ASSERT_TRUE(restarted) << restarted.error;
EXPECT_EQ(restarted.status, AuthRuntimeStatus::kAuthenticated);
ASSERT_EQ(restart_http.request_count, 1);
EXPECT_EQ(restart_http.last_request.headers[1], "Cookie: " + saved);
}
TEST(AuthRuntimeCompositionTest,
RejectedEnvironmentCredentialNeverCreatesProductionVm) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_ROBLOX_COOKIES", kCredential);
environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
const RuntimePaths paths = PathsFor(environment, directory);
const std::string managed_cookie =
"RBXEventTrackerV2=browserid=123456&browserid_insert_timestamp=1\n";
ASSERT_TRUE(WriteFile(paths.cookie_file(), managed_cookie));
FakeHttpClient http;
http.response = {true, 401, {}, {}};
services::AuthService auth_service(http);
const AuthRuntimeComposition composition =
ComposeAuthRuntime(environment, paths, auth_service);
EXPECT_FALSE(composition);
EXPECT_EQ(composition.status, AuthRuntimeStatus::kInvalidCredentials);
EXPECT_EQ(composition.http_status, 401);
EXPECT_EQ(composition.error, "authentication rejected");
EXPECT_EQ(composition.error.find(kCredential), std::string::npos);
EXPECT_EQ(ReadFile(paths.cookie_file()), managed_cookie);
}
TEST(AuthRuntimeCompositionTest,
Http401ClearsManagedCredentialAndStartsNativeSignInGuest) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_USE_SOBER_COOKIES", "0");
environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
const RuntimePaths paths = PathsFor(environment, directory);
const std::string remaining_cookies =
"RBXEventTrackerV2=browserid=123456&browserid_insert_timestamp=1\n"
"OtherCookie=keep\n";
const std::string rejected_cookie =
std::string(".ROBLOSECURITY=") + kCredential;
ASSERT_TRUE(WriteFile(paths.cookie_file(),
rejected_cookie + "\n" + remaining_cookies));
FakeHttpClient http;
http.response = {true, 401, {}, {}};
services::AuthService auth_service(http);
const AuthRuntimeComposition composition =
ComposeAuthRuntime(environment, paths, auth_service);
ASSERT_TRUE(composition) << composition.error;
EXPECT_EQ(composition.status, AuthRuntimeStatus::kGuest);
EXPECT_EQ(composition.http_status, 401);
EXPECT_TRUE(composition.error.empty());
EXPECT_TRUE(composition.credential.empty());
EXPECT_EQ(composition.account_identity.user_id, -1);
EXPECT_EQ(http.request_count, 1);
EXPECT_EQ(
ReadFile(paths.cookie_file()),
std::string(rejected_cookie.size(), ' ') + "\n" + remaining_cookies);
struct stat metadata = {};
ASSERT_EQ(lstat(paths.cookie_file().c_str(), &metadata), 0);
EXPECT_TRUE(S_ISREG(metadata.st_mode));
EXPECT_EQ(metadata.st_mode & (S_IRWXG | S_IRWXO), 0U);
FakeHttpClient restart_http;
services::AuthService restart_auth_service(restart_http);
const AuthRuntimeComposition restarted =
ComposeAuthRuntime(environment, paths, restart_auth_service);
ASSERT_TRUE(restarted) << restarted.error;
EXPECT_EQ(restarted.status, AuthRuntimeStatus::kGuest);
EXPECT_EQ(restart_http.request_count, 0);
}
TEST(AuthRuntimeCompositionTest,
Http401ClearsManagedAndSuppressesMatchingDefaultSoberCredential) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
const RuntimePaths paths = PathsFor(environment, directory);
const std::string rejected_cookie =
std::string(".ROBLOSECURITY=") + kCredential;
const std::string managed_other = "ManagedCookie=keep\n";
const std::string sober_other = "SoberCookie=keep\n";
ASSERT_TRUE(
WriteFile(paths.cookie_file(), rejected_cookie + "\n" + managed_other));
ASSERT_TRUE(WriteFile(paths.sober_cookie_file(),
rejected_cookie + "\n" + sober_other));
FakeHttpClient http;
http.response = {true, 401, {}, {}};
services::AuthService auth_service(http);
const AuthRuntimeComposition composition =
ComposeAuthRuntime(environment, paths, auth_service);
ASSERT_TRUE(composition) << composition.error;
EXPECT_EQ(composition.status, AuthRuntimeStatus::kGuest);
EXPECT_EQ(http.request_count, 1);
const std::string redacted_prefix(rejected_cookie.size(), ' ');
EXPECT_EQ(ReadFile(paths.cookie_file()),
redacted_prefix + "\n" + managed_other);
EXPECT_EQ(ReadFile(paths.sober_cookie_file()),
rejected_cookie + "\n" + sober_other);
const std::filesystem::path rejection_path = SoberRejectionPathFor(paths);
const std::string marker = ReadFile(rejection_path);
EXPECT_EQ(marker.size(), 68U);
EXPECT_EQ(marker.rfind("v1:", 0), 0U);
EXPECT_EQ(marker.find(kCredential), std::string::npos);
struct stat marker_metadata = {};
ASSERT_EQ(lstat(rejection_path.c_str(), &marker_metadata), 0);
EXPECT_TRUE(S_ISREG(marker_metadata.st_mode));
EXPECT_EQ(marker_metadata.st_mode & (S_IRWXG | S_IRWXO), 0U);
FakeHttpClient restart_http;
services::AuthService restart_auth_service(restart_http);
const AuthRuntimeComposition restarted =
ComposeAuthRuntime(environment, paths, restart_auth_service);
ASSERT_TRUE(restarted) << restarted.error;
EXPECT_EQ(restarted.status, AuthRuntimeStatus::kGuest);
EXPECT_EQ(restart_http.request_count, 0);
}
TEST(AuthRuntimeCompositionTest,
Http401SuppressesDefaultSoberCredentialWithoutMutatingItsFile) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
const RuntimePaths paths = PathsFor(environment, directory);
const std::string remaining_cookies =
"RBXEventTrackerV2=browserid=654321&browserid_insert_timestamp=1\n"
"OtherCookie=keep\n";
const std::string rejected_cookie =
std::string(".ROBLOSECURITY=") + kCredential;
ASSERT_TRUE(WriteFile(paths.sober_cookie_file(),
rejected_cookie + "\n" + remaining_cookies));
FakeHttpClient http;
http.response = {true, 401, {}, {}};
services::AuthService auth_service(http);
const AuthRuntimeComposition composition =
ComposeAuthRuntime(environment, paths, auth_service);
ASSERT_TRUE(composition) << composition.error;
EXPECT_EQ(composition.status, AuthRuntimeStatus::kGuest);
EXPECT_EQ(composition.http_status, 401);
EXPECT_EQ(http.request_count, 1);
EXPECT_EQ(ReadFile(paths.sober_cookie_file()),
rejected_cookie + "\n" + remaining_cookies);
EXPECT_FALSE(std::filesystem::exists(paths.cookie_file()));
const std::filesystem::path rejection_path = SoberRejectionPathFor(paths);
EXPECT_EQ(ReadFile(rejection_path).find(kCredential), std::string::npos);
const std::string changed_tracker_cookies =
rejected_cookie +
"\nRBXEventTrackerV2=browserid=999999&browserid_insert_timestamp=2\n";
ASSERT_TRUE(WriteFile(paths.sober_cookie_file(), changed_tracker_cookies));
FakeHttpClient restart_http;
services::AuthService restart_auth_service(restart_http);
const AuthRuntimeComposition restarted =
ComposeAuthRuntime(environment, paths, restart_auth_service);
ASSERT_TRUE(restarted) << restarted.error;
EXPECT_EQ(restarted.status, AuthRuntimeStatus::kGuest);
EXPECT_EQ(restart_http.request_count, 0);
EXPECT_EQ(ReadFile(paths.sober_cookie_file()), changed_tracker_cookies);
}
TEST(AuthRuntimeCompositionTest,
RelativeNestedAuthRootPersistsDefaultSoberRejection) {
TempDirectory directory;
std::error_code error;
const std::filesystem::path current_directory =
std::filesystem::current_path(error);
ASSERT_FALSE(error);
const std::filesystem::path relative_auth_root = std::filesystem::relative(
directory.path() / "relative/auth/root", current_directory, error);
ASSERT_FALSE(error);
ASSERT_FALSE(relative_auth_root.empty());
ASSERT_FALSE(relative_auth_root.is_absolute());
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_AUTH_ROOT", relative_auth_root.string());
environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
const RuntimePaths paths = PathsFor(environment, directory);
const std::string cookies =
std::string(".ROBLOSECURITY=") + kCredential + "\n";
ASSERT_TRUE(WriteFile(paths.sober_cookie_file(), cookies));
FakeHttpClient http;
http.response = {true, 401, {}, {}};
services::AuthService auth_service(http);
const AuthRuntimeComposition composition =
ComposeAuthRuntime(environment, paths, auth_service);
ASSERT_TRUE(composition) << composition.error;
EXPECT_EQ(composition.status, AuthRuntimeStatus::kGuest);
EXPECT_EQ(http.request_count, 1);
EXPECT_EQ(ReadFile(paths.default_sober_rejection_file()),
SoberRejectionMarkerFor(kCredential));
FakeHttpClient restart_http;
services::AuthService restart_auth_service(restart_http);
const AuthRuntimeComposition restarted =
ComposeAuthRuntime(environment, paths, restart_auth_service);
ASSERT_TRUE(restarted) << restarted.error;
EXPECT_EQ(restarted.status, AuthRuntimeStatus::kGuest);
EXPECT_EQ(restart_http.request_count, 0);
}
TEST(AuthRuntimeCompositionTest,
ChangedDefaultSoberCredentialIsCheckedAndReplacesOldMarker) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
const RuntimePaths paths = PathsFor(environment, directory);
const std::string first_cookies =
std::string(".ROBLOSECURITY=") + kCredential + "\nOther=keep\n";
ASSERT_TRUE(WriteFile(paths.sober_cookie_file(), first_cookies));
FakeHttpClient first_http;
first_http.response = {true, 401, {}, {}};
services::AuthService first_auth_service(first_http);
ASSERT_TRUE(ComposeAuthRuntime(environment, paths, first_auth_service));
const std::filesystem::path rejection_path = SoberRejectionPathFor(paths);
const std::string first_marker = ReadFile(rejection_path);
struct stat first_metadata = {};
ASSERT_EQ(lstat(rejection_path.c_str(), &first_metadata), 0);
const std::string changed_value = "_|changed-default-sober-credential";
const std::string changed_cookies =
".ROBLOSECURITY=" + changed_value + "\nOther=still-keep\n";
ASSERT_TRUE(WriteFile(paths.sober_cookie_file(), changed_cookies));
FakeHttpClient changed_http;
changed_http.response = {true, 401, {}, {}};
services::AuthService changed_auth_service(changed_http);
const AuthRuntimeComposition changed =
ComposeAuthRuntime(environment, paths, changed_auth_service);
ASSERT_TRUE(changed) << changed.error;
EXPECT_EQ(changed.status, AuthRuntimeStatus::kGuest);
ASSERT_EQ(changed_http.request_count, 1);
ASSERT_EQ(changed_http.last_request.headers.size(), 2U);
EXPECT_EQ(changed_http.last_request.headers[1],
"Cookie: .ROBLOSECURITY=" + changed_value);
EXPECT_EQ(ReadFile(paths.sober_cookie_file()), changed_cookies);
const std::string changed_marker = ReadFile(rejection_path);
EXPECT_NE(changed_marker, first_marker);
EXPECT_EQ(changed_marker.find(changed_value), std::string::npos);
struct stat changed_metadata = {};
ASSERT_EQ(lstat(rejection_path.c_str(), &changed_metadata), 0);
EXPECT_NE(changed_metadata.st_ino, first_metadata.st_ino);
FakeHttpClient restart_http;
services::AuthService restart_auth_service(restart_http);
const AuthRuntimeComposition restarted =
ComposeAuthRuntime(environment, paths, restart_auth_service);
ASSERT_TRUE(restarted) << restarted.error;
EXPECT_EQ(restarted.status, AuthRuntimeStatus::kGuest);
EXPECT_EQ(restart_http.request_count, 0);
}
TEST(AuthRuntimeCompositionTest,
DefaultSoberRefreshDuringHttp401RecoveryIsNeverModified) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
const RuntimePaths paths = PathsFor(environment, directory);
ASSERT_TRUE(WriteFile(paths.sober_cookie_file(),
std::string(".ROBLOSECURITY=") + kCredential + "\n"));
const std::string refreshed_value = "_|refreshed-during-http";
const std::string refreshed_cookies =
".ROBLOSECURITY=" + refreshed_value + "\nOther=keep\n";
FakeHttpClient http;
http.response = {true, 401, {}, {}};
http.before_response = [&]() {
EXPECT_TRUE(WriteFile(paths.sober_cookie_file(), refreshed_cookies));
};
services::AuthService auth_service(http);
const AuthRuntimeComposition composition =
ComposeAuthRuntime(environment, paths, auth_service);
ASSERT_TRUE(composition) << composition.error;
EXPECT_EQ(composition.status, AuthRuntimeStatus::kGuest);
EXPECT_EQ(ReadFile(paths.sober_cookie_file()), refreshed_cookies);
FakeHttpClient restart_http;
restart_http.response = {
true,
200,
R"({"id":654,"name":"Refreshed","displayName":"Refreshed User"})",
{}};
services::AuthService restart_auth_service(restart_http);
const AuthRuntimeComposition restarted =
ComposeAuthRuntime(environment, paths, restart_auth_service);
ASSERT_TRUE(restarted) << restarted.error;
EXPECT_EQ(restarted.status, AuthRuntimeStatus::kAuthenticated);
EXPECT_EQ(restart_http.request_count, 1);
ASSERT_EQ(restart_http.last_request.headers.size(), 2U);
EXPECT_EQ(restart_http.last_request.headers[1],
"Cookie: .ROBLOSECURITY=" + refreshed_value);
EXPECT_EQ(ReadFile(paths.sober_cookie_file()), refreshed_cookies);
}
TEST(AuthRuntimeCompositionTest,
MalformedDefaultSoberRejectionMarkerFailsClosedBeforeHttp) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
const RuntimePaths paths = PathsFor(environment, directory);
const std::string sober_cookies =
std::string(".ROBLOSECURITY=") + kCredential + "\n";
ASSERT_TRUE(WriteFile(paths.sober_cookie_file(), sober_cookies));
ASSERT_TRUE(WriteFile(SoberRejectionPathFor(paths), "not-a-valid-marker\n"));
ASSERT_EQ(chmod(paths.auth_root().c_str(), S_IRWXU), 0);
FakeHttpClient http;
http.response = {true, 200, {}, {}};
services::AuthService auth_service(http);
const AuthRuntimeComposition composition =
ComposeAuthRuntime(environment, paths, auth_service);
EXPECT_FALSE(composition);
EXPECT_EQ(composition.status, AuthRuntimeStatus::kUnavailable);
EXPECT_EQ(composition.error, "saved Sober rejection marker is invalid");
EXPECT_EQ(http.request_count, 0);
EXPECT_EQ(ReadFile(paths.sober_cookie_file()), sober_cookies);
}
TEST(AuthRuntimeCompositionTest,
UnsafeAuthRootWithoutMarkerFailsClosedBeforeHttp) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
const RuntimePaths paths = PathsFor(environment, directory);
const std::string sober_cookies =
std::string(".ROBLOSECURITY=") + kCredential + "\n";
ASSERT_TRUE(WriteFile(paths.sober_cookie_file(), sober_cookies));
std::error_code error;
std::filesystem::create_directories(paths.auth_root(), error);
ASSERT_FALSE(error);
ASSERT_EQ(chmod(paths.auth_root().c_str(),
S_IRWXU | S_IRGRP | S_IXGRP | S_IROTH | S_IXOTH),
0);
FakeHttpClient http;
http.response = {true, 200, {}, {}};
services::AuthService auth_service(http);
const AuthRuntimeComposition composition =
ComposeAuthRuntime(environment, paths, auth_service);
EXPECT_FALSE(composition);
EXPECT_EQ(composition.status, AuthRuntimeStatus::kUnavailable);
EXPECT_EQ(composition.error,
"saved Sober rejection marker storage is unsafe");
EXPECT_EQ(http.request_count, 0);
EXPECT_EQ(ReadFile(paths.sober_cookie_file()), sober_cookies);
}
TEST(AuthRuntimeCompositionTest,
SymlinkDefaultSoberRejectionMarkerFailsClosedWithoutTouchingTarget) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
const RuntimePaths paths = PathsFor(environment, directory);
const std::string sober_cookies =
std::string(".ROBLOSECURITY=") + kCredential + "\n";
ASSERT_TRUE(WriteFile(paths.sober_cookie_file(), sober_cookies));
const std::filesystem::path protected_target =
directory.path() / "protected-marker-target";
const std::string protected_contents = "must remain untouched\n";
ASSERT_TRUE(WriteFile(protected_target, protected_contents));
std::error_code error;
std::filesystem::create_directories(paths.auth_root(), error);
ASSERT_FALSE(error);
ASSERT_EQ(chmod(paths.auth_root().c_str(), S_IRWXU), 0);
std::filesystem::create_symlink(protected_target,
SoberRejectionPathFor(paths), error);
ASSERT_FALSE(error);
FakeHttpClient http;
http.response = {true, 200, {}, {}};
services::AuthService auth_service(http);
const AuthRuntimeComposition composition =
ComposeAuthRuntime(environment, paths, auth_service);
EXPECT_FALSE(composition);
EXPECT_EQ(composition.status, AuthRuntimeStatus::kUnavailable);
EXPECT_EQ(http.request_count, 0);
EXPECT_EQ(ReadFile(protected_target), protected_contents);
EXPECT_EQ(ReadFile(paths.sober_cookie_file()), sober_cookies);
}
TEST(AuthRuntimeCompositionTest,
FifoDefaultSoberRejectionMarkerFailsClosedWithoutBlocking) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
const RuntimePaths paths = PathsFor(environment, directory);
const std::string sober_cookies =
std::string(".ROBLOSECURITY=") + kCredential + "\n";
ASSERT_TRUE(WriteFile(paths.sober_cookie_file(), sober_cookies));
std::error_code error;
std::filesystem::create_directories(paths.auth_root(), error);
ASSERT_FALSE(error);
ASSERT_EQ(chmod(paths.auth_root().c_str(), S_IRWXU), 0);
ASSERT_EQ(mkfifo(SoberRejectionPathFor(paths).c_str(), S_IRUSR | S_IWUSR), 0);
FakeHttpClient http;
http.response = {true, 200, {}, {}};
services::AuthService auth_service(http);
const AuthRuntimeComposition composition =
ComposeAuthRuntime(environment, paths, auth_service);
EXPECT_FALSE(composition);
EXPECT_EQ(composition.status, AuthRuntimeStatus::kUnavailable);
EXPECT_EQ(http.request_count, 0);
EXPECT_EQ(ReadFile(paths.sober_cookie_file()), sober_cookies);
}
TEST(AuthRuntimeCompositionTest,
RejectionWriterLockHardlinkCannotChangeDefaultSoberFile) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
const RuntimePaths paths = PathsFor(environment, directory);
const std::string sober_cookies =
std::string(".ROBLOSECURITY=") + kCredential + "\n";
ASSERT_TRUE(WriteFile(paths.sober_cookie_file(), sober_cookies));
ASSERT_EQ(chmod(paths.sober_cookie_file().c_str(), S_IRWXU), 0);
std::error_code error;
std::filesystem::create_directories(paths.auth_root(), error);
ASSERT_FALSE(error);
ASSERT_EQ(chmod(paths.auth_root().c_str(), S_IRWXU), 0);
const std::filesystem::path lock_path =
paths.auth_root() / ".default-sober-rejection.mocktail-writer.lock";
std::filesystem::create_hard_link(paths.sober_cookie_file(), lock_path,
error);
ASSERT_FALSE(error);
FakeHttpClient http;
http.response = {true, 401, {}, {}};
services::AuthService auth_service(http);
const AuthRuntimeComposition composition =
ComposeAuthRuntime(environment, paths, auth_service);
EXPECT_FALSE(composition);
EXPECT_EQ(composition.status, AuthRuntimeStatus::kUnavailable);
EXPECT_EQ(composition.http_status, 401);
EXPECT_EQ(composition.error,
"rejected Sober credential could not be retired safely");
EXPECT_EQ(http.request_count, 1);
EXPECT_EQ(ReadFile(paths.sober_cookie_file()), sober_cookies);
struct stat sober_metadata = {};
ASSERT_EQ(lstat(paths.sober_cookie_file().c_str(), &sober_metadata), 0);
EXPECT_EQ(sober_metadata.st_mode & (S_IRWXU | S_IRWXG | S_IRWXO), S_IRWXU);
EXPECT_FALSE(std::filesystem::exists(paths.default_sober_rejection_file()));
}
TEST(AuthRuntimeCompositionTest,
Http401ClearsPrivateReadOnlyManagedCredential) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_USE_SOBER_COOKIES", "0");
environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
const RuntimePaths paths = PathsFor(environment, directory);
const std::string remaining_cookies = "OtherCookie=keep\n";
const std::string rejected_cookie =
std::string(".ROBLOSECURITY=") + kCredential;
ASSERT_TRUE(WriteFile(paths.cookie_file(),
rejected_cookie + "\n" + remaining_cookies));
ASSERT_EQ(chmod(paths.cookie_file().c_str(), S_IRUSR), 0);
FakeHttpClient http;
http.response = {true, 401, {}, {}};
services::AuthService auth_service(http);
const AuthRuntimeComposition composition =
ComposeAuthRuntime(environment, paths, auth_service);
ASSERT_TRUE(composition) << composition.error;
EXPECT_EQ(composition.status, AuthRuntimeStatus::kGuest);
EXPECT_EQ(
ReadFile(paths.cookie_file()),
std::string(rejected_cookie.size(), ' ') + "\n" + remaining_cookies);
struct stat metadata = {};
ASSERT_EQ(lstat(paths.cookie_file().c_str(), &metadata), 0);
EXPECT_EQ(metadata.st_mode & (S_IRWXG | S_IRWXO), 0U);
EXPECT_NE(metadata.st_mode & S_IWUSR, 0U);
}
TEST(AuthRuntimeCompositionTest,
Http401RedactsRawAndDuplicateSegmentsWithMixedDelimiters) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_USE_SOBER_COOKIES", "0");
environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
const RuntimePaths paths = PathsFor(environment, directory);
const std::string raw_cookie = kCredential;
const std::string duplicate = ".ROBLOSECURITY=duplicate-value";
const std::string cookies =
raw_cookie + "\r\nOtherCookie=keep; " + duplicate + "\tTailCookie=keep\n";
ASSERT_TRUE(WriteFile(paths.cookie_file(), cookies));
FakeHttpClient http;
http.response = {true, 401, {}, {}};
services::AuthService auth_service(http);
const AuthRuntimeComposition composition =
ComposeAuthRuntime(environment, paths, auth_service);
ASSERT_TRUE(composition) << composition.error;
EXPECT_EQ(composition.status, AuthRuntimeStatus::kGuest);
const std::string expected =
std::string(raw_cookie.size(), ' ') + "\r\nOtherCookie=keep;" +
std::string(duplicate.size() + 1, ' ') + "\tTailCookie=keep\n";
EXPECT_EQ(ReadFile(paths.cookie_file()), expected);
EXPECT_EQ(expected.size(), cookies.size());
FakeHttpClient restart_http;
services::AuthService restart_auth_service(restart_http);
const AuthRuntimeComposition restarted =
ComposeAuthRuntime(environment, paths, restart_auth_service);
ASSERT_TRUE(restarted) << restarted.error;
EXPECT_EQ(restarted.status, AuthRuntimeStatus::kGuest);
EXPECT_EQ(restart_http.request_count, 0);
}
TEST(AuthRuntimeCompositionTest, Http403DoesNotClearOrStartGuest) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_USE_SOBER_COOKIES", "0");
environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
const RuntimePaths paths = PathsFor(environment, directory);
const std::string cookies = std::string(".ROBLOSECURITY=") + kCredential +
"\nRBXEventTrackerV2=browserid=123456\n";
ASSERT_TRUE(WriteFile(paths.cookie_file(), cookies));
FakeHttpClient http;
http.response = {true, 403, {}, {}};
services::AuthService auth_service(http);
const AuthRuntimeComposition composition =
ComposeAuthRuntime(environment, paths, auth_service);
EXPECT_FALSE(composition);
EXPECT_EQ(composition.status, AuthRuntimeStatus::kInvalidCredentials);
EXPECT_EQ(composition.http_status, 403);
EXPECT_EQ(ReadFile(paths.cookie_file()), cookies);
}
TEST(AuthRuntimeCompositionTest,
DefaultSoberHttp403DoesNotCreateRejectionMarker) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
const RuntimePaths paths = PathsFor(environment, directory);
const std::string cookies =
std::string(".ROBLOSECURITY=") + kCredential + "\nOther=keep\n";
ASSERT_TRUE(WriteFile(paths.sober_cookie_file(), cookies));
FakeHttpClient http;
http.response = {true, 403, {}, {}};
services::AuthService auth_service(http);
const AuthRuntimeComposition composition =
ComposeAuthRuntime(environment, paths, auth_service);
EXPECT_FALSE(composition);
EXPECT_EQ(composition.status, AuthRuntimeStatus::kInvalidCredentials);
EXPECT_EQ(composition.http_status, 403);
EXPECT_EQ(http.request_count, 1);
EXPECT_EQ(ReadFile(paths.sober_cookie_file()), cookies);
EXPECT_FALSE(std::filesystem::exists(SoberRejectionPathFor(paths)));
}
TEST(AuthRuntimeCompositionTest,
DefaultSoberTransportFailureDoesNotCreateRejectionMarker) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
const RuntimePaths paths = PathsFor(environment, directory);
const std::string cookies =
std::string(".ROBLOSECURITY=") + kCredential + "\n";
ASSERT_TRUE(WriteFile(paths.sober_cookie_file(), cookies));
FakeHttpClient http;
http.response = {false, 0, {}, "request contained sensitive details"};
services::AuthService auth_service(http);
const AuthRuntimeComposition composition =
ComposeAuthRuntime(environment, paths, auth_service);
EXPECT_FALSE(composition);
EXPECT_EQ(composition.status, AuthRuntimeStatus::kUnavailable);
EXPECT_EQ(composition.error, "authentication service unavailable");
EXPECT_EQ(http.request_count, 1);
EXPECT_EQ(ReadFile(paths.sober_cookie_file()), cookies);
EXPECT_FALSE(std::filesystem::exists(SoberRejectionPathFor(paths)));
}
TEST(AuthRuntimeCompositionTest,
DefaultSoberHttp401PersistsRejectionWhenGuestIsDisabled) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
const RuntimePaths paths = PathsFor(environment, directory);
const std::string cookies =
std::string(".ROBLOSECURITY=") + kCredential + "\n";
ASSERT_TRUE(WriteFile(paths.sober_cookie_file(), cookies));
FakeHttpClient http;
http.response = {true, 401, {}, {}};
services::AuthService auth_service(http);
const AuthRuntimeComposition composition =
ComposeAuthRuntime(environment, paths, auth_service);
EXPECT_FALSE(composition);
EXPECT_EQ(composition.status, AuthRuntimeStatus::kInvalidCredentials);
EXPECT_EQ(composition.http_status, 401);
EXPECT_EQ(http.request_count, 1);
EXPECT_EQ(ReadFile(paths.sober_cookie_file()), cookies);
EXPECT_EQ(ReadFile(SoberRejectionPathFor(paths)),
SoberRejectionMarkerFor(kCredential));
FakeHttpClient restart_http;
services::AuthService restart_auth_service(restart_http);
const AuthRuntimeComposition restarted =
ComposeAuthRuntime(environment, paths, restart_auth_service);
EXPECT_FALSE(restarted);
EXPECT_EQ(restarted.status, AuthRuntimeStatus::kInvalidCredentials);
EXPECT_EQ(restart_http.request_count, 0);
}
TEST(AuthRuntimeCompositionTest,
Http401ClearsManagedCredentialButDoesNotStartGuestWhenDisabled) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_USE_SOBER_COOKIES", "0");
const RuntimePaths paths = PathsFor(environment, directory);
const std::string remaining_cookies = "RBXEventTrackerV2=browserid=123456\n";
const std::string rejected_cookie =
std::string(".ROBLOSECURITY=") + kCredential;
const std::string cookies = rejected_cookie + "\n" + remaining_cookies;
ASSERT_TRUE(WriteFile(paths.cookie_file(), cookies));
FakeHttpClient http;
http.response = {true, 401, {}, {}};
services::AuthService auth_service(http);
const AuthRuntimeComposition composition =
ComposeAuthRuntime(environment, paths, auth_service);
EXPECT_FALSE(composition);
EXPECT_EQ(composition.status, AuthRuntimeStatus::kInvalidCredentials);
EXPECT_EQ(composition.http_status, 401);
EXPECT_EQ(
ReadFile(paths.cookie_file()),
std::string(rejected_cookie.size(), ' ') + "\n" + remaining_cookies);
}
TEST(AuthRuntimeCompositionTest,
Http401DoesNotClearExplicitCredentialOverride) {
TempDirectory directory;
const std::filesystem::path explicit_cookie =
directory.path() / "explicit-cookie";
const std::string cookies =
std::string(".ROBLOSECURITY=") + kCredential + "\n";
ASSERT_TRUE(WriteFile(explicit_cookie, cookies));
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_COOKIE_FILE", explicit_cookie.string());
environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
FakeHttpClient http;
http.response = {true, 401, {}, {}};
services::AuthService auth_service(http);
const AuthRuntimeComposition composition = ComposeAuthRuntime(
environment, PathsFor(environment, directory), auth_service);
EXPECT_FALSE(composition);
EXPECT_EQ(composition.status, AuthRuntimeStatus::kInvalidCredentials);
EXPECT_EQ(composition.http_status, 401);
EXPECT_EQ(ReadFile(explicit_cookie), cookies);
}
TEST(AuthRuntimeCompositionTest,
Http401DoesNotClearExplicitSoberCredentialOverride) {
TempDirectory directory;
const std::filesystem::path explicit_sober_cookie =
directory.path() / "explicit-sober-cookie";
const std::string cookies =
std::string(".ROBLOSECURITY=") + kCredential + "\n";
ASSERT_TRUE(WriteFile(explicit_sober_cookie, cookies));
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_SOBER_COOKIE_FILE", explicit_sober_cookie.string());
environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
const RuntimePaths paths = PathsFor(environment, directory);
const std::string marker = SoberRejectionMarkerFor(kCredential);
ASSERT_TRUE(WriteFile(SoberRejectionPathFor(paths), marker));
ASSERT_EQ(chmod(paths.auth_root().c_str(), S_IRWXU), 0);
FakeHttpClient http;
http.response = {true, 401, {}, {}};
services::AuthService auth_service(http);
const AuthRuntimeComposition composition =
ComposeAuthRuntime(environment, paths, auth_service);
EXPECT_FALSE(composition);
EXPECT_EQ(composition.status, AuthRuntimeStatus::kInvalidCredentials);
EXPECT_EQ(composition.http_status, 401);
EXPECT_EQ(ReadFile(explicit_sober_cookie), cookies);
EXPECT_EQ(ReadFile(SoberRejectionPathFor(paths)), marker);
}
TEST(AuthRuntimeCompositionTest,
Http401FailsClosedWhileCookieWriterLockIsHeld) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_USE_SOBER_COOKIES", "0");
environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
const RuntimePaths paths = PathsFor(environment, directory);
const std::string cookies =
std::string(".ROBLOSECURITY=") + kCredential + "\n";
ASSERT_TRUE(WriteFile(paths.cookie_file(), cookies));
const std::filesystem::path lock_path =
paths.cookie_file().parent_path() /
("." + paths.cookie_file().filename().string() + ".mocktail-writer.lock");
ASSERT_TRUE(WriteFile(lock_path, ""));
const int lock_descriptor =
open(lock_path.c_str(), O_RDWR | O_CLOEXEC | O_NOFOLLOW);
ASSERT_GE(lock_descriptor, 0);
ASSERT_EQ(flock(lock_descriptor, LOCK_EX | LOCK_NB), 0);
FakeHttpClient http;
http.response = {true, 401, {}, {}};
services::AuthService auth_service(http);
const AuthRuntimeComposition composition =
ComposeAuthRuntime(environment, paths, auth_service);
EXPECT_FALSE(composition);
EXPECT_EQ(composition.status, AuthRuntimeStatus::kUnavailable);
EXPECT_EQ(composition.http_status, 401);
EXPECT_EQ(ReadFile(paths.cookie_file()), cookies);
EXPECT_EQ(flock(lock_descriptor, LOCK_UN), 0);
EXPECT_EQ(close(lock_descriptor), 0);
}
TEST(AuthRuntimeCompositionTest,
CredentialRefreshDuringHttp401RecoveryIsPreserved) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_USE_SOBER_COOKIES", "0");
environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
const RuntimePaths paths = PathsFor(environment, directory);
ASSERT_TRUE(WriteFile(paths.cookie_file(),
std::string(".ROBLOSECURITY=") + kCredential + "\n"));
struct stat original_metadata = {};
ASSERT_EQ(lstat(paths.cookie_file().c_str(), &original_metadata), 0);
const std::string refreshed_contents =
".ROBLOSECURITY=_|newly-refreshed-credential\n"
"RBXEventTrackerV2=browserid=987654\n";
FakeHttpClient http;
http.response = {true, 401, {}, {}};
http.before_response = [&]() {
EXPECT_TRUE(WriteFile(paths.cookie_file(), refreshed_contents));
};
services::AuthService auth_service(http);
const AuthRuntimeComposition composition =
ComposeAuthRuntime(environment, paths, auth_service);
EXPECT_FALSE(composition);
EXPECT_EQ(composition.status, AuthRuntimeStatus::kUnavailable);
EXPECT_EQ(composition.http_status, 401);
EXPECT_EQ(composition.error,
"Roblox cookie changed while authentication was checked");
EXPECT_EQ(ReadFile(paths.cookie_file()), refreshed_contents);
struct stat refreshed_metadata = {};
ASSERT_EQ(lstat(paths.cookie_file().c_str(), &refreshed_metadata), 0);
EXPECT_EQ(refreshed_metadata.st_dev, original_metadata.st_dev);
EXPECT_EQ(refreshed_metadata.st_ino, original_metadata.st_ino);
}
TEST(AuthRuntimeCompositionTest,
CookieReplacementDuringHttp401RecoveryFailsClosed) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_USE_SOBER_COOKIES", "0");
environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
const RuntimePaths paths = PathsFor(environment, directory);
ASSERT_TRUE(WriteFile(paths.cookie_file(),
std::string(".ROBLOSECURITY=") + kCredential + "\n"));
const std::filesystem::path protected_target =
directory.path() / "protected-cookie";
const std::string protected_contents =
".ROBLOSECURITY=_|newly-refreshed-credential\n";
ASSERT_TRUE(WriteFile(protected_target, protected_contents));
FakeHttpClient http;
http.response = {true, 401, {}, {}};
http.before_response = [&]() {
std::error_code error;
std::filesystem::remove(paths.cookie_file(), error);
EXPECT_FALSE(error);
std::filesystem::create_symlink(protected_target, paths.cookie_file(),
error);
EXPECT_FALSE(error);
};
services::AuthService auth_service(http);
const AuthRuntimeComposition composition =
ComposeAuthRuntime(environment, paths, auth_service);
EXPECT_FALSE(composition);
EXPECT_EQ(composition.status, AuthRuntimeStatus::kUnavailable);
EXPECT_EQ(composition.http_status, 401);
EXPECT_EQ(composition.error,
"rejected Roblox cookie could not be cleared safely");
EXPECT_TRUE(std::filesystem::is_symlink(paths.cookie_file()));
EXPECT_EQ(ReadFile(protected_target), protected_contents);
}
TEST(AuthRuntimeCompositionTest,
ManagedHardlinkToDefaultSoberCookieFailsClosedWithoutRedaction) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_USE_SOBER_COOKIES", "0");
environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
const RuntimePaths paths = PathsFor(environment, directory);
const std::string cookies =
std::string(".ROBLOSECURITY=") + kCredential + "\nOther=keep\n";
ASSERT_TRUE(WriteFile(paths.sober_cookie_file(), cookies));
ASSERT_EQ(chmod(paths.sober_cookie_file().c_str(), S_IRUSR), 0);
std::error_code error;
std::filesystem::create_directories(paths.auth_root(), error);
ASSERT_FALSE(error);
std::filesystem::create_hard_link(paths.sober_cookie_file(),
paths.cookie_file(), error);
ASSERT_FALSE(error);
FakeHttpClient http;
http.response = {true, 401, {}, {}};
services::AuthService auth_service(http);
const AuthRuntimeComposition composition =
ComposeAuthRuntime(environment, paths, auth_service);
EXPECT_FALSE(composition);
EXPECT_EQ(composition.status, AuthRuntimeStatus::kUnavailable);
EXPECT_EQ(composition.http_status, 401);
EXPECT_EQ(composition.error,
"managed Roblox cookie aliases the protected Sober file");
EXPECT_EQ(ReadFile(paths.cookie_file()), cookies);
EXPECT_EQ(ReadFile(paths.sober_cookie_file()), cookies);
struct stat sober_metadata = {};
ASSERT_EQ(lstat(paths.sober_cookie_file().c_str(), &sober_metadata), 0);
EXPECT_EQ(sober_metadata.st_mode & (S_IRWXU | S_IRWXG | S_IRWXO), S_IRUSR);
}
TEST(AuthRuntimeCompositionTest, UnavailableAuthFailsClosedAndRedactsErrors) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_ROBLOX_COOKIES", kCredential);
FakeHttpClient http;
http.response = {false, 0, {}, std::string("request exposed ") + kCredential};
services::AuthService auth_service(http);
const AuthRuntimeComposition composition = ComposeAuthRuntime(
environment, PathsFor(environment, directory), auth_service);
EXPECT_FALSE(composition);
EXPECT_EQ(composition.status, AuthRuntimeStatus::kUnavailable);
EXPECT_EQ(composition.error, "authentication service unavailable");
EXPECT_EQ(composition.error.find(kCredential), std::string::npos);
}
TEST(AuthRuntimeCompositionTest, ExplicitCookieFileIsAuthoritative) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_CONFIG_ROOT",
(directory.path() / "config").string());
environment.Set("MOCKTAIL_COOKIE_FILE",
(directory.path() / "missing-cookie").string());
environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
const RuntimePaths paths = PathsFor(environment, directory);
ASSERT_TRUE(WriteFile(paths.cookie_file(),
std::string(".ROBLOSECURITY=") + kCredential));
FakeHttpClient http;
services::AuthService auth_service(http);
const AuthRuntimeComposition composition =
ComposeAuthRuntime(environment, paths, auth_service);
EXPECT_FALSE(composition);
EXPECT_EQ(composition.status, AuthRuntimeStatus::kUnavailable);
EXPECT_EQ(composition.error, "configured Roblox cookie file is unavailable");
EXPECT_EQ(http.request_count, 0);
}
TEST(AuthRuntimeCompositionTest, RejectsCookieFileWithSharedPermissions) {
TempDirectory directory;
const std::filesystem::path cookie_file = directory.path() / "cookie";
ASSERT_TRUE(
WriteFile(cookie_file, std::string(".ROBLOSECURITY=") + kCredential));
ASSERT_EQ(chmod(cookie_file.c_str(), S_IRUSR | S_IWUSR | S_IRGRP), 0);
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_COOKIE_FILE", cookie_file.string());
FakeHttpClient http;
services::AuthService auth_service(http);
const AuthRuntimeComposition composition = ComposeAuthRuntime(
environment, PathsFor(environment, directory), auth_service);
EXPECT_FALSE(composition);
EXPECT_EQ(composition.status, AuthRuntimeStatus::kUnavailable);
EXPECT_EQ(composition.error,
"Roblox cookie file permissions are not private");
EXPECT_EQ(http.request_count, 0);
}
TEST(AuthRuntimeCompositionTest, RejectsSymlinkCookieSource) {
TempDirectory directory;
const std::filesystem::path target = directory.path() / "cookie-target";
const std::filesystem::path symlink = directory.path() / "cookie-link";
ASSERT_TRUE(WriteFile(target, std::string(".ROBLOSECURITY=") + kCredential));
std::error_code error;
std::filesystem::create_symlink(target, symlink, error);
ASSERT_FALSE(error);
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_COOKIE_FILE", symlink.string());
FakeHttpClient http;
services::AuthService auth_service(http);
const AuthRuntimeComposition composition = ComposeAuthRuntime(
environment, PathsFor(environment, directory), auth_service);
EXPECT_FALSE(composition);
EXPECT_EQ(composition.status, AuthRuntimeStatus::kUnavailable);
EXPECT_EQ(composition.error, "configured Roblox cookie file is unavailable");
EXPECT_EQ(http.request_count, 0);
}
TEST(AuthRuntimeCompositionTest, FallsBackToConfiguredSoberCookieFile) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_CONFIG_ROOT",
(directory.path() / "config").string());
environment.Set("MOCKTAIL_SOBER_COOKIE_FILE",
(directory.path() / "sober-cookies").string());
const RuntimePaths paths = PathsFor(environment, directory);
ASSERT_TRUE(WriteFile(paths.sober_cookie_file(),
std::string(".ROBLOSECURITY=") + kCredential + "\n"));
FakeHttpClient http;
http.response = {
true, 200, R"({"id":654,"name":"SoberUser","displayName":"Sober"})", {}};
services::AuthService auth_service(http);
const AuthRuntimeComposition composition =
ComposeAuthRuntime(environment, paths, auth_service);
ASSERT_TRUE(composition);
EXPECT_EQ(composition.status, AuthRuntimeStatus::kAuthenticated);
EXPECT_EQ(composition.jni_vm->GetRobloxAuthIdentitySnapshot().user_id, 654);
EXPECT_EQ(http.request_count, 1);
std::ifstream migrated(paths.cookie_file(), std::ios::binary);
ASSERT_TRUE(migrated.good());
const std::string migrated_value(
(std::istreambuf_iterator<char>(migrated)),
std::istreambuf_iterator<char>());
EXPECT_EQ(migrated_value,
std::string(".ROBLOSECURITY=") + kCredential + "\n");
struct stat migrated_status = {};
ASSERT_EQ(lstat(paths.cookie_file().c_str(), &migrated_status), 0);
EXPECT_EQ(migrated_status.st_mode & (S_IRWXG | S_IRWXO), 0U);
MapEnvironment restart_environment;
restart_environment.Set("HOME", directory.path().string());
restart_environment.Set("MOCKTAIL_USE_SOBER_COOKIES", "0");
FakeHttpClient restart_http;
restart_http.response = {
true, 200, R"({"id":654,"name":"SoberUser","displayName":"Sober"})", {}};
services::AuthService restart_auth_service(restart_http);
const AuthRuntimeComposition restarted = ComposeAuthRuntime(
restart_environment, PathsFor(restart_environment, directory),
restart_auth_service);
ASSERT_TRUE(restarted);
EXPECT_EQ(restarted.status, AuthRuntimeStatus::kAuthenticated);
ASSERT_EQ(restart_http.request_count, 1);
EXPECT_EQ(restart_http.last_request.headers[1],
std::string("Cookie: .ROBLOSECURITY=") + kCredential);
}
TEST(AuthRuntimeCompositionTest,
TrackerOnlyLocalCookieFallsBackToSoberCredential) {
TempDirectory directory;
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_CONFIG_ROOT",
(directory.path() / "config").string());
environment.Set("MOCKTAIL_SOBER_COOKIE_FILE",
(directory.path() / "sober-cookies").string());
const RuntimePaths paths = PathsFor(environment, directory);
ASSERT_TRUE(WriteFile(
paths.cookie_file(),
"RBXEventTrackerV2=browserid=123456&browserid_insert_timestamp=1\n"));
ASSERT_TRUE(WriteFile(paths.sober_cookie_file(),
std::string(".ROBLOSECURITY=") + kCredential + "\n"));
FakeHttpClient http;
http.response = {
true, 200, R"({"id":654,"name":"SoberUser","displayName":"Sober"})", {}};
services::AuthService auth_service(http);
const AuthRuntimeComposition composition =
ComposeAuthRuntime(environment, paths, auth_service);
ASSERT_TRUE(composition) << composition.error;
EXPECT_EQ(composition.status, AuthRuntimeStatus::kAuthenticated);
ASSERT_EQ(http.request_count, 1);
ASSERT_GE(http.last_request.headers.size(), 2U);
EXPECT_EQ(http.last_request.headers[1],
std::string("Cookie: .ROBLOSECURITY=") + kCredential);
}
TEST(AuthRuntimeCompositionTest,
FileReplacementCannotChangeCredentialOrIdentityDependency) {
constexpr char kCredentialA[] = "_|validated-credential-a";
constexpr char kCredentialB[] = "_|replacement-credential-b";
TempDirectory directory;
const std::filesystem::path cookie_file = directory.path() / "cookie";
ASSERT_TRUE(
WriteFile(cookie_file, std::string(".ROBLOSECURITY=") + kCredentialA));
MapEnvironment environment;
environment.Set("HOME", directory.path().string());
environment.Set("MOCKTAIL_COOKIE_FILE", cookie_file.string());
FakeHttpClient http;
http.response = {
true,
200,
R"({"id":777,"name":"ConsistentUser","displayName":"Consistent"})",
{}};
services::AuthService auth_service(http);
AuthRuntimeComposition composition = ComposeAuthRuntime(
environment, PathsFor(environment, directory), auth_service);
ASSERT_TRUE(composition);
ASSERT_TRUE(
WriteFile(cookie_file, std::string(".ROBLOSECURITY=") + kCredentialB));
mocktail::legacy::RuntimeDependencies dependencies(std::move(composition));
const std::string expected_header =
std::string(".ROBLOSECURITY=") + kCredentialA;
ASSERT_EQ(http.request_count, 1);
EXPECT_EQ(http.last_request.headers[1], "Cookie: " + expected_header);
EXPECT_EQ(dependencies.roblox_credential().view(), expected_header);
EXPECT_EQ(dependencies.account_identity().user_id, 777);
EXPECT_EQ(dependencies.account_identity().username, "ConsistentUser");
ASSERT_NE(dependencies.jni_vm(), nullptr);
const jnivm::RobloxAuthIdentity vm_identity =
dependencies.jni_vm()->GetRobloxAuthIdentitySnapshot();
EXPECT_EQ(vm_identity.user_id, dependencies.account_identity().user_id);
EXPECT_EQ(vm_identity.username, dependencies.account_identity().username);
{
const ScopedRobloxCredentialBinding credential_binding(
dependencies.jni_vm().get(), dependencies.roblox_credential());
ASSERT_TRUE(credential_binding.bound());
JNIEnv* env = dependencies.jni_vm()->GetJNIEnv();
jclass cookie_manager =
env->FindClass("com/roblox/universalapp/cookie/JNICookieManager");
jmethodID get_cookie = env->GetStaticMethodID(
cookie_manager, "getCookie", "(Ljava/lang/String;)Ljava/lang/String;");
auto java_cookie = static_cast<jstring>(env->CallStaticObjectMethod(
cookie_manager, get_cookie, env->NewStringUTF("roblox.com")));
EXPECT_EQ(ReadJavaString(env, java_cookie), expected_header);
}
EXPECT_FALSE(
dependencies.jni_vm()->CopyRobloxCredentialFromProvider(nullptr));
}
} // namespace
} // namespace runtime
} // namespace mocktail