chromium/chrome/updater/mac/client_lib/CRURegistration_unittests.mm

// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#import "chrome/updater/mac/client_lib/CRURegistration.h"

#import <Foundation/Foundation.h>
#import <dispatch/dispatch.h>

#include "base/apple/foundation_util.h"
#include "base/files/file_path.h"
#include "base/files/file_util.h"
#include "base/files/scoped_temp_dir.h"
#include "base/path_service.h"
#include "base/scoped_generic.h"
#include "base/strings/sys_string_conversions.h"
#include "build/build_config.h"
#import "chrome/updater/mac/client_lib/CRURegistration-Private.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "testing/gtest_mac.h"
#include "third_party/abseil-cpp/absl/cleanup/cleanup.h"
#include "url/gurl.h"

// Work queue item callbacks get called on a thread that can't make test
// assertions or expectations, so we need a type to store partial results from
// item callbacks during asynchronous work queue tests, for subsequent
// evaluation on the test thread. Making this an Objective-C type (which must be
// outside of any namespace) makes the test much easier to write, both
// syntactically and due to ARC's advantages in avoiding use-after-free bugs
// via use of __block storage duration.
@interface CRUWorkQueueTestObservation : NSObject
@property(nonatomic) int itemId;
@property(nonatomic, copy) NSString* taskStdOut;
@property(nonatomic, copy) NSString* taskStdErr;
@property(nonatomic, copy) NSError* taskNSErr;
@end

@implementation CRUWorkQueueTestObservation
@synthesize itemId = _itemId;
@synthesize taskStdOut = _taskStdOut;
@synthesize taskStdErr = _taskStdErr;
@synthesize taskNSErr = _taskNSErr;
@end

namespace {

constexpr char kEmitTextTestBinaryName[] = "emit_text";

TEST(CRURegistrationTest, SmokeTest) {
  CRURegistration* registration = [[CRURegistration alloc]
             initWithAppId:
                 @"org.chromium.ChromiumUpdater.CRURegistrationTest.SmokeTest"
      existenceCheckerPath:@"IGNORED"];
  ASSERT_TRUE(registration);
}

enum class EmitTextOutputTarget {
  kOut,
  kErr,
  kBoth,
};

class CRUAsyncTaskRunnerTest : public ::testing::Test {
 protected:
  void SetUp() override;

  bool RunEmitText(NSString* text, int repeats, EmitTextOutputTarget mode);

  NSURL* emit_text_nsurl_ = nil;
  dispatch_queue_t queue_ = nil;

  NSString* got_stdout_ = nil;
  NSString* got_stderr_ = nil;
  NSError* got_error_ = nil;
};

void GetEmitTextNSURL(NSURL** result) {
  ASSERT_TRUE(result);
  base::FilePath test_data_path;
  ASSERT_TRUE(base::PathService::Get(base::DIR_EXE, &test_data_path));
  base::FilePath emit_text_path =
      test_data_path.AppendASCII(kEmitTextTestBinaryName);
  ASSERT_TRUE(base::PathExists(emit_text_path))
      << "cannot find: " << emit_text_path;
  *result = base::apple::FilePathToNSURL(emit_text_path);
  ASSERT_TRUE(*result);
}

void CRUAsyncTaskRunnerTest::SetUp() {
  queue_ = dispatch_queue_create_with_target(
      "CRUAsyncTaskRunnerTestBlankOutput", DISPATCH_QUEUE_SERIAL,
      dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0));
  NSURL* emit_text_nsurl = nil;
  ASSERT_NO_FATAL_FAILURE(GetEmitTextNSURL(&emit_text_nsurl));
  emit_text_nsurl_ = emit_text_nsurl;
}

