Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Failing tests with flask>=2.1.0 #594

Closed
mweinelt opened this issue Apr 1, 2022 · 5 comments
Closed

Failing tests with flask>=2.1.0 #594

mweinelt opened this issue Apr 1, 2022 · 5 comments

Comments

@mweinelt
Copy link

mweinelt commented Apr 1, 2022

Primarily around redirect paths. Likely related to pallets/flask#4496. We're on 4.1.3.

_______________________ test_change_invalidates_session ________________________

app = <Flask 'tests.conftest'>, client = <FlaskClient <Flask 'tests.conftest'>>

    def test_change_invalidates_session(app, client):
        # Make sure that if we change our password - prior sessions are invalidated.
    
        # changing password effectively re-logs in user - verify the signal
        auths = []
    
        @user_authenticated.connect_via(app)
        def authned(myapp, user, **extra_args):
            auths.append((user.email, extra_args["authn_via"]))
    
        # No remember cookie since that also be reset and auto-login.
        data = dict(email="matt@lp.com", password="password", remember="")
        response = client.post("/login", data=data)
        sess = get_session(response)
        cur_user_id = sess.get("_user_id", sess.get("user_id"))
    
        response = client.post(
            "/change",
            data={
                "password": "password",
                "new_password": "new strong password",
                "new_password_confirm": "new strong password",
            },
            follow_redirects=True,
        )
        # First auth was the initial login above - second should be from /change
        assert auths[1][0] == "matt@lp.com"
        assert "change" in auths[1][1]
    
        # Should have received a new session cookie - so should still be logged in
        response = client.get("/profile", follow_redirects=True)
        assert b"Profile Page" in response.data
    
        # Now use old session - shouldn't work.
        with client.session_transaction() as oldsess:
            oldsess["_user_id"] = cur_user_id
            oldsess["user_id"] = cur_user_id
    
        # try to access protected endpoint - shouldn't work
        response = client.get("/profile")
        assert response.status_code == 302
>       assert response.headers["Location"] == "http://localhost/login?next=%2Fprofile"
E       AssertionError: assert '/login?next=%2Fprofile' == 'http://local...xt=%2Fprofile'
E         - http://localhost/login?next=%2Fprofile
E         + /login?next=%2Fprofile

tests/test_changeable.py:196: AssertionError
______________________ test_change_invalidates_auth_token ______________________

app = <Flask 'tests.conftest'>, client = <FlaskClient <Flask 'tests.conftest'>>

    def test_change_invalidates_auth_token(app, client):
        # if change password, by default that should invalidate auth tokens
        response = json_authenticate(client)
        token = response.json["response"]["user"]["authentication_token"]
        headers = {"Authentication-Token": token}
        # make sure can access restricted page
        response = client.get("/token", headers=headers)
        assert b"Token Authentication" in response.data
    
        response = client.post(
            "/change",
            data={
                "password": "password",
                "new_password": "new strong password",
                "new_password_confirm": "new strong password",
            },
            follow_redirects=True,
        )
        assert response.status_code == 200
    
        # authtoken should now be invalid
        response = client.get("/token", headers=headers)
        assert response.status_code == 302
>       assert response.headers["Location"] == "http://localhost/login?next=%2Ftoken"
E       AssertionError: assert '/login?next=%2Ftoken' == 'http://local...next=%2Ftoken'
E         - http://localhost/login?next=%2Ftoken
E         + /login?next=%2Ftoken

tests/test_changeable.py:246: AssertionError
____________________ test_unauthorized_access_with_referrer ____________________

client = <FlaskClient <Flask 'tests.conftest'>>
get_message = <function get_message.<locals>.fn at 0x7ffff2f978b0>

    @pytest.mark.settings(unauthorized_view=lambda: None)
    def test_unauthorized_access_with_referrer(client, get_message):
        authenticate(client, "joe@lp.com")
        response = client.get("/admin", headers={"referer": "/admin"})
        assert response.headers["Location"] != "/admin"
        client.get(response.headers["Location"])
    
        response = client.get(
            "/admin?a=b", headers={"referer": "http://localhost/admin?x=y"}
        )
>       assert response.headers["Location"] == "http://localhost/"
E       AssertionError: assert '/' == 'http://localhost/'
E         - http://localhost/
E         + /

