f4f6c9c9e9
- The empty state is about an empty library, not a brand-new Reader:
listView.Fresh becomes EmptyLibrary and moves behind the tab-specific
branches, so "No favourites yet" is no longer shadowed for a Reader whose
library happens to be empty. The action key stays put — hiding it was
never asked for.
- The owner is not a revocable Reader: their row offers no button and
POST /readers/{owner}/revoke is a 404, so the one row where the control
would sign out the tapping browser cannot be reached by a hand-rolled
POST either.
- Modify isolation is asserted in both directions, and the owner's own
sign-in through the OAuth callback is pinned to the seeded row.
- CUTOVER.md and REDEPLOY.md still grepped API_TOKEN out of .env for their
smoke tests, which the last commit deleted; both now take the acting
Reader's derived credential.
- Roster type follows the machine-fact spec (500 10-11px mono, tracked),
and PRODUCT.md names the Readers panel instead of claiming there is no
owner surface at all.
357 lines
14 KiB
Go
357 lines
14 KiB
Go
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")
|
|
}
|
|
}
|