bool CRUAsyncTaskRunnerTest::RunEmitText(NSString* text,
                                         int repeats,
                                         EmitTextOutputTarget mode) {
  NSTask* task = [[NSTask alloc] init];
  task.executableURL = emit_text_nsurl_;
  NSMutableArray<NSString*>* args = [NSMutableArray arrayWithArray:@[
    [NSString stringWithFormat:@"--text=%@", text],
    [NSString stringWithFormat:@"--count=%d", repeats],
  ]];
  switch (mode) {
    case EmitTextOutputTarget::kBoth:
      [args addObject:@"--stdout"];
      [[fallthrough]];
    case EmitTextOutputTarget::kErr:
      [args addObject:@"--stderr"];
      break;
    case EmitTextOutputTarget::kOut:
      // Stdout-only output is the default.
      break;
  }

  task.arguments = [args copy];

  CRUAsyncTaskRunner* runner = [[CRUAsyncTaskRunner alloc] initWithTask:task
                                                            targetQueue:queue_];

  // Capture task results into ephemeral fields guarded by a lock, copying them
  // into the corresponding fields of `self` if we can acquire the lock.
  // Tests can subsequently use the instance fields without further locking.
  NSConditionLock* results_lock = [[NSConditionLock alloc] initWithCondition:0];
  __block NSString* got_stdout;
  __block NSString* got_stderr;
  __block NSError* got_error;

  [runner launchWithReply:^(NSString* task_out, NSString* task_err,
                            NSError* error) {
    [results_lock lock];
    got_stdout = task_out;
    got_stderr = task_err;
    got_error = error;
    [results_lock unlockWithCondition:1];
  }];

  if (![results_lock
          lockWhenCondition:1
                 beforeDate:[NSDate dateWithTimeIntervalSinceNow:15.0]]) {
    // Timed out; can't read any of the fields, we have no sync relationship
    // with them.
    return false;
  }
  absl::Cleanup result_unlocker = ^{
    [results_lock unlock];
  };

  got_stdout_ = got_stdout;
  got_stderr_ = got_stderr;
  got_error_ = got_error;
  return true;
}

TEST_F(CRUAsyncTaskRunnerTest, BlankOutput) {
  ASSERT_TRUE(RunEmitText(@"invisible", 0, EmitTextOutputTarget::kBoth));
  EXPECT_FALSE(got_error_);
  EXPECT_TRUE(got_stdout_);
  EXPECT_TRUE(got_stderr_);
  EXPECT_EQ(got_stdout_.length, 0U);
  EXPECT_EQ(got_stderr_.length, 0U);
}

TEST_F(CRUAsyncTaskRunnerTest, ShortStdout) {
  ASSERT_TRUE(RunEmitText(@"output", 2, EmitTextOutputTarget::kOut));
  EXPECT_FALSE(got_error_);
  EXPECT_TRUE(got_stdout_);
  EXPECT_TRUE(got_stderr_);
  EXPECT_EQ(got_stderr_.length, 0U);
  EXPECT_NSEQ(@"outputoutput", got_stdout_);
}

TEST_F(CRUAsyncTaskRunnerTest, ShortStderr) {
  ASSERT_TRUE(RunEmitText(@"output", 2, EmitTextOutputTarget::kErr));
  EXPECT_FALSE(got_error_);
  EXPECT_TRUE(got_stdout_);
  EXPECT_TRUE(got_stderr_);
  EXPECT_EQ(got_stdout_.length, 0U);
  EXPECT_NSEQ(@"outputoutput", got_stderr_);
}

TEST_F(CRUAsyncTaskRunnerTest, ShortBoth) {
  ASSERT_TRUE(RunEmitText(@"output", 2, EmitTextOutputTarget::kBoth));
  EXPECT_FALSE(got_error_);
  EXPECT_TRUE(got_stdout_);
  EXPECT_TRUE(got_stderr_);
  EXPECT_NSEQ(@"outputoutput", got_stdout_);
  EXPECT_NSEQ(@"outputoutput", got_stderr_);
}

TEST_F(CRUAsyncTaskRunnerTest, LongBoth) {
  // Construct output longer than any likely default buffer size, but small
  // enough to be practical to use in this test. emit_text flushes its output
  // streams every iteration specifically to make buffer size irrelevant, but
  // using a large total is still useful in case unexpected helpful buffering on
  // the intake side hides incorrect asynchronous stream consumption.
  //
  // Currently, this creates 16 MiB of output.
  const NSUInteger reps = 1048576;
  NSString* const text = @"0123456789ABCDEF";
  ASSERT_TRUE(RunEmitText(text, (int)reps, EmitTextOutputTarget::kBoth));
  EXPECT_FALSE(got_error_);
  EXPECT_TRUE(got_stdout_);
  EXPECT_TRUE(got_stderr_);

  // Construct the expectation string.
  NSMutableArray<NSString*>* chunks = [NSMutableArray arrayWithCapacity:reps];
  for (NSUInteger k = 0; k < reps; k++) {
    [chunks addObject:text];
  }
  NSString* want = [chunks componentsJoinedByString:@""];

  EXPECT_NSEQ(want, got_stdout_);
  EXPECT_NSEQ(want, got_stderr_);
}

TEST_F(CRUAsyncTaskRunnerTest, NonzeroReturn) {
  // `emit_text` returns ERANGE, value 34, if given a negative number of
  // repetitions. This allows testing nonzero return code handling.
  ASSERT_TRUE(RunEmitText(@"error", -1, EmitTextOutputTarget::kBoth));
  EXPECT_TRUE(got_error_);
  EXPECT_TRUE(got_stdout_);
  EXPECT_TRUE(got_stderr_);
  EXPECT_EQ(got_stdout_.length, 0U);
  EXPECT_EQ(got_stderr_.length, 0U);
  EXPECT_NSEQ(CRUReturnCodeErrorDomain, got_error_.domain);
  EXPECT_EQ((NSInteger)34, got_error_.code);
}

