diff --git a/integration-testing/.gitignore b/integration-testing/.gitignore deleted file mode 100644 index 57de3a5ca..000000000 --- a/integration-testing/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -client-python/ -cypress/videos/ -cypress/screenshots/ -content/*/.ipynb_checkpoints diff --git a/integration-testing/docker-compose.yml b/integration-testing/docker-compose.yml deleted file mode 100644 index 7941e68f9..000000000 --- a/integration-testing/docker-compose.yml +++ /dev/null @@ -1,55 +0,0 @@ -services: - client-cli: - build: - context: ./docker - dockerfile: cli.Dockerfile - args: - QUARTO_VERSION: ${QUARTO_VERSION} - PY_VERSION: ${PY_VERSION} - volumes: - - ../:/rsconnect-python - working_dir: /rsconnect-python/integration-testing - network_mode: host - entrypoint: '' - environment: - QUARTO_VERSION: ${QUARTO_VERSION} - PY_VERSION: ${PY_VERSION} - API_KEY: ${ADMIN_API_KEY} - - # customized connect built with updated quarto version - # used for nightly deploy_tests.yml that include quarto projects - connect-cli: - hostname: connect-cli - image: rstudio/rstudio-connect:jammy - build: - context: ./docker - dockerfile: connect.Dockerfile - args: - QUARTO_VERSION: ${QUARTO_VERSION} - PY_VERSION: ${PY_VERSION} - restart: always - ports: - - 3939:3939 - volumes: - - $PWD/docker/rstudio-connect-cli.gcfg:/etc/rstudio-connect/rstudio-connect.gcfg - privileged: true - environment: - RSTUDIO_CONNECT_HASTE: "enabled" - RSC_LICENSE: ${CONNECT_LICENSE} - QUARTO_VERSION: ${QUARTO_VERSION} - PY_VERSION: ${PY_VERSION} - - # connect from public docker hub - # used jupyter-notebook and deploy tests in CI, main.yml - connect: - hostname: connect - image: rstudio/rstudio-connect:jammy - restart: always - ports: - - 3939:3939 - volumes: - - $PWD/docker/rstudio-connect.gcfg:/etc/rstudio-connect/rstudio-connect.gcfg - privileged: true - environment: - RSTUDIO_CONNECT_HASTE: "enabled" - RSC_LICENSE: ${CONNECT_LICENSE} diff --git a/integration-testing/docker/cli.Dockerfile b/integration-testing/docker/cli.Dockerfile deleted file mode 100644 index e5040141c..000000000 --- a/integration-testing/docker/cli.Dockerfile +++ /dev/null @@ -1,40 +0,0 @@ -ARG PY_VERSION=${PY_VERSION} -FROM python:${PY_VERSION} -EXPOSE 9999 -VOLUME ../../:/rsconnect-python/ - -WORKDIR /rsconnect-python/integration-testing -ARG QUARTO_VERSION - -RUN apt-get update -y && apt-get install -y --no-install-recommends \ - build-essential \ - curl \ - ca-certificates \ - git \ - sudo \ - vim \ - jq \ - wget - -RUN mkdir -p /libs-client && \ - curl -fsSL https://github.com/casey/just/releases/download/1.1.2/just-1.1.2-x86_64-unknown-linux-musl.tar.gz \ - | tar -C /libs-client -xz just - -ENV PATH=$PATH:/libs-client - -RUN git clone --depth=1 https://github.com/bats-core/bats-core.git /libs/bats-core \ - && cd /libs/bats-core \ - && ./install.sh /libs/bats-core/installation \ - && git clone --depth=1 https://github.com/bats-core/bats-support.git /libs/bats-support \ - && git clone --depth=1 https://github.com/bats-core/bats-file.git /libs/bats-file \ - && git clone --depth=1 https://github.com/bats-core/bats-assert.git /libs/bats-assert - -RUN curl -fsSL -o miniconda.sh https://repo.anaconda.com/miniconda/Miniconda3-py38_4.10.3-Linux-x86_64.sh \ - && chmod 755 miniconda.sh \ - && ./miniconda.sh -b -p /opt/miniconda \ - && rm -rf miniconda.sh - -RUN curl -fsSLO https://github.com/quarto-dev/quarto-cli/releases/download/v${QUARTO_VERSION}/quarto-${QUARTO_VERSION}-linux-amd64.tar.gz && \ - mkdir /opt/quarto && tar xf quarto-${QUARTO_VERSION}-linux-amd64.tar.gz -C /opt/quarto --strip-components 1 && \ - ( echo ""; echo 'export PATH=$PATH:/opt/quarto/bin' ; echo "" ) >> ~/.profile && \ - . ~/.profile diff --git a/integration-testing/docker/connect.Dockerfile b/integration-testing/docker/connect.Dockerfile deleted file mode 100644 index 2d68f7462..000000000 --- a/integration-testing/docker/connect.Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM rstudio/rstudio-connect:jammy -ARG QUARTO_VERSION - -RUN curl -fsSLO https://github.com/quarto-dev/quarto-cli/releases/download/v${QUARTO_VERSION}/quarto-${QUARTO_VERSION}-linux-amd64.tar.gz && \ - tar xf quarto-${QUARTO_VERSION}-linux-amd64.tar.gz -C /opt/quarto --strip-components 1 && \ - ( echo ""; echo 'export PATH=$PATH:/opt/quarto/bin' ; echo "" ) >> ~/.profile && \ - source ~/.profile diff --git a/integration-testing/docker/rstudio-connect-cli.gcfg b/integration-testing/docker/rstudio-connect-cli.gcfg deleted file mode 100644 index cd76347c7..000000000 --- a/integration-testing/docker/rstudio-connect-cli.gcfg +++ /dev/null @@ -1,40 +0,0 @@ -; Posit Connect test environment configuration - -[Server] -SenderEmail = testuser@testrsc.com -EmailProvider = print -DataDir = /connect-rsconnect-data -EnableSitemap = true -AllowConfirmedUsers = true -Address = http://localhost:3939 -JumpStartEnabled = false - -[HTTP] -Listen = :3939 -NoWarning = true - -[Python] -Enabled = true -Executable = /opt/python/3.12.11/bin/python -Executable = /opt/python/3.11.13/bin/python - -[Quarto] -Enabled = true -Executable = /opt/quarto/1.4.557/bin/quarto - -[Authentication] -BasicAuth = true -InsecureDefaultUserAPIKey = true -APIKeyBcryptCost = 4 - -[Authorization] -DefaultUserRole = "publisher" - -[Database] -SeedUsers = true - -[Mount] -BaseDir = /connect-rsconnect-mount - -[Logging] -ServiceLog = STDOUT diff --git a/integration-testing/docker/rstudio-connect.gcfg b/integration-testing/docker/rstudio-connect.gcfg deleted file mode 100644 index 4fefbe57f..000000000 --- a/integration-testing/docker/rstudio-connect.gcfg +++ /dev/null @@ -1,40 +0,0 @@ -; Posit Connect test environment configuration - -[Server] -SenderEmail = testuser@testrsc.com -EmailProvider = print -DataDir = /connect-rsconnect-data -EnableSitemap = true -AllowConfirmedUsers = true -Address = http://localhost:3939 -JumpStartEnabled = false - -[HTTP] -Listen = :3939 -NoWarning = true - -[Python] -Enabled = true -Executable = /opt/python/3.11.13/bin/python -Executable = /opt/python/3.12.11/bin/python - -[Quarto] -Enabled = true -Executable = /opt/quarto/1.4.557/bin/quarto - -[Authentication] -BasicAuth = true -InsecureDefaultUserAPIKey = true -APIKeyBcryptCost = 4 - -[Authorization] -DefaultUserRole = "publisher" - -[Database] -SeedUsers = true - -[Mount] -BaseDir = /connect-rsconnect-mount - -[Logging] -ServiceLog = STDOUT diff --git a/integration/.gitignore b/integration/.gitignore deleted file mode 100644 index 17c41768c..000000000 --- a/integration/.gitignore +++ /dev/null @@ -1 +0,0 @@ -env.json diff --git a/integration/__init__.py b/integration/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/integration/jwt_testbed.py b/integration/jwt_testbed.py deleted file mode 100644 index d0818a89a..000000000 --- a/integration/jwt_testbed.py +++ /dev/null @@ -1,314 +0,0 @@ -import requests -import time -import sys -import json -from datetime import timedelta - -from rsconnect.json_web_token import ( - JWTEncoder, - TokenGenerator, - read_secret_key, - validate_hs256_secret_key, - DEFAULT_ISSUER, - DEFAULT_AUDIENCE, - BOOTSTRAP_SCOPE, - BOOTSTRAP_EXP, -) - -BOOTSTRAP_ENDPOINT = "/__api__/v1/experimental/bootstrap" -API_KEY_ENDPOINT = "/__api__/me" - - -ENV_FILENAME = "env.json" - -SUCCESS = "\u2713" -FAILURE = "FAILED" - - -def read_env(): - with open("integration/env.json", "r") as f: - return json.loads(f.read()) - - -def preamble(step, text): - print("[{}] {}... ".format(step, text), end="") - - -def success(): - print(SUCCESS) - - -def failure(reason): - print(FAILURE + ": {}".format(reason)) - sys.exit(1) - - -def api_key_authorization_header(token): - return {"Authorization": "Key " + token} - - -def jwt_authorization_header(token): - return {"Authorization": "Connect-Bootstrap " + token} - - -def generate_jwt_secured_header(keypath): - secret_key = read_secret_key(keypath) - validate_hs256_secret_key(secret_key) - - token_generator = TokenGenerator(secret_key) - - initial_admin_token = token_generator.bootstrap() - return jwt_authorization_header(initial_admin_token) - - -def create_jwt_encoder(keypath, issuer, audience): - secret_key = read_secret_key(keypath) - validate_hs256_secret_key(secret_key) - - return JWTEncoder(issuer, audience, secret_key) - - -def assert_status_code(response, expected): - if response.status_code != expected: - failure("unexpected response status: " + str(response.status_code)) - - -def no_header(step, env): - preamble(step, "Unable to access endpoint without a header present") - - response = requests.post(env["bootstrap_endpoint"]) - - assert_status_code(response, 401) - - success() - - -def no_jwt_header(step, env): - preamble(step, "Unable to access endpoint without a JWT in the auth header") - - response = requests.post(env["bootstrap_endpoint"], jwt_authorization_header("")) - - assert_status_code(response, 401) - - success() - - -def invalid_jwt_header(step, env): - preamble(step, "Unable to access endpoint with a bearer token that isn't a JWT") - - response = requests.post(env["bootstrap_endpoint"], jwt_authorization_header("invalid")) - - assert_status_code(response, 401) - - success() - - -def incorrect_jwt_invalid_issuer(step, env): - preamble(step, "Unable to access endpoint with an incorrectly scoped JWT (invalid issuer)") - - encoder = create_jwt_encoder(env["keypath"], "invalid", DEFAULT_AUDIENCE) - token = encoder.new_token({"scope": BOOTSTRAP_SCOPE}, BOOTSTRAP_EXP) - response = requests.post(env["bootstrap_endpoint"], jwt_authorization_header(token)) - - assert_status_code(response, 401) - - success() - - -def incorrect_jwt_invalid_audience(step, env): - preamble(step, "Unable to access endpoint with an incorrectly scoped JWT (invalid audience)") - - encoder = create_jwt_encoder(env["keypath"], DEFAULT_ISSUER, "invalid") - token = encoder.new_token({"scope": BOOTSTRAP_SCOPE}, BOOTSTRAP_EXP) - response = requests.post(env["bootstrap_endpoint"], jwt_authorization_header(token)) - - assert_status_code(response, 401) - - success() - - -def incorrect_jwt_invalid_scope(step, env): - preamble(step, "Unable to access endpoint with an incorrectly scoped JWT (invalid scope)") - - encoder = create_jwt_encoder(env["keypath"], DEFAULT_ISSUER, DEFAULT_AUDIENCE) - token = encoder.new_token({"scope": BOOTSTRAP_SCOPE}, BOOTSTRAP_EXP) - response = requests.post(env["bootstrap_endpoint"], jwt_authorization_header(token)) - - assert_status_code(response, 401) - - success() - - -def no_scope(step, env): - preamble(step, "Unable to access endpoint with an incorrectly scoped JWT (no scope provided)") - - encoder = create_jwt_encoder(env["keypath"], DEFAULT_ISSUER, DEFAULT_AUDIENCE) - token = encoder.new_token({"invalid": "invalid"}, BOOTSTRAP_EXP) - response = requests.post(env["bootstrap_endpoint"], jwt_authorization_header(token)) - - assert_status_code(response, 401) - - success() - - -def different_secret(step, env): - preamble(step, "Unable to access endpoint with a JWT signed with an unexpected secret") - - encoder = JWTEncoder(DEFAULT_ISSUER, DEFAULT_AUDIENCE, "invalid_secret") - token = encoder.new_token({"scope": BOOTSTRAP_SCOPE}, BOOTSTRAP_EXP) - response = requests.post(env["bootstrap_endpoint"], jwt_authorization_header(token)) - - assert_status_code(response, 401) - - success() - - -def incorrect_jwt_expired(step, env): - preamble(step, "Unable to access endpoint with an incorrectly scoped JWT (expired)") - - encoder = create_jwt_encoder(env["keypath"], DEFAULT_ISSUER, DEFAULT_AUDIENCE) - token = encoder.new_token({"scope": BOOTSTRAP_SCOPE}, timedelta(seconds=1)) - time.sleep(5) - response = requests.post(env["bootstrap_endpoint"], jwt_authorization_header(token)) - - assert_status_code(response, 401) - - success() - - -def verify_api_key_endpoint_invalid(step, env): - preamble(step, "Unable to access api key endpoint with invalid api key (prereq)") - - response = requests.get(env["api_key_endpoint"], headers=api_key_authorization_header("invalid")) - assert_status_code(response, 401) - - empty_string_response = requests.get(env["api_key_endpoint"], headers=api_key_authorization_header("")) - assert_status_code(empty_string_response, 401) - - success() - - -def verify_api_key_endpoint_empty(step, env): - preamble(step, "Unable to access api key endpoint with no api key (prereq)") - - response = requests.get(env["api_key_endpoint"]) - assert_status_code(response, 401) - - success() - - -def endpoint_happy_path(step, env): - preamble(step, "Verifying initial admin endpoint happy path") - - response = requests.post(env["bootstrap_endpoint"], headers=generate_jwt_secured_header(env["keypath"])) - - assert_status_code(response, 200) - - json_data = response.json() - if "api_key" not in json_data: - failure("api_key key not in json response") - - api_key = json_data["api_key"] - if api_key is None or api_key == "": - failure("api_key value not in json response") - - # verify that we can get into the api key endpoint with the returned key - - api_key = json_data["api_key"] - - api_response = requests.get(env["api_key_endpoint"], headers=api_key_authorization_header(api_key)) - assert_status_code(api_response, 200) - api_json = api_response.json() - - # verify that the response is reasonable from an api_key secured endpoint - - if "username" not in api_json: - failure("No username returned from /me") - - if len(api_json["username"]) == 0: - failure("Empty username returned from /me") - - if "user_role" not in api_json: - failure("No user_role returned from /me") - - if api_json["user_role"] != "administrator": - failure("Invalid user_role returned from /me: {}".format(api_json["user_role"])) - - # bootstrap endpoint should not respond to api key - bootstrap_api_response = requests.post(env["bootstrap_endpoint"], headers=jwt_authorization_header(api_key)) - assert_status_code(bootstrap_api_response, 401) - - success() - - -def endpoint_subsequent_calls(step, env): - preamble(step, "Subsequent call should fail gracefully") - - response = requests.post(env["bootstrap_endpoint"], headers=generate_jwt_secured_header(env["keypath"])) - - assert_status_code(response, 403) - - success() - - -def other_endpoint_does_not_accept_jwts(step, env): - preamble(step, "Only the initial admin endpoint will authorize using jwts") - - response = requests.get(env["api_key_endpoint"], headers=generate_jwt_secured_header(env["keypath"])) - - assert_status_code(response, 401) - - invalid_jwt_response = requests.get(env["api_key_endpoint"], headers={"Authorization": "Bearer invalid"}) - - assert_status_code(invalid_jwt_response, 401) - - success() - - -test_functions = [ - no_header, - no_jwt_header, - invalid_jwt_header, - incorrect_jwt_invalid_issuer, - incorrect_jwt_invalid_audience, - incorrect_jwt_invalid_scope, - incorrect_jwt_expired, - no_scope, - different_secret, - # verify the behavior of a "normal" api key endpoint before running the full endpoint excercise - verify_api_key_endpoint_invalid, - verify_api_key_endpoint_empty, - endpoint_happy_path, - endpoint_subsequent_calls, - other_endpoint_does_not_accept_jwts, -] - - -def run_testbed(): - - print("VERIFYING ENV FILE") - print("------------------") - - json_env = read_env() - if "server" not in json_env: - print("ERROR: server not configured in env file") - sys.exit(1) - if "keypath" not in json_env: - print("ERROR keypath not configured in env file") - sys.exit(1) - - json_env["bootstrap_endpoint"] = json_env["server"] + BOOTSTRAP_ENDPOINT - json_env["api_key_endpoint"] = json_env["server"] + API_KEY_ENDPOINT - - print("RUNNING TESTBED") - print("---------------") - - for i in range(len(test_functions)): - test_functions[i](i, json_env) - - print() - print("Done.") - - -if __name__ == "__main__": - run_testbed() diff --git a/my-shiny-app/app.py b/my-shiny-app/app.py deleted file mode 100644 index f9b5f96bc..000000000 --- a/my-shiny-app/app.py +++ /dev/null @@ -1,16 +0,0 @@ -from shiny import * - -app_ui = ui.page_fluid( - ui.input_slider("n", "N", 0, 100, 20), - ui.output_text_verbatim("txt", placeholder=True), -) - - -def server(input, output, session): - @output() - @render_text() - def txt(): - return f"n*2 is {input.n() * 2}" - - -app = App(app_ui, server) diff --git a/my-shiny-app/manifest.json b/my-shiny-app/manifest.json deleted file mode 100644 index 82fb02ded..000000000 --- a/my-shiny-app/manifest.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "version": 1, - "metadata": { - "appmode": "python-shiny", - "entrypoint": "app" - }, - "locale": "en_US.UTF-8", - "python": { - "version": "3.8.12", - "package_manager": { - "name": "pip", - "version": "22.0.4", - "package_file": "requirements.txt" - } - }, - "files": { - "requirements.txt": { - "checksum": "aa7771af430e482763c29ce773e399ae" - }, - "app.py": { - "checksum": "3aa0db2cc926c4e573783a56749cfb7c" - } - } -} \ No newline at end of file diff --git a/my-shiny-app/requirements.txt b/my-shiny-app/requirements.txt deleted file mode 100644 index 564601356..000000000 --- a/my-shiny-app/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ ---extra-index-url https://rstudio.github.io/pyshiny-site/pypi/ -shiny -websockets diff --git a/pyproject.toml b/pyproject.toml index 8fb46e08b..a5cf02334 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,7 +60,7 @@ module-root = "" [tool.ruff] line-length = 120 -extend-exclude = ["my-shiny-app", "rsconnect-build", "rsconnect-build-test", "integration", "tests/testdata"] +extend-exclude = ["rsconnect-build", "rsconnect-build-test", "tests/testdata"] [tool.ruff.lint] select = ["E", "F", "W"] diff --git a/tests/test_json_web_token.py b/tests/test_json_web_token.py index 662358f5c..d227a11cf 100644 --- a/tests/test_json_web_token.py +++ b/tests/test_json_web_token.py @@ -398,3 +398,16 @@ def test_token_workflow(self): # assert we have a valid token self.assert_bootstrap_jwt_is_valid(test_payload, test_datetime) + + +class TestBootstrapContract(TestCase): + """ + Pin the bootstrap JWT contract to the literal values the Connect server + validates. Connect rejects the token unless these match exactly, so a + change here is a breaking cross-repo change and must be deliberate. + """ + + def test_contract_constants_match_connect(self): + self.assertEqual(DEFAULT_ISSUER, "rsconnect-python") + self.assertEqual(DEFAULT_AUDIENCE, "rsconnect") + self.assertEqual(BOOTSTRAP_SCOPE, "bootstrap")