tests/test_common.py:327: AssertionError
___________________________ test_view_configuration ____________________________

client = <FlaskClient <Flask 'tests.conftest'>>

    @pytest.mark.settings(
        logout_url="/custom_logout",
        login_url="/custom_login",
        post_login_view="/post_login",
        post_logout_view="/post_logout",
        default_http_auth_realm="Custom Realm",
    )
    def test_view_configuration(client):
        response = client.get("/custom_login")
        assert b"<h1>Login</h1>" in response.data
    
        response = authenticate(client, endpoint="/custom_login")
        assert "location" in response.headers
>       assert response.headers["Location"] == "http://localhost/post_login"
E       AssertionError: assert '/post_login' == 'http://localhost/post_login'
E         - http://localhost/post_login
E         + /post_login

tests/test_configuration.py:27: AssertionError
___________________________ test_email_not_identity ____________________________

app = <Flask 'tests.conftest'>
sqlalchemy_datastore = <flask_security.datastore.SQLAlchemyUserDatastore object at 0x7ffff2d44be0>
get_message = <function get_message.<locals>.fn at 0x7ffff2f055e0>

    @pytest.mark.registerable()
    @pytest.mark.settings(
        user_identity_attributes=[{"username": {"mapper": lambda x: x}}],
    )
    def test_email_not_identity(app, sqlalchemy_datastore, get_message):
        # Test that can register/confirm with email even if it isn't an IDENTITY_ATTRIBUTE
        from flask_security import ConfirmRegisterForm, Security, unique_identity_attribute
        from wtforms import StringField, validators
    
        class MyRegisterForm(ConfirmRegisterForm):
            username = StringField(
                "Username",
                validators=[validators.data_required(), unique_identity_attribute],
            )
    
        app.config["SECURITY_CONFIRM_REGISTER_FORM"] = MyRegisterForm
        security = Security(datastore=sqlalchemy_datastore)
        security.init_app(app)
    
        client = app.test_client()
    
        with capture_registrations() as registrations:
            data = dict(email="mary2@lp.com", username="mary", password="awesome sunset")
            response = client.post("/register", data=data, follow_redirects=True)
            assert b"mary2@lp.com" in response.data
    
        token = registrations[0]["confirm_token"]
        response = client.get("/confirm/" + token, headers={"Accept": "application/json"})
        assert response.status_code == 302
>       assert response.location == "http://localhost/"
E       AssertionError: assert '/' == 'http://localhost/'
E         - http://localhost/
E         + /

tests/test_confirmable.py:525: AssertionError
_____________________________ test_authn_freshness _____________________________

app = <Flask 'tests.conftest'>, client = <FlaskClient <Flask 'tests.conftest'>>
get_message = <function get_message.<locals>.fn at 0x7ffff25d78b0>

    def test_authn_freshness(
        app: "Flask", client: "FlaskClient", get_message: t.Callable[..., bytes]
    ) -> None:
        """Test freshness using default reauthn_handler"""
    
        @auth_required(within=30, grace=0)
        def myview():
            return Response(status=200)
    
        @auth_required(within=0.001, grace=0)
        def myspecialview():
            return Response(status=200)
    
        app.add_url_rule("/myview", view_func=myview, methods=["GET"])
        app.add_url_rule("/myspecialview", view_func=myspecialview, methods=["GET"])
        authenticate(client)
    
        # This should work and not be redirected
        response = client.get("/myview", follow_redirects=False)
        assert response.status_code == 200
    
        # This should require additional authn and redirect to verify
        time.sleep(0.1)
        with capture_flashes() as flashes:
            response = client.get("/myspecialview", follow_redirects=False)
            assert response.status_code == 302
>           assert (
                response.location
                == "http://localhost/verify?next=http%3A%2F%2Flocalhost%2Fmyspecialview"
            )
E           AssertionError: assert '/verify?next...myspecialview' == 'http://local...myspecialview'
E             - http://localhost/verify?next=http%3A%2F%2Flocalhost%2Fmyspecialview
E             ? ----------------
E             + /verify?next=http%3A%2F%2Flocalhost%2Fmyspecialview

tests/test_misc.py:816: AssertionError
____________________________ test_default_authn_bp _____________________________

