Persist kagane covers in Postgres (#49)
Closes #43 Persist kagane cover bytes in a dedicated Postgres covers table keyed by image ID. The web handler reads storage before the browser, writes validated fetches through, and no longer keeps an in-process cover cache. Added migration, store persistence tests including reopen, handler coverage for stored/miss/rejected paths, and corrected repository guidance. Verification: - go test ./... - CGO_ENABLED=0 go build ./... Reviewed-on: #49 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #49.
This commit is contained in:
+84
-14
@@ -2,15 +2,19 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"bookmarkmanager/backend/internal/pgtest"
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
)
|
||||
|
||||
// fakeCovers stands in for the headless browser. It counts calls so the test
|
||||
// can prove the cache spares the browser a second navigation.
|
||||
// can prove the store spares the browser after the first navigation.
|
||||
type fakeCovers struct {
|
||||
body []byte
|
||||
contentType string
|
||||
@@ -44,33 +48,92 @@ func getCover(t *testing.T, srv http.Handler, path string, cookie *http.Cookie)
|
||||
// kagane serves its covers behind a Cloudflare challenge and with
|
||||
// cross-origin-resource-policy: same-origin, so the UI can only show one by
|
||||
// re-serving the bytes from its own origin.
|
||||
func TestKaganeCoverProxiesAndCaches(t *testing.T) {
|
||||
func TestKaganeCoverPersistsAndReusesStoredBytes(t *testing.T) {
|
||||
cf := &fakeCovers{body: []byte("\x00webp-bytes"), contentType: "image/webp"}
|
||||
cfg := testConfig()
|
||||
cfg.Covers = cf
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
cookie := sessionCookie(t, st)
|
||||
|
||||
for i := range 2 {
|
||||
rr := getCover(t, srv, "/img/kagane/"+testCoverID, cookie)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("request %d: status = %d, want 200", i, rr.Code)
|
||||
}
|
||||
if got := rr.Body.String(); got != string(cf.body) {
|
||||
t.Fatalf("request %d: body = %q, want %q", i, got, cf.body)
|
||||
}
|
||||
if got := rr.Header().Get("Content-Type"); got != "image/webp" {
|
||||
t.Fatalf("request %d: Content-Type = %q, want image/webp", i, got)
|
||||
}
|
||||
rr := getCover(t, srv, "/img/kagane/"+testCoverID, cookie)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("first request: status = %d, want 200", rr.Code)
|
||||
}
|
||||
if got := rr.Body.String(); got != string(cf.body) {
|
||||
t.Fatalf("first request: body = %q, want %q", got, cf.body)
|
||||
}
|
||||
|
||||
// A new Handler has no process-local state from the first request. The same
|
||||
// store must still answer without navigating the browser again.
|
||||
srv = newRouter(st, cfg)
|
||||
rr = getCover(t, srv, "/img/kagane/"+testCoverID, cookie)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("stored request: status = %d, want 200", rr.Code)
|
||||
}
|
||||
if got := rr.Body.String(); got != string(cf.body) {
|
||||
t.Fatalf("stored request: body = %q, want %q", got, cf.body)
|
||||
}
|
||||
if got := cf.calls.Load(); got != 1 {
|
||||
t.Fatalf("fetcher called %d times, want 1 — the second read must come from the cache", got)
|
||||
t.Fatalf("fetcher called %d times, want 1 — stored bytes must survive a new handler", got)
|
||||
}
|
||||
if got := cf.lastID.Load(); got != testCoverID {
|
||||
t.Fatalf("fetched image id = %v, want %s", got, testCoverID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKaganeCoverServesStoredBytesWithoutBrowser(t *testing.T) {
|
||||
cf := &fakeCovers{err: errors.New("browser must not be called")}
|
||||
cfg := testConfig()
|
||||
cfg.Covers = cf
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
if err := st.PutKaganeCover(testCoverID, []byte("already-stored"), "image/png"); err != nil {
|
||||
t.Fatalf("PutKaganeCover: %v", err)
|
||||
}
|
||||
|
||||
rr := getCover(t, srv, "/img/kagane/"+testCoverID, sessionCookie(t, st))
|
||||
if rr.Code != http.StatusOK || rr.Body.String() != "already-stored" {
|
||||
t.Fatalf("stored request = (%d, %q), want (200, already-stored)", rr.Code, rr.Body.String())
|
||||
}
|
||||
if got := cf.calls.Load(); got != 0 {
|
||||
t.Fatalf("fetcher called %d times for a stored cover, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKaganeCoverServesPersistedBytesAfterRestart(t *testing.T) {
|
||||
url := pgtest.URL(t)
|
||||
owner := store.Owner{
|
||||
DiscordID: "cover-owner",
|
||||
TokenHash: sha256.Sum256([]byte("cover-owner-token")),
|
||||
}
|
||||
first, err := store.Open(url, owner)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
if err := first.PutKaganeCover(testCoverID, []byte("survives-restart"), "image/jpeg"); err != nil {
|
||||
first.Close()
|
||||
t.Fatalf("PutKaganeCover: %v", err)
|
||||
}
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatalf("close first store: %v", err)
|
||||
}
|
||||
|
||||
second, err := store.Open(url, owner)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
defer second.Close()
|
||||
cf := &fakeCovers{err: errors.New("browser must not be called after restart")}
|
||||
cfg := testConfig()
|
||||
cfg.Covers = cf
|
||||
rr := getCover(t, newRouter(second, cfg), "/img/kagane/"+testCoverID, sessionCookie(t, second))
|
||||
if rr.Code != http.StatusOK || rr.Body.String() != "survives-restart" {
|
||||
t.Fatalf("restarted request = (%d, %q), want (200, survives-restart)", rr.Code, rr.Body.String())
|
||||
}
|
||||
if got := cf.calls.Load(); got != 0 {
|
||||
t.Fatalf("fetcher called %d times after restart, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The proxy reaches a headless browser, so it is not open to the internet.
|
||||
func TestKaganeCoverRequiresSession(t *testing.T) {
|
||||
cf := &fakeCovers{body: []byte("x"), contentType: "image/webp"}
|
||||
@@ -123,6 +186,13 @@ func TestKaganeCoverRejectsBadInput(t *testing.T) {
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rr.Code)
|
||||
}
|
||||
_, _, ok, err := st.GetKaganeCover(tc.id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetKaganeCover after rejection: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("rejected cover was persisted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user