// Copyright (c) The OpenTofu Authors // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2023 HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 package http import ( "bytes" "fmt" "io" "net/http" "net/http/httptest" "net/url" "reflect" "testing" "time" "github.com/hashicorp/go-retryablehttp" "github.com/opentofu/opentofu/internal/states/remote" "github.com/opentofu/opentofu/internal/states/statemgr" ) func TestHTTPClient_impl(t *testing.T) { var _ remote.Client = new(httpClient) var _ remote.ClientLocker = new(httpClient) } func TestHTTPClient(t *testing.T) { handler := new(testHTTPHandler) ts := httptest.NewServer(http.HandlerFunc(handler.Handle)) defer ts.Close() url, err := url.Parse(ts.URL) if err != nil { t.Fatalf("Parse: %s", err) } // Test basic get/update client := &httpClient{URL: url, Client: retryablehttp.NewClient()} remote.TestClient(t, client) // test just a single PUT p := &httpClient{ URL: url, UpdateMethod: "PUT", Client: retryablehttp.NewClient(), } remote.TestClient(t, p) // Test headers c := retryablehttp.NewClient() c.RequestLogHook = func(_ retryablehttp.Logger, req *http.Request, _ int) { // Test user defined header is part of the request v := req.Header.Get("user-defined") if v != "test" { t.Fatalf("Expected header \"user-defined\" with value \"test\", got \"%s\"", v) } // Test the content-type header was not overridden v = req.Header.Get("content-type") if req.Method == "PUT" && v != "application/json" { t.Fatalf("Expected header \"content-type\" with value \"application/json\", got \"%s\"", v) } } p = &httpClient{ URL: url, UpdateMethod: "PUT", Headers: map[string]string{ "user-defined": "test", "content-type": "application/xml", }, Client: c, } remote.TestClient(t, p) // Test locking and alternative UpdateMethod a := &httpClient{ URL: url, UpdateMethod: "PUT", LockURL: url, LockMethod: "LOCK", UnlockURL: url, UnlockMethod: "UNLOCK", Client: retryablehttp.NewClient(), } b := &httpClient{ URL: url, UpdateMethod: "PUT", LockURL: url, LockMethod: "LOCK", UnlockURL: url, UnlockMethod: "UNLOCK", Client: retryablehttp.NewClient(), } remote.TestRemoteLocks(t, a, b) // test a WebDAV-ish backend davhandler := new(testHTTPHandler) ts = httptest.NewServer(http.HandlerFunc(davhandler.HandleWebDAV)) defer ts.Close() url, err = url.Parse(ts.URL) client = &httpClient{ URL: url, UpdateMethod: "PUT", Client: retryablehttp.NewClient(), } if err != nil { t.Fatalf("Parse: %s", err) } remote.TestClient(t, client) // first time through: 201 remote.TestClient(t, client) // second time, with identical data: 204 // test a broken backend brokenHandler := new(testBrokenHTTPHandler) brokenHandler.handler = new(testHTTPHandler) ts = httptest.NewServer(http.HandlerFunc(brokenHandler.Handle)) defer ts.Close() url, err = url.Parse(ts.URL) if err != nil { t.Fatalf("Parse: %s", err) } client = &httpClient{URL: url, Client: retryablehttp.NewClient()} remote.TestClient(t, client) } type testHTTPHandler struct { Data []byte Locked bool } func (h *testHTTPHandler) Handle(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": if _, err := w.Write(h.Data); err != nil { w.WriteHeader(500) return } w.WriteHeader(200) case "PUT": buf := new(bytes.Buffer) if _, err := io.Copy(buf, r.Body); err != nil { w.WriteHeader(500) return } w.WriteHeader(201) h.Data = buf.Bytes() case "POST": buf := new(bytes.Buffer) if _, err := io.Copy(buf, r.Body); err != nil { w.WriteHeader(500) return } h.Data = buf.Bytes() case "LOCK": if h.Locked { w.WriteHeader(423) } else { h.Locked = true } case "UNLOCK": h.Locked = false case "DELETE": h.Data = nil w.WriteHeader(200) default: w.WriteHeader(500) // this is already returning a 500, no need for further error checking _, _ = fmt.Fprintf(w, "Unknown method: %s", r.Method) } } // mod_dav-ish behavior func (h *testHTTPHandler) HandleWebDAV(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": if _, err := w.Write(h.Data); err != nil { w.WriteHeader(500) return } w.WriteHeader(200) case "PUT": buf := new(bytes.Buffer) if _, err := io.Copy(buf, r.Body); err != nil { w.WriteHeader(500) } if reflect.DeepEqual(h.Data, buf.Bytes()) { h.Data = buf.Bytes() w.WriteHeader(204) } else { h.Data = buf.Bytes() w.WriteHeader(201) } case "DELETE": h.Data = nil w.WriteHeader(200) default: w.WriteHeader(500) // this is already returning a 500, no need for further error checking _, _ = fmt.Fprintf(w, "Unknown method: %s", r.Method) } } type testBrokenHTTPHandler struct { lastRequestWasBroken bool handler *testHTTPHandler } func (h *testBrokenHTTPHandler) Handle(w http.ResponseWriter, r *http.Request) { if h.lastRequestWasBroken { h.lastRequestWasBroken = false h.handler.Handle(w, r) } else { h.lastRequestWasBroken = true w.WriteHeader(500) } } // Tests the IsLockingEnabled method for the HTTP client. // It checks whether locking is enabled based on the presence of the UnlockURL. func TestHttpClient_IsLockingEnabled(t *testing.T) { tests := []struct { name string unlockURL string wantResult bool }{ { name: "Locking enabled when UnlockURL is set", unlockURL: "http://http-endpoint.com:3333", wantResult: true, }, { name: "Locking disabled when UnlockURL is nil", unlockURL: "", // Empty string will result in nil *url.URL wantResult: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var unlockURL *url.URL if tt.unlockURL != "" { var err error unlockURL, err = url.Parse(tt.unlockURL) if err != nil { t.Fatalf("Failed to parse unlockURL: %v", err) } } else { unlockURL = nil } client := &httpClient{ UnlockURL: unlockURL, } gotResult := client.IsLockingEnabled() if gotResult != tt.wantResult { t.Errorf("IsLockingEnabled() = %v; want %v", gotResult, tt.wantResult) } }) } } // Tests the UnLock method for the HTTP client. func TestHttpClient_Unlock(t *testing.T) { stateLockInfoA := statemgr.LockInfo{ ID: "bjarne-stroustrup-state-lock-id", Who: "BjarneStroustrup", Operation: "TestTypePlan", Created: time.Date(2023, time.August, 21, 15, 9, 26, 0, time.UTC), } stateLockInfoB := statemgr.LockInfo{ ID: "edsger-dijkstra-state-lock-id", } testCases := []struct { name string lockID string jsonLockInfo []byte lockResponseStatus int lockResponseBody []byte expectedErrorMsg error expectedPayload []byte }{ { // Successful unlocking HTTP remote state name: "Successfully unlocked", lockID: stateLockInfoA.ID, jsonLockInfo: stateLockInfoA.Marshal(), lockResponseStatus: http.StatusOK, lockResponseBody: nil, expectedErrorMsg: nil, expectedPayload: stateLockInfoA.Marshal(), }, { // Lock ID parameter does not match with LockInfo object Lock ID name: "Lock ID's don't match", lockID: stateLockInfoB.ID, jsonLockInfo: stateLockInfoA.Marshal(), lockResponseStatus: 0, lockResponseBody: nil, expectedErrorMsg: &statemgr.LockError{ Info: &stateLockInfoA, Err: fmt.Errorf("lock id %q does not match existing lock", stateLockInfoB.ID), }, expectedPayload: nil, }, { // Failed unmarshal jsonLockInfo into LockInfo object name: "Failed to unmarshal jsonLockInfo", lockID: stateLockInfoA.ID, jsonLockInfo: []byte("Simplicity is prerequisite for reliability."), lockResponseStatus: 0, lockResponseBody: nil, expectedErrorMsg: fmt.Errorf("failed to unmarshal jsonLockInfo: invalid character 'S' looking for beginning of value"), expectedPayload: nil, }, { // Force unlock command being executed name: "Successful force unlock", lockID: stateLockInfoB.ID, jsonLockInfo: nil, lockResponseStatus: http.StatusOK, lockResponseBody: nil, expectedErrorMsg: nil, expectedPayload: stateLockInfoB.Marshal(), }, { // Force unlock command being executed name: "Unsuccessful force unlock", lockID: stateLockInfoB.ID, jsonLockInfo: nil, lockResponseStatus: http.StatusNotFound, lockResponseBody: nil, expectedErrorMsg: fmt.Errorf("Unexpected HTTP response code %d", http.StatusNotFound), expectedPayload: stateLockInfoB.Marshal(), }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { var receivedPayload []byte handler := func(w http.ResponseWriter, r *http.Request) { receivedPayload, _ = io.ReadAll(r.Body) w.WriteHeader(tt.lockResponseStatus) _, err := w.Write(tt.lockResponseBody) if err != nil { t.Fatalf("Failed to write response body: %v", err) } } ts := httptest.NewServer(http.HandlerFunc(handler)) defer ts.Close() unlockURL, err := url.Parse(ts.URL) if err != nil { t.Fatalf("Failed to parse lockURL: %v", err) } client := &httpClient{ UnlockURL: unlockURL, LockMethod: "UNLOCK", Client: retryablehttp.NewClient(), jsonLockInfo: tt.jsonLockInfo, } err = client.Unlock(tt.lockID) if tt.expectedErrorMsg != nil && err == nil { // no expected error t.Errorf("UnLock() no expected error = %v", tt.expectedErrorMsg) } if tt.expectedErrorMsg == nil && err != nil { // unexpected error t.Errorf("UnLock() unexpected error = %v", err) } if tt.expectedErrorMsg != nil && err.Error() != tt.expectedErrorMsg.Error() { // mismatched errors t.Errorf("UnLock() error = %v, want %v", err, tt.expectedErrorMsg) } if !bytes.Equal(receivedPayload, tt.expectedPayload) { t.Errorf("UnLock() payload = %v, want %v", receivedPayload, tt.expectedPayload) } }) } } // Tests the Lock method for the HTTP client. // Test to see correct lock info is returned func TestHttpClient_lock(t *testing.T) { stateLockInfoA := statemgr.LockInfo{ ID: "ada-lovelace-state-lock-id", Who: "AdaLovelace", Operation: "TestTypePlan", Created: time.Date(2023, time.August, 16, 15, 9, 26, 0, time.UTC), } stateLockInfoRemoteB := statemgr.LockInfo{ ID: "linus-torvalds-http-remote-state-lock-id", Who: "LinusTorvalds", Operation: "TestTypePlan", Created: time.Date(2024, time.August, 15, 9, 0, 26, 0, time.UTC), } testCases := []struct { name string lockInfo *statemgr.LockInfo lockResponseStatus int lockResponseBody []byte expectedStateLockID string expectedErrorMsg error }{ { // Successful locking HTTP remote state name: "Successfully locked", lockInfo: &stateLockInfoA, lockResponseStatus: http.StatusOK, lockResponseBody: nil, expectedStateLockID: stateLockInfoA.ID, expectedErrorMsg: nil, }, { // Failed to lock state, HTTP remote state already locked name: "Locked remote state", lockInfo: &stateLockInfoA, lockResponseStatus: http.StatusLocked, lockResponseBody: stateLockInfoRemoteB.Marshal(), expectedStateLockID: "", expectedErrorMsg: &statemgr.LockError{ Info: &stateLockInfoRemoteB, Err: fmt.Errorf("HTTP remote state already locked: ID=%s", stateLockInfoRemoteB.ID), }, }, { // Failed to lock state HTTP remote state already locked. No remote lock details returned name: "Locked remote state failed to unmarshal body", lockInfo: &stateLockInfoA, lockResponseStatus: http.StatusLocked, lockResponseBody: nil, expectedStateLockID: "", expectedErrorMsg: &statemgr.LockError{ Info: &stateLockInfoA, Err: fmt.Errorf("HTTP remote state already locked, failed to unmarshal body"), }, }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { handler := func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(tt.lockResponseStatus) _, err := w.Write(tt.lockResponseBody) if err != nil { t.Fatalf("Failed to write response body: %v", err) } } ts := httptest.NewServer(http.HandlerFunc(handler)) defer ts.Close() lockURL, err := url.Parse(ts.URL) if err != nil { t.Fatalf("Failed to parse lockURL: %v", err) } client := &httpClient{ LockURL: lockURL, LockMethod: "LOCK", Client: retryablehttp.NewClient(), } lockID, err := client.Lock(tt.lockInfo) if tt.expectedErrorMsg != nil && err == nil { // no expected error t.Errorf("Lock() no expected error = %v", tt.expectedErrorMsg) } if tt.expectedErrorMsg == nil && err != nil { // unexpected error t.Errorf("Lock() unexpected error = %v", err) } if tt.expectedErrorMsg != nil && err.Error() != tt.expectedErrorMsg.Error() { // mismatched errors t.Errorf("Lock() error = %v, want %v", err, tt.expectedErrorMsg) } if lockID != tt.expectedStateLockID { t.Errorf("Lock() = %v, want %v", lockID, tt.expectedStateLockID) } }) } }