Osy#the first language built for agents
Agents firstAgentic appsWorkflowsDurable Execution — built inSecurityTestingThe editorThe UI modelOne program

Reference / Testing

Workflow.Redeem (answer a callback URL, as nobody)

Workflow.Redeem(<url>) · Workflow.Redeem(<url>, <json>)

Answers a link minted by `<Slot>.CallbackUrl()` the way the third party holding it would — anonymously, with no principal of any kind. It is the only way to test the half of a callback URL that is the point of one: somebody with no account completing a slot. Refusals arrive as ordinary faults, so `Assert.Throws` reads them.

preview2 examples compiled by CItestingworkflowsecurity

Summary#

<Slot>.CallbackUrl() exists so that a person with no account can complete one slot — a supplier confirming a delivery, an invitee accepting. Every other driver verb in a .test.osy acts as somebody, so without this one an app could assert that a link was minted and could never assert that answering it works.

Workflow.Redeem(url) answers it. It runs genuinely anonymous — it does not pass the test's [runas] principal, or any principal — so what a passing test proves is that no principal was involved.

Signature#

Workflow.Redeem(<url>);                 // an event with no parameters
Workflow.Redeem(<url>, <json>);         // the event's arguments, as the JSON a third party would POST

<url> is the string CallbackUrl() returned. <json> is text, not a typed argument list — deliberately: the wire contract is "POST the event's parameters as a JSON object, by name", so the test drives the same bytes a stranger's curl would. A typed form would prove something weaker by skipping the bind that the contract is made of.

Description#

Refusals are faults, so Assert.Throws reads them#

A refusal is not a return value to inspect — it arrives the way every other refusal in this surface does:

what happenedthe fault
the token is unknown, or was already spentNotFoundException
the slot has closed — satisfied, cancelled, or its run finishedConflictException
the body does not fit the event's signatureValidationException
a Requires criterion does not holdRequirementsNotMet
a gate the callback still meets refused (a Pending slot)NotAuthorized

Unknown and SPENT are the same answer on purpose. Telling an unauthenticated caller which of the two it hit tells it that a token exists. A CLOSED slot is distinguishable because the holder needs to know their item was withdrawn rather than that their link was corrupted.

What it does around the call#

It settles staged writes first — the URL almost always came off a row the test just created, and the run has to exist before a token can address it — and after a successful deposit it drives the background pump and drops what the test's context had already loaded. The deposit happens on the engine's own context, so without that last step the next line reads pre-deposit values and a callback that really worked reads as one that silently did nothing.

Examples#

A supplier who is not a user of the app, and cannot become one — Candidates admits only staff, so the link is the only way this run can reach Confirmed:

enum OrderState { Awaiting, Confirmed, Refused }

[Principal]
entity Person {
  [Required, MaxLength(100)] string Name;
  bool IsStaff;
  security { allow read, create when IsAuthenticated || IsAnonymous; }
}

entity Order {
  [Required, MaxLength(100)] string Reference;
  OrderState State;      // no default: the workflow autostarts, so `Initial = Awaiting` IS this field's value
  [MaxLength(400)] string? ConfirmLink;
  security { allow read, create, update when IsAuthenticated || IsAnonymous; }
}

workflow OrderFlow {
  Tracks = Order.State; Autostart = true; Initial = Awaiting;

  event Confirm(bool ok);

  state Awaiting {
    subscribe Confirm(bool ok) as SupplierOk { Candidates = u => u.IsStaff; }
    enter { this.Item.ConfirmLink = SupplierOk.CallbackUrl(); }
    on SupplierOk(bool ok) {
      when (ok) { goto Confirmed; }
      default   { goto Refused; }
    }
  }

  terminal success Confirmed { }
  terminal cancel  Refused { }
}
[TestFixture]
void Seed() {
  new Person { Name = "Sam", IsStaff = true };
}

principal Sam => Person.Single(p => p.Name == "Sam");

// THE FEATURE: a caller with no account completes the slot, while the same deposit made as a signed-in
// NON-candidate is refused. Both halves in one test — a gate that admitted everyone would pass either alone.
[Test(Seed)]
[runas(Sam)]
void a_holder_of_the_link_confirms_with_no_account_at_all() {
  var o = new Order { Reference = "PO-1" };
  Workflow.Settle(o);
  Assert.NotNull(o.ConfirmLink);

  Workflow.Redeem(o.ConfirmLink, "{\"ok\": true}");

  Assert.Equal(OrderState.Confirmed, o.State);
}

// SINGLE-USE — a forwarded link cannot be answered by a second party, which is a different problem from a
// double click and the one that actually bites.
[Test(Seed)]
[runas(Sam)]
void a_forwarded_link_is_dead_once_it_has_been_used() {
  var o = new Order { Reference = "PO-2" };
  Workflow.Settle(o);
  var link = o.ConfirmLink;

  Workflow.Redeem(link, "{\"ok\": true}");
  Assert.Equal(OrderState.Confirmed, o.State);

  Assert.Throws<NotFoundException>(() => Workflow.Redeem(link, "{\"ok\": true}"));
}

// THE PAYLOAD IS CHECKED, NOT TRUSTED — it is bound against the event's own signature, so a property the event
// never declared is refused rather than ignored.
[Test(Seed)]
[runas(Sam)]
void a_body_the_event_does_not_declare_is_refused() {
  var o = new Order { Reference = "PO-3" };
  Workflow.Settle(o);

  Assert.Throws<ValidationException>(() => Workflow.Redeem(o.ConfirmLink, "{\"approved\": true}"));
  Assert.Equal(OrderState.Awaiting, o.State);
}

Note the [runas(Sam)] and the fact that it changes nothing about the redemption. The attribute governs the rest of the body — creating the order is Sam's act. Answering the link is nobody's.

And the property most worth pinning, because a reader will not guess it — an event's [Authorize] is not evaluated either, since a predicate takes a principal and a callback has none:

// The event declares [Authorize(u => u.Email == this.Item.Email)] — and a signed-in stranger IS refused by it…
runas (Mallory) { Assert.Throws<NotAuthorized>(() => Onboarding.RaiseAccept(inv)); }
// …while the LINK, held by nobody, is accepted.
Workflow.Redeem(inv.AcceptLink);

See also#

Related

Callback URLs — letting an outsider complete one slot

Mint a single-use link that completes exactly one waiting slot, for a third party who has no account and cannot sign…

[Test] / [TestFixture]

A test is an ordinary function marked [Test]. It runs against a throwaway clone of the app, so it may create rows and…

Assert

The assertions a test makes. Beyond the usual equality and null checks there are comparisons (Greater, Less, InRange)…

runas

Runs a block as a given principal, so security rules apply exactly as they would for that user. It is how you test that…

signup by invitation (invite, accept link, chase, expire)

How an app lets somebody INVITE a person who has no account yet. The invitation is a workflow: it mints a tokenised…