app = <Flask 'tests.conftest'>, client = <FlaskClient <Flask 'tests.conftest'>>

    @pytest.mark.settings(url_prefix="/myprefix")
    def test_default_authn_bp(app, client):
        """Test default reauthn handler with blueprint prefix"""
    
        @auth_required(within=0.001, grace=0)
        def myview():
            return Response(status=200)
    
        app.add_url_rule("/myview", view_func=myview, methods=["GET"])
        authenticate(client, endpoint="/myprefix/login")
    
        # This should require additional authn and redirect to verify
        time.sleep(0.1)
        response = client.get("/myview", follow_redirects=False)
        assert response.status_code == 302
>       assert (
            response.location
            == "http://localhost/myprefix/verify?next=http%3A%2F%2Flocalhost%2Fmyview"
        )
E       AssertionError: assert '/myprefix/ve...host%2Fmyview' == 'http://local...host%2Fmyview'
E         - http://localhost/myprefix/verify?next=http%3A%2F%2Flocalhost%2Fmyview
E         ? ----------------
E         + /myprefix/verify?next=http%3A%2F%2Flocalhost%2Fmyview

tests/test_misc.py:899: AssertionError
___________________________ test_authn_freshness_nc ____________________________

app = <Flask 'tests.conftest'>
client_nc = <FlaskClient <Flask 'tests.conftest'>>
get_message = <function get_message.<locals>.fn at 0x7ffff2eda5e0>

    def test_authn_freshness_nc(app, client_nc, get_message):
        # If don't send session cookie - then freshness always fails
        @auth_required(within=30)
        def myview():
            return Response(status=200)
    
        app.add_url_rule("/myview", view_func=myview, methods=["GET"])
    
        response = json_authenticate(client_nc)
        token = response.json["response"]["user"]["authentication_token"]
        h = {"Authentication-Token": token}
    
        # This should fail - should be a redirect
        response = client_nc.get("/myview", headers=h, follow_redirects=False)
        assert response.status_code == 302
>       assert (
            response.location
            == "http://localhost/verify?next=http%3A%2F%2Flocalhost%2Fmyview"
        )
E       AssertionError: assert '/verify?next...host%2Fmyview' == 'http://local...host%2Fmyview'
E         - http://localhost/verify?next=http%3A%2F%2Flocalhost%2Fmyview
E         ? ----------------
E         + /verify?next=http%3A%2F%2Flocalhost%2Fmyview

tests/test_misc.py:944: AssertionError
___________________ test_post_security_with_application_root ___________________

app = <Flask 'tests.conftest'>
sqlalchemy_datastore = <flask_security.datastore.SQLAlchemyUserDatastore object at 0x7ffff283d4f0>

    def test_post_security_with_application_root(app, sqlalchemy_datastore):
        init_app_with_options(app, sqlalchemy_datastore, **{"APPLICATION_ROOT": "/root"})
        client = app.test_client()
    
        response = client.post(
            "/login", data=dict(email="matt@lp.com", password="password")
        )
        assert response.status_code == 302
>       assert response.headers["Location"] == "http://localhost/root"
E       AssertionError: assert '/root' == 'http://localhost/root'
E         - http://localhost/root
E         + /root

tests/test_misc.py:1109: AssertionError
______________ test_post_security_with_application_root_and_views ______________

app = <Flask 'tests.conftest'>
sqlalchemy_datastore = <flask_security.datastore.SQLAlchemyUserDatastore object at 0x7ffff1c74250>

    def test_post_security_with_application_root_and_views(app, sqlalchemy_datastore):
        init_app_with_options(
            app,
            sqlalchemy_datastore,
            **{
                "APPLICATION_ROOT": "/root",
                "SECURITY_POST_LOGIN_VIEW": "/post_login",
                "SECURITY_POST_LOGOUT_VIEW": "/post_logout",
            }
        )
        client = app.test_client()
    
        response = client.post(
            "/login", data=dict(email="matt@lp.com", password="password")
        )
        assert response.status_code == 302
>       assert response.headers["Location"] == "http://localhost/post_login"
E       AssertionError: assert '/post_login' == 'http://localhost/post_login'
E         - http://localhost/post_login
E         + /post_login

