feat(oidc): a Serverpod module for signing in with OpenID Connect, SimpleLogin a preset (ADR 0023)

halkyon_oidc_server and its generated client halkyon_oidc_client: any
provider following OpenID Connect's code flow, beside Serverpod's own
providers, with a preset for SimpleLogin (no PKCE, no `typ`). Nothing
of Halkyon's is in it; BSD-3-Clause, `publish_to: none` for now.

- Attempts in `halkyon_oidc_attempt` (ADR 0020's D13): the ID
  PostgreSQL's gen_random_uuid(), a state and a nonce of 32 random
  bytes, PKCE's verifier where used, the caller if signed in; ten
  minutes, taken once. A host may begin the state with a marker.
- The code exchanged by the module itself, so a provider out of reach
  (a timeout, a 5xx) is told apart from a refused code; the ID token
  checked with dart_jsonwebtoken's algorithms (ADR 0020's D12): the
  provider's algorithms alone, the key its `kid` names, of the right
  type; issuer, audience and `azp`, expiry, time issued, `nbf`, a
  minute's leeway; the attempt's nonce; a `sub`.
- Keys kept an hour, fetched again for an unknown `kid` at most once
  a minute; discovery checked against the issuer; every request with
  a timeout.
- Identities in `halkyon_oidc_identity`: the provider's key and the
  `sub`, one per person and provider, deleted with the person.
- The stock endpoint (`OidcIdpBaseEndpoint`), and the parts for a host
  with rules of its own: begin, verify, keep and takeKept, link,
  accountOf, unlink; merging people; a limit per network.
- The forwarding page, moved here from halkyon_base in the next commit.

The package's main library is the code generator's, as halkyon_base's;
a host imports `package:halkyon_oidc_server/oidc.dart`. ADR 0023's
point 5 no longer names OAuth2PkceUtil, which the module doesn't use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-23 20:28:37 +02:00
commit f549fa59df
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 15870 additions and 2 deletions

View file