TEST_F(CRUAsyncTaskRunnerTest, TaskFailureErrorWrapping) {
  CRURegistration* registration = [[CRURegistration alloc]
             initWithAppId:@"org.chromium.ChromiumUpdater.CRURegistrationTest."
                           @"TaskFailureErrorWrapping"
      existenceCheckerPath:@"IGNORED"];

  ASSERT_TRUE(RunEmitText(@"error", -1, EmitTextOutputTarget::kBoth));
  NSError* wrapped = [registration wrapError:got_error_
                                  withStdout:got_stdout_
                                   andStderr:got_stderr_];
  EXPECT_NSEQ(CRURegistrationErrorDomain, wrapped.domain);
  EXPECT_EQ(CRURegistrationErrorTaskFailed, wrapped.code);
  EXPECT_NSEQ(@"", wrapped.userInfo[CRUStdoutKey]);
  EXPECT_NSEQ(@"", wrapped.userInfo[CRUStderrKey]);
  NSError* underlying = wrapped.userInfo[NSUnderlyingErrorKey];
  ASSERT_TRUE([underlying isKindOfClass:[NSError class]]);
  EXPECT_NSEQ(CRUReturnCodeErrorDomain, underlying.domain);
}

void TestWorkQueueImpl(int item_count) {
  dispatch_queue_t queue = dispatch_queue_create_with_target(
      "TestWorkQueueImpl", DISPATCH_QUEUE_SERIAL,
      dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0));

  CRURegistration* registration = [[CRURegistration alloc]
             initWithAppId:@"org.chromium.ChromiumUpdater.CRURegistrationTest."
                           @"WorkQueueTest"
      existenceCheckerPath:@"IGNORED"
               targetQueue:queue];

  NSURL* emit_text_nsurl = nil;
  ASSERT_NO_FATAL_FAILURE(GetEmitTextNSURL(&emit_text_nsurl));

  NSConditionLock* result_order_lock =
      [[NSConditionLock alloc] initWithCondition:0];
  NSMutableArray<CRURegistrationWorkItem*>* items = [NSMutableArray array];
  NSMutableArray<CRUWorkQueueTestObservation*>* observations =
      [NSMutableArray array];

  for (int i = 0; i < item_count; ++i) {
    CRURegistrationWorkItem* item = [[CRURegistrationWorkItem alloc] init];
    item.binPathCallback = ^NSURL* {
      return emit_text_nsurl;
    };
    item.args = @[ [NSString stringWithFormat:@"--text=%d", i] ];
    const int captured_i = i;
    item.resultCallback =
        ^(NSString* task_stdout, NSString* task_stderr, NSError* task_nserr) {
          [result_order_lock lock];
          NSInteger prev_cond = result_order_lock.condition;
          CRUWorkQueueTestObservation* observation =
              [[CRUWorkQueueTestObservation alloc] init];
          observation.itemId = captured_i;
          observation.taskStdOut = task_stdout;
          observation.taskStdErr = task_stderr;
          observation.taskNSErr = task_nserr;
          [observations addObject:observation];
          [result_order_lock unlockWithCondition:prev_cond + 1];
        };
    [items addObject:item];
  }

  [registration addWorkItems:items];

  ASSERT_TRUE([result_order_lock
      lockWhenCondition:(NSInteger)item_count
             beforeDate:[NSDate dateWithTimeIntervalSinceNow:10.0]]);
  absl::Cleanup result_unlocker = ^{
    [result_order_lock unlock];
  };

  ASSERT_EQ((size_t)item_count, observations.count);
  for (int i = 0; i < item_count; ++i) {
    CRUWorkQueueTestObservation* observation = observations[i];
    EXPECT_EQ(i, observation.itemId);
    NSString* expected = [NSString stringWithFormat:@"%d", observation.itemId];
    EXPECT_NSEQ(observation.taskStdOut, expected)
        << "wrong stdout in position " << i << ", item " << observation.itemId;
    EXPECT_NSEQ(observation.taskStdErr, @"")
        << "nonempty stderr in position " << i << ", item "
        << observation.itemId;
    EXPECT_FALSE(observation.taskNSErr)
        << "in position " << i << ", item " << observation.itemId
        << " had error: "
        << base::SysNSStringToUTF8([observation.taskNSErr description]);
  }
}

TEST(CRURegistrationTest, WorkQueueOneItem) {
  ASSERT_NO_FATAL_FAILURE(TestWorkQueueImpl(1));
}

