package provider import ( "context" "testing" "github.com/hashicorp/terraform-plugin-framework/providerserver" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tfprotov6" "github.com/hashicorp/terraform-plugin-go/tftypes" "git.veillax.com/Veillax/hhctl/client" "git.veillax.com/Veillax/hhctl/internal/fakehh" ) func TestProviderSchemasAreValid(t *testing.T) { srv, err := providerserver.NewProtocol6WithError(New("test")())() if err != nil { t.Fatal(err) } resp, err := srv.GetProviderSchema(context.Background(), &tfprotov6.GetProviderSchemaRequest{}) if err != nil { t.Fatal(err) } for _, d := range resp.Diagnostics { t.Errorf("%s: %s", d.Summary, d.Detail) } for _, name := range []string{"hammerhead_site", "hammerhead_port", "hammerhead_domain", "hammerhead_edge", "hammerhead_alert_channel"} { if resp.ResourceSchemas[name] == nil { t.Errorf("missing resource %s", name) } } if resp.DataSourceSchemas["hammerhead_edge"] == nil { t.Error("missing data source") } } // harness drives a resource's CRUD methods the way Terraform does, without the terraform binary. type harness struct { t *testing.T res resource.Resource sch resource.SchemaResponse st tfsdk.State } func newHarness(t *testing.T, res resource.Resource, c *client.Client) *harness { h := &harness{t: t, res: res} res.Schema(context.Background(), resource.SchemaRequest{}, &h.sch) res.(resource.ResourceWithConfigure).Configure(context.Background(), resource.ConfigureRequest{ProviderData: c}, &resource.ConfigureResponse{}) h.st = tfsdk.State{Schema: h.sch.Schema, Raw: tftypes.NewValue(h.sch.Schema.Type().TerraformType(context.Background()), nil)} return h } func (h *harness) plan(model any) tfsdk.Plan { p := tfsdk.Plan{Schema: h.sch.Schema, Raw: tftypes.NewValue(h.sch.Schema.Type().TerraformType(context.Background()), nil)} if d := p.Set(context.Background(), model); d.HasError() { h.t.Fatalf("plan: %v", d) } return p } func (h *harness) create(model any) { h.t.Helper() resp := resource.CreateResponse{State: h.st} h.res.Create(context.Background(), resource.CreateRequest{Plan: h.plan(model)}, &resp) if resp.Diagnostics.HasError() { h.t.Fatalf("create: %v", resp.Diagnostics) } h.st = resp.State } func (h *harness) update(model any) { h.t.Helper() resp := resource.UpdateResponse{State: h.st} h.res.Update(context.Background(), resource.UpdateRequest{Plan: h.plan(model), State: h.st}, &resp) if resp.Diagnostics.HasError() { h.t.Fatalf("update: %v", resp.Diagnostics) } h.st = resp.State } func (h *harness) read(model any) (gone bool) { h.t.Helper() resp := resource.ReadResponse{State: h.st} h.res.Read(context.Background(), resource.ReadRequest{State: h.st}, &resp) if resp.Diagnostics.HasError() { h.t.Fatalf("read: %v", resp.Diagnostics) } if resp.State.Raw.IsNull() { return true } h.st = resp.State if d := h.st.Get(context.Background(), model); d.HasError() { h.t.Fatalf("get: %v", d) } return false } func (h *harness) delete() { h.t.Helper() resp := resource.DeleteResponse{} h.res.Delete(context.Background(), resource.DeleteRequest{State: h.st}, &resp) if resp.Diagnostics.HasError() { h.t.Fatalf("delete: %v", resp.Diagnostics) } } func fixture(t *testing.T) (*fakehh.Server, *client.Client) { fs := fakehh.New() t.Cleanup(fs.Close) c := client.New(fs.URL, fakehh.ServerKey, fakehh.OrgID) return fs, c } func TestSiteLifecycle(t *testing.T) { fs, c := fixture(t) h := newHarness(t, NewSite(), c) m := siteModel{Name: types.StringValue("home"), Tags: setOf([]string{"lab"}), ID: types.StringUnknown(), Token: types.StringUnknown()} h.create(m) var got siteModel h.read(&got) if got.ID.ValueString() == "" || got.Token.ValueString() == "" || got.Name.ValueString() != "home" || len(got.Tags.Elements()) != 1 { t.Fatalf("after create: %+v", got) } got.Name, got.Tags = types.StringValue("house"), setOf(nil) h.update(got) var again siteModel h.read(&again) if again.Name.ValueString() != "house" || len(again.Tags.Elements()) != 0 || again.Token != got.Token { t.Fatalf("after update: %+v", again) } h.delete() if len(fs.Sites) != 0 || !h.read(&again) { t.Fatal("site still exists") } } func basePort(edge, site string) portModel { return portModel{ ID: types.StringUnknown(), Name: types.StringValue("web"), Proto: types.StringValue("https"), EdgeID: types.StringValue(edge), BindIP: types.StringValue("0.0.0.0"), Port: types.Int64Value(443), PortEnd: types.Int64Value(0), Hostname: types.StringValue("a.example.com"), Enabled: types.BoolValue(true), Tags: setOf(nil), ProxyProtocol: types.StringValue(""), RedirectTo: types.StringValue(""), RedirectPermanent: types.BoolValue(false), RedirectKeepPath: types.BoolValue(false), RedirectHTTP: types.BoolValue(true), BackendHTTPS: types.BoolValue(false), RewriteHost: types.StringValue(""), HealthPath: types.StringValue("/up"), HealthSeconds: types.Int64Value(0), Targets: mustList(targetType, []targetModel{{types.StringValue(site), types.StringValue("10.0.0.2"), types.Int64Value(80)}}), Rules: mustList(ruleType, []ruleModel{{types.StringValue("deny"), types.StringValue("ip"), types.StringValue("10.0.0.0/8")}}), AuthSSO: types.BoolValue(false), AuthEmail: types.BoolValue(false), AuthRoles: setOf(nil), AuthUsers: setOf(nil), AuthMails: setOf(nil), AuthSessionHours: types.Int64Value(0), Password: types.StringNull(), Pin: types.StringNull(), } } func mustList(typ types.ObjectType, v any) types.List { l, d := types.ListValueFrom(context.Background(), typ, v) if d.HasError() { panic(d) } return l } func TestPortLifecycle(t *testing.T) { fs, c := fixture(t) h := newHarness(t, NewPort(), c) h.create(basePort("e1", "s1")) var got portModel h.read(&got) if got.ID.ValueString() == "" || got.Port.ValueInt64() != 443 || got.HealthPath.ValueString() != "/up" || !got.RedirectHTTP.ValueBool() { t.Fatalf("after create: %+v", got) } var ts []targetModel got.Targets.ElementsAs(context.Background(), &ts, false) if len(ts) != 1 || ts[0].Host.ValueString() != "10.0.0.2" || ts[0].SiteID.ValueString() != "s1" { t.Fatalf("targets: %+v", ts) } if _, has := fs.Mappings[got.ID.ValueString()]["newPassword"]; has { t.Fatal("no password was configured, none should be sent") } // setting a password sends it once, and flags auth.password so the server keeps it got.Password = types.StringValue("hunter22") h.update(got) stored := fs.Mappings[got.ID.ValueString()] if stored["newPassword"] != "hunter22" || stored["auth"].(map[string]any)["password"] != true { t.Fatalf("password not sent: %v", stored) } delete(stored, "newPassword") // an unchanged password is not re-sent (that would sign every visitor out) got.Enabled = types.BoolValue(false) h.update(got) if _, has := stored["newPassword"]; has { t.Fatal("unchanged password was re-sent") } if stored["enabled"] != false { t.Fatalf("update not applied: %v", stored) } // removing it from the configuration removes it on the server got.Password = types.StringNull() h.update(got) if v, has := stored["newPassword"]; !has || v != "" { t.Fatalf("password not cleared: %v", stored) } h.delete() if len(fs.Mappings) != 0 { t.Fatal("port still exists") } } func TestDomainEdgeAndChannelLifecycle(t *testing.T) { fs, c := fixture(t) e := newHarness(t, NewEdge(), c) e.create(edgeModel{ID: types.StringUnknown(), Name: types.StringValue("fra"), TunnelAddr: types.StringValue("1.2.3.4:51820"), OrgIDs: setOf(nil), Token: types.StringUnknown(), IPs: types.ListUnknown(types.StringType)}) var edge edgeModel e.read(&edge) if edge.Token.ValueString() == "" { t.Fatalf("edge token missing: %+v", edge) } d := newHarness(t, NewDomain(), c) d.create(domainModel{ID: types.StringUnknown(), Name: types.StringValue("example.com"), EdgeID: edge.ID, IP: types.StringValue(""), Note: types.StringValue("")}) var dom domainModel d.read(&dom) if dom.Name.ValueString() != "example.com" || dom.EdgeID != edge.ID { t.Fatalf("domain: %+v", dom) } ch := newHarness(t, NewAlertChannel(), c) ch.create(channelModel{ID: types.StringUnknown(), Name: types.StringValue("hook"), Kind: types.StringValue("webhook"), Target: types.StringValue("https://hooks.example.com/secret"), Format: types.StringValue("json"), Token: types.StringNull(), Policy: types.StringValue(""), AllowPrivate: types.BoolValue(false), Enabled: types.BoolValue(true), Server: types.BoolValue(false)}) var got channelModel ch.read(&got) if got.Target.ValueString() != "https://hooks.example.com/secret" { t.Fatalf("a webhook URL the server never returns must stay as configured, got %q", got.Target.ValueString()) } ch.delete() d.delete() e.delete() if len(fs.Channels)+len(fs.Domains)+len(fs.Edges) != 0 { t.Fatal("resources left behind") } if !ch.read(&got) { t.Fatal("deleted channel should read as gone") } }