@ -0,0 +1,698 @@
import 'dart:async';
import 'dart:io';
import 'package:clock/clock.dart';
import 'package:halkyon_oidc_server/oidc.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:test/test.dart';
import '../support/fake_provider.dart';
import '../support/test_provider.dart';
import 'test_tools/serverpod_test_tools.dart';
// Signing in with an OpenID Connect provider (ADR 0023): an attempt begun,
// the person sent to the provider, the code it sends back ending the attempt
// once, with the ID token it gives checked (test/unit/id_token_test.dart has
// every way a token is refused), the identity linked to a person or a
// session issued. Against a fake provider answering in the test.
void main() {
late TestKey key;
setUpAll(() => key = TestKey.generate('k', seed: 7));
withServerpod('OpenID Connect', (sessionBuilder, endpoints) {
late Session session;
late FakeProvider fake;
late OidcIdp idp;
void useProvider(FakeProvider provider, {RateLimit? beginsPerNetwork}) {
fake = provider;
AuthServices.set(
tokenManagerBuilders: [ServerSideSessionsConfigFromPasswords()],
identityProviderBuilders: [
OidcIdpConfig(
providers: [provider.provider],
httpClient: provider.client,
beginsPerNetwork: beginsPerNetwork ?? OidcIdpConfig.defaultBegins,
),
],
);
idp = AuthServices.instance.oidcIdp;
}
setUp(() {
session = sessionBuilder.build();
useProvider(FakeProvider(key: key));
});
Future<UuidValue> newPerson() async =>
(await AuthServices.instance.authUsers.create(session)).id;
Matcher refused(OidcRefusal reason) => throwsA(
isA<OidcRefusedException>().having((e) => e.reason, 'reason', reason),
);
/// A sign-in begun and allowed by [subject] at the provider: the attempt
/// and the code.
Future<({UuidValue attempt, String code})> allowed(
String subject, {
UuidValue? caller,
Map<String, Object?> claims = const {},
}) async {
final start = await idp.begin(session, provider: 'fake', caller: caller);
return (
attempt: start.attempt,
code: fake.authorize(
start.authorizationUrl,
subject: subject,
claims: claims,
),
);
}
group('beginning', () {
test(
'keeps an attempt for ten minutes and sends the person to the provider',
() async {
final now = DateTime.utc(2026, 10, 23, 18);
final start = await withClock(
Clock.fixed(now),
() => idp.begin(
session,
provider: 'fake',
statePrefix: 'learn-desktop.',
),
);
final attempt = (await OidcAttempt.db.findById(
session,
start.attempt,
))!;
final query = start.authorizationUrl.queryParameters;
expect(start.authorizationUrl.path, '/authorize');
expect(query['client_id'], 'client-1');
expect(query['redirect_uri'], '${fake.callbackUrl}');
expect(start.state, startsWith('learn-desktop.'));
expect(query['state'], start.state);
expect(attempt.state, start.state);
expect(query['nonce'], attempt.nonce);
expect(query['code_challenge'], pkceChallenge(attempt.codeVerifier!));
expect(attempt.expiresAt, now.add(const Duration(minutes: 10)));
expect(attempt.callerId, isNull);
expect(
'${start.authorizationUrl}',
isNot(contains(start.attempt.uuid)),
);
},
);
test('without PKCE where the provider has none', () async {
useProvider(FakeProvider(key: key, pkce: false));
final start = await idp.begin(session, provider: 'fake');
final attempt = (await OidcAttempt.db.findById(
session,
start.attempt,
))!;
expect(attempt.codeVerifier, isNull);
expect(
start.authorizationUrl.queryParameters,
isNot(contains('code_challenge')),
);
});
test('a provider the host does not name is refused', () async {
await expectLater(
idp.begin(session, provider: 'other'),
refused(OidcRefusal.unknownProvider),
);
});
test('too often from one network is refused', () async {
useProvider(
FakeProvider(key: key),
beginsPerNetwork: const RateLimit(
maxAttempts: 2,
timeframe: Duration(hours: 1),
),
);
final network = randomText();
await idp.begin(session, provider: 'fake', network: network);
await idp.begin(session, provider: 'fake', network: network);
await expectLater(
idp.begin(session, provider: 'fake', network: network),
refused(OidcRefusal.tooManyAttempts),
);
await idp.begin(session, provider: 'fake', network: randomText());
});
test('forgets the attempts whose time ran out', () async {
final old = await idp.begin(session, provider: 'fake');
await withClock(
Clock.fixed(DateTime.now().add(const Duration(minutes: 11))),
() => idp.begin(session, provider: 'fake'),
);
expect(await OidcAttempt.db.findById(session, old.attempt), isNull);
});
});
group('verifying', () {
test(
"proves the provider's ID of the person and what its token says",
() async {
final (:attempt, :code) = await allowed(
'42',
claims: {'name': 'Ada'},
);
final verified = await idp.verify(
session,
attempt: attempt,
code: code,
);
expect(verified.provider, 'fake');
expect(verified.subject, '42');
expect(verified.claims['name'], 'Ada');
expect(verified.caller, isNull);
},
);
test(
'asks the token endpoint as the client, with the verifier',
() async {
final (:attempt, :code) = await allowed('42');
final verifier = (await OidcAttempt.db.findById(
session,
attempt,
))!.codeVerifier;
await idp.verify(session, attempt: attempt, code: code);
final form = fake.tokenRequests.single.bodyFields;
expect(form['grant_type'], 'authorization_code');
expect(form['code'], code);
expect(form['redirect_uri'], '${fake.callbackUrl}');
expect(form['code_verifier'], verifier);
expect(form, isNot(contains('client_secret')));
},
);
test('ends the attempt: a second time, it is unknown', () async {
final (:attempt, :code) = await allowed('42');
await idp.verify(session, attempt: attempt, code: code);
expect(await OidcAttempt.db.findById(session, attempt), isNull);
await expectLater(
idp.verify(session, attempt: attempt, code: code),
refused(OidcRefusal.unknownAttempt),
);
});
test('an attempt nobody began is unknown', () async {
await expectLater(
idp.verify(session, attempt: const Uuid().v4obj(), code: 'c'),
refused(OidcRefusal.unknownAttempt),
);
});
test('an attempt older than ten minutes is unknown', () async {
final (:attempt, :code) = await allowed('42');
await expectLater(
withClock(
Clock.fixed(DateTime.now().add(const Duration(minutes: 11))),
() => idp.verify(session, attempt: attempt, code: code),
),
refused(OidcRefusal.unknownAttempt),
);
});
test(
'by someone other than who began it is refused, and ends it',
() async {
final anna = await newPerson();
final ben = await newPerson();
final (:attempt, :code) = await allowed('42', caller: anna);
await expectLater(
idp.verify(session, attempt: attempt, code: code, caller: ben),
refused(OidcRefusal.wrongCaller),
);
await expectLater(
idp.verify(session, attempt: attempt, code: code, caller: anna),
refused(OidcRefusal.unknownAttempt),
);
},
);
test('signed out, of an attempt begun signed in, is refused', () async {
final anna = await newPerson();
final (:attempt, :code) = await allowed('42', caller: anna);
await expectLater(
idp.verify(session, attempt: attempt, code: code),
refused(OidcRefusal.wrongCaller),
);
});
test('signed in, of an attempt begun signed out, is refused', () async {
final (:attempt, :code) = await allowed('42');
await expectLater(
idp.verify(
session,
attempt: attempt,
code: code,
caller: await newPerson(),
),
refused(OidcRefusal.wrongCaller),
);
});
test('with the caller who began it, names them', () async {
final anna = await newPerson();
final (:attempt, :code) = await allowed('42', caller: anna);
final verified = await idp.verify(
session,
attempt: attempt,
code: code,
caller: anna,
);
expect(verified.caller, anna);
});
test('a code caught on its way to another device ends nothing', () async {
// The catcher's attempt is their own: with PKCE its verifier isn't
// the one the code was asked with, and without (SimpleLogin) the
// token carries the other attempt's nonce.
for (final pkce in [true, false]) {
useProvider(FakeProvider(key: key, pkce: pkce));
final victims = await idp.begin(session, provider: 'fake');
final catchers = await idp.begin(session, provider: 'fake');
final caught = fake.authorize(
victims.authorizationUrl,
subject: '42',
);
await expectLater(
idp.verify(session, attempt: catchers.attempt, code: caught),
refused(OidcRefusal.rejected),
reason: 'PKCE: $pkce',
);
}
});
test('a code the provider refuses is rejected', () async {
final (:attempt, code: _) = await allowed('42');
await expectLater(
idp.verify(session, attempt: attempt, code: 'made-up'),
refused(OidcRefusal.rejected),
);
});
test('a token answer without an ID token is rejected', () async {
final (:attempt, :code) = await allowed('42');
fake.tokenAnswer = (status: 200, body: '{"access_token": "a"}');
await expectLater(
idp.verify(session, attempt: attempt, code: code),
refused(OidcRefusal.rejected),
);
});
test('a provider failing or out of reach is unavailable', () async {
for (final failure in [
() => fake.tokenAnswer = (status: 503, body: ''),
() => fake.tokenAnswer = (status: 200, body: 'not json'),
() => fake.tokenError = const SocketException('down'),
]) {
fake
..tokenAnswer = null
..tokenError = null;
failure();
final (:attempt, :code) = await allowed('42');
await expectLater(
idp.verify(session, attempt: attempt, code: code),
refused(OidcRefusal.providerUnavailable),
);
}
});
test('a provider answering too slowly is unavailable', () async {
AuthServices.set(
tokenManagerBuilders: [ServerSideSessionsConfigFromPasswords()],
identityProviderBuilders: [
OidcIdpConfig(
providers: [fake.provider],
httpClient: fake.client,
timeout: const Duration(milliseconds: 50),
),
],
);
idp = AuthServices.instance.oidcIdp;
fake.tokenDelay = const Duration(seconds: 2);
final (:attempt, :code) = await allowed('42');
await expectLater(
idp.verify(session, attempt: attempt, code: code),
refused(OidcRefusal.providerUnavailable),
);
});
test('reads the endpoints from the discovery document, once', () async {
useProvider(FakeProvider(key: key, discovered: true));
for (final subject in ['1', '2']) {
final (:attempt, :code) = await allowed(subject);
expect(
(await idp.verify(session, attempt: attempt, code: code)).subject,
subject,
);
}
expect(fake.discoveries, 1);
expect(fake.keyFetches, 1);
});
test(
'a discovery document of another issuer makes the provider unavailable',
() async {
useProvider(FakeProvider(key: key, discovered: true));
fake.discoveryIssuer = 'https://evil.example';
await expectLater(
idp.begin(session, provider: 'fake'),
refused(OidcRefusal.providerUnavailable),
);
},
);
});
group('keeping a verified attempt', () {
test('keeps the identity for the host to take once', () async {
final (:attempt, :code) = await allowed('42');
await idp.keep(
session,
attempt: attempt,
identity: await idp.verify(
session,
attempt: attempt,
code: code,
),
keepFor: const Duration(minutes: 10),
);
final kept = await idp.takeKept(session, attempt: attempt);
expect((kept.provider, kept.subject), ('fake', '42'));
await expectLater(
idp.takeKept(session, attempt: attempt),
refused(OidcRefusal.unknownAttempt),
);
});
test('can be verified no more', () async {
final (:attempt, :code) = await allowed('42');
await idp.keep(
session,
attempt: attempt,
identity: await idp.verify(
session,
attempt: attempt,
code: code,
),
keepFor: const Duration(minutes: 10),
);
await expectLater(
idp.verify(session, attempt: attempt, code: code),
refused(OidcRefusal.unknownAttempt),
);
});
test('keeps it no longer than said', () async {
final (:attempt, :code) = await allowed('42');
await idp.keep(
session,
attempt: attempt,
identity: await idp.verify(
session,
attempt: attempt,
code: code,
),
keepFor: const Duration(minutes: 10),
);
await expectLater(
withClock(
Clock.fixed(DateTime.now().add(const Duration(minutes: 11))),
() => idp.takeKept(session, attempt: attempt),
),
refused(OidcRefusal.unknownAttempt),
);
});
test('is taken by who began it alone', () async {
final anna = await newPerson();
final (:attempt, :code) = await allowed('42', caller: anna);
await idp.keep(
session,
attempt: attempt,
identity: await idp.verify(
session,
attempt: attempt,
code: code,
caller: anna,
),
keepFor: const Duration(minutes: 10),
);
await expectLater(
idp.takeKept(session, attempt: attempt),
refused(OidcRefusal.wrongCaller),
);
});
test('an attempt not verified yet is not taken', () async {
final (:attempt, code: _) = await allowed('42');
await expectLater(
idp.takeKept(session, attempt: attempt),
refused(OidcRefusal.unknownAttempt),
);
});
});
group('identities', () {
test('linked, name their person', () async {
final anna = await newPerson();
await idp.link(
session,
authUserId: anna,
provider: 'fake',
subject: '42',
);
expect(
await idp.accountOf(session, provider: 'fake', subject: '42'),
anna,
);
expect(
await idp.accountOf(session, provider: 'fake', subject: '43'),
isNull,
);
expect(
await idp.accountOf(session, provider: 'other', subject: '42'),
isNull,
);
expect(await idp.hasIdentity(session, authUserId: anna), isTrue);
});
test('linked again to the same person change nothing', () async {
final anna = await newPerson();
await idp.link(
session,
authUserId: anna,
provider: 'fake',
subject: '42',
);
await idp.link(
session,
authUserId: anna,
provider: 'fake',
subject: '42',
);
expect(await OidcIdentity.db.count(session), 1);
});
test("another person's are refused", () async {
final anna = await newPerson();
await idp.link(
session,
authUserId: anna,
provider: 'fake',
subject: '42',
);
await expectLater(
idp.link(
session,
authUserId: await newPerson(),
provider: 'fake',
subject: '42',
),
refused(OidcRefusal.takenByAnotherPerson),
);
});
test('a second at the same provider is refused', () async {
final anna = await newPerson();
await idp.link(
session,
authUserId: anna,
provider: 'fake',
subject: '42',
);
await expectLater(
idp.link(session, authUserId: anna, provider: 'fake', subject: '43'),
refused(OidcRefusal.otherIdentityLinked),
);
});
test('go when unlinked', () async {
final anna = await newPerson();
await idp.link(
session,
authUserId: anna,
provider: 'fake',
subject: '42',
);
await idp.unlink(session, authUserId: anna, provider: 'fake');
expect(
await idp.accountOf(session, provider: 'fake', subject: '42'),
isNull,
);
expect(await idp.hasIdentity(session, authUserId: anna), isFalse);
});
test('go with their person', () async {
final anna = await newPerson();
await idp.link(
session,
authUserId: anna,
provider: 'fake',
subject: '42',
);
await AuthServices.instance.authUsers.delete(session, authUserId: anna);
expect(await OidcIdentity.db.count(session), 0);
});
test('move to the person kept when two are merged', () async {
final kept = await newPerson();
final removed = await newPerson();
await idp.link(
session,
authUserId: removed,
provider: 'fake',
subject: '42',
);
await session.db.transaction(
(transaction) => idp.mergeAuthUsers(
session,
userToKeepId: kept,
userToRemoveId: removed,
transaction: transaction,
),
);
expect(
await idp.accountOf(session, provider: 'fake', subject: '42'),
kept,
);
});
test('at a provider both merged people have stop the merge', () async {
final kept = await newPerson();
final removed = await newPerson();
await idp.link(
session,
authUserId: kept,
provider: 'fake',
subject: '41',
);
await idp.link(
session,
authUserId: removed,
provider: 'fake',
subject: '42',
);
await expectLater(
session.db.transaction(
(transaction) => idp.mergeAuthUsers(
session,
userToKeepId: kept,
userToRemoveId: removed,
transaction: transaction,
),
),
throwsA(isA<StateError>()),
);
expect(
await idp.accountOf(session, provider: 'fake', subject: '42'),
removed,
);
});
});
group('the stock sign-in', () {
test('makes a new person for an ID nobody has, with a session', () async {
final (:attempt, :code) = await allowed('42');
final signedIn = await idp.login(session, attempt: attempt, code: code);
expect(
await idp.accountOf(session, provider: 'fake', subject: '42'),
signedIn.authUserId,
);
expect(signedIn.token, isNotEmpty);
});
test('signs in the person who has the ID', () async {
final anna = await newPerson();
await idp.link(
session,
authUserId: anna,
provider: 'fake',
subject: '42',
);
final (:attempt, :code) = await allowed('42');
final signedIn = await idp.login(session, attempt: attempt, code: code);
expect(signedIn.authUserId, anna);
expect(await AuthUser.db.count(session), 1);
});
test('refuses an attempt begun signed in', () async {
final anna = await newPerson();
final (:attempt, :code) = await allowed('42', caller: anna);
await expectLater(
idp.login(session, attempt: attempt, code: code),
refused(OidcRefusal.wrongCaller),
);
});
test('links the ID to who began it signed in', () async {
final anna = await newPerson();
final (:attempt, :code) = await allowed('42', caller: anna);
await idp.linkToCaller(
session,
attempt: attempt,
code: code,
caller: anna,
);
expect(
await idp.accountOf(session, provider: 'fake', subject: '42'),
anna,
);
});
});
test('forgets attempts whose time ran out', () async {
await idp.begin(session, provider: 'fake');
final (:attempt, :code) = await allowed('42');
await idp.keep(
session,
attempt: attempt,
identity: await idp.verify(
session,
attempt: attempt,
code: code,
),
keepFor: const Duration(minutes: 30),
);
final later = DateTime.now().add(const Duration(minutes: 11));
expect(
await withClock(
Clock.fixed(later),
() => idp.forgetExpiredAttempts(session),
),
1,
);
expect(await OidcAttempt.db.count(session), 1);
});
});
}

