Record auth audits and surface them in Activity, poll sidebar counts without resetting idle, and add a profile logout path plus theme red/green/blue tokens so failure badges render.
728 lines
21 KiB
Go
728 lines
21 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNodeLogsRequiresLogsRead(t *testing.T) {
|
|
configDir := t.TempDir()
|
|
cookie := seedCompletedSetup(t, configDir)
|
|
|
|
payload, err := settings.LoadSettings(configDir)
|
|
if err != nil {
|
|
t.Fatalf("LoadSettings: %v", err)
|
|
}
|
|
for index := range payload.Groups {
|
|
if payload.Groups[index].Name != settings.AdministratorsGroupName {
|
|
continue
|
|
}
|
|
filtered := make([]string, 0)
|
|
for _, permission := range payload.Groups[index].Permissions {
|
|
switch permission {
|
|
case "logs.read", "nodes.read", "users.manage":
|
|
continue
|
|
}
|
|
filtered = append(filtered, permission)
|
|
}
|
|
payload.Groups[index].Permissions = filtered
|
|
}
|
|
if err := settings.SaveSettings(configDir, payload); err != nil {
|
|
t.Fatalf("SaveSettings: %v", err)
|
|
}
|
|
|
|
router := NewRouter(configDir)
|
|
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/node-logs", nil), cookie)
|
|
recorder := httptest.NewRecorder()
|
|
router.ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusForbidden {
|
|
t.Fatalf("expected %d, got %d body=%s", http.StatusForbidden, recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestNodeLogsAllowsLogsReadWithoutNodesRead(t *testing.T) {
|
|
configDir := t.TempDir()
|
|
cookie := seedCompletedSetup(t, configDir)
|
|
|
|
payload, err := settings.LoadSettings(configDir)
|
|
if err != nil {
|
|
t.Fatalf("LoadSettings: %v", err)
|
|
}
|
|
for index := range payload.Groups {
|
|
if payload.Groups[index].Name != settings.AdministratorsGroupName {
|
|
continue
|
|
}
|
|
payload.Groups[index].Permissions = []string{"logs.read"}
|
|
}
|
|
if err := settings.SaveSettings(configDir, payload); err != nil {
|
|
t.Fatalf("SaveSettings: %v", err)
|
|
}
|
|
|
|
if err := settings.AppendNodeAuditEvent(configDir, settings.NodeAuditEvent{
|
|
ID: "evt-1",
|
|
At: time.Now().UTC(),
|
|
Action: settings.NodeAuditActionCreate,
|
|
Actor: "Admin",
|
|
NodeID: "node-1",
|
|
NodeName: "test-node",
|
|
NodeKind: settings.NodeKindContainer,
|
|
}); err != nil {
|
|
t.Fatalf("AppendNodeAuditEvent: %v", err)
|
|
}
|
|
|
|
router := NewRouter(configDir)
|
|
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/node-logs", nil), cookie)
|
|
recorder := httptest.NewRecorder()
|
|
router.ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("expected %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
|
|
}
|
|
var logs nodeLogsResponse
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &logs); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(logs.Events) != 1 {
|
|
t.Fatalf("expected 1 event, got %d", len(logs.Events))
|
|
}
|
|
}
|