mirror of
https://github.com/dunglas/frankenphp.git
synced 2025-12-24 13:38:11 +08:00
As discussed in https://github.com/php/frankenphp/discussions/1961, there is no real way to pass a severity/level to any log handler offered by PHP that would make it to the FrankenPHP layer. This new function allows applications embedding FrankenPHP to integrate PHP logging into the application itself, thus offering a more streamlined experience. --------- Co-authored-by: Quentin Burgess <qutn.burgess@gmail.com> Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>
1080 lines
40 KiB
Go
1080 lines
40 KiB
Go
// In all tests, headers added to requests are copied on the heap using strings.Clone.
|
|
// This was originally a workaround for https://github.com/golang/go/issues/65286#issuecomment-1920087884 (fixed in Go 1.22),
|
|
// but this allows to catch panics occurring in real life but not when the string is in the internal binary memory.
|
|
|
|
package frankenphp_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"log/slog"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/http/httptest"
|
|
"net/http/httptrace"
|
|
"net/textproto"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/dunglas/frankenphp"
|
|
"github.com/dunglas/frankenphp/internal/fastabs"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
type testOptions struct {
|
|
workerScript string
|
|
watch []string
|
|
nbWorkers int
|
|
env map[string]string
|
|
nbParallelRequests int
|
|
realServer bool
|
|
logger *slog.Logger
|
|
initOpts []frankenphp.Option
|
|
requestOpts []frankenphp.RequestOption
|
|
phpIni map[string]string
|
|
}
|
|
|
|
func runTest(t *testing.T, test func(func(http.ResponseWriter, *http.Request), *httptest.Server, int), opts *testOptions) {
|
|
if opts == nil {
|
|
opts = &testOptions{}
|
|
}
|
|
if opts.nbParallelRequests == 0 {
|
|
opts.nbParallelRequests = 100
|
|
}
|
|
|
|
cwd, _ := os.Getwd()
|
|
testDataDir := cwd + "/testdata/"
|
|
|
|
initOpts := []frankenphp.Option{frankenphp.WithLogger(opts.logger)}
|
|
if opts.workerScript != "" {
|
|
workerOpts := []frankenphp.WorkerOption{
|
|
frankenphp.WithWorkerEnv(opts.env),
|
|
frankenphp.WithWorkerWatchMode(opts.watch),
|
|
}
|
|
initOpts = append(initOpts, frankenphp.WithWorkers("workerName", testDataDir+opts.workerScript, opts.nbWorkers, workerOpts...))
|
|
}
|
|
initOpts = append(initOpts, opts.initOpts...)
|
|
if opts.phpIni != nil {
|
|
initOpts = append(initOpts, frankenphp.WithPhpIni(opts.phpIni))
|
|
}
|
|
|
|
err := frankenphp.Init(initOpts...)
|
|
require.NoError(t, err)
|
|
defer frankenphp.Shutdown()
|
|
|
|
opts.requestOpts = append(opts.requestOpts, frankenphp.WithRequestDocumentRoot(testDataDir, false))
|
|
|
|
handler := func(w http.ResponseWriter, r *http.Request) {
|
|
req, err := frankenphp.NewRequestWithContext(r, opts.requestOpts...)
|
|
assert.NoError(t, err)
|
|
|
|
err = frankenphp.ServeHTTP(w, req)
|
|
if err != nil && !errors.As(err, &frankenphp.ErrRejected{}) {
|
|
assert.Fail(t, fmt.Sprintf("Received unexpected error:\n%+v", err))
|
|
}
|
|
}
|
|
|
|
var ts *httptest.Server
|
|
if opts.realServer {
|
|
ts = httptest.NewServer(http.HandlerFunc(handler))
|
|
defer ts.Close()
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(opts.nbParallelRequests)
|
|
for i := 0; i < opts.nbParallelRequests; i++ {
|
|
go func(i int) {
|
|
test(handler, ts, i)
|
|
wg.Done()
|
|
}(i)
|
|
}
|
|
|
|
wg.Wait()
|
|
}
|
|
|
|
func testRequest(req *http.Request, handler func(http.ResponseWriter, *http.Request), t *testing.T) (string, *http.Response) {
|
|
t.Helper()
|
|
|
|
w := httptest.NewRecorder()
|
|
handler(w, req)
|
|
resp := w.Result()
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
|
|
return string(body), resp
|
|
}
|
|
|
|
func testGet(url string, handler func(http.ResponseWriter, *http.Request), t *testing.T) (string, *http.Response) {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodGet, url, nil)
|
|
|
|
return testRequest(req, handler, t)
|
|
}
|
|
|
|
func testPost(url string, body string, handler func(http.ResponseWriter, *http.Request), t *testing.T) (string, *http.Response) {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodPost, url, nil)
|
|
req.Body = io.NopCloser(strings.NewReader(body))
|
|
|
|
return testRequest(req, handler, t)
|
|
}
|
|
|
|
func TestMain(m *testing.M) {
|
|
flag.Parse()
|
|
|
|
if !testing.Verbose() {
|
|
slog.SetDefault(slog.New(slog.DiscardHandler))
|
|
}
|
|
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
func TestHelloWorld_module(t *testing.T) { testHelloWorld(t, nil) }
|
|
func TestHelloWorld_worker(t *testing.T) {
|
|
testHelloWorld(t, &testOptions{workerScript: "index.php"})
|
|
}
|
|
func testHelloWorld(t *testing.T, opts *testOptions) {
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
|
|
body, _ := testGet(fmt.Sprintf("http://example.com/index.php?i=%d", i), handler, t)
|
|
assert.Equal(t, fmt.Sprintf("I am by birth a Genevese (%d)", i), body)
|
|
}, opts)
|
|
}
|
|
|
|
func TestFinishRequest_module(t *testing.T) { testFinishRequest(t, nil) }
|
|
func TestFinishRequest_worker(t *testing.T) {
|
|
testFinishRequest(t, &testOptions{workerScript: "finish-request.php"})
|
|
}
|
|
func testFinishRequest(t *testing.T, opts *testOptions) {
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
|
|
body, _ := testGet(fmt.Sprintf("http://example.com/finish-request.php?i=%d", i), handler, t)
|
|
assert.Equal(t, fmt.Sprintf("This is output %d\n", i), body)
|
|
}, opts)
|
|
}
|
|
|
|
func TestServerVariable_module(t *testing.T) {
|
|
testServerVariable(t, nil)
|
|
}
|
|
func TestServerVariable_worker(t *testing.T) {
|
|
testServerVariable(t, &testOptions{workerScript: "server-variable.php"})
|
|
}
|
|
func testServerVariable(t *testing.T, opts *testOptions) {
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
|
|
req := httptest.NewRequest("POST", fmt.Sprintf("http://example.com/server-variable.php/baz/bat?foo=a&bar=b&i=%d#hash", i), strings.NewReader("foo"))
|
|
req.SetBasicAuth(strings.Clone("kevin"), strings.Clone("password"))
|
|
req.Header.Add(strings.Clone("Content-Type"), strings.Clone("text/plain"))
|
|
body, _ := testRequest(req, handler, t)
|
|
|
|
assert.Contains(t, body, "[REMOTE_HOST]")
|
|
assert.Contains(t, body, "[REMOTE_USER] => kevin")
|
|
assert.Contains(t, body, "[PHP_AUTH_USER] => kevin")
|
|
assert.Contains(t, body, "[PHP_AUTH_PW] => password")
|
|
assert.Contains(t, body, "[HTTP_AUTHORIZATION] => Basic a2V2aW46cGFzc3dvcmQ=")
|
|
assert.Contains(t, body, "[DOCUMENT_ROOT]")
|
|
assert.Contains(t, body, "[PHP_SELF] => /server-variable.php/baz/bat")
|
|
assert.Contains(t, body, "[CONTENT_TYPE] => text/plain")
|
|
assert.Contains(t, body, fmt.Sprintf("[QUERY_STRING] => foo=a&bar=b&i=%d#hash", i))
|
|
assert.Contains(t, body, fmt.Sprintf("[REQUEST_URI] => /server-variable.php/baz/bat?foo=a&bar=b&i=%d#hash", i))
|
|
assert.Contains(t, body, "[CONTENT_LENGTH]")
|
|
assert.Contains(t, body, "[REMOTE_ADDR]")
|
|
assert.Contains(t, body, "[REMOTE_PORT]")
|
|
assert.Contains(t, body, "[REQUEST_SCHEME] => http")
|
|
assert.Contains(t, body, "[DOCUMENT_URI]")
|
|
assert.Contains(t, body, "[AUTH_TYPE]")
|
|
assert.Contains(t, body, "[REMOTE_IDENT]")
|
|
assert.Contains(t, body, "[REQUEST_METHOD] => POST")
|
|
assert.Contains(t, body, "[SERVER_NAME] => example.com")
|
|
assert.Contains(t, body, "[SERVER_PROTOCOL] => HTTP/1.1")
|
|
assert.Contains(t, body, "[SCRIPT_FILENAME]")
|
|
assert.Contains(t, body, "[SERVER_SOFTWARE] => FrankenPHP")
|
|
assert.Contains(t, body, "[REQUEST_TIME_FLOAT]")
|
|
assert.Contains(t, body, "[REQUEST_TIME]")
|
|
assert.Contains(t, body, "[SERVER_PORT] => 80")
|
|
}, opts)
|
|
}
|
|
|
|
func TestPathInfo_module(t *testing.T) { testPathInfo(t, nil) }
|
|
func TestPathInfo_worker(t *testing.T) {
|
|
testPathInfo(t, &testOptions{workerScript: "server-variable.php"})
|
|
}
|
|
func testPathInfo(t *testing.T, opts *testOptions) {
|
|
cwd, _ := os.Getwd()
|
|
testDataDir := cwd + strings.Clone("/testdata/")
|
|
path := strings.Clone("/server-variable.php/pathinfo")
|
|
|
|
runTest(t, func(_ func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
|
|
handler := func(w http.ResponseWriter, r *http.Request) {
|
|
requestURI := r.URL.RequestURI()
|
|
r.URL.Path = path
|
|
|
|
rewriteRequest, err := frankenphp.NewRequestWithContext(r,
|
|
frankenphp.WithRequestDocumentRoot(testDataDir, false),
|
|
frankenphp.WithRequestEnv(map[string]string{"REQUEST_URI": requestURI}),
|
|
)
|
|
assert.NoError(t, err)
|
|
|
|
err = frankenphp.ServeHTTP(w, rewriteRequest)
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
body, _ := testGet(fmt.Sprintf("http://example.com/pathinfo/%d", i), handler, t)
|
|
|
|
assert.Contains(t, body, "[PATH_INFO] => /pathinfo")
|
|
assert.Contains(t, body, fmt.Sprintf("[REQUEST_URI] => /pathinfo/%d", i))
|
|
assert.Contains(t, body, "[PATH_TRANSLATED] =>")
|
|
assert.Contains(t, body, "[SCRIPT_NAME] => /server-variable.php")
|
|
|
|
}, opts)
|
|
}
|
|
|
|
func TestHeaders_module(t *testing.T) { testHeaders(t, nil) }
|
|
func TestHeaders_worker(t *testing.T) { testHeaders(t, &testOptions{workerScript: "headers.php"}) }
|
|
func testHeaders(t *testing.T, opts *testOptions) {
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
|
|
body, resp := testGet(fmt.Sprintf("http://example.com/headers.php?i=%d", i), handler, t)
|
|
|
|
assert.Equal(t, "Hello", body)
|
|
assert.Equal(t, 201, resp.StatusCode)
|
|
assert.Equal(t, "bar", resp.Header.Get("Foo"))
|
|
assert.Equal(t, "bar2", resp.Header.Get("Foo2"))
|
|
assert.Equal(t, "bar3", resp.Header.Get("Foo3"), "header without whitespace after colon")
|
|
assert.Empty(t, resp.Header.Get("Invalid"))
|
|
assert.Equal(t, fmt.Sprintf("%d", i), resp.Header.Get("I"))
|
|
}, opts)
|
|
}
|
|
|
|
func TestResponseHeaders_module(t *testing.T) { testResponseHeaders(t, nil) }
|
|
func TestResponseHeaders_worker(t *testing.T) {
|
|
testResponseHeaders(t, &testOptions{workerScript: "response-headers.php"})
|
|
}
|
|
func testResponseHeaders(t *testing.T, opts *testOptions) {
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
|
|
body, resp := testGet(fmt.Sprintf("http://example.com/response-headers.php?i=%d", i), handler, t)
|
|
|
|
if i%3 != 0 {
|
|
assert.Equal(t, i+100, resp.StatusCode)
|
|
} else {
|
|
assert.Equal(t, 200, resp.StatusCode)
|
|
}
|
|
|
|
assert.Contains(t, body, "'X-Powered-By' => 'PH")
|
|
assert.Contains(t, body, "'Foo' => 'bar',")
|
|
assert.Contains(t, body, "'Foo2' => 'bar2',")
|
|
assert.Contains(t, body, fmt.Sprintf("'I' => '%d',", i))
|
|
assert.NotContains(t, body, "Invalid")
|
|
}, opts)
|
|
}
|
|
|
|
func TestInput_module(t *testing.T) { testInput(t, nil) }
|
|
func TestInput_worker(t *testing.T) { testInput(t, &testOptions{workerScript: "input.php"}) }
|
|
func testInput(t *testing.T, opts *testOptions) {
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
|
|
body, resp := testPost("http://example.com/input.php", fmt.Sprintf("post data %d", i), handler, t)
|
|
|
|
assert.Equal(t, fmt.Sprintf("post data %d", i), body)
|
|
assert.Equal(t, "bar", resp.Header.Get("Foo"))
|
|
}, opts)
|
|
}
|
|
|
|
func TestPostSuperGlobals_module(t *testing.T) { testPostSuperGlobals(t, nil) }
|
|
func TestPostSuperGlobals_worker(t *testing.T) {
|
|
testPostSuperGlobals(t, &testOptions{workerScript: "super-globals.php"})
|
|
}
|
|
func testPostSuperGlobals(t *testing.T, opts *testOptions) {
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
|
|
formData := url.Values{"baz": {"bat"}, "i": {fmt.Sprintf("%d", i)}}
|
|
req := httptest.NewRequest("POST", fmt.Sprintf("http://example.com/super-globals.php?foo=bar&iG=%d", i), strings.NewReader(formData.Encode()))
|
|
req.Header.Set("Content-Type", strings.Clone("application/x-www-form-urlencoded"))
|
|
body, _ := testRequest(req, handler, t)
|
|
|
|
assert.Contains(t, body, "'foo' => 'bar'")
|
|
assert.Contains(t, body, fmt.Sprintf("'i' => '%d'", i))
|
|
assert.Contains(t, body, "'baz' => 'bat'")
|
|
assert.Contains(t, body, fmt.Sprintf("'iG' => '%d'", i))
|
|
}, opts)
|
|
}
|
|
|
|
func TestCookies_module(t *testing.T) { testCookies(t, nil) }
|
|
func TestCookies_worker(t *testing.T) { testCookies(t, &testOptions{workerScript: "cookies.php"}) }
|
|
func testCookies(t *testing.T, opts *testOptions) {
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
|
|
req := httptest.NewRequest("GET", "http://example.com/cookies.php", nil)
|
|
req.AddCookie(&http.Cookie{Name: "foo", Value: "bar"})
|
|
req.AddCookie(&http.Cookie{Name: "i", Value: fmt.Sprintf("%d", i)})
|
|
body, _ := testRequest(req, handler, t)
|
|
|
|
assert.Contains(t, body, "'foo' => 'bar'")
|
|
assert.Contains(t, body, fmt.Sprintf("'i' => '%d'", i))
|
|
}, opts)
|
|
}
|
|
|
|
func TestMalformedCookie(t *testing.T) {
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
|
|
req := httptest.NewRequest("GET", "http://example.com/cookies.php", nil)
|
|
req.Header.Add("Cookie", "foo =bar; ===;;==; .dot.=val ;\x00 ; PHPSESSID=1234")
|
|
// Multiple Cookie header should be joined https://www.rfc-editor.org/rfc/rfc7540#section-8.1.2.5
|
|
req.Header.Add("Cookie", "secondCookie=test; secondCookie=overwritten")
|
|
body, _ := testRequest(req, handler, t)
|
|
|
|
assert.Contains(t, body, "'foo_' => 'bar'")
|
|
assert.Contains(t, body, "'_dot_' => 'val '")
|
|
|
|
// PHPSESSID should still be present since we remove the null byte
|
|
assert.Contains(t, body, "'PHPSESSID' => '1234'")
|
|
|
|
// The cookie in the second headers should be present,
|
|
// but it should not be overwritten by following values
|
|
assert.Contains(t, body, "'secondCookie' => 'test'")
|
|
|
|
}, &testOptions{nbParallelRequests: 1})
|
|
}
|
|
|
|
func TestSession_module(t *testing.T) { testSession(t, nil) }
|
|
func TestSession_worker(t *testing.T) {
|
|
testSession(t, &testOptions{workerScript: "session.php"})
|
|
}
|
|
func testSession(t *testing.T, opts *testOptions) {
|
|
if opts == nil {
|
|
opts = &testOptions{}
|
|
}
|
|
opts.realServer = true
|
|
|
|
runTest(t, func(_ func(http.ResponseWriter, *http.Request), ts *httptest.Server, i int) {
|
|
jar, err := cookiejar.New(&cookiejar.Options{})
|
|
assert.NoError(t, err)
|
|
|
|
client := &http.Client{Jar: jar}
|
|
|
|
resp1, err := client.Get(ts.URL + "/session.php")
|
|
assert.NoError(t, err)
|
|
|
|
body1, _ := io.ReadAll(resp1.Body)
|
|
assert.Equal(t, "Count: 0\n", string(body1))
|
|
|
|
resp2, err := client.Get(ts.URL + "/session.php")
|
|
assert.NoError(t, err)
|
|
|
|
body2, _ := io.ReadAll(resp2.Body)
|
|
assert.Equal(t, "Count: 1\n", string(body2))
|
|
}, opts)
|
|
}
|
|
|
|
func TestPhpInfo_module(t *testing.T) { testPhpInfo(t, nil) }
|
|
func TestPhpInfo_worker(t *testing.T) { testPhpInfo(t, &testOptions{workerScript: "phpinfo.php"}) }
|
|
func testPhpInfo(t *testing.T, opts *testOptions) {
|
|
var logOnce sync.Once
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
|
|
body, _ := testGet(fmt.Sprintf("http://example.com/phpinfo.php?i=%d", i), handler, t)
|
|
|
|
logOnce.Do(func() {
|
|
t.Log(body)
|
|
})
|
|
|
|
assert.Contains(t, body, "frankenphp")
|
|
assert.Contains(t, body, fmt.Sprintf("i=%d", i))
|
|
}, opts)
|
|
}
|
|
|
|
func TestPersistentObject_module(t *testing.T) { testPersistentObject(t, nil) }
|
|
func TestPersistentObject_worker(t *testing.T) {
|
|
testPersistentObject(t, &testOptions{workerScript: "persistent-object.php"})
|
|
}
|
|
func testPersistentObject(t *testing.T, opts *testOptions) {
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
|
|
body, _ := testGet(fmt.Sprintf("http://example.com/persistent-object.php?i=%d", i), handler, t)
|
|
|
|
assert.Equal(t, fmt.Sprintf(`request: %d
|
|
class exists: 1
|
|
id: obj1
|
|
object id: 1`, i), body)
|
|
}, opts)
|
|
}
|
|
|
|
func TestAutoloader_module(t *testing.T) { testAutoloader(t, nil) }
|
|
func TestAutoloader_worker(t *testing.T) {
|
|
testAutoloader(t, &testOptions{workerScript: "autoloader.php"})
|
|
}
|
|
func testAutoloader(t *testing.T, opts *testOptions) {
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
|
|
body, _ := testGet(fmt.Sprintf("http://example.com/autoloader.php?i=%d", i), handler, t)
|
|
|
|
assert.Equal(t, fmt.Sprintf(`request %d
|
|
my_autoloader`, i), body)
|
|
}, opts)
|
|
}
|
|
|
|
func TestLog_error_log_module(t *testing.T) { testLog_error_log(t, &testOptions{}) }
|
|
func TestLog_error_log_worker(t *testing.T) {
|
|
testLog_error_log(t, &testOptions{workerScript: "log-error_log.php"})
|
|
}
|
|
func testLog_error_log(t *testing.T, opts *testOptions) {
|
|
var buf fmt.Stringer
|
|
opts.logger, buf = newTestLogger(t)
|
|
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
|
|
req := httptest.NewRequest("GET", fmt.Sprintf("http://example.com/log-error_log.php?i=%d", i), nil)
|
|
w := httptest.NewRecorder()
|
|
handler(w, req)
|
|
|
|
assert.Contains(t, buf.String(), fmt.Sprintf("request %d", i))
|
|
}, opts)
|
|
}
|
|
|
|
func TestLog_frankenphp_log_module(t *testing.T) { testLog_frankenphp_log(t, &testOptions{}) }
|
|
func TestLog_frankenphp_log_worker(t *testing.T) {
|
|
testLog_frankenphp_log(t, &testOptions{workerScript: "log-frankenphp_log.php"})
|
|
}
|
|
func testLog_frankenphp_log(t *testing.T, opts *testOptions) {
|
|
var buf fmt.Stringer
|
|
opts.logger, buf = newTestLogger(t)
|
|
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
|
|
req := httptest.NewRequest("GET", fmt.Sprintf("http://example.com/log-frankenphp_log.php?i=%d", i), nil)
|
|
w := httptest.NewRecorder()
|
|
handler(w, req)
|
|
|
|
logs := buf.String()
|
|
for _, message := range []string{
|
|
fmt.Sprintf(`level=DEBUG msg="some debug message %d" "key int"=1`, i),
|
|
fmt.Sprintf(`level=INFO msg="some info message %d" "key string"=string`, i),
|
|
fmt.Sprintf(`level=WARN msg="some warn message %d"`, i),
|
|
fmt.Sprintf(`level=ERROR msg="some error message %d" err="[a v]"`, i),
|
|
} {
|
|
assert.Contains(t, logs, message)
|
|
}
|
|
}, opts)
|
|
}
|
|
|
|
func TestConnectionAbort_module(t *testing.T) { testConnectionAbort(t, &testOptions{}) }
|
|
func TestConnectionAbort_worker(t *testing.T) {
|
|
testConnectionAbort(t, &testOptions{workerScript: "connection_status.php"})
|
|
}
|
|
func testConnectionAbort(t *testing.T, opts *testOptions) {
|
|
testFinish := func(finish string) {
|
|
t.Run(fmt.Sprintf("finish=%s", finish), func(t *testing.T) {
|
|
var buf syncBuffer
|
|
opts.logger = slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
|
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
|
|
req := httptest.NewRequest("GET", fmt.Sprintf("http://example.com/connection_status.php?i=%d&finish=%s", i, finish), nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
ctx, cancel := context.WithCancel(req.Context())
|
|
req = req.WithContext(ctx)
|
|
cancel()
|
|
handler(w, req)
|
|
|
|
for !strings.Contains(buf.String(), fmt.Sprintf("request %d: 1", i)) {
|
|
}
|
|
}, opts)
|
|
})
|
|
}
|
|
|
|
testFinish("0")
|
|
testFinish("1")
|
|
}
|
|
|
|
func TestException_module(t *testing.T) { testException(t, &testOptions{}) }
|
|
func TestException_worker(t *testing.T) {
|
|
testException(t, &testOptions{workerScript: "exception.php"})
|
|
}
|
|
func testException(t *testing.T, opts *testOptions) {
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
|
|
body, _ := testGet(fmt.Sprintf("http://example.com/exception.php?i=%d", i), handler, t)
|
|
|
|
assert.Contains(t, body, "hello")
|
|
assert.Contains(t, body, fmt.Sprintf(`Uncaught Exception: request %d`, i))
|
|
}, opts)
|
|
}
|
|
|
|
func TestEarlyHints_module(t *testing.T) { testEarlyHints(t, &testOptions{}) }
|
|
func TestEarlyHints_worker(t *testing.T) {
|
|
testEarlyHints(t, &testOptions{workerScript: "early-hints.php"})
|
|
}
|
|
func testEarlyHints(t *testing.T, opts *testOptions) {
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
|
|
var earlyHintReceived bool
|
|
trace := &httptrace.ClientTrace{
|
|
Got1xxResponse: func(code int, header textproto.MIMEHeader) error {
|
|
switch code {
|
|
case http.StatusEarlyHints:
|
|
assert.Equal(t, "</style.css>; rel=preload; as=style", header.Get("Link"))
|
|
assert.Equal(t, strconv.Itoa(i), header.Get("Request"))
|
|
|
|
earlyHintReceived = true
|
|
}
|
|
|
|
return nil
|
|
},
|
|
}
|
|
req := httptest.NewRequest("GET", fmt.Sprintf("http://example.com/early-hints.php?i=%d", i), nil)
|
|
w := NewRecorder()
|
|
w.ClientTrace = trace
|
|
handler(w, req)
|
|
|
|
assert.Equal(t, strconv.Itoa(i), w.Header().Get("Request"))
|
|
assert.Equal(t, "", w.Header().Get("Link"))
|
|
|
|
assert.True(t, earlyHintReceived)
|
|
}, opts)
|
|
}
|
|
|
|
type streamResponseRecorder struct {
|
|
*httptest.ResponseRecorder
|
|
writeCallback func(buf []byte)
|
|
}
|
|
|
|
func (srr *streamResponseRecorder) Write(buf []byte) (int, error) {
|
|
srr.writeCallback(buf)
|
|
|
|
return srr.ResponseRecorder.Write(buf)
|
|
}
|
|
|
|
func TestFlush_module(t *testing.T) { testFlush(t, &testOptions{}) }
|
|
func TestFlush_worker(t *testing.T) {
|
|
testFlush(t, &testOptions{workerScript: "flush.php"})
|
|
}
|
|
func testFlush(t *testing.T, opts *testOptions) {
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
|
|
var j int
|
|
|
|
req := httptest.NewRequest("GET", fmt.Sprintf("http://example.com/flush.php?i=%d", i), nil)
|
|
w := &streamResponseRecorder{httptest.NewRecorder(), func(buf []byte) {
|
|
if j == 0 {
|
|
assert.Equal(t, []byte("He"), buf)
|
|
} else {
|
|
assert.Equal(t, fmt.Appendf(nil, "llo %d", i), buf)
|
|
}
|
|
|
|
j++
|
|
}}
|
|
handler(w, req)
|
|
|
|
assert.Equal(t, 2, j)
|
|
}, opts)
|
|
}
|
|
|
|
func TestLargeRequest_module(t *testing.T) {
|
|
testLargeRequest(t, &testOptions{})
|
|
}
|
|
func TestLargeRequest_worker(t *testing.T) {
|
|
testLargeRequest(t, &testOptions{workerScript: "large-request.php"})
|
|
}
|
|
func testLargeRequest(t *testing.T, opts *testOptions) {
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
|
|
body, _ := testPost(
|
|
fmt.Sprintf("http://example.com/large-request.php?i=%d", i),
|
|
strings.Repeat("f", 6_048_576),
|
|
handler,
|
|
t,
|
|
)
|
|
|
|
assert.Contains(t, body, fmt.Sprintf("Request body size: 6048576 (%d)", i))
|
|
}, opts)
|
|
}
|
|
|
|
func TestVersion(t *testing.T) {
|
|
v := frankenphp.Version()
|
|
|
|
assert.GreaterOrEqual(t, v.MajorVersion, 8)
|
|
assert.GreaterOrEqual(t, v.MinorVersion, 0)
|
|
assert.GreaterOrEqual(t, v.ReleaseVersion, 0)
|
|
assert.GreaterOrEqual(t, v.VersionID, 0)
|
|
assert.NotEmpty(t, v.Version, 0)
|
|
}
|
|
|
|
func TestFiberNoCgo_module(t *testing.T) { testFiberNoCgo(t, &testOptions{}) }
|
|
func TestFiberNonCgo_worker(t *testing.T) {
|
|
testFiberNoCgo(t, &testOptions{workerScript: "fiber-no-cgo.php"})
|
|
}
|
|
func testFiberNoCgo(t *testing.T, opts *testOptions) {
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
|
|
body, _ := testGet(fmt.Sprintf("http://example.com/fiber-no-cgo.php?i=%d", i), handler, t)
|
|
assert.Equal(t, body, fmt.Sprintf("Fiber %d", i))
|
|
}, opts)
|
|
}
|
|
|
|
func TestFiberBasic_module(t *testing.T) { testFiberBasic(t, &testOptions{}) }
|
|
func TestFiberBasic_worker(t *testing.T) {
|
|
testFiberBasic(t, &testOptions{workerScript: "fiber-basic.php"})
|
|
}
|
|
func testFiberBasic(t *testing.T, opts *testOptions) {
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
|
|
body, _ := testGet(fmt.Sprintf("http://example.com/fiber-basic.php?i=%d", i), handler, t)
|
|
assert.Equal(t, body, fmt.Sprintf("Fiber %d", i))
|
|
}, opts)
|
|
}
|
|
|
|
func TestRequestHeaders_module(t *testing.T) { testRequestHeaders(t, &testOptions{}) }
|
|
func TestRequestHeaders_worker(t *testing.T) {
|
|
testRequestHeaders(t, &testOptions{workerScript: "request-headers.php"})
|
|
}
|
|
func testRequestHeaders(t *testing.T, opts *testOptions) {
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
|
|
req := httptest.NewRequest("GET", fmt.Sprintf("http://example.com/request-headers.php?i=%d", i), nil)
|
|
req.Header.Add(strings.Clone("Content-Type"), strings.Clone("text/plain"))
|
|
req.Header.Add(strings.Clone("Frankenphp-I"), strings.Clone(strconv.Itoa(i)))
|
|
body, _ := testRequest(req, handler, t)
|
|
|
|
assert.Contains(t, body, "[Content-Type] => text/plain")
|
|
assert.Contains(t, body, fmt.Sprintf("[Frankenphp-I] => %d", i))
|
|
}, opts)
|
|
}
|
|
|
|
func TestFailingWorker(t *testing.T) {
|
|
t.Cleanup(frankenphp.Shutdown)
|
|
|
|
err := frankenphp.Init(
|
|
frankenphp.WithWorkers("failing worker", "testdata/failing-worker.php", 4, frankenphp.WithWorkerMaxFailures(1)),
|
|
frankenphp.WithNumThreads(5),
|
|
)
|
|
assert.Error(t, err, "should return an immediate error if workers fail on startup")
|
|
}
|
|
|
|
func TestEnv(t *testing.T) {
|
|
testEnv(t, &testOptions{nbParallelRequests: 1})
|
|
}
|
|
func TestEnvWorker(t *testing.T) {
|
|
testEnv(t, &testOptions{nbParallelRequests: 1, workerScript: "env/test-env.php"})
|
|
}
|
|
|
|
// testEnv cannot be run in parallel due to https://github.com/golang/go/issues/63567
|
|
func testEnv(t *testing.T, opts *testOptions) {
|
|
assert.NoError(t, os.Setenv("EMPTY", ""))
|
|
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
|
|
body, _ := testGet(fmt.Sprintf("http://example.com/env/test-env.php?var=%d", i), handler, t)
|
|
|
|
// execute the script as regular php script
|
|
cmd := exec.Command("php", "testdata/env/test-env.php", strconv.Itoa(i))
|
|
stdoutStderr, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
// php is not installed or other issue, use the hardcoded output below:
|
|
stdoutStderr = []byte("Set MY_VAR successfully.\nMY_VAR = HelloWorld\nUnset MY_VAR successfully.\nMY_VAR is unset.\nMY_VAR set to empty successfully.\nMY_VAR = \nUnset NON_EXISTING_VAR successfully.\n")
|
|
}
|
|
|
|
assert.Equal(t, string(stdoutStderr), body)
|
|
}, opts)
|
|
}
|
|
|
|
func TestEnvIsResetInNonWorkerMode(t *testing.T) {
|
|
assert.NoError(t, os.Setenv("test", ""))
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
|
|
putResult, _ := testGet(fmt.Sprintf("http://example.com/env/putenv.php?key=test&put=%d", i), handler, t)
|
|
|
|
assert.Equal(t, fmt.Sprintf("test=%d", i), putResult, "putenv and then echo getenv")
|
|
|
|
getResult, _ := testGet("http://example.com/env/putenv.php?key=test", handler, t)
|
|
|
|
assert.Equal(t, "test=", getResult, "putenv should be reset across requests")
|
|
}, &testOptions{})
|
|
}
|
|
|
|
// TODO: should it actually get reset in worker mode?
|
|
func TestEnvIsNotResetInWorkerMode(t *testing.T) {
|
|
assert.NoError(t, os.Setenv("index", ""))
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
|
|
putResult, _ := testGet(fmt.Sprintf("http://example.com/env/remember-env.php?index=%d", i), handler, t)
|
|
|
|
assert.Equal(t, "success", putResult, "putenv and then echo getenv")
|
|
|
|
getResult, _ := testGet("http://example.com/env/remember-env.php", handler, t)
|
|
|
|
assert.Equal(t, "success", getResult, "putenv should not be reset across worker requests")
|
|
}, &testOptions{workerScript: "env/remember-env.php"})
|
|
}
|
|
|
|
// reproduction of https://github.com/php/frankenphp/issues/1061
|
|
func TestModificationsToEnvPersistAcrossRequests(t *testing.T) {
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
|
|
for range 3 {
|
|
result, _ := testGet("http://example.com/env/overwrite-env.php", handler, t)
|
|
assert.Equal(t, "custom_value", result, "a var directly added to $_ENV should persist")
|
|
}
|
|
}, &testOptions{
|
|
workerScript: "env/overwrite-env.php",
|
|
phpIni: map[string]string{"variables_order": "EGPCS"},
|
|
})
|
|
}
|
|
|
|
func TestFileUpload_module(t *testing.T) { testFileUpload(t, &testOptions{}) }
|
|
func TestFileUpload_worker(t *testing.T) {
|
|
testFileUpload(t, &testOptions{workerScript: "file-upload.php"})
|
|
}
|
|
func testFileUpload(t *testing.T, opts *testOptions) {
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
|
|
requestBody := &bytes.Buffer{}
|
|
writer := multipart.NewWriter(requestBody)
|
|
part, _ := writer.CreateFormFile("file", "foo.txt")
|
|
_, err := part.Write([]byte("bar"))
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, writer.Close())
|
|
|
|
req := httptest.NewRequest("POST", "http://example.com/file-upload.php", requestBody)
|
|
req.Header.Add("Content-Type", writer.FormDataContentType())
|
|
|
|
body, _ := testRequest(req, handler, t)
|
|
|
|
assert.Contains(t, string(body), "Upload OK")
|
|
}, opts)
|
|
}
|
|
|
|
func TestExecuteScriptCLI(t *testing.T) {
|
|
if _, err := os.Stat("internal/testcli/testcli"); err != nil {
|
|
t.Skip("internal/testcli/testcli has not been compiled, run `cd internal/testcli/ && go build`")
|
|
}
|
|
|
|
cmd := exec.Command("internal/testcli/testcli", "testdata/command.php", "foo", "bar")
|
|
stdoutStderr, err := cmd.CombinedOutput()
|
|
assert.Error(t, err)
|
|
|
|
var exitError *exec.ExitError
|
|
if errors.As(err, &exitError) {
|
|
assert.Equal(t, 3, exitError.ExitCode())
|
|
}
|
|
|
|
stdoutStderrStr := string(stdoutStderr)
|
|
|
|
assert.Contains(t, stdoutStderrStr, `"foo"`)
|
|
assert.Contains(t, stdoutStderrStr, `"bar"`)
|
|
assert.Contains(t, stdoutStderrStr, "From the CLI")
|
|
}
|
|
|
|
func TestExecuteCLICode(t *testing.T) {
|
|
if _, err := os.Stat("internal/testcli/testcli"); err != nil {
|
|
t.Skip("internal/testcli/testcli has not been compiled, run `cd internal/testcli/ && go build`")
|
|
}
|
|
|
|
cmd := exec.Command("internal/testcli/testcli", "-r", "echo 'Hello World';")
|
|
stdoutStderr, err := cmd.CombinedOutput()
|
|
assert.NoError(t, err)
|
|
|
|
stdoutStderrStr := string(stdoutStderr)
|
|
assert.Equal(t, stdoutStderrStr, `Hello World`)
|
|
}
|
|
|
|
func ExampleServeHTTP() {
|
|
if err := frankenphp.Init(); err != nil {
|
|
panic(err)
|
|
}
|
|
defer frankenphp.Shutdown()
|
|
|
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
req, err := frankenphp.NewRequestWithContext(r, frankenphp.WithRequestDocumentRoot("/path/to/document/root", false))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
if err := frankenphp.ServeHTTP(w, req); err != nil {
|
|
panic(err)
|
|
}
|
|
})
|
|
log.Fatal(http.ListenAndServe(":8080", nil))
|
|
}
|
|
|
|
func ExampleExecuteScriptCLI() {
|
|
if len(os.Args) <= 1 {
|
|
log.Println("Usage: my-program script.php")
|
|
os.Exit(1)
|
|
}
|
|
|
|
os.Exit(frankenphp.ExecuteScriptCLI(os.Args[1], os.Args))
|
|
}
|
|
|
|
func BenchmarkHelloWorld(b *testing.B) {
|
|
require.NoError(b, frankenphp.Init())
|
|
b.Cleanup(frankenphp.Shutdown)
|
|
|
|
cwd, _ := os.Getwd()
|
|
testDataDir := cwd + "/testdata/"
|
|
|
|
opt := frankenphp.WithRequestDocumentRoot(testDataDir, false)
|
|
handler := func(w http.ResponseWriter, r *http.Request) {
|
|
req, err := frankenphp.NewRequestWithContext(r, opt)
|
|
require.NoError(b, err)
|
|
|
|
require.NoError(b, frankenphp.ServeHTTP(w, req))
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "http://example.com/index.php", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
for b.Loop() {
|
|
handler(w, req)
|
|
}
|
|
}
|
|
|
|
func BenchmarkEcho(b *testing.B) {
|
|
require.NoError(b, frankenphp.Init())
|
|
b.Cleanup(frankenphp.Shutdown)
|
|
|
|
cwd, _ := os.Getwd()
|
|
testDataDir := cwd + "/testdata/"
|
|
|
|
opt := frankenphp.WithRequestDocumentRoot(testDataDir, false)
|
|
handler := func(w http.ResponseWriter, r *http.Request) {
|
|
req, err := frankenphp.NewRequestWithContext(r, opt)
|
|
require.NoError(b, err)
|
|
|
|
require.NoError(b, frankenphp.ServeHTTP(w, req))
|
|
}
|
|
|
|
const body = `{
|
|
"squadName": "Super hero squad",
|
|
"homeTown": "Metro City",
|
|
"formed": 2016,
|
|
"secretBase": "Super tower",
|
|
"active": true,
|
|
"members": [
|
|
{
|
|
"name": "Molecule Man",
|
|
"age": 29,
|
|
"secretIdentity": "Dan Jukes",
|
|
"powers": ["Radiation resistance", "Turning tiny", "Radiation blast"]
|
|
},
|
|
{
|
|
"name": "Madame Uppercut",
|
|
"age": 39,
|
|
"secretIdentity": "Jane Wilson",
|
|
"powers": [
|
|
"Million tonne punch",
|
|
"Damage resistance",
|
|
"Superhuman reflexes"
|
|
]
|
|
},
|
|
{
|
|
"name": "Eternal Flame",
|
|
"age": 1000000,
|
|
"secretIdentity": "Unknown",
|
|
"powers": [
|
|
"Immortality",
|
|
"Heat Immunity",
|
|
"Inferno",
|
|
"Teleportation",
|
|
"Interdimensional travel"
|
|
]
|
|
}
|
|
]
|
|
}`
|
|
|
|
r := strings.NewReader(body)
|
|
req := httptest.NewRequest("POST", "http://example.com/echo.php", r)
|
|
w := httptest.NewRecorder()
|
|
|
|
for b.Loop() {
|
|
r.Reset(body)
|
|
handler(w, req)
|
|
}
|
|
}
|
|
|
|
func BenchmarkServerSuperGlobal(b *testing.B) {
|
|
require.NoError(b, frankenphp.Init())
|
|
b.Cleanup(frankenphp.Shutdown)
|
|
|
|
cwd, _ := os.Getwd()
|
|
testDataDir := cwd + "/testdata/"
|
|
|
|
// Mimics headers of a request sent by Firefox to GitHub
|
|
headers := http.Header{}
|
|
headers.Add(strings.Clone("Accept"), strings.Clone("text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"))
|
|
headers.Add(strings.Clone("Accept-Encoding"), strings.Clone("gzip, deflate, br"))
|
|
headers.Add(strings.Clone("Accept-Language"), strings.Clone("fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3"))
|
|
headers.Add(strings.Clone("Cache-Control"), strings.Clone("no-cache"))
|
|
headers.Add(strings.Clone("Connection"), strings.Clone("keep-alive"))
|
|
headers.Add(strings.Clone("Cookie"), strings.Clone("user_session=myrandomuuid; __Host-user_session_same_site=myotherrandomuuid; dotcom_user=dunglas; logged_in=yes; _foo=barbarbarbarbarbar; _device_id=anotherrandomuuid; color_mode=foobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobar; preferred_color_mode=light; tz=Europe%2FParis; has_recent_activity=1"))
|
|
headers.Add(strings.Clone("DNT"), strings.Clone("1"))
|
|
headers.Add(strings.Clone("Host"), strings.Clone("example.com"))
|
|
headers.Add(strings.Clone("Pragma"), strings.Clone("no-cache"))
|
|
headers.Add(strings.Clone("Sec-Fetch-Dest"), strings.Clone("document"))
|
|
headers.Add(strings.Clone("Sec-Fetch-Mode"), strings.Clone("navigate"))
|
|
headers.Add(strings.Clone("Sec-Fetch-Site"), strings.Clone("cross-site"))
|
|
headers.Add(strings.Clone("Sec-GPC"), strings.Clone("1"))
|
|
headers.Add(strings.Clone("Upgrade-Insecure-Requests"), strings.Clone("1"))
|
|
headers.Add(strings.Clone("User-Agent"), strings.Clone("Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:122.0) Gecko/20100101 Firefox/122.0"))
|
|
|
|
// Env vars available in a typical Docker container
|
|
env := map[string]string{
|
|
"HOSTNAME": "a88e81aa22e4",
|
|
"PHP_INI_DIR": "/usr/local/etc/php",
|
|
"HOME": "/root",
|
|
"GODEBUG": "cgocheck=0",
|
|
"PHP_LDFLAGS": "-Wl,-O1 -pie",
|
|
"PHP_CFLAGS": "-fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64",
|
|
"PHP_VERSION": "8.3.2",
|
|
"GPG_KEYS": "1198C0117593497A5EC5C199286AF1F9897469DC C28D937575603EB4ABB725861C0779DC5C0A9DE4 AFD8691FDAEDF03BDF6E460563F15A9B715376CA",
|
|
"PHP_CPPFLAGS": "-fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64",
|
|
"PHP_ASC_URL": "https://www.php.net/distributions/php-8.3.2.tar.xz.asc",
|
|
"PHP_URL": "https://www.php.net/distributions/php-8.3.2.tar.xz",
|
|
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
|
"XDG_CONFIG_HOME": "/config",
|
|
"XDG_DATA_HOME": "/data",
|
|
"PHPIZE_DEPS": "autoconf dpkg-dev file g++ gcc libc-dev make pkg-config re2c",
|
|
"PWD": "/app",
|
|
"PHP_SHA256": "4ffa3e44afc9c590e28dc0d2d31fc61f0139f8b335f11880a121b9f9b9f0634e",
|
|
}
|
|
|
|
preparedEnv := frankenphp.PrepareEnv(env)
|
|
|
|
opts := []frankenphp.RequestOption{frankenphp.WithRequestDocumentRoot(testDataDir, false), frankenphp.WithRequestPreparedEnv(preparedEnv)}
|
|
handler := func(w http.ResponseWriter, r *http.Request) {
|
|
req, err := frankenphp.NewRequestWithContext(r, opts...)
|
|
require.NoError(b, err)
|
|
|
|
r.Header = headers
|
|
|
|
require.NoError(b, frankenphp.ServeHTTP(w, req))
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "http://example.com/server-variable.php", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
for b.Loop() {
|
|
handler(w, req)
|
|
}
|
|
}
|
|
|
|
func BenchmarkUncommonHeaders(b *testing.B) {
|
|
require.NoError(b, frankenphp.Init())
|
|
b.Cleanup(frankenphp.Shutdown)
|
|
|
|
cwd, _ := os.Getwd()
|
|
testDataDir := cwd + "/testdata/"
|
|
|
|
// Mimics headers of a request sent by Firefox to GitHub
|
|
headers := http.Header{}
|
|
headers.Add(strings.Clone("Accept"), strings.Clone("text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"))
|
|
headers.Add(strings.Clone("Accept-Encoding"), strings.Clone("gzip, deflate, br"))
|
|
headers.Add(strings.Clone("Accept-Language"), strings.Clone("fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3"))
|
|
headers.Add(strings.Clone("Cache-Control"), strings.Clone("no-cache"))
|
|
headers.Add(strings.Clone("Connection"), strings.Clone("keep-alive"))
|
|
headers.Add(strings.Clone("Cookie"), strings.Clone("user_session=myrandomuuid; __Host-user_session_same_site=myotherrandomuuid; dotcom_user=dunglas; logged_in=yes; _foo=barbarbarbarbarbar; _device_id=anotherrandomuuid; color_mode=foobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobar; preferred_color_mode=light; tz=Europe%2FParis; has_recent_activity=1"))
|
|
headers.Add(strings.Clone("DNT"), strings.Clone("1"))
|
|
headers.Add(strings.Clone("Host"), strings.Clone("example.com"))
|
|
headers.Add(strings.Clone("Pragma"), strings.Clone("no-cache"))
|
|
headers.Add(strings.Clone("Sec-Fetch-Dest"), strings.Clone("document"))
|
|
headers.Add(strings.Clone("Sec-Fetch-Mode"), strings.Clone("navigate"))
|
|
headers.Add(strings.Clone("Sec-Fetch-Site"), strings.Clone("cross-site"))
|
|
headers.Add(strings.Clone("Sec-GPC"), strings.Clone("1"))
|
|
headers.Add(strings.Clone("Upgrade-Insecure-Requests"), strings.Clone("1"))
|
|
headers.Add(strings.Clone("User-Agent"), strings.Clone("Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:122.0) Gecko/20100101 Firefox/122.0"))
|
|
// Some uncommon headers
|
|
headers.Add(strings.Clone("X-Super-Custom"), strings.Clone("Foo"))
|
|
headers.Add(strings.Clone("Super-Super-Custom"), strings.Clone("Foo"))
|
|
headers.Add(strings.Clone("Super-Super-Custom"), strings.Clone("Bar"))
|
|
headers.Add(strings.Clone("Very-Custom"), strings.Clone("1"))
|
|
|
|
opt := frankenphp.WithRequestDocumentRoot(testDataDir, false)
|
|
handler := func(w http.ResponseWriter, r *http.Request) {
|
|
req, err := frankenphp.NewRequestWithContext(r, opt)
|
|
require.NoError(b, err)
|
|
|
|
r.Header = headers
|
|
|
|
require.NoError(b, frankenphp.ServeHTTP(w, req))
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "http://example.com/server-variable.php", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
for b.Loop() {
|
|
handler(w, req)
|
|
}
|
|
}
|
|
|
|
func TestRejectInvalidHeaders_module(t *testing.T) { testRejectInvalidHeaders(t, &testOptions{}) }
|
|
func TestRejectInvalidHeaders_worker(t *testing.T) {
|
|
testRejectInvalidHeaders(t, &testOptions{workerScript: "headers.php"})
|
|
}
|
|
func testRejectInvalidHeaders(t *testing.T, opts *testOptions) {
|
|
invalidHeaders := [][]string{
|
|
{"Content-Length", "-1"},
|
|
{"Content-Length", "something"},
|
|
}
|
|
for _, header := range invalidHeaders {
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) {
|
|
req := httptest.NewRequest("GET", "http://example.com/headers.php", nil)
|
|
req.Header.Add(header[0], header[1])
|
|
body, resp := testRequest(req, handler, t)
|
|
|
|
assert.Equal(t, 400, resp.StatusCode)
|
|
assert.Contains(t, body, "invalid")
|
|
}, opts)
|
|
}
|
|
}
|
|
|
|
func TestFlushEmptyResponse_module(t *testing.T) { testFlushEmptyResponse(t, &testOptions{}) }
|
|
func TestFlushEmptyResponse_worker(t *testing.T) {
|
|
testFlushEmptyResponse(t, &testOptions{workerScript: "only-headers.php"})
|
|
}
|
|
|
|
func testFlushEmptyResponse(t *testing.T, opts *testOptions) {
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) {
|
|
_, resp := testGet("http://example.com/only-headers.php", handler, t)
|
|
assert.Equal(t, 204, resp.StatusCode)
|
|
}, opts)
|
|
}
|
|
|
|
// Worker mode will clean up unreferenced streams between requests
|
|
// Make sure referenced streams are not cleaned up
|
|
func TestFileStreamInWorkerMode(t *testing.T) {
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) {
|
|
resp1, _ := testGet("http://example.com/file-stream.php", handler, t)
|
|
assert.Equal(t, resp1, "word1")
|
|
|
|
resp2, _ := testGet("http://example.com/file-stream.php", handler, t)
|
|
assert.Equal(t, resp2, "word2")
|
|
|
|
resp3, _ := testGet("http://example.com/file-stream.php", handler, t)
|
|
assert.Equal(t, resp3, "word3")
|
|
}, &testOptions{workerScript: "file-stream.php", nbParallelRequests: 1, nbWorkers: 1})
|
|
}
|
|
|
|
// To run this fuzzing test use: go test -fuzz FuzzRequest
|
|
// TODO: Cover more potential cases
|
|
func FuzzRequest(f *testing.F) {
|
|
absPath, _ := fastabs.FastAbs("./testdata/")
|
|
|
|
f.Add("hello world")
|
|
f.Add("😀😅🙃🤩🥲🤪😘😇😉🐘🧟")
|
|
f.Add("%00%11%%22%%33%%44%%55%%66%%77%%88%%99%%aa%%bb%%cc%%dd%%ee%%ff")
|
|
f.Add("\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f")
|
|
f.Fuzz(func(t *testing.T, fuzzedString string) {
|
|
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) {
|
|
req := httptest.NewRequest("GET", "http://example.com/server-variable", nil)
|
|
req.URL = &url.URL{RawQuery: "test=" + fuzzedString, Path: "/server-variable.php/" + fuzzedString}
|
|
req.Header.Add(strings.Clone("Fuzzed"), strings.Clone(fuzzedString))
|
|
req.Header.Add(strings.Clone("Content-Type"), fuzzedString)
|
|
body, resp := testRequest(req, handler, t)
|
|
|
|
// The response status must be 400 if the request path contains null bytes
|
|
if strings.Contains(req.URL.Path, "\x00") {
|
|
assert.Equal(t, 400, resp.StatusCode)
|
|
assert.Contains(t, body, "invalid request path")
|
|
|
|
return
|
|
}
|
|
|
|
// The fuzzed string must be present in the path
|
|
assert.Contains(t, body, fmt.Sprintf("[PATH_INFO] => /%s", fuzzedString))
|
|
assert.Contains(t, body, fmt.Sprintf("[PATH_TRANSLATED] => %s", filepath.Join(absPath, fuzzedString)))
|
|
|
|
// Headers should always be present even if empty
|
|
assert.Contains(t, body, fmt.Sprintf("[CONTENT_TYPE] => %s", fuzzedString))
|
|
assert.Contains(t, body, fmt.Sprintf("[HTTP_FUZZED] => %s", fuzzedString))
|
|
}, &testOptions{workerScript: "request-headers.php"})
|
|
})
|
|
}
|