fix: align live flow contracts and preserve existing result state
Closes #9
This commit is contained in:
@@ -8,6 +8,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newCollectTestServer() (*Server, *httptest.Server) {
|
func newCollectTestServer() (*Server, *httptest.Server) {
|
||||||
@@ -140,6 +142,85 @@ func TestCollectNotFoundAndSecretLeak(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCollectStartPreservesCurrentResultUntilSuccess(t *testing.T) {
|
||||||
|
s, ts := newCollectTestServer()
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
existing := &models.AnalysisResult{
|
||||||
|
Filename: "archive.tar.gz",
|
||||||
|
SourceType: models.SourceTypeArchive,
|
||||||
|
CollectedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
s.SetResult(existing)
|
||||||
|
|
||||||
|
body := `{"host":"bmc-success.local","protocol":"redfish","port":443,"username":"admin","auth_type":"password","password":"secret","tls_mode":"strict"}`
|
||||||
|
resp, err := http.Post(ts.URL+"/api/collect", "application/json", bytes.NewBufferString(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("post collect failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var created CollectJobResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
|
||||||
|
t.Fatalf("decode create response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
current := s.GetResult()
|
||||||
|
if current != existing {
|
||||||
|
t.Fatalf("expected current result to stay unchanged before success")
|
||||||
|
}
|
||||||
|
|
||||||
|
status := waitForTerminalStatus(t, ts.URL, created.JobID, 4*time.Second)
|
||||||
|
if status.Status != CollectStatusSuccess {
|
||||||
|
t.Fatalf("expected success, got %q", status.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
finalResult := s.GetResult()
|
||||||
|
if finalResult == nil {
|
||||||
|
t.Fatalf("expected result to be set on success")
|
||||||
|
}
|
||||||
|
if finalResult.SourceType != models.SourceTypeAPI {
|
||||||
|
t.Fatalf("expected api source type after success, got %q", finalResult.SourceType)
|
||||||
|
}
|
||||||
|
if finalResult.TargetHost != "bmc-success.local" {
|
||||||
|
t.Fatalf("expected target host to be updated, got %q", finalResult.TargetHost)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectFailedDoesNotOverwriteCurrentResult(t *testing.T) {
|
||||||
|
s, ts := newCollectTestServer()
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
existing := &models.AnalysisResult{
|
||||||
|
Filename: "still-archive.tar.gz",
|
||||||
|
SourceType: models.SourceTypeArchive,
|
||||||
|
CollectedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
s.SetResult(existing)
|
||||||
|
|
||||||
|
body := `{"host":"contains-fail.local","protocol":"redfish","port":443,"username":"admin","auth_type":"password","password":"secret","tls_mode":"strict"}`
|
||||||
|
resp, err := http.Post(ts.URL+"/api/collect", "application/json", bytes.NewBufferString(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("post collect failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var created CollectJobResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
|
||||||
|
t.Fatalf("decode create response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status := waitForTerminalStatus(t, ts.URL, created.JobID, 4*time.Second)
|
||||||
|
if status.Status != CollectStatusFailed {
|
||||||
|
t.Fatalf("expected failed, got %q", status.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
finalResult := s.GetResult()
|
||||||
|
if finalResult != existing {
|
||||||
|
t.Fatalf("expected existing result to remain on failed job")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func waitForTerminalStatus(t *testing.T, baseURL, jobID string, timeout time.Duration) CollectJobStatusResponse {
|
func waitForTerminalStatus(t *testing.T, baseURL, jobID string, timeout time.Duration) CollectJobStatusResponse {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
deadline := time.Now().Add(timeout)
|
deadline := time.Now().Add(timeout)
|
||||||
|
|||||||
@@ -592,8 +592,6 @@ func (s *Server) handleCollectStart(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
job := s.jobManager.CreateJob(req)
|
job := s.jobManager.CreateJob(req)
|
||||||
s.SetResult(newAPIResult(req))
|
|
||||||
s.SetDetectedVendor("")
|
|
||||||
s.startMockCollectionJob(job.ID, req)
|
s.startMockCollectionJob(job.ID, req)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -681,6 +679,8 @@ func (s *Server) startMockCollectionJob(jobID string, req CollectRequest) {
|
|||||||
|
|
||||||
s.jobManager.UpdateJobStatus(jobID, CollectStatusSuccess, 100, "")
|
s.jobManager.UpdateJobStatus(jobID, CollectStatusSuccess, 100, "")
|
||||||
s.jobManager.AppendJobLog(jobID, "Сбор завершен")
|
s.jobManager.AppendJobLog(jobID, "Сбор завершен")
|
||||||
|
s.SetResult(newAPIResult(req))
|
||||||
|
s.SetDetectedVendor("")
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ let collectionJobPollTimer = null;
|
|||||||
let collectionJobScenario = [];
|
let collectionJobScenario = [];
|
||||||
let collectionJobScenarioIndex = 0;
|
let collectionJobScenarioIndex = 0;
|
||||||
let collectionJobLogCounter = 0;
|
let collectionJobLogCounter = 0;
|
||||||
|
let apiPortTouchedByUser = false;
|
||||||
|
let isAutoUpdatingApiPort = false;
|
||||||
|
|
||||||
function initSourceType() {
|
function initSourceType() {
|
||||||
const sourceButtons = document.querySelectorAll('.source-switch-btn');
|
const sourceButtons = document.querySelectorAll('.source-switch-btn');
|
||||||
@@ -49,7 +51,7 @@ function initApiSource() {
|
|||||||
|
|
||||||
const authTypeField = document.getElementById('api-auth-type');
|
const authTypeField = document.getElementById('api-auth-type');
|
||||||
const cancelJobButton = document.getElementById('cancel-job-btn');
|
const cancelJobButton = document.getElementById('cancel-job-btn');
|
||||||
const fieldNames = ['host', 'protocol', 'port', 'username', 'authType', 'password', 'token'];
|
const fieldNames = ['host', 'protocol', 'port', 'username', 'auth_type', 'tls_mode', 'password', 'token'];
|
||||||
|
|
||||||
apiForm.addEventListener('submit', (event) => {
|
apiForm.addEventListener('submit', (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -65,7 +67,6 @@ function initApiSource() {
|
|||||||
apiConnectPayload = payload;
|
apiConnectPayload = payload;
|
||||||
renderApiConnectStatus(true, payload);
|
renderApiConnectStatus(true, payload);
|
||||||
startCollectionJob(payload);
|
startCollectionJob(payload);
|
||||||
console.log('API payload prepared:', apiConnectPayload);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (cancelJobButton) {
|
if (cancelJobButton) {
|
||||||
@@ -82,9 +83,15 @@ function initApiSource() {
|
|||||||
|
|
||||||
const eventName = field.tagName.toLowerCase() === 'select' ? 'change' : 'input';
|
const eventName = field.tagName.toLowerCase() === 'select' ? 'change' : 'input';
|
||||||
field.addEventListener(eventName, () => {
|
field.addEventListener(eventName, () => {
|
||||||
if (fieldName === 'authType') {
|
if (fieldName === 'auth_type') {
|
||||||
toggleApiAuthFields(authTypeField.value);
|
toggleApiAuthFields(authTypeField.value);
|
||||||
}
|
}
|
||||||
|
if (fieldName === 'protocol') {
|
||||||
|
applyProtocolDefaultPort(field.value);
|
||||||
|
}
|
||||||
|
if (fieldName === 'port') {
|
||||||
|
handleApiPortInput(field.value);
|
||||||
|
}
|
||||||
|
|
||||||
const { errors } = validateCollectForm();
|
const { errors } = validateCollectForm();
|
||||||
renderFormErrors(errors);
|
renderFormErrors(errors);
|
||||||
@@ -96,6 +103,7 @@ function initApiSource() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
applyProtocolDefaultPort(getApiValue('protocol'));
|
||||||
toggleApiAuthFields(authTypeField.value);
|
toggleApiAuthFields(authTypeField.value);
|
||||||
renderCollectionJob();
|
renderCollectionJob();
|
||||||
}
|
}
|
||||||
@@ -105,7 +113,8 @@ function validateCollectForm() {
|
|||||||
const protocol = getApiValue('protocol');
|
const protocol = getApiValue('protocol');
|
||||||
const portRaw = getApiValue('port');
|
const portRaw = getApiValue('port');
|
||||||
const username = getApiValue('username');
|
const username = getApiValue('username');
|
||||||
const authType = getApiValue('authType');
|
const authType = getApiValue('auth_type');
|
||||||
|
const tlsMode = getApiValue('tls_mode');
|
||||||
const password = getApiValue('password');
|
const password = getApiValue('password');
|
||||||
const token = getApiValue('token');
|
const token = getApiValue('token');
|
||||||
|
|
||||||
@@ -132,7 +141,10 @@ function validateCollectForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!['password', 'token'].includes(authType)) {
|
if (!['password', 'token'].includes(authType)) {
|
||||||
errors.authType = 'Выберите тип авторизации.';
|
errors.auth_type = 'Выберите тип авторизации.';
|
||||||
|
}
|
||||||
|
if (!['strict', 'insecure'].includes(tlsMode)) {
|
||||||
|
errors.tls_mode = 'Выберите TLS режим.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authType === 'password' && !password) {
|
if (authType === 'password' && !password) {
|
||||||
@@ -148,20 +160,18 @@ function validateCollectForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
sourceType: 'api',
|
host,
|
||||||
connection: {
|
protocol,
|
||||||
host,
|
port,
|
||||||
protocol,
|
username,
|
||||||
port,
|
auth_type: authType,
|
||||||
username,
|
tls_mode: tlsMode
|
||||||
authType
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (authType === 'password') {
|
if (authType === 'password') {
|
||||||
payload.connection.password = password;
|
payload.password = password;
|
||||||
} else {
|
} else {
|
||||||
payload.connection.token = token;
|
payload.token = token;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { isValid: true, errors: {}, payload };
|
return { isValid: true, errors: {}, payload };
|
||||||
@@ -174,7 +184,7 @@ function renderFormErrors(errors) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorFields = ['host', 'protocol', 'port', 'username', 'authType', 'password', 'token'];
|
const errorFields = ['host', 'protocol', 'port', 'username', 'auth_type', 'tls_mode', 'password', 'token'];
|
||||||
errorFields.forEach((fieldName) => {
|
errorFields.forEach((fieldName) => {
|
||||||
const errorNode = apiForm.querySelector(`[data-error-for="${fieldName}"]`);
|
const errorNode = apiForm.querySelector(`[data-error-for="${fieldName}"]`);
|
||||||
if (!errorNode) {
|
if (!errorNode) {
|
||||||
@@ -212,12 +222,12 @@ function renderApiConnectStatus(isValid, payload) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payloadPreview = { ...payload, connection: { ...payload.connection } };
|
const payloadPreview = { ...payload };
|
||||||
if (payloadPreview.connection.password) {
|
if (payloadPreview.password) {
|
||||||
payloadPreview.connection.password = '***';
|
payloadPreview.password = '***';
|
||||||
}
|
}
|
||||||
if (payloadPreview.connection.token) {
|
if (payloadPreview.token) {
|
||||||
payloadPreview.connection.token = '***';
|
payloadPreview.token = '***';
|
||||||
}
|
}
|
||||||
|
|
||||||
status.textContent = `Payload сформирован: ${JSON.stringify(payloadPreview)}`;
|
status.textContent = `Payload сформирован: ${JSON.stringify(payloadPreview)}`;
|
||||||
@@ -408,6 +418,43 @@ function toggleApiAuthFields(authType) {
|
|||||||
tokenField.classList.toggle('hidden', !useToken);
|
tokenField.classList.toggle('hidden', !useToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyProtocolDefaultPort(protocol) {
|
||||||
|
const defaults = {
|
||||||
|
redfish: '443',
|
||||||
|
ipmi: '623'
|
||||||
|
};
|
||||||
|
const defaultPort = defaults[protocol];
|
||||||
|
if (!defaultPort) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiForm = document.getElementById('api-connect-form');
|
||||||
|
if (!apiForm) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const portField = apiForm.elements.namedItem('port');
|
||||||
|
if (!portField || typeof portField.value !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentValue = portField.value.trim();
|
||||||
|
if (apiPortTouchedByUser && currentValue !== '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAutoUpdatingApiPort = true;
|
||||||
|
portField.value = defaultPort;
|
||||||
|
isAutoUpdatingApiPort = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleApiPortInput(value) {
|
||||||
|
if (isAutoUpdatingApiPort) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apiPortTouchedByUser = value.trim() !== '';
|
||||||
|
}
|
||||||
|
|
||||||
function getApiValue(fieldName) {
|
function getApiValue(fieldName) {
|
||||||
const apiForm = document.getElementById('api-connect-form');
|
const apiForm = document.getElementById('api-connect-form');
|
||||||
if (!apiForm) {
|
if (!apiForm) {
|
||||||
|
|||||||
@@ -66,12 +66,22 @@
|
|||||||
|
|
||||||
<label class="api-form-field" for="api-auth-type">
|
<label class="api-form-field" for="api-auth-type">
|
||||||
<span>Тип авторизации</span>
|
<span>Тип авторизации</span>
|
||||||
<select id="api-auth-type" name="authType">
|
<select id="api-auth-type" name="auth_type">
|
||||||
<option value="">Выберите тип</option>
|
<option value="">Выберите тип</option>
|
||||||
<option value="password">Пароль</option>
|
<option value="password">Пароль</option>
|
||||||
<option value="token">Токен</option>
|
<option value="token">Токен</option>
|
||||||
</select>
|
</select>
|
||||||
<span class="field-error" data-error-for="authType"></span>
|
<span class="field-error" data-error-for="auth_type"></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="api-form-field" for="api-tls-mode">
|
||||||
|
<span>TLS режим</span>
|
||||||
|
<select id="api-tls-mode" name="tls_mode">
|
||||||
|
<option value="">Выберите режим</option>
|
||||||
|
<option value="strict">Strict (проверка сертификата)</option>
|
||||||
|
<option value="insecure">Insecure (без проверки сертификата)</option>
|
||||||
|
</select>
|
||||||
|
<span class="field-error" data-error-for="tls_mode"></span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="api-form-field" id="api-password-field" for="api-password">
|
<label class="api-form-field" id="api-password-field" for="api-password">
|
||||||
|
|||||||
Reference in New Issue
Block a user