package router_tests
import "core:fmt"
import "core:os"
import "core:strings"
import "core:testing"
import http "lib:net/http"
import "lib:net/http/router"
Trace :: struct {
name: string,
trace: ^[dynamic]string,
}
trace_middleware :: proc(
req: router.Request,
res: ^router.Response,
ctx: rawptr,
next: router.Route_Handler,
next_ctx: rawptr,
) {
t := (^Trace)(ctx)
append(t.trace, t.name)
next(req, res, next_ctx)
}
// Answers on its own, never calls next
reject_middleware :: proc(
req: router.Request,
res: ^router.Response,
ctx: rawptr,
next: router.Route_Handler,
next_ctx: rawptr,
) {
t := (^Trace)(ctx)
append(t.trace, t.name)
http.respond(res, .Forbidden, "rejected")
}
trace_handler :: proc(req: router.Request, res: ^router.Response, ctx: rawptr) {
t := (^Trace)(ctx)
append(t.trace, t.name)
http.respond(res, .Ok, "ok")
}
request :: proc(r: ^router.Router, target: string) -> http.Response {
handler, data := router.handle(r)
res: http.Response
handler(http.Request{method = .Get, target = target}, &res, data)
return res
}
response_destroy :: proc(res: ^http.Response) {
delete(res.body)
for h in res.headers {
delete(h.name)
delete(h.value)
}
delete(res.headers)
}
expect_trace :: proc(t: ^testing.T, trace: [dynamic]string, expected: []string, loc := #caller_location) {
testing.expectf(t, len(trace) == len(expected), "trace %v, expected %v", trace[:], expected, loc = loc)
if len(trace) == len(expected) {
for e, i in expected {
testing.expectf(t, trace[i] == e, "trace %v, expected %v", trace[:], expected, loc = loc)
}
}
}
@(test)
middleware_order :: proc(t: ^testing.T) {
trace: [dynamic]string
defer delete(trace)
g1 := Trace{"g1", &trace}
g2 := Trace{"g2", &trace}
r1 := Trace{"r1", &trace}
r2 := Trace{"r2", &trace}
h := Trace{"handler", &trace}
r := router.create()
defer router.destroy(&r)
router.use(&r, trace_middleware, &g1)
router.get(&r, "/x", trace_handler, user_data = &h, middlewares = {{trace_middleware, &r1}, {trace_middleware, &r2}})
// Added after the route, still global
router.use(&r, trace_middleware, &g2)
res := request(&r, "/x")
defer response_destroy(&res)
expect_trace(t, trace, {"g1", "g2", "r1", "r2", "handler"})
testing.expect_value(t, res.code, http.Status_Code.Ok)
}
@(test)
route_middleware_only_runs_for_its_route :: proc(t: ^testing.T) {
trace: [dynamic]string
defer delete(trace)
mw := Trace{"mw", &trace}
h := Trace{"handler", &trace}
r := router.create()
defer router.destroy(&r)
router.get(&r, "/guarded", trace_handler, user_data = &h, middlewares = {{trace_middleware, &mw}})
router.get(&r, "/open", trace_handler, user_data = &h)
res := request(&r, "/open")
response_destroy(&res)
expect_trace(t, trace, {"handler"})
clear(&trace)
res = request(&r, "/guarded")
response_destroy(&res)
expect_trace(t, trace, {"mw", "handler"})
}
@(test)
route_middleware_on_every_route_kind :: proc(t: ^testing.T) {
Case :: struct {
kind: router.Route_Kind,
path: string,
target: string,
}
cases := []Case {
{.Exact, "/exact", "/exact"},
{.Prefix, "/prefix/", "/prefix/foo"},
{.Pattern, "/users/:id", "/users/42"},
}
for c in cases {
trace: [dynamic]string
defer delete(trace)
mw := Trace{"mw", &trace}
h := Trace{"handler", &trace}
r := router.create()
defer router.destroy(&r)
router.get(&r, c.path, trace_handler, c.kind, &h, {{trace_middleware, &mw}})
res := request(&r, c.target)
defer response_destroy(&res)
expect_trace(t, trace, {"mw", "handler"})
}
}
// Used to leak the nodes of every middleware below the one that answered
@(test)
middleware_can_short_circuit :: proc(t: ^testing.T) {
trace: [dynamic]string
defer delete(trace)
g1 := Trace{"g1", &trace}
g2 := Trace{"g2", &trace}
deny := Trace{"deny", &trace}
r1 := Trace{"r1", &trace}
h := Trace{"handler", &trace}
r := router.create()
defer router.destroy(&r)
router.use(&r, trace_middleware, &g1)
router.use(&r, reject_middleware, &deny)
router.use(&r, trace_middleware, &g2)
router.get(&r, "/x", trace_handler, user_data = &h, middlewares = {{trace_middleware, &r1}})
res := request(&r, "/x")
defer response_destroy(&res)
expect_trace(t, trace, {"g1", "deny"})
testing.expect_value(t, res.code, http.Status_Code.Forbidden)
}
// More middlewares than fit in the on-stack chain storage
@(test)
long_chain :: proc(t: ^testing.T) {
COUNT :: 12
trace: [dynamic]string
defer delete(trace)
names: [COUNT]string
traces: [COUNT]Trace
expected: [COUNT + 1]string
h := Trace{"handler", &trace}
r := router.create()
defer router.destroy(&r)
for i in 0 ..< COUNT {
names[i] = fmt.aprintf("m%d", i)
traces[i] = Trace{names[i], &trace}
expected[i] = names[i]
router.use(&r, trace_middleware, &traces[i])
}
defer for n in names {
delete(n)
}
expected[COUNT] = "handler"
router.get(&r, "/x", trace_handler, user_data = &h)
res := request(&r, "/x")
response_destroy(&res)
expect_trace(t, trace, expected[:])
// ...and bailing out at the end of it, the handler is never reached
clear(&trace)
deny := Trace{"deny", &trace}
router.use(&r, reject_middleware, &deny)
res = request(&r, "/x")
defer response_destroy(&res)
expected[COUNT] = "deny"
expect_trace(t, trace, expected[:])
testing.expect_value(t, res.code, http.Status_Code.Forbidden)
}
@(test)
static_sees_mime_types_added_later :: proc(t: ^testing.T) {
dir, err := os.make_directory_temp("", "gloam-router-test-*", context.allocator)
if !testing.expectf(t, err == nil, "couldn't create temp dir: %v", err) {
return
}
defer delete(dir)
defer os.remove_all(dir)
file, _ := os.join_path({dir, "a.wasm"}, context.allocator)
defer delete(file)
testing.expect(t, os.write_entire_file(file, "contents") == nil)
r := router.create()
defer router.destroy(&r)
router.static(&r, "/static", dir)
// Enough to make the map reallocate
extra: [64]string
for i in 0 ..< len(extra) {
extra[i] = fmt.aprintf(".ext%d", i)
r.mime_map[extra[i]] = "x/y"
}
defer for e in extra {
delete(e)
}
r.mime_map[".wasm"] = "application/wasm"
res := request(&r, "/static/a.wasm")
defer response_destroy(&res)
testing.expect_value(t, res.code, http.Status_Code.Ok)
testing.expect_value(t, string(res.body), "contents")
ct, _ := http.headers_get_first(res.headers[:], "content-type")
testing.expect_value(t, ct, "application/wasm")
}
// Answers with the value of the param named by its user_data
Param_Probe :: struct {
name: string,
}
param_handler :: proc(req: router.Request, res: ^router.Response, ctx: rawptr) {
probe := (^Param_Probe)(ctx)
http.respond(res, .Ok, req.params[probe.name])
}
expect_response :: proc(
t: ^testing.T,
r: ^router.Router,
target: string,
code: http.Status_Code,
body := "",
loc := #caller_location,
) {
res := request(r, target)
defer response_destroy(&res)
testing.expectf(t, res.code == code, "%s: code %v, expected %v", target, res.code, code, loc = loc)
if code == .Ok {
testing.expectf(t, string(res.body) == body, "%s: body %q, expected %q", target, string(res.body), body, loc = loc)
}
}
@(test)
pattern_needs_exact_segment_count :: proc(t: ^testing.T) {
probe := Param_Probe{"b"}
r := router.create()
defer router.destroy(&r)
router.get(&r, "/a/:b", param_handler, .Pattern, &probe)
expect_response(t, &r, "/a/x", .Ok, "x")
expect_response(t, &r, "/a/x/y", .NotFound)
expect_response(t, &r, "/a", .NotFound)
expect_response(t, &r, "/a/", .NotFound) // empty param
}
@(test)
pattern_wildcard_takes_the_rest :: proc(t: ^testing.T) {
files := Param_Probe{"path"}
r := router.create()
defer router.destroy(&r)
router.get(&r, "/files/*path", param_handler, .Pattern, &files)
expect_response(t, &r, "/files/x", .Ok, "x")
expect_response(t, &r, "/files/a/b/c", .Ok, "a/b/c")
expect_response(t, &r, "/files/", .Ok, "")
expect_response(t, &r, "/files", .Ok, "") // matches nothing at all
expect_response(t, &r, "/filesx", .NotFound)
expect_response(t, &r, "/other/a", .NotFound)
// A wildcard as the very first segment
r2 := router.create()
defer router.destroy(&r2)
router.get(&r2, "/*path", param_handler, .Pattern, &files)
expect_response(t, &r2, "/a/b", .Ok, "a/b")
expect_response(t, &r2, "/a", .Ok, "a")
expect_response(t, &r2, "/", .Ok, "")
}
// The route table of a repository browser, links like /r/repo/tree/ref have to keep working
@(test)
repo_browser_routes :: proc(t: ^testing.T) {
Probe :: struct {
name: string,
}
probe :: proc(req: router.Request, res: ^router.Response, ctx: rawptr) {
http.respond(res, .Ok, fmt.tprintf("%s %s|%s|%s", (^Probe)(ctx).name, req.params["repo"], req.params["ref"], req.params["path"]))
}
raw, blob, tree := Probe{"raw"}, Probe{"blob"}, Probe{"tree"}
r := router.create()
defer router.destroy(&r)
router.get(&r, "/r/:repo/raw/:ref/*path", probe, .Pattern, &raw)
router.get(&r, "/r/:repo/blob/:ref/*path", probe, .Pattern, &blob)
router.get(&r, "/r/:repo", probe, .Pattern, &tree)
router.get(&r, "/r/:repo/tree/:ref/*path", probe, .Pattern, &tree)
expect_response(t, &r, "/r/glaze", .Ok, "tree glaze||")
expect_response(t, &r, "/r/glaze/tree/main", .Ok, "tree glaze|main|")
expect_response(t, &r, "/r/glaze/tree/1a2b3c4", .Ok, "tree glaze|1a2b3c4|")
expect_response(t, &r, "/r/glaze/tree/main/", .Ok, "tree glaze|main|")
expect_response(t, &r, "/r/glaze/tree/main/src/dir", .Ok, "tree glaze|main|src/dir")
expect_response(t, &r, "/r/glaze/blob/main/src/main.odin", .Ok, "blob glaze|main|src/main.odin")
expect_response(t, &r, "/r/glaze/raw/dev/README.md", .Ok, "raw glaze|dev|README.md")
expect_response(t, &r, "/r/glaze/blob/main", .Ok, "blob glaze|main|")
expect_response(t, &r, "/r/glaze/nope/main/x", .NotFound)
}
@(test)
pattern_with_empty_segments :: proc(t: ^testing.T) {
probe := Param_Probe{"x"}
r := router.create()
defer router.destroy(&r)
router.get(&r, "/", param_handler, .Pattern, &probe)
router.get(&r, "/a//b", param_handler, .Pattern, &probe)
expect_response(t, &r, "/", .Ok, "")
expect_response(t, &r, "/a//b", .Ok, "")
expect_response(t, &r, "/a/b", .NotFound)
}
@(test)
pattern_wildcard_must_be_last :: proc(t: ^testing.T) {
probe := Param_Probe{"rest"}
r := router.create()
defer router.destroy(&r)
router.get(&r, "/a/*rest/b", param_handler, .Pattern, &probe) // refused
router.get(&r, "/p/:", param_handler, .Pattern, &probe) // nameless param, refused
router.get(&r, "no-slash/:x", param_handler, .Pattern, &probe) // refused
expect_response(t, &r, "/a/x/b", .NotFound)
expect_response(t, &r, "/p/x", .NotFound)
expect_response(t, &r, "no-slash/x", .NotFound)
}
@(test)
odd_targets_dont_panic :: proc(t: ^testing.T) {
probe := Param_Probe{"x"}
r := router.create()
defer router.destroy(&r)
router.get(&r, "/:x", param_handler, .Pattern, &probe)
expect_response(t, &r, "", .NotFound)
expect_response(t, &r, "*", .NotFound)
expect_response(t, &r, "http://host/x", .NotFound)
}
@(private = "file")
Static_Fixture :: struct {
dir: string, // holds www/ and www-secret/
root: string, // dir/www
}
@(private = "file")
static_fixture :: proc(t: ^testing.T) -> (f: Static_Fixture, ok: bool) {
err: os.Error
f.dir, err = os.make_directory_temp("", "gloam-router-test-*", context.allocator)
if !testing.expectf(t, err == nil, "couldn't create temp dir: %v", err) {
return
}
f.root, _ = os.join_path({f.dir, "www"}, context.allocator)
secret_dir, _ := os.join_path({f.dir, "www-secret"}, context.allocator)
defer delete(secret_dir)
testing.expect(t, os.make_directory(f.root) == nil)
testing.expect(t, os.make_directory(secret_dir) == nil)
write :: proc(t: ^testing.T, elems: []string, contents: string) {
path, _ := os.join_path(elems, context.allocator)
defer delete(path)
testing.expect(t, os.write_entire_file(path, contents) == nil)
}
write(t, {f.root, "a.txt"}, "public")
write(t, {f.dir, "www-secret", "b.txt"}, "secret")
write(t, {f.dir, "outside.txt"}, "outside")
return f, true
}
@(private = "file")
static_fixture_destroy :: proc(f: Static_Fixture) {
os.remove_all(f.dir)
delete(f.root)
delete(f.dir)
}
@(test)
static_stays_inside_root :: proc(t: ^testing.T) {
f, ok := static_fixture(t)
if !ok {
return
}
defer static_fixture_destroy(f)
r := router.create()
defer router.destroy(&r)
router.static(&r, "/static", f.root)
expect_response(t, &r, "/static/a.txt", .Ok, "public")
expect_response(t, &r, "/static/../outside.txt", .NotFound)
// Shares the leading characters of the root, but is a sibling
expect_response(t, &r, "/static/../www-secret/b.txt", .NotFound)
expect_response(t, &r, "/static/a.txt\x00/../../outside.txt", .NotFound)
}
@(test)
static_with_unresolvable_root_serves_nothing :: proc(t: ^testing.T) {
f, ok := static_fixture(t)
if !ok {
return
}
defer static_fixture_destroy(f)
missing, _ := os.join_path({f.dir, "does-not-exist"}, context.allocator)
defer delete(missing)
outside, _ := os.join_path({f.dir, "outside.txt"}, context.allocator)
defer delete(outside)
r := router.create()
defer router.destroy(&r)
router.static(&r, "/static", missing)
// An absolute path as the target used to get through an empty root
target := strings.concatenate({"/static/", outside})
defer delete(target)
expect_response(t, &r, target, .NotFound)
}