mirror of
https://github.com/ThisTine/Snip.git
synced 2026-08-18 23:18:47 +07:00
199 lines
6.3 KiB
Go
199 lines
6.3 KiB
Go
package api_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/snip/backend/internal/adapter/memory"
|
|
"github.com/snip/backend/internal/adapter/security"
|
|
"github.com/snip/backend/internal/config"
|
|
"github.com/snip/backend/internal/domain"
|
|
"github.com/snip/backend/internal/httpx/api"
|
|
"github.com/snip/backend/internal/service"
|
|
)
|
|
|
|
type harness struct {
|
|
h http.Handler
|
|
token string
|
|
}
|
|
|
|
func newHarness(t *testing.T) *harness {
|
|
t.Helper()
|
|
repo := memory.NewLinkRepo()
|
|
cache := memory.NewCache()
|
|
users := memory.NewUserRepo()
|
|
hasher := security.NewBcryptHasher()
|
|
sessions := security.NewHMACSessions("test-secret")
|
|
|
|
links := service.NewLinkService(repo, cache, hasher)
|
|
auth := service.NewAuthService(nil, users, sessions)
|
|
cfg := config.Config{PostLoginRedirect: "/dashboard"}
|
|
|
|
user, err := users.Upsert(context.Background(), &domain.User{
|
|
Email: "[email protected]", Name: "Sam", Provider: "test", Subject: "s1",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
token, _ := sessions.Issue(user.ID, time.Hour)
|
|
|
|
return &harness{h: api.New(links, auth, sessions, cfg).Handler(), token: token}
|
|
}
|
|
|
|
func (h *harness) do(t *testing.T, method, path, body, token string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var r *http.Request
|
|
if body != "" {
|
|
r = httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
|
} else {
|
|
r = httptest.NewRequest(method, path, nil)
|
|
}
|
|
if token != "" {
|
|
r.AddCookie(&http.Cookie{Name: "snip_session", Value: token})
|
|
}
|
|
w := httptest.NewRecorder()
|
|
h.h.ServeHTTP(w, r)
|
|
return w
|
|
}
|
|
|
|
func decodeLink(t *testing.T, w *httptest.ResponseRecorder) map[string]any {
|
|
t.Helper()
|
|
var m map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil {
|
|
t.Fatalf("bad json (%d): %s", w.Code, w.Body.String())
|
|
}
|
|
return m
|
|
}
|
|
|
|
func TestAnonCanCreateRandomButNotList(t *testing.T) {
|
|
h := newHarness(t)
|
|
|
|
w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com/x","mode":"random"}`, "")
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("create: want 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if code, _ := decodeLink(t, w)["code"].(string); code == "" {
|
|
t.Fatal("expected a code")
|
|
}
|
|
|
|
if w := h.do(t, http.MethodGet, "/api/v1/links", "", ""); w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("anon list: want 401, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestCustomAndReservedAndConflict(t *testing.T) {
|
|
h := newHarness(t)
|
|
|
|
// reserved alias → 409
|
|
if w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"custom","customAlias":"dashboard"}`, h.token); w.Code != http.StatusConflict {
|
|
t.Fatalf("reserved: want 409, got %d", w.Code)
|
|
}
|
|
// valid custom → 201
|
|
if w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"custom","customAlias":"promo"}`, h.token); w.Code != http.StatusCreated {
|
|
t.Fatalf("custom: want 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
// duplicate → 409
|
|
if w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"custom","customAlias":"promo"}`, h.token); w.Code != http.StatusConflict {
|
|
t.Fatalf("dupe: want 409, got %d", w.Code)
|
|
}
|
|
// anon custom → 401
|
|
if w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"custom","customAlias":"x9z"}`, ""); w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("anon custom: want 401, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestFullLifecycleWithPin(t *testing.T) {
|
|
h := newHarness(t)
|
|
|
|
// create
|
|
w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com/a","mode":"random"}`, h.token)
|
|
id := decodeLink(t, w)["id"].(string)
|
|
|
|
// list shows it
|
|
w = h.do(t, http.MethodGet, "/api/v1/links", "", h.token)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("list: %d", w.Code)
|
|
}
|
|
if total := listTotal(t, w); total != 1 {
|
|
t.Fatalf("want total 1, got %d", total)
|
|
}
|
|
|
|
// patch destination
|
|
w = h.do(t, http.MethodPatch, "/api/v1/links/"+id, `{"longUrl":"acme.com/b"}`, h.token)
|
|
if w.Code != http.StatusOK || decodeLink(t, w)["longUrl"] != "https://acme.com/b" {
|
|
t.Fatalf("patch failed: %d %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// set pin
|
|
w = h.do(t, http.MethodPut, "/api/v1/links/"+id+"/pin", `{"pin":"123456"}`, h.token)
|
|
if w.Code != http.StatusOK || decodeLink(t, w)["hasPin"] != true {
|
|
t.Fatalf("set pin failed: %d %s", w.Code, w.Body.String())
|
|
}
|
|
// bad pin → 400
|
|
if w := h.do(t, http.MethodPut, "/api/v1/links/"+id+"/pin", `{"pin":"12"}`, h.token); w.Code != http.StatusBadRequest {
|
|
t.Fatalf("bad pin: want 400, got %d", w.Code)
|
|
}
|
|
// remove pin
|
|
w = h.do(t, http.MethodDelete, "/api/v1/links/"+id+"/pin", "", h.token)
|
|
if w.Code != http.StatusOK || decodeLink(t, w)["hasPin"] != false {
|
|
t.Fatalf("remove pin failed: %d", w.Code)
|
|
}
|
|
|
|
// delete
|
|
if w := h.do(t, http.MethodDelete, "/api/v1/links/"+id, "", h.token); w.Code != http.StatusNoContent {
|
|
t.Fatalf("delete: want 204, got %d", w.Code)
|
|
}
|
|
// gone from list
|
|
w = h.do(t, http.MethodGet, "/api/v1/links", "", h.token)
|
|
if total := listTotal(t, w); total != 0 {
|
|
t.Fatalf("want 0 links after delete, got %d", total)
|
|
}
|
|
}
|
|
|
|
func listTotal(t *testing.T, w *httptest.ResponseRecorder) int {
|
|
t.Helper()
|
|
var resp struct {
|
|
Items []map[string]any `json:"items"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("bad list json: %s", w.Body.String())
|
|
}
|
|
return resp.Total
|
|
}
|
|
|
|
func TestPatchForbiddenForOtherUser(t *testing.T) {
|
|
h := newHarness(t)
|
|
w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"random"}`, h.token)
|
|
id := decodeLink(t, w)["id"].(string)
|
|
|
|
other := security.NewHMACSessions("test-secret")
|
|
otherToken, _ := other.Issue("someone-else", time.Hour)
|
|
if w := h.do(t, http.MethodPatch, "/api/v1/links/"+id, `{"longUrl":"https://evil.com"}`, otherToken); w.Code != http.StatusForbidden {
|
|
t.Fatalf("want 403 for non-owner, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestMeRequiresSession(t *testing.T) {
|
|
h := newHarness(t)
|
|
if w := h.do(t, http.MethodGet, "/api/v1/auth/me", "", ""); w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("me anon: want 401, got %d", w.Code)
|
|
}
|
|
w := h.do(t, http.MethodGet, "/api/v1/auth/me", "", h.token)
|
|
if w.Code != http.StatusOK || decodeLink(t, w)["email"] != "[email protected]" {
|
|
t.Fatalf("me: %d %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHealthz(t *testing.T) {
|
|
h := newHarness(t)
|
|
if w := h.do(t, http.MethodGet, "/api/v1/healthz", "", ""); w.Code != http.StatusOK {
|
|
t.Fatalf("healthz: %d", w.Code)
|
|
}
|
|
}
|