package server import ( "context" "net/http" "net/http/httptest" "strings" "testing" "git.unkin.net/unkin/bootapi/internal/model" "git.unkin.net/unkin/bootapi/internal/netbox" "git.unkin.net/unkin/bootapi/internal/render" "git.unkin.net/unkin/bootapi/templates" ) // fakeResolver is a canned netbox.Resolver for handler tests. type fakeResolver struct { byMAC map[string]*model.Host byName map[string]*model.Host err error } func (f *fakeResolver) HostByMAC(_ context.Context, mac string) (*model.Host, error) { if f.err != nil { return nil, f.err } // The real NetBox client normalizes MAC case/separators before matching; // mirror that here so case-insensitive lookups behave like production. if h, ok := f.byMAC[strings.ToLower(mac)]; ok { return h, nil } return nil, netbox.ErrNotFound } func (f *fakeResolver) HostByName(_ context.Context, name string) (*model.Host, error) { if f.err != nil { return nil, f.err } if h, ok := f.byName[name]; ok { return h, nil } return nil, netbox.ErrNotFound } func testHost() *model.Host { return &model.Host{ Hostname: "web01", Domain: "syd1.au.unkin.net", FQDN: "web01.syd1.au.unkin.net", Platform: "almalinux9", OSFamily: "almalinux", OSVersion: "9", Arch: "x86_64", PrimaryIP: "10.0.1.20", Interfaces: []model.Interface{ {Name: "eth0", MAC: "aa:bb:cc:00:11:22", IP: "10.0.1.20", PrefixLen: 24, Netmask: "255.255.255.0", Gateway: "10.0.1.254", Primary: true}, }, } } func newTestServer(t *testing.T, res netbox.Resolver, fallback string) *Server { t.Helper() eng, err := render.NewEngine(templates.FS, "", render.RenderConfig{ PuppetServer: "puppet.query.consul", PuppetCAServer: "puppetca.query.consul", BaseURL: "http://bootapi.example.net", BootBaseURL: "http://mirror.example.net/almalinux/9", DefaultDomain: "main.unkin.net", DefaultTemplate: "almalinux9", RootPasswordHash: "$6$abc$def", }) if err != nil { t.Fatal(err) } return New(Options{Resolver: res, Engine: eng, UnknownMACFallback: fallback}) } func do(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder { t.Helper() rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) return rec } func TestIPXEKnownMAC(t *testing.T) { res := &fakeResolver{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}} h := newTestServer(t, res, "local").Router() rec := do(t, h, "/ipxe/aa:bb:cc:00:11:22") if rec.Code != http.StatusOK { t.Fatalf("status = %d", rec.Code) } body := rec.Body.String() if !strings.Contains(body, "inst.ks=http://bootapi.example.net/ks/web01") { t.Errorf("ipxe body missing inst.ks:\n%s", body) } } func TestIPXEUnknownMACServesFallback200(t *testing.T) { h := newTestServer(t, &fakeResolver{}, "local").Router() rec := do(t, h, "/ipxe/de:ad:be:ef:00:00") // Unknown MAC must NOT 404 — iPXE needs a valid script. Safe local-boot. if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200 with fallback", rec.Code) } if !strings.Contains(rec.Body.String(), "sanboot") { t.Errorf("expected local-boot fallback, got:\n%s", rec.Body.String()) } } func TestIPXEUnknownMACShellFallback(t *testing.T) { h := newTestServer(t, &fakeResolver{}, "shell").Router() rec := do(t, h, "/ipxe/de:ad:be:ef:00:00") if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "shell") { t.Fatalf("shell fallback not served: %d\n%s", rec.Code, rec.Body.String()) } } func TestIPXEQueryAlias(t *testing.T) { res := &fakeResolver{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}} h := newTestServer(t, res, "local").Router() rec := do(t, h, "/boot/ipxe?mac=AA:BB:CC:00:11:22") if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "inst.ks=") { t.Fatalf("query-style ipxe failed: %d\n%s", rec.Code, rec.Body.String()) } } func TestKickstartByMAC(t *testing.T) { res := &fakeResolver{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}} h := newTestServer(t, res, "local").Router() rec := do(t, h, "/ks/aa:bb:cc:00:11:22") if rec.Code != http.StatusOK { t.Fatalf("status = %d", rec.Code) } if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") { t.Errorf("content-type = %q", ct) } if !strings.Contains(rec.Body.String(), "rootpw --iscrypted") { t.Errorf("kickstart body missing rootpw:\n%s", rec.Body.String()) } } func TestKickstartByHostname(t *testing.T) { res := &fakeResolver{byName: map[string]*model.Host{"web01": testHost()}} h := newTestServer(t, res, "local").Router() rec := do(t, h, "/ks/web01.cfg") // .cfg suffix must be stripped if rec.Code != http.StatusOK { t.Fatalf("status = %d\n%s", rec.Code, rec.Body.String()) } } func TestKickstartUnknownIs404(t *testing.T) { h := newTestServer(t, &fakeResolver{}, "local").Router() rec := do(t, h, "/ks/nosuchhost") if rec.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404 (kickstart must fail loudly)", rec.Code) } } func TestHealthAndReady(t *testing.T) { h := newTestServer(t, &fakeResolver{}, "local").Router() if rec := do(t, h, "/healthz"); rec.Code != http.StatusOK { t.Errorf("healthz = %d", rec.Code) } if rec := do(t, h, "/readyz"); rec.Code != http.StatusOK { t.Errorf("readyz = %d", rec.Code) } } func TestMetricsEndpoint(t *testing.T) { res := &fakeResolver{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}} srv := newTestServer(t, res, "local") h := srv.Router() do(t, h, "/ipxe/aa:bb:cc:00:11:22") // ok render + netbox ok do(t, h, "/ipxe/de:ad:be:ef:00:00") // notfound + fallback do(t, h, "/ks/aa:bb:cc:00:11:22") // kickstart render rec := do(t, h, "/metrics") if rec.Code != http.StatusOK { t.Fatalf("metrics status = %d", rec.Code) } body := rec.Body.String() for _, want := range []string{ `bootapi_render_total{kind="ipxe",result="ok"} 1`, `bootapi_render_total{kind="kickstart",result="ok"} 1`, `bootapi_netbox_lookups_total{field="mac",result="notfound"} 1`, "bootapi_http_requests_total", } { if !strings.Contains(body, want) { t.Errorf("metrics missing %q", want) } } } func TestLooksLikeMAC(t *testing.T) { yes := []string{"aa:bb:cc:00:11:22", "aa-bb-cc-00-11-22", "aabbcc001122", "aabb.cc00.1122"} no := []string{"web01", "web01.example.net", "aa:bb:cc", "zz:bb:cc:00:11:22"} for _, s := range yes { if !looksLikeMAC(s) { t.Errorf("looksLikeMAC(%q) = false, want true", s) } } for _, s := range no { if looksLikeMAC(s) { t.Errorf("looksLikeMAC(%q) = true, want false", s) } } }