package main import ( "encoding/json" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "bookmarkmanager/backend/internal/store" "bookmarkmanager/backend/internal/token" ) // registerReader creates an extra Reader the way a first login does and // returns its id. The credential is derived the same way the owner's is, so it // authenticates through the real router. func registerReader(t *testing.T, s *store.Store, discordID string) int64 { t.Helper() id, err := s.EnsureReader(discordID, token.Hash(readerCredential(discordID))) if err != nil { t.Fatalf("register reader %q: %v", discordID, err) } return id } // credRequest builds a request authenticated as the Reader whose credential // is passed. func credRequest(method, target, cred string) *http.Request { req := httptest.NewRequest(method, target, nil) req.Header.Set("Authorization", "Bearer "+cred) return req } // readerCredential is the epoch-0 derived credential of an arbitrary Reader. func readerCredential(discordID string) string { return token.Token([]byte(testTokenKey), discordID, 0) } // withBody attaches a request body, for PUTs that carry a JSON payload. func withBody(req *http.Request, body string) *http.Request { req.Body = io.NopCloser(strings.NewReader(body)) req.ContentLength = int64(len(body)) return req } // A refused credential is refused however plausible it looks: only a hash the // readers table holds authenticates anything. func TestUnknownCredentialRejected(t *testing.T) { srv := newRouter(newTestStore(t), testConfig()) rr := httptest.NewRecorder() srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", readerCredential("never-registered"))) if rr.Code != http.StatusUnauthorized { t.Fatalf("unregistered Reader's credential: status = %d, want 401", rr.Code) } rr = httptest.NewRecorder() srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", ownerCredential())) if rr.Code != http.StatusOK { t.Fatalf("owner's derived credential: status = %d, want 200", rr.Code) } } // A Reader's credential authenticates exactly that Reader: rows written under // one credential are invisible to the other, on the same key. func TestPerReaderIsolation(t *testing.T) { s := newTestStore(t) registerReader(t, s, "other-reader") srv := newRouter(s, testConfig()) ownerKey := "asura:solo" putBookmark(t, srv, ownerKey, store.Bookmark{ Key: ownerKey, Site: "asura", SeriesID: "solo", Title: "Solo Leveling", UpdatedAt: 1, }) // The other Reader's list is empty even though the owner holds the key. rr := httptest.NewRecorder() srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", readerCredential("other-reader"))) if rr.Code != http.StatusOK { t.Fatalf("other reader list: status = %d, want 200", rr.Code) } var theirs []store.Bookmark if err := json.Unmarshal(rr.Body.Bytes(), &theirs); err != nil { t.Fatalf("decode: %v", err) } if len(theirs) != 0 { t.Fatalf("other reader sees %d bookmarks, want 0 (owner's rows leaked)", len(theirs)) } // The other Reader writes the same key; both rows coexist, each visible // only to its owner. The series title is shared (ADR-0003) — the // reader-owned fields are progress and updated_at. req := credRequest(http.MethodPut, "/bookmarks/"+ownerKey, readerCredential("other-reader")) req.Header.Set("Content-Type", "application/json") body := `{"key":"asura:solo","site":"asura","series_id":"solo","title":"Theirs","last_chapter_num":3}` rr = httptest.NewRecorder() srv.ServeHTTP(rr, withBody(req, body)) if rr.Code != http.StatusOK { t.Fatalf("other reader put: status = %d, want 200", rr.Code) } rr = httptest.NewRecorder() srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", readerCredential("other-reader"))) var theirs2 []store.Bookmark if err := json.Unmarshal(rr.Body.Bytes(), &theirs2); err != nil { t.Fatalf("decode: %v", err) } if len(theirs2) != 1 || theirs2[0].LastChapterNum != 3 { t.Fatalf("other reader list = %+v, want their own row with their progress", theirs2) } rr = httptest.NewRecorder() srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", ownerCredential())) var owners []store.Bookmark if err := json.Unmarshal(rr.Body.Bytes(), &owners); err != nil { t.Fatalf("decode: %v", err) } if len(owners) != 1 || owners[0].Title != "Solo Leveling" || owners[0].LastChapterNum != 0 { t.Fatalf("owner list = %+v, want their own row at their own progress", owners) } // The mirror: the owner's write does not move the other Reader's progress // either. Without it, isolation is only asserted in one direction. req = credRequest(http.MethodPut, "/bookmarks/"+ownerKey, ownerCredential()) req.Header.Set("Content-Type", "application/json") rr = httptest.NewRecorder() srv.ServeHTTP(rr, withBody(req, `{"key":"asura:solo","site":"asura","series_id":"solo","title":"Solo Leveling","last_chapter_num":9}`)) if rr.Code != http.StatusOK { t.Fatalf("owner put: status = %d, want 200", rr.Code) } rr = httptest.NewRecorder() srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", readerCredential("other-reader"))) var theirs3 []store.Bookmark if err := json.Unmarshal(rr.Body.Bytes(), &theirs3); err != nil { t.Fatalf("decode: %v", err) } if len(theirs3) != 1 || theirs3[0].LastChapterNum != 3 { t.Fatalf("other reader list = %+v, want progress 3 after the owner's write", theirs3) } // DELETE is scoped to its caller too, asserted in both directions: each // Reader's delete on the shared key takes only their own row. list := func(cred string) []store.Bookmark { t.Helper() rr := httptest.NewRecorder() srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", cred)) var got []store.Bookmark if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { t.Fatalf("decode: %v", err) } return got } del := func(cred string) { t.Helper() rr := httptest.NewRecorder() srv.ServeHTTP(rr, credRequest(http.MethodDelete, "/bookmarks/"+ownerKey, cred)) if rr.Code != http.StatusNoContent { t.Fatalf("delete: status = %d, want 204", rr.Code) } } del(readerCredential("other-reader")) if got := list(ownerCredential()); len(got) != 1 { t.Fatalf("owner's row was deletable by the other Reader: %+v", got) } if got := list(readerCredential("other-reader")); len(got) != 0 { t.Fatalf("other Reader's own delete left %+v behind", got) } // The mirror: the other Reader takes the key again, the owner deletes // theirs, and the other's row is untouched. req = credRequest(http.MethodPut, "/bookmarks/"+ownerKey, readerCredential("other-reader")) req.Header.Set("Content-Type", "application/json") rr = httptest.NewRecorder() srv.ServeHTTP(rr, withBody(req, body)) if rr.Code != http.StatusOK { t.Fatalf("other reader re-put: status = %d, want 200", rr.Code) } del(ownerCredential()) if got := list(readerCredential("other-reader")); len(got) != 1 { t.Fatalf("other Reader's row was deletable by the owner: %+v", got) } if got := list(ownerCredential()); len(got) != 0 { t.Fatalf("owner's own delete left %+v behind", got) } } // The install endpoints are session-gated and render the script directly // with the Reader's credential inside: the credential never appears in the // address bar, the page markup, or any Location header. func TestInstallServesScriptWithCredential(t *testing.T) { cfg := testConfig() dir := t.TempDir() path := filepath.Join(dir, "manga-bookmark.user.js") novelPath := filepath.Join(dir, "novel-bookmark.user.js") for _, p := range []string{path, novelPath} { if err := os.WriteFile(p, []byte("const API_TOKEN = \"__API_TOKEN__\";\n"), 0o644); err != nil { t.Fatalf("write script: %v", err) } } cfg.UserscriptPath = path cfg.NovelUserscriptPath = novelPath srv, st := newWebTestServer(t, cfg) for _, script := range []string{"manga-bookmark.user.js", "novel-bookmark.user.js"} { rr := httptest.NewRecorder() srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/install/"+script, nil)) if rr.Code != http.StatusUnauthorized { t.Fatalf("%s without session: status = %d, want 401", script, rr.Code) } req := httptest.NewRequest(http.MethodGet, "/install/"+script, nil) req.AddCookie(sessionCookie(t, st)) rr = httptest.NewRecorder() srv.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("%s with session: status = %d, want 200", script, rr.Code) } body := rr.Body.String() if strings.Contains(body, "__API_TOKEN__") { t.Fatalf("%s served with an unsubstituted placeholder", script) } // The credential rides inside the served script — nowhere visible in // the UI — and is the session holder's own. if !strings.Contains(body, `API_TOKEN = "`+ownerCredential()+`"`) { t.Fatalf("%s does not carry the owner's credential:\n%s", script, body) } if loc := rr.Header().Get("Location"); loc != "" { t.Fatalf("%s answered with a redirect, credential in Location %q", script, loc) } // The plain link must stay inline: Violentmonkey's updater polls the // /u/ path and an attachment disposition there would break updates. if cd := rr.Header().Get("Content-Disposition"); cd != "" { t.Fatalf("%s served as %q, want inline", script, cd) } // ?download=1 is the mobile path: Violentmonkey on Chromium ignores a // .user.js navigation, so the Reader saves the file and adds it by hand. req = httptest.NewRequest(http.MethodGet, "/install/"+script+"?download=1", nil) req.AddCookie(sessionCookie(t, st)) rr = httptest.NewRecorder() srv.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("%s?download=1: status = %d, want 200", script, rr.Code) } if got, want := rr.Header().Get("Content-Disposition"), `attachment; filename="`+script+`"`; got != want { t.Fatalf("%s?download=1: Content-Disposition = %q, want %q", script, got, want) } if !strings.Contains(rr.Body.String(), `API_TOKEN = "`+ownerCredential()+`"`) { t.Fatalf("%s?download=1 does not carry the owner's credential", script) } } } // Rotation through the web UI invalidates the old credential immediately, // mints one that authenticates the API and the script path, and warns that // every device must reinstall. func TestRotateCredentialViaWebUI(t *testing.T) { s, _ := newTestStoreURL(t) path := filepath.Join(t.TempDir(), "manga-bookmark.user.js") if err := os.WriteFile(path, []byte("const API_TOKEN = \"__API_TOKEN__\";\n"), 0o644); err != nil { t.Fatalf("write script: %v", err) } cfg := testConfig() cfg.UserscriptPath = path srv := newRouter(s, cfg) oldCred := ownerCredential() rr := httptest.NewRecorder() srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", oldCred)) if rr.Code != http.StatusOK { t.Fatalf("old credential before rotation: status = %d, want 200", rr.Code) } req := httptest.NewRequest(http.MethodPost, "/rotate-token", nil) req.AddCookie(sessionCookie(t, s)) rr = httptest.NewRecorder() srv.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("rotate: status = %d, want 200", rr.Code) } if !strings.Contains(rr.Body.String(), "Credential rotated") { t.Fatalf("rotation response does not warn about reinstall:\n%s", rr.Body.String()) } // The old credential is dead on the API and on the script path. rr = httptest.NewRecorder() srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", oldCred)) if rr.Code != http.StatusUnauthorized { t.Fatalf("old credential after rotation: status = %d, want 401", rr.Code) } rr = httptest.NewRecorder() srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/u/"+oldCred+"/manga-bookmark.user.js", nil)) if rr.Code != http.StatusNotFound { t.Fatalf("old credential script path after rotation: status = %d, want 404", rr.Code) } // The new credential authenticates the API and the script path, and is // substituted into the served script. newCred := token.Token([]byte(testTokenKey), testDiscordID, 1) rr = httptest.NewRecorder() srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", newCred)) if rr.Code != http.StatusOK { t.Fatalf("new credential after rotation: status = %d, want 200", rr.Code) } rr = httptest.NewRecorder() srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/u/"+newCred+"/manga-bookmark.user.js", nil)) if rr.Code != http.StatusOK { t.Fatalf("new credential script path: status = %d, want 200", rr.Code) } if got := rr.Body.String(); !strings.Contains(got, `API_TOKEN = "`+newCred+`"`) { t.Fatalf("served script does not carry the rotated credential:\n%s", got) } // The install link now renders the script with the new credential. req = httptest.NewRequest(http.MethodGet, "/install/manga-bookmark.user.js", nil) req.AddCookie(sessionCookie(t, s)) rr = httptest.NewRecorder() srv.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("install after rotation: status = %d, want 200", rr.Code) } if got := rr.Body.String(); !strings.Contains(got, `API_TOKEN = "`+newCred+`"`) { t.Fatalf("install after rotation does not carry the new credential:\n%s", got) } } // The app page offers the install links; the credential never appears in its // markup. func TestIndexShowsSetupPanelWithoutCredential(t *testing.T) { srv, st := newWebTestServer(t, testConfig()) req := httptest.NewRequest(http.MethodGet, "/", nil) req.AddCookie(sessionCookie(t, st)) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) body := rr.Body.String() for _, want := range []string{ `href="/install/manga-bookmark.user.js"`, `href="/install/novel-bookmark.user.js"`, `href="/install/manga-bookmark.user.js?download=1"`, `href="/install/novel-bookmark.user.js?download=1"`, "Rotate credential", } { if !strings.Contains(body, want) { t.Errorf("app page lacks %q", want) } } if strings.Contains(body, ownerCredential()) { t.Fatal("app page leaks the credential") } }