Skip to main content

Sign in as a test user

The first test you wrote carried [SignedInAs], and every journey in the sample carries it. This lesson reads what it declares, who carries it to the application, and what the trace keeps about it.

Level 2, lesson 4About 8 minutes
By the end
  • Declare the identity a test acts as with [SignedInAs].
  • Read the auth entity and the member role the sample derives from it.
  • Tell the in-process case from the published one, and what claim values never reach the trace.
Before you start
  • Add and remove an integration (lesson 3).
  • The sample cloned. Reading the archive alone also works.

The scenario​

Every Northstar journey names the user it acts as. [SignedInAs] with no arguments is the tenant's owner; a name and a role make the test a viewer who is refused. The declaration is the test's identity, not a credential.

The sample's application has its own member store, so it uses the identity to decide which member the journey acts as. An application without its own authentication can opt in to the shipped test user instead. This lesson reads both sides and the trace they leave.

The declaration​

The sample's first journey shows both forms:

ProjectsJourney.cs3 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. Start the context

    [ProtoTest] wraps this test in its own execution context; the identity belongs to that context alone.

  2. Name the user and the role

    The name comes first and roles follow; with no arguments the built-in name test-user is used. Here the viewer role is what the application refuses.

  3. The application decides

    The 403 comes from the application, which resolved the viewer and applied its own authorization.

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

Everything in the declaration is constant attribute data: names and roles, not secrets. One identity per test; declaring it twice replaces, not merges.

What the declaration becomes​

The attribute's whole before step publishes the identity:

SignedInAsAttribute.cs3 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 the type the context resolves and records the auth:user entity, so the next test starts with none.

  2. Name and roles

    A bare [SignedInAs] uses the built-in name test-user.

  3. Claim values stay types in the trace

    The values travel in the header; the trace records their types only.

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

The sample turns that identity into a member. [NorthstarMember] composes a tenant and the sample's authenticator, and the member resolution reads the role:

NorthstarMember.cs3 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. Read the identity

    SignedInUser() throws a message naming both ways to declare one when the test has none.

  2. The role picks the member

    No declared role means the tenant owner; the first role invites a member with exactly that role.

  3. The member is the credential

    The sample sends the member token as the credential; its application resolves that, not the shipped test-user header.

From samples/Northstar.ProtoTest/NorthstarMember.cs. The invited member's email carries the role and the test id, so parallel tests never share one.

The app side​

The sample keeps its own authentication. An application that should treat the test user as its own principal opts in to the shipped handler instead:

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

The handler decodes the ProtoTest-User header into a ClaimsPrincipal with the name, the roles and the custom claims. It becomes the default authentication scheme, so the application's own [Authorize] and role checks decide exactly as in production. The sample leaves it out on purpose: its subject is the application's own authentication, and the handler replaces the default scheme.

What the trace records​

The first journey's archive is the in-process case:

EntryReading
Before · NorthstarTenantAttribute, 139.9 msthe tenant is provisioned first, at Order -200
Before · SignedInAsAttribute, 1.7 msthe declaration runs next, at Order -100, and its event reads Signed in as test-user
Apply · TestUserAuthenticator, 1.2 msthe shipped transport applies for the in-process server
Apply · NorthstarAuthenticator, 1.0 msthe sample's own authenticator rides along on the same request
auth entity auth:user: name test-user, roles empty, claim types empty, application Northstar, transport in-processthe identity the trace keeps
Rest and GraphQL auth entities: source class, count 2, types ProtoTest.Http.SignedInAsAttribute, Northstar.ProtoTest.NorthstarAuthenticatorthe declaration composes with the app's own authenticator instead of replacing it

Claim values never appear. The entity records auth.claim_types, a list of types, and a gRPC call's prototest-user metadata is redacted in the trace as well.

The published case​

The shipped transport needs an application the run hosts in-process. Point the sample at an address that hosts nothing, the dead one from the Level 0 environment drill, and add a test that declares the identity and nothing else:

PublishedAliceProbe.cs2 notes
1[Application(NorthstarTargets.Api)]
2public sealed class PublishedAliceProbe
3{
4[ProtoTest]
5[SignedInAs("alice", "admin", Claims = ["tenant=northstar"])]
6public async Task TheIdentityIsStillRecorded()
7{
8using var response = await Proto.Context.Rest()
9.Body(new CreateProjectRequest("alice-atlas"))
10.PostAsync("/api/v1/projects");
11
12response.Should.HaveHttpStatus(HttpStatusCode.Created);
13}
14}
  1. Select the published application

    The same [Application] the sample journeys carry. Nothing in this test hosts it.

  2. Declare the identity

    This is the declaration the lesson is about. The request behind it is ordinary.

Put it in samples/Northstar.ProtoTest/. Leave [NorthstarMember] off: it provisions a tenant first, that call needs a live address, and it would fail before this declaration runs.

Run it by name against the dead address:

$env:ProtoTest__TargetUrl = "http://127.0.0.1:5099"
dotnet test samples/Northstar.ProtoTest --filter "FullyQualifiedName~PublishedAliceProbe"
Remove-Item Env:ProtoTest__TargetUrl

The request fails, because nothing answers at the address. The identity entity in the trace is still written, and in that run it reads:

  • auth.user: alice, auth.roles: admin, auth.claim_types: tenant;
  • auth.transport: inert, with the reason: the application is not hosted in-process, so register it with AddAspNetCoreServer and add the app-side authentication with webHost.AddTestUserAuthentication();
  • an auth.user.inert event with outcome skipped.

The literal tenant=northstar appears nowhere in that trace. The name, the role and the claim type do.

That is the limit of the shipped transport: a published application holds no test user unless the suite declares its own [Auth<T>] authenticator that reads context.SignedInUser(). The authentication reference shows that shape and the full precedence rules.

Checkpoint​

A test declares [SignedInAs("alice", "admin", Claims = ["tenant=northstar"])]. Which parts reach the trace, and which value does not?

Verify
Read the auth:user entity in l1-first-journey.prototrace, or run the published case below with a test that carries only [Application], [ProtoTest] and [SignedInAs]. The reason the entity records is in src/ProtoTest.Http/Authentication/SignedInAsAttribute.cs.

What you learned​

  • A bare [SignedInAs] signs in as the built-in test-user; a name, roles and claims describe the identity further.
  • The identity is per-test state; the trace shows the name, roles and claim types, never claim values.
  • The shipped transport needs an in-process application. Against a published one it is inert and records why.

Keep exploring​