tests/test_misc.py:1132: AssertionError
_______________________ test_recover_invalidates_session _______________________

app = <Flask 'tests.conftest'>, client = <FlaskClient <Flask 'tests.conftest'>>

    def test_recover_invalidates_session(app, client):
        # Make sure that if we reset our password - prior sessions are invalidated.
    
        other_client = app.test_client()
        authenticate(other_client)
        response = other_client.get("/profile", follow_redirects=True)
        assert b"Profile Page" in response.data
    
        # use normal client to reset password
        with capture_reset_password_requests() as requests:
            response = client.post(
                "/reset",
                json=dict(email="matt@lp.com"),
                headers={"Content-Type": "application/json"},
            )
            assert response.headers["Content-Type"] == "application/json"
    
        assert response.status_code == 200
        token = requests[0]["token"]
    
        # Test submitting a new password
        response = client.post(
            "/reset/" + token + "?include_auth_token",
            json=dict(password="awesome sunset", password_confirm="awesome sunset"),
            headers={"Content-Type": "application/json"},
        )
        assert all(
            k in response.json["response"]["user"]
            for k in ["email", "authentication_token"]
        )
    
        # try to access protected endpoint with old session - shouldn't work
        response = other_client.get("/profile")
        assert response.status_code == 302
>       assert response.headers["Location"] == "http://localhost/login?next=%2Fprofile"
E       AssertionError: assert '/login?next=%2Fprofile' == 'http://local...xt=%2Fprofile'
E         - http://localhost/login?next=%2Fprofile
E         + /login?next=%2Fprofile

tests/test_recoverable.py:292: AssertionError
_____________________________ test_default_unauthn _____________________________

app = <Flask 'tests.conftest'>, client = <FlaskClient <Flask 'tests.conftest'>>

    def test_default_unauthn(app, client):
        """Test default unauthn handler with and without json"""
    
        response = client.get("/profile")
        assert response.status_code == 302
>       assert response.headers["Location"] == "http://localhost/login?next=%2Fprofile"
E       AssertionError: assert '/login?next=%2Fprofile' == 'http://local...xt=%2Fprofile'
E         - http://localhost/login?next=%2Fprofile
E         + /login?next=%2Fprofile

tests/test_response.py:55: AssertionError
___________________________ test_default_unauthn_bp ____________________________

app = <Flask 'tests.conftest'>, client = <FlaskClient <Flask 'tests.conftest'>>

    @pytest.mark.settings(login_url="/mylogin", url_prefix="/myprefix")
    def test_default_unauthn_bp(app, client):
        """Test default unauthn handler with blueprint prefix and login url"""
    
        response = client.get("/profile")
        assert response.status_code == 302
>       assert (
            response.headers["Location"]
            == "http://localhost/myprefix/mylogin?next=%2Fprofile"
        )
E       AssertionError: assert '/myprefix/my...xt=%2Fprofile' == 'http://local...xt=%2Fprofile'
E         - http://localhost/myprefix/mylogin?next=%2Fprofile
E         ? ----------------
E         + /myprefix/mylogin?next=%2Fprofile

tests/test_response.py:71: AssertionError
_____________________________ test_two_factor_flag _____________________________

app = <Flask 'tests.conftest'>, client = <FlaskClient <Flask 'tests.conftest'>>

    @pytest.mark.settings(two_factor_required=True)
    def test_two_factor_flag(app, client):
        # trying to verify code without going through two-factor
        # first login function
        wrong_code = b"000000"
        response = client.post(
            "/tf-validate", data=dict(code=wrong_code), follow_redirects=True
        )
    
        message = b"You currently do not have permissions to access this page"
        assert message in response.data
    
        # Test login using invalid email
        data = dict(email="nobody@lp.com", password="password")
        response = client.post("/login", data=data, follow_redirects=True)
        assert b"Specified user does not exist" in response.data
        response = client.post(
            "/login",
            json=data,
            headers={"Content-Type": "application/json"},
            follow_redirects=True,
        )
        assert b"Specified user does not exist" in response.data
    
        # Test login using valid email and invalid password
        data = dict(email="gal@lp.com", password="wrong_pass")
        response = client.post("/login", data=data, follow_redirects=True)
        assert b"Invalid password" in response.data
        response = client.post(
            "/login",
            json=data,
            headers={"Content-Type": "application/json"},
            follow_redirects=True,
        )
        assert b"Invalid password" in response.data
    
        # Test two-factor authentication first login
        data = dict(email="matt@lp.com", password="password")
        response = client.post("/login", data=data, follow_redirects=True)
        message = b"Two-factor authentication adds an extra layer of security"
        assert message in response.data
        response = client.post(
            "/tf-setup", data=dict(setup="not_a_method"), follow_redirects=True
        )
        assert b"Marked method is not valid" in response.data
        session = get_session(response)
