diff --git a/internal/provider/resource_token.go b/internal/provider/resource_token.go index bc3c702..80f2ec3 100644 --- a/internal/provider/resource_token.go +++ b/internal/provider/resource_token.go @@ -327,8 +327,6 @@ func (r *BitbucketTokenResource) Create(ctx context.Context, req resource.Create resp.Diagnostics.Append(resp.State.Set(ctx, out)...) } -// Read — does not create new tokens (to keep Read side-effect free). -// If the tracked token is missing or expired, remove from state so the next Apply will recreate it. func (r *BitbucketTokenResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { var data BitbucketTokenResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &data)...) @@ -336,38 +334,62 @@ func (r *BitbucketTokenResource) Read(ctx context.Context, req resource.ReadRequ return } + // ---------------------------------------------------------- + // FIX #1: unknown values must be treated as drift + // ---------------------------------------------------------- + if data.CurrentTokenName.IsUnknown() || data.Token.IsUnknown() { + resp.State.RemoveResource(ctx) + return + } + + // If no ID or no token name → resource is incomplete → drift + if data.ID.IsUnknown() || data.ID.IsNull() || + data.CurrentTokenName.IsNull() { + resp.State.RemoveResource(ctx) + return + } + project := data.ProjectName.ValueString() repo := data.RepositoryName.ValueString() prefix := data.TokenName.ValueString() + // List tokens from Bitbucket tokens, err := r.listTokens(r.authHeader, r.serverURL, project, repo, prefix) if err != nil { resp.Diagnostics.AddError("Error listing tokens", err.Error()) return } + stateName := data.CurrentTokenName.ValueString() nowMs := time.Now().UnixMilli() thresholdMs := int64(30 * 24 * time.Hour / time.Millisecond) - stateName := data.CurrentTokenName.ValueString() - var valid bool + // Find token in server list + t := getTokenByName(tokens, stateName) - if stateName != "" { - if t := getTokenByName(tokens, stateName); t != nil { - timeLeft := t.ExpiryMs - nowMs - if timeLeft > thresholdMs { - data.CurrentTokenExpiry = types.Int64Value(t.ExpiryMs) - valid = true - } - } - } - - if !valid { - // Not present or expired -> remove from state; next Apply will create a new one in Create/Update paths. + // ---------------------------------------------------------- + // FIX #2: drift if token does not exist anymore + // ---------------------------------------------------------- + if t == nil { resp.State.RemoveResource(ctx) return } + // Time remaining before expiration + timeLeft := t.ExpiryMs - nowMs + + // ---------------------------------------------------------- + // FIX #3: expired or expiring soon → drift + // ---------------------------------------------------------- + if timeLeft <= thresholdMs { + resp.State.RemoveResource(ctx) + return + } + + // ---------------------------------------------------------- + // All good → update expiry in state + // ---------------------------------------------------------- + data.CurrentTokenExpiry = types.Int64Value(t.ExpiryMs) resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } diff --git a/mock_server/mock_server.go b/mock_server/mock_server.go index 0abaf12..a57344b 100644 --- a/mock_server/mock_server.go +++ b/mock_server/mock_server.go @@ -1,7 +1,10 @@ package mock_server import ( + "bytes" "encoding/json" + "fmt" + "io" "net" "net/http" "strings" @@ -34,6 +37,13 @@ func NewMockBitbucketServer() *MockBitbucketServer { // ONE handler, dispatching by HTTP method (GET / PUT / DELETE) // --------------------------------------------------------------------- mux.HandleFunc("/rest/access-tokens/latest/projects/", func(w http.ResponseWriter, r *http.Request) { + // Debug log incoming requests to help troubleshoot test failures. + fmt.Printf("[mock] %s %s\n", r.Method, r.URL.Path) + if r.Method == http.MethodPut { + body, _ := io.ReadAll(r.Body) + fmt.Printf("[mock] body: %s\n", string(body)) + r.Body = io.NopCloser(bytes.NewReader(body)) + } parts := strings.Split(r.URL.Path, "/") // Expected for LIST and CREATE: // ["","rest","access-tokens","latest","projects",p,"repos",r] @@ -46,8 +56,9 @@ func NewMockBitbucketServer() *MockBitbucketServer { return } - project := parts[4] - repo := parts[6] + // parts layout: ["", "rest", "access-tokens", "latest", "projects", , "repos", , ...] + project := parts[5] + repo := parts[7] key := project + "/" + repo switch r.Method { @@ -150,3 +161,22 @@ func (m *MockBitbucketServer) ClearTokensFor(key string) { m.Tokens[key] = nil m.Mu.Unlock() } + +func (m *MockBitbucketServer) SetExpiredToken(key string) { + m.Mu.Lock() + defer m.Mu.Unlock() + + if len(m.Tokens[key]) == 0 { + // create one if none exists + m.Tokens[key] = []Token{{ + Name: "expired-token", + Token: "secret", + ExpiryDate: time.Now().Add(-1 * time.Hour).UnixMilli(), + Permissions: []string{"REPO_READ"}, + }} + return + } + + // expire the first existing token + m.Tokens[key][0].ExpiryDate = time.Now().Add(-1 * time.Hour).UnixMilli() +} diff --git a/test/resource_token_test.go b/test/resource_token_test.go index 8925a74..fade97e 100644 --- a/test/resource_token_test.go +++ b/test/resource_token_test.go @@ -3,6 +3,7 @@ package test import ( "fmt" "testing" + "time" "github.com/hashicorp/terraform-plugin-framework/providerserver" "github.com/hashicorp/terraform-plugin-go/tfprotov6" @@ -14,48 +15,105 @@ import ( mock "terraform-provider-bitbucket-token/mock_server" ) -// Acceptance test provider factories for protocol v6 var testAccProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){ "bitbucket": providerserver.NewProtocol6WithError(provider.NewProvider()), } -func TestAccBitbucketToken_CRUD(t *testing.T) { - server := mock.NewMockBitbucketServer() - - if err := server.Start(); err != nil { - t.Fatalf("server start error: %v", err) - } - +func TestAccBitbucketToken_AllScenarios(t *testing.T) { + // Split into focused tests to validate specific behaviors. resourceName := "bitbucket_token.test" - resource.Test(t, resource.TestCase{ - PreCheck: func() { testAccPreCheck(t) }, - ProtoV6ProviderFactories: testAccProviderFactories, - CheckDestroy: testAccCheckBitbucketTokenDestroy(server), - Steps: []resource.TestStep{ - { - // CREATE - Config: testAccBitbucketTokenConfig(server.URL), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(resourceName, "id"), - resource.TestCheckResourceAttrSet(resourceName, "current_token_name"), - resource.TestCheckResourceAttrSet(resourceName, "current_token_expiry"), - resource.TestCheckResourceAttrSet(resourceName, "token"), - ), + // Helper to start a fresh server for each test. + startServer := func(t *testing.T) *mock.MockBitbucketServer { + srv := mock.NewMockBitbucketServer() + if err := srv.Start(); err != nil { + t.Fatalf("server start error: %v", err) + } + return srv + } + + t.Run("CreateWhenNone", func(t *testing.T) { + server := startServer(t) + defer func() { _ = server }() + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccBitbucketTokenConfig(server.URL), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttrSet(resourceName, "token"), + resource.TestCheckResourceAttrSet(resourceName, "current_token_name"), + resource.TestCheckResourceAttrSet(resourceName, "current_token_expiry"), + testAccCheckServerHasTokens(server, "proj/repo", 1), + ), + }, }, - { - // READ (no drift) – apply the same config again; plan should stay empty - Config: testAccBitbucketTokenConfig(server.URL), + }) + }) + + t.Run("ReuseStateWhenSecondaryExists", func(t *testing.T) { + server := startServer(t) + defer func() { _ = server }() + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProviderFactories, + Steps: []resource.TestStep{ + // 1) create initial token and capture state + { + Config: testAccBitbucketTokenConfig(server.URL), + }, + // 2) Add a secondary token on the server, expect no changes (reuse) + { + PreConfig: func() { + server.Mu.Lock() + server.Tokens["proj/repo"] = append(server.Tokens["proj/repo"], mock.Token{ + Name: "prefix-secondary", + Token: "secret-secondary", + ExpiryDate: time.Now().Add(24 * time.Hour).UnixMilli(), + }) + server.Mu.Unlock() + }, + Config: testAccBitbucketTokenConfig(server.URL), + PlanOnly: true, + }, }, - }, + }) + }) + + t.Run("RecreateWhenExpired", func(t *testing.T) { + server := startServer(t) + defer func() { _ = server }() + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProviderFactories, + CheckDestroy: testAccCheckBitbucketTokenDestroy(server), + Steps: []resource.TestStep{ + // 1) create initial token + { + Config: testAccBitbucketTokenConfig(server.URL), + }, + // 2) expire token on server and refresh state; expect a non-empty plan + { + PreConfig: func() { + server.SetExpiredToken("proj/repo") + }, + RefreshState: true, + ExpectNonEmptyPlan: true, + }, + }, + }) }) } // -// -------- Helper Functions -------- +// ---------- Helper Configs ---------- // -// Basic test configuration for the provider + resource func testAccBitbucketTokenConfig(url string) string { return fmt.Sprintf(` provider "bitbucket" { @@ -72,25 +130,57 @@ resource "bitbucket_token" "test" { `, url) } -// Ensures Terraform acceptance test environment is correct +func testAccBitbucketTokenConfigPrefix(url, prefix string) string { + return fmt.Sprintf(` +provider "bitbucket" { + server_url = "%s" + auth_header = "dummy" + tls_skip_verify = true +} + +resource "bitbucket_token" "test" { + project_name = "proj" + repository_name = "repo" + token_name = "%s" +} +`, url, prefix) +} + +// +// ---------- Environment ---------- +// + func testAccPreCheck(t *testing.T) { if testing.Short() { t.Skip("skipping acceptance tests in short mode") } } -// Verifies that all tokens were removed by Delete() func testAccCheckBitbucketTokenDestroy(server *mock.MockBitbucketServer) resource.TestCheckFunc { return func(s *terraform.State) error { server.Mu.Lock() defer server.Mu.Unlock() - // server.Tokens should be empty after resource.Delete() - for _, tokens := range server.Tokens { - if len(tokens) != 0 { - return fmt.Errorf("expected no tokens, but found: %#v", server.Tokens) + for _, tok := range server.Tokens { + if len(tok) != 0 { + return fmt.Errorf("tokens still exist: %#v", server.Tokens) } } return nil } } + +// testAccCheckServerHasTokens asserts that the mock server contains exactly +// `expected` tokens for the given repo key (e.g. "proj/repo"). +func testAccCheckServerHasTokens(server *mock.MockBitbucketServer, key string, expected int) resource.TestCheckFunc { + return func(s *terraform.State) error { + server.Mu.Lock() + defer server.Mu.Unlock() + + toks := server.Tokens[key] + if len(toks) != expected { + return fmt.Errorf("expected %d tokens for %s, got %d: %#v", expected, key, len(toks), toks) + } + return nil + } +}