Files
mesh-catalog/modules/brother/cmd/brother/fetch_test.go
T

263 lines
10 KiB
Go

package main
import (
"context"
"errors"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// A fetch rig: names resolve as the test says, and every dial goes to the test's server whatever address was asked,
// after the fetcher has checked the address it was about to dial and told the dial what that was.
type fetchRig struct {
f *Fetcher
mu sync.Mutex
dialed []string
lookups map[string]int
names map[string][]netip.Addr
// after is what a host answers from its second lookup on, when it answers differently (a rebinding).
after map[string][]netip.Addr
srv *httptest.Server
}
func newFetchRig(t *testing.T, h http.HandlerFunc) *fetchRig {
t.Helper()
r := &fetchRig{lookups: map[string]int{}, names: map[string][]netip.Addr{}, after: map[string][]netip.Addr{}}
r.srv = httptest.NewServer(h)
t.Cleanup(r.srv.Close)
r.f = &Fetcher{MeshRange: netip.MustParsePrefix("10.10.0.0/24"), Timeout: 5 * time.Second, Most: 1 << 20, Redirects: FetchRedirects,
Resolve: func(_ context.Context, host string) ([]netip.Addr, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.lookups[host]++
a, ok := r.names[host]
if !ok {
return nil, errors.New("no such host")
}
if again, rebinds := r.after[host]; rebinds && r.lookups[host] > 1 {
return again, nil
}
return a, nil
},
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
r.mu.Lock()
r.dialed = append(r.dialed, address)
r.mu.Unlock()
return (&net.Dialer{}).DialContext(ctx, network, r.srv.Listener.Addr().String())
}}
return r
}
func addrs(s ...string) []netip.Addr {
var out []netip.Addr
for _, a := range s {
out = append(out, netip.MustParseAddr(a))
}
return out
}
// 1599/AC1: a url with a scheme other than http or https is refused by name.
func TestAURLOfAnotherSchemeIsRefused(t *testing.T) {
r := newFetchRig(t, func(http.ResponseWriter, *http.Request) {})
for _, u := range []string{"file:///etc/passwd", "ftp://example.com/a.pdf", "gopher://example.com/", "javascript:alert(1)", "//example.com/a.pdf"} {
_, err := r.f.Fetch(t.Context(), u)
if err == nil || (!strings.Contains(err.Error(), "only http and https") && !strings.Contains(err.Error(), "is not an address")) {
t.Errorf("%s: error = %v", u, err)
}
if err := CheckURLScheme(u); err == nil {
t.Errorf("%s: the tool side took it", u)
}
}
if len(r.dialed) != 0 {
t.Errorf("dialled %v", r.dialed)
}
}
// 1599/AC2: a host that is, or resolves to, a loopback, private, link-local, shared, unspecified or multicast address,
// or one in the mesh's range, is refused naming the class, and no connection is made.
func TestAddressesInsideTheMeshAndTheMachineAreRefused(t *testing.T) {
r := newFetchRig(t, func(http.ResponseWriter, *http.Request) { t.Error("a request reached the server") })
cases := map[string]string{
"127.0.0.1": "loopback", "::1": "loopback", "10.0.0.1": "private address (RFC 1918)", "192.168.1.1": "private address (RFC 1918)",
"172.16.0.1": "private address (RFC 1918)", "fd00::1": "private address (RFC 4193)", "169.254.1.1": "link-local", "fe80::1": "link-local",
"100.64.0.1": "shared address (RFC 6598)", "0.0.0.0": "unspecified", "::": "unspecified", "224.0.0.1": "multicast", "ff02::1": "multicast",
"10.10.0.7": "private address (RFC 1918)", "::ffff:127.0.0.1": "loopback",
}
for ip, class := range cases {
host := ip
if strings.Contains(ip, ":") {
host = "[" + ip + "]"
}
_, err := r.f.Fetch(t.Context(), "http://"+host+"/a.pdf")
if err == nil || !strings.Contains(err.Error(), class) {
t.Errorf("%s: error = %v, want the class %q", ip, err, class)
}
// The same address behind a name.
r.names["inside.test"] = addrs("203.0.113.9", ip)
if _, err := r.f.Fetch(t.Context(), "http://inside.test/a.pdf"); err == nil || !strings.Contains(err.Error(), class) {
t.Errorf("inside.test -> %s: error = %v", ip, err)
}
}
// The mesh's range from fetch.json, when it is not a private range.
r.f.MeshRange = netip.MustParsePrefix("203.0.113.0/24")
if _, err := r.f.Fetch(t.Context(), "http://203.0.113.5/a.pdf"); err == nil || !strings.Contains(err.Error(), "the mesh's own network") {
t.Errorf("the mesh range: error = %v", err)
}
if len(r.dialed) != 0 {
t.Errorf("a connection was made to %v", r.dialed)
}
}
// 1599/AC3: a redirect to such an address is refused; a sixth redirect is refused; a redirect to a public address is followed.
func TestRedirectsAreCheckedAndBounded(t *testing.T) {
hops := 0
r := newFetchRig(t, func(w http.ResponseWriter, req *http.Request) {
switch req.URL.Path {
case "/to-loopback":
http.Redirect(w, req, "http://127.0.0.1/secret", http.StatusFound)
case "/to-name":
http.Redirect(w, req, "http://inside.test/x", http.StatusFound)
case "/loop":
hops++
http.Redirect(w, req, "/loop", http.StatusFound)
case "/ok":
http.Redirect(w, req, "http://public.test/final.pdf", http.StatusMovedPermanently)
default:
w.Write([]byte("%PDF-1.7 final"))
}
})
r.names["public.test"] = addrs("203.0.113.9")
r.names["inside.test"] = addrs("192.168.0.9")
if _, err := r.f.Fetch(t.Context(), "http://public.test/to-loopback"); err == nil || !strings.Contains(err.Error(), "loopback") {
t.Errorf("redirect to loopback: error = %v", err)
}
if _, err := r.f.Fetch(t.Context(), "http://public.test/to-name"); err == nil || !strings.Contains(err.Error(), "private address (RFC 1918)") {
t.Errorf("redirect to a name: error = %v", err)
}
if _, err := r.f.Fetch(t.Context(), "http://public.test/loop"); err == nil || !strings.Contains(err.Error(), "redirected more than 5 times") {
t.Errorf("a loop: error = %v", err)
}
if hops != 6 {
t.Errorf("the loop was followed %d times, want the first request and 5 redirects (6 requests)", hops)
}
got, err := r.f.Fetch(t.Context(), "http://public.test/ok")
if err != nil || string(got.Body) != "%PDF-1.7 final" {
t.Errorf("a redirect to a public address: %v, %v", got, err)
}
}
// 1599/AC4: a body over the limit and a fetch over the time are refused, and nothing is returned to be stored.
func TestABodyOverTheLimitAndAFetchOverTheTimeAreRefused(t *testing.T) {
r := newFetchRig(t, func(w http.ResponseWriter, req *http.Request) {
switch req.URL.Path {
case "/big":
w.Write([]byte(strings.Repeat("x", 2000)))
case "/chunked":
w.(http.Flusher).Flush() // no length is declared
w.Write([]byte(strings.Repeat("x", 2000)))
case "/slow":
time.Sleep(500 * time.Millisecond)
w.Write([]byte("late"))
}
})
r.names["public.test"] = addrs("203.0.113.9")
r.f.Most = 1000
for _, p := range []string{"/big", "/chunked"} {
if got, err := r.f.Fetch(t.Context(), "http://public.test"+p); err == nil || got != nil || !strings.Contains(err.Error(), "over the") {
t.Errorf("%s: %v, %v", p, got, err)
}
}
r.f.Timeout = 100 * time.Millisecond
if got, err := r.f.Fetch(t.Context(), "http://public.test/slow"); err == nil || got != nil {
t.Errorf("a slow fetch: %v, %v", got, err)
}
}
// 1599/AC5: the connection goes to the address that was checked: a name that answers differently on a second lookup
// is not dialled again, and each host is looked up once.
func TestTheCheckedAddressIsTheOneDialled(t *testing.T) {
r := newFetchRig(t, func(w http.ResponseWriter, req *http.Request) { w.Write([]byte("%PDF-1.7 x")) })
r.names["rebind.test"] = addrs("203.0.113.9")
r.after["rebind.test"] = addrs("127.0.0.1") // a second lookup would say loopback
if _, err := r.f.Fetch(t.Context(), "http://rebind.test:8080/a.pdf"); err != nil {
t.Fatal(err)
}
if len(r.dialed) != 1 || r.dialed[0] != "203.0.113.9:8080" {
t.Errorf("dialled %v, want the checked address and the url's port", r.dialed)
}
if r.lookups["rebind.test"] != 1 {
t.Errorf("the host was looked up %d times", r.lookups["rebind.test"])
}
}
// 1599/AC6: a fetched PDF is stored under its SHA-256 and printed as that object.
func TestAFetchedPDFIsStoredUnderItsHashAndPrinted(t *testing.T) {
fake := newIPPFake(t, 41, 5)
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
doc := pdfOf(4000)
web := newFetchRig(t, func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/pdf")
w.Write(doc)
})
web.names["public.test"] = addrs("203.0.113.9")
r.worker.Fetcher = func() (*Fetcher, error) { return web.f, nil }
got, err := r.call(t, "printer.print", map[string]any{"url": "http://public.test/letter.pdf"})
if err != nil {
t.Fatal(err)
}
m := got.(map[string]any)
key := sha256Hex(doc)
if m["object"] != key || m["job-id"] != float64(41) || m["format"] != "application/pdf" {
t.Errorf("answer = %v", m)
}
if stored, err := r.objects.Get(t.Context(), key); err != nil || string(stored) != string(doc) {
t.Errorf("stored under the hash: %v", err)
}
if _, sent := fake.request(t); string(sent) != string(doc) {
t.Error("the printer was not sent the fetched document")
}
queued := r.bus.published[0]
if queued.URL != "http://public.test/letter.pdf" || queued.Object != "" {
t.Errorf("queued %+v", queued)
}
if err := CheckURLScheme("https://public.test/letter.pdf"); err != nil {
t.Errorf("https refused: %v", err)
}
// A refusal inside the mesh is answered, and nothing is stored nor printed.
fake.body = nil
before := len(r.objects.keys())
if _, err := r.call(t, "printer.print", map[string]any{"url": "http://127.0.0.1:9000/mesh-shanks-brother/x"}); err == nil || !strings.Contains(err.Error(), "loopback") {
t.Errorf("a loopback address: error = %v", err)
}
if len(r.objects.keys()) != before || fake.body != nil {
t.Error("something was stored or printed for a refused address")
}
}
// The fetch's facts are the file the mesh writes (files/fetch.json) from the `mesh-range` machine fact, and the device
// process reads them.
func TestTheFetchFactsAreTheMeshRange(t *testing.T) {
raw, err := os.ReadFile("../../files/fetch.json")
if err != nil || !strings.Contains(string(raw), `"mesh-range": "${machine:mesh-range}"`) {
t.Fatalf("files/fetch.json = %s, %v", raw, err)
}
file := filepath.Join(t.TempDir(), "fetch.json")
os.WriteFile(file, []byte(`{"mesh-range": "10.10.0.0/24"}`), 0o600)
p, err := ReadFetchFacts(file)
if err != nil || p.String() != "10.10.0.0/24" {
t.Errorf("prefix %v, %v", p, err)
}
os.WriteFile(file, []byte(`{}`), 0o600)
if _, err := ReadFetchFacts(file); err == nil {
t.Error("facts with no range were taken")
}
}