Skip to main content

Sign in as a test user

The problem​

Most applications decide what a caller may do by who the caller is. A test that calls the API has to act as someone: an owner who can create projects, or a viewer who is refused.

You already used [SignedInAs] in your first test. This lesson follows that identity into a request and into the trace.

Do it​

1. Declare who the test acts as​

ProjectsJourney has a test that acts as a viewer:

ProjectsJourney.cs2 notes
1[Application(NorthstarTargets.Api)]
2[NorthstarMember(PlanIds.Growth)]
3public sealed class ProjectsJourney
4{
5[ProtoTest]
6[SignedInAs("viewer", MemberRoles.Viewer)]
7public async Task AViewerCannotCreateProjects()
8{
9using var response = await Proto.Context.Rest()
10.Body(new CreateProjectRequest("viewer-atlas"))
11.PostAsync("/api/v1/projects");
12
13response.Should.HaveHttpStatus(HttpStatusCode.Forbidden);
14}
15}
  1. Name, then roles

    Without arguments it is the built-in test-user, with no roles.

  2. The application decides

    The 403 comes from the application's own authorization.

From samples/Northstar.ProtoTest/ProjectsJourney.cs. Claims use the same attribute: Claims = new[] { "tenant=northstar" }.

The declaration only describes the identity. It does not get credentials from the application.

2. See what the attribute does​

Before the test body runs, the attribute publishes the identity to the context:

SignedInAsAttribute.cs2 notes
1public override Task BeforeTestAsync(ProtoExecutionContext context)
2{
3context.SignIn(new ProtoTestUser(
4Name,
5Roles,
6[.. Claims.Select(ParseClaim)]));
7return Task.CompletedTask;
8}
  1. One call publishes the identity

    SignIn sets this test's user and records an auth:user entity.

  2. Claim values stay out of auth:user

    It records only the claim types. Embedded source can still show the values.

From src/ProtoTest.Http/Authentication/SignedInAsAttribute.cs. REST, GraphQL and gRPC requests carry the identity through the same auth lifecycle.

[NorthstarMember] also adds the sample's authenticator. When a request needs credentials, the authenticator turns the declared identity into a member of the test's tenant:

NorthstarMember.cs2 notes
1var user = context.SignedInUser();
2var organization = context.Resolve<NorthstarOrganizationContext>();
3var role = user.Roles.Count > 0 ? user.Roles[0] : MemberRoles.Owner;
4var member = role == MemberRoles.Owner
5? new NorthstarMemberContext("owner", organization.OwnerEmail, role, organization.OwnerToken)
6: await InviteAsync(context, role);
  1. The first role picks the member

    No role, or owner: the tenant owner. Any other role: an invited member.

  2. The member is the credential

    The application resolves the member token.

From samples/Northstar.ProtoTest/NorthstarMember.cs.

3. Read the identity in the trace​

Open l1-first-journey.prototrace in the viewer. The recorded test contains these setup and request operations, plus its identity entity:

EntryReading
Before · NorthstarTenantAttribute, 139.9 msthe tenant comes first
Before · SignedInAsAttribute, 1.7 msits event reads Signed in as test-user
Apply · TestUserAuthenticator, 1.2 msthe shipped transport: ProtoTest's built-in way to pass the identity, as a request header. It applies because the server runs in-process
Apply · NorthstarAuthenticator, 1.0 msthe sample's own authenticator on the same request
auth entity auth:user: test-user, no roles, no claim types, transport in-processthe identity the trace keeps

4. Try the limit: a published application​

The shipped transport needs an application the run hosts in-process. To try the limit, create PublishedAliceProbe.cs in samples/Northstar.ProtoTest/ with this content. It leaves [NorthstarMember] off, because tenant setup needs a live application.

PublishedAliceProbe.cs1 note
1namespace Northstar.ProtoTest;
2
3using System.Net;
4using global::ProtoTest.Core;
5using global::ProtoTest.Http;
6using global::ProtoTest.NUnit;
7using global::ProtoTest.Rest;
8using global::ProtoTest.SampleApp.Contracts;
9
10[Application(NorthstarTargets.Api)]
11public sealed class PublishedAliceProbe
12{
13[ProtoTest]
14[SignedInAs("alice", "admin", Claims = ["tenant=northstar"])]
15public async Task TheIdentityIsStillRecorded()
16{
17using var response = await Proto.Context.Rest()
18.Body(new CreateProjectRequest("alice-atlas"))
19.PostAsync("/api/v1/projects");
20
21response.Should.HaveHttpStatus(HttpStatusCode.Created);
22}
23}
  1. The declaration

    The identity is recorded before the request attempts to reach the application.

Run it against an unused local port, such as 5099. The script restores your previous target setting:

$previousTargetUrl = $env:ProtoTest__TargetUrl
try {
$env:ProtoTest__TargetUrl = "http://127.0.0.1:5099"
dotnet test samples/Northstar.ProtoTest --filter "FullyQualifiedName~PublishedAliceProbe"
}
finally {
if ($null -eq $previousTargetUrl) {
Remove-Item Env:ProtoTest__TargetUrl -ErrorAction SilentlyContinue
}
else {
$env:ProtoTest__TargetUrl = $previousTargetUrl
}
}

With no listener on that port, the request fails. The test's trace still records the identity:

  • auth.user: alice, auth.roles: admin, auth.claim_types: tenant.
  • auth.transport: inert, meaning the header is switched off, with a reason that names AddAspNetCoreServer and webHost.AddTestUserAuthentication().
  • An auth.user.inert event with outcome skipped.

Delete PublishedAliceProbe.cs afterwards.

What happened​

[SignedInAs] declared who the test acts as and nothing more. Something else has to turn that into a login the application accepts.

  • In-process, the shipped transport sends a ProtoTest-User header. An application that should treat it as its own principal opts in:

    builder.AddApplication("Api", app => app
    .AddAspNetCoreServer<Program>(webHost => webHost.AddTestUserAuthentication())
    .AddRest(rest => rest.AddClient("Api")));

    The handler turns the header into a ClaimsPrincipal, so the application's own [Authorize] checks decide. The sample leaves it out, because it tests its own authentication.

  • Published, the shipped transport is inert and says why. A custom [Auth<T>] authenticator can map context.SignedInUser() to credentials the application accepts, as the sample does.

Check yourself​

A test declares [SignedInAs("alice", "admin", Claims = ["tenant=northstar"])]. What does auth:user record, and where could the claim value still appear?

Verify
Run the published case above and read its auth:user entity. The saved first journey shows the same fields for test-user, without roles or claims.

Remember​

  • [SignedInAs] declares a per-test identity: a name, roles and claims.
  • Its auth entity records the name, roles and claim types, without claim values.
  • The shipped transport needs an in-process application. Against a published one it is inert and records why.

Go deeper​