Skip to content

Commit 547fcc8

Browse files
committed
Login to an unopenable database now raises correct messages. sys.server_principals and sys.sql_logins are now simulated.
1 parent cd071c6 commit 547fcc8

10 files changed

Lines changed: 412 additions & 12 deletions

File tree

SqlServerSimulator.Tests.SqlClient/AuthenticationTests.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,33 @@ public async Task DropLastLogin_RevertsToAcceptAnything()
132132
await AssertLoginSucceeds(listener, "other", "whatever", TestContext.CancellationToken);
133133
}
134134

135+
// Probe-confirmed (2026-07-15): login naming a database that can't be
136+
// opened fails with a two-error sequence — Msg 4060 severity 11 (database
137+
// name in double quotes) then Msg 18456 severity 14 — and the connection
138+
// closes. Distinct from mid-session USE, which stays Msg 911.
139+
[TestMethod]
140+
public async Task LoginToMissingDatabase_Fails4060Then18456()
141+
{
142+
var simulation = new Simulation();
143+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
144+
145+
var ex = await Assert.ThrowsAsync<SqlException>(async () =>
146+
{
147+
await using var connection = new SqlConnection(
148+
$"Server=127.0.0.1,{listener.Port};User ID=sa;Password=anything;Database=no_such_db;TrustServerCertificate=True;Pooling=False;Connect Timeout=15");
149+
await connection.OpenAsync(TestContext.CancellationToken);
150+
});
151+
152+
AreEqual(4060, ex.Number);
153+
AreEqual(2, ex.Errors.Count);
154+
AreEqual("Cannot open database \"no_such_db\" requested by the login. The login failed.", ex.Errors[0].Message);
155+
AreEqual((byte)11, ex.Errors[0].Class);
156+
AreEqual((byte)1, ex.Errors[0].State);
157+
AreEqual(18456, ex.Errors[1].Number);
158+
AreEqual("Login failed for user 'sa'.", ex.Errors[1].Message);
159+
AreEqual((byte)14, ex.Errors[1].Class);
160+
}
161+
135162
[TestMethod]
136163
public async Task SecondLogin_BothEnforced()
137164
{
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Behavioral tests for the <c>sys.server_principals</c> / <c>sys.sql_logins</c>
7+
/// catalog views, projected over the per-Simulation login registry
8+
/// (<c>CREATE</c> / <c>ALTER</c> / <c>DROP LOGIN</c>) plus the synthetic fixed
9+
/// <c>sa</c> (principal_id 1) and <c>public</c> (principal_id 2) rows.
10+
/// </summary>
11+
[TestClass]
12+
public sealed class ServerPrincipalCatalogViewTests
13+
{
14+
[TestMethod]
15+
public void FreshSimulation_HasOnlySaAndPublic()
16+
{
17+
var sim = new Simulation();
18+
AreEqual(2, sim.ExecuteScalar("select count(*) from sys.server_principals"));
19+
20+
AreEqual("sa", sim.ExecuteScalar("select name from sys.server_principals where principal_id = 1"));
21+
AreEqual("SQL_LOGIN", sim.ExecuteScalar("select type_desc from sys.server_principals where principal_id = 1"));
22+
IsFalse((bool)sim.ExecuteScalar("select is_fixed_role from sys.server_principals where name = 'sa'")!);
23+
AreEqual("01", Convert.ToHexString((byte[])sim.ExecuteScalar("select sid from sys.server_principals where name = 'sa'")!));
24+
IsTrue(sim.ExecuteScalar("select owning_principal_id from sys.server_principals where name = 'sa'") is DBNull);
25+
26+
AreEqual("public", sim.ExecuteScalar("select name from sys.server_principals where principal_id = 2"));
27+
AreEqual("SERVER_ROLE", sim.ExecuteScalar("select type_desc from sys.server_principals where principal_id = 2"));
28+
IsFalse((bool)sim.ExecuteScalar("select is_fixed_role from sys.server_principals where name = 'public'")!);
29+
AreEqual("02", Convert.ToHexString((byte[])sim.ExecuteScalar("select sid from sys.server_principals where name = 'public'")!));
30+
AreEqual(1, sim.ExecuteScalar("select owning_principal_id from sys.server_principals where name = 'public'"));
31+
}
32+
33+
[TestMethod]
34+
public void CreateLogin_AddsRowWithPrincipalIdThree()
35+
{
36+
var sim = new Simulation();
37+
_ = sim.ExecuteNonQuery("create login app_login with password = 'P@ssw0rd1'");
38+
AreEqual(3, sim.ExecuteScalar("select principal_id from sys.server_principals where name = 'app_login'"));
39+
AreEqual("SQL_LOGIN", sim.ExecuteScalar("select type_desc from sys.server_principals where name = 'app_login'"));
40+
AreEqual("master", sim.ExecuteScalar("select default_database_name from sys.server_principals where name = 'app_login'"));
41+
AreEqual("us_english", sim.ExecuteScalar("select default_language_name from sys.server_principals where name = 'app_login'"));
42+
var sid = (byte[])sim.ExecuteScalar("select sid from sys.server_principals where name = 'app_login'")!;
43+
HasCount(16, sid);
44+
}
45+
46+
[TestMethod]
47+
public void TwoLogins_GetDistinctIdsAndSids()
48+
{
49+
var sim = new Simulation();
50+
_ = sim.ExecuteNonQuery("create login login_a with password = 'P@ssw0rd1'; create login login_b with password = 'P@ssw0rd2'");
51+
AreEqual(3, sim.ExecuteScalar("select principal_id from sys.server_principals where name = 'login_a'"));
52+
AreEqual(4, sim.ExecuteScalar("select principal_id from sys.server_principals where name = 'login_b'"));
53+
var sidA = Convert.ToHexString((byte[])sim.ExecuteScalar("select sid from sys.server_principals where name = 'login_a'")!);
54+
var sidB = Convert.ToHexString((byte[])sim.ExecuteScalar("select sid from sys.server_principals where name = 'login_b'")!);
55+
AreNotEqual(sidA, sidB);
56+
}
57+
58+
[TestMethod]
59+
public void AlterLogin_PreservesIdAndCreateDate_AdvancesModifyDate()
60+
{
61+
var sim = new Simulation();
62+
_ = sim.ExecuteNonQuery("create login rotate_login with password = 'P@ssw0rd1'");
63+
var idBefore = sim.ExecuteScalar("select principal_id from sys.server_principals where name = 'rotate_login'");
64+
var createBefore = (DateTime)sim.ExecuteScalar("select create_date from sys.server_principals where name = 'rotate_login'")!;
65+
66+
_ = sim.ExecuteNonQuery("alter login rotate_login with password = 'N3wP@ssw0rd'");
67+
AreEqual(idBefore, sim.ExecuteScalar("select principal_id from sys.server_principals where name = 'rotate_login'"));
68+
var createAfter = (DateTime)sim.ExecuteScalar("select create_date from sys.server_principals where name = 'rotate_login'")!;
69+
var modifyAfter = (DateTime)sim.ExecuteScalar("select modify_date from sys.server_principals where name = 'rotate_login'")!;
70+
AreEqual(createBefore, createAfter);
71+
IsGreaterThanOrEqualTo(createAfter, modifyAfter);
72+
}
73+
74+
[TestMethod]
75+
public void DropLogin_RemovesRow()
76+
{
77+
var sim = new Simulation();
78+
_ = sim.ExecuteNonQuery("create login gone_login with password = 'P@ssw0rd1'");
79+
AreEqual(1, sim.ExecuteScalar("select count(*) from sys.server_principals where name = 'gone_login'"));
80+
_ = sim.ExecuteNonQuery("drop login gone_login");
81+
AreEqual(0, sim.ExecuteScalar("select count(*) from sys.server_principals where name = 'gone_login'"));
82+
}
83+
84+
[TestMethod]
85+
public void SqlLogins_ContainsSaAndLoginsButNotPublic()
86+
{
87+
var sim = new Simulation();
88+
_ = sim.ExecuteNonQuery("create login sql_login with password = 'P@ssw0rd1'");
89+
AreEqual(1, sim.ExecuteScalar("select count(*) from sys.sql_logins where name = 'sa'"));
90+
AreEqual(1, sim.ExecuteScalar("select count(*) from sys.sql_logins where name = 'sql_login'"));
91+
AreEqual(0, sim.ExecuteScalar("select count(*) from sys.sql_logins where name = 'public'"));
92+
IsTrue(sim.ExecuteScalar("select password_hash from sys.sql_logins where name = 'sa'") is DBNull);
93+
IsTrue(sim.ExecuteScalar("select password_hash from sys.sql_logins where name = 'sql_login'") is DBNull);
94+
IsTrue((bool)sim.ExecuteScalar("select is_policy_checked from sys.sql_logins where name = 'sql_login'")!);
95+
IsFalse((bool)sim.ExecuteScalar("select is_expiration_checked from sys.sql_logins where name = 'sql_login'")!);
96+
}
97+
98+
[TestMethod]
99+
public void WhereFilterAndProjection_OrderByPrincipalId()
100+
{
101+
var sim = new Simulation();
102+
_ = sim.ExecuteNonQuery("create login filtered_login with password = 'P@ssw0rd1'");
103+
using var reader = sim.ExecuteReader("select name from sys.server_principals where type = 'S' order by principal_id");
104+
var names = new List<string>();
105+
while (reader.Read())
106+
names.Add(reader.GetString(0));
107+
HasCount(2, names);
108+
AreEqual("sa", names[0]);
109+
AreEqual("filtered_login", names[1]);
110+
}
111+
112+
[TestMethod]
113+
public void BothViews_Expose14Columns()
114+
{
115+
var sim = new Simulation();
116+
using (var reader = sim.ExecuteReader("select * from sys.server_principals"))
117+
AreEqual(14, reader.FieldCount);
118+
using (var reader = sim.ExecuteReader("select * from sys.sql_logins"))
119+
AreEqual(14, reader.FieldCount);
120+
}
121+
}

SqlServerSimulator/BuiltInResources.cs

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -888,6 +888,54 @@ void Iso(string name, HeapColumn[] columns, Func<Parser.BatchContext, Database,
888888
new("member_principal_id", SqlType.Int32, null, false),
889889
], EnumerateSysDatabaseRoleMembers);
890890

