package api import ( "encoding/base64" "os" "path/filepath" "strings" "testing" "github.com/andydixon/nexus/internal/audit" "github.com/andydixon/nexus/internal/leviathan/document" "github.com/andydixon/nexus/internal/models" "github.com/andydixon/nexus/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 .txt`) if err != nil { t.Fatalf("safeSFTPUploadName() error = %v", err) } if got != "report .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) }