본문으로 건너뛰기
cccskills
무료GitHub에 공개

security-test-checklist

Step-by-step checklist for writing ownership enforcement (Forbid/403) tests on API controllers and Web MVC controllers that gate on CreatedByEntraOid

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

설치 방법 보기

포함된 파일(1)

  • SKILL.md11.3 KB

SKILL.md(원문)

설치하기 전에 에이전트에게 전달되는 지시 내용을 확인할 수 있습니다.

Context

When a controller enforces resource ownership via CreatedByEntraOid, every action that reads, modifies, or deletes a resource must return 403 Forbidden when the caller's Entra OID does not match the entity's stored OID. This pattern is established across EngagementsController, SchedulesController, TalksController, and PlatformsController.

Use this checklist every time you:

  • Add a new controller that calls Forbid()
  • Add ownership enforcement to an existing controller action
  • Review a PR that touches any controller with CreatedByEntraOid checks

Project: JJGNET Broadcasting
Framework: xUnit + Moq + FluentAssertions
Auth model: User.FindFirstValue(ApplicationClaimTypes.EntraObjectId) vs entity.CreatedByEntraOid


Step 1 — Pre-work: Grep the Forbid() Sites

Before writing a single test, know exactly how many Forbid() call sites exist in the controller:

Select-String -Path ".\src\**\*Controller.cs" -Pattern "Forbid\(\)" -Recurse

Or for a specific controller:

Select-String -Path ".\src\JosephGuadagno.Broadcasting.Api\Controllers\SchedulesController.cs" -Pattern "Forbid\(\)"

Rule: One non-owner 403 test is required per Forbid() call site. Do not guess — count from grep output.


Step 2 — Build the Coverage Matrix

Create a table before writing any tests. One row per Forbid() site:

Action MethodHTTP VerbForbid() SiteNon-Owner TestAdmin Bypass Test
GetScheduledItemAsyncGETLine 47✅ WhenNonOwner_ReturnsForbidN/A (read-only)
UpdateScheduledItemAsyncPUTLine 89✅ WhenNonOwner_ReturnsForbidN/A
DeleteScheduledItemAsyncDELETELine 112✅ WhenNonOwner_ReturnsForbidN/A

Mark each cell only after the test is written and green. Do not close the PR with empty cells.


Step 3 — OID Mismatch Pattern (API — ForbidResult)

The canonical non-owner test pattern for API controllers:

[Fact]
public async Task UpdateScheduledItemAsync_WhenNonOwner_ReturnsForbid()
{
    // Arrange
    // Entity is owned by "owner-oid-12345".
    // The calling user has a DIFFERENT OID — ownership check must reject it.
    var item = BuildScheduledItem(5, oid: "owner-oid-12345");
    var request = BuildScheduledItemRequest(5);
    _scheduledItemManagerMock.Setup(m => m.GetAsync(5)).ReturnsAsync(item);

    // SUT is initialized with a non-owner OID — this is the mismatch.
    var sut = CreateSut(Domain.Scopes.Schedules.All, ownerOid: "non-owner-oid-99999");

    // Act
    var result = await sut.UpdateScheduledItemAsync(5, request);

    // Assert
    result.Result.Should().BeOfType<ForbidResult>();
    // Side-effect must never fire — authorization short-circuits before SaveAsync.
    _scheduledItemManagerMock.Verify(m => m.SaveAsync(It.IsAny<ScheduledItem>()), Times.Never);
}

Key invariants:

  • Entity OID ("owner-oid-12345") ≠ caller OID ("non-owner-oid-99999") — they must be different strings
  • Use domain-constant scope: Domain.Scopes.Schedules.All — no magic strings
  • Times.Never on every side-effect mock that must not fire during a 403

Step 4 — Required Test Assertions

Every non-owner 403 test must include both of these assertions:

4a. Result type assertion

result.Result.Should().BeOfType<ForbidResult>();

4b. Side-effect Times.Never assertion

// Example: delete action must not call DeleteAsync if ownership check fails
_managerMock.Verify(m => m.DeleteAsync(It.IsAny<int>()), Times.Never);

If the controller calls GetAsync first (pre-flight), verify that only GetAsync fires:

_managerMock.Verify(m => m.GetAsync(5), Times.Once);        // pre-flight: must fire
_managerMock.Verify(m => m.DeleteAsync(It.IsAny<int>()), Times.Never); // side-effect: must NOT fire

Step 5 — Admin Bypass Verification

When the controller has an IsSiteAdministrator() branch that skips the ownership check, add a test that proves the admin path calls the unfiltered overload:

[Fact]
public async Task GetScheduledItemsAsync_WhenSiteAdmin_CallsUnfilteredGetAll()
{
    // Arrange
    var items = new List<ScheduledItem> { BuildScheduledItem(1) };
    // Set up the unfiltered overload (no ownerOid param).
    _scheduledItemManagerMock
        .Setup(m => m.GetAllAsync(It.IsAny<int>(), It.IsAny<int>()))
        .ReturnsAsync(new PagedResult<ScheduledItem> { Items = items, TotalCount = items.Count });

    // SiteAdmin = true bypasses ownership filter.
    var sut = CreateSut(Domain.Scopes.Schedules.All, isSiteAdmin: true);

    // Act
    var result = await sut.GetScheduledItemsAsync();

    // Assert
    // Unfiltered overload must be invoked exactly once …
    _scheduledItemManagerMock.Verify(
        m => m.GetAllAsync(It.IsAny<int>(), It.IsAny<int>()),
        Times.Once);
    // … and the owner-filtered overload must NEVER be called.
    _scheduledItemManagerMock.Verify(
        m => m.GetAllAsync(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()),
        Times.Never);
}

Step 6 — Pre-PR Gate

Before opening any PR that adds or modifies controller security logic:

dotnet test .\src\ `
  --no-build `
  --verbosity normal `
  --configuration Release `
  --filter "FullyQualifiedName!~SyndicationFeedReader"

Hard rule: Zero failures. No exceptions. SyndicationFeedReader tests may be excluded (network-dependent), but all other tests must be green.

If the build is required first:

dotnet build .\src\ --configuration Release

Step 7 — Web MVC Variant (Redirect + TempData)

Web MVC controllers do not return ForbidResult. They redirect with an error message in TempData. The non-owner pattern for Web MVC:

[Fact]
public async Task Edit_Post_WhenNonOwner_RedirectsWithError()
{
    // Arrange
    var item = BuildScheduledItem(5, oid: "owner-oid-12345");
    _scheduledItemServiceMock.Setup(s => s.GetScheduledItemAsync(5)).ReturnsAsync(item);

    // Caller has a different OID — ownership check must reject it.
    _controller.ControllerContext = CreateControllerContext(ownerOid: "non-owner-oid-99999");

    // Act
    var result = await _controller.Edit(viewModel);

    // Assert — Web MVC redirects rather than returning ForbidResult
    var redirect = result.Should().BeOfType<RedirectToActionResult>().Subject;
    redirect.ActionName.Should().Be("Index");                         // or whichever action
    _controller.TempData["ErrorMessage"].Should().NotBeNull();
    // Side-effect must not fire
    _scheduledItemServiceMock.Verify(
        s => s.UpdateScheduledItemAsync(It.IsAny<ScheduledItem>()),
        Times.Never);
}

Key difference from API pattern:

API ControllerWeb MVC Controller
result.Result.Should().BeOfType<ForbidResult>()result.Should().BeOfType<RedirectToActionResult>()
No TempData checkTempData["ErrorMessage"].Should().NotBeNull()

Examples

Reference Files

ControllerTest File
EngagementsControllersrc\JosephGuadagno.Broadcasting.Api.Tests\Controllers\EngagementsControllerTests.cs
SchedulesController (API)src\JosephGuadagno.Broadcasting.Api.Tests\Controllers\SchedulesControllerTests.cs
TalksControllersrc\JosephGuadagno.Broadcasting.Api.Tests\Controllers\EngagementsController_TalksTests.cs
PlatformsControllersrc\JosephGuadagno.Broadcasting.Api.Tests\Controllers\EngagementsController_PlatformsTests.cs

Helper Structure (Required in Every API Test Class)

private SchedulesController CreateSut(string scopeClaimValue, string ownerOid = "test-oid-12345", bool isSiteAdmin = false)
{
    var controller = new SchedulesController(_scheduledItemManagerMock.Object, _loggerMock.Object, _mapper)
    {
        ControllerContext = CreateControllerContext(scopeClaimValue, ownerOid, isSiteAdmin),
        ProblemDetailsFactory = new TestProblemDetailsFactory()
    };
    return controller;
}

