diff --git a/internal/web/uploads_disconnect_handler_test.go b/internal/web/uploads_disconnect_handler_test.go new file mode 100644 index 0000000..f34deab --- /dev/null +++ b/internal/web/uploads_disconnect_handler_test.go @@ -0,0 +1,78 @@ +package web_test + +import ( + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A client that hangs up in the middle of an upload is routine, not a server fault. The +// in-process recorder cannot see it (its context never cancels), so this talks to a real +// listener (third adversarial review: over a socket every such case logged an ERROR and +// half-close returned a 500, because the error page was rendered on a cancelled context). + +func partialUpload(t *testing.T, addr string, hangUp func(*net.TCPConn)) { + t.Helper() + c, err := net.Dial("tcp", addr) + require.NoError(t, err) + tc := c.(*net.TCPConn) + const boundary = "xBOUNDARYx" + head := "--" + boundary + "\r\nContent-Disposition: form-data; name=\"file\"; filename=\"a.m4a\"\r\nContent-Type: audio/mp4\r\n\r\n" + _, err = fmt.Fprintf(tc, "POST /uploads HTTP/1.1\r\nHost: x\r\nContent-Type: multipart/form-data; boundary=%s\r\nContent-Length: 200000\r\n\r\n%s%s", + boundary, head, strings.Repeat("a", 4000)) + require.NoError(t, err) + time.Sleep(50 * time.Millisecond) // let the server start reading the part + hangUp(tc) +} + +func TestUploadPost_AClientThatHangsUpMidFileIsLoggedAtInfoAndNeverAsAnError(t *testing.T) { + hangUps := map[string]func(*net.TCPConn){ + "a half close": func(c *net.TCPConn) { _ = c.CloseWrite(); time.Sleep(100 * time.Millisecond); _ = c.Close() }, + "a full close": func(c *net.TCPConn) { _ = c.Close() }, + "a reset (RST)": func(c *net.TCPConn) { _ = c.SetLinger(0); _ = c.Close() }, + } + for name, hangUp := range hangUps { + t.Run(name, func(t *testing.T) { + app, sub := newUploadApp(t, audioOK, 200<<20) + buf := &lockedBuf{} + app.Log = slog.New(slog.NewTextHandler(buf, nil)) + srv := httptest.NewServer(app.Router()) + defer srv.Close() + + partialUpload(t, strings.TrimPrefix(srv.URL, "http://"), hangUp) + + require.Eventually(t, func() bool { + out := buf.String() + return strings.Contains(out, "upload abandoned") || strings.Contains(out, "upload rejected") || strings.Contains(out, "level=ERROR") + }, 3*time.Second, 20*time.Millisecond, "the server never finished handling the aborted upload") + time.Sleep(100 * time.Millisecond) + out := buf.String() + assert.NotContains(t, out, "level=ERROR", "a client hanging up is not a server fault:\n%s", out) + assert.Empty(t, sub.jobs) + }) + } +} + +func TestUploadPost_ADatabaseDropThatLooksLikeAnEOFIsStillAServerError(t *testing.T) { + // pgx turns a connection that drops mid-message into io.ErrUnexpectedEOF. Mapping that + // sentinel to "your upload was cut off" (a 400) would hide a real fault: only an error + // from reading the CLIENT's body is theirs (third adversarial review). + app, _ := newUploadApp(t, audioOK, 200<<20) + buf := &lockedBuf{} + app.Log = slog.New(slog.NewTextHandler(buf, nil)) + app.Uploads = failingUploader{err: fmt.Errorf("failed to receive message: %w", io.ErrUnexpectedEOF)} + + rec := do(t, app, uploadReq(t, "t", "a.m4a", []byte("x"))) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Contains(t, buf.String(), "level=ERROR") +}