387 lines
12 KiB
Go
387 lines
12 KiB
Go
|
|
package transport
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"image"
|
||
|
|
"image/color"
|
||
|
|
"image/png"
|
||
|
|
"mime/multipart"
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"net/textproto"
|
||
|
|
"net/url"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"go-places-api/internal/analysis"
|
||
|
|
"go-places-api/internal/auth"
|
||
|
|
"go-places-api/internal/blobstore"
|
||
|
|
"go-places-api/internal/config"
|
||
|
|
"go-places-api/internal/service"
|
||
|
|
"go-places-api/internal/store"
|
||
|
|
)
|
||
|
|
|
||
|
|
// newTestServer wires a Server against a throwaway database and media
|
||
|
|
// directory, so a test exercises the real store and blob store rather than
|
||
|
|
// fakes. mutate, when given, adjusts the configuration before wiring.
|
||
|
|
func newTestServer(t *testing.T, mutate ...func(*config.Config)) *Server {
|
||
|
|
t.Helper()
|
||
|
|
|
||
|
|
dir := t.TempDir()
|
||
|
|
cfg := config.Default()
|
||
|
|
cfg.DatabaseURL = "file:" + filepath.Join(dir, "test.db")
|
||
|
|
cfg.MediaDir = filepath.Join(dir, "media")
|
||
|
|
cfg.AuthMode = "dev"
|
||
|
|
cfg.MaxUploadBytes = 1 << 20
|
||
|
|
for _, m := range mutate {
|
||
|
|
m(&cfg)
|
||
|
|
}
|
||
|
|
|
||
|
|
db, err := store.OpenSQL(cfg.DatabaseURL)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("open store: %v", err)
|
||
|
|
}
|
||
|
|
t.Cleanup(func() { db.Close() })
|
||
|
|
|
||
|
|
blobs, err := blobstore.NewLocalFS(cfg.MediaDir)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("open blob store: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
svc := service.New(service.Options{
|
||
|
|
Store: db,
|
||
|
|
Blobs: blobs,
|
||
|
|
Analyzer: stubAnalyzer{},
|
||
|
|
Config: cfg,
|
||
|
|
})
|
||
|
|
|
||
|
|
srv, err := NewServer(Options{
|
||
|
|
Service: svc,
|
||
|
|
Config: cfg,
|
||
|
|
Auth: auth.NewMiddleware(auth.NewDevVerifier(nil)),
|
||
|
|
Blobs: blobs,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("new server: %v", err)
|
||
|
|
}
|
||
|
|
return srv
|
||
|
|
}
|
||
|
|
|
||
|
|
// stubAnalyzer keeps the badge rules out of these tests, which are about the
|
||
|
|
// transport rather than about image classification.
|
||
|
|
type stubAnalyzer struct{}
|
||
|
|
|
||
|
|
var _ analysis.Analyzer = stubAnalyzer{}
|
||
|
|
|
||
|
|
func (stubAnalyzer) Name() string { return "stub" }
|
||
|
|
|
||
|
|
func (stubAnalyzer) Analyze(ctx context.Context, in analysis.Input) (*analysis.Result, error) {
|
||
|
|
return &analysis.Result{Width: 8, Height: 4}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// do runs a GraphQL request against the handler and returns the decoded body.
|
||
|
|
func do(t *testing.T, srv *Server, query string, vars map[string]any) (int, map[string]any) {
|
||
|
|
t.Helper()
|
||
|
|
|
||
|
|
body, err := json.Marshal(map[string]any{"query": query, "variables": vars})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("marshal request: %v", err)
|
||
|
|
}
|
||
|
|
req := httptest.NewRequest(http.MethodPost, "/graphql", bytes.NewReader(body))
|
||
|
|
req.Header.Set("Content-Type", "application/json")
|
||
|
|
req.Header.Set("Authorization", "Bearer valid_token")
|
||
|
|
rec := httptest.NewRecorder()
|
||
|
|
srv.Handler().ServeHTTP(rec, req)
|
||
|
|
|
||
|
|
var out map[string]any
|
||
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||
|
|
t.Fatalf("decode response %q: %v", rec.Body.String(), err)
|
||
|
|
}
|
||
|
|
return rec.Code, out
|
||
|
|
}
|
||
|
|
|
||
|
|
// data returns the "data" object, failing the test if the response carried
|
||
|
|
// errors.
|
||
|
|
func data(t *testing.T, resp map[string]any) map[string]any {
|
||
|
|
t.Helper()
|
||
|
|
if errs, ok := resp["errors"]; ok {
|
||
|
|
t.Fatalf("unexpected GraphQL errors: %v", errs)
|
||
|
|
}
|
||
|
|
d, ok := resp["data"].(map[string]any)
|
||
|
|
if !ok {
|
||
|
|
t.Fatalf("response has no data object: %v", resp)
|
||
|
|
}
|
||
|
|
return d
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestNewSchemaCompiles guards the self-referential Place/Image/RelatedPlace
|
||
|
|
// types against being constructed more than once. graphql-go rejects a schema
|
||
|
|
// that contains two distinct types under the same name, and that failure only
|
||
|
|
// surfaces at startup, so it is worth pinning down here.
|
||
|
|
func TestNewSchemaCompiles(t *testing.T) {
|
||
|
|
srv := newTestServer(t)
|
||
|
|
|
||
|
|
if _, err := newSchema(srv.svc); err != nil {
|
||
|
|
t.Fatalf("schema did not compile: %v", err)
|
||
|
|
}
|
||
|
|
for _, name := range []string{"Image", "Place", "RelatedPlace", "Badge", "Coordinate", "Comment", "User"} {
|
||
|
|
if srv.schema.Type(name) == nil {
|
||
|
|
t.Errorf("schema is missing the %s type", name)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestPlaceLifecycle walks a place through creation, upload and read-back.
|
||
|
|
func TestPlaceLifecycle(t *testing.T) {
|
||
|
|
srv := newTestServer(t)
|
||
|
|
|
||
|
|
code, resp := do(t, srv,
|
||
|
|
`mutation($i: PlaceInput!) { createPlace(input: $i) { id title coordinate { latitude longitude } } }`,
|
||
|
|
map[string]any{"i": map[string]any{
|
||
|
|
"title": "Test Place", "latitude": 48.85, "longitude": 2.33,
|
||
|
|
}})
|
||
|
|
if code != http.StatusOK {
|
||
|
|
t.Fatalf("createPlace status = %d, want 200", code)
|
||
|
|
}
|
||
|
|
created := data(t, resp)["createPlace"].(map[string]any)
|
||
|
|
placeID := created["id"].(string)
|
||
|
|
if placeID == "" {
|
||
|
|
t.Fatal("createPlace returned an empty id")
|
||
|
|
}
|
||
|
|
if created["title"] != "Test Place" {
|
||
|
|
t.Errorf("title = %v, want %q", created["title"], "Test Place")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Upload a real PNG so the analysis and decode paths are exercised.
|
||
|
|
uploadCode, img := uploadPNG(t, srv, placeID)
|
||
|
|
if uploadCode != http.StatusCreated {
|
||
|
|
t.Fatalf("upload status = %d, want 201", uploadCode)
|
||
|
|
}
|
||
|
|
imageID := img["id"].(string)
|
||
|
|
url := img["url"].(string)
|
||
|
|
if !strings.HasPrefix(url, "/media/") {
|
||
|
|
t.Errorf("url = %q, want a /media/ prefix", url)
|
||
|
|
}
|
||
|
|
if img["analysisStatus"] != "completed" {
|
||
|
|
t.Errorf("analysisStatus = %v, want completed", img["analysisStatus"])
|
||
|
|
}
|
||
|
|
|
||
|
|
// The picture must be readable back through the place.
|
||
|
|
_, resp = do(t, srv,
|
||
|
|
`query($id: String!) { place(id: $id) { images { id url } } }`,
|
||
|
|
map[string]any{"id": placeID})
|
||
|
|
images := data(t, resp)["place"].(map[string]any)["images"].([]any)
|
||
|
|
if len(images) != 1 {
|
||
|
|
t.Fatalf("got %d images on the place, want 1", len(images))
|
||
|
|
}
|
||
|
|
if got := images[0].(map[string]any)["url"]; got != url {
|
||
|
|
t.Errorf("url = %v, want %q", got, url)
|
||
|
|
}
|
||
|
|
|
||
|
|
// And the media route must serve the stored bytes.
|
||
|
|
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||
|
|
rec := httptest.NewRecorder()
|
||
|
|
srv.Handler().ServeHTTP(rec, req)
|
||
|
|
if rec.Code != http.StatusOK {
|
||
|
|
t.Fatalf("GET %s status = %d, want 200", url, rec.Code)
|
||
|
|
}
|
||
|
|
if got := rec.Header().Get("Content-Type"); got != "image/png" {
|
||
|
|
t.Errorf("Content-Type = %q, want image/png", got)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Deleting the place must remove the stored blob too.
|
||
|
|
_, resp = do(t, srv, `mutation($id: String!) { deletePlace(id: $id) }`, map[string]any{"id": placeID})
|
||
|
|
if data(t, resp)["deletePlace"] != true {
|
||
|
|
t.Fatalf("deletePlace did not return true: %v", resp)
|
||
|
|
}
|
||
|
|
entries, err := os.ReadDir(filepath.Join(srv.cfg.MediaDir, placeID))
|
||
|
|
if err == nil && len(entries) > 0 {
|
||
|
|
t.Errorf("blob directory still holds %d entries after delete", len(entries))
|
||
|
|
}
|
||
|
|
_ = imageID
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestResolverErrorYieldsNull checks that a failed resolver nulls its field
|
||
|
|
// instead of emitting the zero value of the domain type, which would otherwise
|
||
|
|
// hand clients a plausible-looking empty record.
|
||
|
|
func TestResolverErrorYieldsNull(t *testing.T) {
|
||
|
|
srv := newTestServer(t)
|
||
|
|
|
||
|
|
code, resp := do(t, srv, `{ place(id: "does-not-exist") { id title } }`, nil)
|
||
|
|
if code != http.StatusOK {
|
||
|
|
t.Fatalf("status = %d, want 200 (errors travel in the body)", code)
|
||
|
|
}
|
||
|
|
if _, ok := resp["errors"]; !ok {
|
||
|
|
t.Error("expected an error entry for a missing place")
|
||
|
|
}
|
||
|
|
if got := data2(t, resp)["place"]; got != nil {
|
||
|
|
t.Errorf("data.place = %v, want null", got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// data2 returns the data object without asserting the absence of errors, for
|
||
|
|
// the case where errors are expected.
|
||
|
|
func data2(t *testing.T, resp map[string]any) map[string]any {
|
||
|
|
t.Helper()
|
||
|
|
d, ok := resp["data"].(map[string]any)
|
||
|
|
if !ok {
|
||
|
|
t.Fatalf("response has no data object: %v", resp)
|
||
|
|
}
|
||
|
|
return d
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestUnauthenticatedIsRejected checks the auth middleware is actually mounted.
|
||
|
|
func TestUnauthenticatedIsRejected(t *testing.T) {
|
||
|
|
srv := newTestServer(t)
|
||
|
|
|
||
|
|
req := httptest.NewRequest(http.MethodPost, "/graphql", strings.NewReader(`{"query":"{ places { id } }"}`))
|
||
|
|
req.Header.Set("Content-Type", "application/json")
|
||
|
|
rec := httptest.NewRecorder()
|
||
|
|
srv.Handler().ServeHTTP(rec, req)
|
||
|
|
|
||
|
|
if rec.Code != http.StatusUnauthorized {
|
||
|
|
t.Errorf("status = %d, want 401", rec.Code)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestProbesAreReachableWithoutAuth guards against a probe route accidentally
|
||
|
|
// being mounted behind the auth middleware: a kubelet cannot present a token.
|
||
|
|
func TestProbesAreReachableWithoutAuth(t *testing.T) {
|
||
|
|
srv := newTestServer(t)
|
||
|
|
|
||
|
|
for _, path := range []string{"/healthz", "/readyz"} {
|
||
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||
|
|
rec := httptest.NewRecorder()
|
||
|
|
srv.Handler().ServeHTTP(rec, req)
|
||
|
|
if rec.Code != http.StatusOK {
|
||
|
|
t.Errorf("GET %s status = %d, want 200", path, rec.Code)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestUploadRejectsUnsupportedType checks the content-type guard.
|
||
|
|
func TestUploadRejectsUnsupportedType(t *testing.T) {
|
||
|
|
srv := newTestServer(t)
|
||
|
|
|
||
|
|
_, resp := do(t, srv,
|
||
|
|
`mutation { createPlace(input: {title: "T"}) { id } }`, nil)
|
||
|
|
placeID := data(t, resp)["createPlace"].(map[string]any)["id"].(string)
|
||
|
|
|
||
|
|
uploadCode, _ := upload(t, srv, placeID, "notes.txt", "text/plain", []byte("hello"))
|
||
|
|
if uploadCode != http.StatusUnsupportedMediaType {
|
||
|
|
t.Errorf("upload status = %d, want 415", uploadCode)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestUploadRejectsOversizedPayload checks the size guard and that no partial
|
||
|
|
// blob is left behind.
|
||
|
|
func TestUploadRejectsOversizedPayload(t *testing.T) {
|
||
|
|
srv := newTestServer(t, func(c *config.Config) { c.MaxUploadBytes = 64 })
|
||
|
|
|
||
|
|
_, resp := do(t, srv, `mutation { createPlace(input: {title: "T"}) { id } }`, nil)
|
||
|
|
placeID := data(t, resp)["createPlace"].(map[string]any)["id"].(string)
|
||
|
|
|
||
|
|
uploadCode, _ := upload(t, srv, placeID, "big.png", "image/png", bytes.Repeat([]byte{0x41}, 4096))
|
||
|
|
if uploadCode != http.StatusRequestEntityTooLarge {
|
||
|
|
t.Errorf("upload status = %d, want 413", uploadCode)
|
||
|
|
}
|
||
|
|
entries, err := os.ReadDir(filepath.Join(srv.cfg.MediaDir, placeID))
|
||
|
|
if err == nil && len(entries) > 0 {
|
||
|
|
t.Errorf("blob directory holds %d entries after a rejected upload", len(entries))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// uploadPNG posts a small generated PNG and returns the decoded response.
|
||
|
|
func uploadPNG(t *testing.T, srv *Server, placeID string) (int, map[string]any) {
|
||
|
|
t.Helper()
|
||
|
|
var buf bytes.Buffer
|
||
|
|
img := image.NewRGBA(image.Rect(0, 0, 8, 4))
|
||
|
|
for y := 0; y < 4; y++ {
|
||
|
|
for x := 0; x < 8; x++ {
|
||
|
|
img.Set(x, y, color.RGBA{R: uint8(x * 30), G: uint8(y * 60), B: 200, A: 255})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if err := png.Encode(&buf, img); err != nil {
|
||
|
|
t.Fatalf("encode png: %v", err)
|
||
|
|
}
|
||
|
|
code, resp := upload(t, srv, placeID, "pic.png", "image/png", buf.Bytes())
|
||
|
|
if code != http.StatusCreated {
|
||
|
|
t.Fatalf("upload status = %d, want 201: %v", code, resp)
|
||
|
|
}
|
||
|
|
inner, _ := resp["image"].(map[string]any)
|
||
|
|
if inner == nil {
|
||
|
|
t.Fatalf("upload response has no image object: %v", resp)
|
||
|
|
}
|
||
|
|
return code, inner
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestGraphQLGetCannotExecuteQueries checks that the unauthenticated GET route
|
||
|
|
// only serves the console. If a ?query= URL ran a query, anyone who got hold of
|
||
|
|
// the link could read the whole travel log.
|
||
|
|
func TestGraphQLGetCannotExecuteQueries(t *testing.T) {
|
||
|
|
srv := newTestServer(t)
|
||
|
|
_, resp := do(t, srv, `mutation { createPlace(input: {title: "Secret"}) { id } }`, nil)
|
||
|
|
placeID := data(t, resp)["createPlace"].(map[string]any)["id"].(string)
|
||
|
|
|
||
|
|
req := httptest.NewRequest(http.MethodGet, "/graphql?"+url.Values{
|
||
|
|
"query": {`{place(id:"` + placeID + `"){title}}`},
|
||
|
|
}.Encode(), nil)
|
||
|
|
rec := httptest.NewRecorder()
|
||
|
|
srv.Handler().ServeHTTP(rec, req)
|
||
|
|
|
||
|
|
if rec.Code != http.StatusBadRequest {
|
||
|
|
t.Fatalf("GET /graphql?query status = %d, want 400", rec.Code)
|
||
|
|
}
|
||
|
|
if strings.Contains(rec.Body.String(), "Secret") {
|
||
|
|
t.Fatalf("GET /graphql?query leaked data: %s", rec.Body.String())
|
||
|
|
}
|
||
|
|
|
||
|
|
// The console itself stays reachable, since it carries no data.
|
||
|
|
rec = httptest.NewRecorder()
|
||
|
|
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/graphql", nil))
|
||
|
|
if rec.Code != http.StatusOK {
|
||
|
|
t.Fatalf("GET /graphql status = %d, want 200", rec.Code)
|
||
|
|
}
|
||
|
|
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
||
|
|
t.Fatalf("GET /graphql content-type = %q, want text/html", ct)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// upload posts a multipart image and returns the status and decoded body.
|
||
|
|
func upload(t *testing.T, srv *Server, placeID, filename, contentType string, payload []byte) (int, map[string]any) {
|
||
|
|
t.Helper()
|
||
|
|
|
||
|
|
var body bytes.Buffer
|
||
|
|
w := multipart.NewWriter(&body)
|
||
|
|
h := make(textproto.MIMEHeader)
|
||
|
|
h.Set("Content-Disposition", `form-data; name="image"; filename="`+filename+`"`)
|
||
|
|
h.Set("Content-Type", contentType)
|
||
|
|
part, err := w.CreatePart(h)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("create part: %v", err)
|
||
|
|
}
|
||
|
|
if _, err := part.Write(payload); err != nil {
|
||
|
|
t.Fatalf("write part: %v", err)
|
||
|
|
}
|
||
|
|
if err := w.Close(); err != nil {
|
||
|
|
t.Fatalf("close writer: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
req := httptest.NewRequest(http.MethodPost, "/places/"+placeID+"/images", &body)
|
||
|
|
req.Header.Set("Content-Type", w.FormDataContentType())
|
||
|
|
req.Header.Set("Authorization", "Bearer valid_token")
|
||
|
|
rec := httptest.NewRecorder()
|
||
|
|
srv.Handler().ServeHTTP(rec, req)
|
||
|
|
|
||
|
|
var out map[string]any
|
||
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||
|
|
// Error responses are plain text, which is fine here.
|
||
|
|
return rec.Code, map[string]any{"error": strings.TrimSpace(rec.Body.String())}
|
||
|
|
}
|
||
|
|
return rec.Code, out
|
||
|
|
}
|