Side B removes substantial duplicated OAuth/HTTP/test-harness code across four test files by consolidating into shared test.common and test.oauth modules, reducing maintenance burden and duplication risk with no behavior change. Side A is a reasonable UI refinement (grouped CLI panel, JS-safety assert) but is smaller in scope and purely cosmetic/feature-polish rather than structural improvement.
constitution · epochs · watch · epoch 3
c_ca72f0995396 (tommy-mor) vs c_48fcbcde8f88 (tommy-mor)
download prompt · raw event · cmp_32737fe248d163
council reasoning
B removes large duplicated blocks (HTTP clients, mock Google OAuth, assert/pass-fail harness, server env, cargo build) from auth.bb/grants.bb/integration.bb into shared test.common and test.oauth (with complete-registration! and multi-user mock), lasting maintainability win. A improves production cli_panel UX (grouped cmds, row click-to-copy) plus JS-safety asserts and CSS, but is narrower polish versus B's cross-suite foundation.
Side A changes the HTML API from a single command to a list, consolidates multiple CLI hints into one panel, adds click-to-copy rows, and introduces runtime assertions preventing unsafe characters from being embedded in single-quoted JavaScript, improving both usability and correctness. Side B is a valuable refactor that centralizes duplicated test utilities (OAuth helpers, build helpers, assertions, shared server environment) and adds request timeouts, but it primarily reduces maintenance overhead in the test suite rather than changing core project behavior.
sides
A — c_ca72f0995396 (tommy-mor)
message
[798c764d] feat(html): grouped cli_panel with hover-to-copy and JS-safe asserts Single bordered panel for multiple commands; rows copy on click without a separate copy control. Assert CLI strings contain no chars that would break single-quoted onclick JS. Made-with: Cursor
diff preview
diff --git a/server/src/html/forum.rs b/server/src/html/forum.rs
index f6e45b05bb92126966e304619f80ef1d45be7a4b..075860914a6ce18bb0dfa73dc5651ef1a6318b67 100644
--- a/server/src/html/forum.rs
+++ b/server/src/html/forum.rs
@@ -718,7 +718,7 @@ pub async fn home(
}
div id="public-new-thread-ui-slot" {}
(render_thread_feed(Some(&nav), "thread-feed", &public_rows, now))
- (cli_panel("npx slugsocial public forum list"))
+ (cli_panel(&["npx slugsocial public forum list"]))
},
None,
theme_from_jar(&jar),
@@ -868,7 +868,7 @@ async fn thread_view_inner(
div id="thread-live-region" {
(compose_form(&nav, &tag, show_compose))
}
- (cli_panel(&cli))
+ (cli_panel(std::slice::from_ref(&cli)))
},
None,
theme_from_jar(&jar),
@@ -984,9 +984,7 @@ pub async fn room_page(
(new_thread_form_for_room(&nav, true, false))
}
}
- (cli_panel(&forum_cli))
- (cli_panel(&garden_cli))
- (cli_panel(&audit_cli))
+ (cli_panel(&[forum_cli, garden_cli, audit_cli]))
},
None,
theme_from_jar(&jar),
@@ -1409,7 +1407,7 @@ pub async fn user_profile_page(
}
}
}
- (cli_panel(&format!("npx slugsocial public forum list")))
+ (cli_panel(&[format!("npx slugsocial public forum list")]))
},
None,
theme_from_jar(&jar),
diff --git a/server/src/html/garden.rs b/server/src/html/garden.rs
index 9b4789a79c3e293cbfc5f033a0eac8650320d94d..c8ce7de450f7d14e04020511e7eb7323bd487deb 100644
--- a/server/src/html/garden.rs
+++ b/server/src/html/garden.rs
@@ -139,7 +139,7 @@ pub async fn garden_index(
}
}
}
- (cli_panel("npx slugsocial garden tree"))
+ (cli_panel(&["npx slugsocial garden tree"]))
},
None,
theme_from_jar(&jar),
@@ -542,7 +542,7 @@ async fn render_scope_view(
ScopeId::Public => format!("npx slugsocial public garden body {}", path.as_str().trim_start_matches("https://slug.social/~/")),
ScopeId::Room(room_id) => format!("npx slugsocial private {room_id} garden body {}", path.as_str().trim_start_matches("https://slug.social/~/")),
};
- (cli_panel(&cli))
+ (cli_panel(std::slice::from_ref(&cli)))
},
None,
theme_from_jar(&jar),
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index e7f5bfb7a4b2dee2adf96447224c58d641092124..6617781a2e8e86c2e2693788ea7cd0eb0e3659a2 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -599,18 +599,38 @@ pub(super) fn render_linkified_with_embeds_in_scope(raw: &str, garden_prefix: &s
}
}
-/// Small CLI hint panel showing how to look up this page from the terminal.
-pub(super) fn cli_panel(cmd: &str) -> Markup {
+/// CLI strings are embedded in a single-quoted JS literal; they must never need escaping.
+fn assert_cli_panel_cmd_js_single_quote_safe(s: &str) {
+ assert!(
+ !s.contains('\\')
+ && !s.contains('\'')
+ && !s.contains('\n')
+ && !s.contains('\r'),
+ "cli_panel cmd must not contain `\\`, `'`, or newlines (got {s:?})"
+ );
+}
+
+/// Small CLI hint panel: one border and title; each line is hover-highlighted and copies on click.
+pub(super) fn cli_panel<I: AsRef<str>>(cmds: &[I]) -> Markup {
+ if cmds.is_empty() {
+ return html! {};
+ }
+ for cmd in cmds {
+ assert_cli_panel_cmd_js_single_quote_safe(cmd.as_ref());
+ }
html! {
div class="cli-panel" {
span class="cli-panel-label muted" { "cli" }
- code class="cli-panel-cmd" { (cmd) }
- button
- class="cli-panel-copy"
- title="Copy to clipboard"
- onclick=(format!(r#"navigator.clipboard.writeText('{}'); this.textContent='✓'; setTimeout(() => this.textContent='copy', 2000);"#, cmd.replace("'", "\\'")))
- {
- "copy"
+ div class="cli-panel-cmds" {
+ @for cmd in cmds {
+ @let s = cmd.as_ref();
+ button type="button" class="cli-panel-row" title="Copy command" onclick=(format!(
+ r#"navigator.clipboard.writeText('{}');"#,
+ s
+ )) {
+ code class="cli-panel-cmd" { (s) }
+ }
+ }
}
}
}
diff --git a/server/src/html/search.rs b/server/src/html/search.rs
index 28ceaa53c3fcac6777311535e95fb771b19438f5..43e6ebf36cf0fe72c96f0f9d850bea51ac094c43 100644
--- a/server/src/html/search.rs
+++ b/server/src/html/search.rs
@@ -418,7 +418,7 @@ pub async fn search_page(
value=(query) autocomplete="off" autofocus;
}
(render_search_results(&results, &query))
- (cli_panel("npx slugsocial search <query>"))
+ (cli_panel(&["npx slugsocial search <query>"]))
},
None,
theme_from_jar(&jar),
diff --git a/server/static/theme_default.css b/server/static/theme_default.css
index e024a5c8b74139ae8be37b2a1bd17e4c3abe9324..764b66c8208e91e6138b83c534387cf43c85b8b5 100644
--- a/server/static/theme_default.css
+++ b/server/static/theme_default.css
@@ -610,7 +610,7 @@ code {
CLI PANEL — how to view this page from the terminal
---------------------------------------------------------------- */
div.cli-panel {
- align-items: baseline;
+ align-items: flex-start;
background: var(--g1);
border: var(--bv) solid;
border-color: var(--lo) var(--hi) var(--hi) var(--lo); /* inset */
@@ -621,11 +621,34 @@ div.cli-panel {
width: fit-content;
max-width: 100%;
}
+.cli-panel-cmds {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ flex: 1;
+ min-width: 0;
+}
+button.cli-panel-row {
+ background: transparent;
+ border: none;
+ color: inherit;
+ cursor: pointer;
+ display: block;
+ font: inherit;
+ margin: 0;
+ padding: 2px 4px;
+ text-align: left;
+ width: 100%;
+}
+button.cli-panel-row:hover {
+ background: var(--g3);
+}
.cli-panel-label {
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
flex-shrink: 0;
+ padding-top: 2px;
}
.cli-panel-cmd {
background: none;
diff --git a/server/static/theme_retro_craft.css b/server/static/theme_retro_craft.css
index 00714575bfa533d1d9c66653b9089642e2b0a6ca..f89ecbc3a18eb9b2b27d1f7764330f6bd6987552 100644
--- a/server/static/theme_retro_craft.css
+++ b/server/static/theme_retro_craft.css
@@ -306,19 +306,41 @@ a.post-nav-btn:hover {
}
div.cli-panel {
- align-items: baseline;
+ align-items: flex-start;
border: 1px dashed var(--line);
display: flex;
- flex-wrap: wrap;
gap: 0.5rem;
margin: 0.65rem 0;
padding: 0.45rem 0.65rem;
}
+.cli-panel-cmds {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ flex: 1;
+ min-width: 0;
+}
+button.cli-panel-row {
+ background: transparent;
+ border: none;
+ color: inherit;
+ cursor: pointer;
+ display: block;
+ font: inherit;
+ margin: 0;
+ padding: 0.1rem 0.2rem;
+ text-align: left;
+ width: 100%;
+}
+button.cli-panel-row:hover {
+ background: color-mix(in srgb, var(--accent) 14%, transparent);
+}
.cli-panel-label {
color: var(--ink-dim);
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
+ padding-top: 0.12rem;
}
.cli-panel-cmd {
color: var(--accent);
B — c_48fcbcde8f88 (tommy-mor)
message
[b32c92f2] consolidated shared functions in tests (composer 2 fast)
diff preview
diff --git a/test/auth.bb b/test/auth.bb
index a67cc1de763434176d2f68e419dd37a8cd328395..78cffc875ffe83f512bca253f4a7489227311da4 100644
--- a/test/auth.bb
+++ b/test/auth.bb
@@ -3,113 +3,14 @@
(:require [babashka.process :as p]
[babashka.fs :as fs]
[cheshire.core :as json]
- [org.httpkit.server :as http]
- [test.common :as common]))
+ [clojure.string :as str]
+ [test.common :as common]
+ [test.oauth :as oauth]))
-(def ^:private ansi-green "\033[32m")
-(def ^:private ansi-red "\033[31m")
-(def ^:private ansi-reset "\033[0m")
(def ^:private counts (atom {:pass 0 :fail 0}))
-(defn- pass [msg]
- (swap! counts update :pass inc)
- (println (str ansi-green " ✓ " ansi-reset msg)))
-
-(defn- fail [msg]
- (swap! counts update :fail inc)
- (println (str ansi-red " ✗ " ansi-reset msg)))
-
(defn- assert! [pred msg]
- (if pred
- (pass msg)
- (do (fail msg)
- (throw (ex-info (str "FAIL: " msg) {})))))
-
-(defn- http-client []
- (-> (java.net.http.HttpClient/newBuilder)
- (.followRedirects java.net.http.HttpClient$Redirect/ALWAYS)
- (.build)))
-
-(defn- http-get [url & {:keys [headers]}]
- (let [b (java.net.http.HttpRequest/newBuilder (java.net.URI/create url))]
- (doseq [[k v] (or headers {})]
- (.header b k v))
- (let [req (-> b (.GET) (.build))
- resp (.send (http-client) req (java.net.http.HttpResponse$BodyHandlers/ofString))]
- {:status (.statusCode resp) :body (.body resp) :headers (.map (.headers resp))})))
-
-(defn- http-post-json [url data & {:keys [headers]}]
- (let [body (json/generate-string data)
- b (java.net.http.HttpRequest/newBuilder (java.net.URI/create url))]
- (.header b "Content-Type" "application/json")
- (doseq [[k v] (or headers {})]
- (.header b k v))
- (let [req (-> b
- (.POST (java.net.http.HttpRequest$BodyPublishers/ofString body))
- (.build))
- resp (.send (http-client) req (java.net.http.HttpResponse$BodyHandlers/ofString))]
- {:status (.statusCode resp) :body (.body resp) :headers (.map (.headers resp))})))
-
-(defn- http-post-form [url form]
- (let [pairs (->> form
- (map (fn [[k v]]
- (str (java.net.URLEncoder/encode (name k) "UTF-8")
- "="
- (java.net.URLEncoder/encode (str v) "UTF-8"))))
- (clojure.string/join "&"))
- b (java.net.http.HttpRequest/newBuilder (java.net.URI/create url))]
- (.header b "Content-Type" "application/x-www-form-urlencoded")
- (let [req (-> b
- (.POST (java.net.http.HttpRequest$BodyPublishers/ofString pairs))
- (.build))
- resp (.send (http-client) req (java.net.http.HttpResponse$BodyHandlers/ofString))]
- {:status (.statusCode resp) :body (.body resp) :headers (.map (.headers resp))})))
-
-(defn- parse-query [s]
- (into {}
- (for [part (clojure.string/split (or s "") #"&")
- :when (not (clojure.string/blank? part))]
- (let [[k v] (clojure.string/split part #"=" 2)]
- [(keyword (java.net.URLDecoder/decode k "UTF-8"))
- (some-> v (java.net.URLDecoder/decode "UTF-8"))]))))
-
-(defn- b64url [s]
- (-> (java.util.Base64/getUrlEncoder)
- (.withoutPadding)
- (.encodeToString (.getBytes s "UTF-8"))))
-
-(defn- make-id-token [sub]
- (str (b64url "{\"alg\":\"RS256\",\"typ\":\"JWT\"}")
- "."
- (b64url (json/generate-string {:sub sub}))
- ".fakesig"))
-
-(defn- start-mock-google [port]
- (let [google-users ["google-user-1" "google-user-2"]
- !call-count (atom 0)
- handler
- (fn [req]
- (cond
- (and (= :get (:request-method req))
- (= "/o/oauth2/v2/auth" (:uri req)))
- (let [q (parse-query (:query-string req))
- redirect-uri (:redirect_uri q)
- state (:state q)
- loc (str redirect-uri "?code=mockcode&state=" state)]
- {:status 302 :headers {"Location" loc} :body ""})
-
- (and (= :post (:request-method req))
- (= "/token" (:uri req)))
- (let [n (swap! !call-count inc)
- sub (nth google-users (mod (dec n) (count google-users)))]
- {:status 200
- :headers {"Content-Type" "application/json"}
- :body (json/generate-string {:id_token (make-id-token sub)})})
-
- :else
- {:status 404 :body "not found"}))
- stop-fn (http/run-server handler {:port port})]
- {:stop-fn stop-fn :port port}))
+ (common/test-assert! counts pred msg))
(defn auth-test [& _args]
(println "\n━━━ auth v3 integration check ━━━\n")
@@ -117,8 +18,7 @@
(println "building server + CLI binaries…")
(common/letlocals
- (bind build @(p/process [(common/cargo-bin) "build" "--release" "-p" "slugsocial-server" "-p" "slugsocial"]
- {:inherit true :env common/base-env}))
+ (bind build (common/run-cargo-build-release! ["slugsocial-server" "slugsocial"]))
(assert! (zero? (:exit build)) "cargo build succeeds")
(bind server-bin "target/release/slugsocial-server")
(bind cli-bin "target/release/slugsocial")
@@ -134,19 +34,10 @@
(bind !server (atom nil))
(bind !google (atom nil))
- (bind server-env (merge common/base-env
- {"SLUG_DATA_DIR" tmp-dir
- "SLUG_KEYS" "test:test"
- "PORT" (str slug-port)
- "RUST_LOG" "warn"
- "SLUG_PUBLIC_URL" base-url
- "SLUG_GOOGLE_AUTH_URL" (str google-url "/o/oauth2/v2/auth")
- "SLUG_GOOGLE_TOKEN_URL" (str google-url "/token")
- "SLUG_GOOGLE_CLIENT_ID" "mock"
- "SLUG_GOOGLE_CLIENT_SECRET" "mock"}))
+ (bind server-env (common/slug-server-env tmp-dir base-url google-url slug-port))
(try
(println (str "\nstarting mock google on :" google-port))
- (reset! !google (start-mock-google google-port))
+ (reset! !google (oauth/start-mock-google google-port :google-users ["google-user-1" "google-user-2"]))
(assert! (some? (:stop-fn @!google)) "mock google started")
(println (str "starting server on :" slug-port))
@@ -154,34 +45,33 @@
(assert! (common/wait-for-server base-url 10000) "server responds to /healthz")
(println "\nstarting pending session…")
- (let [start-resp (http-post-json (str base-url "/api/v0/pending-session")
- {:agent "00000000-0000-0000-0000-000000000000:bb:local/dev"})
+ (let [start-resp (oauth/http-post-json (str base-url "/api/v0/pending-session")
+ {:agent "00000000-0000-0000-0000-000000000000:bb:local/dev"})
_ (assert! (= 200 (:status start-resp)) "pending-session start returns 200")
start-json (json/parse-string (:body start-resp) true)]
- (assert! (clojure.string/starts-with? (:session start-json) "p_") "session id has p_ prefix")
- (assert! (clojure.string/includes? (:login_url start-json) "/auth/login") "login_url provided")
+ (assert! (str/starts-with? (:session start-json) "p_") "session id has p_ prefix")
+ (assert! (str/includes? (:login_url start-json) "/auth/login") "login_url provided")
(println "\nsimulating browser oauth redirects…")
- ;; This will follow redirects: /auth/login -> mock google -> /auth/callback -> /auth/choose-username
- (let [login-get (http-get (:login_url start-json))]
+ (let [login-get (oauth/http-get (:login_url start-json))]
(assert! (= 200 (:status login-get)) "choose-username page reachable after oauth callback"))
(println "\nchoosing username…")
- (let [choose (http-post-form (str base-url "/auth/choose-username")
- {:session (:session start-json) :username "bbuser"})]
+ (let [choose (oauth/http-post-form (str base-url "/auth/choose-username")
+ {:session (:session start-json) :username "bbuser"})]
(assert! (= 200 (:status choose)) "choose-username POST returns 200"))
(println "\npolling pending session…")
- (let [poll (http-get (str base-url "/api/v0/pending-session/" (:session start-json)))]
+ (let [poll (oauth/http-get (str base-url "/api/v0/pending-session/" (:session start-json)))]
(assert! (= 200 (:status poll)) "pending-session poll returns 200")
(let [poll-json (json/parse-string (:body poll) true)]
(assert! (:complete poll-json) "pending session complete=true")
(assert! (= "bbuser" (:user poll-json)) "poll returns stored username bbuser")
- (assert! (clojure.string/starts-with? (:token poll-json) "slug_") "poll returns bearer token")
+ (assert! (str/starts-with? (:token poll-json) "slug_") "poll returns bearer token")
(println "\nwhoami…")
- (let [who (http-get (str base-url "/api/v0/whoami")
- :headers {"Authorization" (str "Bearer " (:token poll-json))})]
+ (let [who (oauth/http-get (str base-url "/api/v0/whoami")
+ :headers {"Authorization" (str "Bearer " (:token poll-json))})]
(assert! (= 200 (:status who)) "whoami returns 200")
(let [who-json (json/parse-string (:body who) true)]
(assert! (= "bbuser" (:user who-json)) "whoami user is bbuser (stored form)"))))))
@@ -195,11 +85,11 @@
(str "identity start exits 0 (stderr: " (:err start-proc) ")"))
(let [start-cli (json/parse-string (:out start-proc) true)]
(assert! (= "present_oauth_url_to_user" (:phase start-cli)) "identity start --json phase")
- (assert! (clojure.string/starts-with? (:session start-cli) "p_") "CLI start session id")
- (let [login-get (http-get (:login_url start-cli))]
+ (assert! (str/starts-with? (:session start-cli) "p_") "CLI start session id")
+ (let [login-get (oauth/http-get (:login_url start-cli))]
(assert! (= 200 (:status login-get)) "CLI login_url redirect chain succeeds"))
- (let [choose (http-post-form (str base-url "/auth/choose-username")
- {:session (:session start-cli) :username "cliuser"})]
+ (let [choose (oauth/http-post-form (str base-url "/auth/choose-username")
+ {:session (:session start-cli) :username "cliuser"})]
(assert! (= 200 (:status choose)) "choose-username for cliuser"))
(let [poll-proc @(p/process [cli-bin "identity" "poll" (:session start-cli)
"--poll-interval-ms" "100" "--max-wait-secs" "30" "--json"]
@@ -209,10 +99,10 @@
(let [poll-cli (json/parse-string (:out poll-proc) true)]
(assert! (= "complete" (:phase poll-cli)) "identity poll --json phase")
(assert! (= "cliuser" (:user poll-cli)) "CLI poll user (stored form)")
- (assert! (clojure.string/starts-with? (:token poll-cli) "slug_") "CLI poll token")
+ (assert! (str/starts-with? (:token poll-cli) "slug_") "CLI poll token")
(let [token-path (str cli-home "/.config/slugsocial/token")]
(assert! (fs/exists? token-path) "token written under isolated HOME")
- (assert! (= (clojure.string/trim (slurp token-path)) (:token poll-cli))
+ (assert! (= (str/trim (slurp token-path)) (:token poll-cli))
"token file matches poll JSON"))
… preview truncated; 24,704 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.