From aedcd1d851cdf06eafd2d43f1eb33879539ac89e Mon Sep 17 00:00:00 2001 From: Ryan Clark Date: Thu, 11 Jun 2026 11:32:54 +0200 Subject: [PATCH] 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 --- .../desktopthumbnail_property_test.go | 119 ++++++++++++++ .../recordingmetadata_property_test.go | 112 +++++++++++++ .../ttythumbnail_property_test.go | 154 ++++++++++++++++++ 3 files changed, 385 insertions(+) create mode 100644 lib/auth/recordingmetadata/recordingmetadatav1/desktopthumbnail_property_test.go create mode 100644 lib/auth/recordingmetadata/recordingmetadatav1/recordingmetadata_property_test.go create mode 100644 lib/auth/recordingmetadata/recordingmetadatav1/ttythumbnail_property_test.go 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) +}