mirror of
https://github.com/gravitational/teleport.git
synced 2026-09-19 01:58:44 +08:00
Add property-based testing for thumbnails/metadata generation (#67324)
* Add property-based testing for thumbnails/metadata generation * Convert the resize test to a normal table test * Increase the timeouts * Reduce dimensions to avoid CI from dying
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Teleport
|
||||
* Copyright (C) 2026 Gravitational, Inc.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package recordingmetadatav1
|
||||
|
||||
import (
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"pgregory.net/rapid"
|
||||
|
||||
"github.com/gravitational/teleport/lib/srv/desktop/rdp/decoder"
|
||||
)
|
||||
|
||||
func TestProperty_CalculateCropBounds_NonNegativeOrigin(t *testing.T) {
|
||||
rapid.Check(t, func(t *rapid.T) {
|
||||
bounds := genScreenBounds(t)
|
||||
cursor := genCursor(t)
|
||||
result := calculateCropBounds(bounds, cursor)
|
||||
|
||||
require.GreaterOrEqual(t, result.Min.X, 0, "result=%v bounds=%v cursor=%+v", result, bounds, cursor)
|
||||
require.GreaterOrEqual(t, result.Min.Y, 0, "result=%v bounds=%v cursor=%+v", result, bounds, cursor)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProperty_CalculateCropBounds_WithinScreen(t *testing.T) {
|
||||
rapid.Check(t, func(t *rapid.T) {
|
||||
bounds := genScreenBounds(t)
|
||||
cursor := genCursor(t)
|
||||
result := calculateCropBounds(bounds, cursor)
|
||||
|
||||
require.LessOrEqual(t, result.Max.X, bounds.Max.X, "result=%v bounds=%v cursor=%+v", result, bounds, cursor)
|
||||
require.LessOrEqual(t, result.Max.Y, bounds.Max.Y, "result=%v bounds=%v cursor=%+v", result, bounds, cursor)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProperty_CalculateCropBounds_AtMostHalfPlusOne(t *testing.T) {
|
||||
rapid.Check(t, func(t *rapid.T) {
|
||||
bounds := genScreenBounds(t)
|
||||
cursor := genCursor(t)
|
||||
result := calculateCropBounds(bounds, cursor)
|
||||
|
||||
require.LessOrEqual(t, result.Dx(), bounds.Dx()/2+1, "result.Dx=%d bounds.Dx=%d", result.Dx(), bounds.Dx())
|
||||
require.LessOrEqual(t, result.Dy(), bounds.Dy()/2+1, "result.Dy=%d bounds.Dy=%d", result.Dy(), bounds.Dy())
|
||||
})
|
||||
}
|
||||
|
||||
func TestProperty_CalculateCropBounds_ContainsCursorWhenOnScreen(t *testing.T) {
|
||||
rapid.Check(t, func(t *rapid.T) {
|
||||
// Below 4px per axis the cursor-centered crop can be empty and exclude the cursor.
|
||||
w := rapid.IntRange(4, 8192).Draw(t, "screen_w")
|
||||
h := rapid.IntRange(4, 8192).Draw(t, "screen_h")
|
||||
|
||||
bounds := image.Rect(0, 0, w, h)
|
||||
cursor := decoder.CursorState{
|
||||
Visible: rapid.Bool().Draw(t, "visible"),
|
||||
X: uint16(rapid.IntRange(0, w-1).Draw(t, "cursor_x")),
|
||||
Y: uint16(rapid.IntRange(0, h-1).Draw(t, "cursor_y")),
|
||||
}
|
||||
|
||||
result := calculateCropBounds(bounds, cursor)
|
||||
|
||||
require.True(t,
|
||||
int(cursor.X) >= result.Min.X && int(cursor.X) < result.Max.X,
|
||||
"cursor.X=%d not in result.X=[%d,%d) bounds=%v",
|
||||
cursor.X, result.Min.X, result.Max.X, bounds)
|
||||
require.True(t,
|
||||
int(cursor.Y) >= result.Min.Y && int(cursor.Y) < result.Max.Y,
|
||||
"cursor.Y=%d not in result.Y=[%d,%d) bounds=%v",
|
||||
cursor.Y, result.Min.Y, result.Max.Y, bounds)
|
||||
})
|
||||
}
|
||||
|
||||
// genScreenBounds generates a screen rectangle anchored at (0,0). Matches the real call-site shape where bounds come
|
||||
// from RDP framebuffer dimensions.
|
||||
func genScreenBounds(t *rapid.T) image.Rectangle {
|
||||
t.Helper()
|
||||
|
||||
w := rapid.OneOf(
|
||||
rapid.Just(0),
|
||||
rapid.Just(1),
|
||||
rapid.Just(2),
|
||||
rapid.IntRange(2, 8192),
|
||||
).Draw(t, "screen_w")
|
||||
h := rapid.OneOf(
|
||||
rapid.Just(0),
|
||||
rapid.Just(1),
|
||||
rapid.Just(2),
|
||||
rapid.IntRange(2, 8192),
|
||||
).Draw(t, "screen_h")
|
||||
|
||||
return image.Rect(0, 0, w, h)
|
||||
}
|
||||
|
||||
func genCursor(t *rapid.T) decoder.CursorState {
|
||||
t.Helper()
|
||||
|
||||
return decoder.CursorState{
|
||||
Visible: rapid.Bool().Draw(t, "visible"),
|
||||
X: uint16(rapid.IntRange(0, 65535).Draw(t, "cursor_x")),
|
||||
Y: uint16(rapid.IntRange(0, 65535).Draw(t, "cursor_y")),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Teleport
|
||||
* Copyright (C) 2026 Gravitational, Inc.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package recordingmetadatav1
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"pgregory.net/rapid"
|
||||
)
|
||||
|
||||
func TestProperty_GetRandomThumbnailTime_InDurationRange(t *testing.T) {
|
||||
rapid.Check(t, func(t *rapid.T) {
|
||||
duration := genDuration(t)
|
||||
result := getRandomThumbnailTime(duration)
|
||||
require.GreaterOrEqual(t, int64(result), int64(0), "duration=%v result=%v", duration, result)
|
||||
require.LessOrEqual(t, int64(result), int64(duration), "duration=%v result=%v", duration, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProperty_GetRandomThumbnailTime_RespectsTwentyEightyWindow(t *testing.T) {
|
||||
rapid.Check(t, func(t *rapid.T) {
|
||||
duration := genDuration(t)
|
||||
minIdx := int64(0.2 * float64(duration))
|
||||
maxIdx := int64(0.8 * float64(duration))
|
||||
result := getRandomThumbnailTime(duration)
|
||||
|
||||
if maxIdx > minIdx {
|
||||
require.GreaterOrEqual(t, int64(result), minIdx, "duration=%v result=%v", duration, result)
|
||||
require.Less(t, int64(result), maxIdx, "duration=%v result=%v", duration, result)
|
||||
} else {
|
||||
require.Equal(t, int64(duration)/2, int64(result), "duration=%v result=%v", duration, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestProperty_CalculateThumbnailInterval_AtLeastMinInterval(t *testing.T) {
|
||||
rapid.Check(t, func(t *rapid.T) {
|
||||
duration := genDuration(t)
|
||||
maxThumbnails := rapid.IntRange(1, 10_000).Draw(t, "max_thumbnails")
|
||||
minInterval := time.Duration(rapid.Int64Range(0, int64(5*time.Minute)).Draw(t, "min_interval"))
|
||||
|
||||
result := calculateThumbnailInterval(duration, maxThumbnails, minInterval)
|
||||
require.GreaterOrEqual(t, int64(result), int64(minInterval),
|
||||
"duration=%v max=%d minInterval=%v result=%v", duration, maxThumbnails, minInterval, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProperty_CalculateThumbnailInterval_MonotonicInDuration(t *testing.T) {
|
||||
rapid.Check(t, func(t *rapid.T) {
|
||||
maxThumbnails := rapid.IntRange(1, 10_000).Draw(t, "max_thumbnails")
|
||||
minInterval := time.Duration(rapid.Int64Range(0, int64(5*time.Minute)).Draw(t, "min_interval"))
|
||||
d1 := time.Duration(rapid.Int64Range(0, int64(24*time.Hour)).Draw(t, "d1"))
|
||||
extra := time.Duration(rapid.Int64Range(0, int64(24*time.Hour)).Draw(t, "extra"))
|
||||
d2 := d1 + extra
|
||||
|
||||
r1 := calculateThumbnailInterval(d1, maxThumbnails, minInterval)
|
||||
r2 := calculateThumbnailInterval(d2, maxThumbnails, minInterval)
|
||||
|
||||
require.LessOrEqual(t, int64(r1), int64(r2),
|
||||
"non-monotonic: d1=%v d2=%v max=%d min=%v r1=%v r2=%v",
|
||||
d1, d2, maxThumbnails, minInterval, r1, r2)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProperty_CalculateThumbnailInterval_RoundedToSecondsAboveMin(t *testing.T) {
|
||||
rapid.Check(t, func(t *rapid.T) {
|
||||
duration := time.Duration(rapid.Int64Range(0, int64(48*time.Hour)).Draw(t, "duration"))
|
||||
maxThumbnails := rapid.IntRange(1, 10_000).Draw(t, "max_thumbnails")
|
||||
|
||||
minIntervalSec := rapid.IntRange(0, 300).Draw(t, "min_interval_s")
|
||||
minInterval := time.Duration(minIntervalSec) * time.Second
|
||||
|
||||
result := calculateThumbnailInterval(duration, maxThumbnails, minInterval)
|
||||
|
||||
require.Equal(t, time.Duration(0), result%time.Second,
|
||||
"non-rounded: duration=%v max=%d min=%v result=%v", duration, maxThumbnails, minInterval, result)
|
||||
})
|
||||
}
|
||||
|
||||
// genDuration produces durations biased toward edge cases (0, 1ns) and realistic session lengths.
|
||||
func genDuration(t *rapid.T) time.Duration {
|
||||
t.Helper()
|
||||
|
||||
return rapid.OneOf(
|
||||
rapid.Just(time.Duration(0)),
|
||||
rapid.Just(time.Duration(1)),
|
||||
rapid.Just(time.Nanosecond),
|
||||
rapid.Just(time.Second),
|
||||
rapid.Just(time.Hour),
|
||||
rapid.Map(rapid.Int64Range(0, int64(48*time.Hour)), func(n int64) time.Duration {
|
||||
return time.Duration(n)
|
||||
}),
|
||||
).Draw(t, "duration")
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Teleport
|
||||
* Copyright (C) 2026 Gravitational, Inc.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package recordingmetadatav1
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pgregory.net/rapid"
|
||||
|
||||
apievents "github.com/gravitational/teleport/api/types/events"
|
||||
"github.com/gravitational/teleport/lib/utils/testutils"
|
||||
)
|
||||
|
||||
func TestProperty_TTYThumbnail_NeverPanicsOnRandomEventSequence(t *testing.T) {
|
||||
rapid.Check(t, func(t *rapid.T) {
|
||||
count := rapid.IntRange(0, 30).Draw(t, "count")
|
||||
events := make([]apievents.AuditEvent, count)
|
||||
for i := range events {
|
||||
events[i] = genEvent(t, "evt")
|
||||
}
|
||||
|
||||
testutils.RunWithTimeout(t, 10*time.Second, func() {
|
||||
gen := newTTYThumbnailGenerator()
|
||||
defer gen.release()
|
||||
|
||||
for _, evt := range events {
|
||||
_ = gen.handleEvent(evt)
|
||||
}
|
||||
_, _ = gen.produceThumbnail(0)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
var extremeResizeSizes = []string{
|
||||
"0:0", "1:0", "0:1", "1:1", "2:2",
|
||||
"2048:1", "1:2048",
|
||||
"2049:1", "1:2049",
|
||||
"99999:99999", "9999999:9999999",
|
||||
}
|
||||
|
||||
func TestTTYThumbnail_NeverPanicsOnZeroOrExtremeResize(t *testing.T) {
|
||||
for _, size := range extremeResizeSizes {
|
||||
t.Run(size, func(t *testing.T) {
|
||||
testutils.RunWithTimeout(t, 10*time.Second, func() {
|
||||
gen := newTTYThumbnailGenerator()
|
||||
defer gen.release()
|
||||
|
||||
_ = gen.handleEvent(&apievents.SessionStart{TerminalSize: size})
|
||||
_ = gen.handleEvent(&apievents.SessionPrint{Data: []byte("hello world\r\n")})
|
||||
_, _ = gen.produceThumbnail(0)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProperty_TTYThumbnail_NeverPanicsBeforeSessionStart(t *testing.T) {
|
||||
rapid.Check(t, func(t *rapid.T) {
|
||||
// SessionPrint and Resize delivered without prior SessionStart.
|
||||
data := rapid.SliceOfN(rapid.Byte(), 0, 512).Draw(t, "data")
|
||||
|
||||
testutils.RunWithTimeout(t, 10*time.Second, func() {
|
||||
gen := newTTYThumbnailGenerator()
|
||||
defer gen.release()
|
||||
|
||||
_ = gen.handleEvent(&apievents.SessionPrint{Data: data})
|
||||
_ = gen.handleEvent(&apievents.Resize{TerminalSize: "80:24"})
|
||||
_, _ = gen.produceThumbnail(0)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
var ttyEventKinds = []string{
|
||||
"start", "resize", "print", "print_ansi", "print_long", "other",
|
||||
}
|
||||
|
||||
// genEvent produces a random TTY event. Bias toward control sequences in SessionPrint so the vt10x parser exercises
|
||||
// escape-code paths.
|
||||
func genEvent(t *rapid.T, label string) apievents.AuditEvent {
|
||||
t.Helper()
|
||||
|
||||
kind := rapid.SampledFrom(ttyEventKinds).Draw(t, label+"_kind")
|
||||
|
||||
switch kind {
|
||||
case "start":
|
||||
return &apievents.SessionStart{TerminalSize: genTerminalSize(t, label+"_size")}
|
||||
|
||||
case "resize":
|
||||
return &apievents.Resize{TerminalSize: genTerminalSize(t, label+"_size")}
|
||||
|
||||
case "print":
|
||||
return &apievents.SessionPrint{
|
||||
Data: rapid.SliceOfN(rapid.Byte(), 0, 128).Draw(t, label+"_data"),
|
||||
}
|
||||
|
||||
case "print_ansi":
|
||||
return &apievents.SessionPrint{
|
||||
Data: rapid.SliceOfN(
|
||||
rapid.OneOf(
|
||||
rapid.Just(byte(0x1b)),
|
||||
rapid.Just(byte('[')),
|
||||
rapid.Just(byte(']')),
|
||||
rapid.Just(byte('?')),
|
||||
rapid.Just(byte('h')),
|
||||
rapid.Just(byte('l')),
|
||||
rapid.Byte(),
|
||||
),
|
||||
0, 128,
|
||||
).Draw(t, label+"_ansi"),
|
||||
}
|
||||
|
||||
case "print_long":
|
||||
return &apievents.SessionPrint{
|
||||
Data: rapid.SliceOfN(rapid.Byte(), 256, 1024).Draw(t, label+"_long"),
|
||||
}
|
||||
|
||||
default:
|
||||
return &apievents.SessionEnd{}
|
||||
}
|
||||
}
|
||||
|
||||
// genTerminalSize produces "W:H" strings biased toward edge cases, including values exceeding the vt10x resize cap
|
||||
// (2048 per dimension), which the terminal silently ignores. UnmarshalTerminalParams itself imposes no range limit.
|
||||
func genTerminalSize(t *rapid.T, label string) string {
|
||||
t.Helper()
|
||||
|
||||
return rapid.OneOf(
|
||||
rapid.Just(""),
|
||||
rapid.Just("0:0"),
|
||||
rapid.Just("1:1"),
|
||||
rapid.Just("80:24"),
|
||||
rapid.Just("2048:1"),
|
||||
rapid.Just("1:2048"),
|
||||
rapid.Just("99999:99999"),
|
||||
rapid.Just("not-a-size"),
|
||||
rapid.StringN(0, 32, -1),
|
||||
).Draw(t, label)
|
||||
}
|
||||
Reference in New Issue
Block a user