mirror of
https://chromium.googlesource.com/chromium/tools/depot_tools.git
synced 2026-01-11 10:41:31 +00:00
[gerrit_util] Add some additional tests and fixes for SSOAuthenticator.
Unfortunately, the depot_tools presubmit builders are incredibly slow which make the subprocess based tests fail flakily. I've marked them all as `skip` with an optional way to run them locally. R=ayatane, yiwzhang Bug: b/335483238 Change-Id: I407aed3a1ed01563a0a80973b679aca405b9cde9 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/tools/depot_tools/+/5641259 Auto-Submit: Robbie Iannucci <iannucci@chromium.org> Commit-Queue: Robbie Iannucci <iannucci@chromium.org> Reviewed-by: Yiwei Zhang <yiwzhang@google.com>
This commit is contained in:
114
gerrit_util.py
114
gerrit_util.py
@@ -230,6 +230,10 @@ class SSOAuthenticator(Authenticator):
|
|||||||
# cookies.
|
# cookies.
|
||||||
_testing_load_expired_cookies = False
|
_testing_load_expired_cookies = False
|
||||||
|
|
||||||
|
# How long we should wait for the sso helper to write and close stdout.
|
||||||
|
# Overridden in tests.
|
||||||
|
_timeout_secs = 5
|
||||||
|
|
||||||
# Tri-state cache for sso helper command:
|
# Tri-state cache for sso helper command:
|
||||||
# * None - no lookup yet
|
# * None - no lookup yet
|
||||||
# * () - lookup was performed, but no binary was found.
|
# * () - lookup was performed, but no binary was found.
|
||||||
@@ -322,60 +326,80 @@ class SSOAuthenticator(Authenticator):
|
|||||||
|
|
||||||
Raises an exception if something goes wrong.
|
Raises an exception if something goes wrong.
|
||||||
"""
|
"""
|
||||||
tdir = tempfile.mkdtemp(suffix='gerrit_util')
|
cmd = cls._resolve_sso_cmd()
|
||||||
tf = os.path.join(tdir, 'git-remote-sso.stderr')
|
|
||||||
|
|
||||||
with tempdir() as tdir:
|
with tempdir() as tdir:
|
||||||
cmd = cls._resolve_sso_cmd()
|
tf = os.path.join(tdir, 'git-remote-sso.stderr')
|
||||||
|
|
||||||
stderr_file = open(tf, mode='w')
|
with open(tf, mode='w') as stderr_file:
|
||||||
|
# NOTE: The git-remote-sso helper does the following:
|
||||||
|
#
|
||||||
|
# 1. writes files to disk.
|
||||||
|
# 2. writes config to stdout, referencing those files.
|
||||||
|
# 3. closes stdout (thus sending EOF to us, allowing
|
||||||
|
# sys.stdout.read() to complete).
|
||||||
|
# 4. waits for stdin to close.
|
||||||
|
# 5. deletes files on disk (which is why we make sys.stdin a PIPE
|
||||||
|
# instead of closing it outright).
|
||||||
|
#
|
||||||
|
# NOTE: the http.proxy value in the emitted config points to
|
||||||
|
# a socket which is owned by a system service, not `proc` itself.
|
||||||
|
with subprocess2.Popen(cmd,
|
||||||
|
stdout=subprocess2.PIPE,
|
||||||
|
stderr=stderr_file,
|
||||||
|
stdin=subprocess2.PIPE,
|
||||||
|
encoding='utf-8') as proc:
|
||||||
|
stderr_file.close() # we can close after process starts.
|
||||||
|
timedout = False
|
||||||
|
|
||||||
# NOTE: The git-remote-sso helper does the following:
|
def _fire_timeout():
|
||||||
#
|
nonlocal timedout
|
||||||
# 1. writes files to disk.
|
timedout = True
|
||||||
# 2. writes config to stdout, referencing those files.
|
proc.kill()
|
||||||
# 3. closes stdout (thus sending EOF to us, allowing
|
|
||||||
# sys.stdout.read() to complete).
|
|
||||||
# 4. waits for stdin to close.
|
|
||||||
# 5. deletes files on disk (which is why we make sys.stdin a PIPE
|
|
||||||
# instead of closing it outright).
|
|
||||||
#
|
|
||||||
# NOTE: the http.proxy value in the emitted config points to
|
|
||||||
# a socket which is owned by a system service, not `proc` itself.
|
|
||||||
with subprocess2.Popen(cmd,
|
|
||||||
stdout=subprocess2.PIPE,
|
|
||||||
stderr=stderr_file,
|
|
||||||
stdin=subprocess2.PIPE,
|
|
||||||
encoding='utf-8') as proc:
|
|
||||||
timedout = False
|
|
||||||
|
|
||||||
def _fire_timeout():
|
timer = threading.Timer(cls._timeout_secs, _fire_timeout)
|
||||||
nonlocal timedout
|
timer.start()
|
||||||
timedout = True
|
try:
|
||||||
proc.kill()
|
stdout_data = proc.stdout.read()
|
||||||
|
finally:
|
||||||
|
timer.cancel()
|
||||||
|
|
||||||
timer = threading.Timer(5, _fire_timeout)
|
if timedout:
|
||||||
timer.start()
|
LOGGER.error(
|
||||||
try:
|
'SSOAuthenticator: Timeout: %r: reading config.',
|
||||||
ret = cls._parse_config(proc.stdout.read())
|
cmd)
|
||||||
finally:
|
raise subprocess.TimeoutExpired(
|
||||||
timer.cancel()
|
cmd=cmd, timeout=cls._timeout_secs)
|
||||||
|
|
||||||
if timedout:
|
# if the process already ended, then something is wrong.
|
||||||
LOGGER.error(
|
retcode = proc.poll()
|
||||||
'SSOAuthenticator: Timeout: %r: reading config.', cmd)
|
# if stdout was closed without any data, we need to wait for
|
||||||
raise subprocess.TimeoutExpired(cmd=cmd, timeout=5)
|
# end-of-process here and hope for an error message - the
|
||||||
|
# poll above is racy in this case (we could see stdout EOF
|
||||||
|
# but the process may not have quit yet).
|
||||||
|
if not retcode and not stdout_data:
|
||||||
|
retcode = proc.wait(timeout=cls._timeout_secs)
|
||||||
|
# We timed out while doing `wait` - we can't safely open
|
||||||
|
# stderr on windows, so just emit a generic timeout
|
||||||
|
# exception.
|
||||||
|
if retcode is None:
|
||||||
|
LOGGER.error(
|
||||||
|
'SSOAuthenticator: Timeout: %r: waiting error output.',
|
||||||
|
cmd)
|
||||||
|
raise subprocess.TimeoutExpired(
|
||||||
|
cmd=cmd, timeout=cls._timeout_secs)
|
||||||
|
|
||||||
proc.poll()
|
# Finally, if the poll or wait ended up getting the retcode,
|
||||||
if (retcode := proc.returncode) is not None:
|
# it means the process failed, so we can read the stderr
|
||||||
# process failed - we should be able to read the tempfile.
|
# file and reflect it back to the user.
|
||||||
stderr_file.close()
|
if retcode is not None:
|
||||||
with open(tf, encoding='utf-8') as stderr:
|
# process failed - we should be able to read the tempfile.
|
||||||
sys.exit(
|
with open(tf, encoding='utf-8') as stderr:
|
||||||
f'SSOAuthenticator: exit {retcode}: {stderr.read().strip()}'
|
sys.exit(
|
||||||
)
|
f'SSOAuthenticator: exit {retcode}: {stderr.read().strip()}'
|
||||||
|
)
|
||||||
|
|
||||||
return ret
|
return cls._parse_config(stdout_data)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _get_sso_info(cls) -> SSOInfo:
|
def _get_sso_info(cls) -> SSOInfo:
|
||||||
|
|||||||
10
tests/gerrit_util_test.inputs/testLaunchHelperFailQuick/git-remote-sso.py
Executable file
10
tests/gerrit_util_test.inputs/testLaunchHelperFailQuick/git-remote-sso.py
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Copyright 2024 The Chromium Authors. All rights reserved.
|
||||||
|
# Use of this source code is governed by a BSD-style license that can be
|
||||||
|
# found in the LICENSE file.
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
print("SSO Failure Message!!!", file=sys.stderr)
|
||||||
|
os.close(1) # signal that we've written all config
|
||||||
10
tests/gerrit_util_test.inputs/testLaunchHelperFailSlow/git-remote-sso.py
Executable file
10
tests/gerrit_util_test.inputs/testLaunchHelperFailSlow/git-remote-sso.py
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Copyright 2024 The Chromium Authors. All rights reserved.
|
||||||
|
# Use of this source code is governed by a BSD-style license that can be
|
||||||
|
# found in the LICENSE file.
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
time.sleep(6)
|
||||||
|
print(f"I ran too long without writing output!!", file=sys.stderr)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Netscape HTTP Cookie File
|
||||||
|
# https://curl.se/docs/http-cookies.html
|
||||||
|
# This file was generated by libcurl! Edit at your own risk.
|
||||||
|
|
||||||
|
#HttpOnly_login.example.com FALSE / FALSE 1718730497 SSO TUVFUE1PUlAK
|
||||||
|
#HttpOnly_.example.com TRUE / FALSE 1718730497 __CoolProxy QkxFRVBCTE9SUAo=
|
||||||
38
tests/gerrit_util_test.inputs/testLaunchHelperOK/git-remote-sso.py
Executable file
38
tests/gerrit_util_test.inputs/testLaunchHelperOK/git-remote-sso.py
Executable file
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Copyright 2024 The Chromium Authors. All rights reserved.
|
||||||
|
# Use of this source code is governed by a BSD-style license that can be
|
||||||
|
# found in the LICENSE file.
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
THIS_DIR = Path(__file__).parent.absolute()
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tempdir:
|
||||||
|
tempdir = Path(tempdir)
|
||||||
|
|
||||||
|
target_config = tempdir / "gitconfig"
|
||||||
|
target_cookies = tempdir / "cookiefile.txt"
|
||||||
|
|
||||||
|
shutil.copyfile(THIS_DIR / "gitconfig", target_config)
|
||||||
|
shutil.copyfile(THIS_DIR / "cookiefile.txt", target_cookies)
|
||||||
|
|
||||||
|
print('http.proxy=localhost:12345')
|
||||||
|
print(f'include.path={target_config}')
|
||||||
|
print(f'http.cookiefile={target_cookies}')
|
||||||
|
sys.stdout.flush()
|
||||||
|
# need to fully close file descriptor, sys.stdout.close() doesn't seem to cut
|
||||||
|
# it.
|
||||||
|
os.close(1)
|
||||||
|
|
||||||
|
print("OK", file=sys.stderr)
|
||||||
|
|
||||||
|
# block until stdin closes, then clean everything via TemporaryDirectory().
|
||||||
|
#
|
||||||
|
# This emulates the behavior of the real git-remote-sso helper which just
|
||||||
|
# prints temporary configuration for a daemon running elsewhere.
|
||||||
|
sys.stdin.read()
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[http]
|
||||||
|
extraHeader = Authorization: Basic REALLY_COOL_TOKEN
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import textwrap
|
import textwrap
|
||||||
import unittest
|
import unittest
|
||||||
@@ -25,6 +26,8 @@ import git_common
|
|||||||
import metrics
|
import metrics
|
||||||
import subprocess2
|
import subprocess2
|
||||||
|
|
||||||
|
RUN_SUBPROC_TESTS = 'RUN_SUBPROC_TESTS' in os.environ
|
||||||
|
|
||||||
|
|
||||||
def makeConn(host: str) -> gerrit_util.HttpConn:
|
def makeConn(host: str) -> gerrit_util.HttpConn:
|
||||||
"""Makes an empty gerrit_util.HttpConn for the given host."""
|
"""Makes an empty gerrit_util.HttpConn for the given host."""
|
||||||
@@ -590,10 +593,16 @@ class GerritUtilTest(unittest.TestCase):
|
|||||||
|
|
||||||
class SSOAuthenticatorTest(unittest.TestCase):
|
class SSOAuthenticatorTest(unittest.TestCase):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
cls._original_timeout_secs = gerrit_util.SSOAuthenticator._timeout_secs
|
||||||
|
return super().setUpClass()
|
||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
gerrit_util.SSOAuthenticator._sso_cmd = None
|
gerrit_util.SSOAuthenticator._sso_cmd = None
|
||||||
gerrit_util.SSOAuthenticator._sso_info = None
|
gerrit_util.SSOAuthenticator._sso_info = None
|
||||||
gerrit_util.SSOAuthenticator._testing_load_expired_cookies = True
|
gerrit_util.SSOAuthenticator._testing_load_expired_cookies = True
|
||||||
|
gerrit_util.SSOAuthenticator._timeout_secs = self._original_timeout_secs
|
||||||
self.sso = gerrit_util.SSOAuthenticator()
|
self.sso = gerrit_util.SSOAuthenticator()
|
||||||
return super().setUp()
|
return super().setUp()
|
||||||
|
|
||||||
@@ -601,11 +610,14 @@ class SSOAuthenticatorTest(unittest.TestCase):
|
|||||||
gerrit_util.SSOAuthenticator._sso_cmd = None
|
gerrit_util.SSOAuthenticator._sso_cmd = None
|
||||||
gerrit_util.SSOAuthenticator._sso_info = None
|
gerrit_util.SSOAuthenticator._sso_info = None
|
||||||
gerrit_util.SSOAuthenticator._testing_load_expired_cookies = False
|
gerrit_util.SSOAuthenticator._testing_load_expired_cookies = False
|
||||||
|
gerrit_util.SSOAuthenticator._timeout_secs = self._original_timeout_secs
|
||||||
return super().tearDown()
|
return super().tearDown()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _input_dir(self) -> Path:
|
def _input_dir(self) -> Path:
|
||||||
return Path(__file__).with_suffix('.inputs') / self._testMethodName
|
base = Path(__file__).absolute().with_suffix('.inputs')
|
||||||
|
# Here _testMethodName would be a string like "testCmdAssemblyFound"
|
||||||
|
return base / self._testMethodName
|
||||||
|
|
||||||
@mock.patch('shutil.which', return_value='/fake/git-remote-sso')
|
@mock.patch('shutil.which', return_value='/fake/git-remote-sso')
|
||||||
def testCmdAssemblyFound(self, _):
|
def testCmdAssemblyFound(self, _):
|
||||||
@@ -631,8 +643,8 @@ class SSOAuthenticatorTest(unittest.TestCase):
|
|||||||
somekey=a value with = in it
|
somekey=a value with = in it
|
||||||
novalue=
|
novalue=
|
||||||
http.proxy=localhost:12345
|
http.proxy=localhost:12345
|
||||||
http.cookiefile={(self._input_dir/'cookiefile.txt').absolute()}
|
http.cookiefile={self._input_dir/'cookiefile.txt'}
|
||||||
include.path={(self._input_dir/'gitconfig').absolute()}
|
include.path={self._input_dir/'gitconfig'}
|
||||||
''').strip())
|
''').strip())
|
||||||
self.assertDictEqual(parsed.headers, {
|
self.assertDictEqual(parsed.headers, {
|
||||||
'Authorization': 'Basic REALLY_COOL_TOKEN',
|
'Authorization': 'Basic REALLY_COOL_TOKEN',
|
||||||
@@ -646,6 +658,43 @@ class SSOAuthenticatorTest(unittest.TestCase):
|
|||||||
self.assertEqual(c['.example.com']['/']['__CoolProxy'].value,
|
self.assertEqual(c['.example.com']['/']['__CoolProxy'].value,
|
||||||
'QkxFRVBCTE9SUAo=')
|
'QkxFRVBCTE9SUAo=')
|
||||||
|
|
||||||
|
@unittest.skipUnless(RUN_SUBPROC_TESTS, 'subprocess tests are flakey')
|
||||||
|
def testLaunchHelperOK(self):
|
||||||
|
gerrit_util.SSOAuthenticator._sso_cmd = ('python3',
|
||||||
|
str(self._input_dir /
|
||||||
|
'git-remote-sso.py'))
|
||||||
|
|
||||||
|
info = self.sso._get_sso_info()
|
||||||
|
self.assertDictEqual(info.headers, {
|
||||||
|
'Authorization': 'Basic REALLY_COOL_TOKEN',
|
||||||
|
})
|
||||||
|
self.assertEqual(info.proxy.proxy_host, b'localhost')
|
||||||
|
self.assertEqual(info.proxy.proxy_port, 12345)
|
||||||
|
c = info.cookies._cookies
|
||||||
|
self.assertEqual(c['login.example.com']['/']['SSO'].value,
|
||||||
|
'TUVFUE1PUlAK')
|
||||||
|
self.assertEqual(c['.example.com']['/']['__CoolProxy'].value,
|
||||||
|
'QkxFRVBCTE9SUAo=')
|
||||||
|
|
||||||
|
@unittest.skipUnless(RUN_SUBPROC_TESTS, 'subprocess tests are flakey')
|
||||||
|
def testLaunchHelperFailQuick(self):
|
||||||
|
gerrit_util.SSOAuthenticator._sso_cmd = ('python3',
|
||||||
|
str(self._input_dir /
|
||||||
|
'git-remote-sso.py'))
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(SystemExit, "SSO Failure Message!!!"):
|
||||||
|
self.sso._get_sso_info()
|
||||||
|
|
||||||
|
@unittest.skipUnless(RUN_SUBPROC_TESTS, 'subprocess tests are flakey')
|
||||||
|
def testLaunchHelperFailSlow(self):
|
||||||
|
gerrit_util.SSOAuthenticator._timeout_secs = 0.2
|
||||||
|
gerrit_util.SSOAuthenticator._sso_cmd = ('python3',
|
||||||
|
str(self._input_dir /
|
||||||
|
'git-remote-sso.py'))
|
||||||
|
|
||||||
|
with self.assertRaises(subprocess.TimeoutExpired):
|
||||||
|
self.sso._get_sso_info()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user