package userscript import ( "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" ) const testToken = "s3cret-token" // sampleScript is a stand-in for the real userscript: a metadata block with a // @version line, plus a body that must survive the rewrite untouched. const sampleScript = `// ==UserScript== // @name Manga Bookmark Sync // @version 1.5.0 // @match https://asurascans.com/* // ==/UserScript== (function () { "use strict"; })(); ` // writeScript drops a userscript in a temp dir with a known mtime and returns // its path plus the version string the handler is expected to stamp. func writeScript(t *testing.T, body string) (path, wantVersion string) { t.Helper() path = filepath.Join(t.TempDir(), "manga-bookmark.user.js") if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatalf("write script: %v", err) } mod := time.Date(2026, 7, 28, 16, 42, 0, 0, time.UTC) if err := os.Chtimes(path, mod, mod); err != nil { t.Fatalf("chtimes: %v", err) } return path, "2026.07.28.1642" } // newTestMux registers Handler the same way main.go's router does, without // pulling in the store or the rest of the app. func newTestMux(token, path string) http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", Handler(token, path)) return mux } func getScript(t *testing.T, srv http.Handler, token string) *httptest.ResponseRecorder { t.Helper() rr := httptest.NewRecorder() srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/u/"+token+"/manga-bookmark.user.js", nil)) return rr } func TestUserscriptServedWithStampedVersion(t *testing.T) { path, wantVersion := writeScript(t, sampleScript) rr := getScript(t, newTestMux(testToken, path), testToken) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") { t.Errorf("Content-Type = %q, want text/javascript", ct) } if cc := rr.Header().Get("Cache-Control"); cc != "no-cache" { t.Errorf("Cache-Control = %q, want no-cache", cc) } body := rr.Body.String() if !strings.Contains(body, "// @version "+wantVersion) { t.Errorf("body has no stamped version %q:\n%s", wantVersion, body) } if strings.Contains(body, "1.5.0") { t.Errorf("body still carries the file's own version:\n%s", body) } // Everything outside the @version line is served verbatim. if !strings.Contains(body, `(function () { "use strict"; })();`) { t.Errorf("body was altered beyond the version line:\n%s", body) } if !strings.Contains(body, "// @name Manga Bookmark Sync") { t.Errorf("metadata block was altered:\n%s", body) } } // The empty-token case ("/u//manga-bookmark.user.js") is covered at the // router level (see backend's guardEmptyUserscriptToken): ServeMux 307s it to // "/u/manga-bookmark.user.js" before this handler's own token check ever runs. func TestUserscriptWrongTokenIs404(t *testing.T) { path, _ := writeScript(t, sampleScript) srv := newTestMux(testToken, path) for _, tok := range []string{"wrong", testToken + "x", testToken[:3]} { if got := getScript(t, srv, tok).Code; got != http.StatusNotFound { t.Errorf("token %q: status = %d, want 404", tok, got) } } } func TestUserscriptMissingFileIs404(t *testing.T) { srv := newTestMux(testToken, filepath.Join(t.TempDir(), "absent.user.js")) if got := getScript(t, srv, testToken).Code; got != http.StatusNotFound { t.Fatalf("status = %d, want 404", got) } } func TestUserscriptWithoutVersionLineServedUnmodified(t *testing.T) { const noVersion = "// ==UserScript==\n// @name x\n// ==/UserScript==\nconsole.log(1);\n" path, _ := writeScript(t, noVersion) rr := getScript(t, newTestMux(testToken, path), testToken) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } if rr.Body.String() != noVersion { t.Fatalf("body = %q, want it unmodified", rr.Body.String()) } }