initial commit
This commit is contained in:
commit
1253955070
75 changed files with 13972 additions and 0 deletions
386
api/internal/transport/transport_test.go
Normal file
386
api/internal/transport/transport_test.go
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue