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 }