TEST(CRURegistrationTest, WorkQueueThreeItems) {
  ASSERT_NO_FATAL_FAILURE(TestWorkQueueImpl(3));
}

TEST(CRURegistrationTest, WrapNoError) {
  CRURegistration* registration = [[CRURegistration alloc]
             initWithAppId:
                 @"org.chromium.ChromiumUpdater.CRURegistrationTest.WrapError"
      existenceCheckerPath:@"IGNORED"];

  EXPECT_FALSE([registration wrapError:nil withStdout:nil andStderr:nil]);
  EXPECT_FALSE([registration wrapError:nil
                            withStdout:@"irrelevant"
                             andStderr:@"irrelevant"]);
  EXPECT_FALSE([registration wrapError:nil
                            withStdout:nil
                             andStderr:@"irrelevant"]);
  EXPECT_FALSE([registration wrapError:nil
                            withStdout:@"irrelevant"
                             andStderr:@"nil"]);
}

// Verify that `wrapError:withStdout:andStderr:` correctly identifies an NSTask
// error from attempting to run a nonexistent file as "helper not found".
TEST(CRURegistrationTest, WrapMissingTaskTargetError) {
  CRURegistration* registration = [[CRURegistration alloc]
             initWithAppId:
                 @"org.chromium.ChromiumUpdater.CRURegistrationTest.WrapError"
      existenceCheckerPath:@"IGNORED"];

  // Create an NSTask configured to execute a file that does not exist and
  // capture the error that ensues from running it.
  base::ScopedTempDir empty_dir;
  ASSERT_TRUE(empty_dir.CreateUniqueTempDir());
  NSURL* nonexistent_path = base::apple::FilePathToNSURL(
      empty_dir.GetPath().AppendASCII("nonexistent"));
  NSTask* will_fail = [[NSTask alloc] init];
  will_fail.executableURL = nonexistent_path;
  NSError* bad_task_error;
  ASSERT_FALSE([will_fail launchAndReturnError:&bad_task_error]);
  ASSERT_TRUE(bad_task_error);

  NSError* wrapped_error = [registration wrapError:bad_task_error
                                        withStdout:nil
                                         andStderr:nil];
  EXPECT_TRUE(wrapped_error);
  EXPECT_NSEQ(wrapped_error.domain, CRURegistrationErrorDomain);
  EXPECT_EQ(wrapped_error.code, CRURegistrationErrorHelperNotFound);
}

TEST(CRURegistrationTest, WrapErrorUnchanged) {
  CRURegistration* registration = [[CRURegistration alloc]
             initWithAppId:
                 @"org.chromium.ChromiumUpdater.CRURegistrationTest.WrapError"
      existenceCheckerPath:@"IGNORED"];

  NSError* registration_error =
      [NSError errorWithDomain:CRURegistrationErrorDomain
                          code:CRURegistrationErrorTaskFailed
                      userInfo:@{@"test-key" : @"test-value"}];
  NSError* wrapped_reg_error = [registration wrapError:registration_error
                                            withStdout:@"discarded"
                                             andStderr:@"discarded"];
  EXPECT_NSEQ(wrapped_reg_error.domain, CRURegistrationErrorDomain);
  EXPECT_EQ(wrapped_reg_error.code, CRURegistrationErrorTaskFailed);
  EXPECT_NSEQ(wrapped_reg_error.userInfo[@"test-key"], @"test-value");
  EXPECT_FALSE(wrapped_reg_error.userInfo[CRUStdoutKey]);
  EXPECT_FALSE(wrapped_reg_error.userInfo[CRUStderrKey]);
  EXPECT_FALSE(wrapped_reg_error.userInfo[NSUnderlyingErrorKey]);

  NSError* internal_error =
      [NSError errorWithDomain:CRURegistrationInternalErrorDomain
                          code:CRURegistrationInternalErrorUnrecognized
                      userInfo:@{@"test-key" : @"test-value"}];
  NSError* wrapped_internal_error = [registration wrapError:internal_error
                                                 withStdout:@"discarded"
                                                  andStderr:@"discarded"];
  EXPECT_NSEQ(wrapped_internal_error.domain,
              CRURegistrationInternalErrorDomain);
  EXPECT_EQ(wrapped_internal_error.code,
            CRURegistrationInternalErrorUnrecognized);
  EXPECT_NSEQ(wrapped_internal_error.userInfo[@"test-key"], @"test-value");
  EXPECT_FALSE(wrapped_internal_error.userInfo[CRUStdoutKey]);
  EXPECT_FALSE(wrapped_internal_error.userInfo[CRUStderrKey]);
  EXPECT_FALSE(wrapped_internal_error.userInfo[NSUnderlyingErrorKey]);
}

}  // namespace