private static ControllerContext CreateControllerContext(string scopeClaimValue, string ownerOid = "test-oid-12345", bool isSiteAdmin = false)
{
    var claims = new List<Claim>
    {
        new Claim("scp", scopeClaimValue),
        new Claim("http://schemas.microsoft.com/identity/claims/scope", scopeClaimValue),
        new Claim(Domain.Constants.ApplicationClaimTypes.EntraObjectId, ownerOid)
    };
    if (isSiteAdmin)
        claims.Add(new Claim(ClaimTypes.Role, Domain.Constants.RoleNames.SiteAdministrator));

    var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuthentication"));
    var httpContext = new DefaultHttpContext { User = user };
    return new ControllerContext { HttpContext = httpContext };
}

private static ScheduledItem BuildScheduledItem(int id = 1, string oid = "test-oid-12345") => new()
{
    Id = id,
    // ... fields ...
    CreatedByEntraOid = oid   // ← must always be set; defaults to "test-oid-12345"
};

Anti-Patterns

❌ Forgetting to set entity OID

// WRONG — entity OID is null; ownership check behaviour is undefined
var item = new ScheduledItem { Id = 5, Message = "Test" };
// RIGHT — entity OID explicitly set to a different value than the caller's OID
var item = BuildScheduledItem(5, oid: "owner-oid-12345");
var sut = CreateSut(scope, ownerOid: "non-owner-oid-99999");

❌ Using the same OID for entity and caller in a non-owner test

// WRONG — this is an *owner* test; controller will pass, not Forbid
var item = BuildScheduledItem(5, oid: "test-oid-12345");
var sut = CreateSut(scope, ownerOid: "test-oid-12345"); // same!

❌ Missing Times.Never on side-effects

// WRONG — does not verify that the side-effect was suppressed
result.Result.Should().BeOfType<ForbidResult>();
// (missing _managerMock.Verify(..., Times.Never))

❌ Pushing with failing tests

Opening a PR with failing tests is not allowed. Always run dotnet test first. If tests fail, fix them before opening the PR.

❌ Mock overload mismatch after controller signature change

When a controller method signature adds an ownerOid parameter, the Moq .Setup() overload must be updated immediately. Mismatched overloads silently miss the setup, causing tests to return null and producing wrong results.


Patterns

Naming Convention for Non-Owner Tests

{ActionMethod}_WhenNonOwner_ReturnsForbid         // API
{ActionMethod}_WhenNonOwner_RedirectsWithError    // Web MVC
{ActionMethod}_WhenSiteAdmin_CallsUnfilteredGetAll // Admin bypass

OID Constants to Use

ConstantValueRole
Default owner OID"test-oid-12345"Entity owner + authorized user in happy-path tests
Non-owner OID"non-owner-oid-99999"Unauthorized caller in 403 tests
Owner OID (explicit)"owner-oid-12345"Entity owner when 403 test is explicit

All OIDs are test-only strings. No magic strings in production code — use Domain.Constants.ApplicationClaimTypes.EntraObjectId.

리뷰

아직 리뷰가 없습니다. 사용해 본 소감을 남겨 주세요.

댓글

로그인 없이 쓸 수 있습니다. 사용 후기, 질문, 작동하지 않은 환경 등을 편하게 남겨 주세요.

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

보내면 바로 공개됩니다. 이메일 주소나 전화번호 같은 개인 정보는 쓰지 마세요. URL은 2개까지입니다.

0 / 1,000자

이 양식은 reCAPTCHA로 보호되며 Google 개인정보처리방침 및 서비스 약관이 적용됩니다.

같은 저장소의 스킬

Standard collaboration patterns for all squad agents — worktree awareness, decisions, cross-agent communication

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

jguadagno/jjgnet-broadcast182026년 8월 4일 업데이트

Shared hard rules enforced across all squad agents

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

jguadagno/jjgnet-broadcast182026년 8월 4일 업데이트

Comprehensive AI prompt engineering safety review and improvement prompt. Analyzes prompts for safety, bias, security vulnerabilities, and effectiveness while providing detailed improvement recommendations with extensive frameworks, testing methodologies, and educational content.

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

jguadagno/jjgnet-broadcast182026년 8월 4일 업데이트

Migrate API controller scope checks to framework-enforced authorization policies while preserving ownership checks.

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

jguadagno/jjgnet-broadcast182026년 8월 4일 업데이트

Review API DTO implementations for contract/domain separation, mapping patterns, and REST compliance

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

jguadagno/jjgnet-broadcast182026년 8월 4일 업데이트

Register shared claims transformation and hierarchical role policies in the API without breaking existing scope enforcement.

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

jguadagno/jjgnet-broadcast182026년 8월 4일 업데이트

jguadagno의 스킬 모두 보기

이 스킬의 문제 신고