Files
LinkPort/internal/api/target_draft_test.go

249 lines
9.0 KiB
Go

package api
import (
"context"
"encoding/json"
"net"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
"golang.org/x/crypto/ssh"
"linkport/internal/gateway"
"linkport/internal/model"
"linkport/internal/protocol/sshproxy"
)
type recordingDraftProtocol struct {
inertProtocol
seen []model.Proxy
}
func (h *recordingDraftProtocol) Test(_ context.Context, p model.Proxy, _ model.DialFunc) (model.TestResult, error) {
h.seen = append(h.seen, p)
return model.TestResult{Version: "draft-fixture"}, nil
}
func draftConfig(kind string) map[string]any {
return map[string]any{"type": kind, "targetHost": "draft.invalid", "targetPort": 12345,
"targetTLS": false, "outboundId": "", "ssh": map[string]any{"ignoreHostKey": true},
"accounts": []any{map[string]any{"id": "chosen", "username": "draft-user", "password": "fixture-password", "authMethod": "password"}, map[string]any{"id": "unfinished"}}}
}
func TestTargetDraftAllProtocolsWithoutPersistence(t *testing.T) {
f := newFixture(t)
f.login()
h := &recordingDraftProtocol{}
handlers := map[string]model.Handler{}
kinds := []string{"mysql", "mongodb", "pgsql", "sqlserver", "redis", "rabbitmq", "http", "https", "ssh", "sftp"}
for _, kind := range kinds {
handlers[kind] = h
}
m := gateway.New(f.store, nil, handlers)
f.server.manager = m
t.Cleanup(m.Shutdown)
before := f.request("GET", "/api/v1/targets", nil, false).Body.String()
events, _ := f.store.Events()
for _, kind := range kinds {
t.Run(kind, func(t *testing.T) {
w := f.request("POST", "/api/v1/targets/test", map[string]any{"accountId": "chosen", "config": draftConfig(kind)}, true)
requireStatus(t, w, 200)
p := h.seen[len(h.seen)-1]
if p.Type != kind || p.TargetHost != "draft.invalid" || p.TargetPort != 12345 || p.TargetUser != "draft-user" || p.TargetPassword != "fixture-password" || p.TargetTLS || len(p.TargetAccounts) != 1 {
t.Fatal("test did not receive selected draft configuration")
}
if strings.Contains(w.Body.String(), "fixture-password") {
t.Fatal("credential leaked")
}
})
}
if after := f.request("GET", "/api/v1/targets", nil, false).Body.String(); before != after {
t.Fatal("draft test created targets")
}
afterEvents, _ := f.store.Events()
if !reflect.DeepEqual(events, afterEvents) {
t.Fatal("draft test persisted events")
}
}
func TestTargetDraftRetainsSecretsByIDAndDoesNotChangeSavedTarget(t *testing.T) {
f := newFixture(t)
f.login()
v := f.create("mysql")
before, err := f.store.GetTarget(v.Config.TargetID)
if err != nil {
t.Fatal(err)
}
history, _ := f.store.History("targets", before.ID)
h := &recordingDraftProtocol{}
m := gateway.New(f.store, nil, map[string]model.Handler{"mysql": h})
f.server.manager = m
t.Cleanup(m.Shutdown)
cfg := draftConfig("mysql")
a := map[string]any{"id": "test-account", "username": "new-user"}
cfg["accounts"] = []any{map[string]any{"id": "unfinished"}, a}
body := map[string]any{"targetId": before.ID, "accountId": "test-account", "config": cfg}
for _, replacement := range []bool{false, true} {
if replacement {
a["password"] = "replacement-fixture"
}
requireStatus(t, f.request("POST", "/api/v1/targets/test", body, true), 200)
p := h.seen[len(h.seen)-1]
want := "target-secret-unique"
if replacement {
want = "replacement-fixture"
}
if p.TargetPassword != want || p.TargetHost != "draft.invalid" || p.TargetUser != "new-user" || p.Outbound.Mode != "direct" {
t.Fatal("wrong draft credentials or route")
}
}
after, _ := f.store.GetTarget(before.ID)
afterHistory, _ := f.store.History("targets", before.ID)
if !reflect.DeepEqual(before, after) || !reflect.DeepEqual(history, afterHistory) {
t.Fatal("draft test modified saved target or history")
}
p, _ := f.store.Get(v.Config.ID)
if p.TargetHost != before.TargetHost || p.TargetPassword != before.Accounts[0].Password {
t.Fatal("draft test changed gateway target")
}
}
func TestTargetDraftValidationAndSanitizedFailures(t *testing.T) {
f := newFixture(t)
f.login()
body := map[string]any{"accountId": "chosen", "config": draftConfig("mysql")}
requireStatus(t, f.request("POST", "/api/v1/targets/test", body, false), 403)
requireStatus(t, f.request("GET", "/api/v1/targets/test", nil, false), 405)
for _, mutate := range []func(map[string]any){
func(b map[string]any) { b["accountId"] = "missing" },
func(b map[string]any) { b["config"] = nil },
func(b map[string]any) { b["config"].(map[string]any)["id"] = "injected" },
func(b map[string]any) {
b["config"].(map[string]any)["ssh"] = map[string]any{"targetHostKey": "injected"}
},
func(b map[string]any) { b["config"].(map[string]any)["targetCAAssetId"] = "missing" },
func(b map[string]any) { b["config"].(map[string]any)["outboundId"] = "missing" },
} {
b := map[string]any{"accountId": "chosen", "config": draftConfig("mysql")}
mutate(b)
requireStatus(t, f.request("POST", "/api/v1/targets/test", b, true), 400)
}
m := gateway.New(f.store, nil, map[string]model.Handler{"mysql": failingProtocol{}})
f.server.manager = m
t.Cleanup(m.Shutdown)
w := f.request("POST", "/api/v1/targets/test", body, true)
requireStatus(t, w, 422)
// This fixture supplies only error text, not an observed authentication rejection.
if strings.Contains(w.Body.String(), "private-upstream-diagnostic") || !strings.Contains(w.Body.String(), "target_connection_or_protocol") {
t.Fatal("unsanitized test failure")
}
}
func TestTargetDraftResolvesSelectedOutboundAndPrivateKeyAssets(t *testing.T) {
f := newFixture(t)
f.login()
key, err := sshproxy.GenerateHostKey()
if err != nil {
t.Fatal(err)
}
for _, asset := range []model.Asset{
{ID: "draft-key", Name: "fixture key", Type: "ssh_private_key", Content: key},
{ID: "draft-kube", Name: "fixture kube", Type: "kubeconfig", Content: assetKubeconfigFixture},
} {
if err := f.store.SaveAsset(asset); err != nil {
t.Fatal(err)
}
}
w := f.request("POST", "/api/v1/outbounds", map[string]any{"name": "fixture transport", "outbound": map[string]any{"mode": "k8s", "resource": "pod/fixture", "kubeConfigAssetId": "draft-kube"}}, true)
requireStatus(t, w, 201)
var out struct {
Config model.OutboundProfile `json:"config"`
}
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
h := &recordingDraftProtocol{}
m := gateway.New(f.store, nil, map[string]model.Handler{"ssh": h})
f.server.manager = m
t.Cleanup(m.Shutdown)
cfg := draftConfig("ssh")
cfg["outboundId"] = out.Config.ID
cfg["accounts"] = []any{map[string]any{"id": "chosen", "username": "fixture", "authMethod": "privatekey", "privateKeyAssetId": "draft-key"}}
requireStatus(t, f.request("POST", "/api/v1/targets/test", map[string]any{"accountId": "chosen", "config": cfg}, true), 200)
if len(h.seen) != 1 || h.seen[0].SSH.PrivateKey != key || h.seen[0].Outbound.KubeConfigContent != assetKubeconfigFixture || h.seen[0].Outbound.Resource != "pod/fixture" {
t.Fatal("draft assets or transport not resolved")
}
}
func TestTargetDraftSSHTrustIsTemporaryAndPrecedesAuthentication(t *testing.T) {
f := newFixture(t)
f.login()
key, err := sshproxy.GenerateHostKey()
if err != nil {
t.Fatal(err)
}
signer, err := ssh.ParsePrivateKey([]byte(key))
if err != nil {
t.Fatal(err)
}
var authenticated atomic.Int32
server := &ssh.ServerConfig{PasswordCallback: func(_ ssh.ConnMetadata, _ []byte) (*ssh.Permissions, error) { authenticated.Add(1); return nil, nil }}
server.AddHostKey(signer)
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { l.Close() })
go func() {
for {
c, e := l.Accept()
if e != nil {
return
}
go func() {
defer c.Close()
_ = c.SetDeadline(time.Now().Add(5 * time.Second))
conn, channels, requests, e := ssh.NewServerConn(c, server)
if e != nil {
return
}
defer conn.Close()
go ssh.DiscardRequests(requests)
for ch := range channels {
_ = ch.Reject(ssh.UnknownChannelType, "fixture")
}
}()
}
}()
m := gateway.New(f.store, nil, map[string]model.Handler{"ssh": &sshproxy.Handler{}})
f.server.manager = m
t.Cleanup(m.Shutdown)
cfg := draftConfig("ssh")
cfg["targetHost"], cfg["targetPort"], cfg["ssh"] = "127.0.0.1", l.Addr().(*net.TCPAddr).Port, map[string]any{}
body := map[string]any{"accountId": "chosen", "config": cfg}
for _, fingerprint := range []string{"", "SHA256:wrong"} {
body["hostFingerprint"] = fingerprint
w := f.request("POST", "/api/v1/targets/test", body, true)
requireStatus(t, w, 409)
var result map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result["fingerprint"] != ssh.FingerprintSHA256(signer.PublicKey()) || authenticated.Load() != 0 {
t.Fatal("authentication before host confirmation")
}
}
body["hostFingerprint"] = ssh.FingerprintSHA256(signer.PublicKey())
requireStatus(t, f.request("POST", "/api/v1/targets/test", body, true), 200)
if authenticated.Load() != 1 {
t.Fatal("SSH test did not authenticate once")
}
delete(body, "hostFingerprint")
requireStatus(t, f.request("POST", "/api/v1/targets/test", body, true), 409)
if authenticated.Load() != 1 {
t.Fatal("temporary host trust was persisted")
}
}