|
| 1 | +using System.Data.Common; |
| 2 | +using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert; |
| 3 | + |
| 4 | +namespace SqlServerSimulator; |
| 5 | + |
| 6 | +/// <summary> |
| 7 | +/// Behavioral tests for the <c>FORMATMESSAGE</c>, <c>PWDENCRYPT</c> / |
| 8 | +/// <c>PWDCOMPARE</c>, and <c>LOGINPROPERTY</c> scalar built-ins. All expected |
| 9 | +/// values probe-confirmed against SQL Server 2025 (2026-07-10). |
| 10 | +/// </summary> |
| 11 | +[TestClass] |
| 12 | +public sealed class FormatMessageAndLoginScalarTests |
| 13 | +{ |
| 14 | + /// <summary> |
| 15 | + /// The terse in-server formatting-error diagnostic returned (as data, not |
| 16 | + /// thrown) for a recoverable FORMATMESSAGE failure. Byte-exact from the |
| 17 | + /// live server, trailing CRLF included. |
| 18 | + /// </summary> |
| 19 | + private const string TerseError = |
| 20 | + "Error: 50000, Severity: -1, State: 1. (Params:). The error is printed in terse mode because there was error during formatting. Tracing, ETW, notifications etc are skipped.\r\n"; |
| 21 | + |
| 22 | + private static object? Scalar(string sql) => new Simulation().ExecuteScalar(sql); |
| 23 | + |
| 24 | + private static void AssertMsg(string sql, int errorNumber) |
| 25 | + { |
| 26 | + var ex = Throws<DbException>(() => Scalar(sql)); |
| 27 | + AreEqual(errorNumber.ToString(), ex.Data["HelpLink.EvtID"], $"expected Msg {errorNumber}"); |
| 28 | + } |
| 29 | + |
| 30 | + // ---- FORMATMESSAGE: core specifiers ---- |
| 31 | + |
| 32 | + [TestMethod] |
| 33 | + public void FormatMessage_StringAndInt() |
| 34 | + => AreEqual("hi world and 42", Scalar("select formatmessage('hi %s and %d', 'world', 42)")); |
| 35 | + |
| 36 | + [TestMethod] |
| 37 | + public void FormatMessage_IntSynonym() |
| 38 | + => AreEqual("num 42", Scalar("select formatmessage('num %i', 42)")); |
| 39 | + |
| 40 | + [TestMethod] |
| 41 | + public void FormatMessage_HexLowerUpper() |
| 42 | + { |
| 43 | + AreEqual("hex ff", Scalar("select formatmessage('hex %x', 255)")); |
| 44 | + AreEqual("HEX FF", Scalar("select formatmessage('HEX %X', 255)")); |
| 45 | + } |
| 46 | + |
| 47 | + [TestMethod] |
| 48 | + public void FormatMessage_Octal() |
| 49 | + => AreEqual("oct 100", Scalar("select formatmessage('oct %o', 64)")); |
| 50 | + |
| 51 | + [TestMethod] |
| 52 | + public void FormatMessage_UnsignedReinterpretsNegative() |
| 53 | + => AreEqual("uns 4294967295", Scalar("select formatmessage('uns %u', -1)")); |
| 54 | + |
| 55 | + // ---- FORMATMESSAGE: width / flags / precision ---- |
| 56 | + |
| 57 | + [TestMethod] |
| 58 | + public void FormatMessage_WidthRightAlign() |
| 59 | + => AreEqual("[ 42]", Scalar("select formatmessage('[%5d]', 42)")); |
| 60 | + |
| 61 | + [TestMethod] |
| 62 | + public void FormatMessage_WidthLeftAlign() |
| 63 | + => AreEqual("[42 ]", Scalar("select formatmessage('[%-5d]', 42)")); |
| 64 | + |
| 65 | + [TestMethod] |
| 66 | + public void FormatMessage_ZeroPad() |
| 67 | + => AreEqual("[00042]", Scalar("select formatmessage('[%05d]', 42)")); |
| 68 | + |
| 69 | + [TestMethod] |
| 70 | + public void FormatMessage_ForceSign() |
| 71 | + { |
| 72 | + AreEqual("[+42]", Scalar("select formatmessage('[%+d]', 42)")); |
| 73 | + AreEqual("[-42]", Scalar("select formatmessage('[%+d]', -42)")); |
| 74 | + } |
| 75 | + |
| 76 | + [TestMethod] |
| 77 | + public void FormatMessage_PrecisionZeroPadsInteger() |
| 78 | + => AreEqual("[005]", Scalar("select formatmessage('[%.3d]', 5)")); |
| 79 | + |
| 80 | + [TestMethod] |
| 81 | + public void FormatMessage_AltFormHexPrefix() |
| 82 | + => AreEqual("[0xff]", Scalar("select formatmessage('[%#x]', 255)")); |
| 83 | + |
| 84 | + [TestMethod] |
| 85 | + public void FormatMessage_HexWidthZeroPad() |
| 86 | + => AreEqual("[000000FF]", Scalar("select formatmessage('[%08X]', 255)")); |
| 87 | + |
| 88 | + [TestMethod] |
| 89 | + public void FormatMessage_StarWidthConsumesArgument() |
| 90 | + => AreEqual("[ 42]", Scalar("select formatmessage('[%*d]', 5, 42)")); |
| 91 | + |
| 92 | + // ---- FORMATMESSAGE: length modifiers ---- |
| 93 | + |
| 94 | + [TestMethod] |
| 95 | + public void FormatMessage_LongModifierIgnored() |
| 96 | + => AreEqual("val=[42]", Scalar("select formatmessage('val=[%ld]', 42)")); |
| 97 | + |
| 98 | + [TestMethod] |
| 99 | + public void FormatMessage_Int64Modifier() |
| 100 | + => AreEqual("val=[9999999999]", Scalar("select formatmessage('val=[%I64d]', cast(9999999999 as bigint))")); |
| 101 | + |
| 102 | + // ---- FORMATMESSAGE: literal / escape / arg-count ---- |
| 103 | + |
| 104 | + [TestMethod] |
| 105 | + public void FormatMessage_PercentEscape() |
| 106 | + => AreEqual("100% done", Scalar("select formatmessage('100%% done')")); |
| 107 | + |
| 108 | + [TestMethod] |
| 109 | + public void FormatMessage_TooFewArgsRendersNull() |
| 110 | + => AreEqual("a one b (null)", Scalar("select formatmessage('a %s b %s', 'one')")); |
| 111 | + |
| 112 | + [TestMethod] |
| 113 | + public void FormatMessage_TooManyArgsIgnored() |
| 114 | + => AreEqual("a one", Scalar("select formatmessage('a %s', 'one', 'two')")); |
| 115 | + |
| 116 | + // ---- FORMATMESSAGE: NULL handling ---- |
| 117 | + |
| 118 | + [TestMethod] |
| 119 | + public void FormatMessage_NullFormatReturnsNull() |
| 120 | + => AreEqual(DBNull.Value, Scalar("select formatmessage(cast(null as nvarchar(100)), 'x')")); |
| 121 | + |
| 122 | + [TestMethod] |
| 123 | + public void FormatMessage_NullStringArgRendersNull() |
| 124 | + => AreEqual("val=[(null)]", Scalar("select formatmessage('val=[%s]', cast(null as nvarchar(10)))")); |
| 125 | + |
| 126 | + [TestMethod] |
| 127 | + public void FormatMessage_NullIntArgRendersNull() |
| 128 | + => AreEqual("val=[(null)]", Scalar("select formatmessage('val=[%d]', cast(null as int))")); |
| 129 | + |
| 130 | + // ---- FORMATMESSAGE: terse-error recoverable failures ---- |
| 131 | + |
| 132 | + [TestMethod] |
| 133 | + public void FormatMessage_IntIntoStringSpecifier_Terse() |
| 134 | + => AreEqual(TerseError, Scalar("select formatmessage('val=[%s]', 42)")); |
| 135 | + |
| 136 | + [TestMethod] |
| 137 | + public void FormatMessage_StringIntoIntSpecifier_Terse() |
| 138 | + => AreEqual(TerseError, Scalar("select formatmessage('val=[%d]', 'abc')")); |
| 139 | + |
| 140 | + [TestMethod] |
| 141 | + public void FormatMessage_BigIntIntoNarrowSpecifier_Terse() |
| 142 | + => AreEqual(TerseError, Scalar("select formatmessage('val=[%d]', cast(9999999999 as bigint))")); |
| 143 | + |
| 144 | + [TestMethod] |
| 145 | + public void FormatMessage_IntIntoInt64Specifier_Terse() |
| 146 | + => AreEqual(TerseError, Scalar("select formatmessage('val=[%I64d]', 5)")); |
| 147 | + |
| 148 | + [TestMethod] |
| 149 | + public void FormatMessage_EmptyFormat_Terse() |
| 150 | + => AreEqual(TerseError, Scalar("select formatmessage('')")); |
| 151 | + |
| 152 | + // ---- FORMATMESSAGE: Msg 2748 disallowed substitution types ---- |
| 153 | + |
| 154 | + [TestMethod] |
| 155 | + public void FormatMessage_FloatArg_Msg2748() |
| 156 | + => AssertMsg("select formatmessage('%d', cast(5 as float))", 2748); |
| 157 | + |
| 158 | + [TestMethod] |
| 159 | + public void FormatMessage_MoneyArg_Msg2748() |
| 160 | + => AssertMsg("select formatmessage('%d', cast(5 as money))", 2748); |
| 161 | + |
| 162 | + [TestMethod] |
| 163 | + public void FormatMessage_DateTimeArg_Msg2748() |
| 164 | + => AssertMsg("select formatmessage('%d', cast('2020-01-01' as datetime))", 2748); |
| 165 | + |
| 166 | + [TestMethod] |
| 167 | + public void FormatMessage_Msg2748_MessageWordingAndParamIndex() |
| 168 | + { |
| 169 | + var ex = Throws<DbException>(() => Scalar("select formatmessage('%d', cast(5 as float))")); |
| 170 | + AreEqual("Cannot specify float data type (parameter 1) as a substitution parameter.", ex.Message); |
| 171 | + } |
| 172 | + |
| 173 | + // ---- FORMATMESSAGE: msg_id overload + truncation ---- |
| 174 | + |
| 175 | + [TestMethod] |
| 176 | + public void FormatMessage_UnknownMessageId_ReturnsNull() |
| 177 | + { |
| 178 | + AreEqual(DBNull.Value, Scalar("select formatmessage(50000, 'x')")); |
| 179 | + AreEqual(DBNull.Value, Scalar("select formatmessage(99999999)")); |
| 180 | + } |
| 181 | + |
| 182 | + [TestMethod] |
| 183 | + public void FormatMessage_ResultTruncatesTo2047Chars() |
| 184 | + => AreEqual(4094, Scalar("select datalength(formatmessage('%s', replicate(cast('a' as nvarchar(max)), 5000)))")); |
| 185 | + |
| 186 | + // ---- PWDENCRYPT / PWDCOMPARE ---- |
| 187 | + |
| 188 | + [TestMethod] |
| 189 | + public void PwdEncrypt_ProducesSeventyByteHash() |
| 190 | + { |
| 191 | + var hash = (byte[])new Simulation().ExecuteScalar("select pwdencrypt('abc')")!; |
| 192 | + HasCount(70, hash); |
| 193 | + AreEqual((byte)0x03, hash[0]); |
| 194 | + AreEqual((byte)0x00, hash[1]); |
| 195 | + } |
| 196 | + |
| 197 | + [TestMethod] |
| 198 | + public void PwdEncrypt_DataLengthIs70() |
| 199 | + => AreEqual(70, Scalar("select datalength(pwdencrypt('abc'))")); |
| 200 | + |
| 201 | + [TestMethod] |
| 202 | + public void PwdEncrypt_SamePasswordDiffersBySalt() |
| 203 | + { |
| 204 | + using var connection = new Simulation().CreateOpenConnection(); |
| 205 | + var a = (byte[])connection.CreateCommand("select pwdencrypt('abc')").ExecuteScalar()!; |
| 206 | + var b = (byte[])connection.CreateCommand("select pwdencrypt('abc')").ExecuteScalar()!; |
| 207 | + IsFalse(a.AsSpan().SequenceEqual(b), "salt should randomize successive hashes"); |
| 208 | + } |
| 209 | + |
| 210 | + [TestMethod] |
| 211 | + public void PwdCompare_RoundTripRightPassword() |
| 212 | + => AreEqual(1, Scalar("select pwdcompare('abc', pwdencrypt('abc'))")); |
| 213 | + |
| 214 | + [TestMethod] |
| 215 | + public void PwdCompare_WrongPassword() |
| 216 | + => AreEqual(0, Scalar("select pwdcompare('xyz', pwdencrypt('abc'))")); |
| 217 | + |
| 218 | + [TestMethod] |
| 219 | + public void PwdCompare_NullClearReturnsNull() |
| 220 | + => AreEqual(DBNull.Value, Scalar("select pwdcompare(null, pwdencrypt('abc'))")); |
| 221 | + |
| 222 | + [TestMethod] |
| 223 | + public void PwdCompare_NullHashReturnsNull() |
| 224 | + => AreEqual(DBNull.Value, Scalar("select pwdcompare('abc', null)")); |
| 225 | + |
| 226 | + [TestMethod] |
| 227 | + public void PwdCompare_GarbageShortHashReturnsZero() |
| 228 | + => AreEqual(0, Scalar("select pwdcompare('abc', 0x1234)")); |
| 229 | + |
| 230 | + [TestMethod] |
| 231 | + public void PwdCompare_EmptyHashReturnsZero() |
| 232 | + => AreEqual(0, Scalar("select pwdcompare('abc', 0x)")); |
| 233 | + |
| 234 | + [TestMethod] |
| 235 | + public void PwdCompare_ThirdVersionArgumentAcceptedAndIgnored() |
| 236 | + { |
| 237 | + AreEqual(1, Scalar("select pwdcompare('abc', pwdencrypt('abc'), 0)")); |
| 238 | + AreEqual(1, Scalar("select pwdcompare('abc', pwdencrypt('abc'), 1)")); |
| 239 | + } |
| 240 | + |
| 241 | + /// <summary> |
| 242 | + /// Cross-engine fidelity: a <c>0x0300</c> hash generated by the live |
| 243 | + /// SQL Server 2025 reference for password <c>'abc'</c> verifies inside the |
| 244 | + /// simulator (probe-captured 2026-07-10). Proves the simulator reproduces |
| 245 | + /// SQL Server's real PBKDF2-HMAC-SHA512(UTF-16LE, salt, 100000) layout. |
| 246 | + /// </summary> |
| 247 | + [TestMethod] |
| 248 | + public void PwdCompare_VerifiesRealServerGeneratedHash() |
| 249 | + { |
| 250 | + const string realHash = "0x030016EEF69E6272D4880A64A024480DC06B67872BF77647CBA42241A5DD922EDCC7495917C2691CFE6255123A27CFBA9F8DE1AA5F0D500F357B7063EAC9ACFBE94D61B8815D"; |
| 251 | + AreEqual(1, Scalar($"select pwdcompare('abc', {realHash})")); |
| 252 | + AreEqual(0, Scalar($"select pwdcompare('wrong', {realHash})")); |
| 253 | + } |
| 254 | + |
| 255 | + // ---- LOGINPROPERTY ---- |
| 256 | + |
| 257 | + [TestMethod] |
| 258 | + public void LoginProperty_KnownLogin_TimeAndCountProperties() |
| 259 | + { |
| 260 | + AreEqual("2020-01-01 00:00:00.000", Scalar("select loginproperty('dbo', 'PasswordLastSetTime')")); |
| 261 | + AreEqual("0", Scalar("select loginproperty('dbo', 'BadPasswordCount')")); |
| 262 | + AreEqual("1900-01-01 00:00:00.000", Scalar("select loginproperty('dbo', 'BadPasswordTime')")); |
| 263 | + AreEqual("1900-01-01 00:00:00.000", Scalar("select loginproperty('dbo', 'LockoutTime')")); |
| 264 | + AreEqual("0", Scalar("select loginproperty('dbo', 'HistoryLength')")); |
| 265 | + } |
| 266 | + |
| 267 | + [TestMethod] |
| 268 | + public void LoginProperty_KnownLogin_BooleanFlags() |
| 269 | + { |
| 270 | + AreEqual("0", Scalar("select loginproperty('dbo', 'IsExpired')")); |
| 271 | + AreEqual("0", Scalar("select loginproperty('dbo', 'IsLocked')")); |
| 272 | + AreEqual("0", Scalar("select loginproperty('dbo', 'IsMustChange')")); |
| 273 | + } |
| 274 | + |
| 275 | + [TestMethod] |
| 276 | + public void LoginProperty_KnownLogin_NullValuedProperties() |
| 277 | + { |
| 278 | + AreEqual(DBNull.Value, Scalar("select loginproperty('dbo', 'DaysUntilExpiration')")); |
| 279 | + AreEqual(DBNull.Value, Scalar("select loginproperty('dbo', 'PasswordHash')")); |
| 280 | + AreEqual(DBNull.Value, Scalar("select loginproperty('dbo', 'PasswordHashAlgorithm')")); |
| 281 | + } |
| 282 | + |
| 283 | + [TestMethod] |
| 284 | + public void LoginProperty_KnownLogin_NameProperties() |
| 285 | + { |
| 286 | + AreEqual("simulated", Scalar("select loginproperty('dbo', 'DefaultDatabase')")); |
| 287 | + AreEqual("us_english", Scalar("select loginproperty('dbo', 'DefaultLanguage')")); |
| 288 | + } |
| 289 | + |
| 290 | + [TestMethod] |
| 291 | + public void LoginProperty_CaseInsensitiveLoginAndProperty() |
| 292 | + => AreEqual("0", Scalar("select loginproperty('DBO', 'isexpired')")); |
| 293 | + |
| 294 | + [TestMethod] |
| 295 | + public void LoginProperty_UnknownLogin_ReturnsNull() |
| 296 | + => AreEqual(DBNull.Value, Scalar("select loginproperty('no_such_login', 'IsExpired')")); |
| 297 | + |
| 298 | + [TestMethod] |
| 299 | + public void LoginProperty_UnknownProperty_ReturnsNull() |
| 300 | + => AreEqual(DBNull.Value, Scalar("select loginproperty('dbo', 'NoSuchProperty')")); |
| 301 | + |
| 302 | + [TestMethod] |
| 303 | + public void LoginProperty_NullLogin_ReturnsNull() |
| 304 | + => AreEqual(DBNull.Value, Scalar("select loginproperty(null, 'IsExpired')")); |
| 305 | + |
| 306 | + [TestMethod] |
| 307 | + public void LoginProperty_NullProperty_ReturnsNull() |
| 308 | + => AreEqual(DBNull.Value, Scalar("select loginproperty('dbo', null)")); |
| 309 | +} |
0 commit comments