736 lines
26 KiB
Go
736 lines
26 KiB
Go
package management
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
|
)
|
|
|
|
func TestDecodeLogCursorRejectsUnsafeFiles(t *testing.T) {
|
|
unsafeNames := []string{
|
|
"",
|
|
".",
|
|
"..",
|
|
"../secret",
|
|
"nested/main.log",
|
|
`nested\main.log`,
|
|
"error.log",
|
|
}
|
|
|
|
for _, name := range unsafeNames {
|
|
t.Run(name, func(t *testing.T) {
|
|
raw := mustEncodeRawCursor(t, logCursor{
|
|
Version: logCursorVersion,
|
|
File: name,
|
|
Fingerprint: "fingerprint",
|
|
})
|
|
if _, err := decodeLogCursor(raw); err == nil {
|
|
t.Fatalf("decodeLogCursor(%q) succeeded, want error", name)
|
|
}
|
|
})
|
|
}
|
|
|
|
for _, name := range []string{defaultLogFileName, defaultLogFileName + ".1", "main-2026-06-15T10-00-00.log"} {
|
|
t.Run("allowed_"+name, func(t *testing.T) {
|
|
raw := mustEncodeRawCursor(t, logCursor{
|
|
Version: logCursorVersion,
|
|
File: name,
|
|
Fingerprint: "fingerprint",
|
|
})
|
|
if _, err := decodeLogCursor(raw); err != nil {
|
|
t.Fatalf("decodeLogCursor(%q) error = %v", name, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLogCursorRoundTripOmitsAbsolutePath(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, defaultLogFileName)
|
|
if err := os.WriteFile(path, []byte("line one\nline two\n"), 0o644); err != nil {
|
|
t.Fatalf("write log file: %v", err)
|
|
}
|
|
|
|
boundary, errBoundary := completeLogBoundary(path)
|
|
if errBoundary != nil {
|
|
t.Fatalf("completeLogBoundary() error = %v", errBoundary)
|
|
}
|
|
raw, errCursor := newLogCursor(path, boundary, 123)
|
|
if errCursor != nil {
|
|
t.Fatalf("newLogCursor() error = %v", errCursor)
|
|
}
|
|
decoded, errDecode := decodeLogCursor(raw)
|
|
if errDecode != nil {
|
|
t.Fatalf("decodeLogCursor() error = %v", errDecode)
|
|
}
|
|
if decoded.File != defaultLogFileName {
|
|
t.Fatalf("cursor file = %q, want %q", decoded.File, defaultLogFileName)
|
|
}
|
|
if decoded.Offset != boundary {
|
|
t.Fatalf("cursor offset = %d, want %d", decoded.Offset, boundary)
|
|
}
|
|
if decoded.LatestTimestamp != 123 {
|
|
t.Fatalf("cursor latest timestamp = %d, want 123", decoded.LatestTimestamp)
|
|
}
|
|
if strings.Contains(raw, dir) {
|
|
t.Fatalf("encoded cursor contains log directory %q: %q", dir, raw)
|
|
}
|
|
}
|
|
|
|
func TestReadCompleteLogLinesSkipsTrailingPartial(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, defaultLogFileName)
|
|
initial := "first\nsecond\r\npartial"
|
|
if err := os.WriteFile(path, []byte(initial), 0o644); err != nil {
|
|
t.Fatalf("write log file: %v", err)
|
|
}
|
|
|
|
read, errRead := readCompleteLogLines(path, 0, -1, 0)
|
|
if errRead != nil {
|
|
t.Fatalf("readCompleteLogLines() error = %v", errRead)
|
|
}
|
|
wantLines := []string{"first", "second"}
|
|
if !reflect.DeepEqual(read.lines, wantLines) {
|
|
t.Fatalf("lines = %#v, want %#v", read.lines, wantLines)
|
|
}
|
|
wantOffset := int64(len("first\nsecond\r\n"))
|
|
if read.endOffset != wantOffset {
|
|
t.Fatalf("endOffset = %d, want %d", read.endOffset, wantOffset)
|
|
}
|
|
|
|
file, errOpen := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
|
|
if errOpen != nil {
|
|
t.Fatalf("open log file: %v", errOpen)
|
|
}
|
|
if _, errWrite := file.WriteString("\n"); errWrite != nil {
|
|
_ = file.Close()
|
|
t.Fatalf("append newline: %v", errWrite)
|
|
}
|
|
if errClose := file.Close(); errClose != nil {
|
|
t.Fatalf("close log file: %v", errClose)
|
|
}
|
|
|
|
next, errNext := readCompleteLogLines(path, read.endOffset, -1, 0)
|
|
if errNext != nil {
|
|
t.Fatalf("readCompleteLogLines() after append error = %v", errNext)
|
|
}
|
|
if !reflect.DeepEqual(next.lines, []string{"partial"}) {
|
|
t.Fatalf("next lines = %#v, want partial", next.lines)
|
|
}
|
|
if next.endOffset != int64(len(initial)+1) {
|
|
t.Fatalf("next endOffset = %d, want %d", next.endOffset, len(initial)+1)
|
|
}
|
|
}
|
|
|
|
func TestGetLogsTailLimitReturnsRecentLinesWithCursor(t *testing.T) {
|
|
dir := t.TempDir()
|
|
lines := []string{
|
|
"[2026-06-15 10:00:00] first",
|
|
"[2026-06-15 10:00:01] second",
|
|
"[2026-06-15 10:00:02] third",
|
|
"[2026-06-15 10:00:03] fourth",
|
|
}
|
|
writeMainLog(t, dir, strings.Join(lines, "\n")+"\n")
|
|
|
|
resp := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?limit=2")
|
|
wantLines := []string{lines[2], lines[3]}
|
|
if !reflect.DeepEqual(resp.Lines, wantLines) {
|
|
t.Fatalf("lines = %#v, want %#v", resp.Lines, wantLines)
|
|
}
|
|
if resp.LineCount != len(wantLines) {
|
|
t.Fatalf("line-count = %d, want returned line count %d", resp.LineCount, len(wantLines))
|
|
}
|
|
if resp.NextCursor == "" {
|
|
t.Fatal("next-cursor is empty")
|
|
}
|
|
wantLatest := time.Date(2026, 6, 15, 10, 0, 3, 0, time.Local).Unix()
|
|
if resp.LatestTimestamp != wantLatest {
|
|
t.Fatalf("latest-timestamp = %d, want %d", resp.LatestTimestamp, wantLatest)
|
|
}
|
|
}
|
|
|
|
func TestGetLogsTailLimitDoesNotScanOlderFilesForLineCount(t *testing.T) {
|
|
dir := t.TempDir()
|
|
rotatedPath := filepath.Join(dir, defaultLogFileName+".1")
|
|
if err := os.WriteFile(rotatedPath, []byte(strings.Repeat("x", logScannerMaxBuffer+1)+"\n"), 0o644); err != nil {
|
|
t.Fatalf("write rotated log: %v", err)
|
|
}
|
|
writeMainLog(t, dir, "[2026-06-15 10:00:00] current\n")
|
|
|
|
resp := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?limit=1")
|
|
wantLines := []string{"[2026-06-15 10:00:00] current"}
|
|
if !reflect.DeepEqual(resp.Lines, wantLines) {
|
|
t.Fatalf("lines = %#v, want %#v", resp.Lines, wantLines)
|
|
}
|
|
if resp.LineCount != len(wantLines) {
|
|
t.Fatalf("line-count = %d, want returned line count %d", resp.LineCount, len(wantLines))
|
|
}
|
|
}
|
|
|
|
func TestGetLogsNoLimitKeepsFullScanBehavior(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeMainLog(t, dir, "complete\npartial")
|
|
|
|
resp := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs")
|
|
wantLines := []string{"complete", "partial"}
|
|
if !reflect.DeepEqual(resp.Lines, wantLines) {
|
|
t.Fatalf("lines = %#v, want %#v", resp.Lines, wantLines)
|
|
}
|
|
if resp.LineCount != 2 {
|
|
t.Fatalf("line-count = %d, want full scan count 2", resp.LineCount)
|
|
}
|
|
if resp.NextCursor == "" {
|
|
t.Fatal("next-cursor is empty")
|
|
}
|
|
cursor, errCursor := decodeLogCursor(resp.NextCursor)
|
|
if errCursor != nil {
|
|
t.Fatalf("decode next-cursor: %v", errCursor)
|
|
}
|
|
if cursor.Offset != int64(len("complete\n")) {
|
|
t.Fatalf("cursor offset = %d, want complete-line boundary", cursor.Offset)
|
|
}
|
|
}
|
|
|
|
func TestGetLogsAfterKeepsTimestampScanAndReturnsCursor(t *testing.T) {
|
|
dir := t.TempDir()
|
|
lines := []string{
|
|
"[2026-06-15 10:00:00] first",
|
|
"[2026-06-15 10:00:01] second",
|
|
"[2026-06-15 10:00:02] third",
|
|
}
|
|
writeMainLog(t, dir, strings.Join(lines, "\n")+"\n")
|
|
|
|
cutoff := time.Date(2026, 6, 15, 10, 0, 0, 0, time.Local).Unix()
|
|
resp := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?after="+strconv.FormatInt(cutoff, 10))
|
|
wantLines := []string{lines[1], lines[2]}
|
|
if !reflect.DeepEqual(resp.Lines, wantLines) {
|
|
t.Fatalf("lines = %#v, want %#v", resp.Lines, wantLines)
|
|
}
|
|
if resp.LineCount != 3 {
|
|
t.Fatalf("line-count = %d, want full scan count 3", resp.LineCount)
|
|
}
|
|
if resp.NextCursor == "" {
|
|
t.Fatal("next-cursor is empty")
|
|
}
|
|
}
|
|
|
|
func TestGetLogsCursorReturnsOnlyNewCompleteLines(t *testing.T) {
|
|
dir := t.TempDir()
|
|
lines := []string{
|
|
"[2026-06-15 10:00:00] first",
|
|
"[2026-06-15 10:00:01] second",
|
|
"[2026-06-15 10:00:02] third",
|
|
}
|
|
writeMainLog(t, dir, strings.Join(lines, "\n")+"\n")
|
|
initial := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?limit=2")
|
|
if initial.NextCursor == "" {
|
|
t.Fatal("initial next-cursor is empty")
|
|
}
|
|
|
|
appendMainLog(t, dir, "[2026-06-15 10:00:03] fourth\n")
|
|
resp := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?cursor="+url.QueryEscape(initial.NextCursor)+"&limit=10")
|
|
wantLines := []string{"[2026-06-15 10:00:03] fourth"}
|
|
if !reflect.DeepEqual(resp.Lines, wantLines) {
|
|
t.Fatalf("lines = %#v, want %#v", resp.Lines, wantLines)
|
|
}
|
|
if resp.LineCount != 1 {
|
|
t.Fatalf("line-count = %d, want 1", resp.LineCount)
|
|
}
|
|
if resp.CursorReset {
|
|
t.Fatal("cursor-reset = true, want false")
|
|
}
|
|
wantLatest := time.Date(2026, 6, 15, 10, 0, 3, 0, time.Local).Unix()
|
|
if resp.LatestTimestamp != wantLatest {
|
|
t.Fatalf("latest-timestamp = %d, want %d", resp.LatestTimestamp, wantLatest)
|
|
}
|
|
}
|
|
|
|
func TestGetLogsCursorRejectsOversizedLine(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeMainLog(t, dir, "[2026-06-15 10:00:00] first\n")
|
|
initial := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?limit=1")
|
|
if initial.NextCursor == "" {
|
|
t.Fatal("initial next-cursor is empty")
|
|
}
|
|
|
|
appendMainLog(t, dir, strings.Repeat("x", logScannerMaxBuffer+1)+"\n")
|
|
status, body := performGetLogsRaw(t, newLogsTestHandler(dir, true), "/v0/management/logs?cursor="+url.QueryEscape(initial.NextCursor)+"&limit=1")
|
|
if status != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want %d", status, http.StatusInternalServerError)
|
|
}
|
|
if !strings.Contains(body, "log line exceeds") {
|
|
t.Fatalf("body = %s, want oversized line error", body)
|
|
}
|
|
}
|
|
|
|
func TestGetLogsCursorNoNewLinesKeepsCursorStable(t *testing.T) {
|
|
dir := t.TempDir()
|
|
line := "[2026-06-15 10:00:00] first"
|
|
writeMainLog(t, dir, line+"\n")
|
|
initial := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?limit=1")
|
|
|
|
resp := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?cursor="+url.QueryEscape(initial.NextCursor)+"&limit=10")
|
|
if len(resp.Lines) != 0 {
|
|
t.Fatalf("lines = %#v, want empty", resp.Lines)
|
|
}
|
|
if resp.LineCount != 0 {
|
|
t.Fatalf("line-count = %d, want 0", resp.LineCount)
|
|
}
|
|
if resp.NextCursor != initial.NextCursor {
|
|
t.Fatalf("next-cursor changed with no complete lines")
|
|
}
|
|
if resp.LatestTimestamp != initial.LatestTimestamp {
|
|
t.Fatalf("latest-timestamp = %d, want %d", resp.LatestTimestamp, initial.LatestTimestamp)
|
|
}
|
|
}
|
|
|
|
func TestGetLogsCursorDoesNotAdvancePastTrailingPartial(t *testing.T) {
|
|
dir := t.TempDir()
|
|
line := "[2026-06-15 10:00:00] first"
|
|
writeMainLog(t, dir, line+"\n")
|
|
initial := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?limit=1")
|
|
|
|
appendMainLog(t, dir, "partial")
|
|
partial := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?cursor="+url.QueryEscape(initial.NextCursor)+"&limit=10")
|
|
if len(partial.Lines) != 0 {
|
|
t.Fatalf("partial lines = %#v, want empty", partial.Lines)
|
|
}
|
|
if partial.NextCursor != initial.NextCursor {
|
|
t.Fatalf("cursor advanced past partial line")
|
|
}
|
|
|
|
appendMainLog(t, dir, "\n")
|
|
complete := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?cursor="+url.QueryEscape(initial.NextCursor)+"&limit=10")
|
|
if !reflect.DeepEqual(complete.Lines, []string{"partial"}) {
|
|
t.Fatalf("complete lines = %#v, want partial", complete.Lines)
|
|
}
|
|
if complete.LatestTimestamp != initial.LatestTimestamp {
|
|
t.Fatalf("latest-timestamp = %d, want %d", complete.LatestTimestamp, initial.LatestTimestamp)
|
|
}
|
|
}
|
|
|
|
func TestGetLogsCursorResetAfterTruncateTailsLimit(t *testing.T) {
|
|
dir := t.TempDir()
|
|
lines := []string{
|
|
"[2026-06-15 10:00:00] first",
|
|
"[2026-06-15 10:00:01] second",
|
|
"[2026-06-15 10:00:02] third",
|
|
}
|
|
writeMainLog(t, dir, strings.Join(lines, "\n")+"\n")
|
|
initial := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?limit=3")
|
|
|
|
resetLine := "[2026-06-15 10:00:03] reset"
|
|
writeMainLog(t, dir, resetLine+"\n")
|
|
resp := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?cursor="+url.QueryEscape(initial.NextCursor)+"&limit=1")
|
|
if !resp.CursorReset {
|
|
t.Fatal("cursor-reset = false, want true")
|
|
}
|
|
if !reflect.DeepEqual(resp.Lines, []string{resetLine}) {
|
|
t.Fatalf("lines = %#v, want reset tail", resp.Lines)
|
|
}
|
|
if resp.LineCount != 1 {
|
|
t.Fatalf("line-count = %d, want 1", resp.LineCount)
|
|
}
|
|
}
|
|
|
|
func TestGetLogsCursorReadsAcrossRotation(t *testing.T) {
|
|
dir := t.TempDir()
|
|
line1 := "[2026-06-15 10:00:00] first"
|
|
line2 := "[2026-06-15 10:00:01] second"
|
|
line3 := "[2026-06-15 10:00:02] third"
|
|
writeMainLog(t, dir, line1+"\n")
|
|
initial := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?limit=1")
|
|
|
|
appendMainLog(t, dir, line2+"\n")
|
|
if err := os.Rename(filepath.Join(dir, defaultLogFileName), filepath.Join(dir, defaultLogFileName+".1")); err != nil {
|
|
t.Fatalf("rotate main log: %v", err)
|
|
}
|
|
writeMainLog(t, dir, line3+"\n")
|
|
|
|
resp := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?cursor="+url.QueryEscape(initial.NextCursor)+"&limit=10")
|
|
wantLines := []string{line2, line3}
|
|
if !reflect.DeepEqual(resp.Lines, wantLines) {
|
|
t.Fatalf("lines = %#v, want %#v", resp.Lines, wantLines)
|
|
}
|
|
if resp.CursorReset {
|
|
t.Fatal("cursor-reset = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestGetLogsCursorReadsRotatedFileWhenNewMainIsSmaller(t *testing.T) {
|
|
dir := t.TempDir()
|
|
line1 := "[2026-06-15 10:00:00] first line with enough bytes"
|
|
line2 := "[2026-06-15 10:00:01] second"
|
|
line3 := "new"
|
|
writeMainLog(t, dir, line1+"\n")
|
|
initial := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?limit=1")
|
|
|
|
appendMainLog(t, dir, line2+"\n")
|
|
if err := os.Rename(filepath.Join(dir, defaultLogFileName), filepath.Join(dir, defaultLogFileName+".1")); err != nil {
|
|
t.Fatalf("rotate main log: %v", err)
|
|
}
|
|
writeMainLog(t, dir, line3+"\n")
|
|
|
|
resp := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?cursor="+url.QueryEscape(initial.NextCursor)+"&limit=1")
|
|
if !reflect.DeepEqual(resp.Lines, []string{line2}) {
|
|
t.Fatalf("lines = %#v, want rotated unread line", resp.Lines)
|
|
}
|
|
if resp.CursorReset {
|
|
t.Fatal("cursor-reset = true, want false")
|
|
}
|
|
|
|
next := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?cursor="+url.QueryEscape(resp.NextCursor)+"&limit=1")
|
|
if !reflect.DeepEqual(next.Lines, []string{line3}) {
|
|
t.Fatalf("next lines = %#v, want new main line", next.Lines)
|
|
}
|
|
if next.CursorReset {
|
|
t.Fatal("next cursor-reset = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestGetLogsZeroOffsetCursorWithPartialLineReadsAcrossRotation(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeMainLog(t, dir, "partial")
|
|
initial := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?limit=1")
|
|
if initial.NextCursor == "" {
|
|
t.Fatal("initial next-cursor is empty")
|
|
}
|
|
cursor, errCursor := decodeLogCursor(initial.NextCursor)
|
|
if errCursor != nil {
|
|
t.Fatalf("decode initial cursor: %v", errCursor)
|
|
}
|
|
if cursor.Offset != 0 || cursor.Size == 0 {
|
|
t.Fatalf("cursor offset/size = %d/%d, want zero offset with partial size", cursor.Offset, cursor.Size)
|
|
}
|
|
|
|
appendMainLog(t, dir, " complete\n")
|
|
if err := os.Rename(filepath.Join(dir, defaultLogFileName), filepath.Join(dir, defaultLogFileName+".1")); err != nil {
|
|
t.Fatalf("rotate main log: %v", err)
|
|
}
|
|
writeMainLog(t, dir, "new\n")
|
|
|
|
resp := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?cursor="+url.QueryEscape(initial.NextCursor)+"&limit=10")
|
|
wantLines := []string{"partial complete", "new"}
|
|
if !reflect.DeepEqual(resp.Lines, wantLines) {
|
|
t.Fatalf("lines = %#v, want %#v", resp.Lines, wantLines)
|
|
}
|
|
if resp.CursorReset {
|
|
t.Fatal("cursor-reset = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestGetLogsZeroOffsetCursorWithEmptyFileReadsAcrossRotation(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeMainLog(t, dir, "")
|
|
initial := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?limit=1")
|
|
if initial.NextCursor == "" {
|
|
t.Fatal("initial next-cursor is empty")
|
|
}
|
|
cursor, errCursor := decodeLogCursor(initial.NextCursor)
|
|
if errCursor != nil {
|
|
t.Fatalf("decode initial cursor: %v", errCursor)
|
|
}
|
|
if cursor.Offset != 0 || cursor.Size != 0 {
|
|
t.Fatalf("cursor offset/size = %d/%d, want empty zero offset", cursor.Offset, cursor.Size)
|
|
}
|
|
|
|
appendMainLog(t, dir, "first\n")
|
|
mainPath := filepath.Join(dir, defaultLogFileName)
|
|
nextModTime := time.Unix(0, cursorModTimeUnixNano(cursor)+int64(time.Second))
|
|
if err := os.Chtimes(mainPath, nextModTime, nextModTime); err != nil {
|
|
t.Fatalf("update main log mtime: %v", err)
|
|
}
|
|
if err := os.Rename(mainPath, filepath.Join(dir, defaultLogFileName+".1")); err != nil {
|
|
t.Fatalf("rotate main log: %v", err)
|
|
}
|
|
writeMainLog(t, dir, "second\n")
|
|
|
|
resp := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?cursor="+url.QueryEscape(initial.NextCursor)+"&limit=1")
|
|
if !reflect.DeepEqual(resp.Lines, []string{"first"}) {
|
|
t.Fatalf("lines = %#v, want first rotated line", resp.Lines)
|
|
}
|
|
if resp.CursorReset {
|
|
t.Fatal("cursor-reset = true, want false")
|
|
}
|
|
|
|
next := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?cursor="+url.QueryEscape(resp.NextCursor)+"&limit=1")
|
|
if !reflect.DeepEqual(next.Lines, []string{"second"}) {
|
|
t.Fatalf("next lines = %#v, want second main line", next.Lines)
|
|
}
|
|
if next.CursorReset {
|
|
t.Fatal("next cursor-reset = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestGetLogsZeroOffsetCursorWithEmptyFileReadsAcrossTwoRotations(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeMainLog(t, dir, "")
|
|
initial := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?limit=1")
|
|
if initial.NextCursor == "" {
|
|
t.Fatal("initial next-cursor is empty")
|
|
}
|
|
cursor, errCursor := decodeLogCursor(initial.NextCursor)
|
|
if errCursor != nil {
|
|
t.Fatalf("decode initial cursor: %v", errCursor)
|
|
}
|
|
if cursor.Offset != 0 || cursor.Size != 0 {
|
|
t.Fatalf("cursor offset/size = %d/%d, want empty zero offset", cursor.Offset, cursor.Size)
|
|
}
|
|
|
|
mainPath := filepath.Join(dir, defaultLogFileName)
|
|
firstRotatedPath := filepath.Join(dir, defaultLogFileName+".1")
|
|
secondRotatedPath := filepath.Join(dir, defaultLogFileName+".2")
|
|
firstModTime := time.Unix(0, cursorModTimeUnixNano(cursor)+int64(time.Second))
|
|
secondModTime := time.Unix(0, cursorModTimeUnixNano(cursor)+2*int64(time.Second))
|
|
|
|
appendMainLog(t, dir, "first\n")
|
|
if err := os.Chtimes(mainPath, firstModTime, firstModTime); err != nil {
|
|
t.Fatalf("update first main log mtime: %v", err)
|
|
}
|
|
if err := os.Rename(mainPath, firstRotatedPath); err != nil {
|
|
t.Fatalf("rotate first main log: %v", err)
|
|
}
|
|
writeMainLog(t, dir, "second\n")
|
|
if err := os.Chtimes(mainPath, secondModTime, secondModTime); err != nil {
|
|
t.Fatalf("update second main log mtime: %v", err)
|
|
}
|
|
if err := os.Rename(firstRotatedPath, secondRotatedPath); err != nil {
|
|
t.Fatalf("advance first rotated log: %v", err)
|
|
}
|
|
if err := os.Rename(mainPath, firstRotatedPath); err != nil {
|
|
t.Fatalf("rotate second main log: %v", err)
|
|
}
|
|
writeMainLog(t, dir, "third\n")
|
|
|
|
resp := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?cursor="+url.QueryEscape(initial.NextCursor)+"&limit=1")
|
|
if !reflect.DeepEqual(resp.Lines, []string{"first"}) {
|
|
t.Fatalf("lines = %#v, want oldest rotated line", resp.Lines)
|
|
}
|
|
if resp.CursorReset {
|
|
t.Fatal("cursor-reset = true, want false")
|
|
}
|
|
|
|
next := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?cursor="+url.QueryEscape(resp.NextCursor)+"&limit=1")
|
|
if !reflect.DeepEqual(next.Lines, []string{"second"}) {
|
|
t.Fatalf("next lines = %#v, want newer rotated line", next.Lines)
|
|
}
|
|
if next.CursorReset {
|
|
t.Fatal("next cursor-reset = true, want false")
|
|
}
|
|
|
|
latest := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?cursor="+url.QueryEscape(next.NextCursor)+"&limit=1")
|
|
if !reflect.DeepEqual(latest.Lines, []string{"third"}) {
|
|
t.Fatalf("latest lines = %#v, want main line", latest.Lines)
|
|
}
|
|
if latest.CursorReset {
|
|
t.Fatal("latest cursor-reset = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestGetLogsZeroOffsetCursorWithEmptyFileResetsWhenRotationModTimeAmbiguous(t *testing.T) {
|
|
dir := t.TempDir()
|
|
mainPath := filepath.Join(dir, defaultLogFileName)
|
|
fixedModTime := time.Date(2026, 6, 15, 10, 0, 0, 0, time.Local)
|
|
writeMainLog(t, dir, "")
|
|
if err := os.Chtimes(mainPath, fixedModTime, fixedModTime); err != nil {
|
|
t.Fatalf("set initial main mtime: %v", err)
|
|
}
|
|
initial := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?limit=1")
|
|
if initial.NextCursor == "" {
|
|
t.Fatal("initial next-cursor is empty")
|
|
}
|
|
cursor, errCursor := decodeLogCursor(initial.NextCursor)
|
|
if errCursor != nil {
|
|
t.Fatalf("decode initial cursor: %v", errCursor)
|
|
}
|
|
if cursor.Offset != 0 || cursor.Size != 0 {
|
|
t.Fatalf("cursor offset/size = %d/%d, want empty zero offset", cursor.Offset, cursor.Size)
|
|
}
|
|
|
|
first := "[2026-06-15 10:00:01] first"
|
|
second := "[2026-06-15 10:00:02] second"
|
|
appendMainLog(t, dir, first+"\n")
|
|
if err := os.Chtimes(mainPath, fixedModTime, fixedModTime); err != nil {
|
|
t.Fatalf("set rotated mtime: %v", err)
|
|
}
|
|
if err := os.Rename(mainPath, filepath.Join(dir, defaultLogFileName+".1")); err != nil {
|
|
t.Fatalf("rotate main log: %v", err)
|
|
}
|
|
writeMainLog(t, dir, second+"\n")
|
|
if err := os.Chtimes(mainPath, fixedModTime, fixedModTime); err != nil {
|
|
t.Fatalf("set new main mtime: %v", err)
|
|
}
|
|
|
|
resp := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?cursor="+url.QueryEscape(initial.NextCursor)+"&limit=2")
|
|
wantLines := []string{first, second}
|
|
if !reflect.DeepEqual(resp.Lines, wantLines) {
|
|
t.Fatalf("lines = %#v, want %#v", resp.Lines, wantLines)
|
|
}
|
|
if !resp.CursorReset {
|
|
t.Fatal("cursor-reset = false, want true for ambiguous empty cursor rotation")
|
|
}
|
|
if resp.LineCount != len(wantLines) {
|
|
t.Fatalf("line-count = %d, want returned line count %d", resp.LineCount, len(wantLines))
|
|
}
|
|
}
|
|
|
|
func TestGetLogsInvalidCursorResetsToTail(t *testing.T) {
|
|
dir := t.TempDir()
|
|
lines := []string{
|
|
"[2026-06-15 10:00:00] first",
|
|
"[2026-06-15 10:00:01] second",
|
|
}
|
|
writeMainLog(t, dir, strings.Join(lines, "\n")+"\n")
|
|
|
|
cases := []string{
|
|
"not-base64",
|
|
mustEncodeRawCursor(t, logCursor{
|
|
Version: logCursorVersion,
|
|
File: "../secret",
|
|
Fingerprint: "fingerprint",
|
|
}),
|
|
}
|
|
for _, raw := range cases {
|
|
resp := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?cursor="+url.QueryEscape(raw)+"&limit=1")
|
|
if !resp.CursorReset {
|
|
t.Fatalf("cursor-reset = false for cursor %q", raw)
|
|
}
|
|
if !reflect.DeepEqual(resp.Lines, []string{lines[1]}) {
|
|
t.Fatalf("lines = %#v, want latest line", resp.Lines)
|
|
}
|
|
if resp.LineCount != 1 {
|
|
t.Fatalf("line-count = %d, want 1", resp.LineCount)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGetLogsMissingRotatedCursorFileResetsToTail(t *testing.T) {
|
|
dir := t.TempDir()
|
|
current := "[2026-06-15 10:00:01] current"
|
|
writeMainLog(t, dir, current+"\n")
|
|
rotatedPath := filepath.Join(dir, defaultLogFileName+".1")
|
|
if err := os.WriteFile(rotatedPath, []byte("[2026-06-15 10:00:00] old\n"), 0o644); err != nil {
|
|
t.Fatalf("write rotated log: %v", err)
|
|
}
|
|
cursor, errCursor := newLogCursor(rotatedPath, int64(len("[2026-06-15 10:00:00] old\n")), 0)
|
|
if errCursor != nil {
|
|
t.Fatalf("newLogCursor() error = %v", errCursor)
|
|
}
|
|
if errRemove := os.Remove(rotatedPath); errRemove != nil {
|
|
t.Fatalf("remove rotated log: %v", errRemove)
|
|
}
|
|
|
|
resp := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?cursor="+url.QueryEscape(cursor)+"&limit=1")
|
|
if !resp.CursorReset {
|
|
t.Fatal("cursor-reset = false, want true")
|
|
}
|
|
if !reflect.DeepEqual(resp.Lines, []string{current}) {
|
|
t.Fatalf("lines = %#v, want current tail", resp.Lines)
|
|
}
|
|
}
|
|
|
|
func TestGetLogsMissingLogDirKeepsOKEmptyResponse(t *testing.T) {
|
|
dir := filepath.Join(t.TempDir(), "missing")
|
|
resp := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?cursor="+url.QueryEscape("not-base64")+"&limit=1")
|
|
if len(resp.Lines) != 0 {
|
|
t.Fatalf("lines = %#v, want empty", resp.Lines)
|
|
}
|
|
if resp.LineCount != 0 {
|
|
t.Fatalf("line-count = %d, want 0", resp.LineCount)
|
|
}
|
|
if !resp.CursorReset {
|
|
t.Fatal("cursor-reset = false, want true for cursor against missing log dir")
|
|
}
|
|
}
|
|
|
|
func TestGetLogsLoggingDisabledKeepsBadRequest(t *testing.T) {
|
|
status, body := performGetLogsRaw(t, newLogsTestHandler(t.TempDir(), false), "/v0/management/logs?cursor=not-base64&limit=1")
|
|
if status != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d", status, http.StatusBadRequest)
|
|
}
|
|
if !strings.Contains(body, "logging to file disabled") {
|
|
t.Fatalf("body = %s, want logging disabled error", body)
|
|
}
|
|
}
|
|
|
|
func mustEncodeRawCursor(t *testing.T, cursor logCursor) string {
|
|
t.Helper()
|
|
raw, err := json.Marshal(cursor)
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal cursor: %v", err)
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(raw)
|
|
}
|
|
|
|
type logsAPIResponse struct {
|
|
Lines []string `json:"lines"`
|
|
LineCount int `json:"line-count"`
|
|
LatestTimestamp int64 `json:"latest-timestamp"`
|
|
NextCursor string `json:"next-cursor"`
|
|
CursorReset bool `json:"cursor-reset"`
|
|
}
|
|
|
|
func newLogsTestHandler(dir string, loggingToFile bool) *Handler {
|
|
h := NewHandlerWithoutConfigFilePath(&config.Config{LoggingToFile: loggingToFile}, nil)
|
|
h.SetLogDirectory(dir)
|
|
return h
|
|
}
|
|
|
|
func performGetLogs(t *testing.T, h *Handler, target string) logsAPIResponse {
|
|
t.Helper()
|
|
status, body := performGetLogsRaw(t, h, target)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("GetLogs status = %d, body = %s", status, body)
|
|
}
|
|
var resp logsAPIResponse
|
|
if err := json.Unmarshal([]byte(body), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp.Lines == nil {
|
|
resp.Lines = []string{}
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func performGetLogsRaw(t *testing.T, h *Handler, target string) (int, string) {
|
|
t.Helper()
|
|
rec := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(rec)
|
|
c.Request = httptest.NewRequest(http.MethodGet, target, nil)
|
|
h.GetLogs(c)
|
|
return rec.Code, rec.Body.String()
|
|
}
|
|
|
|
func writeMainLog(t *testing.T, dir, content string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(filepath.Join(dir, defaultLogFileName), []byte(content), 0o644); err != nil {
|
|
t.Fatalf("write main log: %v", err)
|
|
}
|
|
}
|
|
|
|
func appendMainLog(t *testing.T, dir, content string) {
|
|
t.Helper()
|
|
file, errOpen := os.OpenFile(filepath.Join(dir, defaultLogFileName), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
|
if errOpen != nil {
|
|
t.Fatalf("open main log: %v", errOpen)
|
|
}
|
|
if _, errWrite := file.WriteString(content); errWrite != nil {
|
|
_ = file.Close()
|
|
t.Fatalf("append main log: %v", errWrite)
|
|
}
|
|
if errClose := file.Close(); errClose != nil {
|
|
t.Fatalf("close main log: %v", errClose)
|
|
}
|
|
}
|