package store import ( "errors" "reflect" "strconv" "sync" "testing" "helix-proxy/internal/config" ) // --- test helpers --- func newTestBbolt(t *testing.T) Store { t.Helper() tmp := t.TempDir() config.ResetForTest() t.Setenv("DATA_DIR", tmp) s, err := NewBboltStore() if err != nil { t.Fatalf("NewBboltStore: %v", err) } return s } // closeIfBbolt helps cleanup for bbolt (interface doesn't have Close but impl does) func closeIfBbolt(s Store) { if c, ok := s.(interface{ Close() error }); ok { c.Close() } } func TestNew_SeedsAdminAndSettings(t *testing.T) { s := newTestBbolt(t) defer closeIfBbolt(s) if !s.IsSetup() { t.Error("IsSetup false after new") } users := s.GetUsers() if len(users) != 1 || users[0].Email != "admin@example.com" || users[0].ID != 1 { t.Errorf("seeded admin wrong: %+v", users) } settings := s.GetSettings() if settings["default_site"] != "congratulations" { t.Errorf("default settings wrong: %v", settings) } } func TestIDGeneration(t *testing.T) { s := newTestBbolt(t) defer closeIfBbolt(s) h1, _ := s.CreateProxyHost(ProxyHost{DomainNames: []string{"a.com"}}) h2, _ := s.CreateProxyHost(ProxyHost{DomainNames: []string{"b.com"}}) if h1.ID == 0 || h2.ID == 0 || h2.ID <= h1.ID { t.Errorf("ids not increasing: %d %d", h1.ID, h2.ID) } if h1.CreatedOn == "" { t.Error("CreateProxyHost did not populate CreatedOn") } al1, _ := s.CreateAccessList(AccessList{Name: "t1"}) if al1.CreatedOn == "" { t.Error("CreateAccessList did not populate CreatedOn") } st1, _ := s.CreateStream(Stream{IncomingPort: 1}) if st1.CreatedOn == "" { t.Error("CreateStream did not populate CreatedOn") } // explicit id should be honored? (current impls assign only if 0) h3, _ := s.CreateProxyHost(ProxyHost{ID: 99, DomainNames: []string{"c.com"}}) if h3.ID != 99 { t.Errorf("explicit id not kept: %d", h3.ID) } } func TestProxyHostCRUD(t *testing.T) { s := newTestBbolt(t) defer closeIfBbolt(s) // create h := ProxyHost{ DomainNames: []string{"example.com", "www.example.com"}, ForwardScheme: "https", ForwardHost: "10.0.0.1", ForwardPort: 8443, Enabled: true, Meta: map[string]any{"foo": "bar"}, } created, err := s.CreateProxyHost(h) if err != nil { t.Fatal(err) } if created.ID == 0 { t.Fatal("no id assigned") } if !reflect.DeepEqual(created.DomainNames, h.DomainNames) || created.Meta["foo"] != "bar" { t.Error("roundtrip fidelity lost on create") } // get list / get list := s.GetProxyHosts() if len(list) != 1 { t.Errorf("list len %d", len(list)) } got, ok := s.GetProxyHost(created.ID) if !ok || got.ID != created.ID { t.Error("get by id failed") } // update created.ForwardHost = "10.0.0.2" created.Meta["new"] = 42 up, err := s.UpdateProxyHost(created) if err != nil { t.Fatal(err) } if up.ForwardHost != "10.0.0.2" || up.Meta["new"] != 42 { t.Error("update not persisted") } if up.CreatedOn == "" { t.Error("UpdateProxyHost did not preserve CreatedOn") } // set enabled if err := s.SetProxyHostEnabled(created.ID, false); err != nil { t.Fatal(err) } g2, _ := s.GetProxyHost(created.ID) if g2.Enabled { t.Error("set enabled false failed") } // delete if err := s.DeleteProxyHost(created.ID); err != nil { t.Fatal(err) } if _, ok := s.GetProxyHost(created.ID); ok { t.Error("delete did not remove") } } func TestProxyHostDuplicateSources(t *testing.T) { s := newTestBbolt(t) defer closeIfBbolt(s) h1, err := s.CreateProxyHost(ProxyHost{DomainNames: []string{"a.example.com"}}) if err != nil { t.Fatal(err) } _, err = s.CreateProxyHost(ProxyHost{DomainNames: []string{"a.example.com"}}) if !errors.Is(err, ErrDuplicateProxySource) { t.Fatalf("expected duplicate error on cross-host conflict, got %v", err) } _, err = s.CreateProxyHost(ProxyHost{DomainNames: []string{"A.EXAMPLE.COM"}}) if !errors.Is(err, ErrDuplicateProxySource) { t.Fatalf("expected duplicate error on case-insensitive conflict, got %v", err) } _, err = s.CreateProxyHost(ProxyHost{DomainNames: []string{"b.example.com", "b.example.com"}}) if !errors.Is(err, ErrDuplicateProxySource) { t.Fatalf("expected duplicate error within same host, got %v", err) } h1.DomainNames = []string{"a.example.com", "c.example.com"} if _, err = s.UpdateProxyHost(h1); err != nil { t.Fatalf("update own domains: %v", err) } h2, err := s.CreateProxyHost(ProxyHost{DomainNames: []string{"d.example.com"}}) if err != nil { t.Fatal(err) } h2.DomainNames = []string{"c.example.com"} _, err = s.UpdateProxyHost(h2) if !errors.Is(err, ErrDuplicateProxySource) { t.Fatalf("expected duplicate error on update conflict, got %v", err) } } func TestRedirectionHostDuplicateSources(t *testing.T) { s := newTestBbolt(t) defer closeIfBbolt(s) _, err := s.CreateRedirectionHost(RedirectionHost{DomainNames: []string{"redir.example.com"}}) if err != nil { t.Fatal(err) } _, err = s.CreateRedirectionHost(RedirectionHost{DomainNames: []string{"redir.example.com"}}) if !errors.Is(err, ErrDuplicateRedirectionSource) { t.Fatalf("expected duplicate error, got %v", err) } } func TestDeadHostDuplicateSources(t *testing.T) { s := newTestBbolt(t) defer closeIfBbolt(s) _, err := s.CreateDeadHost(DeadHost{DomainNames: []string{"dead.example.com"}}) if err != nil { t.Fatal(err) } _, err = s.CreateDeadHost(DeadHost{DomainNames: []string{"DEAD.example.com"}}) if !errors.Is(err, ErrDuplicateDeadSource) { t.Fatalf("expected duplicate error, got %v", err) } } func TestStreamDuplicateIncomingPort(t *testing.T) { s := newTestBbolt(t) defer closeIfBbolt(s) _, err := s.CreateStream(Stream{IncomingPort: 9001, ForwardingHost: "127.0.0.1", ForwardingPort: 80}) if err != nil { t.Fatal(err) } _, err = s.CreateStream(Stream{IncomingPort: 9001, ForwardingHost: "127.0.0.1", ForwardingPort: 8080}) if !errors.Is(err, ErrDuplicateStreamPort) { t.Fatalf("expected duplicate port error, got %v", err) } } func TestStreamUpdateDuplicateIncomingPort(t *testing.T) { s := newTestBbolt(t) defer closeIfBbolt(s) a, err := s.CreateStream(Stream{IncomingPort: 9001, ForwardingHost: "127.0.0.1", ForwardingPort: 80}) if err != nil { t.Fatal(err) } b, err := s.CreateStream(Stream{IncomingPort: 9002, ForwardingHost: "127.0.0.1", ForwardingPort: 80}) if err != nil { t.Fatal(err) } b.IncomingPort = 9001 _, err = s.UpdateStream(b) if !errors.Is(err, ErrDuplicateStreamPort) { t.Fatalf("expected duplicate port error on update, got %v", err) } _ = a } func TestAccessListCRUD(t *testing.T) { s := newTestBbolt(t) defer closeIfBbolt(s) al := AccessList{ Name: "test al", SatisfyAny: true, Clients: []AccessClient{{Address: "1.2.3.4", Directive: "allow"}}, Items: []AccessItem{{Username: "u", Password: "p"}}, PassAuth: true, } created, err := s.CreateAccessList(al) if err != nil || created.ID == 0 { t.Fatalf("create: %v", err) } if len(created.Clients) != 1 || len(created.Items) != 1 { t.Error("clients/items nilled?") } // update created.Name = "renamed" up, err := s.UpdateAccessList(created) if err != nil || up.Name != "renamed" { t.Fatalf("update: %v", err) } if up.CreatedOn == "" { t.Error("UpdateAccessList did not preserve CreatedOn") } if err := s.DeleteAccessList(created.ID); err != nil { t.Fatal(err) } if _, ok := s.GetAccessList(created.ID); ok { t.Error("not deleted") } } func TestStreamCRUD(t *testing.T) { s := newTestBbolt(t) defer closeIfBbolt(s) st := Stream{ IncomingPort: 1234, ForwardingHost: "127.0.0.1", ForwardingPort: 5678, TCPForwarding: true, Enabled: true, Meta: map[string]any{"k": 1}, } c, err := s.CreateStream(st) if err != nil { t.Fatal(err) } if c.ID == 0 || c.Meta == nil { t.Error("id or meta") } c.Enabled = false up, _ := s.UpdateStream(c) if up.CreatedOn == "" { t.Error("UpdateStream did not preserve CreatedOn") } if err := s.SetStreamEnabled(c.ID, true); err != nil { t.Fatal(err) } g, _ := s.GetStream(c.ID) if !g.Enabled { t.Error("set enabled") } _ = s.DeleteStream(c.ID) } func TestCertificateCRUD(t *testing.T) { s := newTestBbolt(t) defer closeIfBbolt(s) c := Certificate{ Provider: "letsencrypt", DomainNames: []string{"ex.com"}, Meta: map[string]any{"cert": "pem"}, } created, err := s.CreateCertificate(c) if err != nil || len(created.DomainNames) == 0 || created.Meta == nil { t.Fatal(err) } created.ExpiresOn = "2099-01-01" origCreated := created.CreatedOn if err := s.SetCertificate(created); err != nil { t.Fatal(err) } g, _ := s.GetCertificate(created.ID) if g.ExpiresOn != "2099-01-01" { t.Error("set cert") } if g.CreatedOn != origCreated { t.Error("SetCertificate did not preserve CreatedOn") } _ = s.DeleteCertificate(created.ID) } func TestRedirectionDeadHostCRUD(t *testing.T) { s := newTestBbolt(t) defer closeIfBbolt(s) rh := RedirectionHost{DomainNames: []string{"r.com"}, ForwardDomainName: "t.com", Enabled: true, Meta: map[string]any{}} crh, _ := s.CreateRedirectionHost(rh) crh.ForwardHTTPCode = 301 rup, _ := s.UpdateRedirectionHost(crh) if rup.CreatedOn == "" { t.Error("UpdateRedirectionHost did not preserve CreatedOn") } _ = s.SetRedirectionHostEnabled(crh.ID, false) _ = s.DeleteRedirectionHost(crh.ID) dh := DeadHost{DomainNames: []string{"d.com"}, Enabled: true, Meta: map[string]any{}} cdh, _ := s.CreateDeadHost(dh) dup, _ := s.UpdateDeadHost(cdh) if dup.CreatedOn == "" { t.Error("UpdateDeadHost did not preserve CreatedOn") } _ = s.SetDeadHostEnabled(cdh.ID, false) _ = s.DeleteDeadHost(cdh.ID) } func TestSettingsAudit(t *testing.T) { s := newTestBbolt(t) defer closeIfBbolt(s) if err := s.SetSetting("foo", "bar"); err != nil { t.Fatal(err) } m := s.GetSettings() if m["foo"] != "bar" { t.Errorf("set/get setting: %v", m) } log := AuditLog{UserID: 1, ObjectType: "test", ObjectID: 99, Action: "create", Meta: map[string]any{"x": 1}} added, err := s.AddAuditLog(log) if err != nil || added.ID == 0 { t.Fatalf("add audit: %v", err) } if added.CreatedOn == "" { t.Error("AddAuditLog did not populate CreatedOn") } logs := s.GetAuditLogs() if len(logs) < 1 || logs[len(logs)-1].CreatedOn == "" { t.Error("no audit logs or missing CreatedOn") } } func TestUsersCRUD(t *testing.T) { s := newTestBbolt(t) defer closeIfBbolt(s) u := User{Email: "new@example.com", Name: "New", Roles: []string{"user"}, Password: "pw"} created, err := s.CreateUser(u) if err != nil || created.ID == 0 { t.Fatal(err) } created.Name = "Renamed" if err := s.UpdateUser(created); err != nil { t.Fatal(err) } if err := s.UpdateUserFull(created); err != nil { t.Fatal(err) } by, ok := s.GetUserByEmail("new@example.com") if !ok || by.Name != "Renamed" { t.Error("get by email or update") } if err := s.DeleteUser(created.ID); err != nil { t.Fatal(err) } } func TestGetUsersAndIsSetup(t *testing.T) { s := newTestBbolt(t) defer closeIfBbolt(s) users := s.GetUsers() if len(users) == 0 { t.Error("no users") } if !s.IsSetup() { t.Error("not setup") } } func TestFidelityMetaSlices(t *testing.T) { s := newTestBbolt(t) defer closeIfBbolt(s) ph := ProxyHost{DomainNames: nil, Meta: nil} c, _ := s.CreateProxyHost(ph) // impls normalize to non-nil if c.DomainNames == nil || c.Meta == nil { t.Error("should have non-nil slices/maps after create") } g, _ := s.GetProxyHost(c.ID) if g.DomainNames == nil { t.Error("get returned nil domains") } // audit fidelity (CreatedOn + Meta) alog := AuditLog{UserID: 1, ObjectType: "t", ObjectID: 1, Action: "a", Meta: map[string]any{"m": 2}} added, _ := s.AddAuditLog(alog) if added.CreatedOn == "" || added.Meta["m"] != 2 { t.Error("audit CreatedOn/Meta fidelity") } gots := s.GetAuditLogs() if len(gots) == 0 || gots[len(gots)-1].Meta == nil { t.Error("get audit meta nil") } // access list nested slices fidelity al := AccessList{Name: "al", Clients: []AccessClient{{Address: "1.2.3.4", Directive: "allow"}}, Items: []AccessItem{{Username: "u", Password: "p"}}} ca, _ := s.CreateAccessList(al) if len(ca.Clients) == 0 || len(ca.Items) == 0 { t.Error("access nested create") } ga, _ := s.GetAccessList(ca.ID) if len(ga.Items) == 0 || len(ga.Clients) == 0 { t.Error("access nested get") } } func TestConcurrency(t *testing.T) { s := newTestBbolt(t) defer closeIfBbolt(s) var wg sync.WaitGroup n := 20 var mu sync.Mutex ids := []int{} errs := []error{} for i := 0; i < n; i++ { wg.Add(1) go func(i int) { defer wg.Done() h, err := s.CreateProxyHost(ProxyHost{DomainNames: []string{"c" + strconv.Itoa(i) + ".com"}}) _ = s.SetSetting("k", i) _, _ = s.AddAuditLog(AuditLog{Action: "cc"}) mu.Lock() ids = append(ids, h.ID) if err != nil { errs = append(errs, err) } mu.Unlock() }(i) } wg.Wait() if len(errs) > 0 { t.Errorf("conc errs: %v", errs) } if len(ids) != n { t.Errorf("expected %d creates, got %d", n, len(ids)) } seen := map[int]bool{} for _, id := range ids { if seen[id] { t.Errorf("dup ID %d in conc creates", id) } seen[id] = true } // final fidelity post-conc final := s.GetProxyHosts() if len(final) != n { t.Errorf("post-conc hosts count %d != %d", len(final), n) } }