View file

@ -0,0 +1,158 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_local_identifiers
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:io' as _idi;
import 'package:serverpod/serverpod.dart' as _is;
import 'package:serverpod_test/serverpod_test.dart' as _ist;
import 'package:halkyon_oidc_server/src/generated/protocol.dart';
import 'package:halkyon_oidc_server/src/generated/endpoints.dart';
export 'package:serverpod_test/serverpod_test_public_exports.dart';
/// Creates a new test group that takes a callback that can be used to write tests.
/// The callback has two parameters: `sessionBuilder` and `endpoints`.
/// `sessionBuilder` is used to build a `Session` object that represents the server state during an endpoint call and is used to set up scenarios.
/// `endpoints` contains all your Serverpod endpoints and lets you call them:
/// ```dart
/// withServerpod('Given Example endpoint', (sessionBuilder, endpoints) {
/// test('when calling `hello` then should return greeting', () async {
/// final greeting = await endpoints.example.hello(sessionBuilder, 'Michael');
/// expect(greeting, 'Hello Michael');
/// });
/// });
/// ```
///
/// **Configuration options**
///
/// [applyMigrations] Whether pending migrations should be applied when starting Serverpod. Defaults to `true`
///
/// [enableSessionLogging] Whether session logging should be enabled. Defaults to `false`
///
/// [ephemeralDatabase] Whether this group gets its own empty database, created when the group starts and dropped when it finishes. Defaults to `true`.
/// Set this to `false` to use the database configured for [runMode], including a previously seeded database. [configOverride] can still replace that database. The configured database is not created or dropped, so groups that share it cannot run in parallel. [rollbackDatabase] still controls whether writes inside the group are rolled back.
///
/// [rollbackDatabase] Options for when to rollback the database during the test lifecycle.
/// By default `withServerpod` does all database operations inside a transaction that is rolled back after each `test` case.
/// Just like the following enum describes, the behavior of the automatic rollbacks can be configured:
/// ```dart
/// /// Options for when to rollback the database during the test lifecycle.
/// enum RollbackDatabase {
/// /// After each test. This is the default.
/// afterEach,
///
/// /// After all tests.
/// afterAll,
///
/// /// Disable rolling back the database.
/// disabled,
/// }
/// ```
///
/// [runMode] The run mode that Serverpod should be running in. Defaults to `test`.
///
/// [serverpodLoggingMode] The logging mode used when creating Serverpod. Defaults to `ServerpodLoggingMode.normal`
///
/// [serverpodStartTimeout] The timeout to use when starting Serverpod, which connects to the database among other things. Defaults to `Duration(seconds: 120)`.
///
/// [testServerOutputMode] Options for controlling test server output during test execution. Defaults to `TestServerOutputMode.normal`.
/// ```dart
/// /// Options for controlling test server output during test execution.
/// enum TestServerOutputMode {
/// /// Default mode - only stderr is printed (stdout suppressed).
/// /// This hides normal startup/shutdown logs while preserving error messages.
/// normal,
///
/// /// All logging - both stdout and stderr are printed.
/// /// Useful for debugging when you need to see all server output.
/// verbose,
///
/// /// No logging - both stdout and stderr are suppressed.
/// /// Completely silent mode, useful when you don't want any server output.
/// silent,
/// }
/// ```
///
/// [configOverride] A function to override the server configuration. This function is called with
/// the default server configuration after it is loaded from the config/ directory
/// and before it is used to start the server. Use this to override particular
/// settings in the server configuration.
///
/// [databaseInterceptor] Optional interceptor that replaces the default database for each session.
/// See [Serverpod.databaseInterceptor] for more information.
///
/// [testGroupTagsOverride] By default Serverpod test tools tags the `withServerpod` test group with `"integration"`.
/// This is to provide a simple way to only run unit or integration tests.
/// This property allows this tag to be overridden to something else. Defaults to `['integration']`.
///
/// [experimentalFeatures] Optionally specify experimental features. See [Serverpod] for more information.
///
/// [serverDirectory] The server package directory `config/<runMode>.yaml`, `config/passwords.yaml`,
/// and `migrations/<module>/...` are resolved against. Defaults to
/// [Directory.current] at the time the test boots. Pass this when the test
/// isolate's cwd is not the server package root (e.g. running tests from a
/// workspace parent directory) so config and migrations are still loaded
/// from the right place.
@_ist.isTestGroup
void withServerpod(
String testGroupName,
_ist.TestClosure<TestEndpoints> testClosure, {
bool? applyMigrations,
_is.ServerpodConfig Function(_is.ServerpodConfig)? configOverride,
_is.DatabaseInterceptor? databaseInterceptor,
bool? enableSessionLogging,
bool? ephemeralDatabase,
_is.ExperimentalFeatures? experimentalFeatures,
_ist.RollbackDatabase? rollbackDatabase,
String? runMode,
_is.RuntimeParametersListBuilder? runtimeParametersBuilder,
_idi.Directory? serverDirectory,
_is.ServerpodLoggingMode? serverpodLoggingMode,
Duration? serverpodStartTimeout,
List<String>? testGroupTagsOverride,
_ist.TestServerOutputMode? testServerOutputMode,
}) {
_ist.buildWithServerpod<_InternalTestEndpoints>(
testGroupName,
_ist.TestServerpod(
testEndpoints: _InternalTestEndpoints(),
endpoints: Endpoints(),
serializationManager: Protocol(),
runMode: runMode,
applyMigrations: applyMigrations,
ephemeralDatabase: ephemeralDatabase,
isDatabaseEnabled: true,
serverpodLoggingMode: serverpodLoggingMode,
testServerOutputMode: testServerOutputMode,
serverDirectory: serverDirectory,
experimentalFeatures: experimentalFeatures,
configOverride: configOverride,
runtimeParametersBuilder: runtimeParametersBuilder,
databaseInterceptor: databaseInterceptor,
),
maybeRollbackDatabase: rollbackDatabase,
maybeEnableSessionLogging: enableSessionLogging,
maybeTestGroupTagsOverride: testGroupTagsOverride,
maybeServerpodStartTimeout: serverpodStartTimeout,
maybeTestServerOutputMode: testServerOutputMode,
)(testClosure);
}
class TestEndpoints {}
class _InternalTestEndpoints extends TestEndpoints
implements _ist.InternalTestEndpoints {
@override
void initialize(
_is.SerializationManager serializationManager,
_is.EndpointDispatch endpoints,
) {}
}