;; This Source Code Form is subject to the terms of the Mozilla Public ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; ;; Copyright (c) KALEIDOS INC (ns performance-tests.render-wasm.server-test (:require ["fs" :as fs] ["http" :as http] ["net" :as net] ["os" :as os] ["path" :as path] [benches.render-wasm.node.server :as server] [cljs.test :as t :include-macros true])) (def ^:private temporary-root (atom nil)) (def ^:private wasm-bytes [0 97 115 109 1 0 0 0]) (defn- write-file [file content] (fs/mkdirSync (path/dirname file) #js {:recursive true}) (fs/writeFileSync file content)) (t/use-fixtures :each {:before (fn [] (let [root (fs/mkdtempSync (path/join (os/tmpdir) "penpot-render-http-")) dir (path/join root "selected")] (reset! temporary-root root) (write-file (path/join dir "index.html") "selected") (write-file (path/join dir "browser" "browser.js") "export const selected = true;") (write-file (path/join dir "cases.cjs") "module.exports = {};") (write-file (path/join dir "renderer" "render-wasm.wasm") (js/Buffer.from (to-array wasm-bytes))) (write-file (path/join root "selected-outside" "secret.txt") "private sibling") (write-file (path/join root "outside.txt") "private file"))) :after #(fs/rmSync @temporary-root #js {:recursive true :force true})}) (defn- build [] {:id "selected-http-build" :dir (path/join @temporary-root "selected")}) (defn- request [host request-path method] (js/Promise. (fn [resolve reject] (let [address (js/URL. (:base-url host)) req (http/request ;; Supplying a raw path avoids URL normalization hiding traversal. #js {:hostname (.-hostname address) :port (.-port address) :path request-path :method method :agent false} (fn [response] (let [chunks (atom [])] (.on ^js response "data" #(swap! chunks conj %)) (.once ^js response "error" reject) (.once ^js response "end" #(resolve {:status (.-statusCode response) :headers (js->clj (.-headers response)) :body (js/Buffer.concat (to-array @chunks))})))))] (.once ^js req "error" reject) (.setTimeout ^js req 3000 #(.destroy ^js req (js/Error. "test request timed out"))) (.end ^js req))))) (defn- ^:async request-failure [host] (try (await (request host "/" "GET")) nil (catch :default error error))) (t/deftest ^:async serves-only-the-selected-build-with-head-and-artifact-mime-types (let [selected (build) host (await (server/open selected))] (try (t/is (= selected (:build host))) (t/is (re-matches #"http://127\.0\.0\.1:[0-9]+" (:base-url host))) (let [index (await (request host "/?unused=query" "GET")) browser (await (request host "/browser/browser.js" "GET")) cjs (await (request host "/cases.cjs" "GET")) wasm (await (request host "/renderer/render-wasm.wasm" "GET")) head (await (request host "/renderer/render-wasm.wasm" "HEAD"))] (t/is (= 200 (:status index))) (t/is (= "selected" (.toString (:body index) "utf8"))) (t/is (= "text/html; charset=utf-8" (get-in index [:headers "content-type"]))) (t/is (= "export const selected = true;" (.toString (:body browser) "utf8"))) (t/is (= "application/javascript" (get-in browser [:headers "content-type"]))) (t/is (= "application/javascript" (get-in cjs [:headers "content-type"]))) (t/is (= "application/wasm" (get-in wasm [:headers "content-type"]))) (t/is (zero? (js/Buffer.compare (:body wasm) (js/Buffer.from (to-array wasm-bytes))))) (t/is (= 200 (:status head))) (t/is (= "8" (get-in head [:headers "content-length"]))) (t/is (zero? (.-length (:body head))))) (t/is (= 404 (:status (await (request host "/selected-outside/secret.txt" "GET"))))) (t/is (= 405 (:status (await (request host "/" "POST"))))) (finally (await (server/close host)))))) (t/deftest ^:async rejects-raw-traversal-and-outside-file-and-directory-symlinks (let [selected (build) dir (:dir selected)] (fs/symlinkSync (path/join @temporary-root "outside.txt") (path/join dir "escape.txt")) (fs/symlinkSync (path/join @temporary-root "selected-outside") (path/join dir "linked") "dir") (let [host (await (server/open selected))] (try (doseq [request-path ["/../selected-outside/secret.txt" "/%2e%2e/selected-outside/secret.txt" "/%2e%2e%2fselected-outside%2fsecret.txt" "/%5c..%5cselected-outside%5csecret.txt" "/escape.txt" "/linked/secret.txt"]] (let [response (await (request host request-path "GET"))] (t/is (= 403 (:status response)) request-path) (t/is (= "forbidden" (.toString (:body response) "utf8")) request-path))) (t/is (= 400 (:status (await (request host "/%zz" "GET"))))) (t/is (= 403 (:status (await (request host "/%00" "GET"))))) (t/is (= 404 (:status (await (request host "/missing-file" "GET"))))) (t/is (= 404 (:status (await (request host "/browser" "GET"))))) (finally (await (server/close host))))))) (defn- connect [host] (js/Promise. (fn [resolve reject] (let [address (js/URL. (:base-url host)) socket (net/createConnection #js {:host "127.0.0.1" :port (js/Number (.-port address))})] (.once ^js socket "error" reject) (.once ^js socket "connect" #(resolve socket)))))) (defn- bounded [promise] (js/Promise. (fn [resolve reject] (let [timer (js/setTimeout #(reject (js/Error. "test shutdown timed out")) 3000)] (.then promise (fn [value] (js/clearTimeout timer) (resolve value)) (fn [error] (js/clearTimeout timer) (reject error))))))) (t/deftest ^:async close-destroys-owned-connections-and-keeps-other-servers-live (let [host (await (server/open (build)))] (try (let [other (await (server/open (build)))] (try (let [socket (await (connect host)) closed (js/Promise. (fn [resolve _] (.once ^js socket "close" resolve)))] (try (await (bounded (js/Promise.all (to-array [(server/close host) (server/close host)])))) (await (bounded closed)) (t/is (some? (await (request-failure host)))) (let [response (await (request other "/" "GET"))] (t/is (= 200 (:status response))) (t/is (= "selected" (.toString (:body response) "utf8")))) (await (server/close host)) (finally (.destroy ^js socket)))) (finally (await (server/close other))))) (finally (await (server/close host)))))) (defn- ^:async startup-failure [opening] (try (let [host (await opening)] (await (server/close host)) nil) (catch :default error error))) (t/deftest ^:async startup-rejects-already-aborted-and-in-flight-signals (let [already (js/AbortController.)] (.abort already) (let [error (await (startup-failure (server/open (build) {:signal (.-signal already)})))] (t/is (= ::server/aborted (:type (ex-data error)))))) (let [controller (js/AbortController.) opening (server/open (build) {:signal (.-signal controller)})] (.abort controller) (let [error (await (startup-failure opening))] (t/is (= ::server/aborted (:type (ex-data error)))))) (let [host (await (server/open (build) {:startup-timeout-ms 3000}))] (try (t/is (= 200 (:status (await (request host "/" "GET"))))) (finally (await (server/close host))))))