Skip to content

Commit 931674f

Browse files
committed
Added optional credential enforcement to the TDS endpoint via a server-scope CREATE/ALTER/DROP LOGIN registry.
1 parent 12e2cd8 commit 931674f

23 files changed

Lines changed: 759 additions & 53 deletions

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
153153
- **Locking, MVCC, SNAPSHOT/RCSI, deadlock/timeout, lock-related DMVs**[`locking.md`](docs/claude/locking.md).
154154
- **Application locks** (`sp_getapplock` / `sp_releaseapplock` / `APPLOCK_MODE` / `APPLOCK_TEST`, return-code-vs-raised-error asymmetry, EF 9/10 `Database.Migrate()`'s `__EFMigrationsLock`) → [`app-locks.md`](docs/claude/app-locks.md).
155155
- **`hierarchyid` type** (incl. deferred byte-identical CAST research notes) → [`hierarchyid.md`](docs/claude/hierarchyid.md).
156-
- **`GRANT` / `REVOKE` / `DENY`, principal DDL, fixed-principal seed, principal scalars** (`USER_NAME` / `SUSER_SNAME` / `DATABASE_PRINCIPAL_ID` / `CURRENT_USER` / `SESSION_USER` / `ORIGINAL_LOGIN` / `HAS_PERMS_BY_NAME` / `IS_MEMBER`) → [`permissions.md`](docs/claude/permissions.md).
156+
- **`GRANT` / `REVOKE` / `DENY`, principal DDL (incl. server logins: `CREATE/ALTER/DROP LOGIN`), fixed-principal seed, principal scalars** (`USER_NAME` / `SUSER_SNAME` / `DATABASE_PRINCIPAL_ID` / `CURRENT_USER` / `SESSION_USER` / `ORIGINAL_LOGIN` / `HAS_PERMS_BY_NAME` / `IS_MEMBER`) → [`permissions.md`](docs/claude/permissions.md).
157157
- **`CREATE FULLTEXT CATALOG`/`INDEX`, `CONTAINS`/`FREETEXT` rejection**[`full-text.md`](docs/claude/full-text.md).
158158
- **`xml` type, XML schema collections, XML methods (`.value()` / `.nodes()` / `.query()` / `.exist()` via an XQuery-subset evaluator; `.modify()` XML-DML skip-with-diagnostic), XML indexes**[`xml.md`](docs/claude/xml.md).
159159
- **`geography` / `geometry` types, spatial methods, spatial indexes**[`spatial.md`](docs/claude/spatial.md).
@@ -162,7 +162,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
162162
- **New top-level statement parser or dispatch-loop separator rules**[`grammar.md`](docs/claude/grammar.md) + [`control-flow.md`](docs/claude/control-flow.md).
163163
- **BACPAC import** (`Simulation.ImportBacpac` — multi-database via repeated calls, `BacpacImportOptions`, `ModelXmlReader` dispatcher, BCP wire format, `BacpacBuilder` test harness) → [`bacpac-loader.md`](docs/claude/bacpac-loader.md).
164164
- **Linked servers** (`Simulation.AddRemoteSimulation`, `sp_addlinkedserver` / `sp_dropserver`, four-part FROM routing through the remote's ADO.NET pipeline, `OPENQUERY(server,'query')` ad-hoc pass-through + compile-time schema discovery, `sys.servers`) → [`linked-servers.md`](docs/claude/linked-servers.md).
165-
- **TDS network endpoint** (`Simulation.ListenAsync``SimulatedNetworkListener`; real SqlClient over loopback TCP+TLS; SQLBatch/RPC/TM, no bulk; EF via plain `UseSqlServer`; oracle = `*.Tests.SqlClient`) → [`tds-endpoint.md`](docs/claude/tds-endpoint.md).
165+
- **TDS network endpoint** (`Simulation.ListenAsync``SimulatedNetworkListener`; real SqlClient over loopback TCP+TLS; SQLBatch/RPC/TM, no bulk; credential enforcement via the `CREATE LOGIN` registry, Msg 18456 on mismatch; EF via plain `UseSqlServer`; oracle = `*.Tests.SqlClient`) → [`tds-endpoint.md`](docs/claude/tds-endpoint.md).
166166

167167
## Not modeled
168168

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
using Microsoft.Data.SqlClient;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Credential enforcement at the wire endpoint: an empty
8+
/// <c>CREATE LOGIN</c> registry accepts any credentials (the
9+
/// zero-configuration default every other test in this project relies on);
10+
/// once a login exists, the LOGIN7 username/password must match or the
11+
/// connection fails with the probe-confirmed Msg 18456 severity 14 state 1
12+
/// shape and closes.
13+
/// </summary>
14+
[TestClass]
15+
public sealed class AuthenticationTests
16+
{
17+
public TestContext TestContext { get; set; } = null!;
18+
19+
private static string CredentialConnectionString(SimulatedNetworkListener listener, string user, string password) =>
20+
$"Server=127.0.0.1,{listener.Port};User ID={user};Password='{password.Replace("'", "''", StringComparison.Ordinal)}';TrustServerCertificate=True;Pooling=False;Connect Timeout=15";
21+
22+
private static async Task<SqlException> AssertLoginFails(SimulatedNetworkListener listener, string user, string password, CancellationToken cancellationToken)
23+
{
24+
var ex = await Assert.ThrowsAsync<SqlException>(async () =>
25+
{
26+
await using var connection = new SqlConnection(CredentialConnectionString(listener, user, password));
27+
await connection.OpenAsync(cancellationToken);
28+
});
29+
30+
AreEqual(18456, ex.Number);
31+
AreEqual(14, ex.Class);
32+
AreEqual(1, ex.State);
33+
AreEqual($"Login failed for user '{user}'.", ex.Message);
34+
return ex;
35+
}
36+
37+
private static async Task AssertLoginSucceeds(SimulatedNetworkListener listener, string user, string password, CancellationToken cancellationToken)
38+
{
39+
await using var connection = new SqlConnection(CredentialConnectionString(listener, user, password));
40+
await connection.OpenAsync(cancellationToken);
41+
await using var command = new SqlCommand("select 1", connection);
42+
AreEqual(1, await command.ExecuteScalarAsync(cancellationToken));
43+
}
44+
45+
[TestMethod]
46+
public async Task EmptyRegistry_AcceptsAnyCredentials()
47+
{
48+
var simulation = new Simulation();
49+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
50+
await AssertLoginSucceeds(listener, "whoever", "whatever", TestContext.CancellationToken);
51+
}
52+
53+
[TestMethod]
54+
public async Task CorrectPassword_Connects()
55+
{
56+
var simulation = new Simulation();
57+
Wire.ExecInProc(simulation, "CREATE LOGIN app WITH PASSWORD = 'S3cret!Pass'");
58+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
59+
await AssertLoginSucceeds(listener, "app", "S3cret!Pass", TestContext.CancellationToken);
60+
}
61+
62+
[TestMethod]
63+
public async Task WrongPassword_Fails18456()
64+
{
65+
var simulation = new Simulation();
66+
Wire.ExecInProc(simulation, "CREATE LOGIN app WITH PASSWORD = 'S3cret!Pass'");
67+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
68+
_ = await AssertLoginFails(listener, "app", "wrong", TestContext.CancellationToken);
69+
}
70+
71+
[TestMethod]
72+
public async Task UnknownUser_Fails18456()
73+
{
74+
var simulation = new Simulation();
75+
Wire.ExecInProc(simulation, "CREATE LOGIN app WITH PASSWORD = 'S3cret!Pass'");
76+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
77+
_ = await AssertLoginFails(listener, "nosuchuser", "S3cret!Pass", TestContext.CancellationToken);
78+
}
79+
80+
[TestMethod]
81+
public async Task EmptyPassword_Fails18456()
82+
{
83+
var simulation = new Simulation();
84+
Wire.ExecInProc(simulation, "CREATE LOGIN app WITH PASSWORD = 'S3cret!Pass'");
85+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
86+
_ = await AssertLoginFails(listener, "app", "", TestContext.CancellationToken);
87+
}
88+
89+
[TestMethod]
90+
public async Task NonAsciiPassword_RoundTripsObfuscation()
91+
{
92+
// Non-ASCII UTF-16 units (including a surrogate pair) exercise the
93+
// LOGIN7 nibble-swap/XOR de-obfuscation and the char-counted length.
94+
var simulation = new Simulation();
95+
Wire.ExecInProc(simulation, "CREATE LOGIN app WITH PASSWORD = N'p䣀🙂ß'");
96+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
97+
await AssertLoginSucceeds(listener, "app", "p䣀🙂ß", TestContext.CancellationToken);
98+
_ = await AssertLoginFails(listener, "app", "p䣀ß", TestContext.CancellationToken);
99+
}
100+
101+
[TestMethod]
102+
public async Task UserNameLookup_IsCaseInsensitive()
103+
{
104+
var simulation = new Simulation();
105+
Wire.ExecInProc(simulation, "CREATE LOGIN app WITH PASSWORD = 'S3cret!Pass'");
106+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
107+
await AssertLoginSucceeds(listener, "APP", "S3cret!Pass", TestContext.CancellationToken);
108+
}
109+
110+
[TestMethod]
111+
public async Task AlterLogin_ChangesEnforcedPassword()
112+
{
113+
var simulation = new Simulation();
114+
Wire.ExecInProc(simulation, "CREATE LOGIN app WITH PASSWORD = 'OldPass1!'");
115+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
116+
await AssertLoginSucceeds(listener, "app", "OldPass1!", TestContext.CancellationToken);
117+
118+
Wire.ExecInProc(simulation, "ALTER LOGIN app WITH PASSWORD = 'NewPass2!'");
119+
_ = await AssertLoginFails(listener, "app", "OldPass1!", TestContext.CancellationToken);
120+
await AssertLoginSucceeds(listener, "app", "NewPass2!", TestContext.CancellationToken);
121+
}
122+
123+
[TestMethod]
124+
public async Task DropLastLogin_RevertsToAcceptAnything()
125+
{
126+
var simulation = new Simulation();
127+
Wire.ExecInProc(simulation, "CREATE LOGIN app WITH PASSWORD = 'S3cret!Pass'");
128+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
129+
_ = await AssertLoginFails(listener, "other", "whatever", TestContext.CancellationToken);
130+
131+
Wire.ExecInProc(simulation, "DROP LOGIN app");
132+
await AssertLoginSucceeds(listener, "other", "whatever", TestContext.CancellationToken);
133+
}
134+
135+
[TestMethod]
136+
public async Task SecondLogin_BothEnforced()
137+
{
138+
var simulation = new Simulation();
139+
Wire.ExecInProc(simulation, "CREATE LOGIN app1 WITH PASSWORD = 'Pass!One1'");
140+
Wire.ExecInProc(simulation, "CREATE LOGIN app2 WITH PASSWORD = 'Pass!Two2'");
141+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
142+
await AssertLoginSucceeds(listener, "app1", "Pass!One1", TestContext.CancellationToken);
143+
await AssertLoginSucceeds(listener, "app2", "Pass!Two2", TestContext.CancellationToken);
144+
_ = await AssertLoginFails(listener, "app1", "Pass!Two2", TestContext.CancellationToken);
145+
}
146+
}

SqlServerSimulator.Tests/FormatMessageAndLoginScalarTests.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,32 @@ public void PwdCompare_ThirdVersionArgumentAcceptedAndIgnored()
238238
AreEqual(1, Scalar("select pwdcompare('abc', pwdencrypt('abc'), 1)"));
239239
}
240240

241+
// SQL Server caps password-machinery input at 128 characters
242+
// (probe-confirmed at exactly the 128/129 boundary, 2026-07-14).
243+
244+
[TestMethod]
245+
public void PwdEncrypt_128Chars_Succeeds()
246+
=> AreEqual(70, Scalar("select datalength(pwdencrypt(replicate('a', 128)))"));
247+
248+
[TestMethod]
249+
public void PwdEncrypt_129Chars_Raises6607()
250+
{
251+
var ex = new Simulation().AssertSqlError("select pwdencrypt(replicate('a', 129))", 6607);
252+
AreEqual("Password Encryption: The value supplied for parameter number 1 is invalid.", ex.Message);
253+
AreEqual((byte)16, ex.Class);
254+
AreEqual((byte)5, ex.State);
255+
}
256+
257+
[TestMethod]
258+
public void PwdCompare_128CharClear_RoundTrips()
259+
=> AreEqual(1, Scalar("select pwdcompare(replicate('a', 128), pwdencrypt(replicate('a', 128)))"));
260+
261+
// An oversized clear compares in full rather than truncating — real
262+
// returns 0 for a 129-char clear against its own 128-char prefix's hash.
263+
[TestMethod]
264+
public void PwdCompare_OversizedClear_ReturnsZeroWithoutTruncating()
265+
=> AreEqual(0, Scalar("select pwdcompare(replicate('a', 129), pwdencrypt(replicate('a', 128)))"));
266+
241267
/// <summary>
242268
/// Cross-engine fidelity: a <c>0x0300</c> hash generated by the live
243269
/// SQL Server 2025 reference for password <c>'abc'</c> verifies inside the
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Behavioral tests for the server-scope login DDL
7+
/// (<c>CREATE LOGIN</c> / <c>ALTER LOGIN</c> / <c>DROP LOGIN</c>) and the
8+
/// <c>LOGINPROPERTY</c> wiring that reads the resulting registry. The registry
9+
/// (<c>Simulation.Logins</c>) is enforced only by the TDS network endpoint —
10+
/// covered by the SqlClient oracle, not here; these tests exercise the parse /
11+
/// registry-maintenance / error-shape surface reachable through plain ADO.NET.
12+
/// </summary>
13+
[TestClass]
14+
public sealed class LoginDdlTests
15+
{
16+
[TestMethod]
17+
public void CreateThenDropLogin_Succeeds()
18+
=> new Simulation().ExecuteBatches(
19+
"create login app with password = 'Xy!12345'",
20+
"drop login app");
21+
22+
[TestMethod]
23+
public void CreateLogin_Duplicate_Raises15025()
24+
{
25+
var sim = new Simulation();
26+
_ = sim.ExecuteNonQuery("create login app with password = 'Xy!12345'");
27+
var ex = sim.AssertSqlError("create login app with password = 'Zz!98765'", 15025);
28+
AreEqual((byte)16, ex.Class);
29+
AreEqual((byte)1, ex.State);
30+
AreEqual("The server principal 'app' already exists.", ex.Message);
31+
}
32+
33+
[TestMethod]
34+
public void AlterLogin_Missing_Raises15151_AlterWording()
35+
{
36+
var ex = new Simulation().AssertSqlError("alter login nosuch with password = 'Xy!12345'", 15151);
37+
AreEqual((byte)16, ex.Class);
38+
AreEqual((byte)1, ex.State);
39+
AreEqual("Cannot alter the login 'nosuch', because it does not exist or you do not have permission.", ex.Message);
40+
}
41+
42+
[TestMethod]
43+
public void DropLogin_Missing_Raises15151_DropWording()
44+
{
45+
var ex = new Simulation().AssertSqlError("drop login nosuch", 15151);
46+
AreEqual((byte)16, ex.Class);
47+
AreEqual((byte)1, ex.State);
48+
AreEqual("Cannot drop the login 'nosuch', because it does not exist or you do not have permission.", ex.Message);
49+
}
50+
51+
// Real SQL Server has no IF EXISTS clause on DROP LOGIN — probe-confirmed
52+
// Msg 156, the keyword-flavored syntax error, near 'IF'.
53+
[TestMethod]
54+
public void DropLogin_IfExists_Raises156()
55+
=> new Simulation().AssertSqlError("drop login if exists app", 156, "Incorrect syntax near the keyword 'if'.");
56+
57+
[TestMethod]
58+
public void CreateLogin_FromWindows_NotSupported()
59+
{
60+
var ex = Throws<NotSupportedException>(
61+
() => new Simulation().ExecuteNonQuery("create login w from windows"));
62+
Assert.Contains("SQL-authentication", ex.Message);
63+
}
64+
65+
[TestMethod]
66+
public void CreateLogin_HashedPassword_NotSupported()
67+
{
68+
var ex = Throws<NotSupportedException>(
69+
() => new Simulation().ExecuteNonQuery("create login h with password = 0x1234 hashed"));
70+
Assert.Contains("hashed-password", ex.Message);
71+
}
72+
73+
// Real documents the 128-char password cap; its exact CREATE LOGIN
74+
// rejection shape is unverifiable from the reference instance (the probe
75+
// login hits the Msg 15247 permission wall first), so the simulator
76+
// reuses the password-machinery Msg 6607 that PWDENCRYPT's cap raises.
77+
[TestMethod]
78+
public void CreateLogin_PasswordOver128Chars_Raises6607()
79+
{
80+
var password = new string('a', 129);
81+
_ = new Simulation().AssertSqlError($"create login app with password = '{password}'", 6607);
82+
}
83+
84+
[TestMethod]
85+
public void AlterLogin_PasswordOver128Chars_Raises6607()
86+
{
87+
var sim = new Simulation();
88+
_ = sim.ExecuteNonQuery("create login app with password = 'Xy!12345'");
89+
var password = new string('a', 129);
90+
_ = sim.AssertSqlError($"alter login app with password = '{password}'", 6607);
91+
}
92+
93+
[TestMethod]
94+
public void CreateLogin_128CharPassword_Succeeds()
95+
=> new Simulation().ExecuteBatches($"create login app with password = '{new string('a', 128)}'");
96+
97+
[TestMethod]
98+
public void CreateLogin_WithOptionTail_Parses()
99+
=> new Simulation().ExecuteBatches(
100+
"create login t with password = 'Xy!12345', check_policy = off, check_expiration = off, default_database = master");
101+
102+
[TestMethod]
103+
public void AlterLogin_Disable_ExistingLogin_Succeeds()
104+
{
105+
var sim = new Simulation();
106+
_ = sim.ExecuteNonQuery("create login t with password = 'Xy!12345'");
107+
_ = sim.ExecuteNonQuery("alter login t disable");
108+
}
109+
110+
[TestMethod]
111+
public void AlterLogin_WithDefaultDatabase_ExistingLogin_Succeeds()
112+
{
113+
var sim = new Simulation();
114+
_ = sim.ExecuteNonQuery("create login t with password = 'Xy!12345'");
115+
_ = sim.ExecuteNonQuery("alter login t with default_database = master");
116+
}
117+
118+
[TestMethod]
119+
public void AlterLogin_Disable_Missing_Raises15151_AlterWording()
120+
=> new Simulation().AssertSqlError(
121+
"alter login nosuch disable",
122+
15151,
123+
"Cannot alter the login 'nosuch', because it does not exist or you do not have permission.");
124+
125+
[TestMethod]
126+
public void LoginProperty_IsLocked_CreatedLogin_ReturnsZero()
127+
{
128+
var sim = new Simulation();
129+
_ = sim.ExecuteNonQuery("create login t with password = 'Xy!12345'");
130+
AreEqual("0", sim.ExecuteScalar("select loginproperty('t', 'IsLocked')"));
131+
}
132+
133+
[TestMethod]
134+
public void LoginProperty_IsLocked_MissingLogin_ReturnsNull()
135+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar("select loginproperty('nosuch_xyz', 'IsLocked')"));
136+
137+
[TestMethod]
138+
public void LoginProperty_PasswordLastSetTime_CreatedLogin_NonNull()
139+
{
140+
var sim = new Simulation();
141+
_ = sim.ExecuteNonQuery("create login t with password = 'Xy!12345'");
142+
var result = sim.ExecuteScalar("select loginproperty('t', 'PasswordLastSetTime')");
143+
Assert.IsNotNull(result);
144+
AreNotEqual(DBNull.Value, result);
145+
}
146+
147+
[TestMethod]
148+
public void CreateLogin_InSkippedBranch_DoesNotCreate()
149+
{
150+
var sim = new Simulation();
151+
_ = sim.ExecuteNonQuery("if 1 = 0 create login skipme with password = 'Xy!12345'");
152+
// Never created, so the follow-up ALTER hits the missing-login path.
153+
_ = sim.AssertSqlError("alter login skipme with password = 'Zz!98765'", 15151);
154+
}
155+
156+
[TestMethod]
157+
public void CreateLogin_RegistryIsCaseInsensitive_Raises15025()
158+
{
159+
var sim = new Simulation();
160+
_ = sim.ExecuteNonQuery("create login App with password = 'Xy!12345'");
161+
_ = sim.AssertSqlError("create login APP with password = 'Zz!98765'", 15025);
162+
}
163+
}

