71 changed files with 1121 additions and 409 deletions
@ -0,0 +1,57 @@ |
|||
package mock |
|||
|
|||
import ( |
|||
"context" |
|||
"errors" |
|||
"strings" |
|||
|
|||
"tera-clone/services/tera-api/internal/domain" |
|||
) |
|||
|
|||
// VerifyCurrentPassword reauthenticates the session actor without creating or
|
|||
// replacing a browser session. The caller cannot nominate another account.
|
|||
func (s *Store) VerifyCurrentPassword(ctx context.Context, credential string) error { |
|||
actor, ok := domain.ActorFromContext(ctx) |
|||
if !ok || actor.Name == "" { |
|||
return errors.New("authentication required") |
|||
} |
|||
s.mu.Lock() |
|||
username := "" |
|||
var user authUserRecord |
|||
for name, candidate := range s.authUsers { |
|||
if candidate.Actor == actor.Name { |
|||
username, user = name, candidate |
|||
break |
|||
} |
|||
} |
|||
external := username == "" && strings.HasPrefix(actor.Name, "external-") |
|||
if external { |
|||
username = strings.TrimPrefix(actor.Name, "external-") |
|||
} |
|||
now := s.clock().UTC() |
|||
locked, _ := s.loginLockedLocked(username, now) |
|||
config := cloneExternalAuth(s.runningExternalAuth) |
|||
verified := false |
|||
if !locked && !external && username != "" && !user.Disabled && credential != "" { |
|||
verified, _ = s.passwordKDFs.Verify(user.Password, credential) |
|||
} |
|||
s.mu.Unlock() |
|||
if external && !locked && credential != "" { |
|||
authenticated, accepted, _ := s.authenticateExternal(ctx, config, username, credential) |
|||
verified = accepted && authenticated.Name == actor.Name |
|||
} |
|||
s.mu.Lock() |
|||
defer s.mu.Unlock() |
|||
if !verified { |
|||
if !locked { |
|||
s.recordLoginFailureLocked(username, now) |
|||
} |
|||
s.recordAuditLocked(ctx, "auth.reauthenticate", actor.Name, "failed", "current account password verification failed") |
|||
if err := s.persistLoginFailureStoreLocked(); err != nil { |
|||
return err |
|||
} |
|||
return errors.New("current account password is incorrect or account is locked") |
|||
} |
|||
delete(s.loginFailures, username) |
|||
return s.persistLoginFailureStoreLocked() |
|||
} |
|||
@ -0,0 +1,36 @@ |
|||
package mock |
|||
|
|||
import ( |
|||
"context" |
|||
"testing" |
|||
|
|||
"tera-clone/services/tera-api/internal/domain" |
|||
) |
|||
|
|||
func TestReauthenticationUsesSessionAccountAndPreservesSession(t *testing.T) { |
|||
store := NewStore(fixedClock) |
|||
defer store.Close() |
|||
force := false |
|||
_, err := store.CreateAuthUser(context.Background(), domain.AuthUserCreateRequest{Username: "second-admin", Password: "second-admin-secret", Role: "administrator", ForceChange: &force}) |
|||
if err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
session, err := store.Login(context.Background(), domain.AuthLoginRequest{Username: "second-admin", Password: "second-admin-secret"}) |
|||
if err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
actor, err := store.ActorBySession(context.Background(), session.SessionToken) |
|||
if err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
ctx := domain.WithActor(context.Background(), actor) |
|||
if err := store.VerifyCurrentPassword(ctx, "mock-admin-secret"); err == nil { |
|||
t.Fatal("another administrator's password accepted") |
|||
} |
|||
if err := store.VerifyCurrentPassword(ctx, "second-admin-secret"); err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
if _, err := store.ActorBySession(context.Background(), session.SessionToken); err != nil { |
|||
t.Fatalf("reauthentication replaced active session: %v", err) |
|||
} |
|||
} |
|||
@ -0,0 +1,33 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"fmt" |
|||
"strings" |
|||
|
|||
"tera-clone/services/tera-api/internal/domain" |
|||
"tera-clone/services/tera-api/internal/logpolicy" |
|||
) |
|||
|
|||
func portLogDisplay(event domain.PortLogEvent) domain.PortLogEvent { |
|||
if len(event.Payload) == 0 { |
|||
return event |
|||
} |
|||
text, redacted := logpolicy.SanitizeMultiline(strings.ToValidUTF8(string(event.Payload), "�"), logpolicy.MaxSerialPayloadBytes) |
|||
event.Redacted = event.Redacted || redacted |
|||
bytes := event.Payload |
|||
if event.Redacted { |
|||
bytes = []byte(text) |
|||
} |
|||
ascii := strings.ReplaceAll(strings.ReplaceAll(text, "\r\n", "\n"), "\r", "\n") |
|||
switch event.DisplayFormat { |
|||
case "hex": |
|||
event.DisplayText = fmt.Sprintf("% X", bytes) |
|||
case "ascii_hex": |
|||
event.DisplayText = "ASCII\n" + ascii + "\nHEX\n" + fmt.Sprintf("% X", bytes) |
|||
default: |
|||
event.DisplayText = ascii |
|||
} |
|||
// Raw bytes remain in the journal; API responses expose only the sanitized view.
|
|||
event.Payload = nil |
|||
return event |
|||
} |
|||
@ -0,0 +1,25 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"strings" |
|||
"tera-clone/services/tera-api/internal/domain" |
|||
"testing" |
|||
) |
|||
|
|||
func TestPortLogDisplayPreservesASCIIAndUppercasesHEX(t *testing.T) { |
|||
event := domain.PortLogEvent{Payload: []byte("MiXeD\r\nnext\rline"), DisplayFormat: "ascii"} |
|||
result := portLogDisplay(event) |
|||
if result.DisplayText != "MiXeD\nnext\nline" || result.Payload != nil { |
|||
t.Fatalf("display: %+v", result) |
|||
} |
|||
event.Payload = []byte{0xab, 0xcd, 0xef} |
|||
event.DisplayFormat = "hex" |
|||
if result = portLogDisplay(event); result.DisplayText != "AB CD EF" { |
|||
t.Fatal(result.DisplayText) |
|||
} |
|||
event.Payload = []byte("password=supersecret") |
|||
event.DisplayFormat = "ascii" |
|||
if result = portLogDisplay(event); strings.Contains(result.DisplayText, "supersecret") || !result.Redacted { |
|||
t.Fatalf("secret not redacted: %+v", result) |
|||
} |
|||
} |
|||
@ -0,0 +1,19 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"context" |
|||
"net/http" |
|||
"tera-clone/services/tera-api/internal/domain" |
|||
) |
|||
|
|||
func (r *Router) authorizePortLogSettings(w http.ResponseWriter, ctx context.Context, update domain.PortUpdate) bool { |
|||
if update.DataLog == nil && update.DataLogMode == nil { |
|||
return true |
|||
} |
|||
actor, ok := domain.ActorFromContext(ctx) |
|||
if !ok || actor.Role != "administrator" { |
|||
writeError(w, http.StatusForbidden, "authorization_denied", "port log settings require administrator role") |
|||
return false |
|||
} |
|||
return true |
|||
} |
|||
@ -0,0 +1,18 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"context" |
|||
"net/http" |
|||
) |
|||
|
|||
func (r *Router) verifyCurrentPassword(w http.ResponseWriter, ctx context.Context, password string) bool { |
|||
if password == "" { |
|||
writeError(w, http.StatusBadRequest, "password_required", "enter the current account password") |
|||
return false |
|||
} |
|||
if err := r.store.VerifyCurrentPassword(ctx, password); err != nil { |
|||
writeError(w, http.StatusForbidden, "reauthentication_failed", "current account password is incorrect or account is locked") |
|||
return false |
|||
} |
|||
return true |
|||
} |
|||
@ -0,0 +1,42 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"net/http" |
|||
"net/http/httptest" |
|||
"strings" |
|||
"testing" |
|||
|
|||
"tera-clone/services/tera-api/internal/adapters/mock" |
|||
) |
|||
|
|||
func TestDestructiveActionsRequireCurrentAccountPassword(t *testing.T) { |
|||
for _, path := range []string{"/api/v1/logs/system/clear", "/api/v1/logs/links/clear", "/api/v1/logs/port/clear", "/api/v1/system/factory-reset"} { |
|||
t.Run(path, func(t *testing.T) { |
|||
store := mock.NewStore(fixedClock) |
|||
defer store.Close() |
|||
handler := NewRouter(store) |
|||
body := `{"password":"wrong"}` |
|||
if strings.Contains(path, "/port/") { |
|||
body = `{"password":"wrong","channel":"ALL"}` |
|||
} |
|||
if strings.Contains(path, "factory-reset") { |
|||
body = `{"password":"wrong","confirmed":true}` |
|||
} |
|||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) |
|||
req.Header.Set("X-Mock-Actor", "mock-admin") |
|||
response := httptest.NewRecorder() |
|||
handler.ServeHTTP(response, req) |
|||
if response.Code != http.StatusForbidden { |
|||
t.Fatalf("wrong password accepted: %d %s", response.Code, response.Body.String()) |
|||
} |
|||
body = strings.Replace(body, "wrong", "mock-admin-secret", 1) |
|||
req = httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) |
|||
req.Header.Set("X-Mock-Actor", "mock-admin") |
|||
response = httptest.NewRecorder() |
|||
handler.ServeHTTP(response, req) |
|||
if response.Code != http.StatusOK { |
|||
t.Fatalf("correct password rejected: %d %s", response.Code, response.Body.String()) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
@ -0,0 +1,33 @@ |
|||
package logstorage |
|||
|
|||
import ( |
|||
"strings" |
|||
"testing" |
|||
"time" |
|||
) |
|||
|
|||
func TestAuditCapacityRotatesOldestActiveSegment(t *testing.T) { |
|||
store := openTestStore(t, Options{AuditCapBytes: 1400}) |
|||
for i := 0; i < 5; i++ { |
|||
if _, err := store.Append(ClassAudit, time.Now().Add(time.Duration(i)*time.Second), map[string]string{"message": strings.Repeat(string(rune('a'+i)), 400)}); err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
} |
|||
snapshot, err := store.Snapshot() |
|||
if err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
if snapshot.UsageBytes["audit"] > 1400 { |
|||
t.Fatalf("capacity exceeded: %+v", snapshot) |
|||
} |
|||
var messages []string |
|||
store.mu.Lock() |
|||
err = store.walkClassLocked(ClassAudit, false, func(event Envelope) error { messages = append(messages, string(event.Payload)); return nil }) |
|||
store.mu.Unlock() |
|||
if err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
if len(messages) == 0 || strings.Contains(strings.Join(messages, ""), strings.Repeat("a", 100)) || !strings.Contains(messages[len(messages)-1], strings.Repeat("e", 100)) { |
|||
t.Fatalf("rotation retained wrong records: %v", messages) |
|||
} |
|||
} |
|||
@ -0,0 +1,26 @@ |
|||
//go:build linux
|
|||
|
|||
package portservice |
|||
|
|||
import ( |
|||
"context" |
|||
"net" |
|||
"syscall" |
|||
|
|||
"golang.org/x/sys/unix" |
|||
) |
|||
|
|||
// Bind the socket to the physical interface, including before DHCP assigns an
|
|||
// address. A wildcard listener must never expose operation 1 on eth1 or vice versa.
|
|||
func ListenOnInterface(device, address string) (net.Listener, error) { |
|||
config := net.ListenConfig{Control: func(_, _ string, raw syscall.RawConn) error { |
|||
var socketErr error |
|||
if err := raw.Control(func(fd uintptr) { |
|||
socketErr = unix.SetsockoptString(int(fd), unix.SOL_SOCKET, unix.SO_BINDTODEVICE, device) |
|||
}); err != nil { |
|||
return err |
|||
} |
|||
return socketErr |
|||
}} |
|||
return config.Listen(context.Background(), "tcp4", address) |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
//go:build !linux
|
|||
|
|||
package portservice |
|||
|
|||
import ( |
|||
"fmt" |
|||
"net" |
|||
) |
|||
|
|||
func ListenOnInterface(device, address string) (net.Listener, error) { |
|||
return nil, fmt.Errorf("physical interface binding for %s requires Linux", device) |
|||
} |
|||
@ -0,0 +1,52 @@ |
|||
package portservice |
|||
|
|||
import ( |
|||
"testing" |
|||
|
|||
"tera-clone/services/tera-api/internal/domain" |
|||
) |
|||
|
|||
func TestOperationsShareSessionLimitAndStableIPSlots(t *testing.T) { |
|||
pool := &sessionPool{} |
|||
ssh := &service{config: serviceConfig{operation: domain.PortOperation{MaxClients: 2}}, pool: pool} |
|||
telnet := &service{config: serviceConfig{operation: domain.PortOperation{MaxClients: 2}}, pool: pool} |
|||
first, ok := beginTerminalSession(ssh, "192.168.0.100:12345") |
|||
if !ok { |
|||
t.Fatal("first SSH session rejected") |
|||
} |
|||
second, ok := beginTerminalSession(telnet, "192.168.200.100:23456") |
|||
if !ok { |
|||
t.Fatal("second Telnet session rejected") |
|||
} |
|||
if _, ok := beginTerminalSession(telnet, "192.168.200.101:34567"); ok { |
|||
t.Fatal("combined limit exceeded") |
|||
} |
|||
if slots := pool.snapshot(); slots[0] != "192.168.0.100" || slots[1] != "192.168.200.100" { |
|||
t.Fatalf("slots = %v", slots) |
|||
} |
|||
first() |
|||
first() |
|||
third, ok := beginTerminalSession(telnet, "[2001:db8::100]:45678") |
|||
if !ok { |
|||
t.Fatal("released slot not reusable") |
|||
} |
|||
if slots := pool.snapshot(); slots[0] != "2001:db8::100" || slots[1] != "192.168.200.100" { |
|||
t.Fatalf("stable slots = %v", slots) |
|||
} |
|||
third() |
|||
second() |
|||
if ssh.clients.Load() != 0 || telnet.clients.Load() != 0 { |
|||
t.Fatal("session counter leaked") |
|||
} |
|||
} |
|||
|
|||
func TestOperationTwoUsesOperationOneLimitAndSeparateInterfacePort(t *testing.T) { |
|||
ports := []domain.Port{{Channel: "CH20", Operation1: domain.PortOperation{Mode: "reverse_ssh", Port: 10020, MaxClients: 2}, Operation2: domain.PortOperation{Mode: "reverse_telnet", Port: 10020, MaxClients: 4}}} |
|||
configs, err := operationConfigs(ports) |
|||
if err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
if len(configs) != 2 || configs[0].operation.MaxClients != 2 || configs[1].operation.MaxClients != 2 { |
|||
t.Fatalf("configs = %+v", configs) |
|||
} |
|||
} |
|||
@ -0,0 +1,23 @@ |
|||
package serial |
|||
|
|||
import "time" |
|||
|
|||
// Allow several normal four-second sweeps to recover from a slow/noisy reply.
|
|||
// A CR timeout alone cannot establish physical cable disconnection.
|
|||
const CRResponseGracePeriod = 15 * time.Second |
|||
|
|||
func CRConnectionStatus(probe CRProbeState, now time.Time) string { |
|||
if !probe.Enabled { |
|||
return "unknown" |
|||
} |
|||
if probe.Up { |
|||
return "up" |
|||
} |
|||
if probe.EverUp && probe.LastResponseAt != nil { |
|||
age := now.Sub(*probe.LastResponseAt) |
|||
if age >= 0 && age <= CRResponseGracePeriod { |
|||
return "up" |
|||
} |
|||
} |
|||
return "unknown" |
|||
} |
|||
@ -0,0 +1,34 @@ |
|||
package serial |
|||
|
|||
import ( |
|||
"testing" |
|||
"time" |
|||
) |
|||
|
|||
func TestCRConnectionStatusToleratesTransientMissWithoutInventingCableDown(t *testing.T) { |
|||
now := time.Date(2026, 9, 14, 1, 0, 0, 0, time.UTC) |
|||
probe := CRProbeState{Enabled: true, EverUp: true, LastResponseAt: &now} |
|||
for _, state := range []string{CRProbeStateNoResponse, CRProbeStateBusy, CRProbeStateSuppressed, CRProbeStateError} { |
|||
probe.State = state |
|||
for _, age := range []time.Duration{0, 4 * time.Second, CRResponseGracePeriod} { |
|||
if got := CRConnectionStatus(probe, now.Add(age)); got != "up" { |
|||
t.Fatalf("transient %s at %s = %s", state, age, got) |
|||
} |
|||
} |
|||
if got := CRConnectionStatus(probe, now.Add(CRResponseGracePeriod+time.Millisecond)); got != "unknown" { |
|||
t.Fatalf("stale %s = %s; no response cannot prove cable down", state, got) |
|||
} |
|||
} |
|||
probe.EverUp = false |
|||
if CRConnectionStatus(probe, now) != "unknown" { |
|||
t.Fatal("unconfirmed response promoted") |
|||
} |
|||
probe.Up = true |
|||
if CRConnectionStatus(probe, now) != "up" { |
|||
t.Fatal("confirmed reply ignored") |
|||
} |
|||
probe.Enabled = false |
|||
if CRConnectionStatus(probe, now) != "unknown" { |
|||
t.Fatal("disabled probe promoted") |
|||
} |
|||
} |
|||
@ -0,0 +1,40 @@ |
|||
# 2026-09-14 수정본 확인 절차 |
|||
|
|||
**현재 상태: 192.168.0.223 장비 배포 완료, 준비 상태 12/12 및 바이너리 해시 일치 확인.** 대시보드 위치는 사용자 정정에 따라 1~48번으로 고정했습니다. 아래 절차로 실제 기능을 확인합니다. 기존 문서의 포트 고급 설정, 별칭 위치, 로그 삭제 확인 문구는 이 문서로 대체합니다. |
|||
|
|||
현재 환경의 기준은 관리 주소 `192.168.0.223`, PC `192.168.0.100`, 외부 장비가 연결된 CH20입니다. eth1의 `192.168.200.4`는 이전 DHCP 주소이므로 적용 전 네트워크 화면에서 다시 확인합니다. COM4는 TERA 관리 콘솔이며 CH20 송수신 시험 창과 다릅니다. |
|||
|
|||
| 기능 | UI 설정·시험 | 확인할 결과 | |
|||
|---|---|---| |
|||
| 별칭 | **포트 상태 → 포트 별칭**에서 CH20 선택 → 구분 가능한 긴 이름 입력 → 변경 | 다시 CH20을 선택해 같은 이름 확인. 대시보드 포트 위치는 1~48번으로 고정되고 채널·상태가 같은 줄에 표시. 아래 별칭은 넘칠 때 `…`, 마우스를 올리면 전체 이름 표시 | |
|||
| 상태·이벤트 | 포트 상태 표와 **로그 → 포트 상태 이벤트** 확인 | 별칭 열과 이벤트의 채널 옆 별칭 표시. 링크 변화가 없으면 새 이벤트가 생기지 않아도 정상 | |
|||
| 설정 변경 | **포트 설정**에서 다른 채널의 개별 보기 선택 → 해당 채널 설정 변경 | 기존 채널의 설정을 PATCH로 변경. 개별 보기로 이동하면 이전의 다중 변경 대상이 따라붙지 않음. 여러 채널 적용은 ‘변경 대상’을 명시적으로 선택 | |
|||
| 포트 설정 탭 | Port 1 설정을 열고 포트 선택에서 26 클릭 | 기존 탭의 같은 위치에서 ‘포트 26 설정’으로 바뀌며 탭이 추가되지 않음. 새로고침해도 포트 설정 탭은 하나만 유지 | |
|||
| 변경 대상 체크박스 | 확대된 카드에서 ‘변경 대상’ 옆 체크박스를 선택·해제 | 체크와 카드 선택색이 함께 바뀜. 카드 위쪽 숫자는 개별 설정 전환, 아래 체크박스는 여러 포트의 변경 대상 선택 | |
|||
| 순간적인 응답 누락 | CH20·CH26을 연결한 채 대시보드와 상태 이벤트를 관찰 | 마지막 CR 응답 후 15초 이내의 일시적인 누락은 UP 유지. 계속 응답이 없으면 Unknown(미확인). CR 무응답만으로 케이블이 끊겼다고 판단하거나 DOWN 알림을 발생시키지 않음 | |
|||
| 동작 1 | **포트 설정 → CH20 → 동작 설정 1 · eth0**에서 Reverse SSH, 최대 세션 2 설정 | Listen Port는 `10020` 고정. PC PuTTY에서 관리 주소 `192.168.0.223`, SSH, 포트 `10020`으로 접속. 장비에 등록된 로그인 계정 사용 | |
|||
| 동작 2 | **동작 설정 2 · eth1**에서 Reverse Telnet 설정 | Listen Port `10020`, 최대 세션 2가 읽기 전용으로 표시. 두 설정을 합쳐 2개까지 접속 가능 | |
|||
| 합산 제한 | 위 SSH 연결을 유지하고 eth1 주소의 `10020`에 Telnet으로 접속. 그 뒤 세 번째 연결 시도 | 앞의 두 연결은 유지되고 세 번째는 `Too many clients`로 거절. 포트 상태에서 세션 1·2 IP 확인. 하나를 종료하면 해당 칸이 비워지고 다시 연결 가능 | |
|||
| eth1 접근 조건 | PC에서 현재 eth1 주소까지 통신 가능한지 먼저 확인 | 지금 PC가 관리망에만 연결돼 있고 eth1 경로가 없으면 SSH/Telnet 혼합 시험은 아직 수행할 수 없음. 이것을 세션 제한 실패로 판정하지 않음. 관리망의 SSH 두 창으로 총 세션·IP 표시부터 시험 가능 | |
|||
| 수신 로그 | **CH20 → 데이터 로그**에서 Receive 기록, ASCII 선택. CH20 상대 장비의 기존 인터페이스 입력 화면에서 `d` 입력 | `Invalid interface`가 실제 수신되어 포트 로그에 표시. 줄바꿈과 ASCII 대소문자 유지. HEX로 바꾸고 같은 동작을 반복하면 A~F 대문자 표시 | |
|||
| 키워드 알림 | **기타 설정 → 알림 설정 → 시리얼 이벤트**, CH20, 수신 키워드 감지 켜기, `Invalid interface`, 메일 전달 켜기 → 적용 | 같은 CH20 화면에서 `d` 입력 후 감지·메일 결과 확인. 삭제된 포트 고급 설정 탭으로 이동하지 않음. SMTP 자체 전달 여부는 기존 Gmail → Daum 메일 시험으로 먼저 확인 | |
|||
| 로그 삭제 | 관리자 로그인 → 로그 화면에서 대상 선택 → 로그 클리어 → 현재 로그인 계정의 비밀번호 | 틀린 비밀번호는 거절, 기존 로그 유지. 올바른 비밀번호는 지정 범위 삭제. 취소는 삭제 요청을 보내지 않음 | |
|||
| 전체 삭제 범위 | 포트 로그에서 전체 포트 선택 / 상태 이벤트 / 시스템 로그를 각각 시험 | 포트 통신 로그 전체 / 상태 이벤트 전체 / 시스템·감사 로그가 각각 삭제. 필터 기간·화면 표시 개수와 무관하며, 새로 발생한 로그는 계속 저장 | |
|||
| 관리자 제한 | 포트 운영자로 로그인 | 포트 로그 메뉴·데이터 로그 설정 탭 접근 불가. 관련 API 변경도 관리자 권한 필요 | |
|||
| 저장 공간 | 로그 화면의 저장 공간 확인 | 90% 고정 표시. 파일시스템 최소 10% 여유 공간을 확보하고, 클래스별 저장 한도나 여유 공간 한도에 걸리면 오래된 로그 세그먼트부터 회수. 원격 전송만 켜더라도 기존 기본 로컬 기록은 유지 | |
|||
| 직접 연결 | **직접 연결 모드** 열기 | 기존 포트 디버그의 새 이름. 데이터 형식 선택은 ASCII, HEX 순서. 내려받은 직접 연결 기록도 ASCII 다음 HEX | |
|||
| 진단 | **관리자 운영 → 진단 도구**에서 PC `192.168.0.100` 핑 | 진단 결과 표시. 별칭 편집 항목 없음. 네트워크 고급 설정은 유지 | |
|||
| 백업·복원 | 현재 설정 백업 → 구분 가능한 별칭 변경 → 백업 복원 | 복원 적용·영구 저장 성공 후 ‘복원 완료’ 알림과 자동 재시작. 재접속해서 백업 당시 별칭 확인. 복원이 실패하면 재시작하지 않음 | |
|||
| 초기화 | 백업 확보 후 **공장 초기화** → 현재 계정 비밀번호 → 최종 확인 버튼 | 문자 ‘공장 초기화’ 입력 불필요. 잘못된 비밀번호로는 초기화되지 않음. 승인·인증 성공 후 초기화 및 재시작 | |
|||
|
|||
비밀번호 변경은 사이드바 메뉴만 제거했습니다. 최초 로그인 필수 변경과 관리자의 계정 비밀번호 재설정은 유지한 상태입니다. |
|||
|
|||
## 검증 범위 |
|||
|
|||
- 백엔드 전체 Go 테스트 통과. 이후 수정한 API·저장소·로그 정책·포트 서비스도 재검증했습니다. |
|||
- 프런트엔드 전체 Go 테스트 통과. |
|||
- Edge의 로컬 시험 데이터로 대시보드 고정 위치·말줄임표, 포트 상태의 세션 IP 4열, 별칭 PATCH, 동작 2 세션 읽기 전용, 삭제 비밀번호 입력·취소를 확인했습니다. |
|||
- Linux ARM64 실행 파일 3종을 만들었습니다. `.pkg` 펌웨어 패키지나 장비 배포 완료를 뜻하지 않습니다. |
|||
- 추가 수정본은 API·웹을 다시 배포했습니다. 포트 위치 1~48 유지, 기존 포트 탭 통합, 체크박스 선택·해제와 새로고침, 1440/1024/800/480px 화면 너비, 콘솔 아이콘 제거를 로컬 Edge에서 확인했습니다. 장비 준비 상태 12/12 및 설치 파일·정적 JS 해시 일치를 확인했습니다. |
|||
- 장비 CH26에서 100ms 응답 대기 두 번 실패 후 다음 주기 정상 응답을 직접 관찰했습니다. 15초 재확인 및 지속 무응답의 미확인 판정은 Go 회귀 테스트로 검증했습니다. 케이블을 실제로 뽑는 시험은 진행하지 않았습니다. |
|||
- eth0/eth1 동일 포트 바인딩은 장비의 일반 사용자 권한으로 검증했습니다. 실제 SSH/Telnet 혼합 접속, 실제 저장 공간 회전, 실제 복원·초기화 후 재부팅은 장비에 실행하지 않았습니다. 종전 부팅 지연도 해결됐다고 판정하지 않았습니다. |
|||
@ -1,57 +1,28 @@ |
|||
# 최종 장비 빌드 |
|||
|
|||
릴리스: **20260910-demo-log-controls-1** · Linux ARM64 · 시연 장비: http://192.168.0.223/ |
|||
**릴리스 20260914-port-selection-3 · Linux ARM64 · 192.168.0.223 배포 완료** |
|||
|
|||
2026-09-10 프론트엔드·API·로그 데몬에 이어 메일 알림 데몬을 갱신했습니다. 설치 SHA256 일치와 서비스 준비 **12/12**를 확인했습니다. 나머지 네 파일은 이전 최종 빌드 세트에서 유지했습니다. |
|||
장비 준비 상태 12/12, HTTP 80·HTTPS 443 서비스, 설치 파일 8개와 final-build 폴더의 SHA256 일치를 확인했습니다. API·웹은 이번 수정본으로 빌드·배포했고, logd는 같은 날 배포한 시스템 개선 빌드입니다. 나머지 데몬은 현재 장비 설치본과 맞춘 파일입니다. |
|||
|
|||
메일 수정: 발송 시 현재 비밀번호 저장 파일을 읽어 UI에서 새로 저장한 SMTP/Trap 자격 증명을 반영합니다. 기존 테스트 메일 재시도는 **16:29:10 KST에 delivered**로 완료됐습니다. 이 상태는 SMTP 서버의 수락이며 수신함 도착 확인과는 구분합니다. 메일 데몬의 이전 실행 파일은 `/userdata/tera-backups/alarm-live-secrets-20260910`에 보관했습니다. |
|||
final-build는 애플리케이션·데몬 실행 파일 모음이며 boot/rootfs를 포함한 전체 펌웨어 `.pkg`가 아닙니다. 사용자 계정, 장비 설정, 인증서 개인키와 런타임 로그는 포함하지 않습니다. |
|||
|
|||
시리얼 키워드 수정: 실제 seriald가 logd에 기록한 새 RX 데이터도 알림 검사 대상에 포함합니다. 분할 수신을 이어서 비교하고 같은 기록을 반복 발송하지 않습니다. API와 배포 환경의 검사 주기는 2초입니다. 장비 적용 해시 및 12/12 준비 완료를 확인했으며 이전 API/환경 파일은 `/userdata/tera-backups/serial-keyword-20260910`에 보관했습니다. 과거 로그 재발송은 하지 않으므로 새 RX로 시연합니다. |
|||
| 파일 | 장비 설치 경로 | |
|||
|---|---| |
|||
| frontend/tera-clone-demo-web | /usr/bin/tera-clone-demo-web | |
|||
| backend/tera-clone-demo-api | /usr/bin/tera-api | |
|||
| backend/tera-logd | /usr/bin/tera-logd | |
|||
| backend/tera-clone-demo-alarmd | /usr/bin/tera-alarmd | |
|||
| backend/tera-netd | /usr/bin/tera-netd | |
|||
| backend/tera-seriald | /usr/bin/tera-seriald | |
|||
| backend/tera-sessiond | /usr/bin/tera-sessiond | |
|||
| backend/tera-timed | /usr/bin/tera-timed | |
|||
|
|||
이 디렉터리는 실행 파일 모음입니다. boot/rootfs를 포함한 전체 펌웨어 `.pkg`는 아닙니다. 장비 설정, 사용자 계정, 인증서 개인키와 런타임 로그는 포함하지 않습니다. |
|||
이번 수정은 포트 설정 탭 하나에서 채널 전환, 확대된 카드와 변경 대상 체크박스, 직접 연결 모드의 콘솔 아이콘 제거를 포함합니다. 대시보드 포트 위치는 1~48번으로 고정합니다. CR 응답이 잠시 누락돼 발생하던 DOWN 표시에는 15초 재확인을 적용했고, 지속적인 CR 무응답은 케이블 단절로 단정하지 않고 미확인으로 표시합니다. API와 상태 이벤트가 같은 CR 판정 함수를 사용합니다. |
|||
|
|||
## 파일과 설치 경로 |
|||
앞선 시스템 개선에는 포트 별칭 관리와 세션 IP, eth0/eth1 공유 세션 제한, Receive/HEX 표시, 90% 로그 회전, 관리자 로그 관리, 현재 계정 비밀번호 재인증, 복원 완료 후 재시작이 포함됩니다. 기존 메일·시리얼 키워드 알림 수정도 유지합니다. |
|||
|
|||
| 파일 | 장비 설치 경로 | 서비스 | 이번 변경 | |
|||
|---|---|---|---| |
|||
| frontend/tera-clone-demo-web | /usr/bin/tera-clone-demo-web | S61teraweb | 갱신 | |
|||
| backend/tera-clone-demo-api | /usr/bin/tera-api | S50tera-api | 갱신 | |
|||
| backend/tera-logd | /usr/bin/tera-logd | S44tera-logd | 갱신 | |
|||
| backend/tera-timed | /usr/bin/tera-timed | S45tera-timed | 유지 | |
|||
| backend/tera-clone-demo-alarmd | /usr/bin/tera-alarmd | S47tera-alarmd | 비밀번호 갱신 반영 수정 | |
|||
| backend/tera-seriald | /usr/bin/tera-seriald | S40tera-seriald | 유지 | |
|||
| backend/tera-sessiond | /usr/bin/tera-sessiond | S55tera-sessiond | 유지 | |
|||
| backend/tera-netd | /usr/bin/tera-netd | S43tera-netd | 유지 | |
|||
검증: 변경된 API·시리얼·상태 이벤트 Go 테스트, 프런트엔드 전체 Go 테스트, 탭·화면 전환·대시보드 JavaScript 테스트 19개, 로컬 Edge UI 검증을 통과했습니다. UI 검증은 시험 데이터로 수행했으며 장비에서 로그 삭제·복원·초기화·전원 재부팅은 실행하지 않았습니다. |
|||
|
|||
설치 실행 권한은 0755입니다. 이 장비의 웹 설정은 HTTP 80과 기존 서비스 구성을 유지합니다. 이번 로그 기능에는 일치하는 API·웹·로그 데몬이 모두 필요합니다. 배포 시 기존 바이너리를 백업하고 로그 데몬의 소켓 준비를 확인한 다음 API를 시작해야 합니다. |
|||
이전 실행 파일 백업: `/userdata/tera-backups/port-selection-20260914` (API·웹), `/userdata/tera-backups/system-improvements-20260914` (앞선 시스템 개선). |
|||
|
|||
## 반영 내용 |
|||
|
|||
- 사용자 계정 작업 셀 정렬 수정. |
|||
- 포트 운영자의 로그 메뉴와 로그 API 접근 제거. |
|||
- 인증 서버의 ‘장애 시 인증’, 주소 필터의 ‘안전 안내’ 고정 UI 제거. |
|||
- DDNS IPv6 항목 제거. DDNS 화면에서 적용하면 IPv6 사용을 끄고 IPv6 도메인을 비웁니다. |
|||
- 시리얼 이벤트 감지 키워드 편집 및 포트 고급 설정과 공유. |
|||
- 시스템 로그의 기존 감사 기록 포함 삭제. |
|||
- 포트 로그 단일 채널 및 전체 48포트 삭제(확인 문자열 ALL). |
|||
- 포트 상태 이벤트 전체 삭제(확인 문자열 LINKS). 현재 링크 상태는 변경하지 않습니다. |
|||
- 삭제 체크포인트 및 다른 접속의 캐시 갱신 처리. |
|||
|
|||
## 검증 결과 |
|||
|
|||
- 백엔드: logstorage, logservice, api, adapters/mock 패키지 전체 테스트 통과. |
|||
- 프론트엔드: handlers, mock 패키지 테스트 통과. |
|||
- JavaScript: 삭제 제어 11개, 로그 표시 7개, 시리얼 키워드 1개 테스트 통과. |
|||
- 전체 포트 삭제 후 다른 종류 로그 유지, 상태 이벤트 삭제 후 재시작 복구 방지, 새 이벤트 수집과 다른 클라이언트 캐시 갱신 검증. |
|||
- 8개 실행 파일의 ELF64 AArch64 형식과 체크섬 확인. |
|||
- 장비 배포 후 웹의 ALL/LINKS 코드 제공 및 준비 상태 12/12 확인. |
|||
|
|||
검증을 위해 장비의 기존 로그를 실제로 삭제하지 않았습니다. SNMP·인증서 신뢰·48채널 전체 트래픽·실기 기록 중 전원 차단 내성은 이 릴리스에서 완료했다고 주장하지 않습니다. |
|||
|
|||
## 파일 무결성과 배포 기록 |
|||
|
|||
[SHA256SUMS](SHA256SUMS)에 모든 실행 파일의 해시가 있습니다. [release-manifest.json](release-manifest.json)에 크기·설치 경로·검증 범위가 있습니다. |
|||
|
|||
이번 장비 백업: `/userdata/tera-backups/ui-settings-20260910-125259`. |
|||
|
|||
[시연 자료](../demo-guide/README.md)에는 실제 주소 필터 차단 사례와 복구, 제공된 SSL 인증서 입력 화면, SNMP 시험, 알림·로그 삭제 절차와 결과 기록표가 포함되어 있습니다. |
|||
설정·시연 순서는 [시스템 개선 확인 절차](../demo-guide/07-system-improvements-20260914.md)를 참고하세요. 파일별 해시와 검증 범위는 `SHA256SUMS`, `release-manifest.json`에 기록했습니다. |
|||
|
|||
@ -1,8 +1,8 @@ |
|||
7616cf19151d7ce0b8afc35cad7a5d66b1168bf856036c73fe92f59f8d2fddb0 frontend/tera-clone-demo-web |
|||
2ef9c70584f275deebac08711809be556f76f3601417f08cc62a0bc234b17cd2 frontend/tera-clone-demo-web |
|||
a7225f0786111cd3c714fe7f23a34778085c86b923d08bbc5e67dc6e8e611860 backend/tera-clone-demo-api |
|||
b5856899698bfafb1f382a3dd45db02b034ba785d56a075ea964c937b57a48b3 backend/tera-logd |
|||
ac3149f54d93ce252527606bda35dc739f1f3ad40bfb77409f16b3514d437006 backend/tera-clone-demo-alarmd |
|||
bc3f3643e01ca297c90f90c4c9eb4a57cb5f2bdc4d76eca6b5041ffa93a287f1 backend/tera-clone-demo-api |
|||
5b351238c8973f2c4222f6926397ab4544bf80b43ade438ab2dba8128aaecd4c backend/tera-logd |
|||
0df3697104ebdb8bfb1d8b0869a175789fedd8bda50f41b3395dcbd7d77dfcef backend/tera-netd |
|||
c86983a2921efd0f038821b707915679324d94402301c4bbe8d264fb29dfaaf4 backend/tera-seriald |
|||
34f1755b54a562d988ef5f922c101ab10af9f7a246fb604478595651449ee3f1 backend/tera-sessiond |
|||
d0babd9ae746bc4b7ec5931d66ddae0ad0782df0703256160c7233fbdea169a1 backend/tera-timed |
|||
2864dbde4fcde16898a3b187716b325883365a1fd39f16264a5186877f94c50c backend/tera-netd |
|||
1263fe6823fb07704ecbd0bc5be18db56f9f2b60ebafc1692e873c40bac8f01f backend/tera-seriald |
|||
a1d868818fa2bbb57996e5fe5786fb1838d337e223a23729cd243762c751aadf backend/tera-sessiond |
|||
c0cc73a1d390ef9964adc3a1623b90b8c7950a798af71a7201c93b1b58f12b12 backend/tera-timed |
|||
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,97 +1,93 @@ |
|||
{ |
|||
"release_id": "20260910-demo-log-controls-1", |
|||
"release_id": "20260914-port-selection-3", |
|||
"platform": "linux-arm64", |
|||
"scope": "application-and-daemon-binaries-not-full-firmware", |
|||
"generated_at": "2026-09-14T05:22:41.7644664+00:00", |
|||
"files": [ |
|||
{ |
|||
"path": "frontend/tera-clone-demo-web", |
|||
"bytes": 19063465, |
|||
"sha256": "7616cf19151d7ce0b8afc35cad7a5d66b1168bf856036c73fe92f59f8d2fddb0", |
|||
"bytes": 13238434, |
|||
"sha256": "2ef9c70584f275deebac08711809be556f76f3601417f08cc62a0bc234b17cd2", |
|||
"install_path": "/usr/bin/tera-clone-demo-web", |
|||
"updated_in_this_release": true |
|||
}, |
|||
{ |
|||
"path": "backend/tera-clone-demo-alarmd", |
|||
"bytes": 7711921, |
|||
"sha256": "ac3149f54d93ce252527606bda35dc739f1f3ad40bfb77409f16b3514d437006", |
|||
"install_path": "/usr/bin/tera-alarmd", |
|||
"updated_in_this_release": true |
|||
"provenance": "built-and-deployed" |
|||
}, |
|||
{ |
|||
"path": "backend/tera-clone-demo-api", |
|||
"bytes": 9961634, |
|||
"sha256": "bc3f3643e01ca297c90f90c4c9eb4a57cb5f2bdc4d76eca6b5041ffa93a287f1", |
|||
"bytes": 10027170, |
|||
"sha256": "a7225f0786111cd3c714fe7f23a34778085c86b923d08bbc5e67dc6e8e611860", |
|||
"install_path": "/usr/bin/tera-api", |
|||
"updated_in_this_release": true |
|||
"provenance": "built-and-deployed" |
|||
}, |
|||
{ |
|||
"path": "backend/tera-logd", |
|||
"bytes": 4760736, |
|||
"sha256": "5b351238c8973f2c4222f6926397ab4544bf80b43ade438ab2dba8128aaecd4c", |
|||
"bytes": 3211426, |
|||
"sha256": "b5856899698bfafb1f382a3dd45db02b034ba785d56a075ea964c937b57a48b3", |
|||
"install_path": "/usr/bin/tera-logd", |
|||
"updated_in_this_release": true |
|||
"provenance": "built-and-deployed-earlier-today" |
|||
}, |
|||
{ |
|||
"path": "backend/tera-clone-demo-alarmd", |
|||
"bytes": 7711921, |
|||
"sha256": "ac3149f54d93ce252527606bda35dc739f1f3ad40bfb77409f16b3514d437006", |
|||
"install_path": "/usr/bin/tera-alarmd", |
|||
"provenance": "existing-device-baseline" |
|||
}, |
|||
{ |
|||
"path": "backend/tera-netd", |
|||
"bytes": 3211426, |
|||
"sha256": "0df3697104ebdb8bfb1d8b0869a175789fedd8bda50f41b3395dcbd7d77dfcef", |
|||
"bytes": 4661671, |
|||
"sha256": "2864dbde4fcde16898a3b187716b325883365a1fd39f16264a5186877f94c50c", |
|||
"install_path": "/usr/bin/tera-netd", |
|||
"updated_in_this_release": false |
|||
"provenance": "copied-from-device" |
|||
}, |
|||
{ |
|||
"path": "backend/tera-seriald", |
|||
"bytes": 3408034, |
|||
"sha256": "c86983a2921efd0f038821b707915679324d94402301c4bbe8d264fb29dfaaf4", |
|||
"bytes": 5020690, |
|||
"sha256": "1263fe6823fb07704ecbd0bc5be18db56f9f2b60ebafc1692e873c40bac8f01f", |
|||
"install_path": "/usr/bin/tera-seriald", |
|||
"updated_in_this_release": false |
|||
"provenance": "copied-from-device" |
|||
}, |
|||
{ |
|||
"path": "backend/tera-sessiond", |
|||
"bytes": 6226082, |
|||
"sha256": "34f1755b54a562d988ef5f922c101ab10af9f7a246fb604478595651449ee3f1", |
|||
"bytes": 9089559, |
|||
"sha256": "a1d868818fa2bbb57996e5fe5786fb1838d337e223a23729cd243762c751aadf", |
|||
"install_path": "/usr/bin/tera-sessiond", |
|||
"updated_in_this_release": false |
|||
"provenance": "copied-from-device" |
|||
}, |
|||
{ |
|||
"path": "backend/tera-timed", |
|||
"bytes": 2883746, |
|||
"sha256": "d0babd9ae746bc4b7ec5931d66ddae0ad0782df0703256160c7233fbdea169a1", |
|||
"bytes": 4329693, |
|||
"sha256": "c0cc73a1d390ef9964adc3a1623b90b8c7950a798af71a7201c93b1b58f12b12", |
|||
"install_path": "/usr/bin/tera-timed", |
|||
"updated_in_this_release": false |
|||
"provenance": "copied-from-device" |
|||
} |
|||
], |
|||
"source": { |
|||
"base_commit": "8d5936102974bf1fa2a2b10f63b04beac6cdaeef", |
|||
"changes": "Release source is included with this manifest in the Git commit; baseline daemons have individual provenance above." |
|||
}, |
|||
"deployment": { |
|||
"device": "192.168.0.223", |
|||
"date": "2026-09-10", |
|||
"date": "2026-09-14", |
|||
"ready": true, |
|||
"completed": 12, |
|||
"total": 12, |
|||
"backup": "/userdata/tera-backups/ui-settings-20260910-125259", |
|||
"verified_updated_files": [ |
|||
"tera-clone-demo-api", |
|||
"tera-clone-demo-web", |
|||
"tera-logd" |
|||
], |
|||
"verification": "Installed SHA256 values matched these three build artifacts. Other five artifacts retained from the previous final-build set." |
|||
"backup": "/userdata/tera-backups/port-selection-20260914", |
|||
"verified": "Eight binary SHA256 values match installed files; HTTP/HTTPS listeners and served workspace-tabs.js verified." |
|||
}, |
|||
"validation": { |
|||
"backend": "go test ./internal/logstorage ./internal/logservice ./internal/api ./internal/adapters/mock passed", |
|||
"frontend": "go test ./internal/handlers ./internal/mock passed", |
|||
"javascript": "11 clear-control tests, 7 log-view tests and 1 serial-keyword test passed", |
|||
"limitations": "No existing device logs were deleted for verification. SNMP, certificate trust, full 48-port traffic and power-loss-on-device acceptance remain demonstration procedures, not claimed completed tests." |
|||
"backend": "Changed API, serial and logruntime tests passed; earlier system improvements passed the full backend suite.", |
|||
"frontend": "Full Go suite passed, then final page fixtures regenerated and checked.", |
|||
"javascript": "19 workspace, navigation and dashboard tests passed.", |
|||
"browser": "Local Edge fixtures: fixed positions, one settings tab, legacy migration, checkbox, refresh, 1440/1024/800/480 layouts, icon removal.", |
|||
"live": "Read-only CH20/CH26 CR cache samples observed; transient CH26 miss followed by normal reply.", |
|||
"limitations": "No cable-unplug, destructive action or power-cycle acceptance test; authenticated live UI was not tested in this run." |
|||
}, |
|||
"mail_fix": { |
|||
"date": "2026-09-10", |
|||
"description": "Reload current secret snapshot for email and SNMP Trap delivery", |
|||
"backup": "/userdata/tera-backups/alarm-live-secrets-20260910", |
|||
"verified_sha256": "ac3149f54d93ce252527606bda35dc739f1f3ad40bfb77409f16b3514d437006" |
|||
}, |
|||
"serial_keyword_fix": { |
|||
"date": "2026-09-10", |
|||
"description": "Evaluate new seriald RX journal events and split keyword matches", |
|||
"backup": "/userdata/tera-backups/serial-keyword-20260910", |
|||
"poll_interval_seconds": 2, |
|||
"ready": true, |
|||
"ready_stages": "12/12" |
|||
} |
|||
"changes": [ |
|||
"Keep channel positions fixed from 1 to 48", |
|||
"Replace port settings in one tab", |
|||
"Larger cards with target checkboxes", |
|||
"Remove serial console icon", |
|||
"Share CR response classification with 15-second grace; sustained silence is unknown, not cable down", |
|||
"Include earlier system/session/log/reauthentication improvements" |
|||
] |
|||
} |
|||
|
|||
@ -0,0 +1,64 @@ |
|||
package handlers |
|||
|
|||
import ( |
|||
"net/http" |
|||
"net/http/httptest" |
|||
"os" |
|||
"path/filepath" |
|||
"strings" |
|||
"testing" |
|||
|
|||
"teraclone/internal/service" |
|||
) |
|||
|
|||
func TestSystemImprovementPagesAndRemovedNavigation(t *testing.T) { |
|||
handler, err := NewDeviceHandler(service.NewStatelessAppService(), "http://127.0.0.1:1") |
|||
if err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
mux := http.NewServeMux() |
|||
handler.RegisterRoutes(mux) |
|||
handler.sessions["qa-admin"] = browserSession{Username: "admin", BackendToken: "test-token", Role: "administrator", WorkspaceID: "local-ui-qa"} |
|||
for _, path := range []string{"/", "/port-status", "/ports/1/parameters", "/ports/26/parameters", "/ports/20/parameters", "/ports/20/operation-1", "/ports/20/operation-2", "/ports/20/data-log", "/port-debug", "/tools/nettest/ping", "/logs/ports", "/device/factory-reset"} { |
|||
req := httptest.NewRequest(http.MethodGet, path, nil) |
|||
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "qa-admin"}) |
|||
response := httptest.NewRecorder() |
|||
mux.ServeHTTP(response, req) |
|||
if response.Code != 200 { |
|||
t.Fatalf("%s: %d", path, response.Code) |
|||
} |
|||
html := response.Body.String() |
|||
for _, removed := range []string{`href="/tools/alias"`, `href="/password"`, `href="/ports/20/advanced"`} { |
|||
if strings.Contains(html, removed) { |
|||
t.Errorf("%s still contains %s", path, removed) |
|||
} |
|||
} |
|||
if path == "/port-status" && (!strings.Contains(html, "세션 4 IP") || !strings.Contains(html, "포트 별칭")) { |
|||
t.Fatal("port alias/session controls missing") |
|||
} |
|||
if strings.Contains(path, "operation-") && !strings.Contains(html, `value="10020"`) { |
|||
t.Fatal("channel listen port is not fixed") |
|||
} |
|||
if directory := os.Getenv("TERA_UI_QA_OUTPUT"); directory != "" { |
|||
if err := os.MkdirAll(directory, 0755); err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
name := strings.ReplaceAll(strings.Trim(path, "/"), "/", "-") |
|||
if name == "" { |
|||
name = "dashboard" |
|||
} |
|||
if err := os.WriteFile(filepath.Join(directory, name+".html"), response.Body.Bytes(), 0644); err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
} |
|||
} |
|||
for _, path := range []string{"/ports/20/advanced", "/tools/alias"} { |
|||
req := httptest.NewRequest(http.MethodGet, path, nil) |
|||
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "qa-admin"}) |
|||
response := httptest.NewRecorder() |
|||
mux.ServeHTTP(response, req) |
|||
if response.Code != 404 { |
|||
t.Fatalf("removed page %s returned %d", path, response.Code) |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue