Skip to content

Commit 8e1b7f3

Browse files
committed
DDL trigger ON DATABASE + permission statements (GRANT / REVOKE / DENY) + principal DDL (CREATE USER / CREATE ROLE / ALTER ROLE / DROP USER / DROP ROLE) + the three principal/permission catalog views — fifth bacpac prerequisite bundle. Combined parse-and-store-but-no-enforce surface. The simulator has no permission model and no DDL-event dispatch loop; both elements exist for AW round-trip (model.xml load + sys.* catalog visibility) and intentionally don't fire / don't gate any operation.
**Storage**: three new per-database collections on `Database` + two new entity types. `DdlTrigger` (`SqlServerSimulator/DdlTrigger.cs`) carries name + object_id + event-type list + body source + is_disabled; `Database.DdlTriggers` is the per-database `ConcurrentDictionary<string, DdlTrigger>` (not per-schema — DDL triggers belong to the database itself, `parent_class=0` in `sys.triggers`). `DatabasePrincipal` (`SqlServerSimulator/DatabasePrincipal.cs`) carries principal_id + name + type_code (`S`=SQL_USER, `R`=DATABASE_ROLE) + type_desc + is_fixed_role + create_date; `DatabasePermission` (`SqlServerSimulator/DatabasePermission.cs`) carries class + major_id + minor_id + grantee/grantor ids + permission_name + 4-char type code + state (`G`/`W`/`D`/`R`). `Database.Principals` is the per-database principal dict (case-insensitive by name), `Database.Permissions` is the per-database permission list, `Database.RoleMembers` is the per-database `(role_id, member_id)` tuple list. Five fixed principals pre-seeded at database construction matching real SQL Server's `sys.database_principals` ids (probe-confirmed 2026-05-14 against the reference instance): public=0, dbo=1, guest=2, INFORMATION_SCHEMA=3, sys=4. User principals start at 5 via `Database.AllocatePrincipalId`. **Parser** — five new top-level statement parsers and one extended one: - **`CREATE TRIGGER … ON DATABASE [WITH options] {FOR|AFTER} <event_type_list> AS <body>`**: `Simulation.CreateTrigger.cs::TryParseCreateTrigger` branches after the `ON` keyword — if `DATABASE` follows, dispatch to the new `ParseDdlTriggerBody` helper. Event types parse as bare Name / UnquotedString identifiers (e.g. `DDL_DATABASE_LEVEL_EVENTS`, `CREATE_TABLE`) and store verbatim. Body source captured between `AS` and end-of-batch via the same idiom DML triggers use. CREATE OR ALTER upserts; same-name collision against any per-schema object in the trigger's owner schema raises Msg 2714. - **`DROP TRIGGER name ON DATABASE`** (also `IF EXISTS`): `Simulation.Drop.cs::DropOneTrigger` extended — peeks for `ON DATABASE` via `SaveCheckpoint` / `RestoreCheckpoint` and routes to `Database.DdlTriggers` instead of the per-schema `Schema.Triggers` dict. - **`GRANT <perm_list> [ON <securable>] TO <principal_list> [WITH GRANT OPTION] [AS <grantor>]`** + **`REVOKE [GRANT OPTION FOR] <perm_list> [ON <securable>] {FROM|TO} <principal_list> [CASCADE] [AS <grantor>]`** + **`DENY <perm_list> [ON <securable>] TO <principal_list> [AS <grantor>]`** in `Simulation/Simulation.GrantRevokeDeny.cs`. Permission-list parser eats word sequences (any sequence of bare Name / ReservedKeyword text) until the next clause boundary (`,` / `ON` / `TO` / `FROM` / `AS` / `WITH`) — supports multi-word permission names like `VIEW ANY COLUMN ENCRYPTION KEY DEFINITION` without a closed accept-list. ON-clause `OBJECT::name` / `SCHEMA::name` / `DATABASE::name` / `TYPE::name` detected via peek-restore on the `:` operator pair. Grantee names accept either `Name` or `ReservedKeyword` raw text so `public` (which tokenizes as `ReservedKeyword.Public`) works without special-casing. WITH GRANT OPTION sets state to `W`; REVOKE removes matching rows; DENY sets state to `D`. Dispatched alongside CREATE/DROP/ALTER in `Simulation.cs`'s main statement loop; `Grant` / `Revoke` / `Deny` also added to the statement-boundary keyword list so the parser can resume after them. - **`CREATE USER name [{FOR | FROM} ...] [WITH ...]`** + **`CREATE ROLE name [AUTHORIZATION owner]`** + **`ALTER ROLE name { ADD MEMBER name | DROP MEMBER name | WITH NAME = newname }`** + **`DROP USER [IF EXISTS] name`** + **`DROP ROLE [IF EXISTS] name`** in `Simulation/Simulation.PrincipalDdl.cs`. CREATE USER allocates a SQL_USER principal; CREATE ROLE allocates a DATABASE_ROLE principal; both eat their tail clauses through the next statement boundary via a shared `ConsumeToStatementBoundary` helper (FROM LOGIN / WITH PASSWORD / DEFAULT_SCHEMA / EXTERNAL PROVIDER / AUTHORIZATION all parse-and-discard). ALTER ROLE ADD / DROP MEMBER mutates `Database.RoleMembers`; WITH NAME parse-and-discards. DROP USER / DROP ROLE remove from `Principals` and cascade-drop any `RoleMembers` row referencing the removed id. Routed ahead of the generic `DROP <target>` switch in `Simulation.Drop.cs` (principals don't live in a per-schema dict). `Role` added to `ContextualKeyword` enum; `User` already on the reserved list. **Catalog views** in `BuiltInResources.cs` (probe-confirmed shipped subsets): - **`sys.triggers`** extended — the existing per-schema `Schema.Triggers` loop is followed by a per-database `Database.DdlTriggers` loop, yielding rows with `parent_class=0`, `parent_class_desc='DATABASE'`, `parent_id=0`, `type_desc='SQL_TRIGGER'`, `is_instead_of_trigger=0`. - **`sys.database_principals`** (12-col): name / principal_id / type / type_desc / default_schema_name (NULL) / create_date / modify_date / owning_principal_id (NULL) / sid (NULL) / is_fixed_role / authentication_type (NULL) / authentication_type_desc (NULL). The simulator's principal model doesn't track default_schema or SIDs; surfaced as NULL. - **`sys.database_permissions`** (10-col): class (tinyint) / class_desc (`DATABASE` / `OBJECT_OR_COLUMN` / `SCHEMA` / `DATABASE_PRINCIPAL`) / major_id / minor_id / grantee_principal_id / grantor_principal_id / type (char(2) — 4-char code right-trimmed) / permission_name (nvarchar(128)) / state (char(1)) / state_desc (`GRANT` / `GRANT_WITH_GRANT_OPTION` / `DENY` / `REVOKE`). - **`sys.database_role_members`** (2-col, full row): role_principal_id / member_principal_id. The 4-char permission type code is derived as first-letter-of-each-word right-padded with spaces (e.g. `VIEW ANY COLUMN MASTER KEY DEFINITION` → `VACM`). Approximate — real SQL Server's mapping diverges for short names like `SELECT` → `SL` and `UPDATE` → `UP`; a per-permission lookup table would close the gap and is the polish path. **Errors enforced verbatim** (probe-confirmed against SQL Server 2025 on 2026-05-14): `Msg 15151` for unknown principal (GRANT/REVOKE/DENY/ALTER ROLE), `Msg 15023` for duplicate CREATE USER / CREATE ROLE name. **AW emit confirmed** by probing the AW bacpac model.xml on 2026-05-14: `SqlDatabaseDdlTrigger Name="[ddlDatabaseTriggerLog]"` carries a `BodyScript` property with the full T-SQL body + a `SqlTriggerEventTypeSpecifier` list (17 event-type codes 101 / 103 / 138 / 137 / 139 / 248 / 247 / 249 / 205 / 198 / 197 / 199 / 257 / 255 / 258 / 256 / 291 …) representing the expanded `DDL_DATABASE_LEVEL_EVENTS` group; the `HeaderContents` annotation shows the canonical T-SQL `CREATE TRIGGER [ddlDatabaseTriggerLog] ON DATABASE FOR DDL_DATABASE_LEVEL_EVENTS AS`. AW's two `SqlPermissionStatement` elements both target `Grantee=[public]` and `SecuredObject Disambiguator="1"` (database scope), encoding the GRANT-VIEW-ANY-COLUMN-ENCRYPTION-KEY-DEFINITION-TO-PUBLIC + GRANT-VIEW-ANY-COLUMN-MASTER-KEY-DEFINITION-TO-PUBLIC pair. No `CREATE USER` / `CREATE ROLE` elements in AW — only the pre-seeded `public` is referenced. 22 new tests across `DdlTriggerTests.cs` (7) and `PermissionStatementTests.cs` (15) cover: DDL trigger CREATE / DROP / DROP IF EXISTS / multi-event-type / collision Msg 2714 / CREATE OR ALTER upsert / catalog row shape; GRANT to public (AW exact shape) / object-scope GRANT / REVOKE removes prior grant / DENY state / WITH GRANT OPTION state / unknown-principal Msg 15151 / CREATE USER + CREATE ROLE happy paths / duplicate Msg 15023 / ALTER ROLE ADD MEMBER lands in role_members / DROP USER / DROP USER IF EXISTS / DROP ROLE cascade-drops membership / pre-seeded fixed principals visible / multi-permission comma list. Test counts: 4120 main (+22) / 227 internal / 328 EFCore / 58 analyzers — all green Debug + Release. `docs/claude/bacpac-prerequisites.md` flips both prereq items from `[ ]` to `[x]` with full implementation walkthroughs; the order-of-operations sequence drops the merged item. `CLAUDE.md` shrinks the BACPAC prerequisite list to the remaining items (xml / spatial / full-text) and gains two new Feature-reference index entries for DDL trigger and the GRANT/REVOKE/DENY+principal-DDL surface. **Deferred follow-ups** documented in `bacpac-prerequisites.md`: DDL trigger firing (the simulator doesn't dispatch DDL events — AW's trigger body is an audit-log writer, not load-bearing); `sys.sql_modules` to surface DDL trigger bodies (not modeled at all yet — would extend to procs / funcs / views / DML triggers too); `DISABLE TRIGGER … ON DATABASE` / `ENABLE TRIGGER … ON DATABASE`; the per-permission 4-char type code lookup table (currently first-letter heuristic); server-scope / schema-scope / column-scope grants; `CREATE LOGIN` / `ALTER LOGIN` / `DROP LOGIN` (server-scope); the full `CREATE USER … FROM EXTERNAL PROVIDER` / `WITH PASSWORD` semantics (currently parse-and-discard).
1 parent f91a71c commit 8e1b7f3