891+
// sys.server_principals: probe-confirmed 14-col shape against SQL
892+
// Server 2025 (2026-07-15), projected over the per-Simulation login
893+
// registry (Simulation.Logins) plus two synthetic fixed rows: sa
894+
// (principal_id 1) and public (principal_id 2). Columns the simulator
895+
// doesn't track (credential_id, disabled flag) surface as their real
896+
// low-privilege defaults.
897+
Sys("server_principals",
898+
[
899+
new("name", SqlType.SystemName, 128, false),
900+
new("principal_id", SqlType.Int32, null, false),
901+
new("sid", SqlType.Varbinary, 85, true),
902+
new("type", charOne, 1, false),
903+
new("type_desc", nvarchar60Catalog, 60, true),
904+
new("is_disabled", SqlType.Bit, null, false),
905+
new("create_date", SqlType.DateTime, null, false),
906+
new("modify_date", SqlType.DateTime, null, false),
907+
new("default_database_name", SqlType.SystemName, 128, true),
908+
new("default_language_name", SqlType.SystemName, 128, true),
909+
new("credential_id", SqlType.Int32, null, true),
910+
new("owning_principal_id", SqlType.Int32, null, true),
911+
new("is_fixed_role", SqlType.Bit, null, false),
912+
new("tenant_id", SqlType.UniqueIdentifier, null, true),
913+
], EnumerateSysServerPrincipals);
914+
915+
// sys.sql_logins: probe-confirmed 14-col shape against SQL Server 2025
916+
// (2026-07-15). Same leading 10 columns as sys.server_principals,
917+
// filtered to type='S' (SQL logins) — sa plus the registry logins,
918+
// never the public server role. password_hash surfaces NULL: the
919+
// simulator deliberately doesn't expose its stored PWDCOMPARE hash,
920+
// matching what a low-privilege reader sees on the reference instance.
921+
Sys("sql_logins",
922+
[
923+
new("name", SqlType.SystemName, 128, false),
924+
new("principal_id", SqlType.Int32, null, false),
925+
new("sid", SqlType.Varbinary, 85, true),
926+
new("type", charOne, 1, false),
927+
new("type_desc", nvarchar60Catalog, 60, true),
928+
new("is_disabled", SqlType.Bit, null, false),
929+
new("create_date", SqlType.DateTime, null, false),
930+
new("modify_date", SqlType.DateTime, null, false),
931+
new("default_database_name", SqlType.SystemName, 128, true),
932+
new("default_language_name", SqlType.SystemName, 128, true),
933+
new("credential_id", SqlType.Int32, null, true),
934+
new("is_policy_checked", SqlType.Bit, null, true),
935+
new("is_expiration_checked", SqlType.Bit, null, true),
936+
new("password_hash", SqlType.Varbinary, 256, true),
937+
], EnumerateSysSqlLogins);
938+
891939
// sys.fulltext_catalogs: per-database full-text catalog metadata.
892940
// Column subset matches Microsoft Learn's documented surface for
893941
// SQL Server 2022+ (the reference instance doesn't have full-text
@@ -1897,6 +1945,166 @@ private static IEnumerable<SqlValue[]> EnumerateSysDatabaseRoleMembers(Parser.Ba
18971945
}
18981946
}
18991947

