Files
nexus/internal/api/api_test.go
T
Andy DixonandClaude Sonnet 5 1a380a0e99 Rebrand: Nexus Terminal → Alchementary Link
Renames the product across the codebase: Go module path, config/data
paths, keychain service name, export file extension, build tooling,
docs, and UI text. Sidebar brand mark now reads ">_ AlchementaryLink"
with "Alchementary" in the connection-group gold accent colour, and
the window title is "Alchementary Link".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T8bJS8BCtY5GeSG2HBj1n
2026-09-03 16:41:23 +01:00

388 lines
11 KiB
Go

package api
import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
"github.com/andydixon/link/internal/audit"
"github.com/andydixon/link/internal/leviathan/document"
"github.com/andydixon/link/internal/models"
"github.com/andydixon/link/internal/storage"
)
type closeTrackingWriter struct {
closed int
}
func (w *closeTrackingWriter) Write(p []byte) (int, error) {
return len(p), nil
}
func (w *closeTrackingWriter) Close() error {
w.closed++
return nil
}
func TestSSHKeyPairPathsAcceptsSafeName(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
privPath, pubPath, err := sshKeyPairPaths("deploy_key-01")
if err != nil {
t.Fatalf("sshKeyPairPaths() error = %v", err)
}
wantPriv := filepath.Join(home, ".ssh", "deploy_key-01")
if privPath != wantPriv {
t.Fatalf("private path = %q, want %q", privPath, wantPriv)
}
if pubPath != wantPriv+".pub" {
t.Fatalf("public path = %q, want %q", pubPath, wantPriv+".pub")
}
}
func TestSSHKeyPairPathsRejectsTraversal(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
_, _, err := sshKeyPairPaths("../outside")
if err == nil {
t.Fatal("expected traversal key name to be rejected")
}
if !strings.Contains(err.Error(), "invalid characters") {
t.Fatalf("error = %q, want invalid characters", err.Error())
}
}
func TestSSHKeyPairPathsRejectsNestedPath(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
_, _, err := sshKeyPairPaths("nested/key")
if err == nil {
t.Fatal("expected nested key path to be rejected")
}
}
func TestRedactProfileSecretsClearsInlineSSHSecrets(t *testing.T) {
profile := models.NewProfile(models.ProtocolSSH)
profile.SSH.ProxyPassword = "proxy-secret"
profile.SSH.TOTPSecret = "totp-secret"
got := redactProfileSecrets([]*models.Profile{&profile})
if got[0].SSH.ProxyPassword != "" {
t.Fatalf("ProxyPassword = %q, want empty", got[0].SSH.ProxyPassword)
}
if got[0].SSH.TOTPSecret != "" {
t.Fatalf("TOTPSecret = %q, want empty", got[0].SSH.TOTPSecret)
}
if profile.SSH.ProxyPassword == "" || profile.SSH.TOTPSecret == "" {
t.Fatal("redaction mutated the original profile")
}
}
func TestCreateProfileRejectsUnsafeGitHubBaseURL(t *testing.T) {
svc := newTestService(t)
profile := models.NewProfile(models.ProtocolGitHub)
profile.DisplayName = "Unsafe GitHub"
profile.GitHub.BaseURL = "http://evil.example/api/v3"
if _, err := svc.CreateProfile(profile); err == nil {
t.Fatal("expected unsafe GitHub base URL to be rejected")
}
}
func TestImportProfilesRejectsNullEntry(t *testing.T) {
svc := newTestService(t)
_, err := svc.ImportProfiles(`[null]`, "skip")
if err == nil {
t.Fatal("expected null import entry to be rejected")
}
if !strings.Contains(err.Error(), "entry 0 is null") {
t.Fatalf("error = %q, want null entry detail", err.Error())
}
}
func TestImportProfilesRejectsOversizedJSON(t *testing.T) {
svc := newTestService(t)
oversized := strings.Repeat(" ", maxProfileImportJSONBytes+1)
_, err := svc.ImportProfiles(oversized, "skip")
if err == nil {
t.Fatal("expected oversized import JSON to be rejected")
}
}
func TestSFTPUploadDataRejectsOversizedPayloadBeforeSessionLookup(t *testing.T) {
svc := newTestService(t)
payload := base64.StdEncoding.EncodeToString(make([]byte, maxInlineSFTPUploadBytes+1))
err := svc.SFTPUploadData("missing-session", "/", "large.bin", payload)
if err == nil {
t.Fatal("expected oversized upload payload to be rejected")
}
if !strings.Contains(err.Error(), "too large") {
t.Fatalf("error = %q, want too large", err.Error())
}
}
func TestSFTPUploadChunkRejectsOversizedChunkBeforeUploadLookup(t *testing.T) {
svc := newTestService(t)
payload := base64.StdEncoding.EncodeToString(make([]byte, maxSFTPUploadChunkBytes+1))
err := svc.SFTPUploadChunk("missing-upload", payload)
if err == nil {
t.Fatal("expected oversized upload chunk to be rejected")
}
if !strings.Contains(err.Error(), "chunk is too large") {
t.Fatalf("error = %q, want chunk is too large", err.Error())
}
}
func TestCancelSFTPUploadsClosesOnlyMatchingSession(t *testing.T) {
first := &closeTrackingWriter{}
second := &closeTrackingWriter{}
svc := &Service{
uploads: map[string]*sftpUpload{
"first": {writer: first, sessionID: "session-1", remotePath: "/first"},
"second": {writer: second, sessionID: "session-2", remotePath: "/second"},
},
}
svc.cancelSFTPUploads("session-1")
if first.closed != 1 {
t.Fatalf("matching writer closed %d times, want 1", first.closed)
}
if second.closed != 0 {
t.Fatalf("unrelated writer closed %d times, want 0", second.closed)
}
if len(svc.uploads) != 1 || svc.uploads["second"] == nil {
t.Fatalf("remaining uploads = %#v, want only session-2", svc.uploads)
}
}
func TestSFTPUploadCapacityIsBounded(t *testing.T) {
svc := &Service{uploads: make(map[string]*sftpUpload)}
for i := 0; i < maxConcurrentSFTPUploads; i++ {
svc.uploads[string(rune('a'+i))] = &sftpUpload{writer: &closeTrackingWriter{}}
}
if err := svc.checkSFTPUploadCapacity(); err == nil {
t.Fatal("expected active upload limit to be enforced")
}
}
func TestServiceShutdownReleasesUploadsAndSecretWaiters(t *testing.T) {
writer := &closeTrackingWriter{}
waiter := make(chan secretResponse, 1)
svc := &Service{
uploads: map[string]*sftpUpload{"upload": {writer: writer}},
secretChans: map[string]chan secretResponse{"prompt": waiter},
}
svc.Shutdown()
if writer.closed != 1 {
t.Fatalf("writer closed %d times, want 1", writer.closed)
}
if len(svc.uploads) != 0 {
t.Fatalf("active uploads = %d, want 0", len(svc.uploads))
}
select {
case response := <-waiter:
if !response.cancelled {
t.Fatal("shutdown response was not cancelled")
}
default:
t.Fatal("shutdown did not release secret prompt waiter")
}
}
func TestSafeSFTPUploadNamePreservesHTMLCharacters(t *testing.T) {
got, err := safeSFTPUploadName(`../report <final>.txt`)
if err != nil {
t.Fatalf("safeSFTPUploadName() error = %v", err)
}
if got != "report <final>.txt" {
t.Fatalf("safe name = %q, want HTML characters preserved in base name", got)
}
}
func TestSafeSFTPUploadNameRejectsParentDirectory(t *testing.T) {
for _, name := range []string{"..", "folder/..", `folder\..`} {
t.Run(name, func(t *testing.T) {
if _, err := safeSFTPUploadName(name); err == nil {
t.Fatal("expected parent directory upload name to be rejected")
}
})
}
}
func TestValidateSSHKeyGenerationOptions(t *testing.T) {
keyType, bits, err := validateSSHKeyGenerationOptions("", 0)
if err != nil {
t.Fatalf("default options error = %v", err)
}
if keyType != "ed25519" {
t.Fatalf("default key type = %q, want ed25519", keyType)
}
if bits != 0 {
t.Fatalf("default bits = %d, want 0", bits)
}
keyType, bits, err = validateSSHKeyGenerationOptions("rsa", 0)
if err != nil {
t.Fatalf("rsa default options error = %v", err)
}
if keyType != "rsa" || bits != 4096 {
t.Fatalf("rsa defaults = %q/%d, want rsa/4096", keyType, bits)
}
if _, _, err := validateSSHKeyGenerationOptions("rsa", 999999); err == nil {
t.Fatal("expected oversized RSA key size to be rejected")
}
if _, _, err := validateSSHKeyGenerationOptions("dsa", 0); err == nil {
t.Fatal("expected unsupported key type to be rejected")
}
}
func TestImportProfilesNormalizesSparseRAWProfile(t *testing.T) {
svc := newTestService(t)
data := `[
{
"displayName": "Raw Device",
"protocol": "raw",
"host": "device.local",
"raw": {}
}
]`
count, err := svc.ImportProfiles(data, "skip")
if err != nil {
t.Fatalf("ImportProfiles() error = %v", err)
}
if count != 1 {
t.Fatalf("imported = %d, want 1", count)
}
profiles, err := svc.ListProfiles()
if err != nil {
t.Fatalf("ListProfiles() error = %v", err)
}
if len(profiles) != 1 {
t.Fatalf("profiles = %d, want 1", len(profiles))
}
if profiles[0].Port != models.DefaultRAWPort {
t.Fatalf("port = %d, want %d", profiles[0].Port, models.DefaultRAWPort)
}
if profiles[0].RAW.Encoding != "utf-8" {
t.Fatalf("raw encoding = %q, want utf-8", profiles[0].RAW.Encoding)
}
}
func TestLeviathanDocumentBytesRejectsOversizedDocument(t *testing.T) {
doc := document.New("large.bin", []byte("abcd"))
_, err := leviathanDocumentBytes(doc, 3, "test")
if err == nil {
t.Fatal("expected oversized document to be rejected")
}
if !strings.Contains(err.Error(), "too large") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestWriteLeviathanDocumentFileWritesPatchedBytes(t *testing.T) {
doc := document.New("patched.bin", []byte("abcd"))
if _, err := doc.Set(1, 'Z'); err != nil {
t.Fatalf("Set() failed: %v", err)
}
path := filepath.Join(t.TempDir(), "out.bin")
if err := writeLeviathanDocumentFile(path, doc); err != nil {
t.Fatalf("writeLeviathanDocumentFile() failed: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() failed: %v", err)
}
if string(got) != "aZcd" {
t.Fatalf("written bytes = %q, want %q", string(got), "aZcd")
}
}
func TestWriteLeviathanDocumentRangeFileWritesSelectedPatchedBytes(t *testing.T) {
doc := document.New("patched.bin", []byte("abcdef"))
if _, err := doc.Set(3, 'Z'); err != nil {
t.Fatalf("Set() failed: %v", err)
}
path := filepath.Join(t.TempDir(), "range.bin")
if err := writeLeviathanDocumentRangeFile(path, doc, 2, 3); err != nil {
t.Fatalf("writeLeviathanDocumentRangeFile() failed: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() failed: %v", err)
}
if string(got) != "cZe" {
t.Fatalf("written bytes = %q, want %q", string(got), "cZe")
}
}
func TestReadLeviathanSmallFileRejectsOversizedFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "big.img")
if err := os.WriteFile(path, []byte("abcd"), 0600); err != nil {
t.Fatalf("WriteFile() failed: %v", err)
}
_, err := readLeviathanSmallFile(path, 3, "image")
if err == nil {
t.Fatal("expected oversized file to be rejected")
}
if !strings.Contains(err.Error(), "too large") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestReadSSHPublicKeyFileRejectsOversizedFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "big.pub")
if err := os.WriteFile(path, []byte(strings.Repeat("x", maxSSHPublicKeyBytes+1)), 0600); err != nil {
t.Fatalf("WriteFile() failed: %v", err)
}
_, err := readSSHPublicKeyFile(path)
if err == nil {
t.Fatal("expected oversized public key to be rejected")
}
if !strings.Contains(err.Error(), "too large") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCreateProfileNormalizesLegacyVNCSecretMode(t *testing.T) {
svc := newTestService(t)
profile := models.NewProfile(models.ProtocolVNC)
profile.DisplayName = "VNC"
profile.Host = "vnc.local"
profile.VNC.SecretMode = "prompt"
profile.VNC.SSHSecretMode = "prompt"
got, err := svc.CreateProfile(profile)
if err != nil {
t.Fatalf("CreateProfile() error = %v", err)
}
if got.VNC.SecretMode != models.SecretModeAskEveryTime {
t.Fatalf("secret mode = %q, want %q", got.VNC.SecretMode, models.SecretModeAskEveryTime)
}
if got.VNC.SSHSecretMode != models.SecretModeAskEveryTime {
t.Fatalf("ssh secret mode = %q, want %q", got.VNC.SSHSecretMode, models.SecretModeAskEveryTime)
}
}
func newTestService(t *testing.T) *Service {
t.Helper()
store, err := storage.NewStore(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("NewStore() error = %v", err)
}
t.Cleanup(func() { store.Close() })
return NewService(store, nil, nil, nil, audit.NewLogger(store), nil)
}