19 files changed

Lines changed: 1369 additions & 24 deletions

CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,10 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
158158
- **Touching table hints (`WITH (NOLOCK [, …])` on FROM sources / JOIN-RHS / UPDATE / DELETE targets, the legacy `(hint)` without-WITH form, `Msg 321` unknown-hint rejection), statement-level `OPTION (…)` hints (`RECOMPILE` / `MAXDOP` / `FAST` / `LOOP|HASH|MERGE JOIN` / `FORCE ORDER` / `OPTIMIZE FOR (UNKNOWN)` / `USE HINT(…)` / `MAXRECURSION` with its CTE-binding override), the `TableHintNames` / `OptionHintFirstWords` closed accept-lists, or `SkipBalancedParens` argument consumption**[`docs/claude/query-hints.md`](docs/claude/query-hints.md).
159159
- **Touching the `LockResource` / `LockManager` pair, the 8-mode compatibility matrix (Sch-S / Sch-M / IS / IX / SIX / S / U / X), `BatchContext.AcquireStatementLock` / `AcquireTransactionLock` / `AcquireDataLockIfApplicable` / `AcquireRowLockTxScoped` / `TouchRowForRead` / `WrapWithRowConflictChecks` / `ResolveSnapshotXidForRead`, the `DataLockPlan` struct that captures the per-row strategy, the row-lock dict on `HeapTable.RowLocks`, lock escalation (5000-threshold via `SimulatedDbTransaction.RowLockCountsByTable` + `EscalatedTables`), `SimulatedDbConnection.SessionIsolationLevel` + `SET TRANSACTION ISOLATION LEVEL` wiring, the hint surface (UPDLOCK / XLOCK / READPAST / TABLOCK / TABLOCKX / HOLDLOCK / SERIALIZABLE / REPEATABLEREAD / NOLOCK), `SimulatedDbConnection.Spid` / `LockTimeoutMillis` / `CurrentExecutingThreadId` / `WaitingOnResource`, the same-thread-deadlock check and cross-thread waiter-graph cycle detection (Msg 1205, auto-rollback of victim's tx), the lock-timeout path (Msg 1222), or the SNAPSHOT + RCSI machinery (`Database.AllowSnapshotIsolation` / `ReadCommittedSnapshot` / `AllocateTransactionCommitId`, `HeapTable.RowVersions` + `RowVersionChain` + `HistoricalVersion`, the `VersionStore` static helper for `CaptureWrite` / `FinalizePendingEntries` / `DiscardPendingEntries` / `ResolveVisibleVersion` / `CheckSnapshotUpdateConflict`, `SimulatedDbTransaction.SnapshotXid` / `PendingVersionEntries`, `BatchContext.RcsiStatementSnapshotXid`, `ALTER DATABASE … SET (ALLOW_SNAPSHOT_ISOLATION | READ_COMMITTED_SNAPSHOT)`, Msg 3952 + Msg 3960, `VersionStore.RunGarbageCollection` + `Database.ActiveSnapshotTxs`, or the `sys.dm_tran_version_store` / `sys.dm_tran_version_store_space_usage` / `sys.dm_tran_active_snapshot_database_transactions` DMVs via `VersionStoreDmvs`)** → [`docs/claude/locking.md`](docs/claude/locking.md).
160160
- **Touching `hierarchyid` (the `HierarchyIdSqlType` storage + segment-array `int[][]` internal representation in `Storage/HierarchyIdType.cs`, the `SqlValue.FromHierarchyId` / `AsHierarchyId` accessors, the `hierarchyid::Parse` / `hierarchyid::GetRoot` static-call dispatch in `HierarchyIdStaticCall`, the `.GetLevel` / `.GetAncestor` / `.GetDescendant` / `.IsDescendantOf` / `.ToString` instance-method dispatch in `HierarchyIdMethodCall`, the Msg 6522 verbatim wording in `SimulatedSqlException.TypeErrors.cs`, the `:` / `.<method>(` parser cases in `Expression.cs`, or the `sys.types` row shape — system_type_id 240, user_type_id 128, max_length 892)**[`docs/claude/bacpac-prerequisites.md`](docs/claude/bacpac-prerequisites.md) (the `hierarchyid` section carries the implementation detail; the byte-identical-CAST gap is deferred to the BACPAC loader bundle).
161+
- **Touching `CREATE TRIGGER … ON DATABASE` / `DROP TRIGGER … ON DATABASE`, the `DdlTrigger` class + `Database.DdlTriggers` dict, the `sys.triggers` row shape for DDL triggers (parent_class=0, parent_class_desc='DATABASE', parent_id=0), or the no-fire deferral (DDL events aren't dispatched to a trigger loop)**[`docs/claude/bacpac-prerequisites.md`](docs/claude/bacpac-prerequisites.md) (the "DDL trigger" section carries the implementation detail).
162+
- **Touching `GRANT` / `REVOKE` / `DENY` parsing (including `WITH GRANT OPTION`, `REVOKE GRANT OPTION FOR`, `CASCADE`, `ON OBJECT::name` / `SCHEMA::name` / `DATABASE::name` / `TYPE::name`), the principal DDL surface (`CREATE USER`, `CREATE ROLE`, `ALTER ROLE … {ADD | DROP} MEMBER`, `DROP USER`, `DROP ROLE`), the `DatabasePrincipal` / `DatabasePermission` classes + `Database.Principals` / `Database.Permissions` / `Database.RoleMembers`, the pre-seeded fixed principals (public=0 / dbo=1 / guest=2 / INFORMATION_SCHEMA=3 / sys=4), the `sys.database_principals` / `sys.database_permissions` / `sys.database_role_members` catalog views, the Msg 15151 unknown-principal verbatim wording, or the Msg 15023 duplicate-principal-name verbatim wording**[`docs/claude/bacpac-prerequisites.md`](docs/claude/bacpac-prerequisites.md) (the "Permission statements" section carries the implementation detail).
161163
- **Adding a new top-level statement parser or changing the dispatch loop's statement-separator rules**[`docs/claude/grammar.md`](docs/claude/grammar.md) + [`docs/claude/control-flow.md`](docs/claude/control-flow.md).
162-
- **Working on BACPAC import support (the eventual `Simulation.FromBacpac` / `FromBacPac` entry point), or knocking off any of its remaining prerequisite features (DDL triggers `ON DATABASE`, permission statements `GRANT`/`REVOKE`/`DENY`, `xml` + XML schema collections + XML methods + XML indexes, `geography`/`geometry` spatial types, full-text catalog/index + `CONTAINS`/`FREETEXT`)**[`docs/claude/bacpac-prerequisites.md`](docs/claude/bacpac-prerequisites.md). Working document with a feature checklist + the BCP wire-format type matrix; serves as the running scoping doc until the loader baseline lands. Shipped prerequisites: UDDTs / alias types, `sp_addextendedproperty` + `sys.extended_properties`, ALTER DATABASE options accept-list, hierarchyid AW-minimum-viable surface.
164+
- **Working on BACPAC import support (the eventual `Simulation.FromBacpac` / `FromBacPac` entry point), or knocking off any of its remaining prerequisite features (`xml` + XML schema collections + XML methods + XML indexes, `geography`/`geometry` spatial types, full-text catalog/index + `CONTAINS`/`FREETEXT`)**[`docs/claude/bacpac-prerequisites.md`](docs/claude/bacpac-prerequisites.md). Working document with a feature checklist + the BCP wire-format type matrix; serves as the running scoping doc until the loader baseline lands. Shipped prerequisites: UDDTs / alias types, `sp_addextendedproperty` + `sys.extended_properties`, ALTER DATABASE options accept-list, hierarchyid AW-minimum-viable surface, DDL trigger `ON DATABASE`, GRANT/REVOKE/DENY + CREATE USER/CREATE ROLE/ALTER ROLE/DROP USER/DROP ROLE + `sys.database_principals` / `sys.database_permissions` / `sys.database_role_members`.
163165

164166
## Not modeled
165167

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Behavioral tests for database-scope DDL triggers
7+
/// (<c>CREATE TRIGGER … ON DATABASE FOR &lt;event_type_group&gt; AS &lt;body&gt;</c>).
8+
/// The simulator stores DDL triggers for catalog-view round-trip but does
9+
/// not fire them; these tests verify the storage + catalog visibility +
10+
/// drop / disable paths.
11+
/// </summary>
12+
[TestClass]
13+
public sealed class DdlTriggerTests
14+
{
15+
private const string CreateAwTrigger = """
16+
create trigger [ddlDatabaseTriggerLog]
17+
on database
18+
for ddl_database_level_events as
19+
begin
20+
set nocount on;
21+
end
22+
""";
23+
24+
[TestMethod]
25+
public void CreateTrigger_OnDatabase_Succeeds()
26+
{
27+
var sim = new Simulation();
28+
_ = sim.ExecuteNonQuery(CreateAwTrigger);
29+
AreEqual(1, sim.ExecuteScalar("select count(*) from sys.triggers where name = 'ddlDatabaseTriggerLog'"));
30+
}
31+
32+
[TestMethod]
33+
public void DdlTrigger_SysTriggersRow_HasDatabaseParentClass()
34+
{
35+
var sim = new Simulation();
36+
_ = sim.ExecuteNonQuery(CreateAwTrigger);
37+
AreEqual((byte)0, sim.ExecuteScalar("select parent_class from sys.triggers where name = 'ddlDatabaseTriggerLog'"));
38+
AreEqual("DATABASE", sim.ExecuteScalar("select parent_class_desc from sys.triggers where name = 'ddlDatabaseTriggerLog'"));
39+
AreEqual(0, sim.ExecuteScalar("select parent_id from sys.triggers where name = 'ddlDatabaseTriggerLog'"));
40+
AreEqual("SQL_TRIGGER", sim.ExecuteScalar("select type_desc from sys.triggers where name = 'ddlDatabaseTriggerLog'"));
41+
IsFalse((bool)sim.ExecuteScalar("select is_instead_of_trigger from sys.triggers where name = 'ddlDatabaseTriggerLog'")!);
42+
IsFalse((bool)sim.ExecuteScalar("select is_disabled from sys.triggers where name = 'ddlDatabaseTriggerLog'")!);
43+
}
44+
45+
[TestMethod]
46+
public void DropTrigger_OnDatabase_RemovesFromCatalog()
47+
{
48+
var sim = new Simulation();
49+
_ = sim.ExecuteNonQuery(CreateAwTrigger);
50+
_ = sim.ExecuteNonQuery("drop trigger [ddlDatabaseTriggerLog] on database");
51+
AreEqual(0, sim.ExecuteScalar("select count(*) from sys.triggers where name = 'ddlDatabaseTriggerLog'"));
52+
}
53+
54+
[TestMethod]
55+
public void DropTrigger_OnDatabase_IfExists_SilentOnMissing()
56+
{
57+
var sim = new Simulation();
58+
_ = sim.ExecuteNonQuery("drop trigger if exists [missingDdlTrigger] on database");
59+
AreEqual(0, sim.ExecuteScalar("select count(*) from sys.triggers where name = 'missingDdlTrigger'"));
60+
}
61+
62+
[TestMethod]
63+
public void CreateTrigger_MultiEvent_StoresAllEventTypes()
64+
{
65+
var sim = new Simulation();
66+
_ = sim.ExecuteNonQuery("""
67+
create trigger [multiEvent]
68+
on database
69+
for create_table, drop_table, alter_table as
70+
begin set nocount on; end
71+
""");
72+
AreEqual(1, sim.ExecuteScalar("select count(*) from sys.triggers where name = 'multiEvent'"));
73+
}
74+
75+
[TestMethod]
76+
public void CreateTrigger_OnDatabase_AlreadyExists_Raises2714()
77+
{
78+
var sim = new Simulation();
79+
_ = sim.ExecuteNonQuery(CreateAwTrigger);
80+
_ = sim.AssertSqlError(CreateAwTrigger, 2714);
81+
}
82+
83+
[TestMethod]
84+
public void CreateOrAlterTrigger_OnDatabase_UpsertsBody()
85+
{
86+
var sim = new Simulation();
87+
_ = sim.ExecuteNonQuery(CreateAwTrigger);
88+
_ = sim.ExecuteNonQuery("""
89+
create or alter trigger [ddlDatabaseTriggerLog]
90+
on database
91+
for ddl_table_events as
92+
begin set nocount on; end
93+
""");
94+
AreEqual(1, sim.ExecuteScalar("select count(*) from sys.triggers where name = 'ddlDatabaseTriggerLog'"));
95+
}
96+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Behavioral tests for <c>GRANT</c> / <c>REVOKE</c> / <c>DENY</c> + the
7+
/// principal DDL (<c>CREATE USER</c>, <c>CREATE ROLE</c>,
8+
/// <c>ALTER ROLE … ADD MEMBER</c>, <c>DROP USER</c>, <c>DROP ROLE</c>).
9+
/// The simulator stores parsed permissions and principals for catalog-view
10+
/// round-trip; no enforcement is performed.
11+
/// </summary>
12+
[TestClass]
13+
public sealed class PermissionStatementTests
14+
{
15+
[TestMethod]
16+
public void Grant_AwShape_ToPublic_LandsInCatalog()
17+
{
18+
var sim = new Simulation();
19+
_ = sim.ExecuteNonQuery("grant view any column encryption key definition to public");
20+
AreEqual(1, sim.ExecuteScalar("select count(*) from sys.database_permissions where permission_name = 'VIEW ANY COLUMN ENCRYPTION KEY DEFINITION'"));
21+
AreEqual("GRANT", sim.ExecuteScalar("select state_desc from sys.database_permissions where permission_name = 'VIEW ANY COLUMN ENCRYPTION KEY DEFINITION'"));
22+
AreEqual(0, sim.ExecuteScalar("select cast(class as int) from sys.database_permissions where permission_name = 'VIEW ANY COLUMN ENCRYPTION KEY DEFINITION'"));
23+
// Grantee = public (principal_id 0, pre-seeded)
24+
AreEqual(0, sim.ExecuteScalar("select grantee_principal_id from sys.database_permissions where permission_name = 'VIEW ANY COLUMN ENCRYPTION KEY DEFINITION'"));
25+
}
26+
27+
[TestMethod]
28+
public void Grant_SelectOnTable_StoresObjectScope()
29+
{
30+
var sim = new Simulation();
31+
_ = sim.ExecuteNonQuery("""
32+
create table t (id int);
33+
grant select on t to public
34+
""");
35+
AreEqual(1, sim.ExecuteScalar("select count(*) from sys.database_permissions where permission_name = 'SELECT'"));
36+
}
37+
38+
[TestMethod]
39+
public void Revoke_RemovesPriorGrant()
40+
{
41+
var sim = new Simulation();
42+
_ = sim.ExecuteNonQuery("""
43+
grant view any column master key definition to public;
44+
revoke view any column master key definition from public;
45+
""");
46+
AreEqual(0, sim.ExecuteScalar("select count(*) from sys.database_permissions where permission_name = 'VIEW ANY COLUMN MASTER KEY DEFINITION'"));
47+
}
48+
49+
[TestMethod]
50+
public void Deny_StoresWithDenyState()
51+
{
52+
var sim = new Simulation();
53+
_ = sim.ExecuteNonQuery("deny view any column master key definition to public");
54+
AreEqual("DENY", sim.ExecuteScalar("select state_desc from sys.database_permissions where permission_name = 'VIEW ANY COLUMN MASTER KEY DEFINITION'"));
55+
}
56+
57+
[TestMethod]
58+
public void Grant_WithGrantOption_StoresWState()
59+
{
60+
var sim = new Simulation();
61+
_ = sim.ExecuteNonQuery("grant view any column master key definition to public with grant option");
62+
AreEqual("GRANT_WITH_GRANT_OPTION", sim.ExecuteScalar("select state_desc from sys.database_permissions where permission_name = 'VIEW ANY COLUMN MASTER KEY DEFINITION'"));
63+
}
64+
65+
[TestMethod]
66+
public void Grant_UnknownPrincipal_Raises15151()
67+
=> new Simulation().AssertSqlError("grant select to no_such_principal", 15151);
68+
69+
[TestMethod]
70+
public void CreateUser_StoresInPrincipalsDict()
71+
{
72+
var sim = new Simulation();
73+
_ = sim.ExecuteNonQuery("create user alice");
74+
AreEqual(1, sim.ExecuteScalar("select count(*) from sys.database_principals where name = 'alice'"));
75+
AreEqual("SQL_USER", sim.ExecuteScalar("select type_desc from sys.database_principals where name = 'alice'"));
76+
}
77+
78+
[TestMethod]
79+
public void CreateRole_StoresInPrincipalsDict()
80+
{
81+
var sim = new Simulation();
82+
_ = sim.ExecuteNonQuery("create role data_reader");
83+
AreEqual("DATABASE_ROLE", sim.ExecuteScalar("select type_desc from sys.database_principals where name = 'data_reader'"));
84+
}
85+
86+
[TestMethod]
87+
public void CreateUser_Duplicate_Raises15023()
88+
{
89+
var sim = new Simulation();
90+
_ = sim.ExecuteNonQuery("create user alice");
91+
_ = sim.AssertSqlError("create user alice", 15023);
92+
}
93+
94+
[TestMethod]
95+
public void AlterRole_AddMember_LandsInRoleMembersView()
96+
{
97+
var sim = new Simulation();
98+
_ = sim.ExecuteNonQuery("""
99+
create user alice;
100+
create role data_reader;
101+
alter role data_reader add member alice;
102+
""");
103+
AreEqual(1, sim.ExecuteScalar("""
104+
select count(*)
105+
from sys.database_role_members rm
106+
join sys.database_principals r on r.principal_id = rm.role_principal_id
107+
join sys.database_principals m on m.principal_id = rm.member_principal_id
108+
where r.name = 'data_reader' and m.name = 'alice'
109+
"""));
110+
}
111+
112+
[TestMethod]
113+
public void DropUser_RemovesFromCatalog()
114+
{
115+
var sim = new Simulation();
116+
_ = sim.ExecuteNonQuery("""
117+
create user alice;
118+
drop user alice;
119+
""");
120+
AreEqual(0, sim.ExecuteScalar("select count(*) from sys.database_principals where name = 'alice'"));
121+
}
122+
123+
[TestMethod]
124+
public void DropUser_IfExists_SilentOnMissing()
125+
=> AreEqual(0, new Simulation().ExecuteScalar("drop user if exists alice; select count(*) from sys.database_principals where name = 'alice'"));
126+
127+
[TestMethod]
128+
public void DropRole_CascadeDropsMembership()
129+
{
130+
var sim = new Simulation();
131+
_ = sim.ExecuteNonQuery("""
132+
create user alice;
133+
create role data_reader;
134+
alter role data_reader add member alice;
135+
drop role data_reader
136+
""");
137+
AreEqual(0, sim.ExecuteScalar("select count(*) from sys.database_role_members"));
138+
}
139+
140+
[TestMethod]
141+
public void SysDatabasePrincipals_HasFixedPrincipalsPreSeeded()
142+
{
143+
var sim = new Simulation();
144+
AreEqual(1, sim.ExecuteScalar("select count(*) from sys.database_principals where name = 'public' and is_fixed_role = 1"));
145+
AreEqual(1, sim.ExecuteScalar("select count(*) from sys.database_principals where name = 'dbo'"));
146+
AreEqual(0, sim.ExecuteScalar("select principal_id from sys.database_principals where name = 'public'"));
147+
AreEqual(1, sim.ExecuteScalar("select principal_id from sys.database_principals where name = 'dbo'"));
148+
}
149+
150+
[TestMethod]
151+
public void Grant_MultiplePermissionsCommaList_StoresEach()
152+
{
153+
var sim = new Simulation();
154+
_ = sim.ExecuteNonQuery("""
155+
create table t (id int);
156+
grant select, update, delete on t to public
157+
""");
158+
AreEqual(3, sim.ExecuteScalar("select count(*) from sys.database_permissions where grantee_principal_id = 0 and class = 1"));
159+
}
160+
}

0 commit comments

Comments
 (0)