>       assert session["tf_state"] == "setup_from_login"
E       TypeError: 'NoneType' object is not subscriptable

tests/test_two_factor.py:323: TypeError
____________________________ test_admin_setup_reset ____________________________

app = <Flask 'tests.conftest'>, client = <FlaskClient <Flask 'tests.conftest'>>
get_message = <function get_message.<locals>.fn at 0x7ffff1705550>

    @pytest.mark.settings(two_factor_required=True)
    def test_admin_setup_reset(app, client, get_message):
        # Verify can use administrative datastore method to setup SMS
        # and that administrative reset removes access.
        sms_sender = SmsSenderFactory.createSender("test")
    
        data = dict(email="gene@lp.com", password="password")
        response = client.post(
            "/login", json=data, headers={"Content-Type": "application/json"}
        )
        assert response.json["response"]["tf_required"]
    
        # we shouldn't be logged in
        response = client.get("/profile", follow_redirects=False)
        assert response.status_code == 302
>       assert response.location == "http://localhost/login?next=%2Fprofile"
E       AssertionError: assert '/login?next=%2Fprofile' == 'http://local...xt=%2Fprofile'
E         - http://localhost/login?next=%2Fprofile
E         + /login?next=%2Fprofile

tests/test_two_factor.py:854: AssertionError
_______________________________ test_bad_sender ________________________________

app = <Flask 'tests.conftest'>, client = <FlaskClient <Flask 'tests.conftest'>>
get_message = <function get_message.<locals>.fn at 0x7ffff25eac10>

    @pytest.mark.settings(sms_service="bad")
    def test_bad_sender(app, client, get_message):
        # If SMS sender fails - make sure propagated
        # Test form, json, x signin, setup
        headers = {"Accept": "application/json", "Content-Type": "application/json"}
    
        # test normal, already setup up login.
        with capture_flashes() as flashes:
            data = {"email": "gal@lp.com", "password": "password"}
            response = client.post("login", data=data, follow_redirects=False)
            assert response.status_code == 302
>           assert response.location == "http://localhost/login"
E           AssertionError: assert '/login' == 'http://localhost/login'
E             - http://localhost/login
E             + /login

tests/test_two_factor.py:1108: AssertionError
_________________________________ test_verify __________________________________

app = <Flask 'tests.conftest'>, client = <FlaskClient <Flask 'tests.conftest'>>
get_message = <function get_message.<locals>.fn at 0x7ffff1be3f70>

    @pytest.mark.settings(freshness=timedelta(minutes=0))
    def test_verify(app, client, get_message):
        # Test setup when re-authenticate required
        authenticate(client)
        response = client.get("tf-setup", follow_redirects=False)
        verify_url = response.location
>       assert (
            verify_url == "http://localhost/verify?next=http%3A%2F%2Flocalhost%2Ftf-setup"
        )
E       AssertionError: assert '/verify?next...st%2Ftf-setup' == 'http://local...st%2Ftf-setup'
E         - http://localhost/verify?next=http%3A%2F%2Flocalhost%2Ftf-setup
E         ? ----------------
E         + /verify?next=http%3A%2F%2Flocalhost%2Ftf-setup

tests/test_two_factor.py:1190: AssertionError
_______________________________ test_verify_link _______________________________

