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:
Ryan Clark
2026-06-11 09:32:54 +00:00
committed by GitHub
parent 161503d5d1
commit aedcd1d851
3 changed files with 385 additions and 0 deletions
@@ -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)
}