mirror of
https://github.com/logseq/logseq.git
synced 2026-06-01 19:01:22 +00:00
055-logseq-cli-login-logout.md
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
[frontend.state :as state]
|
||||
[frontend.util :as util]
|
||||
[goog.crypt.Md5]
|
||||
[logseq.common.cognito-config :as cognito-config]
|
||||
[logseq.common.config :as common-config]
|
||||
[logseq.common.path :as path]
|
||||
[logseq.db.sqlite.util :as sqlite-util]
|
||||
@@ -19,33 +20,27 @@
|
||||
(goog-define REVISION "unknown")
|
||||
(defonce revision REVISION)
|
||||
|
||||
(goog-define ENABLE-FILE-SYNC-PRODUCTION false)
|
||||
|
||||
;; this is a feature flag to enable the account tab
|
||||
;; when it launches (when pro plan launches) it should be removed
|
||||
(def ENABLE-SETTINGS-ACCOUNT-TAB false)
|
||||
|
||||
(if ENABLE-FILE-SYNC-PRODUCTION
|
||||
(do (def LOGIN-URL
|
||||
"https://logseq-prod.auth.us-east-1.amazoncognito.com/login?client_id=3c7np6bjtb4r1k1bi9i049ops5&response_type=code&scope=email+openid+phone&redirect_uri=logseq%3A%2F%2Fauth-callback")
|
||||
(def API-DOMAIN "api.logseq.com")
|
||||
(def LOGIN-URL cognito-config/LOGIN-URL)
|
||||
(def COGNITO-CLIENT-ID cognito-config/COGNITO-CLIENT-ID)
|
||||
(def OAUTH-DOMAIN cognito-config/OAUTH-DOMAIN)
|
||||
|
||||
(if cognito-config/ENABLE-FILE-SYNC-PRODUCTION
|
||||
(do (def API-DOMAIN "api.logseq.com")
|
||||
(def COGNITO-IDP "https://cognito-idp.us-east-1.amazonaws.com/")
|
||||
(def COGNITO-CLIENT-ID "69cs1lgme7p8kbgld8n5kseii6")
|
||||
(def REGION "us-east-1")
|
||||
(def USER-POOL-ID "us-east-1_dtagLnju8")
|
||||
(def IDENTITY-POOL-ID "us-east-1:d6d3b034-1631-402b-b838-b44513e93ee0")
|
||||
(def OAUTH-DOMAIN "logseq-prod.auth.us-east-1.amazoncognito.com")
|
||||
(def PUBLISH-API-BASE "https://logseq.io"))
|
||||
|
||||
(do (def LOGIN-URL
|
||||
"https://logseq-test2.auth.us-east-2.amazoncognito.com/login?client_id=3ji1a0059hspovjq5fhed3uil8&response_type=code&scope=email+openid+phone&redirect_uri=logseq%3A%2F%2Fauth-callback")
|
||||
(def API-DOMAIN "api-dev.logseq.com")
|
||||
(do (def API-DOMAIN "api-dev.logseq.com")
|
||||
(def COGNITO-IDP "https://cognito-idp.us-east-2.amazonaws.com/")
|
||||
(def COGNITO-CLIENT-ID "1qi1uijg8b6ra70nejvbptis0q")
|
||||
(def REGION "us-east-2")
|
||||
(def USER-POOL-ID "us-east-2_kAqZcxIeM")
|
||||
(def IDENTITY-POOL-ID "us-east-2:cc7d2ad3-84d0-4faf-98fe-628f6b52c0a5")
|
||||
(def OAUTH-DOMAIN "logseq-test2.auth.us-east-2.amazoncognito.com")
|
||||
(def PUBLISH-API-BASE "https://logseq-publish-staging.logseq.workers.dev")))
|
||||
|
||||
;; Enable for local development
|
||||
|
||||
466
src/main/logseq/cli/auth.cljs
Normal file
466
src/main/logseq/cli/auth.cljs
Normal file
@@ -0,0 +1,466 @@
|
||||
(ns logseq.cli.auth
|
||||
"CLI auth helpers for persisted login state."
|
||||
(:require [clojure.string :as string]
|
||||
[logseq.cli.transport :as transport]
|
||||
[logseq.common.cognito-config :as cognito-config]
|
||||
[promesa.core :as p]
|
||||
["child_process" :as child-process]
|
||||
["crypto" :as crypto]
|
||||
["fs" :as fs]
|
||||
["http" :as http]
|
||||
["os" :as os]
|
||||
["path" :as node-path]))
|
||||
|
||||
(def ^:private default-login-timeout-ms 300000)
|
||||
(def ^:private default-logout-timeout-ms 120000)
|
||||
(def ^:private redirect-path "/auth/callback")
|
||||
(def ^:private logout-complete-path "/logout-complete")
|
||||
(def ^:private callback-host "localhost")
|
||||
(def ^:private callback-port 8765)
|
||||
(def ^:private auth-provider "cognito")
|
||||
(def ^:private default-scope "email openid phone")
|
||||
(def ^:private token-endpoint-path "/oauth2/token")
|
||||
(def ^:private authorize-endpoint-path "/oauth2/authorize")
|
||||
(def ^:private logout-endpoint-path "/logout")
|
||||
|
||||
(defn default-auth-path
|
||||
[]
|
||||
(node-path/join (.homedir os) "logseq" "auth.json"))
|
||||
|
||||
(defn auth-path
|
||||
[{custom-auth-path :auth-path}]
|
||||
(or custom-auth-path (default-auth-path)))
|
||||
|
||||
(defn- ensure-auth-dir!
|
||||
[path]
|
||||
(let [dir (node-path/dirname path)]
|
||||
(when (and (seq dir) (not (fs/existsSync dir)))
|
||||
(.mkdirSync fs dir #js {:recursive true}))))
|
||||
|
||||
(defn- try-chmod!
|
||||
[path]
|
||||
(try
|
||||
(.chmodSync fs path 384)
|
||||
(catch :default _
|
||||
nil)))
|
||||
|
||||
(defn- parse-json
|
||||
[text]
|
||||
(js->clj (js/JSON.parse text) :keywordize-keys true))
|
||||
|
||||
(defn- login-url
|
||||
[]
|
||||
(js/URL. cognito-config/LOGIN-URL))
|
||||
|
||||
(defn- oauth-client-id
|
||||
[]
|
||||
cognito-config/CLI-COGNITO-CLIENT-ID)
|
||||
|
||||
(defn- oauth-scope
|
||||
[]
|
||||
(or (.get (.-searchParams (login-url)) "scope")
|
||||
cognito-config/OAUTH-SCOPE
|
||||
default-scope))
|
||||
|
||||
(defn- oauth-domain
|
||||
[]
|
||||
cognito-config/OAUTH-DOMAIN)
|
||||
|
||||
(defn- logout-complete-uri
|
||||
[]
|
||||
(str "http://" callback-host ":" callback-port logout-complete-path))
|
||||
|
||||
(defn- token-endpoint
|
||||
[]
|
||||
(str "https://" (oauth-domain) token-endpoint-path))
|
||||
|
||||
(defn- authorize-endpoint
|
||||
[]
|
||||
(str "https://" (oauth-domain) authorize-endpoint-path))
|
||||
|
||||
(defn- logout-endpoint
|
||||
[]
|
||||
(str "https://" (oauth-domain) logout-endpoint-path))
|
||||
|
||||
(defn- build-logout-url
|
||||
[]
|
||||
(let [params (doto (js/URLSearchParams.)
|
||||
(.set "client_id" (oauth-client-id))
|
||||
(.set "logout_uri" (logout-complete-uri)))]
|
||||
(str (logout-endpoint) "?" (.toString params))))
|
||||
|
||||
(defn- parse-jwt
|
||||
[jwt]
|
||||
(when (seq jwt)
|
||||
(try
|
||||
(let [parts (string/split jwt #"\.")
|
||||
payload (second parts)]
|
||||
(when (seq payload)
|
||||
(-> (js/Buffer.from payload "base64url")
|
||||
(.toString "utf8")
|
||||
parse-json)))
|
||||
(catch :default _
|
||||
nil))))
|
||||
|
||||
(defn write-auth-file!
|
||||
[opts auth-data]
|
||||
(let [path (auth-path opts)
|
||||
payload (js/JSON.stringify (clj->js auth-data) nil 2)]
|
||||
(ensure-auth-dir! path)
|
||||
(.writeFileSync fs path payload "utf8")
|
||||
(try-chmod! path)
|
||||
auth-data))
|
||||
|
||||
(defn read-auth-file
|
||||
[opts]
|
||||
(let [path (auth-path opts)]
|
||||
(when (fs/existsSync path)
|
||||
(try
|
||||
(-> (fs/readFileSync path)
|
||||
(.toString "utf8")
|
||||
parse-json)
|
||||
(catch :default e
|
||||
(throw (ex-info "invalid auth file"
|
||||
{:code :invalid-auth-file
|
||||
:auth-path path}
|
||||
e)))))))
|
||||
|
||||
(defn delete-auth-file!
|
||||
[opts]
|
||||
(let [path (auth-path opts)]
|
||||
(when (fs/existsSync path)
|
||||
(.unlinkSync fs path))
|
||||
nil))
|
||||
|
||||
(declare start-logout-complete-server! open-browser!)
|
||||
|
||||
(defn logout!
|
||||
[opts]
|
||||
(let [path (auth-path opts)
|
||||
existed? (fs/existsSync path)
|
||||
logout-url (build-logout-url)]
|
||||
(delete-auth-file! opts)
|
||||
(-> (p/let [callback-server (start-logout-complete-server! opts)]
|
||||
(-> (p/let [open-result (open-browser! logout-url)
|
||||
logout-completed? (if (:opened? open-result)
|
||||
(-> ((:wait! callback-server))
|
||||
(p/then (constantly true))
|
||||
(p/catch (fn [_]
|
||||
false)))
|
||||
false)]
|
||||
{:auth-path path
|
||||
:deleted? existed?
|
||||
:logout-url logout-url
|
||||
:opened? (:opened? open-result)
|
||||
:logout-completed? logout-completed?})
|
||||
(p/finally (fn []
|
||||
((:stop! callback-server))))))
|
||||
(p/catch (fn [_]
|
||||
{:auth-path path
|
||||
:deleted? existed?
|
||||
:logout-url logout-url
|
||||
:opened? false
|
||||
:logout-completed? false})))))
|
||||
|
||||
(defn expired-auth?
|
||||
[{:keys [expires-at]}]
|
||||
(or (not (number? expires-at))
|
||||
(<= expires-at (js/Date.now))))
|
||||
|
||||
(defn- random-base64url
|
||||
[size]
|
||||
(.toString (.randomBytes crypto size) "base64url"))
|
||||
|
||||
(defn- code-challenge
|
||||
[code-verifier]
|
||||
(-> (.createHash crypto "sha256")
|
||||
(.update code-verifier)
|
||||
(.digest "base64url")))
|
||||
|
||||
(defn build-authorize-url
|
||||
[{:keys [redirect-uri state pkce-challenge]}]
|
||||
(let [params (doto (js/URLSearchParams.)
|
||||
(.set "response_type" "code")
|
||||
(.set "client_id" (oauth-client-id))
|
||||
(.set "scope" (oauth-scope))
|
||||
(.set "redirect_uri" redirect-uri)
|
||||
(.set "state" state)
|
||||
(.set "code_challenge" pkce-challenge)
|
||||
(.set "code_challenge_method" "S256"))]
|
||||
(str (authorize-endpoint) "?" (.toString params))))
|
||||
|
||||
(defn- stop-server!
|
||||
[server]
|
||||
(if (some? server)
|
||||
(p/create (fn [resolve _reject]
|
||||
(.close server (fn []
|
||||
(resolve true)))))
|
||||
(p/resolved true)))
|
||||
|
||||
(defn start-login-callback-server!
|
||||
[{:keys [state login-timeout-ms]}]
|
||||
(p/create
|
||||
(fn [resolve reject]
|
||||
(let [callback-handlers (atom nil)
|
||||
settled? (atom false)
|
||||
callback-promise (p/create (fn [resolve' reject']
|
||||
(reset! callback-handlers {:resolve resolve'
|
||||
:reject reject'})))
|
||||
finish! (fn [kind payload]
|
||||
(when-not @settled?
|
||||
(reset! settled? true)
|
||||
(when-let [{:keys [resolve reject]} @callback-handlers]
|
||||
((if (= kind :resolve) resolve reject) payload))))
|
||||
server (.createServer http
|
||||
(fn [^js req ^js res]
|
||||
(let [url (js/URL. (str "http://" callback-host (.-url req)))
|
||||
pathname (.-pathname url)
|
||||
params (.-searchParams url)
|
||||
code (.get params "code")
|
||||
callback-state (.get params "state")
|
||||
error-code (.get params "error")]
|
||||
(cond
|
||||
(not= redirect-path pathname)
|
||||
(do
|
||||
(.writeHead res 404 #js {"Content-Type" "text/plain; charset=utf-8"})
|
||||
(.end res "Not found"))
|
||||
|
||||
(seq error-code)
|
||||
(do
|
||||
(.writeHead res 400 #js {"Content-Type" "text/plain; charset=utf-8"})
|
||||
(.end res "Login failed. You can return to the CLI.")
|
||||
(finish! :reject (ex-info "login callback returned oauth error"
|
||||
{:code :login-callback-error
|
||||
:oauth-error error-code})))
|
||||
|
||||
(not= state callback-state)
|
||||
(do
|
||||
(.writeHead res 400 #js {"Content-Type" "text/plain; charset=utf-8"})
|
||||
(.end res "Login failed due to state mismatch. Return to the CLI and retry.")
|
||||
(finish! :reject (ex-info "login callback state mismatch"
|
||||
{:code :invalid-callback-state})))
|
||||
|
||||
(not (seq code))
|
||||
(do
|
||||
(.writeHead res 400 #js {"Content-Type" "text/plain; charset=utf-8"})
|
||||
(.end res "Login failed because the callback did not include a code.")
|
||||
(finish! :reject (ex-info "missing authorization code"
|
||||
{:code :missing-callback-code})))
|
||||
|
||||
:else
|
||||
(do
|
||||
(.writeHead res 200 #js {"Content-Type" "text/plain; charset=utf-8"})
|
||||
(.end res "Login successful. You can return to the CLI.")
|
||||
(finish! :resolve {:code code}))))))
|
||||
timeout-id (js/setTimeout (fn []
|
||||
(finish! :reject (ex-info "login callback timed out"
|
||||
{:code :login-timeout})))
|
||||
(or login-timeout-ms default-login-timeout-ms))]
|
||||
(.on server "error" (fn [error]
|
||||
(js/clearTimeout timeout-id)
|
||||
(reject (ex-info "failed to start login callback server"
|
||||
{:code :login-callback-server-start-failed}
|
||||
error))))
|
||||
(.listen server callback-port callback-host
|
||||
(fn []
|
||||
(let [address (.address server)
|
||||
port (.-port address)
|
||||
redirect-uri (str "http://" callback-host ":" port redirect-path)]
|
||||
(resolve {:port port
|
||||
:redirect-uri redirect-uri
|
||||
:wait! (fn []
|
||||
(-> callback-promise
|
||||
(p/finally (fn []
|
||||
(js/clearTimeout timeout-id)))))
|
||||
:stop! (fn []
|
||||
(js/clearTimeout timeout-id)
|
||||
(stop-server! server))}))))))))
|
||||
|
||||
(defn start-logout-complete-server!
|
||||
[{:keys [logout-timeout-ms]}]
|
||||
(p/create
|
||||
(fn [resolve reject]
|
||||
(let [settled? (atom false)
|
||||
callback-handlers (atom nil)
|
||||
callback-promise (p/create (fn [resolve' reject']
|
||||
(reset! callback-handlers {:resolve resolve'
|
||||
:reject reject'})))
|
||||
finish! (fn [kind payload]
|
||||
(when-not @settled?
|
||||
(reset! settled? true)
|
||||
(when-let [{:keys [resolve reject]} @callback-handlers]
|
||||
((if (= kind :resolve) resolve reject) payload))))
|
||||
server (.createServer http
|
||||
(fn [^js req ^js res]
|
||||
(let [url (js/URL. (str "http://" callback-host (.-url req)))
|
||||
pathname (.-pathname url)]
|
||||
(if (= logout-complete-path pathname)
|
||||
(do
|
||||
(.writeHead res 200 #js {"Content-Type" "text/plain; charset=utf-8"})
|
||||
(.end res "Logout successful. You can return to the CLI.")
|
||||
(finish! :resolve true))
|
||||
(do
|
||||
(.writeHead res 404 #js {"Content-Type" "text/plain; charset=utf-8"})
|
||||
(.end res "Not found"))))))
|
||||
timeout-id (js/setTimeout (fn []
|
||||
(finish! :reject (ex-info "logout callback timed out"
|
||||
{:code :logout-timeout})))
|
||||
(or logout-timeout-ms default-logout-timeout-ms))]
|
||||
(.on server "error" (fn [error]
|
||||
(js/clearTimeout timeout-id)
|
||||
(reject (ex-info "failed to start logout callback server"
|
||||
{:code :logout-callback-server-start-failed}
|
||||
error))))
|
||||
(.listen server callback-port callback-host
|
||||
(fn []
|
||||
(resolve {:logout-uri (logout-complete-uri)
|
||||
:wait! (fn []
|
||||
(-> callback-promise
|
||||
(p/finally (fn []
|
||||
(js/clearTimeout timeout-id)))))
|
||||
:stop! (fn []
|
||||
(js/clearTimeout timeout-id)
|
||||
(stop-server! server))})))))))
|
||||
|
||||
(defn open-browser!
|
||||
[url]
|
||||
(let [platform (.-platform js/process)
|
||||
[command args] (case platform
|
||||
"darwin" ["open" [url]]
|
||||
"linux" ["xdg-open" [url]]
|
||||
"win32" ["cmd" ["/c" "start" "" url]]
|
||||
[nil nil])]
|
||||
(if-not (seq command)
|
||||
(p/resolved {:opened? false})
|
||||
(p/create
|
||||
(fn [resolve _reject]
|
||||
(try
|
||||
(let [child (.spawn child-process command (clj->js args)
|
||||
#js {:detached true
|
||||
:stdio "ignore"
|
||||
:shell false})]
|
||||
(.unref child)
|
||||
(resolve {:opened? true
|
||||
:command command}))
|
||||
(catch :default e
|
||||
(resolve {:opened? false
|
||||
:command command
|
||||
:error (or (.-message e) (str e))}))))))))
|
||||
|
||||
(defn- oauth-token-request!
|
||||
[params]
|
||||
(let [search-params (js/URLSearchParams.)]
|
||||
(.set search-params "client_id" (oauth-client-id))
|
||||
(doseq [[k v] params]
|
||||
(.set search-params k (str v)))
|
||||
(let [body (.toString search-params)]
|
||||
(-> (transport/request {:method "POST"
|
||||
:url (token-endpoint)
|
||||
:headers {"Content-Type" "application/x-www-form-urlencoded"
|
||||
"Accept" "application/json"}
|
||||
:body body
|
||||
:timeout-ms 10000})
|
||||
(p/then (fn [{:keys [body]}]
|
||||
(parse-json body)))))))
|
||||
|
||||
(defn- token-body->auth-data
|
||||
[token-body current-auth]
|
||||
(let [id-token (:id_token token-body)
|
||||
claims (parse-jwt id-token)
|
||||
refresh-token (or (:refresh_token token-body)
|
||||
(:refresh-token current-auth))]
|
||||
{:provider auth-provider
|
||||
:id-token id-token
|
||||
:access-token (:access_token token-body)
|
||||
:refresh-token refresh-token
|
||||
:expires-at (some-> (:exp claims) (* 1000))
|
||||
:sub (:sub claims)
|
||||
:email (:email claims)
|
||||
:updated-at (js/Date.now)}))
|
||||
|
||||
(defn exchange-code-for-auth!
|
||||
[_opts {:keys [code redirect-uri code-verifier]}]
|
||||
(-> (oauth-token-request! {"grant_type" "authorization_code"
|
||||
"code" code
|
||||
"redirect_uri" redirect-uri
|
||||
"code_verifier" code-verifier})
|
||||
(p/then (fn [token-body]
|
||||
(token-body->auth-data token-body nil)))
|
||||
(p/catch (fn [error]
|
||||
(let [data (ex-data error)]
|
||||
(p/rejected
|
||||
(ex-info "authorization code exchange failed"
|
||||
(merge {:code :auth-code-exchange-failed}
|
||||
(when data {:context data}))
|
||||
error)))))))
|
||||
|
||||
(defn refresh-auth!
|
||||
[opts auth-data]
|
||||
(let [refresh-token (:refresh-token auth-data)]
|
||||
(if (seq refresh-token)
|
||||
(-> (oauth-token-request! {"grant_type" "refresh_token"
|
||||
"refresh_token" refresh-token})
|
||||
(p/then (fn [token-body]
|
||||
(token-body->auth-data token-body auth-data)))
|
||||
(p/catch (fn [error]
|
||||
(let [data (ex-data error)
|
||||
parsed-body (try
|
||||
(some-> (:body data) parse-json)
|
||||
(catch :default _
|
||||
nil))]
|
||||
(if (= "invalid_grant" (:error parsed-body))
|
||||
(p/rejected
|
||||
(ex-info "refresh token is invalid"
|
||||
{:code :missing-auth
|
||||
:hint "Run logseq login first."
|
||||
:auth-path (auth-path opts)}
|
||||
error))
|
||||
(p/rejected
|
||||
(ex-info "auth refresh failed"
|
||||
{:code :auth-refresh-failed
|
||||
:hint "Run logseq login first."
|
||||
:auth-path (auth-path opts)
|
||||
:context data}
|
||||
error)))))))
|
||||
(p/rejected (ex-info "missing refresh token"
|
||||
{:code :missing-auth
|
||||
:hint "Run logseq login first."
|
||||
:auth-path (auth-path opts)})))))
|
||||
|
||||
(defn login!
|
||||
[opts]
|
||||
(let [state (or (:state opts) (random-base64url 24))
|
||||
code-verifier (or (:code-verifier opts) (random-base64url 48))
|
||||
authorize-payload {:state state
|
||||
:pkce-challenge (code-challenge code-verifier)}]
|
||||
(p/let [callback-server (start-login-callback-server! (merge opts {:state state}))
|
||||
redirect-uri (:redirect-uri callback-server)
|
||||
authorize-url (build-authorize-url (assoc authorize-payload :redirect-uri redirect-uri))]
|
||||
(-> (p/let [open-result (open-browser! authorize-url)
|
||||
callback-result ((:wait! callback-server))
|
||||
auth-data (exchange-code-for-auth! opts {:code (:code callback-result)
|
||||
:redirect-uri redirect-uri
|
||||
:code-verifier code-verifier})
|
||||
_ (write-auth-file! opts auth-data)]
|
||||
{:auth-path (auth-path opts)
|
||||
:authorize-url authorize-url
|
||||
:opened? (:opened? open-result)
|
||||
:email (:email auth-data)
|
||||
:sub (:sub auth-data)
|
||||
:updated-at (:updated-at auth-data)})
|
||||
(p/finally (fn []
|
||||
((:stop! callback-server))))))))
|
||||
|
||||
(defn resolve-auth-token!
|
||||
[opts]
|
||||
(if-let [current-auth (read-auth-file opts)]
|
||||
(if (expired-auth? current-auth)
|
||||
(p/let [refreshed-auth (refresh-auth! opts current-auth)
|
||||
next-auth (merge current-auth refreshed-auth)]
|
||||
(write-auth-file! opts next-auth)
|
||||
(:id-token next-auth))
|
||||
(p/resolved (:id-token current-auth)))
|
||||
(p/rejected (ex-info "missing auth"
|
||||
{:code :missing-auth
|
||||
:hint "Run logseq login first."
|
||||
:auth-path (auth-path opts)}))))
|
||||
56
src/main/logseq/cli/command/auth.cljs
Normal file
56
src/main/logseq/cli/command/auth.cljs
Normal file
@@ -0,0 +1,56 @@
|
||||
(ns logseq.cli.command.auth
|
||||
"Authentication-related CLI commands."
|
||||
(:require [logseq.cli.auth :as cli-auth]
|
||||
[logseq.cli.command.core :as core]
|
||||
[promesa.core :as p]))
|
||||
|
||||
(def entries
|
||||
[(core/command-entry ["login"]
|
||||
:login
|
||||
"Authenticate this machine with Logseq cloud"
|
||||
{})
|
||||
(core/command-entry ["logout"]
|
||||
:logout
|
||||
"Remove persisted CLI auth"
|
||||
{})])
|
||||
|
||||
(defn build-action
|
||||
[command]
|
||||
{:ok? true
|
||||
:action {:type command}})
|
||||
|
||||
(defn- ex-message->code
|
||||
[message]
|
||||
(when (and (string? message)
|
||||
(re-matches #"[a-zA-Z0-9._/\-]+" message))
|
||||
(keyword message)))
|
||||
|
||||
(defn- exception->error
|
||||
[error]
|
||||
(let [data (or (ex-data error) {})
|
||||
code (or (:code data)
|
||||
(ex-message->code (ex-message error))
|
||||
:exception)]
|
||||
{:status :error
|
||||
:error (merge {:code code
|
||||
:message (or (ex-message error) (str error))}
|
||||
(when (seq data) {:context data}))}))
|
||||
|
||||
(defn execute
|
||||
[action config]
|
||||
(case (:type action)
|
||||
:login
|
||||
(-> (p/let [data (cli-auth/login! config)]
|
||||
{:status :ok
|
||||
:data data})
|
||||
(p/catch exception->error))
|
||||
|
||||
:logout
|
||||
(-> (p/let [data (cli-auth/logout! config)]
|
||||
{:status :ok
|
||||
:data data})
|
||||
(p/catch exception->error))
|
||||
|
||||
(p/resolved {:status :error
|
||||
:error {:code :unknown-action
|
||||
:message "unknown auth action"}})))
|
||||
@@ -99,7 +99,9 @@
|
||||
(let [groups [{:title "Graph Inspect and Edit"
|
||||
:commands #{"list" "upsert" "remove" "query" "show"}}
|
||||
{:title "Graph Management"
|
||||
:commands #{"graph" "server" "doctor" "sync"}}]
|
||||
:commands #{"graph" "server" "doctor" "sync"}}
|
||||
{:title "Authentication"
|
||||
:commands #{"login" "logout"}}]
|
||||
render-group (fn [{:keys [title commands]}]
|
||||
(let [entries (filter #(contains? commands (first (:cmds %))) table)]
|
||||
(string/join "\n" [title (format-commands entries)])))]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
(ns logseq.cli.command.sync
|
||||
"Sync-related CLI commands."
|
||||
(:require [clojure.string :as string]
|
||||
[logseq.cli.auth :as cli-auth]
|
||||
[logseq.cli.command.core :as core]
|
||||
[logseq.cli.config :as cli-config]
|
||||
[logseq.cli.server :as cli-server]
|
||||
@@ -27,9 +28,16 @@
|
||||
(def ^:private config-key-map
|
||||
{"ws-url" :ws-url
|
||||
"http-base" :http-base
|
||||
"auth-token" :auth-token
|
||||
"e2ee-password" :e2ee-password})
|
||||
|
||||
(def ^:private authenticated-sync-actions
|
||||
#{:sync-start
|
||||
:sync-upload
|
||||
:sync-download
|
||||
:sync-remote-graphs
|
||||
:sync-ensure-keys
|
||||
:sync-grant-access})
|
||||
|
||||
(def ^:private sync-start-timeout-ms 10000)
|
||||
(def ^:private sync-start-poll-interval-ms 100)
|
||||
|
||||
@@ -190,6 +198,15 @@
|
||||
:auth-token (:auth-token config)
|
||||
:e2ee-password (:e2ee-password config)})
|
||||
|
||||
(defn- resolve-runtime-config!
|
||||
[action config]
|
||||
(if (contains? authenticated-sync-actions (:type action))
|
||||
(if (seq (:auth-token config))
|
||||
(p/resolved config)
|
||||
(p/let [auth-token (cli-auth/resolve-auth-token! config)]
|
||||
(assoc config :auth-token auth-token)))
|
||||
(p/resolved config)))
|
||||
|
||||
(defn- invoke-with-repo
|
||||
[config repo method args]
|
||||
(let [sync-cfg (sync-config config)]
|
||||
@@ -235,7 +252,7 @@
|
||||
(let [timeout-ms (max 0 (or (:wait-timeout-ms action) sync-start-timeout-ms))
|
||||
poll-interval-ms (max 0 (or (:wait-poll-interval-ms action) sync-start-poll-interval-ms))
|
||||
deadline (+ (js/Date.now) timeout-ms)
|
||||
config-skipped-hint "Set sync config keys (ws-url/http-base/auth-token) and retry sync start."
|
||||
config-skipped-hint "Run logseq login, set sync config keys (ws-url/http-base), and retry sync start."
|
||||
graph-id-skipped-hint "Graph-id is missing locally. Run sync download first, then retry sync start."
|
||||
runtime-error-hint "Run sync status to inspect last-error and fix sync runtime error before retrying."
|
||||
timeout-hint "Run sync status to inspect ws-state and ensure sync endpoint/token are valid."]
|
||||
@@ -336,10 +353,11 @@
|
||||
:data result})
|
||||
|
||||
:sync-start
|
||||
(-> (p/let [_ (invoke-with-repo config (:repo action)
|
||||
(-> (p/let [config' (resolve-runtime-config! action config)
|
||||
_ (invoke-with-repo config' (:repo action)
|
||||
:thread-api/db-sync-start
|
||||
[(:repo action)])
|
||||
result (wait-sync-start-ready config (:repo action) action)]
|
||||
result (wait-sync-start-ready config' (:repo action) action)]
|
||||
result)
|
||||
(p/catch (fn [error]
|
||||
(exception->error error {:repo (:repo action)}))))
|
||||
@@ -352,27 +370,45 @@
|
||||
:data {:result result}})
|
||||
|
||||
:sync-upload
|
||||
(execute-sync-upload action config)
|
||||
(-> (p/let [config' (resolve-runtime-config! action config)]
|
||||
(execute-sync-upload action config'))
|
||||
(p/catch (fn [error]
|
||||
(exception->error error {:repo (:repo action)}))))
|
||||
|
||||
:sync-download
|
||||
(execute-sync-download action config)
|
||||
(-> (p/let [config' (resolve-runtime-config! action config)]
|
||||
(execute-sync-download action config'))
|
||||
(p/catch (fn [error]
|
||||
(exception->error error {:repo (:repo action)
|
||||
:graph (:graph action)}))))
|
||||
|
||||
:sync-remote-graphs
|
||||
(p/let [graphs (invoke-global config :thread-api/db-sync-list-remote-graphs [])]
|
||||
{:status :ok
|
||||
:data {:graphs (or graphs [])}})
|
||||
(-> (p/let [config' (resolve-runtime-config! action config)
|
||||
graphs (invoke-global config' :thread-api/db-sync-list-remote-graphs [])]
|
||||
{:status :ok
|
||||
:data {:graphs (or graphs [])}})
|
||||
(p/catch (fn [error]
|
||||
(exception->error error nil))))
|
||||
|
||||
:sync-ensure-keys
|
||||
(p/let [result (invoke-global config :thread-api/db-sync-ensure-user-rsa-keys [])]
|
||||
{:status :ok
|
||||
:data {:result result}})
|
||||
(-> (p/let [config' (resolve-runtime-config! action config)
|
||||
result (invoke-global config' :thread-api/db-sync-ensure-user-rsa-keys [])]
|
||||
{:status :ok
|
||||
:data {:result result}})
|
||||
(p/catch (fn [error]
|
||||
(exception->error error nil))))
|
||||
|
||||
:sync-grant-access
|
||||
(p/let [result (invoke-with-repo config (:repo action)
|
||||
:thread-api/db-sync-grant-graph-access
|
||||
[(:repo action) (:graph-id action) (:email action)])]
|
||||
{:status :ok
|
||||
:data {:result result}})
|
||||
(-> (p/let [config' (resolve-runtime-config! action config)
|
||||
result (invoke-with-repo config' (:repo action)
|
||||
:thread-api/db-sync-grant-graph-access
|
||||
[(:repo action) (:graph-id action) (:email action)])]
|
||||
{:status :ok
|
||||
:data {:result result}})
|
||||
(p/catch (fn [error]
|
||||
(exception->error error {:repo (:repo action)
|
||||
:graph-id (:graph-id action)
|
||||
:email (:email action)}))))
|
||||
|
||||
:sync-config-get
|
||||
(p/let [current config]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"Command parsing and action building for the Logseq CLI."
|
||||
(:require [babashka.cli :as cli]
|
||||
[clojure.string :as string]
|
||||
[logseq.cli.command.auth :as auth-command]
|
||||
[logseq.cli.command.core :as command-core]
|
||||
[logseq.cli.command.doctor :as doctor-command]
|
||||
[logseq.cli.command.graph :as graph-command]
|
||||
@@ -103,7 +104,8 @@
|
||||
query-command/entries
|
||||
show-command/entries
|
||||
doctor-command/entries
|
||||
sync-command/entries)))
|
||||
sync-command/entries
|
||||
auth-command/entries)))
|
||||
|
||||
;; Global option parsing lives in logseq.cli.command.core.
|
||||
|
||||
@@ -417,6 +419,9 @@
|
||||
:sync-config-set :sync-config-get :sync-config-unset)
|
||||
(sync-command/build-action command options args repo)
|
||||
|
||||
(:login :logout)
|
||||
(auth-command/build-action command)
|
||||
|
||||
{:ok? false
|
||||
:error {:code :unknown-command
|
||||
:message (str "unknown command: " command)}}))))
|
||||
@@ -466,6 +471,8 @@
|
||||
:sync-remote-graphs :sync-ensure-keys :sync-grant-access
|
||||
:sync-config-set :sync-config-get :sync-config-unset)
|
||||
(sync-command/execute action config)
|
||||
(:login :logout)
|
||||
(auth-command/execute action config)
|
||||
{:status :error
|
||||
:error {:code :unknown-action
|
||||
:message "unknown action"}}))]
|
||||
|
||||
@@ -28,11 +28,19 @@
|
||||
[]
|
||||
(node-path/join (.homedir os) "logseq" "cli.edn"))
|
||||
|
||||
(def ^:private removed-config-keys
|
||||
#{:auth-token :retries})
|
||||
|
||||
(defn- sanitize-file-config
|
||||
[config]
|
||||
(apply dissoc (or config {}) removed-config-keys))
|
||||
|
||||
(defn- read-config-file
|
||||
[config-path]
|
||||
(when (and (some? config-path) (fs/existsSync config-path))
|
||||
(let [contents (.toString (fs/readFileSync config-path) "utf8")]
|
||||
(reader/read-string contents))))
|
||||
(-> (reader/read-string contents)
|
||||
sanitize-file-config))))
|
||||
|
||||
(defn- ensure-config-dir!
|
||||
[config-path]
|
||||
@@ -45,8 +53,8 @@
|
||||
[{:keys [config-path]} updates]
|
||||
(let [path (or config-path (default-config-path))
|
||||
current (or (read-config-file path) {})
|
||||
filtered-current (dissoc current :retries)
|
||||
filtered-updates (dissoc (or updates {}) :retries)
|
||||
filtered-current (sanitize-file-config current)
|
||||
filtered-updates (sanitize-file-config updates)
|
||||
nil-keys (->> filtered-updates
|
||||
(keep (fn [[k v]]
|
||||
(when (nil? v)
|
||||
@@ -72,6 +80,12 @@
|
||||
(seq (gobj/get env "LOGSEQ_CLI_TIMEOUT_MS"))
|
||||
(assoc :timeout-ms (parse-int (gobj/get env "LOGSEQ_CLI_TIMEOUT_MS")))
|
||||
|
||||
(seq (gobj/get env "LOGSEQ_CLI_LOGIN_TIMEOUT_MS"))
|
||||
(assoc :login-timeout-ms (parse-int (gobj/get env "LOGSEQ_CLI_LOGIN_TIMEOUT_MS")))
|
||||
|
||||
(seq (gobj/get env "LOGSEQ_CLI_LOGOUT_TIMEOUT_MS"))
|
||||
(assoc :logout-timeout-ms (parse-int (gobj/get env "LOGSEQ_CLI_LOGOUT_TIMEOUT_MS")))
|
||||
|
||||
(seq (gobj/get env "LOGSEQ_CLI_OUTPUT"))
|
||||
(assoc :output-format (parse-output-format (gobj/get env "LOGSEQ_CLI_OUTPUT")))
|
||||
|
||||
@@ -81,6 +95,8 @@
|
||||
(defn resolve-config
|
||||
[opts]
|
||||
(let [defaults {:timeout-ms 10000
|
||||
:login-timeout-ms 300000
|
||||
:logout-timeout-ms 120000
|
||||
:output-format nil
|
||||
:data-dir "~/logseq/graphs"
|
||||
:config-path (default-config-path)}
|
||||
|
||||
@@ -341,11 +341,23 @@
|
||||
(update data :kv redact-graph-kv)
|
||||
data))
|
||||
|
||||
(defn- sanitize-auth-data
|
||||
[data]
|
||||
(if (map? data)
|
||||
(apply dissoc data [:id-token :access-token :refresh-token])
|
||||
data))
|
||||
|
||||
(defn- sanitize-result
|
||||
[result]
|
||||
(if (and (= :ok (:status result))
|
||||
(= :graph-info (:command result)))
|
||||
(cond
|
||||
(and (= :ok (:status result))
|
||||
(= :graph-info (:command result)))
|
||||
(update result :data sanitize-graph-info-data)
|
||||
|
||||
(= :login (:command result))
|
||||
(update result :data sanitize-auth-data)
|
||||
|
||||
:else
|
||||
result))
|
||||
|
||||
(defn- format-sync-status
|
||||
@@ -406,6 +418,28 @@
|
||||
[{:keys [key]}]
|
||||
(str "sync config unset: " (name key)))
|
||||
|
||||
(defn- format-login
|
||||
[{:keys [auth-path email sub]}]
|
||||
(string/join "\n"
|
||||
(cond-> ["Login successful"
|
||||
(str "Auth file: " (or auth-path "-"))]
|
||||
(seq email) (conj (str "Email: " email))
|
||||
(seq sub) (conj (str "User: " sub)))))
|
||||
|
||||
(defn- format-logout
|
||||
[{:keys [auth-path deleted? opened? logout-completed?]}]
|
||||
(string/join "\n"
|
||||
(cond-> [(str (if deleted?
|
||||
"Logged out"
|
||||
"Already logged out")
|
||||
": "
|
||||
(or auth-path "-"))]
|
||||
logout-completed? (conj "Cognito logout: completed")
|
||||
(and (not logout-completed?) (true? opened?))
|
||||
(conj "Cognito logout: browser opened, completion not confirmed")
|
||||
(false? opened?)
|
||||
(conj "Cognito logout: could not open browser"))))
|
||||
|
||||
(defn- format-upsert-block
|
||||
[{:keys [repo source target update-tags update-properties remove-tags remove-properties]} result]
|
||||
(if (vector? result)
|
||||
@@ -508,6 +542,8 @@
|
||||
:sync-config-get (format-sync-config-get data)
|
||||
:sync-config-set (format-sync-config-set data)
|
||||
:sync-config-unset (format-sync-config-unset data)
|
||||
:login (format-login data)
|
||||
:logout (format-logout data)
|
||||
:list-page (format-list-page (:items data) now-ms)
|
||||
:list-tag (format-list-tag (:items data) now-ms)
|
||||
:list-property (format-list-property (:items data) now-ms)
|
||||
|
||||
102
src/test/logseq/cli/auth_test.cljs
Normal file
102
src/test/logseq/cli/auth_test.cljs
Normal file
@@ -0,0 +1,102 @@
|
||||
(ns logseq.cli.auth-test
|
||||
(:require [cljs.test :refer [async deftest is]]
|
||||
[frontend.test.node-helper :as node-helper]
|
||||
[logseq.cli.auth :as auth]
|
||||
[promesa.core :as p]
|
||||
["fs" :as fs]
|
||||
["os" :as os]
|
||||
["path" :as node-path]))
|
||||
|
||||
(defn- sample-auth
|
||||
([]
|
||||
(sample-auth {}))
|
||||
([overrides]
|
||||
(merge {:provider "cognito"
|
||||
:id-token "id-token-1"
|
||||
:access-token "access-token-1"
|
||||
:refresh-token "refresh-token-1"
|
||||
:expires-at (+ (js/Date.now) 3600000)
|
||||
:sub "user-123"
|
||||
:email "user@example.com"
|
||||
:updated-at 1735686000000}
|
||||
overrides)))
|
||||
|
||||
(defn- read-json-file
|
||||
[path]
|
||||
(-> (fs/readFileSync path)
|
||||
(.toString "utf8")
|
||||
js/JSON.parse
|
||||
(js->clj :keywordize-keys true)))
|
||||
|
||||
(deftest test-default-auth-path
|
||||
(is (= (node-path/join (.homedir os) "logseq" "auth.json")
|
||||
(auth/default-auth-path))))
|
||||
|
||||
(deftest test-write-auth-file-creates-parent-dir
|
||||
(let [dir (node-helper/create-tmp-dir "cli-auth")
|
||||
auth-dir (node-path/join dir "nested" "tokens")
|
||||
auth-path (node-path/join auth-dir "auth.json")
|
||||
payload (sample-auth)]
|
||||
(is (not (fs/existsSync auth-dir)))
|
||||
(auth/write-auth-file! {:auth-path auth-path} payload)
|
||||
(is (fs/existsSync auth-dir))
|
||||
(is (fs/existsSync auth-path))
|
||||
(when (fs/existsSync auth-path)
|
||||
(is (= payload (read-json-file auth-path))))))
|
||||
|
||||
(deftest test-read-auth-file-returns-nil-when-missing
|
||||
(let [dir (node-helper/create-tmp-dir "cli-auth")
|
||||
auth-path (node-path/join dir "missing" "auth.json")]
|
||||
(is (nil? (auth/read-auth-file {:auth-path auth-path})))))
|
||||
|
||||
(deftest test-delete-auth-file-is-idempotent
|
||||
(let [dir (node-helper/create-tmp-dir "cli-auth")
|
||||
auth-path (node-path/join dir "auth.json")]
|
||||
(auth/delete-auth-file! {:auth-path auth-path})
|
||||
(is (not (fs/existsSync auth-path)))
|
||||
(auth/write-auth-file! {:auth-path auth-path} (sample-auth))
|
||||
(is (fs/existsSync auth-path))
|
||||
(auth/delete-auth-file! {:auth-path auth-path})
|
||||
(is (not (fs/existsSync auth-path)))))
|
||||
|
||||
(deftest test-read-auth-file-invalid-json
|
||||
(let [dir (node-helper/create-tmp-dir "cli-auth")
|
||||
auth-path (node-path/join dir "auth.json")]
|
||||
(fs/writeFileSync auth-path "{\"provider\":")
|
||||
(try
|
||||
(auth/read-auth-file {:auth-path auth-path})
|
||||
(is false "expected invalid-auth-file error")
|
||||
(catch :default e
|
||||
(is (= :invalid-auth-file (-> e ex-data :code)))
|
||||
(is (= auth-path (-> e ex-data :auth-path)))))))
|
||||
|
||||
(deftest test-resolve-auth-token-refreshes-expired-token
|
||||
(async done
|
||||
(let [dir (node-helper/create-tmp-dir "cli-auth")
|
||||
auth-path (node-path/join dir "auth.json")
|
||||
refresh-calls (atom [])
|
||||
expired-auth (sample-auth {:id-token "expired-id-token"
|
||||
:access-token "expired-access-token"
|
||||
:expires-at 0})
|
||||
refreshed-auth (sample-auth {:id-token "fresh-id-token"
|
||||
:access-token "fresh-access-token"
|
||||
:refresh-token "refresh-token-1"
|
||||
:expires-at (+ (js/Date.now) 7200000)
|
||||
:updated-at 1735689600000})]
|
||||
(auth/write-auth-file! {:auth-path auth-path} expired-auth)
|
||||
(let [result-promise
|
||||
(p/with-redefs [auth/refresh-auth! (fn [opts auth-data]
|
||||
(swap! refresh-calls conj [opts auth-data])
|
||||
(p/resolved refreshed-auth))]
|
||||
(p/let [token (auth/resolve-auth-token! {:auth-path auth-path})
|
||||
stored (auth/read-auth-file {:auth-path auth-path})]
|
||||
(is (= [[{:auth-path auth-path} expired-auth]] @refresh-calls))
|
||||
(is (= "fresh-id-token" token))
|
||||
(is (= refreshed-auth stored))
|
||||
(when (fs/existsSync auth-path)
|
||||
(is (= refreshed-auth (read-json-file auth-path))))))]
|
||||
(-> result-promise
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally (fn []
|
||||
(done))))))))
|
||||
215
src/test/logseq/cli/command/auth_test.cljs
Normal file
215
src/test/logseq/cli/command/auth_test.cljs
Normal file
@@ -0,0 +1,215 @@
|
||||
(ns logseq.cli.command.auth-test
|
||||
(:require [cljs.test :refer [async deftest is]]
|
||||
[frontend.test.node-helper :as node-helper]
|
||||
[logseq.cli.auth :as cli-auth]
|
||||
[logseq.cli.command.auth :as auth-command]
|
||||
[logseq.common.cognito-config :as cognito-config]
|
||||
[promesa.core :as p]
|
||||
["fs" :as fs]
|
||||
["path" :as node-path]))
|
||||
|
||||
(defn- sample-auth
|
||||
([]
|
||||
(sample-auth {}))
|
||||
([overrides]
|
||||
(merge {:provider "cognito"
|
||||
:id-token "id-token-1"
|
||||
:access-token "access-token-1"
|
||||
:refresh-token "refresh-token-1"
|
||||
:expires-at (+ (js/Date.now) 3600000)
|
||||
:sub "user-123"
|
||||
:email "user@example.com"
|
||||
:updated-at 1735686000000}
|
||||
overrides)))
|
||||
|
||||
(deftest test-login-opens-browser-with-authorize-url-and-persists-auth
|
||||
(async done
|
||||
(let [dir (node-helper/create-tmp-dir "cli-auth")
|
||||
auth-path (node-path/join dir "auth.json")
|
||||
open-calls (atom [])
|
||||
exchange-calls (atom [])
|
||||
write-calls (atom [])
|
||||
auth-data (sample-auth)
|
||||
callback-server {:port 8765
|
||||
:redirect-uri "http://localhost:8765/auth/callback"
|
||||
:wait! (fn [] (p/resolved {:code "oauth-code"}))
|
||||
:stop! (fn [] (p/resolved true))}]
|
||||
(-> (p/with-redefs [cognito-config/CLI-COGNITO-CLIENT-ID "69cs1lgme7p8kbgld8n5kseii6"
|
||||
cli-auth/start-login-callback-server! (fn [_opts]
|
||||
(p/resolved callback-server))
|
||||
cli-auth/open-browser! (fn [url]
|
||||
(swap! open-calls conj url)
|
||||
(p/resolved {:opened? true}))
|
||||
cli-auth/exchange-code-for-auth! (fn [opts payload]
|
||||
(swap! exchange-calls conj [opts payload])
|
||||
(p/resolved auth-data))
|
||||
cli-auth/write-auth-file! (fn [opts payload]
|
||||
(swap! write-calls conj [opts payload])
|
||||
payload)]
|
||||
(p/let [result (cli-auth/login! {:auth-path auth-path})
|
||||
authorize-url (first @open-calls)]
|
||||
(is (= 1 (count @open-calls)))
|
||||
(is (string? authorize-url))
|
||||
(is (re-find #"/oauth2/authorize" authorize-url))
|
||||
(is (re-find #"response_type=code" authorize-url))
|
||||
(is (re-find #"client_id=69cs1lgme7p8kbgld8n5kseii6" authorize-url))
|
||||
(is (re-find #"redirect_uri=http%3A%2F%2Flocalhost%3A8765%2Fauth%2Fcallback" authorize-url))
|
||||
(is (re-find #"state=" authorize-url))
|
||||
(is (re-find #"code_challenge=" authorize-url))
|
||||
(is (= 1 (count @exchange-calls)))
|
||||
(let [[exchange-opts exchange-payload] (first @exchange-calls)]
|
||||
(is (= {:auth-path auth-path} exchange-opts))
|
||||
(is (= "oauth-code" (:code exchange-payload)))
|
||||
(is (= "http://localhost:8765/auth/callback" (:redirect-uri exchange-payload)))
|
||||
(is (string? (:code-verifier exchange-payload))))
|
||||
(is (= [[{:auth-path auth-path} auth-data]] @write-calls))
|
||||
(is (= auth-path (:auth-path result)))
|
||||
(is (= "user@example.com" (:email result)))
|
||||
(is (= "user-123" (:sub result)))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally (fn [] (done)))))))
|
||||
|
||||
(deftest test-login-validates-state-before-code-exchange
|
||||
(async done
|
||||
(let [exchange-calls (atom [])]
|
||||
(-> (p/with-redefs [cli-auth/open-browser! (fn [authorize-url]
|
||||
(let [parsed (js/URL. authorize-url)
|
||||
redirect-uri (.get (.-searchParams parsed) "redirect_uri")]
|
||||
(-> (js/fetch (str redirect-uri "?code=oauth-code&state=wrong-state"))
|
||||
(p/then (fn [_]
|
||||
{:opened? true})))) )
|
||||
cli-auth/exchange-code-for-auth! (fn [opts payload]
|
||||
(swap! exchange-calls conj [opts payload])
|
||||
(p/resolved (sample-auth)))]
|
||||
(-> (cli-auth/login! {:timeout-ms 200})
|
||||
(p/then (fn [_]
|
||||
(is false "expected invalid callback state error")))
|
||||
(p/catch (fn [e]
|
||||
(is (= :invalid-callback-state (-> e ex-data :code)))
|
||||
(is (= [] @exchange-calls))))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally (fn [] (done)))))))
|
||||
|
||||
(deftest test-login-timeout-when-no-browser-callback-arrives
|
||||
(async done
|
||||
(let [exchange-calls (atom [])]
|
||||
(-> (p/with-redefs [cli-auth/open-browser! (fn [_authorize-url]
|
||||
(p/resolved {:opened? false}))
|
||||
cli-auth/exchange-code-for-auth! (fn [opts payload]
|
||||
(swap! exchange-calls conj [opts payload])
|
||||
(p/resolved (sample-auth)))]
|
||||
(-> (cli-auth/login! {:login-timeout-ms 10})
|
||||
(p/then (fn [_]
|
||||
(is false "expected login timeout error")))
|
||||
(p/catch (fn [e]
|
||||
(is (= :login-timeout (-> e ex-data :code)))
|
||||
(is (= [] @exchange-calls))))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally (fn [] (done)))))))
|
||||
|
||||
(deftest test-login-ignores-global-request-timeout-for-callback-wait
|
||||
(async done
|
||||
(let [exchange-calls (atom [])]
|
||||
(-> (p/with-redefs [cli-auth/open-browser! (fn [_authorize-url]
|
||||
(p/resolved {:opened? false}))
|
||||
cli-auth/exchange-code-for-auth! (fn [opts payload]
|
||||
(swap! exchange-calls conj [opts payload])
|
||||
(p/resolved (sample-auth)))]
|
||||
(-> (cli-auth/login! {:timeout-ms 10
|
||||
:login-timeout-ms 200})
|
||||
(p/then (fn [_]
|
||||
(is false "expected login timeout error")))
|
||||
(p/catch (fn [e]
|
||||
(is (= :login-timeout (-> e ex-data :code)))
|
||||
(is (= [] @exchange-calls))))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally (fn [] (done)))))))
|
||||
|
||||
(deftest test-logout-removes-auth-file-and-completes-cognito-logout-when-file-existed
|
||||
(async done
|
||||
(let [dir (node-helper/create-tmp-dir "cli-auth")
|
||||
auth-path (node-path/join dir "auth.json")
|
||||
open-calls (atom [])]
|
||||
(cli-auth/write-auth-file! {:auth-path auth-path} (sample-auth))
|
||||
(is (fs/existsSync auth-path))
|
||||
(-> (p/with-redefs [cognito-config/CLI-COGNITO-CLIENT-ID "69cs1lgme7p8kbgld8n5kseii6"
|
||||
cli-auth/open-browser! (fn [url]
|
||||
(swap! open-calls conj url)
|
||||
(let [parsed (js/URL. url)
|
||||
logout-uri (.get (.-searchParams parsed) "logout_uri")]
|
||||
(-> (js/fetch logout-uri)
|
||||
(p/then (fn [_]
|
||||
{:opened? true})))))]
|
||||
(p/let [result (cli-auth/logout! {:auth-path auth-path})
|
||||
logout-url (first @open-calls)]
|
||||
(is (= 1 (count @open-calls)))
|
||||
(is (= auth-path (:auth-path result)))
|
||||
(is (true? (:deleted? result)))
|
||||
(is (true? (:opened? result)))
|
||||
(is (true? (:logout-completed? result)))
|
||||
(is (not (fs/existsSync auth-path)))
|
||||
(is (string? logout-url))
|
||||
(when (string? logout-url)
|
||||
(is (re-find #"/logout\?" logout-url))
|
||||
(is (re-find #"client_id=69cs1lgme7p8kbgld8n5kseii6" logout-url))
|
||||
(is (re-find #"logout_uri=http%3A%2F%2Flocalhost%3A8765%2Flogout-complete" logout-url)))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally (fn [] (done)))))))
|
||||
|
||||
(deftest test-logout-completes-cognito-logout-when-auth-file-is-already-absent
|
||||
(async done
|
||||
(let [dir (node-helper/create-tmp-dir "cli-auth")
|
||||
auth-path (node-path/join dir "auth.json")
|
||||
open-calls (atom [])]
|
||||
(-> (p/with-redefs [cognito-config/CLI-COGNITO-CLIENT-ID "69cs1lgme7p8kbgld8n5kseii6"
|
||||
cli-auth/open-browser! (fn [url]
|
||||
(swap! open-calls conj url)
|
||||
(let [parsed (js/URL. url)
|
||||
logout-uri (.get (.-searchParams parsed) "logout_uri")]
|
||||
(-> (js/fetch logout-uri)
|
||||
(p/then (fn [_]
|
||||
{:opened? true})))))]
|
||||
(p/let [result (cli-auth/logout! {:auth-path auth-path})
|
||||
logout-url (first @open-calls)]
|
||||
(is (= 1 (count @open-calls)))
|
||||
(is (= auth-path (:auth-path result)))
|
||||
(is (false? (:deleted? result)))
|
||||
(is (true? (:opened? result)))
|
||||
(is (true? (:logout-completed? result)))
|
||||
(is (not (fs/existsSync auth-path)))
|
||||
(is (string? logout-url))
|
||||
(when (string? logout-url)
|
||||
(is (re-find #"logout_uri=http%3A%2F%2Flocalhost%3A8765%2Flogout-complete" logout-url)))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally (fn [] (done)))))))
|
||||
|
||||
(deftest test-command-execute-login-and-logout
|
||||
(async done
|
||||
(let [login-calls (atom [])
|
||||
logout-calls (atom [])]
|
||||
(-> (p/with-redefs [cli-auth/login! (fn [config]
|
||||
(swap! login-calls conj config)
|
||||
(p/resolved {:auth-path "/tmp/auth.json"
|
||||
:email "user@example.com"
|
||||
:sub "user-123"}))
|
||||
cli-auth/logout! (fn [config]
|
||||
(swap! logout-calls conj config)
|
||||
{:auth-path "/tmp/auth.json"
|
||||
:deleted? true})]
|
||||
(p/let [login-result (auth-command/execute {:type :login} {:auth-path "/tmp/auth.json"})
|
||||
logout-result (auth-command/execute {:type :logout} {:auth-path "/tmp/auth.json"})]
|
||||
(is (= [{:auth-path "/tmp/auth.json"}] @login-calls))
|
||||
(is (= [{:auth-path "/tmp/auth.json"}] @logout-calls))
|
||||
(is (= :ok (:status login-result)))
|
||||
(is (= "user@example.com" (get-in login-result [:data :email])))
|
||||
(is (= :ok (:status logout-result)))
|
||||
(is (true? (get-in logout-result [:data :deleted?])))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally (fn [] (done)))))))
|
||||
@@ -1,11 +1,16 @@
|
||||
(ns logseq.cli.command.sync-test
|
||||
(:require [cljs.test :refer [async deftest is testing]]
|
||||
[logseq.cli.auth :as cli-auth]
|
||||
[logseq.cli.command.sync :as sync-command]
|
||||
[logseq.cli.config :as cli-config]
|
||||
[logseq.cli.server :as cli-server]
|
||||
[logseq.cli.transport :as transport]
|
||||
[promesa.core :as p]))
|
||||
|
||||
(defn- execute-with-runtime-auth
|
||||
[action config]
|
||||
(sync-command/execute action (assoc config :auth-token "runtime-token")))
|
||||
|
||||
(deftest test-build-action-validation
|
||||
(testing "sync status requires repo"
|
||||
(let [result (sync-command/build-action :sync-status {} [] nil)]
|
||||
@@ -25,12 +30,23 @@
|
||||
|
||||
(testing "sync config set requires name and value"
|
||||
(let [missing-both (sync-command/build-action :sync-config-set {} [] nil)
|
||||
missing-value (sync-command/build-action :sync-config-set {} ["auth-token"] nil)]
|
||||
missing-value (sync-command/build-action :sync-config-set {} ["ws-url"] nil)]
|
||||
(is (false? (:ok? missing-both)))
|
||||
(is (= :invalid-options (get-in missing-both [:error :code])))
|
||||
(is (false? (:ok? missing-value)))
|
||||
(is (= :invalid-options (get-in missing-value [:error :code])))))
|
||||
|
||||
(testing "sync config rejects auth-token key"
|
||||
(let [set-result (sync-command/build-action :sync-config-set {} ["auth-token" "secret"] nil)
|
||||
get-result (sync-command/build-action :sync-config-get {} ["auth-token"] nil)
|
||||
unset-result (sync-command/build-action :sync-config-unset {} ["auth-token"] nil)]
|
||||
(is (false? (:ok? set-result)))
|
||||
(is (= :invalid-options (get-in set-result [:error :code])))
|
||||
(is (false? (:ok? get-result)))
|
||||
(is (= :invalid-options (get-in get-result [:error :code])))
|
||||
(is (false? (:ok? unset-result)))
|
||||
(is (= :invalid-options (get-in unset-result [:error :code])))))
|
||||
|
||||
(testing "sync config accepts e2ee-password key"
|
||||
(let [result (sync-command/build-action :sync-config-set {} ["e2ee-password" "pw"] nil)]
|
||||
(is (true? (:ok? result)))
|
||||
@@ -63,7 +79,7 @@
|
||||
:pending-asset 0
|
||||
:pending-server 0}))
|
||||
(p/resolved {:ok true})))]
|
||||
(p/let [result (sync-command/execute {:type :sync-start
|
||||
(p/let [result (execute-with-runtime-auth {:type :sync-start
|
||||
:repo "logseq_db_demo"}
|
||||
{:data-dir "/tmp"})
|
||||
invoked-methods (map first @invoke-calls)]
|
||||
@@ -92,7 +108,7 @@
|
||||
:pending-asset 0
|
||||
:pending-server 0})
|
||||
(p/resolved {:ok true})))]
|
||||
(p/let [result (sync-command/execute {:type :sync-start
|
||||
(p/let [result (execute-with-runtime-auth {:type :sync-start
|
||||
:repo "logseq_db_demo"
|
||||
:wait-timeout-ms 20
|
||||
:wait-poll-interval-ms 0}
|
||||
@@ -118,7 +134,7 @@
|
||||
:pending-asset 0
|
||||
:pending-server 0})
|
||||
(p/resolved {:ok true})))]
|
||||
(p/let [result (sync-command/execute {:type :sync-start
|
||||
(p/let [result (execute-with-runtime-auth {:type :sync-start
|
||||
:repo "logseq_db_demo"
|
||||
:wait-timeout-ms 20
|
||||
:wait-poll-interval-ms 0}
|
||||
@@ -153,7 +169,7 @@
|
||||
:last-error {:code :decrypt-aes-key
|
||||
:message "decrypt-aes-key"}})))
|
||||
(p/resolved {:ok true})))]
|
||||
(p/let [result (sync-command/execute {:type :sync-start
|
||||
(p/let [result (execute-with-runtime-auth {:type :sync-start
|
||||
:repo "logseq_db_demo"
|
||||
:wait-timeout-ms 200
|
||||
:wait-poll-interval-ms 0}
|
||||
@@ -178,7 +194,8 @@
|
||||
(p/let [_ (sync-command/execute {:type :sync-stop
|
||||
:repo "logseq_db_demo"}
|
||||
{:data-dir "/tmp"})]
|
||||
(is (= [[{:data-dir "/tmp"} "logseq_db_demo"]]
|
||||
(is (= [[{:data-dir "/tmp"}
|
||||
"logseq_db_demo"]]
|
||||
@ensure-calls))
|
||||
(is (= [[:thread-api/set-db-sync-config false [{:ws-url nil
|
||||
:http-base nil
|
||||
@@ -200,14 +217,16 @@
|
||||
transport/invoke (fn [_ method direct-pass? args]
|
||||
(swap! invoke-calls conj [method direct-pass? args])
|
||||
(p/resolved {:ok true}))]
|
||||
(p/let [_ (sync-command/execute {:type :sync-upload
|
||||
(p/let [_ (execute-with-runtime-auth {:type :sync-upload
|
||||
:repo "logseq_db_demo"}
|
||||
{:data-dir "/tmp"})]
|
||||
(is (= [[{:data-dir "/tmp"} "logseq_db_demo"]]
|
||||
(is (= [[{:data-dir "/tmp"
|
||||
:auth-token "runtime-token"}
|
||||
"logseq_db_demo"]]
|
||||
@ensure-calls))
|
||||
(is (= [[:thread-api/set-db-sync-config false [{:ws-url nil
|
||||
:http-base nil
|
||||
:auth-token nil
|
||||
:auth-token "runtime-token"
|
||||
:e2ee-password nil}]]
|
||||
[:thread-api/db-sync-upload-graph false ["logseq_db_demo"]]]
|
||||
@invoke-calls))))
|
||||
@@ -231,7 +250,7 @@
|
||||
:graph-id "graph-1"}))
|
||||
|
||||
(p/resolved nil)))]
|
||||
(p/let [result (sync-command/execute {:type :sync-upload
|
||||
(p/let [result (execute-with-runtime-auth {:type :sync-upload
|
||||
:repo "logseq_db_demo"}
|
||||
{:data-dir "/tmp"})]
|
||||
(is (= :error (:status result)))
|
||||
@@ -261,26 +280,27 @@
|
||||
:thread-api/db-sync-download-graph-by-id
|
||||
(p/resolved {:ok true})
|
||||
(p/resolved nil)))]
|
||||
(p/let [_ (sync-command/execute {:type :sync-download
|
||||
(p/let [_ (execute-with-runtime-auth {:type :sync-download
|
||||
:repo "logseq_db_demo"
|
||||
:graph "demo"}
|
||||
{:base-url "http://example"
|
||||
:data-dir "/tmp"})]
|
||||
(is (= [[{:base-url "http://example"
|
||||
:create-empty-db? true
|
||||
:data-dir "/tmp"}
|
||||
:data-dir "/tmp"
|
||||
:auth-token "runtime-token"}
|
||||
"logseq_db_demo"]]
|
||||
@ensure-calls))
|
||||
(is (= [:thread-api/set-db-sync-config false [{:ws-url nil
|
||||
:http-base nil
|
||||
:auth-token nil
|
||||
:auth-token "runtime-token"
|
||||
:e2ee-password nil}]]
|
||||
(nth @invoke-calls 0)))
|
||||
(is (= [:thread-api/db-sync-list-remote-graphs false []]
|
||||
(nth @invoke-calls 1)))
|
||||
(is (= [:thread-api/set-db-sync-config false [{:ws-url nil
|
||||
:http-base nil
|
||||
:auth-token nil
|
||||
:auth-token "runtime-token"
|
||||
:e2ee-password nil}]]
|
||||
(nth @invoke-calls 2)))
|
||||
(let [[method direct-pass? args] (nth @invoke-calls 3)]
|
||||
@@ -312,30 +332,32 @@
|
||||
:thread-api/db-sync-download-graph-by-id
|
||||
(p/resolved {:ok true})
|
||||
(p/resolved nil)))]
|
||||
(p/let [_ (sync-command/execute {:type :sync-download
|
||||
(p/let [_ (execute-with-runtime-auth {:type :sync-download
|
||||
:repo "logseq_db_demo"
|
||||
:graph "demo"}
|
||||
{:graph "demo"
|
||||
:data-dir "/tmp"})]
|
||||
(is (= [[{:graph "demo"
|
||||
:create-empty-db? true
|
||||
:data-dir "/tmp"}
|
||||
:data-dir "/tmp"
|
||||
:auth-token "runtime-token"}
|
||||
"logseq_db_demo"]
|
||||
[{:graph "demo"
|
||||
:create-empty-db? true
|
||||
:data-dir "/tmp"}
|
||||
:data-dir "/tmp"
|
||||
:auth-token "runtime-token"}
|
||||
"logseq_db_demo"]]
|
||||
@ensure-calls))
|
||||
(is (= [:thread-api/set-db-sync-config false [{:ws-url nil
|
||||
:http-base nil
|
||||
:auth-token nil
|
||||
:auth-token "runtime-token"
|
||||
:e2ee-password nil}]]
|
||||
(nth @invoke-calls 0)))
|
||||
(is (= [:thread-api/db-sync-list-remote-graphs false []]
|
||||
(nth @invoke-calls 1)))
|
||||
(is (= [:thread-api/set-db-sync-config false [{:ws-url nil
|
||||
:http-base nil
|
||||
:auth-token nil
|
||||
:auth-token "runtime-token"
|
||||
:e2ee-password nil}]]
|
||||
(nth @invoke-calls 2)))
|
||||
(let [[method direct-pass? args] (nth @invoke-calls 3)]
|
||||
@@ -362,7 +384,7 @@
|
||||
:thread-api/db-sync-download-graph-by-id
|
||||
(p/resolved {:ok true})
|
||||
(p/resolved nil)))]
|
||||
(p/let [result (sync-command/execute {:type :sync-download
|
||||
(p/let [result (execute-with-runtime-auth {:type :sync-download
|
||||
:repo "logseq_db_demo"
|
||||
:graph "demo"}
|
||||
{:base-url "http://example"
|
||||
@@ -371,7 +393,7 @@
|
||||
(is (= :remote-graph-not-found (get-in result [:error :code])))
|
||||
(is (= [[:thread-api/set-db-sync-config false [{:ws-url nil
|
||||
:http-base nil
|
||||
:auth-token nil
|
||||
:auth-token "runtime-token"
|
||||
:e2ee-password nil}]]
|
||||
[:thread-api/db-sync-list-remote-graphs false []]]
|
||||
@invoke-calls))))
|
||||
@@ -396,7 +418,7 @@
|
||||
{:code :db-sync/incomplete-snapshot-frame
|
||||
:graph-id "remote-graph-id"}))
|
||||
(p/resolved nil)))]
|
||||
(p/let [result (sync-command/execute {:type :sync-download
|
||||
(p/let [result (execute-with-runtime-auth {:type :sync-download
|
||||
:repo "logseq_db_demo"
|
||||
:graph "demo"}
|
||||
{:base-url "http://example"
|
||||
@@ -426,7 +448,7 @@
|
||||
:thread-api/db-sync-download-graph-by-id
|
||||
(p/resolved {:ok true})
|
||||
(p/resolved nil)))]
|
||||
(p/let [result (sync-command/execute {:type :sync-download
|
||||
(p/let [result (execute-with-runtime-auth {:type :sync-download
|
||||
:repo "logseq_db_demo"
|
||||
:graph "demo"}
|
||||
{:data-dir "/tmp"})]
|
||||
@@ -444,8 +466,12 @@
|
||||
(deftest test-execute-sync-remote-graphs
|
||||
(async done
|
||||
(let [ensure-calls (atom [])
|
||||
invoke-calls (atom [])]
|
||||
(-> (p/with-redefs [cli-server/ensure-server! (fn [config repo]
|
||||
invoke-calls (atom [])
|
||||
auth-calls (atom [])]
|
||||
(-> (p/with-redefs [cli-auth/resolve-auth-token! (fn [config]
|
||||
(swap! auth-calls conj config)
|
||||
(p/resolved "resolved-token"))
|
||||
cli-server/ensure-server! (fn [config repo]
|
||||
(swap! ensure-calls conj [config repo])
|
||||
(p/resolved (assoc config :base-url "http://example")))
|
||||
transport/invoke (fn [_ method direct-pass? args]
|
||||
@@ -455,13 +481,18 @@
|
||||
{:base-url "http://example"
|
||||
:http-base "https://sync.example.com"
|
||||
:ws-url "wss://sync.example.com/sync/%s"
|
||||
:auth-token "test-token"
|
||||
:e2ee-password "pw"
|
||||
:data-dir "/tmp"})]
|
||||
(is (= [] @ensure-calls))
|
||||
(is (= [{:base-url "http://example"
|
||||
:http-base "https://sync.example.com"
|
||||
:ws-url "wss://sync.example.com/sync/%s"
|
||||
:e2ee-password "pw"
|
||||
:data-dir "/tmp"}]
|
||||
@auth-calls))
|
||||
(is (= [[:thread-api/set-db-sync-config false [{:ws-url "wss://sync.example.com/sync/%s"
|
||||
:http-base "https://sync.example.com"
|
||||
:auth-token "test-token"
|
||||
:auth-token "resolved-token"
|
||||
:e2ee-password "pw"}]]
|
||||
[:thread-api/db-sync-list-remote-graphs false []]]
|
||||
@invoke-calls))))
|
||||
@@ -469,6 +500,27 @@
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally done)))))
|
||||
|
||||
(deftest test-execute-sync-remote-graphs-missing-auth
|
||||
(async done
|
||||
(let [invoke-calls (atom [])]
|
||||
(-> (p/with-redefs [cli-auth/resolve-auth-token! (fn [_config]
|
||||
(p/rejected (ex-info "missing auth"
|
||||
{:code :missing-auth
|
||||
:hint "Run logseq login first."})))
|
||||
transport/invoke (fn [_ method direct-pass? args]
|
||||
(swap! invoke-calls conj [method direct-pass? args])
|
||||
(p/resolved []))]
|
||||
(p/let [result (sync-command/execute {:type :sync-remote-graphs}
|
||||
{:base-url "http://example"
|
||||
:data-dir "/tmp"})]
|
||||
(is (= :error (:status result)))
|
||||
(is (= :missing-auth (get-in result [:error :code])))
|
||||
(is (= "Run logseq login first." (get-in result [:error :context :hint])))
|
||||
(is (= [] @invoke-calls))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally done)))))
|
||||
|
||||
(deftest test-execute-sync-ensure-keys
|
||||
(async done
|
||||
(let [ensure-calls (atom [])
|
||||
@@ -479,13 +531,13 @@
|
||||
transport/invoke (fn [_ method direct-pass? args]
|
||||
(swap! invoke-calls conj [method direct-pass? args])
|
||||
(p/resolved {:ok true}))]
|
||||
(p/let [_ (sync-command/execute {:type :sync-ensure-keys}
|
||||
(p/let [_ (execute-with-runtime-auth {:type :sync-ensure-keys}
|
||||
{:base-url "http://example"
|
||||
:data-dir "/tmp"})]
|
||||
(is (= [] @ensure-calls))
|
||||
(is (= [[:thread-api/set-db-sync-config false [{:ws-url nil
|
||||
:http-base nil
|
||||
:auth-token nil
|
||||
:auth-token "runtime-token"
|
||||
:e2ee-password nil}]]
|
||||
[:thread-api/db-sync-ensure-user-rsa-keys false []]]
|
||||
@invoke-calls))))
|
||||
@@ -503,16 +555,18 @@
|
||||
transport/invoke (fn [_ method direct-pass? args]
|
||||
(swap! invoke-calls conj [method direct-pass? args])
|
||||
(p/resolved {:ok true}))]
|
||||
(p/let [_ (sync-command/execute {:type :sync-grant-access
|
||||
(p/let [_ (execute-with-runtime-auth {:type :sync-grant-access
|
||||
:repo "logseq_db_demo"
|
||||
:graph-id "graph-uuid"
|
||||
:email "user@example.com"}
|
||||
{:data-dir "/tmp"})]
|
||||
(is (= [[{:data-dir "/tmp"} "logseq_db_demo"]]
|
||||
(is (= [[{:data-dir "/tmp"
|
||||
:auth-token "runtime-token"}
|
||||
"logseq_db_demo"]]
|
||||
@ensure-calls))
|
||||
(is (= [[:thread-api/set-db-sync-config false [{:ws-url nil
|
||||
:http-base nil
|
||||
:auth-token nil
|
||||
:auth-token "runtime-token"
|
||||
:e2ee-password nil}]]
|
||||
[:thread-api/db-sync-grant-graph-access false ["logseq_db_demo" "graph-uuid" "user@example.com"]]]
|
||||
@invoke-calls))))
|
||||
@@ -531,9 +585,9 @@
|
||||
(swap! invoke-calls conj [method direct-pass? args])
|
||||
(p/resolved {:ok true}))]
|
||||
(p/let [_ (sync-command/execute {:type :sync-config-get
|
||||
:config-key :auth-token}
|
||||
:config-key :ws-url}
|
||||
{:base-url "http://example"
|
||||
:auth-token "abc"
|
||||
:ws-url "wss://sync.example.com/sync/%s"
|
||||
:data-dir "/tmp"})]
|
||||
(is (= [] @ensure-calls))
|
||||
(is (= [] @invoke-calls))))
|
||||
@@ -552,15 +606,15 @@
|
||||
(swap! update-calls conj [config updates])
|
||||
(merge {:ws-url "wss://old.example/sync/%s"} updates))]
|
||||
(p/let [_ (sync-command/execute {:type :sync-config-set
|
||||
:config-key :auth-token
|
||||
:config-value "token-value"}
|
||||
:config-key :ws-url
|
||||
:config-value "wss://sync.example.com/sync/%s"}
|
||||
{:base-url "http://example"
|
||||
:config-path "/tmp/cli.edn"
|
||||
:data-dir "/tmp"})]
|
||||
(is (= [[{:base-url "http://example"
|
||||
:config-path "/tmp/cli.edn"
|
||||
:data-dir "/tmp"}
|
||||
{:auth-token "token-value"}]]
|
||||
{:ws-url "wss://sync.example.com/sync/%s"}]]
|
||||
@update-calls))
|
||||
(is (= [] @invoke-calls))))
|
||||
(p/catch (fn [e]
|
||||
@@ -576,18 +630,17 @@
|
||||
(p/resolved nil))
|
||||
cli-config/update-config! (fn [config updates]
|
||||
(swap! update-calls conj [config updates])
|
||||
(dissoc {:ws-url "wss://old.example/sync/%s"
|
||||
:auth-token "token-value"}
|
||||
:auth-token))]
|
||||
(dissoc {:ws-url "wss://old.example/sync/%s"}
|
||||
:ws-url))]
|
||||
(p/let [_ (sync-command/execute {:type :sync-config-unset
|
||||
:config-key :auth-token}
|
||||
:config-key :ws-url}
|
||||
{:base-url "http://example"
|
||||
:config-path "/tmp/cli.edn"
|
||||
:data-dir "/tmp"})]
|
||||
(is (= [[{:base-url "http://example"
|
||||
:config-path "/tmp/cli.edn"
|
||||
:data-dir "/tmp"}
|
||||
{:auth-token nil}]]
|
||||
{:ws-url nil}]]
|
||||
@update-calls))
|
||||
(is (= [] @invoke-calls))))
|
||||
(p/catch (fn [e]
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
(is (not (string/includes? plain-summary "--retries")))
|
||||
(is (string/includes? plain-summary "Graph Inspect and Edit"))
|
||||
(is (string/includes? plain-summary "Graph Management"))
|
||||
(is (string/includes? plain-summary "Authentication"))
|
||||
(is (string/includes? plain-summary "list"))
|
||||
(is (string/includes? plain-summary "upsert"))
|
||||
(is (string/includes? plain-summary "remove"))
|
||||
@@ -68,6 +69,8 @@
|
||||
(is (string/includes? plain-summary "graph"))
|
||||
(is (string/includes? plain-summary "server"))
|
||||
(is (string/includes? plain-summary "sync"))
|
||||
(is (string/includes? plain-summary "login"))
|
||||
(is (string/includes? plain-summary "logout"))
|
||||
(is (string/includes? plain-summary "Path to db-worker data dir (default ~/logseq/graphs)"))
|
||||
(is (contains-bold? summary "list page"))
|
||||
(is (contains-bold? summary "list tag"))
|
||||
@@ -90,6 +93,8 @@
|
||||
(is (contains-bold? summary "server start"))
|
||||
(is (contains-bold? summary "sync status"))
|
||||
(is (contains-bold? summary "sync start"))
|
||||
(is (contains-bold? summary "login"))
|
||||
(is (contains-bold? summary "logout"))
|
||||
(is (contains-bold? summary "--help"))
|
||||
(is (contains-bold? summary "--graph"))
|
||||
(is (re-find #"\u001b\[[0-9;]*mCommands\u001b\[[0-9;]*m:" summary))
|
||||
@@ -209,6 +214,27 @@
|
||||
(is (seq lines))
|
||||
(is (every? #(not (string/includes? % "[options]")) lines)))))
|
||||
|
||||
(deftest test-parse-args-help-auth-commands
|
||||
(testing "login command shows help"
|
||||
(let [result (binding [style/*color-enabled?* true]
|
||||
(commands/parse-args ["login" "--help"]))
|
||||
summary (:summary result)
|
||||
plain-summary (strip-ansi summary)]
|
||||
(is (true? (:help? result)))
|
||||
(is (string/includes? plain-summary "Usage: logseq login"))
|
||||
(is (string/includes? plain-summary "Global options:"))
|
||||
(is (string/includes? plain-summary "Command options:"))))
|
||||
|
||||
(testing "logout command shows help"
|
||||
(let [result (binding [style/*color-enabled?* true]
|
||||
(commands/parse-args ["logout" "--help"]))
|
||||
summary (:summary result)
|
||||
plain-summary (strip-ansi summary)]
|
||||
(is (true? (:help? result)))
|
||||
(is (string/includes? plain-summary "Usage: logseq logout"))
|
||||
(is (string/includes? plain-summary "Global options:"))
|
||||
(is (string/includes? plain-summary "Command options:")))))
|
||||
|
||||
(deftest test-parse-args-help-sync-group
|
||||
(testing "sync group shows subcommands"
|
||||
(let [result (binding [style/*color-enabled?* true]
|
||||
|
||||
@@ -27,21 +27,30 @@
|
||||
(str "{:graph \"file-repo\" "
|
||||
":data-dir \"file-data\" "
|
||||
":timeout-ms 111 "
|
||||
":output-format :edn}"))
|
||||
":login-timeout-ms 444 "
|
||||
":logout-timeout-ms 555 "
|
||||
":output-format :edn "
|
||||
":auth-token \"file-secret\"}"))
|
||||
env {"LOGSEQ_CLI_GRAPH" "env-repo"
|
||||
"LOGSEQ_CLI_DATA_DIR" "env-data"
|
||||
"LOGSEQ_CLI_TIMEOUT_MS" "222"
|
||||
"LOGSEQ_CLI_LOGIN_TIMEOUT_MS" "666"
|
||||
"LOGSEQ_CLI_LOGOUT_TIMEOUT_MS" "777"
|
||||
"LOGSEQ_CLI_OUTPUT" "json"}
|
||||
opts {:config-path cfg-path
|
||||
:graph "cli-repo"
|
||||
:data-dir "cli-data"
|
||||
:timeout-ms 333
|
||||
:login-timeout-ms 888
|
||||
:logout-timeout-ms 999
|
||||
:output-format :human}
|
||||
result (with-env env #(config/resolve-config opts))]
|
||||
(is (= cfg-path (:config-path result)))
|
||||
(is (= "cli-repo" (:graph result)))
|
||||
(is (= "cli-data" (:data-dir result)))
|
||||
(is (= 333 (:timeout-ms result)))
|
||||
(is (= 888 (:login-timeout-ms result)))
|
||||
(is (= 999 (:logout-timeout-ms result)))
|
||||
(is (nil? (:auth-token result)))
|
||||
(is (nil? (:retries result)))
|
||||
(is (= :human (:output-format result)))))
|
||||
@@ -82,7 +91,10 @@
|
||||
(let [result (config/resolve-config {})
|
||||
expected-config-path (node-path/join (.homedir os) "logseq" "cli.edn")]
|
||||
(is (= expected-config-path (:config-path result)))
|
||||
(is (= "~/logseq/graphs" (:data-dir result)))))
|
||||
(is (= "~/logseq/graphs" (:data-dir result)))
|
||||
(is (= 10000 (:timeout-ms result)))
|
||||
(is (= 300000 (:login-timeout-ms result)))
|
||||
(is (= 120000 (:logout-timeout-ms result)))))
|
||||
|
||||
(deftest test-update-config
|
||||
(let [dir (node-helper/create-tmp-dir "cli")
|
||||
@@ -96,7 +108,7 @@
|
||||
(deftest test-update-config-strips-removed-options
|
||||
(let [dir (node-helper/create-tmp-dir "cli")
|
||||
cfg-path (node-path/join dir "cli.edn")
|
||||
_ (fs/writeFileSync cfg-path "{:graph \"old\"}")
|
||||
_ (fs/writeFileSync cfg-path "{:graph \"old\" :auth-token \"legacy-secret\"}")
|
||||
_ (config/update-config! {:config-path cfg-path}
|
||||
{:graph "new"
|
||||
:auth-token "secret"
|
||||
@@ -104,7 +116,7 @@
|
||||
contents (.toString (fs/readFileSync cfg-path) "utf8")
|
||||
parsed (reader/read-string contents)]
|
||||
(is (= "new" (:graph parsed)))
|
||||
(is (= "secret" (:auth-token parsed)))
|
||||
(is (not (contains? parsed :auth-token)))
|
||||
(is (not (contains? parsed :retries)))))
|
||||
|
||||
(deftest test-update-config-removes-nil-values
|
||||
|
||||
@@ -341,18 +341,6 @@
|
||||
(is (string/includes? result "Sync download"))
|
||||
(is (string/includes? result "demo-graph")))))
|
||||
|
||||
(deftest test-human-output-sync-config-get-token-redaction
|
||||
(testing "sync config get auth-token redacts value in human output"
|
||||
(let [token "super-secret-token-value"
|
||||
result (format/format-result {:status :ok
|
||||
:command :sync-config-get
|
||||
:data {:key :auth-token
|
||||
:value token}}
|
||||
{:output-format nil})]
|
||||
(is (string/includes? result "auth-token"))
|
||||
(is (string/includes? result "[REDACTED]"))
|
||||
(is (not (string/includes? result token))))))
|
||||
|
||||
(deftest test-human-output-sync-config-get-e2ee-password-redaction
|
||||
(testing "sync config get e2ee-password redacts value in human output"
|
||||
(let [password "super-secret-password"
|
||||
@@ -365,6 +353,46 @@
|
||||
(is (string/includes? result "[REDACTED]"))
|
||||
(is (not (string/includes? result password))))))
|
||||
|
||||
(deftest test-human-output-auth-commands
|
||||
(testing "login human output reports auth path and user metadata without tokens"
|
||||
(let [token "secret-token-value"
|
||||
result (format/format-result {:status :ok
|
||||
:command :login
|
||||
:data {:auth-path "/tmp/auth.json"
|
||||
:email "user@example.com"
|
||||
:sub "user-123"
|
||||
:authorize-url "https://example.com/oauth2/authorize?..."
|
||||
:id-token token}}
|
||||
{:output-format nil})]
|
||||
(is (string/includes? result "Login successful"))
|
||||
(is (string/includes? result "user@example.com"))
|
||||
(is (string/includes? result "/tmp/auth.json"))
|
||||
(is (not (string/includes? result token)))))
|
||||
|
||||
(testing "logout human output reports whether auth was removed"
|
||||
(let [result (format/format-result {:status :ok
|
||||
:command :logout
|
||||
:data {:auth-path "/tmp/auth.json"
|
||||
:deleted? true
|
||||
:opened? true
|
||||
:logout-completed? true}}
|
||||
{:output-format nil})]
|
||||
(is (string/includes? result "Logged out"))
|
||||
(is (string/includes? result "/tmp/auth.json"))
|
||||
(is (string/includes? result "Cognito logout: completed"))))
|
||||
|
||||
(testing "logout human output is still successful when auth file is absent"
|
||||
(let [result (format/format-result {:status :ok
|
||||
:command :logout
|
||||
:data {:auth-path "/tmp/auth.json"
|
||||
:deleted? false
|
||||
:opened? true
|
||||
:logout-completed? true}}
|
||||
{:output-format nil})]
|
||||
(is (string/includes? result "Already logged out"))
|
||||
(is (string/includes? result "/tmp/auth.json"))
|
||||
(is (string/includes? result "Cognito logout: completed")))))
|
||||
|
||||
(deftest test-human-output-graph-info
|
||||
(testing "graph info includes key metadata lines and kv section"
|
||||
(let [result (format/format-result {:status :ok
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
[clojure.string :as string]
|
||||
[frontend.test.node-helper :as node-helper]
|
||||
[frontend.worker.db-worker-node-lock :as db-lock]
|
||||
[logseq.cli.auth :as cli-auth]
|
||||
[logseq.cli.command.core :as command-core]
|
||||
[logseq.cli.command.show :as show-command]
|
||||
[logseq.cli.config :as cli-config]
|
||||
@@ -185,6 +186,136 @@
|
||||
[payload]
|
||||
(first (get-in payload [:data :result])))
|
||||
|
||||
(defn- sample-auth
|
||||
([]
|
||||
(sample-auth {}))
|
||||
([overrides]
|
||||
(merge {:provider "cognito"
|
||||
:id-token "id-token-1"
|
||||
:access-token "access-token-1"
|
||||
:refresh-token "refresh-token-1"
|
||||
:expires-at (+ (js/Date.now) 3600000)
|
||||
:sub "user-123"
|
||||
:email "user@example.com"
|
||||
:updated-at 1735686000000}
|
||||
overrides)))
|
||||
|
||||
(deftest test-cli-login-integration
|
||||
(async done
|
||||
(let [data-dir (node-helper/create-tmp-dir "cli-login-data")
|
||||
cfg-path (node-path/join (node-helper/create-tmp-dir "cli") "cli.edn")
|
||||
auth-path (node-path/join (node-helper/create-tmp-dir "cli-auth") "auth.json")
|
||||
open-calls (atom [])
|
||||
auth-data (sample-auth)]
|
||||
(fs/writeFileSync cfg-path "{:output-format :json}")
|
||||
(let [promise
|
||||
(p/with-redefs [cli-auth/default-auth-path (fn [] auth-path)
|
||||
cli-auth/open-browser! (fn [authorize-url]
|
||||
(swap! open-calls conj authorize-url)
|
||||
(let [parsed (js/URL. authorize-url)
|
||||
redirect-uri (.get (.-searchParams parsed) "redirect_uri")
|
||||
state (.get (.-searchParams parsed) "state")]
|
||||
(-> (js/fetch (str redirect-uri "?code=integration-code&state=" state))
|
||||
(p/then (fn [_]
|
||||
{:opened? true})))))
|
||||
cli-auth/exchange-code-for-auth! (fn [_opts payload]
|
||||
(is (= "integration-code" (:code payload)))
|
||||
(p/resolved auth-data))]
|
||||
(p/let [result (run-cli ["login"] data-dir cfg-path)
|
||||
payload (parse-json-output-safe result "login")
|
||||
stored (cli-auth/read-auth-file {:auth-path auth-path})]
|
||||
(is (= 0 (:exit-code result)))
|
||||
(is (= "ok" (:status payload)))
|
||||
(is (= auth-path (get-in payload [:data :auth-path])))
|
||||
(is (= "user@example.com" (get-in payload [:data :email])))
|
||||
(is (= 1 (count @open-calls)))
|
||||
(is (= auth-data stored))
|
||||
(is (fs/existsSync auth-path))))]
|
||||
(-> promise
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally (fn []
|
||||
(done))))))))
|
||||
|
||||
(deftest test-cli-logout-integration
|
||||
(async done
|
||||
(let [data-dir (node-helper/create-tmp-dir "cli-logout-data")
|
||||
cfg-path (node-path/join (node-helper/create-tmp-dir "cli") "cli.edn")
|
||||
auth-path (node-path/join (node-helper/create-tmp-dir "cli-auth") "auth.json")
|
||||
open-calls (atom [])]
|
||||
(fs/writeFileSync cfg-path "{:output-format :json}")
|
||||
(cli-auth/write-auth-file! {:auth-path auth-path} (sample-auth))
|
||||
(let [promise
|
||||
(p/with-redefs [cli-auth/default-auth-path (fn [] auth-path)
|
||||
cli-auth/open-browser! (fn [url]
|
||||
(swap! open-calls conj url)
|
||||
(let [parsed (js/URL. url)
|
||||
logout-uri (.get (.-searchParams parsed) "logout_uri")]
|
||||
(-> (js/fetch logout-uri)
|
||||
(p/then (fn [_]
|
||||
{:opened? true})))))]
|
||||
(p/let [result (run-cli ["logout"] data-dir cfg-path)
|
||||
payload (parse-json-output-safe result "logout")]
|
||||
(is (= 0 (:exit-code result)))
|
||||
(is (= "ok" (:status payload)))
|
||||
(is (= 1 (count @open-calls)))
|
||||
(is (= auth-path (get-in payload [:data :auth-path])))
|
||||
(is (= true (get-in payload [:data :deleted?])))
|
||||
(is (= true (get-in payload [:data :opened?])))
|
||||
(is (= true (get-in payload [:data :logout-completed?])))
|
||||
(is (not (fs/existsSync auth-path)))))]
|
||||
(-> promise
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally (fn []
|
||||
(done))))))))
|
||||
|
||||
(deftest test-cli-sync-remote-graphs-refreshes-auth-file-and-injects-runtime-token
|
||||
(async done
|
||||
(let [data-dir (node-helper/create-tmp-dir "cli-sync-auth")
|
||||
cfg-path (node-path/join (node-helper/create-tmp-dir "cli") "cli.edn")
|
||||
auth-path (node-path/join (node-helper/create-tmp-dir "cli-auth") "auth.json")
|
||||
invoke-calls (atom [])
|
||||
expired-auth (sample-auth {:id-token "expired-token"
|
||||
:access-token "expired-access-token"
|
||||
:expires-at 0})
|
||||
refreshed-auth (sample-auth {:id-token "fresh-token"
|
||||
:access-token "fresh-access-token"
|
||||
:expires-at (+ (js/Date.now) 7200000)
|
||||
:updated-at 1735689600000})]
|
||||
(fs/writeFileSync cfg-path "{:output-format :json}")
|
||||
(cli-auth/write-auth-file! {:auth-path auth-path} expired-auth)
|
||||
(let [promise
|
||||
(p/with-redefs [cli-auth/default-auth-path (fn [] auth-path)
|
||||
cli-auth/refresh-auth! (fn [_opts _auth-data]
|
||||
(p/resolved refreshed-auth))
|
||||
cli-server/list-graphs (fn [_config]
|
||||
["demo"])
|
||||
cli-server/ensure-server! (fn [config _repo]
|
||||
(p/resolved (assoc config :base-url "http://example")))
|
||||
transport/invoke (fn [_ method direct-pass? args]
|
||||
(swap! invoke-calls conj [method direct-pass? args])
|
||||
(case method
|
||||
:thread-api/set-db-sync-config
|
||||
(p/resolved nil)
|
||||
:thread-api/db-sync-list-remote-graphs
|
||||
(p/resolved [])
|
||||
(p/resolved nil)))]
|
||||
(p/let [result (run-cli ["sync" "remote-graphs"] data-dir cfg-path)
|
||||
payload (parse-json-output-safe result "sync remote-graphs")
|
||||
stored (cli-auth/read-auth-file {:auth-path auth-path})]
|
||||
(is (= 0 (:exit-code result)))
|
||||
(is (= "ok" (:status payload)))
|
||||
(is (= :thread-api/set-db-sync-config (ffirst @invoke-calls)))
|
||||
(is (= "fresh-token" (get-in (nth @invoke-calls 0) [2 0 :auth-token])))
|
||||
(is (= "fresh-token" (:id-token stored)))
|
||||
(is (= "fresh-access-token" (:access-token stored)))))]
|
||||
(-> promise
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally (fn []
|
||||
(done))))))))
|
||||
|
||||
(deftest test-cli-sync-download-and-start-readiness-with-mocked-sync
|
||||
(async done
|
||||
(let [data-dir (node-helper/create-tmp-dir "db-worker-sync-cli")
|
||||
@@ -199,7 +330,9 @@
|
||||
_ (is (= 0 (:exit-code create-result)))
|
||||
_ (is (= "ok" (:status create-payload)))
|
||||
[download-result start-result]
|
||||
(p/with-redefs [cli-server/ensure-server! (fn [config _repo]
|
||||
(p/with-redefs [cli-auth/resolve-auth-token! (fn [_config]
|
||||
(p/resolved "runtime-token"))
|
||||
cli-server/ensure-server! (fn [config _repo]
|
||||
(p/resolved (assoc config :base-url "http://example")))
|
||||
transport/invoke (fn [_ method _direct-pass? args]
|
||||
(swap! invoke-calls conj [method args])
|
||||
@@ -268,7 +401,9 @@
|
||||
_ (is (= 0 (:exit-code create-result)))
|
||||
_ (is (= "ok" (:status create-payload)))
|
||||
upload-result (p/with-redefs
|
||||
[cli-server/ensure-server! (fn [config _repo]
|
||||
[cli-auth/resolve-auth-token! (fn [_config]
|
||||
(p/resolved "runtime-token"))
|
||||
cli-server/ensure-server! (fn [config _repo]
|
||||
(p/resolved (assoc config :base-url "http://example")))
|
||||
transport/invoke (fn [_ method _direct-pass? args]
|
||||
(swap! invoke-calls conj [method args])
|
||||
@@ -287,7 +422,7 @@
|
||||
(is (= "created-graph-id" (get-in upload-payload [:data :graph-id])))
|
||||
(is (= [[:thread-api/set-db-sync-config [{:ws-url nil
|
||||
:http-base nil
|
||||
:auth-token nil
|
||||
:auth-token "runtime-token"
|
||||
:e2ee-password nil}]]
|
||||
[:thread-api/db-sync-upload-graph ["logseq_db_sync-upload-graph"]]]
|
||||
@invoke-calls)))
|
||||
@@ -308,7 +443,9 @@
|
||||
_ (is (= 0 (:exit-code create-result)))
|
||||
_ (is (= "ok" (:status create-payload)))
|
||||
[upload-result info-result]
|
||||
(p/with-redefs [cli-server/ensure-server! (fn [config _repo]
|
||||
(p/with-redefs [cli-auth/resolve-auth-token! (fn [_config]
|
||||
(p/resolved "runtime-token"))
|
||||
cli-server/ensure-server! (fn [config _repo]
|
||||
(p/resolved (assoc config :base-url "http://example")))
|
||||
transport/invoke (fn [_ method _direct-pass? args]
|
||||
(swap! invoke-calls conj [method args])
|
||||
|
||||
15
src/test/logseq/common/cognito_config_test.cljs
Normal file
15
src/test/logseq/common/cognito_config_test.cljs
Normal file
@@ -0,0 +1,15 @@
|
||||
(ns logseq.common.cognito-config-test
|
||||
(:require [cljs.test :refer [deftest is]]
|
||||
[frontend.config :as config]
|
||||
[logseq.common.cognito-config :as cognito-config]
|
||||
["fs" :as fs]))
|
||||
|
||||
(deftest test-shared-cognito-config-matches-frontend-config
|
||||
(is (= config/LOGIN-URL cognito-config/LOGIN-URL))
|
||||
(is (= config/COGNITO-CLIENT-ID cognito-config/COGNITO-CLIENT-ID))
|
||||
(is (= config/OAUTH-DOMAIN cognito-config/OAUTH-DOMAIN)))
|
||||
|
||||
(deftest test-logseq-cli-build-enables-prod-file-sync-by-default
|
||||
(let [shadow-config (.toString (fs/readFileSync "shadow-cljs.edn") "utf8")]
|
||||
(is (re-find #"(?s):logseq-cli\s+\{:target :node-script.*?logseq\.common\.cognito-config/ENABLE-FILE-SYNC-PRODUCTION #shadow/env \[\"ENABLE_FILE_SYNC_PRODUCTION\" :as :bool :default true\]"
|
||||
shadow-config))))
|
||||
Reference in New Issue
Block a user