mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 09:26:50 +08:00
* fix(vcs): report untrusted provider TLS certificates on connect (MUL-7639) ConnectVCS reported every non-token failure as "could not reach the provider instance" and logged nothing, so a Gitea behind a private CA looked like a network problem. Log the underlying validation error and tell certificate failures (untrusted CA, host name mismatch, other verification failures such as expiry) apart from unreachable instances. Status codes are unchanged. Co-authored-by: multica-agent <[email protected]> * feat(helm): trust extra CA certificates in the backend (MUL-7639) backend.extraCACerts.configMap mounts an existing ConfigMap of PEM certificates read-only and points SSL_CERT_DIR at the system directory plus the mount, so the backend trusts an internal CA without dropping public CAs or disabling TLS verification. Unset renders unchanged. Co-authored-by: multica-agent <[email protected]> * docs(self-host): document trusting a private CA (MUL-7639) Co-authored-by: multica-agent <[email protected]> * fix(helm): render when values predate extraCACerts (MUL-7639) helm upgrade --reuse-values from an older chart carries no backend.extraCACerts key, and reading .configMap on it failed the whole render with a nil pointer even when no CA was wanted. Fall back to an empty dict, and cover the missing-key, default and enabled renders in the chart test. Co-authored-by: multica-agent <[email protected]> * docs(self-host): restart Compose backend after replacing a CA (MUL-7639) up -d keeps the running container when only a mounted CA file changed, so the backend kept its old trust store. Use restart instead. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-agent <[email protected]>
267 lines
9.1 KiB
Go
267 lines
9.1 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/multica-ai/multica/server/internal/testutil"
|
|
)
|
|
|
|
func vcsHandlerRequest(method, path string, body any, connectionID string) *http.Request {
|
|
req := newRequest(method, path, body)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", testWorkspaceID)
|
|
if connectionID != "" {
|
|
rctx.URLParams.Add("connectionId", connectionID)
|
|
}
|
|
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
}
|
|
|
|
func TestListVCSConnectionsHonorsDeploymentSwitch(t *testing.T) {
|
|
ctx := context.Background()
|
|
box := withVCSBox(t)
|
|
connID := seedVCSConnection(t, ctx, box, "forgejo", "https://forgejo-list.test")
|
|
t.Cleanup(func() { cleanupVCS(context.Background(), "") })
|
|
|
|
fetch := func() struct {
|
|
Connections []VCSConnectionResponse `json:"connections"`
|
|
Available bool `json:"available"`
|
|
Configured bool `json:"configured"`
|
|
} {
|
|
t.Helper()
|
|
req := vcsHandlerRequest(http.MethodGet, "/api/workspaces/"+testWorkspaceID+"/vcs/connections", nil, "")
|
|
w := httptest.NewRecorder()
|
|
testHandler.ListVCSConnections(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ListVCSConnections: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
Connections []VCSConnectionResponse `json:"connections"`
|
|
Available bool `json:"available"`
|
|
Configured bool `json:"configured"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode ListVCSConnections: %v", err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
testHandler.cfg.VCSIntegrationEnabled = false
|
|
disabled := fetch()
|
|
if disabled.Available || disabled.Configured || len(disabled.Connections) != 0 {
|
|
t.Fatalf("disabled response must hide stored connections and availability, got %+v", disabled)
|
|
}
|
|
|
|
testHandler.cfg.VCSIntegrationEnabled = true
|
|
enabled := fetch()
|
|
if !enabled.Available || !enabled.Configured {
|
|
t.Fatalf("enabled response must expose availability and configuration, got %+v", enabled)
|
|
}
|
|
if len(enabled.Connections) != 1 || enabled.Connections[0].ID != connID {
|
|
t.Fatalf("enabled response must include seeded connection %s, got %+v", connID, enabled.Connections)
|
|
}
|
|
}
|
|
|
|
func TestConnectVCSHonorsDeploymentSwitch(t *testing.T) {
|
|
var validationCalls atomic.Int32
|
|
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
validationCalls.Add(1)
|
|
if r.URL.Path != "/api/v1/user" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"login":"vcs-test-user"}`))
|
|
}))
|
|
defer provider.Close()
|
|
|
|
withVCSBox(t)
|
|
t.Cleanup(func() { cleanupVCS(context.Background(), "") })
|
|
body := map[string]any{
|
|
"provider": "forgejo",
|
|
"instance_url": provider.URL,
|
|
"access_token": "test-token",
|
|
}
|
|
connect := func() *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := vcsHandlerRequest(http.MethodPost, "/api/workspaces/"+testWorkspaceID+"/vcs/connections", body, "")
|
|
w := httptest.NewRecorder()
|
|
testHandler.ConnectVCS(w, req)
|
|
return w
|
|
}
|
|
countConnections := func() int {
|
|
t.Helper()
|
|
var count int
|
|
if err := testPool.QueryRow(context.Background(),
|
|
`SELECT count(*) FROM vcs_connection WHERE workspace_id = $1 AND instance_url = $2`,
|
|
testWorkspaceID, provider.URL,
|
|
).Scan(&count); err != nil {
|
|
t.Fatalf("count VCS connections: %v", err)
|
|
}
|
|
return count
|
|
}
|
|
|
|
testHandler.cfg.VCSIntegrationEnabled = false
|
|
if w := connect(); w.Code != http.StatusNotFound {
|
|
t.Fatalf("disabled ConnectVCS: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if got := validationCalls.Load(); got != 0 {
|
|
t.Fatalf("disabled ConnectVCS must not call provider, got %d requests", got)
|
|
}
|
|
if got := countConnections(); got != 0 {
|
|
t.Fatalf("disabled ConnectVCS must not write a connection, got %d rows", got)
|
|
}
|
|
|
|
testHandler.cfg.VCSIntegrationEnabled = true
|
|
if w := connect(); w.Code != http.StatusOK {
|
|
t.Fatalf("enabled ConnectVCS: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if got := validationCalls.Load(); got != 1 {
|
|
t.Fatalf("enabled ConnectVCS: expected one provider validation, got %d", got)
|
|
}
|
|
if got := countConnections(); got != 1 {
|
|
t.Fatalf("enabled ConnectVCS: expected one stored connection, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestRotateVCSConnectionWebhookHonorsDeploymentSwitch(t *testing.T) {
|
|
ctx := context.Background()
|
|
box := withVCSBox(t)
|
|
connID := seedVCSConnection(t, ctx, box, "forgejo", "https://forgejo-rotate.test")
|
|
t.Cleanup(func() { cleanupVCS(context.Background(), "") })
|
|
connUUID := parseUUID(connID)
|
|
|
|
loadSecret := func() string {
|
|
t.Helper()
|
|
conn, err := testHandler.Queries.GetVCSConnectionByID(context.Background(), connUUID)
|
|
if err != nil {
|
|
t.Fatalf("GetVCSConnectionByID: %v", err)
|
|
}
|
|
return conn.WebhookSecretEncrypted
|
|
}
|
|
rotate := func() *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := vcsHandlerRequest(
|
|
http.MethodPost,
|
|
"/api/workspaces/"+testWorkspaceID+"/vcs/connections/"+connID+"/rotate-webhook",
|
|
nil,
|
|
connID,
|
|
)
|
|
w := httptest.NewRecorder()
|
|
testHandler.RotateVCSConnectionWebhook(w, req)
|
|
return w
|
|
}
|
|
|
|
originalSecret := loadSecret()
|
|
testHandler.cfg.VCSIntegrationEnabled = false
|
|
if w := rotate(); w.Code != http.StatusNotFound {
|
|
t.Fatalf("disabled RotateVCSConnectionWebhook: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if got := loadSecret(); got != originalSecret {
|
|
t.Fatal("disabled RotateVCSConnectionWebhook must not modify the stored secret")
|
|
}
|
|
|
|
testHandler.cfg.VCSIntegrationEnabled = true
|
|
if w := rotate(); w.Code != http.StatusOK {
|
|
t.Fatalf("enabled RotateVCSConnectionWebhook: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if got := loadSecret(); got == originalSecret {
|
|
t.Fatal("enabled RotateVCSConnectionWebhook must replace the stored secret")
|
|
}
|
|
}
|
|
|
|
// TestConnectVCSReportsUntrustedCertificate covers a Gitea behind a private or
|
|
// self-signed CA: the connect must fail as a certificate problem, not as an
|
|
// unreachable instance, and must not store a connection.
|
|
func TestConnectVCSReportsUntrustedCertificate(t *testing.T) {
|
|
var reached atomic.Int32
|
|
provider := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
reached.Add(1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"login":"vcs-test-user"}`))
|
|
}))
|
|
defer provider.Close()
|
|
|
|
withVCSBox(t)
|
|
t.Cleanup(func() { cleanupVCS(context.Background(), "") })
|
|
req := vcsHandlerRequest(http.MethodPost, "/api/workspaces/"+testWorkspaceID+"/vcs/connections", map[string]any{
|
|
"provider": "gitea",
|
|
"instance_url": provider.URL,
|
|
"access_token": "test-token",
|
|
}, "")
|
|
var resp struct {
|
|
Error string `json:"error"`
|
|
}
|
|
testutil.Call(t, testHandler.ConnectVCS, req).Want(http.StatusBadGateway).JSON(&resp)
|
|
if !strings.Contains(resp.Error, "certificate authority") {
|
|
t.Fatalf("expected an untrusted-certificate message, got %q", resp.Error)
|
|
}
|
|
if got := reached.Load(); got != 0 {
|
|
t.Fatalf("the TLS handshake must fail before any request is served, got %d", got)
|
|
}
|
|
var count int
|
|
if err := testPool.QueryRow(context.Background(),
|
|
`SELECT count(*) FROM vcs_connection WHERE workspace_id = $1 AND instance_url = $2`,
|
|
testWorkspaceID, provider.URL,
|
|
).Scan(&count); err != nil {
|
|
t.Fatalf("count VCS connections: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("a failed connect must not store a connection, got %d rows", count)
|
|
}
|
|
}
|
|
|
|
// TestVCSValidationFailureMessage drives real handshakes so each case matches
|
|
// the error shape crypto/tls actually returns, wrapped the way providers wrap
|
|
// it.
|
|
func TestVCSValidationFailureMessage(t *testing.T) {
|
|
srv := httptest.NewTLSServer(http.NotFoundHandler())
|
|
defer srv.Close()
|
|
// srv.Client() trusts the test certificate, which covers 127.0.0.1 and
|
|
// example.com and is valid until 2084.
|
|
trusting := func(adjust func(*tls.Config)) *http.Client {
|
|
transport := srv.Client().Transport.(*http.Transport).Clone()
|
|
adjust(transport.TLSClientConfig)
|
|
return &http.Client{Transport: transport}
|
|
}
|
|
closed := httptest.NewServer(http.NotFoundHandler())
|
|
closedURL := closed.URL
|
|
closed.Close()
|
|
|
|
cases := []struct {
|
|
name string
|
|
client *http.Client
|
|
url string
|
|
want string
|
|
}{
|
|
{"untrusted CA", &http.Client{}, srv.URL, "certificate authority this server does not trust"},
|
|
{"hostname mismatch", trusting(func(c *tls.Config) { c.ServerName = "gitea.internal.test" }), srv.URL, "does not match"},
|
|
{"expired", trusting(func(c *tls.Config) {
|
|
c.Time = func() time.Time { return time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC) }
|
|
}), srv.URL, "failed verification"},
|
|
{"unreachable", &http.Client{}, closedURL, "could not reach the provider instance"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
resp, err := tc.client.Get(tc.url)
|
|
if err == nil {
|
|
resp.Body.Close()
|
|
t.Fatal("expected the request to fail")
|
|
}
|
|
got := vcsValidationFailureMessage(fmt.Errorf("gitea: request: %w", err))
|
|
if !strings.Contains(got, tc.want) {
|
|
t.Fatalf("error %v: got message %q, want it to contain %q", err, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|