SqlServerSimulator/Errors/SimulatedSqlException.QueryErrors.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,20 @@ internal static SimulatedSqlException FetchMustBeGreaterThanZero() =>
124124
internal static SimulatedSqlException FunctionRequiresNArguments(string functionLowerName, int argumentCount) =>
125125
new($"The {functionLowerName} function requires {argumentCount} argument(s).", 174, 15, 1);
126126

127+
/// <summary>
128+
/// Mimics SQL Server error 6607: the password-encryption machinery
129+
/// rejected an input — fired by <c>PWDENCRYPT</c> for a clear text over
130+
/// 128 characters (probe-confirmed at exactly the 128/129 boundary, same
131+
/// shape for varchar and nvarchar input). Also raised for a
132+
/// <c>CREATE/ALTER LOGIN</c> password over the same documented 128-char
133+
/// cap, where real's rejection shape is unverifiable from the reference
134+
/// instance (its login hits the Msg 15247 permission wall before password
135+
/// validation) — an approximation flagged in
136+
/// <c>docs/claude/permissions.md</c>.
137+
/// </summary>
138+
internal static SimulatedSqlException PasswordEncryptionInvalidValue() =>
139+
new("Password Encryption: The value supplied for parameter number 1 is invalid.", 6607, 16, 5);
140+
127141
/// <summary>
128142
/// Mimics SQL Server error 155: the first argument to <c>DATEPART</c> /
129143
/// <c>DATEADD</c> / <c>DATEDIFF</c> / etc. wasn't a recognized datepart

0 commit comments

Comments
 (0)