package hammerhead import ( "bytes" "encoding/json" "fmt" "net/http" "net/http/cookiejar" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "git.veillax.com/Veillax/hammerhead/internal/secretbox" "git.veillax.com/Veillax/reef/model" ) func apiClient(t *testing.T, s *Server) (do func(method, path string, in any) (int, []byte)) { ts := httptest.NewServer(s.Handler()) t.Cleanup(ts.Close) jar, _ := cookiejar.New(nil) c := &http.Client{Jar: jar} do = func(method, path string, in any) (int, []byte) { var body bytes.Buffer if in != nil { json.NewEncoder(&body).Encode(in) } req, _ := http.NewRequest(method, ts.URL+path, &body) req.Header.Set("Content-Type", "application/json") resp, err := c.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() var out bytes.Buffer out.ReadFrom(resp.Body) return resp.StatusCode, out.Bytes() } if code, b := do("POST", "/api/login", map[string]string{"email": "admin@test.local", "password": "password123"}); code != 200 { t.Fatalf("login: %d %s", code, b) } return do } func fileHas(t *testing.T, dir, needle string) bool { found := false filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { if err == nil && !info.IsDir() && strings.HasPrefix(filepath.Base(p), "hammerhead.db") { if b, _ := os.ReadFile(p); bytes.Contains(b, []byte(needle)) { found = true } } return nil }) return found } func TestPersistsAcrossRestartsAndEncryptsSecrets(t *testing.T) { dir := t.TempDir() key := secretbox.GenerateKey() opts := Options{AdminEmail: "admin@test.local", AdminPassword: "password123", MasterKey: key} s, err := New(dir, opts) if err != nil { t.Fatal(err) } do := apiClient(t, s) code, b := do("POST", "/api/edges", map[string]any{"name": "edge-one", "tunnelAddr": "203.0.113.5:51820"}) if code != 200 { t.Fatalf("edge: %d %s", code, b) } var edge struct{ ID, Token string } json.Unmarshal(b, &edge) if code, b := do("PUT", "/api/admin/settings", map[string]any{"dashboardUrl": "http://x.test", "smtp": map[string]any{"host": "smtp.test", "pass": "smtp-secret-pw-31337", "mode": "none"}}); code != 200 { t.Fatalf("settings: %d %s", code, b) } s.Close() // nothing sensitive is readable in the database file for _, secret := range []string{edge.Token, "smtp-secret-pw-31337"} { if secret == "" || fileHas(t, dir, secret) { t.Errorf("secret %q is readable in the database file", secret) } } // reopening with the same key restores everything, secrets included s2, err := New(dir, opts) if err != nil { t.Fatal(err) } do = apiClient(t, s2) _, b = do("GET", "/api/edges", nil) var edges []struct{ ID, Name, Token string } json.Unmarshal(b, &edges) if len(edges) != 1 || edges[0].Name != "edge-one" || edges[0].Token != edge.Token { t.Errorf("edge after restart: %+v (want token %s)", edges, edge.Token) } _, b = do("GET", "/api/admin/settings", nil) if !strings.Contains(string(b), `"smtpPassSet":true`) { t.Errorf("smtp password lost after restart: %s", b) } s2.mu.Lock() pw := s2.st.Settings.SMTP.Pass s2.mu.Unlock() if pw != "smtp-secret-pw-31337" { t.Errorf("decrypted smtp password = %q", pw) } s2.Close() // the wrong key is a loud failure, not silent garbage if _, err := New(dir, Options{MasterKey: secretbox.GenerateKey()}); err == nil || !strings.Contains(err.Error(), "decrypt") { t.Errorf("opening with the wrong key: %v", err) } } func TestGeneratedKeyFileKeepsWorking(t *testing.T) { dir := t.TempDir() s, err := New(dir, Options{AdminEmail: "admin@test.local", AdminPassword: "password123"}) if err != nil { t.Fatal(err) } apiClient(t, s)("POST", "/api/edges", map[string]any{"name": "e", "tunnelAddr": "203.0.113.5:51820"}) s.Close() if _, err := os.Stat(filepath.Join(dir, "master.key")); err != nil { t.Fatalf("no key file was generated: %v", err) } s2, err := New(dir, Options{}) if err != nil { t.Fatalf("reopen with the generated key: %v", err) } s2.Close() } func TestMigratesALegacyStateFile(t *testing.T) { dir := t.TempDir() legacy := map[string]any{ "version": 7, "users": []any{map[string]any{"id": "u1", "email": "old@test.local", "name": "Old", "serverAdmin": true, "created": time.Now()}}, "orgs": []any{map[string]any{"id": "o1", "name": "Legacy org", "allowLocal": true}}, "edges": []any{map[string]any{"id": "e1", "name": "old-edge", "token": "legacy-plain-token-abc", "authKey": "k", "tunnelAddr": "1.2.3.4:51820", "tunnelIp": "100.89.0.1", "ips": []string{}}}, } b, _ := json.Marshal(legacy) os.WriteFile(filepath.Join(dir, "state.json"), b, 0o600) os.MkdirAll(filepath.Join(dir, "logs"), 0o700) os.WriteFile(filepath.Join(dir, "logs", "access.jsonl"), []byte(`{"time":"`+time.Now().UTC().Format(time.RFC3339)+`","mapping":"m1","orgId":"o1","remote":"9.9.9.9","method":"GET","host":"h","path":"/x","status":200}`+"\n"), 0o600) s, err := New(dir, Options{MasterKey: secretbox.GenerateKey()}) if err != nil { t.Fatal(err) } defer s.Close() s.mu.Lock() ok := len(s.st.Users) == 1 && s.st.Users[0].Email == "old@test.local" && len(s.st.Orgs) == 1 && len(s.st.Edges) == 1 && s.st.Edges[0].Token == "legacy-plain-token-abc" && s.st.Version >= 7 s.mu.Unlock() if !ok { t.Fatal("legacy state was not imported") } if _, err := os.Stat(filepath.Join(dir, "state.json.migrated")); err != nil { t.Error("the old file should be kept aside as state.json.migrated") } if got := s.logs.queryAccess(accessFilter{Org: "o1"}); len(got) != 1 || got[0].Remote != "9.9.9.9" { t.Errorf("legacy access log not imported: %+v", got) } s.Close() if fileHas(t, dir, "legacy-plain-token-abc") { t.Error("the imported plaintext token was stored unencrypted") } } func TestLogSearchPaginationAndRetention(t *testing.T) { s, err := New(t.TempDir(), Options{MasterKey: secretbox.GenerateKey()}) if err != nil { t.Fatal(err) } defer s.Close() var batch []model.LogEntry for i := 0; i < 250; i++ { status := 200 if i%10 == 0 { status = 404 } batch = append(batch, model.LogEntry{Time: time.Now(), OrgID: "o1", Mapping: fmt.Sprintf("m%d", i%2), Remote: "1.1.1.1", Method: "GET", Host: "site.test", Path: fmt.Sprintf("/page/%d", i), Status: status, Result: "allowed", User: "bob"}) } batch = append(batch, model.LogEntry{Time: time.Now(), OrgID: "o1", Mapping: "m0", Path: "/100%_sure", Status: 200, Result: "denied", Host: "site.test"}, model.LogEntry{Time: time.Now(), OrgID: "o2", Mapping: "m9", Path: "/other-org", Status: 200}) s.logs.addAccess(batch) page1 := s.logs.queryAccess(accessFilter{Org: "o1", Limit: 100}) if len(page1) != 100 { t.Fatalf("page 1 has %d entries", len(page1)) } page2 := s.logs.queryAccess(accessFilter{Org: "o1", Limit: 100, Before: page1[len(page1)-1].ID}) if len(page2) != 100 || page2[0].ID >= page1[len(page1)-1].ID { t.Errorf("pagination is wrong: page2=%d", len(page2)) } if n := len(s.logs.queryAccess(accessFilter{Org: "o1", Limit: 1000, StatusClass: 4})); n != 25 { t.Errorf("status class 4xx matched %d, want 25", n) } if n := len(s.logs.queryAccess(accessFilter{Org: "o1", Limit: 1000, Mapping: "m1"})); n != 125 { t.Errorf("mapping filter matched %d, want 125", n) } if got := s.logs.queryAccess(accessFilter{Org: "o1", Q: "100%_sure"}); len(got) != 1 { t.Errorf("a search containing LIKE wildcards must match literally, got %d", len(got)) } if got := s.logs.queryAccess(accessFilter{Org: "o1", Result: "denied"}); len(got) != 1 { t.Errorf("result filter: %d", len(got)) } for _, e := range s.logs.queryAccess(accessFilter{Org: "o1", Limit: 1000}) { if e.OrgID != "o1" || strings.Contains(e.Path, "other-org") { t.Fatal("another organization's log entry leaked") } } // retention s.db.sql.Exec(`UPDATE access_log SET ts = ? WHERE path = '/page/3'`, time.Now().AddDate(0, 0, -60).UnixMilli()) s.db.purge(30, 365) if got := s.logs.queryAccess(accessFilter{Org: "o1", Q: "/page/3", Limit: 1000}); len(got) != 0 && got[0].Path == "/page/3" { t.Error("old entries should have been purged") } // traffic history s.db.addTraffic("m0", model.Stat{Conns: 2, BytesIn: 100, BytesOut: 900}) s.db.addTraffic("m0", model.Stat{Conns: 1, BytesIn: 50, BytesOut: 50}) h := s.db.trafficHistory([]string{"m0", "m1"}, 24) if len(h["m0"]) != 24 || h["m0"][23] != 1100 || h["m1"][23] != 0 { t.Errorf("traffic history: %v", h["m0"]) } }