1948+
/// <summary>
1949+
/// Derives a deterministic 16-byte synthetic <c>sid</c> from a login name.
1950+
/// Real SQL logins carry a 16-byte random GUID sid; the simulator fills the
1951+
/// four 32-bit quadrants with a per-quadrant-salted FNV-1a hash so the same
1952+
/// name always maps to the same bytes without persisting a GUID.
1953+
/// </summary>
1954+
private static byte[] DeriveLoginSid(string name)
1955+
{
1956+
var sid = new byte[16];
1957+
for (var quadrant = 0; quadrant < 4; quadrant++)
1958+
{
1959+
var hash = Simulation.Fnv1a32.Initial;
1960+
hash.Mix(name);
1961+
hash.Mix((byte)quadrant);
1962+
var value = hash.Value;
1963+
var offset = quadrant * 4;
1964+
sid[offset] = (byte)value;
1965+
sid[offset + 1] = (byte)(value >> 8);
1966+
sid[offset + 2] = (byte)(value >> 16);
1967+
sid[offset + 3] = (byte)(value >> 24);
1968+
}
1969+
return sid;
1970+
}
1971+
1972+
/// <summary>
1973+
/// Projects <c>sys.server_principals</c> over the per-Simulation login
1974+
/// registry plus the two synthetic fixed rows (<c>sa</c> = principal_id 1,
1975+
/// <c>public</c> = principal_id 2). Rows emit in principal_id order.
1976+
/// </summary>
1977+
private static IEnumerable<SqlValue[]> EnumerateSysServerPrincipals(Parser.BatchContext batch, Database database)
1978+
{
1979+
var simulation = batch.Connection.Simulation;
1980+
var charOne = SqlType.GetChar(1);
1981+
var falseBit = SqlValue.FromBoolean(false);
1982+
var sqlLogin = SqlValue.FromNVarchar("SQL_LOGIN");
1983+
var loginType = SqlValue.FromChar(charOne, "S");
1984+
var nullCredentialId = SqlValue.Null(SqlType.Int32);
1985+
var nullOwningId = SqlValue.Null(SqlType.Int32);
1986+
var master = SqlValue.FromSystemName("master");
1987+
var usEnglish = SqlValue.FromSystemName("us_english");
1988+
var nullTenant = SqlValue.Null(SqlType.UniqueIdentifier);
1989+
var zeroTenant = SqlValue.FromGuid(Guid.Empty);
1990+
var seedDate = SqlValue.FromDateTime(simulation.SeedDate);
1991+
1992+
// sa: the fixed SQL-authentication login, principal_id 1.
1993+
yield return [
1994+
SqlValue.FromSystemName("sa"),
1995+
SqlValue.FromInt32(1),
1996+
SqlValue.FromVarbinary([0x01]),
1997+
loginType,
1998+
sqlLogin,
1999+
falseBit,
2000+
seedDate,
2001+
seedDate,
2002+
master,
2003+
usEnglish,
2004+
nullCredentialId,
2005+
nullOwningId,
2006+
falseBit,
2007+
nullTenant,
2008+
];
2009+
2010+
// public: the fixed server role, principal_id 2. owning_principal_id
2011+
// points at sa (1); is_fixed_role is 0 (probe-confirmed).
2012+
yield return [
2013+
SqlValue.FromSystemName("public"),
2014+
SqlValue.FromInt32(2),
2015+
SqlValue.FromVarbinary([0x02]),
2016+
SqlValue.FromChar(charOne, "R"),
2017+
SqlValue.FromNVarchar("SERVER_ROLE"),
2018+
falseBit,
2019+
seedDate,
2020+
seedDate,
2021+
SqlValue.Null(SqlType.SystemName),
2022+
SqlValue.Null(SqlType.SystemName),
2023+
nullCredentialId,
2024+
SqlValue.FromInt32(1),
2025+
falseBit,
2026+
nullTenant,
2027+
];
2028+
2029+
foreach (var login in simulation.Logins.Values.OrderBy(l => l.PrincipalId))
2030+
{
2031+
yield return [
2032+
SqlValue.FromSystemName(login.Name),
2033+
SqlValue.FromInt32(login.PrincipalId),
2034+
SqlValue.FromVarbinary(DeriveLoginSid(login.Name)),
2035+
loginType,
2036+
sqlLogin,
2037+
falseBit,
2038+
SqlValue.FromDateTime(login.CreateDate),
2039+
SqlValue.FromDateTime(login.PasswordLastSetTime),
2040+
master,
2041+
usEnglish,
2042+
nullCredentialId,
2043+
nullOwningId,
2044+
falseBit,
2045+
zeroTenant,
2046+
];
2047+
}
2048+
}
2049+
2050+
/// <summary>
2051+
/// Projects <c>sys.sql_logins</c>: the type='S' subset of
2052+
/// <c>sys.server_principals</c> (<c>sa</c> plus the registry logins, never
2053+
/// the <c>public</c> server role), with the policy / expiration / hash
2054+
/// tail. Rows emit in principal_id order.
2055+
/// </summary>
2056+
private static IEnumerable<SqlValue[]> EnumerateSysSqlLogins(Parser.BatchContext batch, Database database)
2057+
{
2058+
var simulation = batch.Connection.Simulation;
2059+
var charOne = SqlType.GetChar(1);
2060+
var trueBit = SqlValue.FromBoolean(true);
2061+
var falseBit = SqlValue.FromBoolean(false);
2062+
var sqlLogin = SqlValue.FromNVarchar("SQL_LOGIN");
2063+
var loginType = SqlValue.FromChar(charOne, "S");
2064+
var nullCredentialId = SqlValue.Null(SqlType.Int32);
2065+
var master = SqlValue.FromSystemName("master");
2066+
var usEnglish = SqlValue.FromSystemName("us_english");
2067+
var nullPasswordHash = SqlValue.Null(SqlType.Varbinary);
2068+
var seedDate = SqlValue.FromDateTime(simulation.SeedDate);
2069+
2070+
yield return [
2071+
SqlValue.FromSystemName("sa"),
2072+
SqlValue.FromInt32(1),
2073+
SqlValue.FromVarbinary([0x01]),
2074+
loginType,
2075+
sqlLogin,
2076+
falseBit,
2077+
seedDate,
2078+
seedDate,
2079+
master,
2080+
usEnglish,
2081+
nullCredentialId,
2082+
trueBit,
2083+
falseBit,
2084+
nullPasswordHash,
2085+
];
2086+
2087+
foreach (var login in simulation.Logins.Values.OrderBy(l => l.PrincipalId))
2088+
{
2089+
yield return [
2090+
SqlValue.FromSystemName(login.Name),
2091+
SqlValue.FromInt32(login.PrincipalId),
2092+
SqlValue.FromVarbinary(DeriveLoginSid(login.Name)),
2093+
loginType,
2094+
sqlLogin,
2095+
falseBit,
2096+
SqlValue.FromDateTime(login.CreateDate),
2097+
SqlValue.FromDateTime(login.PasswordLastSetTime),
2098+
master,
2099+
usEnglish,
2100+
nullCredentialId,
2101+
trueBit,
2102+
falseBit,
2103+
nullPasswordHash,
2104+
];
2105+
}
2106+
}
2107+
19002108
/// <summary>
19012109
/// Rows for <c>sys.fulltext_catalogs</c>. One row per
19022110
/// <see cref="FullTextCatalog"/> in <see cref="Database.FullTextCatalogs"/>.

0 commit comments

Comments
 (0)