diff --git a/lib/auth/recordingmetadata/recordingmetadatav1/desktopthumbnail_property_test.go b/lib/auth/recordingmetadata/recordingmetadatav1/desktopthumbnail_property_test.go
new file mode 100644
index 00000000000..9eaa6e1021a
--- /dev/null
+++ b/lib/auth/recordingmetadata/recordingmetadatav1/desktopthumbnail_property_test.go
@@ -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 .
+ */
+
+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")),
+ }
+}
diff --git a/lib/auth/recordingmetadata/recordingmetadatav1/recordingmetadata_property_test.go b/lib/auth/recordingmetadata/recordingmetadatav1/recordingmetadata_property_test.go
new file mode 100644
index 00000000000..ec0908bd599
--- /dev/null
+++ b/lib/auth/recordingmetadata/recordingmetadatav1/recordingmetadata_property_test.go
@@ -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 .
+ */
+
+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")
+}
diff --git a/lib/auth/recordingmetadata/recordingmetadatav1/ttythumbnail_property_test.go b/lib/auth/recordingmetadata/recordingmetadatav1/ttythumbnail_property_test.go
new file mode 100644
index 00000000000..b8a5b1efcf3
--- /dev/null
+++ b/lib/auth/recordingmetadata/recordingmetadatav1/ttythumbnail_property_test.go
@@ -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 .
+ */
+
+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)
+}