package api import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "codeberg.org/SquidSE/ClusterCanvas/service/internal/auth" "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" ) func TestNodesCreateGeneratesEd25519AndStoresEncryptedKey(t *testing.T) { configDir := t.TempDir() cookie := seedCompletedSetup(t, configDir) router := NewRouter(configDir) body := map[string]any{ "id": "11111111-2222-4333-8444-555555555555", "kind": "container", "name": "ct-alpha", "host_ip": "192.168.10.20", "username": "clustercanvas", "group_name": settings.AdministratorsGroupName, "generate": map[string]any{ "algorithm": "ed25519", "kdf_rounds": 100, }, } payload, err := json.Marshal(body) if err != nil { t.Fatalf("marshal: %v", err) } request := withSession( httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(payload)), cookie, ) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusCreated { t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) } var response nodeResponse if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { t.Fatalf("decode: %v", err) } if response.Node.ID != "11111111-2222-4333-8444-555555555555" { t.Fatalf("id = %q", response.Node.ID) } if response.Node.PublicKey == "" { t.Fatal("expected public key") } if strings.Contains(recorder.Body.String(), "BEGIN OPENSSH PRIVATE KEY") { t.Fatal("private key must not appear in API response") } keyBytes, err := settings.KeyFromEnv() if err != nil { t.Fatalf("KeyFromEnv: %v", err) } keyStore, err := settings.LoadNodeKeys(configDir, keyBytes) if err != nil { t.Fatalf("LoadNodeKeys: %v", err) } if len(keyStore.Keys) != 1 { t.Fatalf("keys = %d", len(keyStore.Keys)) } if keyStore.Keys[0].NodeID != response.Node.ID { t.Fatalf("key node id = %q", keyStore.Keys[0].NodeID) } if !strings.Contains(keyStore.Keys[0].PrivateKey, "BEGIN OPENSSH PRIVATE KEY") { t.Fatal("expected openssh private key in encrypted store") } if keyStore.Keys[0].KDFRounds != 100 { t.Fatalf("kdf rounds = %d", keyStore.Keys[0].KDFRounds) } } func TestNodesCreateRejectsWithoutNodesExec(t *testing.T) { configDir := t.TempDir() _ = seedCompletedSetup(t, configDir) keyBytes, err := settings.KeyFromEnv() if err != nil { t.Fatalf("KeyFromEnv: %v", err) } now := time.Now().UTC() passwordHash, err := auth.HashPassword("correct horse battery staple extra") if err != nil { t.Fatalf("HashPassword: %v", err) } payload := settings.Settings{ SetupCompleted: true, Groups: []settings.Group{ { Name: settings.AdministratorsGroupName, ScopeKind: settings.GroupScopeGroup, ScopeName: "Admin Group", Permissions: allPermissionList(), }, { Name: "Readers", ScopeKind: settings.GroupScopeGroup, ScopeName: "Readers", Permissions: []string{"nodes.read"}, }, }, Security: settings.DefaultSecuritySettings(), Network: settings.DefaultNetworkSettings(), } if err := settings.SaveSettings(configDir, payload); err != nil { t.Fatalf("SaveSettings: %v", err) } if err := settings.SavePasswords(configDir, settings.PasswordStore{ Users: []settings.UserCredential{ { ID: "admin-id", Username: "Admin", PasswordHash: passwordHash, Enabled: true, GroupNames: []string{settings.AdministratorsGroupName}, CreatedAt: now, PasswordChangedAt: now, }, { ID: "reader-id", Username: "reader", PasswordHash: passwordHash, Enabled: true, GroupNames: []string{"Readers"}, CreatedAt: now, PasswordChangedAt: now, }, }, }, keyBytes); err != nil { t.Fatalf("SavePasswords: %v", err) } router := NewRouter(configDir) loginRecorder := httptest.NewRecorder() loginRequest := httptest.NewRequest( http.MethodPost, "/api/v1/auth/login", bytes.NewReader([]byte(`{"username":"reader","password":"correct horse battery staple extra"}`)), ) router.ServeHTTP(loginRecorder, loginRequest) if loginRecorder.Code != http.StatusOK { t.Fatalf("login status = %d body=%s", loginRecorder.Code, loginRecorder.Body.String()) } var readerCookie *http.Cookie for _, candidate := range loginRecorder.Result().Cookies() { if candidate.Name == auth.SessionCookieName { readerCookie = candidate } } if readerCookie == nil { t.Fatal("missing session cookie") } createBody := []byte(`{ "kind":"container", "name":"ct-beta", "host_ip":"10.0.0.2", "group_name":"Readers", "generate":{"algorithm":"ed25519"} }`) request := withSession( httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(createBody)), readerCookie, ) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusForbidden { t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String()) } } func TestNodesCreateRSAAndListByKind(t *testing.T) { configDir := t.TempDir() cookie := seedCompletedSetup(t, configDir) router := NewRouter(configDir) body := []byte(`{ "kind":"vm", "name":"vm-one", "host_ip":"10.0.0.8", "group_name":"Administrators", "generate":{"algorithm":"rsa","rsa_bits":2048} }`) request := withSession( httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(body)), cookie, ) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusCreated { t.Fatalf("create status = %d body=%s", recorder.Code, recorder.Body.String()) } listRequest := withSession( httptest.NewRequest(http.MethodGet, "/api/v1/nodes?kind=vm", nil), cookie, ) listRecorder := httptest.NewRecorder() router.ServeHTTP(listRecorder, listRequest) if listRecorder.Code != http.StatusOK { t.Fatalf("list status = %d", listRecorder.Code) } var list nodesResponse if err := json.Unmarshal(listRecorder.Body.Bytes(), &list); err != nil { t.Fatalf("decode: %v", err) } if len(list.Nodes) != 1 || list.Nodes[0].Name != "vm-one" { t.Fatalf("nodes = %+v", list.Nodes) } if list.Nodes[0].KeyAlgo != "rsa" { t.Fatalf("key algo = %q", list.Nodes[0].KeyAlgo) } emptyRequest := withSession( httptest.NewRequest(http.MethodGet, "/api/v1/nodes?kind=docker", nil), cookie, ) emptyRecorder := httptest.NewRecorder() router.ServeHTTP(emptyRecorder, emptyRequest) var emptyList nodesResponse if err := json.Unmarshal(emptyRecorder.Body.Bytes(), &emptyList); err != nil { t.Fatalf("decode: %v", err) } if len(emptyList.Nodes) != 0 { t.Fatalf("expected no docker nodes, got %d", len(emptyList.Nodes)) } } func TestNodesCreateWithNewGroup(t *testing.T) { configDir := t.TempDir() cookie := seedCompletedSetup(t, configDir) router := NewRouter(configDir) body := []byte(`{ "kind":"docker", "name":"docker-host", "host_ip":"10.1.1.1", "new_group":{"name":"Docker Hosts"}, "generate":{"algorithm":"ed25519","kdf_rounds":64} }`) request := withSession( httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(body)), cookie, ) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusCreated { t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String()) } var response nodeResponse if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { t.Fatalf("decode: %v", err) } if response.Node.GroupName != "Docker Hosts" { t.Fatalf("group = %q", response.Node.GroupName) } settingsPayload, err := settings.LoadSettings(configDir) if err != nil { t.Fatalf("LoadSettings: %v", err) } found := false for _, group := range settingsPayload.Groups { if group.Name == "Docker Hosts" { found = true if group.ScopeKind != settings.GroupScopeNode { t.Fatalf("scope kind = %q", group.ScopeKind) } if group.ScopeName != response.Node.ID { t.Fatalf("scope name = %q want %q", group.ScopeName, response.Node.ID) } if len(group.Permissions) != 0 { t.Fatalf("expected empty permissions, got %v", group.Permissions) } } } if !found { t.Fatal("new group not saved") } } func TestNodesTestSSHReportsFailureForUnreachableHost(t *testing.T) { configDir := t.TempDir() cookie := seedCompletedSetup(t, configDir) router := NewRouter(configDir) createBody := []byte(`{ "id":"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", "kind":"container", "name":"unreachable", "host_ip":"127.0.0.1", "group_name":"Administrators", "generate":{"algorithm":"ed25519"} }`) createRequest := withSession( httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(createBody)), cookie, ) createRecorder := httptest.NewRecorder() router.ServeHTTP(createRecorder, createRequest) if createRecorder.Code != http.StatusCreated { t.Fatalf("create status = %d body=%s", createRecorder.Code, createRecorder.Body.String()) } // 127.0.0.1 with no SSH listener (or auth failure) should not panic; expect ok=false. testRequest := withSession( httptest.NewRequest( http.MethodPost, "/api/v1/nodes/aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee/test-ssh", nil, ), cookie, ) testRecorder := httptest.NewRecorder() router.ServeHTTP(testRecorder, testRequest) if testRecorder.Code != http.StatusOK { t.Fatalf("test status = %d body=%s", testRecorder.Code, testRecorder.Body.String()) } var response nodeSSHTestResponse if err := json.Unmarshal(testRecorder.Body.Bytes(), &response); err != nil { t.Fatalf("decode: %v", err) } if response.OK { t.Fatal("expected SSH test to fail against localhost without matching key/auth") } if response.Message == "" { t.Fatal("expected failure message") } audit, err := settings.LoadNodeAudit(configDir) if err != nil { t.Fatalf("LoadNodeAudit: %v", err) } foundTestSSH := false for _, event := range audit.Events { if event.Action == settings.NodeAuditActionTestSSH && event.NodeID == "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" { foundTestSSH = true } } if !foundTestSSH { t.Fatal("expected test_ssh audit event") } } func TestNodesDeleteRequiresPermissionAndInlineReauth(t *testing.T) { configDir := t.TempDir() cookie := seedCompletedSetup(t, configDir) router := NewRouter(configDir) createBody := []byte(`{ "id":"bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee", "kind":"vm", "name":"vm-delete-me", "host_ip":"10.0.0.9", "group_name":"Administrators", "generate":{"algorithm":"ed25519"} }`) createRequest := withSession( httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(createBody)), cookie, ) createRecorder := httptest.NewRecorder() router.ServeHTTP(createRecorder, createRequest) if createRecorder.Code != http.StatusCreated { t.Fatalf("create status = %d body=%s", createRecorder.Code, createRecorder.Body.String()) } missingAuthRequest := withSession( httptest.NewRequest( http.MethodDelete, "/api/v1/nodes/bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee", bytes.NewReader([]byte(`{}`)), ), cookie, ) missingAuthRecorder := httptest.NewRecorder() router.ServeHTTP(missingAuthRecorder, missingAuthRequest) if missingAuthRecorder.Code != http.StatusUnauthorized { t.Fatalf("missing reauth status = %d body=%s", missingAuthRecorder.Code, missingAuthRecorder.Body.String()) } deleteBody := []byte(`{"current_password":"correct horse battery staple extra"}`) deleteRequest := withSession( httptest.NewRequest( http.MethodDelete, "/api/v1/nodes/bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee", bytes.NewReader(deleteBody), ), cookie, ) deleteRecorder := httptest.NewRecorder() router.ServeHTTP(deleteRecorder, deleteRequest) if deleteRecorder.Code != http.StatusOK { t.Fatalf("delete status = %d body=%s", deleteRecorder.Code, deleteRecorder.Body.String()) } var list nodesResponse if err := json.Unmarshal(deleteRecorder.Body.Bytes(), &list); err != nil { t.Fatalf("decode: %v", err) } for _, node := range list.Nodes { if node.ID == "bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee" { t.Fatal("deleted node still listed") } } store, err := settings.LoadNodes(configDir) if err != nil { t.Fatalf("LoadNodes: %v", err) } for _, node := range store.Nodes { if node.ID == "bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee" { t.Fatal("node still on disk") } } keyBytes, err := settings.KeyFromEnv() if err != nil { t.Fatalf("KeyFromEnv: %v", err) } keyStore, err := settings.LoadNodeKeys(configDir, keyBytes) if err != nil { t.Fatalf("LoadNodeKeys: %v", err) } for _, entry := range keyStore.Keys { if entry.NodeID == "bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee" { t.Fatal("private key still on disk") } } audit, err := settings.LoadNodeAudit(configDir) if err != nil { t.Fatalf("LoadNodeAudit: %v", err) } foundDelete := false foundCreate := false for _, event := range audit.Events { if event.NodeID != "bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee" { continue } if event.Action == settings.NodeAuditActionCreate { foundCreate = true } if event.Action == settings.NodeAuditActionDelete { foundDelete = true } } if !foundCreate || !foundDelete { t.Fatalf("audit create=%v delete=%v events=%+v", foundCreate, foundDelete, audit.Events) } } func TestNodesDeleteRejectsWithoutNodesDelete(t *testing.T) { configDir := t.TempDir() _ = seedCompletedSetup(t, configDir) keyBytes, err := settings.KeyFromEnv() if err != nil { t.Fatalf("KeyFromEnv: %v", err) } now := time.Now().UTC() passwordHash, err := auth.HashPassword("correct horse battery staple extra") if err != nil { t.Fatalf("HashPassword: %v", err) } payload := settings.Settings{ SetupCompleted: true, Groups: []settings.Group{ { Name: settings.AdministratorsGroupName, ScopeKind: settings.GroupScopeGroup, ScopeName: "Admin Group", Permissions: allPermissionList(), }, { Name: "Executors", ScopeKind: settings.GroupScopeGroup, ScopeName: "Executors", Permissions: []string{"nodes.read", "nodes.exec"}, }, }, Security: settings.DefaultSecuritySettings(), Network: settings.DefaultNetworkSettings(), } if err := settings.SaveSettings(configDir, payload); err != nil { t.Fatalf("SaveSettings: %v", err) } if err := settings.SavePasswords(configDir, settings.PasswordStore{ Users: []settings.UserCredential{ { ID: "admin-id", Username: "Admin", PasswordHash: passwordHash, Enabled: true, GroupNames: []string{settings.AdministratorsGroupName}, CreatedAt: now, PasswordChangedAt: now, }, { ID: "exec-id", Username: "executor", PasswordHash: passwordHash, Enabled: true, GroupNames: []string{"Executors"}, CreatedAt: now, PasswordChangedAt: now, }, }, }, keyBytes); err != nil { t.Fatalf("SavePasswords: %v", err) } if err := settings.SaveNodes(configDir, settings.NodeStore{ Nodes: []settings.Node{ { ID: "cccccccc-bbbb-4ccc-8ddd-eeeeeeeeeeee", Kind: settings.NodeKindContainer, Name: "ct-keep", HostIP: "10.0.0.3", Username: "clustercanvas", GroupName: "Executors", PublicKey: "ssh-ed25519 AAAA", KeyAlgo: "ed25519", CreatedAt: now, }, }, }); err != nil { t.Fatalf("SaveNodes: %v", err) } if err := settings.SaveNodeKeys(configDir, settings.NodeKeyStore{ Keys: []settings.NodeKeyEntry{ { NodeID: "cccccccc-bbbb-4ccc-8ddd-eeeeeeeeeeee", PrivateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----\n", Algorithm: "ed25519", }, }, }, keyBytes); err != nil { t.Fatalf("SaveNodeKeys: %v", err) } router := NewRouter(configDir) loginRecorder := httptest.NewRecorder() loginRequest := httptest.NewRequest( http.MethodPost, "/api/v1/auth/login", bytes.NewReader([]byte(`{"username":"executor","password":"correct horse battery staple extra"}`)), ) router.ServeHTTP(loginRecorder, loginRequest) if loginRecorder.Code != http.StatusOK { t.Fatalf("login status = %d body=%s", loginRecorder.Code, loginRecorder.Body.String()) } var execCookie *http.Cookie for _, candidate := range loginRecorder.Result().Cookies() { if candidate.Name == auth.SessionCookieName { execCookie = candidate } } if execCookie == nil { t.Fatal("missing session cookie") } deleteBody := []byte(`{"current_password":"correct horse battery staple extra"}`) request := withSession( httptest.NewRequest( http.MethodDelete, "/api/v1/nodes/cccccccc-bbbb-4ccc-8ddd-eeeeeeeeeeee", bytes.NewReader(deleteBody), ), execCookie, ) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusForbidden { t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String()) } } func TestNodeLogsListFiltersByKind(t *testing.T) { configDir := t.TempDir() cookie := seedCompletedSetup(t, configDir) router := NewRouter(configDir) for _, body := range []string{ `{"kind":"container","name":"ct-log","host_ip":"10.0.0.1","group_name":"Administrators","generate":{"algorithm":"ed25519"}}`, `{"kind":"vm","name":"vm-log","host_ip":"10.0.0.2","group_name":"Administrators","generate":{"algorithm":"ed25519"}}`, } { request := withSession( httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader([]byte(body))), cookie, ) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusCreated { t.Fatalf("create status = %d body=%s", recorder.Code, recorder.Body.String()) } } allRequest := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/node-logs", nil), cookie) allRecorder := httptest.NewRecorder() router.ServeHTTP(allRecorder, allRequest) if allRecorder.Code != http.StatusOK { t.Fatalf("logs status = %d body=%s", allRecorder.Code, allRecorder.Body.String()) } var allLogs nodeLogsResponse if err := json.Unmarshal(allRecorder.Body.Bytes(), &allLogs); err != nil { t.Fatalf("decode: %v", err) } if len(allLogs.Events) < 2 { t.Fatalf("expected at least 2 events, got %d", len(allLogs.Events)) } vmRequest := withSession( httptest.NewRequest(http.MethodGet, "/api/v1/node-logs?kind=vm", nil), cookie, ) vmRecorder := httptest.NewRecorder() router.ServeHTTP(vmRecorder, vmRequest) var vmLogs nodeLogsResponse if err := json.Unmarshal(vmRecorder.Body.Bytes(), &vmLogs); err != nil { t.Fatalf("decode: %v", err) } if len(vmLogs.Events) == 0 { t.Fatal("expected vm events") } for _, event := range vmLogs.Events { if event.NodeKind != settings.NodeKindVM { t.Fatalf("unexpected kind %q", event.NodeKind) } } }