| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "io" |
| 5 | "net/http" |
| 6 | "net/http/httptest" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | ) |
| 12 | |
| 13 | func withPreviewWorkspace(t *testing.T) string { |
| 14 | t.Helper() |
| 15 | original, err := os.Getwd() |
| 16 | if err != nil { |
| 17 | t.Fatal(err) |
| 18 | } |
| 19 | dir := t.TempDir() |
| 20 | if err := os.Chdir(dir); err != nil { |
| 21 | t.Fatal(err) |
| 22 | } |
| 23 | t.Cleanup(func() { _ = os.Chdir(original) }) |
| 24 | return dir |
| 25 | } |
| 26 | |
| 27 | func TestHTMLPreviewBindsLocalDependencies(t *testing.T) { |
| 28 | withPreviewWorkspace(t) |
| 29 | if err := os.MkdirAll("assets", 0o755); err != nil { |
| 30 | t.Fatal(err) |
| 31 | } |
| 32 | files := map[string]string{ |
| 33 | "index.html": `<!doctype html><link rel="stylesheet" href="assets/app.css"><script src="assets/app.js"></script><img src="assets/icon.png">`, |
| 34 | "assets/app.css": "body { color: green }", |
| 35 | "assets/app.js": "document.body.dataset.ready = 'yes'", |
| 36 | "assets/icon.png": "png-bytes", |
| 37 | } |
| 38 | for name, body := range files { |
| 39 | if err := os.WriteFile(filepath.FromSlash(name), []byte(body), 0o644); err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | app := NewApp() |
| 45 | preview := app.ReadFile("index.html") |
| 46 | if preview.Err != "" || preview.Kind != "html" || preview.URL == "" { |
| 47 | t.Fatalf("HTML preview = %+v", preview) |
| 48 | } |
| 49 | tokenRoot := strings.TrimSuffix(preview.URL, "/index.html") |
| 50 | handler := app.workspaceMediaMiddleware()(http.NotFoundHandler()) |
| 51 | for name, want := range files { |
| 52 | requestPath := tokenRoot + "/" + name |
| 53 | recorder := httptest.NewRecorder() |
| 54 | handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, requestPath, nil)) |
| 55 | if recorder.Code != http.StatusOK || recorder.Body.String() != want { |
| 56 | t.Fatalf("GET %s = %d %q, want 200 %q", requestPath, recorder.Code, recorder.Body.String(), want) |
| 57 | } |
| 58 | } |
| 59 | app.RevokeWorkspaceMediaPreview(preview.URL) |
| 60 | recorder := httptest.NewRecorder() |
| 61 | handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, preview.URL, nil)) |
| 62 | if recorder.Code != http.StatusNotFound { |
| 63 | t.Fatalf("revoked document preview response = %d, want 404", recorder.Code) |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | func TestHTMLPreviewDecodesDependencyPathsExactlyOnce(t *testing.T) { |
| 68 | withPreviewWorkspace(t) |
| 69 | files := map[string]string{ |
| 70 | "raw%20name.js": "literal-percent", |
| 71 | "raw name.js": "space", |
| 72 | "100%.css": "literal-percent-sign", |
| 73 | "中文#.png": "unicode-hash", |
| 74 | } |
| 75 | for name, body := range files { |
| 76 | if err := os.WriteFile(name, []byte(body), 0o644); err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | } |
| 80 | htmlBody := `<script src="raw%2520name.js"></script><script src="raw%20name.js"></script>` + |
| 81 | `<link href="100%25.css"><img src="%E4%B8%AD%E6%96%87%23.png">` |
| 82 | if err := os.WriteFile("index.html", []byte(htmlBody), 0o644); err != nil { |
| 83 | t.Fatal(err) |
| 84 | } |
| 85 | app := NewApp() |
| 86 | preview := app.ReadFile("index.html") |
| 87 | if preview.Err != "" || preview.URL == "" { |
| 88 | t.Fatalf("HTML preview = %+v", preview) |
| 89 | } |
| 90 | tokenRoot := strings.TrimSuffix(preview.URL, "/index.html") |
| 91 | handler := app.workspaceMediaMiddleware()(http.NotFoundHandler()) |
| 92 | requests := map[string]string{ |
| 93 | "raw%2520name.js": "literal-percent", |
| 94 | "raw%20name.js": "space", |
| 95 | "100%25.css": "literal-percent-sign", |
| 96 | "%E4%B8%AD%E6%96%87%23.png": "unicode-hash", |
| 97 | } |
| 98 | for encoded, want := range requests { |
| 99 | recorder := httptest.NewRecorder() |
| 100 | handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, tokenRoot+"/"+encoded, nil)) |
| 101 | if recorder.Code != http.StatusOK || recorder.Body.String() != want { |
| 102 | t.Fatalf("GET %q = %d %q, want 200 %q", encoded, recorder.Code, recorder.Body.String(), want) |
| 103 | } |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | func TestHTMLPreviewRejectsEncodedTraversalAndNUL(t *testing.T) { |
| 108 | withPreviewWorkspace(t) |
| 109 | for _, source := range []string{"%2e%2e/outside.js", "asset%00.js"} { |
| 110 | if err := os.WriteFile("index.html", []byte(`<script src="`+source+`"></script>`), 0o644); err != nil { |
| 111 | t.Fatal(err) |
| 112 | } |
| 113 | if preview := NewApp().ReadFile("index.html"); preview.URL != "" || preview.Err == "" { |
| 114 | t.Fatalf("unsafe dependency %q = %+v", source, preview) |
| 115 | } |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | func TestHTMLPreviewFailsBeforePublishingIncompleteBundle(t *testing.T) { |
| 120 | withPreviewWorkspace(t) |
| 121 | if err := os.WriteFile("index.html", []byte(`<script src="missing.js"></script>`), 0o644); err != nil { |
| 122 | t.Fatal(err) |
| 123 | } |
| 124 | preview := NewApp().ReadFile("index.html") |
| 125 | if preview.URL != "" || !strings.Contains(preview.Err, "missing.js") { |
| 126 | t.Fatalf("incomplete HTML preview = %+v", preview) |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | func TestWorkspaceBrowserPreviewUsesRevocableLoopbackOrigin(t *testing.T) { |
| 131 | withPreviewWorkspace(t) |
| 132 | if err := os.WriteFile("index.html", []byte("<!doctype html><title>preview</title>"), 0o644); err != nil { |
| 133 | t.Fatal(err) |
| 134 | } |
| 135 | app := NewApp() |
| 136 | previewURL, err := app.CreateWorkspaceBrowserPreviewForTab("", "index.html") |
| 137 | if err != nil { |
| 138 | t.Fatal(err) |
| 139 | } |
| 140 | if !strings.HasPrefix(previewURL, "http://127.0.0.1:") || !strings.Contains(previewURL, "/__reasonix_workspace_media/") { |
| 141 | t.Fatalf("browser preview URL = %q", previewURL) |
| 142 | } |
| 143 | response, err := http.Get(previewURL) //nolint:gosec -- loopback URL created by the test app |
| 144 | if err != nil { |
| 145 | t.Fatal(err) |
| 146 | } |
| 147 | body, readErr := io.ReadAll(response.Body) |
| 148 | _ = response.Body.Close() |
| 149 | if readErr != nil || response.StatusCode != http.StatusOK || !strings.Contains(string(body), "preview") { |
| 150 | t.Fatalf("browser preview response = %d %q, err=%v", response.StatusCode, body, readErr) |
| 151 | } |
| 152 | app.RevokeWorkspaceBrowserPreview(previewURL) |
| 153 | revoked, err := http.Get(previewURL) //nolint:gosec -- loopback URL created by the test app |
| 154 | if err != nil { |
| 155 | t.Fatal(err) |
| 156 | } |
| 157 | _ = revoked.Body.Close() |
| 158 | if revoked.StatusCode != http.StatusNotFound { |
| 159 | t.Fatalf("revoked browser preview response = %d, want 404", revoked.StatusCode) |
| 160 | } |
| 161 | app.stopWorkspacePreviewOrigin() |
| 162 | } |
| 163 | |
| 164 | func TestPresentedTextPagesAreUTF8AlignedAndVersionFenced(t *testing.T) { |
| 165 | dir := t.TempDir() |
| 166 | path := filepath.Join(dir, "large.txt") |
| 167 | prefix := strings.Repeat("a", presentedTextPageLimit-1) |
| 168 | body := prefix + "界" + strings.Repeat("b", 64) |
| 169 | if err := os.WriteFile(path, []byte(body), 0o644); err != nil { |
| 170 | t.Fatal(err) |
| 171 | } |
| 172 | info, err := os.Stat(path) |
| 173 | if err != nil { |
| 174 | t.Fatal(err) |
| 175 | } |
| 176 | version := workspaceFileVersion(info) |
| 177 | first, err := readPresentedTextPage(path, "large.txt", 0, version) |
| 178 | if err != nil { |
| 179 | t.Fatal(err) |
| 180 | } |
| 181 | if !first.HasMore || first.NextOffset != int64(len(prefix)) || first.Body != prefix { |
| 182 | t.Fatalf("first page split = offset %d more=%v tail=%q", first.NextOffset, first.HasMore, first.Body[len(first.Body)-4:]) |
| 183 | } |
| 184 | second, err := readPresentedTextPage(path, "large.txt", first.NextOffset, version) |
| 185 | if err != nil { |
| 186 | t.Fatal(err) |
| 187 | } |
| 188 | if first.Body+second.Body != body || second.HasMore { |
| 189 | t.Fatalf("joined pages do not equal source (joined=%d source=%d more=%v)", len(first.Body+second.Body), len(body), second.HasMore) |
| 190 | } |
| 191 | if err := os.WriteFile(path, []byte(body+"changed"), 0o644); err != nil { |
| 192 | t.Fatal(err) |
| 193 | } |
| 194 | if _, err := readPresentedTextPage(path, "large.txt", first.NextOffset, version); err == nil || !strings.Contains(err.Error(), "file changed") { |
| 195 | t.Fatalf("stale version error = %v", err) |
| 196 | } |
| 197 | } |
| 198 |