package main import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "path/filepath" "testing" ) const testToken = "s3cret-token" func testConfig() Config { return Config{ Token: testToken, AllowedOrigins: []string{"https://asuracomic.net", "https://demonicscans.org"}, Port: "8080", } } func newTestServer(t *testing.T) http.Handler { t.Helper() dbPath := filepath.Join(t.TempDir(), "test.db") store, err := OpenStore(dbPath) if err != nil { t.Fatalf("OpenStore: %v", err) } t.Cleanup(func() { store.Close() }) return newRouter(store, testConfig()) } func auth(req *http.Request) *http.Request { req.Header.Set("Authorization", "Bearer "+testToken) return req } func TestHealthzNoAuth(t *testing.T) { srv := newTestServer(t) rr := httptest.NewRecorder() srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil)) if rr.Code != http.StatusOK { t.Fatalf("healthz status = %d, want 200", rr.Code) } if rr.Body.String() != "ok" { t.Fatalf("healthz body = %q, want ok", rr.Body.String()) } } func TestAuthRequired(t *testing.T) { srv := newTestServer(t) cases := []struct { name string header string }{ {"no header", ""}, {"bad token", "Bearer wrong"}, {"not bearer", "Basic " + testToken}, {"empty bearer", "Bearer "}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/bookmarks", nil) if tc.header != "" { req.Header.Set("Authorization", tc.header) } rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) if rr.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", rr.Code) } }) } } func TestAuthAccepted(t *testing.T) { srv := newTestServer(t) rr := httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil))) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } if got := rr.Body.String(); got != "[]\n" { t.Fatalf("empty list body = %q, want []", got) } } func TestCORSPreflight(t *testing.T) { srv := newTestServer(t) req := httptest.NewRequest(http.MethodOptions, "/bookmarks/asura:foo-1", nil) req.Header.Set("Origin", "https://asuracomic.net") req.Header.Set("Access-Control-Request-Method", "PUT") rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) if rr.Code != http.StatusNoContent { t.Fatalf("preflight status = %d, want 204", rr.Code) } if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "https://asuracomic.net" { t.Fatalf("Allow-Origin = %q, want reflected origin", got) } if got := rr.Header().Get("Access-Control-Allow-Methods"); got == "" { t.Fatal("Allow-Methods missing") } if got := rr.Header().Get("Access-Control-Allow-Headers"); got == "" { t.Fatal("Allow-Headers missing") } } func TestCORSDisallowedOrigin(t *testing.T) { srv := newTestServer(t) req := httptest.NewRequest(http.MethodOptions, "/bookmarks", nil) req.Header.Set("Origin", "https://evil.example") rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "" { t.Fatalf("Allow-Origin = %q, want empty for disallowed origin", got) } } func TestBookmarkRoundTrip(t *testing.T) { srv := newTestServer(t) key := "asura:solo-leveling-123" in := Bookmark{ Title: "Solo Leveling", SeriesURL: "https://asuracomic.net/series/solo-leveling-123", Cover: "https://asuracomic.net/cover.jpg", LastChapter: "Chapter 10", LastChapterNum: 10, LastChapterURL: "https://asuracomic.net/series/solo-leveling-123/chapter/10", } body, _ := json.Marshal(in) // PUT rr := httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body)))) if rr.Code != http.StatusOK { t.Fatalf("PUT status = %d, want 200", rr.Code) } var stored Bookmark if err := json.Unmarshal(rr.Body.Bytes(), &stored); err != nil { t.Fatalf("decode PUT response: %v", err) } if stored.Key != key || stored.Site != "asura" || stored.SeriesID != "solo-leveling-123" { t.Fatalf("derived fields wrong: %+v", stored) } if stored.UpdatedAt == 0 { t.Fatal("server did not set updated_at") } // GET rr = httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil))) var list []Bookmark if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil { t.Fatalf("decode list: %v", err) } if len(list) != 1 || list[0].Key != key || list[0].LastChapterNum != 10 { t.Fatalf("GET list wrong: %+v", list) } // PUT again (upsert, progress advance) in.LastChapter, in.LastChapterNum = "Chapter 11", 11 body, _ = json.Marshal(in) rr = httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body)))) if rr.Code != http.StatusOK { t.Fatalf("second PUT status = %d", rr.Code) } rr = httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil))) json.Unmarshal(rr.Body.Bytes(), &list) if len(list) != 1 || list[0].LastChapterNum != 11 { t.Fatalf("upsert did not update in place: %+v", list) } // DELETE rr = httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodDelete, "/bookmarks/"+key, nil))) if rr.Code != http.StatusNoContent { t.Fatalf("DELETE status = %d, want 204", rr.Code) } rr = httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil))) json.Unmarshal(rr.Body.Bytes(), &list) if len(list) != 0 { t.Fatalf("after delete list = %+v, want empty", list) } }