From 587f78dbcc52bdcbae8d36d4e6eddc4e92a00c92 Mon Sep 17 00:00:00 2001 From: sdairs Date: Fri, 2 Oct 2026 18:48:52 +0100 Subject: [PATCH 1/2] Add Clojure customer notebook with bounded foreign activity reports --- .github/workflows/customer-notebook.yml | 33 +++ applications/customer-notebook/.env.example | 7 + applications/customer-notebook/.gitignore | 7 + applications/customer-notebook/README.md | 189 ++++++++++++++++ .../customer-notebook/checks/browser.py | 55 +++++ .../customer-notebook/checks/cloud.py | 208 ++++++++++++++++++ .../customer-notebook/checks/launch.py | 39 ++++ .../customer-notebook/checks/requirements.txt | 1 + .../customer-notebook/checks/restart.py | 31 +++ applications/customer-notebook/deps-lock.edn | 62 ++++++ applications/customer-notebook/deps.edn | 19 ++ .../scripts/seed-analytics.py | 42 ++++ .../customer-notebook/sql/001-down.sql | 2 + applications/customer-notebook/sql/001-up.sql | 19 ++ .../customer-notebook/sql/bootstrap.sql | 16 ++ .../customer-notebook/sql/foreign-table.sql | 22 ++ .../customer-notebook/sql/migrate-down.sql | 10 + .../customer-notebook/sql/migrate-up.sql | 11 + applications/customer-notebook/sql/seed.sql | 9 + .../customer-notebook/src/notebook/build.clj | 9 + .../customer-notebook/src/notebook/db.clj | 82 +++++++ .../customer-notebook/src/notebook/http.clj | 117 ++++++++++ .../customer-notebook/src/notebook/inputs.clj | 60 +++++ .../customer-notebook/src/notebook/main.clj | 21 ++ .../customer-notebook/src/notebook/plan.clj | 14 ++ .../src/notebook/preflight.clj | 21 ++ .../src/notebook/security.clj | 19 ++ .../src/notebook/tls_probe.clj | 22 ++ .../customer-notebook/src/notebook/views.clj | 70 ++++++ .../test/notebook/inputs_test.clj | 45 ++++ .../test/notebook/test_runner.clj | 8 + 31 files changed, 1270 insertions(+) create mode 100644 .github/workflows/customer-notebook.yml create mode 100644 applications/customer-notebook/.env.example create mode 100644 applications/customer-notebook/.gitignore create mode 100644 applications/customer-notebook/README.md create mode 100644 applications/customer-notebook/checks/browser.py create mode 100644 applications/customer-notebook/checks/cloud.py create mode 100644 applications/customer-notebook/checks/launch.py create mode 100644 applications/customer-notebook/checks/requirements.txt create mode 100644 applications/customer-notebook/checks/restart.py create mode 100644 applications/customer-notebook/deps-lock.edn create mode 100644 applications/customer-notebook/deps.edn create mode 100644 applications/customer-notebook/scripts/seed-analytics.py create mode 100644 applications/customer-notebook/sql/001-down.sql create mode 100644 applications/customer-notebook/sql/001-up.sql create mode 100644 applications/customer-notebook/sql/bootstrap.sql create mode 100644 applications/customer-notebook/sql/foreign-table.sql create mode 100644 applications/customer-notebook/sql/migrate-down.sql create mode 100644 applications/customer-notebook/sql/migrate-up.sql create mode 100644 applications/customer-notebook/sql/seed.sql create mode 100644 applications/customer-notebook/src/notebook/build.clj create mode 100644 applications/customer-notebook/src/notebook/db.clj create mode 100644 applications/customer-notebook/src/notebook/http.clj create mode 100644 applications/customer-notebook/src/notebook/inputs.clj create mode 100644 applications/customer-notebook/src/notebook/main.clj create mode 100644 applications/customer-notebook/src/notebook/plan.clj create mode 100644 applications/customer-notebook/src/notebook/preflight.clj create mode 100644 applications/customer-notebook/src/notebook/security.clj create mode 100644 applications/customer-notebook/src/notebook/tls_probe.clj create mode 100644 applications/customer-notebook/src/notebook/views.clj create mode 100644 applications/customer-notebook/test/notebook/inputs_test.clj create mode 100644 applications/customer-notebook/test/notebook/test_runner.clj diff --git a/.github/workflows/customer-notebook.yml b/.github/workflows/customer-notebook.yml new file mode 100644 index 00000000..ace7f93a --- /dev/null +++ b/.github/workflows/customer-notebook.yml @@ -0,0 +1,33 @@ +name: Customer notebook +on: + push: + paths: ['applications/customer-notebook/**', '.github/workflows/customer-notebook.yml'] + pull_request: + paths: ['applications/customer-notebook/**', '.github/workflows/customer-notebook.yml'] +jobs: + clojure: + runs-on: ubuntu-24.04-arm + defaults: + run: + working-directory: applications/customer-notebook + shell: bash + steps: + - uses: actions/checkout@v4 + - name: Install stable native Java21 and verified Clojure CLI + run: | + set -euo pipefail + sudo apt-get update -qq + sudo apt-get install -y -qq openjdk-21-jdk-headless curl ca-certificates + curl -fSLo "$RUNNER_TEMP/install-clojure.sh" https://download.clojure.org/install/linux-install-1.12.6.1673.sh + echo "5ae63b082ed33bf4c29bf1a8317c5c15249d1bc753676b2f5177fb3804ad6f77 $RUNNER_TEMP/install-clojure.sh" | sha256sum -c - + cd "$RUNNER_TEMP" + bash install-clojure.sh --prefix "$RUNNER_TEMP/clojure" + echo "$RUNNER_TEMP/clojure/bin" >> "$GITHUB_PATH" + echo '/usr/lib/jvm/java-21-openjdk-arm64/bin' >> "$GITHUB_PATH" + echo 'JAVA_HOME=/usr/lib/jvm/java-21-openjdk-arm64' >> "$GITHUB_ENV" + - name: Resolve exact dependency versions without Cloud credentials + run: clojure -Srepro -Sdeps "$(cat deps-lock.edn)" -P -M:locked:test + - name: Build and test + run: | + clojure -Srepro -Sdeps "$(cat deps-lock.edn)" -M:locked:build + clojure -Srepro -Sdeps "$(cat deps-lock.edn)" -M:locked:test diff --git a/applications/customer-notebook/.env.example b/applications/customer-notebook/.env.example new file mode 100644 index 00000000..1432896c --- /dev/null +++ b/applications/customer-notebook/.env.example @@ -0,0 +1,7 @@ +# Copy selected fields into a private exported app.env, not an administrator file. +PGHOST=YOUR_POSTGRES_HOST +PGPORT=5432 +PGDATABASE=postgres +PGSSLROOTCERT=/absolute/path/to/ca.pem +NOTEBOOK_APP_PASSWORD=YOUR_OPERATIONAL_ROLE_PASSWORD +NOTEBOOK_REPORT_PASSWORD=YOUR_REPORT_ROLE_PASSWORD diff --git a/applications/customer-notebook/.gitignore b/applications/customer-notebook/.gitignore new file mode 100644 index 00000000..3cd98a64 --- /dev/null +++ b/applications/customer-notebook/.gitignore @@ -0,0 +1,7 @@ +.cpcache/ +target/ +.deployment/ +.env +*.env +!*.env.example +__pycache__/ diff --git a/applications/customer-notebook/README.md b/applications/customer-notebook/README.md new file mode 100644 index 00000000..e42f9a66 --- /dev/null +++ b/applications/customer-notebook/README.md @@ -0,0 +1,189 @@ +# Customer notebook + +A small Clojure workbench using ClickHouse Managed Postgres (public beta) for operational notes and ClickHouse for synthetic daily activity. Ring/Reitit serve forms, Hiccup escapes their contents, and next.jdbc uses pgJDBC/HikariCP for both query paths. Reports run through pg_clickhouse, with a separate connection pool and role. + +This is a trusted local operator tool bound to 127.0.0.1, with seeded customers and native Ring CSRF protection. It has no signup, operator authentication, public deployment or tenant isolation. The runtime role can edit every seeded customer. Keep it on a trusted machine; sharing database credentials bypasses the form workflow. + +An edit submits the revision the operator actually saw. One transaction conditionally updates the profile and appends an edit record on the same connection. Two editors cannot both save the same revision. A 409 retains the submitted note, health and original revision; reload and review the current profile before deciding what to save. An ambiguous connection failure requires reloading to check the revision. There is no automatic rebase or issue-token retry protocol. + +The activity fixture is independent synthetic data, without CDC, ClickPipes or dual writes. Saving a note does not update ClickHouse, and a PostgreSQL transaction does not make a foreign read atomic with operational writes. A report failure displays unavailable; it does not invent zero activity. Empty days are filled with zero only after a successful query. + +## Native prerequisites and dependencies + +Use Linux, Java 21, Python 3, psql, jq, OpenSSL and the stable Clojure CLI. The verified run used Ubuntu 24.04 ARM64, OpenJDK 21.0.12.1, Clojure 1.12.6/CLI 1.12.6.1673, Ring 1.15.5, Reitit 0.11.0, Hiccup 2.0.0, next.jdbc 1.3.1118, pgJDBC 42.7.13 and HikariCP 7.1.0. PostgreSQL 18.6, pg_clickhouse 0.10.0 and ClickHouse 26.6.1.2191 were tested on 2 October 2026. + +```bash +sudo apt-get update +sudo apt-get install -y openjdk-21-jdk-headless python3 python3-venv \ + postgresql-client jq openssl curl ca-certificates rlwrap +curl -fSLo install-clojure.sh \ + https://download.clojure.org/install/linux-install-1.12.6.1673.sh +echo '5ae63b082ed33bf4c29bf1a8317c5c15249d1bc753676b2f5177fb3804ad6f77 install-clojure.sh' | sha256sum -c - +bash install-clojure.sh --prefix "$HOME/.local" +export PATH="$HOME/.local/bin:$PATH" +git clone https://github.com/ClickHouse/examples.git +cd examples/applications/customer-notebook +clojure -Sdeps "$(cat deps-lock.edn)" -P -M:locked:build +clojure -Sdeps "$(cat deps-lock.edn)" -M:locked:build +clojure -Sdeps "$(cat deps-lock.edn)" -M:locked:test +``` + +The official installer also verifies its downloaded tools archive SHA256. `deps.edn` pins direct dependencies; `deps-lock.edn` is a Clojure CLI `:locked` alias overriding all 51 resolved Maven versions. Pass it with `-Sdeps` as shown. The native preflight and locked graphs agreed exactly. Build/tests need no database secrets. A fresh source directory passed four unit tests / 29 assertions. + +## Create the owned Postgres service + +Install [clickhousectl](https://github.com/ClickHouse/clickhousectl), authenticate with your Cloud API key in a private terminal, and choose your organization. These services incur Cloud charges; the example has no HA requirement. The tested small PG shape is c6gd.large in AWS us-east-1. Check current size availability before creating a retained deployment. + +```bash +curl -fsSL https://clickhouse.com/cli | sh +export PATH="$HOME/.local/bin:$PATH" +clickhousectl cloud auth login --interactive +umask 077 +mkdir -p .deployment +export CH_ORG_ID=YOUR_ORG_ID +clickhousectl cloud postgres create --org-id "$CH_ORG_ID" \ + --name customer-notebook-pg --provider aws --region us-east-1 \ + --size c6gd.large --pg-version 18 --ha-type none --json \ + > .deployment/postgres-create.json +export PG_ID=$(jq -r .id .deployment/postgres-create.json) +clickhousectl cloud postgres get "$PG_ID" --org-id "$CH_ORG_ID" --json \ + > .deployment/postgres-status.json +jq -r .state .deployment/postgres-status.json +``` + +Repeat the final get until running, with a bounded provisioning window; preserve the ID instead of creating another service after a timeout. Create returns the administrator password once. Get does not return it. + +```bash +clickhousectl cloud postgres certs get "$PG_ID" --org-id "$CH_ORG_ID" \ + --output "$PWD/.deployment/ca.pem" +jq -r '"export PGHOST="+(.hostname|@sh), + "export PGPORT=5432", "export PGDATABASE=postgres", + "export PGUSER="+(.username|@sh), "export PGPASSWORD="+(.password|@sh)' \ + .deployment/postgres-create.json > .deployment/admin.env +printf 'export PGSSLROOTCERT=%q\n' "$PWD/.deployment/ca.pem" >> .deployment/admin.env +printf 'export PGSSLMODE=verify-full\nexport PGCONNECT_TIMEOUT=5\n' >> .deployment/admin.env +source .deployment/admin.env +psql -X -v ON_ERROR_STOP=1 -c "SELECT version(); SELECT name,default_version FROM pg_available_extensions WHERE name='pg_clickhouse';" +psql -X -v ON_ERROR_STOP=1 -c 'CREATE SCHEMA notebook_fdw; CREATE EXTENSION pg_clickhouse WITH SCHEMA notebook_fdw; SELECT notebook_fdw.pgch_version();' +``` + +Stop if the extension/version or secure server options do not match your deployment. The [extension reference](https://clickhouse.com/docs/products/managed-postgres/extensions/pg_clickhouse/reference) can describe newer behavior than the installed library. + +## Establish a narrow analytical network route + +Use a supported documented outbound address, or observe this fixture's actual route before configuring ClickHouse. The inbound Postgres hostname is not evidence of its egress identity. This verified 0.10.0 fixture used the owner-only deprecated HTTP helper once to a public IPv4 echo endpoint, sending only `SELECT 1` and no real credentials/customer data. Its installed HTTP resolver uses TLS on port 443; libcurl retains system certificate/name verification. This setup probe is not a runtime API and may disappear in later versions. + +```bash +export PG_EGRESS_IP=$(psql -X -At -v ON_ERROR_STOP=1 -c \ + "SELECT notebook_fdw.clickhouse_raw_query('SELECT 1','driver=http host=ipv4.icanhazip.com port=443');") +export APP_EGRESS_IP=$(curl -4fsS --max-time 10 https://icanhazip.com) +export PG_EGRESS_CIDR=$(python3 -c 'import os,ipaddress; print(str(ipaddress.IPv4Address(os.environ["PG_EGRESS_IP"]))+"/32")') +export APP_EGRESS_CIDR=$(python3 -c 'import os,ipaddress; print(str(ipaddress.IPv4Address(os.environ["APP_EGRESS_IP"]))+"/32")') +clickhousectl cloud service create --org-id "$CH_ORG_ID" \ + --name customer-notebook-ch --provider aws --region us-east-1 \ + --min-replica-memory-gb 8 --max-replica-memory-gb 8 --num-replicas 2 \ + --idle-scaling true --idle-timeout-minutes 5 \ + --ip-allow "$PG_EGRESS_CIDR=observed-postgres" \ + --ip-allow "$APP_EGRESS_CIDR=setup-client" --json > .deployment/clickhouse-create.json +export CH_ID=$(jq -r .service.id .deployment/clickhouse-create.json) +clickhousectl cloud service get "$CH_ID" --org-id "$CH_ORG_ID" --json \ + > .deployment/clickhouse-status.json +jq -r .state .deployment/clickhouse-status.json +``` + +The current CLI requires at least two replicas for a first warehouse; this fixture uses its minimum 8 GiB memory per replica. Wait for running. Observed egress is fixture-specific and can change after infrastructure changes; re-verify it, rather than opening unrestricted access. If this route cannot be verified, stop and clean up. The actual secure binary query below is the final route control. + +```bash +jq -r '.service.endpoints[]|select(.protocol=="https")| + "export CH_HOST="+(.host|@sh),"export CH_PORT="+(.port|tostring|@sh)' \ + .deployment/clickhouse-create.json > .deployment/analytics.env +jq -r '"export CH_ADMIN_PASSWORD="+(.password|@sh)' \ + .deployment/clickhouse-create.json >> .deployment/analytics.env +python3 - <<'PY' +import pathlib,secrets,shlex +names=['NOTEBOOK_MIGRATOR_PASSWORD','NOTEBOOK_APP_PASSWORD', + 'NOTEBOOK_REPORT_PASSWORD','CH_REPORT_PASSWORD'] +pathlib.Path('.deployment/passwords.env').write_text(''.join( + 'export '+name+'='+shlex.quote('Aa1!'+secrets.token_hex(30))+'\n' for name in names)) +PY +source .deployment/analytics.env +source .deployment/passwords.env +python3 scripts/seed-analytics.py +python3 scripts/seed-analytics.py +``` + +Seed explicitly truncates and replaces the four-row synthetic fixture, using one synchronous insert with retry deduplication disabled. Repeating it leaves exactly four events. This is a small setup fixture, not a production ingestion pattern; do not run it against retained production data. Runtime has no ClickHouse write credentials. No Query API keys are created by these direct HTTPS commands. + +## Operational migrations and foreign table + +```bash +psql -X -v migrator_password="$NOTEBOOK_MIGRATOR_PASSWORD" \ + -v app_password="$NOTEBOOK_APP_PASSWORD" -v report_password="$NOTEBOOK_REPORT_PASSWORD" \ + -f sql/bootstrap.sql +PGUSER=notebook_migrator PGPASSWORD="$NOTEBOOK_MIGRATOR_PASSWORD" psql -X -f sql/migrate-up.sql +PGUSER=notebook_migrator PGPASSWORD="$NOTEBOOK_MIGRATOR_PASSWORD" psql -X -f sql/seed.sql +psql -X -v ch_host="$CH_HOST" -v ch_password="$CH_REPORT_PASSWORD" -f sql/foreign-table.sql +``` + +Bootstrap is once per empty role/schema fixture. Migrate-up is transactional and repeats as a no-op, guarded by an advisory lock and version table. Migrate-down is `psql -f sql/migrate-down.sql` under the same migrator credentials; it removes profiles/history. Repeated operational seed preserves edits. The NOLOGIN owner is assumed only for schema/seed operations. Administrator-only extension/server/mapping setup grants the report role SELECT on this one foreign table; arbitrary-query helpers remain denied. + +The remote login has only SELECT on the activity table, with readonly 2 so the driver can set query settings. The installed defaults `join_use_nulls 1,group_by_use_nulls 1,final 1,transform_null_in 0` are retained alongside execution/read/result limits. Our plain MergeTree fixture needs no version selection or tombstone logic. The mapped Postgres role can see its own mapping options, including the remote password, through `pg_user_mappings`; credentials are not hidden from that trusted role. The operational role cannot use this schema. + +## Start and use the workbench + +Create a runtime file containing only endpoint/CA fields and the two restricted Postgres passwords: + +```bash +python3 - <<'PY' +import os,pathlib,shlex +names=['PGHOST','PGPORT','PGDATABASE','PGSSLROOTCERT', + 'NOTEBOOK_APP_PASSWORD','NOTEBOOK_REPORT_PASSWORD'] +pathlib.Path('.deployment/app.env').write_text(''.join( + 'export '+name+'='+shlex.quote(os.environ[name])+'\n' for name in names)) +PY +``` + +Open a new terminal that has not sourced setup credentials before loading this file. A nested shell inherits the administrator/migrator/analytical secrets exported during setup. + +```bash +source .deployment/app.env +clojure -Sdeps "$(cat deps-lock.edn)" -M:locked:run +``` + +Open http://127.0.0.1:8080, choose Northstar Labs, edit its note/health, and save. Open the same profile in two tabs before saving to see stale-edit feedback. View September 28–30, 2026 activity: counts 2, 0, 1. Harbor Studio has 0, 1, 0; Fieldcraft Tools has all zeroes. + +The report accepts 1–31 inclusive UTC dates within 2000-01-01..2100-12-31. It binds UUID/LocalDate parameters and projects SQL dates as text before decoding, avoiding process-timezone shifts. Customer list/history are capped at 20 each; note is at most 2,000 UTF-16 units and form body 4 KiB. Unknown fields, long decimal revisions, NUL/controls/unpaired surrogates and malformed percent-encoded UTF-8 are rejected. Hiccup escapes note contents, and forms retain Ring CSRF protection. + +Hikari pools have two operational connections and one reporting connection; acquisition 3 s, connect 5 s and socket 15 s. Role settings are operational statement 5 s / lock 2 s and report statement 12 s / lock 2 s. Remote query execution 10 s / read 20,000 rows / result 31 rows / 64 KiB are bounded separately. These layer limits are not one overall deadline. An idle analytical service may fail a first report while resuming; retry the report, and continue saving notes. For a bounded acceptance window, use `clickhousectl cloud service scale "$CH_ID" --org-id "$CH_ORG_ID" --idle-scaling false`; restore idle scaling or delete promptly afterward. + +## Verify and clean up + +In a separate test shell, explicitly load `.deployment/admin.env`, `.deployment/passwords.env`, `.deployment/analytics.env` and `.deployment/app.env`. Runtime should still use only its original app file. + +```bash +clojure -Sdeps "$(cat deps-lock.edn)" -M:locked:security +clojure -Sdeps "$(cat deps-lock.edn)" -M:locked:plan +python3 checks/cloud.py +python3 -m venv .deployment/browser +.deployment/browser/bin/pip install -r checks/requirements.txt +.deployment/browser/bin/python -m playwright install --with-deps chromium +export EVIDENCE_DIR="$PWD/.deployment/evidence" +.deployment/browser/bin/python checks/browser.py +``` + +`checks/launch.py` starts a runtime-only child and records its PID in EVIDENCE_DIR. Use it instead of the foreground run command if exercising `checks/restart.py`; the helper asserts the original PID is gone before starting another. Its children use Pacific/Auckland to exercise date boundaries. CSRF sessions are in memory and reset on restart; profiles and edit history persist. `:tls-probe` can exercise the same runtime pool factory with an unrelated valid PEM (`EXPECTED_TLS_FAILURE=ca`) or the same endpoint's IP (`EXPECTED_TLS_FAILURE=hostname`) while retaining verify-full. + +Verified live controls: two independent HTTP editors 303/409, rollback after profile UPDATE when append fails, retained escaped stale form, actual Chromium CSRF 403, invalid input 400 / body 413, exact three-customer app/direct report equality including empty days, remote permission loss with successful operational edit and report recovery, actual process restart, real Hikari role/settings/TLS controls and remote INSERT denial. EXPLAIN of the exact bound report pushed projection cast, customer/date filters, aggregation, sort and limit to ClickHouse. No scale/performance claim follows from this tiny fixture. + +Stop the app first. If retaining services, any schema cleanup must use administrator credentials explicitly (`source .deployment/admin.env`), then drop only this app's server/schemas/roles. Delete your owned test resources when finished: + +```bash +clickhousectl cloud postgres delete "$PG_ID" --org-id "$CH_ORG_ID" +clickhousectl cloud service delete "$CH_ID" --org-id "$CH_ORG_ID" --force +clickhousectl cloud postgres list --org-id "$CH_ORG_ID" --json +clickhousectl cloud service list --org-id "$CH_ORG_ID" --json +``` + +Confirm both IDs are absent. Analytical `--force` stops/polls/deletes a running service; Postgres delete has no force flag. Preserve source/evidence and remove private credentials from retained public copies. + +Primary references: [next.jdbc transactions](https://github.com/seancorfield/next-jdbc/blob/develop/doc/transactions.md), [pgJDBC TLS](https://jdbc.postgresql.org/documentation/ssl/), [pg_clickhouse 0.10.0 reference](https://github.com/ClickHouse/pg_clickhouse/blob/v0.10.0/doc/pg_clickhouse.md), [Managed Postgres overview](https://clickhouse.com/docs/products/managed-postgres/overview). diff --git a/applications/customer-notebook/checks/browser.py b/applications/customer-notebook/checks/browser.py new file mode 100644 index 00000000..9cdaf170 --- /dev/null +++ b/applications/customer-notebook/checks/browser.py @@ -0,0 +1,55 @@ +import os +import pathlib +from playwright.sync_api import sync_playwright, expect + +URL = "http://127.0.0.1:8080/customers/00000000-0000-0000-0000-000000000001" + +with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + context = browser.new_context(viewport={"width": 1000, "height": 1000}) + first, second = context.new_page(), context.new_page() + first.goto(URL) + second.goto(URL) + seen = second.locator('input[name="revision"]').input_value() + assert first.locator('input[name="revision"]').input_value() == seen + first.locator("#note").fill("Browser first editor saved") + with first.expect_response(lambda response: response.request.method == "POST") as response: + first.get_by_role("button", name="Save note", exact=True).click() + assert response.value.status == 303 + first.wait_for_url(URL) + expect(first.locator("#note")).to_have_value("Browser first editor saved") + + second.locator("#note").fill("Browser retained note ") + second.locator("#health").select_option("risk") + with second.expect_response(lambda response: response.request.method == "POST") as response: + second.get_by_role("button", name="Save note", exact=True).click() + assert response.value.status == 409 + expect(second.get_by_role("alert")).to_contain_text("This profile changed") + expect(second.locator("#note")).to_have_value("Browser retained note ") + assert second.locator('input[name="revision"]').input_value() == seen + assert second.locator("script").count() == 0 + evidence = pathlib.Path(os.environ["EVIDENCE_DIR"]) + evidence.mkdir(parents=True, exist_ok=True) + second.screenshot(path=str(evidence / "stale-form.png"), full_page=True) + print("Actual Chromium two-editor workflow303/409; visible retained note/health/original revision; output escaped.") + + with first.expect_response(lambda response: response.request.method == "POST") as response: + first.evaluate("""() => { + document.querySelector('input[name="__anti-forgery-token"]').remove(); + document.querySelector('form[method="post"]').requestSubmit(); + }""") + assert response.value.status == 403 + expect(first.get_by_text("Session check failed. Reload the form before saving.", exact=True)).to_be_visible() + first.wait_for_load_state("load") + print("Actual browser request without native Ring CSRF token rejected403.") + + first.goto(URL) + assert first.locator('input[name="from"]').get_attribute("min") == "2000-01-01" + assert first.locator('input[name="to"]').get_attribute("max") == "2100-12-31" + first.get_by_role("button", name="View activity (up to 31 days)", exact=True).click() + expect(first.get_by_role("heading", name="Synthetic daily activity")).to_be_visible() + assert first.locator("tbody tr").count() == 3 + expect(first.locator("tbody tr").nth(1)).to_have_text("2026-09-290") + first.screenshot(path=str(evidence / "activity.png"), full_page=True) + print("Actual browser activity panel:three UTC dates, exact empty day0, date picker bounds2000..2100.") + browser.close() diff --git a/applications/customer-notebook/checks/cloud.py b/applications/customer-notebook/checks/cloud.py new file mode 100644 index 00000000..82801a52 --- /dev/null +++ b/applications/customer-notebook/checks/cloud.py @@ -0,0 +1,208 @@ +"""Native live fixture controls. Load separate setup/test credentials, never runtime. + +Uses only Python's standard library; SQL controls run through native psql. +""" +import concurrent.futures +import html.parser +import http.cookiejar +import json +import os +import pathlib +import ssl +import subprocess +import threading +import urllib.error +import urllib.parse +import urllib.request + +BASE = "http://127.0.0.1:8080" +CUSTOMERS = [f"00000000-0000-0000-0000-{n:012d}" for n in (1, 2, 3)] + + +def sql(statement, role=None, expect=None): + environment = dict(os.environ) + if role: + environment.update(PGUSER=role, PGPASSWORD=os.environ[ + {"notebook_app": "NOTEBOOK_APP_PASSWORD", + "notebook_report": "NOTEBOOK_REPORT_PASSWORD"}[role]]) + result = subprocess.run(["psql", "-X", "-At", "-v", "ON_ERROR_STOP=1", + "--set", "VERBOSITY=verbose"], + input=statement, text=True, capture_output=True, + env=environment, timeout=20) + if expect: + assert result.returncode != 0 and expect in result.stderr, result.stderr + print("SQL negative control", role, expect) + else: + assert result.returncode == 0, result.stderr + return result.stdout.strip() + + +def direct(query, user="default", password=None): + endpoint = f"https://{os.environ['CH_HOST']}:{os.environ['CH_PORT']}/" + endpoint += "?max_execution_time=10&max_rows_to_read=20000&max_result_rows=1000" + request = urllib.request.Request(endpoint, data=query.encode(), headers={ + "X-ClickHouse-User": user, + "X-ClickHouse-Key": password or os.environ["CH_ADMIN_PASSWORD"]}) + with urllib.request.urlopen(request, context=ssl.create_default_context(), timeout=15) as response: + return response.read().decode() + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + + +class Document(html.parser.HTMLParser): + def __init__(self, source): + super().__init__() + self.inputs, self.cells = {}, [] + self.in_cell = False + self.cell = "" + self.feed(source) + + def handle_starttag(self, tag, attributes): + attributes = dict(attributes) + if tag == "input" and "name" in attributes: + self.inputs[attributes["name"]] = attributes.get("value", "") + if tag == "td": + self.in_cell, self.cell = True, "" + + def handle_data(self, value): + if self.in_cell: + self.cell += value + + def handle_endtag(self, tag): + if tag == "td": + self.cells.append(self.cell) + self.in_cell = False + + +class Session: + def __init__(self): + self.client = urllib.request.build_opener( + NoRedirect(), urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())) + + def request(self, path, fields=None, raw=None): + data = urllib.parse.urlencode(fields).encode() if fields is not None else raw + request = urllib.request.Request(BASE + path, data=data) + if data is not None: + request.add_header("Content-Type", "application/x-www-form-urlencoded") + try: + response = self.client.open(request, timeout=20) + except urllib.error.HTTPError as error: + response = error + return response.status, response.read().decode() + + def form(self, customer): + status, source = self.request("/customers/" + customer) + assert status == 200 + values = Document(source).inputs + return {key: values[key] for key in ("revision", "__anti-forgery-token")} + + def save(self, customer, form, note, health="watch"): + return self.request("/customers/" + customer, + {**form, "note": note, "health": health}) + + +def snapshot(customer): + return json.loads(sql(f"SELECT json_build_object('revision',revision,'note',note," + f"'edits',(SELECT count(*) FROM notebook.customer_edits WHERE customer_id='{customer}')) " + f"FROM notebook.customers WHERE id='{customer}';")) + + +def main(): + customer = CUSTOMERS[0] + first, second = Session(), Session() + before = snapshot(customer) + forms = [first.form(customer), second.form(customer)] + assert forms[0]["revision"] == forms[1]["revision"] == str(before["revision"]) + barrier = threading.Barrier(2) + notes = ["Editor A ", "Editor B "] + + def save(index): + barrier.wait() + return [first, second][index].save(customer, forms[index], notes[index]) + + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: + outcomes = list(executor.map(save, (0, 1))) + assert sorted(status for status, _ in outcomes) == [303, 409], outcomes + loser = next(i for i, outcome in enumerate(outcomes) if outcome[0] == 409) + conflict = outcomes[loser][1] + assert Document(conflict).inputs["revision"] == forms[loser]["revision"] + assert notes[loser].replace("<", "<").replace(">", ">") in conflict + assert "" :health "risk" :revision 7} true)] + (is (.contains html "<script>alert(1)</script>")) + (is (.contains html "value=\"7\"")) + (is (.contains html "Current revision 8")) + (is (not (.contains html "