app = <Flask 'tests.conftest'>, client = <FlaskClient <Flask 'tests.conftest'>>
get_message = <function get_message.<locals>.fn at 0x7ffff1cbd670>

    @pytest.mark.settings(us_email_subject="Code For You")
    def test_verify_link(app, client, get_message):
        auths = []
    
        @user_authenticated.connect_via(app)
        def authned(myapp, user, **extra_args):
            auths.append((user.email, extra_args["authn_via"]))
    
        with app.mail.record_messages() as outbox:
            response = client.post(
                "/us-signin/send-code",
                data=dict(identity="matt@lp.com", chosen_method="email"),
                follow_redirects=True,
            )
            assert response.status_code == 200
            assert b"Sign In" in response.data
    
        assert outbox[0].recipients == ["matt@lp.com"]
        assert outbox[0].sender == "no-reply@localhost"
        assert outbox[0].subject == "Code For You"
        matcher = re.match(
            r".*(http://[^\s*]*).*", outbox[0].body, re.IGNORECASE | re.DOTALL
        )
        magic_link = matcher.group(1)
    
        # Try with no code
        response = client.get("us-verify-link?email=matt@lp.com", follow_redirects=False)
>       assert response.location == "http://localhost/us-signin"
E       AssertionError: assert '/us-signin' == 'http://localhost/us-signin'
E         - http://localhost/us-signin
E         + /us-signin

tests/test_unified_signin.py:516: AssertionError
_________________________________ test_verify __________________________________

app = <Flask 'tests.conftest'>, client = <FlaskClient <Flask 'tests.conftest'>>
get_message = <function get_message.<locals>.fn at 0x7ffff130a040>

    @pytest.mark.settings(freshness=timedelta(minutes=0))
    def test_verify(app, client, get_message):
        # Test setup when re-authenticate required
        # With  freshness set to 0 - the first call should require reauth (by
        # redirecting); but the second should work due to grace period.
        us_authenticate(client)
        response = client.get("us-setup", follow_redirects=False)
        verify_url = response.location
>       assert (
            verify_url
            == "http://localhost/us-verify?next=http%3A%2F%2Flocalhost%2Fus-setup"
        )
E       AssertionError: assert '/us-verify?n...st%2Fus-setup' == 'http://local...st%2Fus-setup'
E         - http://localhost/us-verify?next=http%3A%2F%2Flocalhost%2Fus-setup
E         ? ----------------
E         + /us-verify?next=http%3A%2F%2Flocalhost%2Fus-setup

tests/test_unified_signin.py:840: AssertionError
__________________________________ test_next ___________________________________

app = <Flask 'tests.conftest'>, client = <FlaskClient <Flask 'tests.conftest'>>
get_message = <function get_message.<locals>.fn at 0x7ffff1c70040>

    def test_next(app, client, get_message):
        with capture_send_code_requests() as requests:
            response = client.post(
                "/us-signin/send-code",
                data=dict(identity="matt@lp.com", chosen_method="email"),
                follow_redirects=True,
            )
            assert response.status_code == 200
    
        response = client.post(
            "/us-signin?next=/post_login",
            data=dict(identity="matt@lp.com", passcode=requests[0]["token"]),
            follow_redirects=False,
        )
>       assert response.location == "http://localhost/post_login"
E       AssertionError: assert '/post_login' == 'http://localhost/post_login'
E         - http://localhost/post_login
E         + /post_login

tests/test_unified_signin.py:1102: AssertionError
@jwag956
Copy link
Collaborator

jwag956 commented Apr 1, 2022

This has been fixed in main. Any reason you need to run tests on other branches?

@mweinelt
Copy link
Author

mweinelt commented Apr 1, 2022

We're running the tests in NixOS to verify that our packaging makes all the right assumptions.

@mcepl
Copy link

mcepl commented Apr 18, 2022

This has been fixed in main. Any reason you need to run tests on other branches?

Where in main? Which commit so we can use it as a patch? And yes, Linux distributions (I am a maintainer of openSUSE) prefer using released tarballs.

@mweinelt
Copy link
Author

This has been fixed in main. Any reason you need to run tests on other branches?

Where in main? Which commit so we can use it as a patch? And yes, Linux distributions (I am a maintainer of openSUSE) prefer using released tarballs.

Flask-Middleware/flask-security@3e921ae

jwag956 added a commit that referenced this issue Apr 19, 2022
jwag956 added a commit that referenced this issue Apr 19, 2022
@jwag956
Copy link
Collaborator

jwag956 commented Apr 19, 2022

Fixed - will get out a release soon.

@jwag956 jwag956 closed this as completed Apr 19, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

No branches or pull requests

3 participants