🍯 Glaze

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)
}