feat(base): handles hidden until their people go public, every answer about a new handle counted (ADR 0020, D5)
A handle's page and profiles.byHandle answer as for a handle nobody has until its person goes public: a handle fixed by a publish, or a display name, a bio, a link or an avatar (isPublic). A person's own profile, and the host's own lookups (personWithHandle), are unchanged. So every answer about a new handle now tells that an account has it, and countHandleCheck counts them together on the register, 30 an hour per person (or attempt) and 60 per network, refused beyond with ProfileRefusal.tooManyHandleChecks: profile.checkHandle and saving a profile with a handle new for the person, simpleLogin.checkHandle and signUp, keys.set with a handle, and Halkyon Learn's inbox.block and unblock, which look people up by handle. A person's own handle and what is no handle aren't counted. The SimpleLogin limiters of commit 3 give way to it, so a network's 60 are shared between people signed in and people new. The register's checkHandle, saveProfile, signUpWithSimpleLogin and setFirstKeys carry the visitor's network from the member that asked. Halkyon Learn's handle dialog words the new refusal (English, German). Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
e5b9578916
commit
7eb1961b9f
23 changed files with 674 additions and 134 deletions
|
|
@ -287,8 +287,8 @@ class EndpointKeys extends _isc.EndpointRef {
|
|||
/// ten minutes on this device, under [attempt]. Refused for an account
|
||||
/// with keys (`PasswordRefusal.passwordExists`), a setup out of range
|
||||
/// (`PasswordRefusal.invalidSetup`), a handle refused or missing
|
||||
/// (`ProfileRefusedException`), and without that sign-in
|
||||
/// (`OidcRefusal.unknownAttempt`).
|
||||
/// (`ProfileRefusedException`; a handle counted as `profile.checkHandle`
|
||||
/// counts it), and without that sign-in (`OidcRefusal.unknownAttempt`).
|
||||
_ida.Future<void> set({
|
||||
required _isc.UuidValue attempt,
|
||||
required _iec2fqr0.KeysSetup setup,
|
||||
|
|
@ -790,7 +790,8 @@ class EndpointProfile extends _isc.EndpointRef {
|
|||
/// Saves your public profile, making it if you have none. Throws
|
||||
/// [ProfileRefusedException] when it can't be saved (see
|
||||
/// [ProfileRefusal]): a handle someone else has, whatever its case, or a
|
||||
/// new handle once yours is fixed.
|
||||
/// new handle once yours is fixed. A new handle counts as [checkHandle]
|
||||
/// counts it.
|
||||
_ida.Future<_i8qdmv6n.Profile> save(
|
||||
String handle,
|
||||
String displayName,
|
||||
|
|
@ -823,9 +824,13 @@ class EndpointProfile extends _isc.EndpointRef {
|
|||
/// Why [handle] couldn't be yours, as saving it would refuse it: not a
|
||||
/// handle, reserved for the instance, someone else's whatever its case,
|
||||
/// or a new one once yours is fixed; null when it can, as your own can. For
|
||||
/// the app to say so while it is typed; saving checks again. Whose a handle
|
||||
/// is, anyone can find out already ([ProfilesEndpoint.byHandle]), so this
|
||||
/// tells nothing new.
|
||||
/// the app to say so while it is typed; saving checks again. Handles stay
|
||||
/// hidden until their people go public (Halkyon Learn's ADR 0020, D5), so
|
||||
/// this tells that an account exists: at most 30 an hour for you and 60
|
||||
/// from your network, with every other answer about a new handle (saving
|
||||
/// one, signing up with one), refused beyond
|
||||
/// ([ProfileRefusal.tooManyHandleChecks], thrown); your own handle and
|
||||
/// what is no handle aren't counted.
|
||||
_ida.Future<_ih722tf9.ProfileRefusal?> checkHandle(String handle) =>
|
||||
caller.callServerEndpoint<_ih722tf9.ProfileRefusal?>(
|
||||
'halkyon_base.profile',
|
||||
|
|
@ -873,7 +878,10 @@ class EndpointProfiles extends _isc.EndpointRef {
|
|||
String get name => 'halkyon_base.profiles';
|
||||
|
||||
/// The public profile with [handle], whatever its case. Throws
|
||||
/// [ProfileNotFoundException] when there is none.
|
||||
/// [ProfileNotFoundException] when there is none, and as well while its
|
||||
/// person hasn't gone public (Halkyon Learn's ADR 0020, D5): published
|
||||
/// nothing and left the rest of the profile empty (display name, bio,
|
||||
/// links, avatar).
|
||||
_ida.Future<_i8qdmv6n.Profile> byHandle(String handle) =>
|
||||
caller.callServerEndpoint<_i8qdmv6n.Profile>(
|
||||
'halkyon_base.profiles',
|
||||
|
|
@ -931,9 +939,10 @@ class EndpointSimpleLogin extends _isc.EndpointRef {
|
|||
/// said `newcomer`), could take [handle]: null where they could, else why
|
||||
/// not, as saving a profile would say. For the app to say so while it is
|
||||
/// typed; [signUp] checks again. At most 30 for an attempt and 60 from a
|
||||
/// network in an hour (`ProfileRefusal.tooManyHandleChecks`, thrown):
|
||||
/// each answer tells which handles exist. Refused
|
||||
/// (`OidcRefusal.unknownAttempt`) where no identity waits.
|
||||
/// network in an hour, with every other answer about a handle
|
||||
/// (`ProfileRefusal.tooManyHandleChecks`, thrown): each tells which
|
||||
/// handles exist. Refused (`OidcRefusal.unknownAttempt`) where no identity
|
||||
/// waits.
|
||||
_ida.Future<_ih722tf9.ProfileRefusal?> checkHandle({
|
||||
required _isc.UuidValue attempt,
|
||||
required String handle,
|
||||
|
|
@ -952,9 +961,10 @@ class EndpointSimpleLogin extends _isc.EndpointRef {
|
|||
/// keys [setup] describes, all made on the device beforehand; a session
|
||||
/// for it here. Refused, the identity still waiting: for a setup out of
|
||||
/// range (`PasswordRefusal.invalidSetup`) and a handle refused
|
||||
/// (`ProfileRefusedException`, as saving a profile refuses it); for an
|
||||
/// attempt unknown, taken or past its time (`OidcRefusal.unknownAttempt`),
|
||||
/// and called signed in (`OidcRefusal.wrongCaller`).
|
||||
/// (`ProfileRefusedException`, as saving a profile refuses it, the handle
|
||||
/// counted as [checkHandle] counts it); for an attempt unknown, taken or
|
||||
/// past its time (`OidcRefusal.unknownAttempt`), and called signed in
|
||||
/// (`OidcRefusal.wrongCaller`).
|
||||
_ida.Future<_iacc.AuthSuccess> signUp({
|
||||
required _isc.UuidValue attempt,
|
||||
required String handle,
|
||||
|
|
|
|||
|
|
@ -45,8 +45,9 @@ enum ProfileRefusal implements _isc.SerializableModel {
|
|||
/// The links were checked too often in the last hour.
|
||||
tooManyLinkChecks,
|
||||
|
||||
/// The handles were checked too often in the last hour: their answers
|
||||
/// tell which handles exist.
|
||||
/// Too many answers about new handles in the last hour (checking them,
|
||||
/// saving, signing up or blocking with them): each tells which handles
|
||||
/// exist.
|
||||
tooManyHandleChecks;
|
||||
|
||||
static ProfileRefusal fromJson(String name) {
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
|
|||
| `emailCode` | `sendCode`: a code by mail, which proves the address and signs nobody in | anyone |
|
||||
| `account` | `delete` (with the password's proof where the account has one, `keysProof` for one that signs in with SimpleLogin), `signInWays` | signed in |
|
||||
| `instance` | `basics`: the app it serves, GitHub's client ID if any and when GitHub sign-in ends, whether codes by mail, password sign-in and SimpleLogin are on, the source link, the oldest app build, where data and backups are | anyone |
|
||||
| `profile` | `mine`, `save`, `checkHandle`, `checkLinks` (at most 10 an hour), `setAvatar`, `removeAvatar`, `remove`: the caller's own public profile | `identified` |
|
||||
| `profiles` | `byHandle`: anyone's public profile, whatever the handle's case | signed in |
|
||||
| `profile` | `mine`, `save`, `checkHandle` (with every answer about a new handle, at most 30 an hour per person and 60 per network), `checkLinks` (at most 10 an hour), `setAvatar`, `removeAvatar`, `remove`: the caller's own public profile | `identified` |
|
||||
| `profiles` | `byHandle`: anyone's public profile once they went public, whatever the handle's case | signed in |
|
||||
|
||||
- GitHub keeps only the numeric GitHub ID, in Serverpod's own table; email keeps only
|
||||
HMAC-SHA256 of the address, keyed with `emailSignInPepper` (`email_identity`), and its
|
||||
|
|
@ -44,7 +44,16 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
|
|||
in the public storage at `avatars/<SHA-256>.png`. The page is at
|
||||
`https://<account domain>/@handle` (`ACCOUNT_DOMAIN`, else the server's public host),
|
||||
served by `addProfilePageRoute`, in English or German, its foot what the host's
|
||||
`profilePageNote` says.
|
||||
`profilePageNote` says. A handle stays its person's alone until they go public
|
||||
(Halkyon Learn's ADR 0020, D5: a handle fixed, or a display name, bio, link or
|
||||
avatar): until then its page and `profiles.byHandle` answer as for a handle nobody
|
||||
has, while the host's own code still finds whose it is (`personWithHandle`). Every
|
||||
answer about a new handle tells whether an account has it, so they are counted
|
||||
together (`countHandleCheck`, on the register: checking one, saving one, signing up
|
||||
or setting first keys with one, and the host's own lookups, such as Halkyon Learn's
|
||||
blocks), at most 30 an hour per person (or attempt) and 60 per network
|
||||
(`tooManyHandleChecks`); a person's own handle and what is no handle aren't
|
||||
counted.
|
||||
- SimpleLogin (Halkyon Learn's ADR 0020) is halkyon_oidc's sign-in (ADR 0023) with its
|
||||
SimpleLogin preset, on the register alone, which holds the instance's SimpleLogin app
|
||||
and exchanges every code; a member asks it to begin and to complete, and issues its own
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ export 'src/password/passwords.dart'
|
|||
signInChallengeLifetime,
|
||||
signsInWithPassword,
|
||||
wrappedKeysLength;
|
||||
export 'src/profile/handle_checks.dart';
|
||||
export 'src/profile/link_verification.dart';
|
||||
export 'src/profile/profile_page.dart' show addProfilePageRoute, profilePage;
|
||||
export 'src/profile/profile_rules.dart'
|
||||
|
|
|
|||
|
|
@ -45,8 +45,9 @@ enum ProfileRefusal implements _is.SerializableModel {
|
|||
/// The links were checked too often in the last hour.
|
||||
tooManyLinkChecks,
|
||||
|
||||
/// The handles were checked too often in the last hour: their answers
|
||||
/// tell which handles exist.
|
||||
/// Too many answers about new handles in the last hour (checking them,
|
||||
/// saving, signing up or blocking with them): each tells which handles
|
||||
/// exist.
|
||||
tooManyHandleChecks;
|
||||
|
||||
static ProfileRefusal fromJson(String name) {
|
||||
|
|
|
|||
|
|
@ -53,14 +53,16 @@ Future<DeviceKeys> unlockKeys(
|
|||
/// ([PasswordRefusal.invalidSetup]) and an account with keys already, under
|
||||
/// either kind of password ([PasswordRefusal.passwordExists]);
|
||||
/// ([ProfileRefusedException]) for a handle refused as saving a profile
|
||||
/// refuses it, and one missing where the account has none or given where it
|
||||
/// has one ([ProfileRefusal.handleInvalid]); the sign-in's refusals as
|
||||
/// refuses it (a handle counted as checking it is, from [network]), and one
|
||||
/// missing where the account has none or given where it has one
|
||||
/// ([ProfileRefusal.handleInvalid]); the sign-in's refusals as
|
||||
/// [takeSimpleLoginConfirmation]'s. Nothing changes then.
|
||||
Future<void> setFirstKeys(
|
||||
Session session, {
|
||||
required UuidValue person,
|
||||
required UuidValue attempt,
|
||||
required KeysSetup setup,
|
||||
required String network,
|
||||
String? handle,
|
||||
}) async {
|
||||
if (!isKeysSetup(setup)) {
|
||||
|
|
@ -87,7 +89,13 @@ Future<void> setFirstKeys(
|
|||
try {
|
||||
await session.db.transaction((transaction) async {
|
||||
if (handle != null) {
|
||||
if (await refusalOfNewHandle(session, handle, transaction: transaction)
|
||||
if (await refusalOfNewHandle(
|
||||
session,
|
||||
handle,
|
||||
asker: person.uuid,
|
||||
network: network,
|
||||
transaction: transaction,
|
||||
)
|
||||
case final reason?) {
|
||||
throw ProfileRefusedException(reason: reason);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_base_server/src/common/network.dart';
|
||||
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_base_server/src/password/key_proof.dart';
|
||||
import 'package:halkyon_base_server/src/password/owner_proof_tries.dart';
|
||||
|
|
@ -57,8 +58,8 @@ class KeysEndpoint extends Endpoint {
|
|||
/// ten minutes on this device, under [attempt]. Refused for an account
|
||||
/// with keys (`PasswordRefusal.passwordExists`), a setup out of range
|
||||
/// (`PasswordRefusal.invalidSetup`), a handle refused or missing
|
||||
/// (`ProfileRefusedException`), and without that sign-in
|
||||
/// (`OidcRefusal.unknownAttempt`).
|
||||
/// (`ProfileRefusedException`; a handle counted as `profile.checkHandle`
|
||||
/// counts it), and without that sign-in (`OidcRefusal.unknownAttempt`).
|
||||
Future<void> set(
|
||||
Session session, {
|
||||
required UuidValue attempt,
|
||||
|
|
@ -69,6 +70,7 @@ class KeysEndpoint extends Endpoint {
|
|||
person: session.authenticated!.authUserId,
|
||||
attempt: attempt,
|
||||
setup: setup,
|
||||
network: networkOf(session),
|
||||
handle: handle,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
|
||||
// Whether a handle is taken, which every answer about a new handle tells:
|
||||
// checking one, saving one, signing up or setting first keys with one, and
|
||||
// a host's own lookups by handle (Halkyon Learn's blocks). Handles are
|
||||
// hidden until their person goes public (Halkyon Learn's ADR 0020, 9 and
|
||||
// D5), so these answers alone tell that an account exists, and are counted
|
||||
// together, per asker and per network.
|
||||
|
||||
/// Counts one answer about a handle for [asker] (a person's ID, or the
|
||||
/// attempt someone new waits under) and from [network] where given: at most
|
||||
/// [handleChecksPerAsker] an hour for one and [handleChecksPerNetwork] from
|
||||
/// a network, refused beyond ([ProfileRefusal.tooManyHandleChecks]), a
|
||||
/// refused one counted for neither. On the host's own database: a member's
|
||||
/// counts are its own.
|
||||
Future<void> countHandleCheck(
|
||||
Session session, {
|
||||
required String asker,
|
||||
required String? network,
|
||||
}) async {
|
||||
if (!await _perAsker.tryRecordAttempt(session, key: asker) ||
|
||||
(network != null &&
|
||||
!await _perNetwork.tryRecordAttempt(session, key: network))) {
|
||||
throw ProfileRefusedException(reason: ProfileRefusal.tooManyHandleChecks);
|
||||
}
|
||||
}
|
||||
|
||||
/// How many answers about handles one asker gets in an hour: enough to try
|
||||
/// a few while typing.
|
||||
const handleChecksPerAsker = 30;
|
||||
|
||||
/// How many answers about handles one network gets in an hour.
|
||||
const handleChecksPerNetwork = 60;
|
||||
|
||||
final _perAsker = DatabaseRateLimiter(
|
||||
RateLimiterConfig(
|
||||
domain: 'profile',
|
||||
source: 'handle_checks',
|
||||
maxAttempts: handleChecksPerAsker,
|
||||
timeframe: const Duration(hours: 1),
|
||||
),
|
||||
);
|
||||
|
||||
final _perNetwork = DatabaseRateLimiter(
|
||||
RateLimiterConfig(
|
||||
domain: 'profile',
|
||||
source: 'handle_checks_per_network',
|
||||
maxAttempts: handleChecksPerNetwork,
|
||||
timeframe: const Duration(hours: 1),
|
||||
),
|
||||
);
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_base_server/src/auth/scopes.dart';
|
||||
import 'package:halkyon_base_server/src/common/network.dart';
|
||||
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_base_server/src/register/account_register.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
|
@ -24,7 +25,8 @@ class ProfileEndpoint extends Endpoint {
|
|||
/// Saves your public profile, making it if you have none. Throws
|
||||
/// [ProfileRefusedException] when it can't be saved (see
|
||||
/// [ProfileRefusal]): a handle someone else has, whatever its case, or a
|
||||
/// new handle once yours is fixed.
|
||||
/// new handle once yours is fixed. A new handle counts as [checkHandle]
|
||||
/// counts it.
|
||||
Future<Profile> save(
|
||||
Session session,
|
||||
String handle,
|
||||
|
|
@ -38,6 +40,7 @@ class ProfileEndpoint extends Endpoint {
|
|||
displayName: displayName,
|
||||
bio: bio,
|
||||
links: links,
|
||||
network: networkOf(session),
|
||||
);
|
||||
|
||||
/// Checks which of your links' pages link back to your profile's page
|
||||
|
|
@ -52,11 +55,20 @@ class ProfileEndpoint extends Endpoint {
|
|||
/// Why [handle] couldn't be yours, as saving it would refuse it: not a
|
||||
/// handle, reserved for the instance, someone else's whatever its case,
|
||||
/// or a new one once yours is fixed; null when it can, as your own can. For
|
||||
/// the app to say so while it is typed; saving checks again. Whose a handle
|
||||
/// is, anyone can find out already ([ProfilesEndpoint.byHandle]), so this
|
||||
/// tells nothing new.
|
||||
/// the app to say so while it is typed; saving checks again. Handles stay
|
||||
/// hidden until their people go public (Halkyon Learn's ADR 0020, D5), so
|
||||
/// this tells that an account exists: at most 30 an hour for you and 60
|
||||
/// from your network, with every other answer about a new handle (saving
|
||||
/// one, signing up with one), refused beyond
|
||||
/// ([ProfileRefusal.tooManyHandleChecks], thrown); your own handle and
|
||||
/// what is no handle aren't counted.
|
||||
Future<ProfileRefusal?> checkHandle(Session session, String handle) =>
|
||||
_register(session).checkHandle(session, _caller(session), handle);
|
||||
_register(session).checkHandle(
|
||||
session,
|
||||
_caller(session),
|
||||
handle,
|
||||
network: networkOf(session),
|
||||
);
|
||||
|
||||
/// Shows [png] as your avatar, in place of any before: a PNG of at most
|
||||
/// 1 MB, which the app draws from the picture chosen. Throws
|
||||
|
|
@ -84,7 +96,10 @@ class ProfilesEndpoint extends Endpoint {
|
|||
bool get requireLogin => true;
|
||||
|
||||
/// The public profile with [handle], whatever its case. Throws
|
||||
/// [ProfileNotFoundException] when there is none.
|
||||
/// [ProfileNotFoundException] when there is none, and as well while its
|
||||
/// person hasn't gone public (Halkyon Learn's ADR 0020, D5): published
|
||||
/// nothing and left the rest of the profile empty (display name, bio,
|
||||
/// links, avatar).
|
||||
Future<Profile> byHandle(Session session, String handle) async =>
|
||||
await _register(session).profileByHandle(session, handle) ??
|
||||
(throw ProfileNotFoundException(what: 'profile'));
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ values:
|
|||
- avatarInvalid
|
||||
### The links were checked too often in the last hour.
|
||||
- tooManyLinkChecks
|
||||
### The handles were checked too often in the last hour: their answers
|
||||
### tell which handles exist.
|
||||
### Too many answers about new handles in the last hour (checking them,
|
||||
### saving, signing up or blocking with them): each tells which handles
|
||||
### exist.
|
||||
- tooManyHandleChecks
|
||||
|
|
|
|||
|
|
@ -139,6 +139,17 @@ Uri profilePageUrl(Serverpod pod, String handle) {
|
|||
);
|
||||
}
|
||||
|
||||
/// Whether [row]'s person has gone public (Halkyon Learn's ADR 0020, D5):
|
||||
/// published something, or filled in their profile (a display name, a bio,
|
||||
/// a link or an avatar). Until then their handle is theirs alone: its page
|
||||
/// and `profiles.byHandle` answer as for a handle nobody has.
|
||||
bool isPublic(PublicProfile row) =>
|
||||
row.firstPublishedAt != null ||
|
||||
row.displayName.isNotEmpty ||
|
||||
row.bio.isNotEmpty ||
|
||||
row.links.isNotEmpty ||
|
||||
row.avatarHash != null;
|
||||
|
||||
/// [row] as the app sees it: without whose it is.
|
||||
Future<Profile> profileViewOf(Session session, PublicProfile row) async =>
|
||||
Profile(
|
||||
|
|
|
|||
|
|
@ -65,7 +65,9 @@ abstract interface class AccountRegister {
|
|||
/// Saves [person]'s public profile, making it if they have none. Throws
|
||||
/// `ProfileRefusedException` when it can't be saved (see
|
||||
/// `ProfileRefusal`): a handle someone else has, whatever its case, or a
|
||||
/// new handle once theirs is fixed.
|
||||
/// new handle once theirs is fixed. A handle new for them counts as an
|
||||
/// answer about it (`countHandleCheck`), for them and from [network] where
|
||||
/// given (a host's tests give none).
|
||||
Future<Profile> saveProfile(
|
||||
Session session,
|
||||
UuidValue person, {
|
||||
|
|
@ -73,15 +75,18 @@ abstract interface class AccountRegister {
|
|||
required String displayName,
|
||||
required String bio,
|
||||
required List<String> links,
|
||||
String? network,
|
||||
});
|
||||
|
||||
/// Why [handle] couldn't be [person]'s, as saving it would refuse it; null
|
||||
/// when it can.
|
||||
/// when it can. Counted (`countHandleCheck`) for them and from [network],
|
||||
/// unless it is theirs or no handle at all.
|
||||
Future<ProfileRefusal?> checkHandle(
|
||||
Session session,
|
||||
UuidValue person,
|
||||
String handle,
|
||||
);
|
||||
String handle, {
|
||||
required String network,
|
||||
});
|
||||
|
||||
/// Checks which of [person]'s links' pages link back to their profile's
|
||||
/// page with `rel="me"`, and keeps the answer; at most 10 times an hour.
|
||||
|
|
@ -169,14 +174,15 @@ abstract interface class AccountRegister {
|
|||
});
|
||||
|
||||
/// Signs up someone new whose SimpleLogin identity waits under [attempt],
|
||||
/// with [handle] and the keys [setup] describes: the new account's ID,
|
||||
/// which the member issues its own session for. Refusals as
|
||||
/// `signUpWithSimpleLogin`'s.
|
||||
/// with [handle] and the keys [setup] describes, the handle counted as a
|
||||
/// check from [network]: the new account's ID, which the member issues its
|
||||
/// own session for. Refusals as `signUpWithSimpleLogin`'s.
|
||||
Future<UuidValue> signUpWithSimpleLogin(
|
||||
Session session, {
|
||||
required UuidValue attempt,
|
||||
required String handle,
|
||||
required KeysSetup setup,
|
||||
required String network,
|
||||
});
|
||||
|
||||
/// The first step of mending the SimpleLogin link of the account with
|
||||
|
|
@ -216,13 +222,15 @@ abstract interface class AccountRegister {
|
|||
);
|
||||
|
||||
/// Gives [person], an account without keys that SimpleLogin was added to,
|
||||
/// the keys [setup] describes and [handle] where it has none, with their
|
||||
/// SimpleLogin sign-in under [attempt]. Refusals as `setFirstKeys`'.
|
||||
/// the keys [setup] describes and [handle] where it has none (counted as a
|
||||
/// check from [network]), with their SimpleLogin sign-in under [attempt].
|
||||
/// Refusals as `setFirstKeys`'.
|
||||
Future<void> setFirstKeys(
|
||||
Session session, {
|
||||
required UuidValue person,
|
||||
required UuidValue attempt,
|
||||
required KeysSetup setup,
|
||||
required String network,
|
||||
String? handle,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ Future<Map<String, Object?>?> _answer(
|
|||
displayName: arguments.string('displayName'),
|
||||
bio: arguments.string('bio'),
|
||||
links: arguments.strings('links'),
|
||||
network: arguments.optionalString('network'),
|
||||
);
|
||||
return {'profile': profile.toJson()};
|
||||
case RegisterOperation.checkHandle:
|
||||
|
|
@ -136,6 +137,7 @@ Future<Map<String, Object?>?> _answer(
|
|||
session,
|
||||
arguments.id('person'),
|
||||
arguments.string('handle'),
|
||||
network: arguments.string('network'),
|
||||
);
|
||||
return {'refusal': refusal?.name};
|
||||
case RegisterOperation.checkLinks:
|
||||
|
|
@ -225,6 +227,7 @@ Future<Map<String, Object?>?> _answer(
|
|||
attempt: arguments.id('attempt'),
|
||||
handle: arguments.string('handle'),
|
||||
setup: arguments.model('setup', KeysSetup.fromJson),
|
||||
network: arguments.string('network'),
|
||||
);
|
||||
return {'account': account.uuid};
|
||||
case RegisterOperation.beginSimpleLoginMend:
|
||||
|
|
@ -261,6 +264,7 @@ Future<Map<String, Object?>?> _answer(
|
|||
person: arguments.id('person'),
|
||||
attempt: arguments.id('attempt'),
|
||||
setup: arguments.model('setup', KeysSetup.fromJson),
|
||||
network: arguments.string('network'),
|
||||
handle: arguments.optionalString('handle'),
|
||||
);
|
||||
return const {};
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import 'package:halkyon_base_server/src/password/password_change.dart'
|
|||
as change;
|
||||
import 'package:halkyon_base_server/src/password/password_config.dart';
|
||||
import 'package:halkyon_base_server/src/password/passwords.dart' as passwords;
|
||||
import 'package:halkyon_base_server/src/profile/handle_checks.dart';
|
||||
import 'package:halkyon_base_server/src/profile/link_verification.dart';
|
||||
import 'package:halkyon_base_server/src/profile/profile_rules.dart';
|
||||
import 'package:halkyon_base_server/src/register/account_register.dart';
|
||||
|
|
@ -72,7 +73,10 @@ final class LocalRegister implements AccountRegister {
|
|||
session,
|
||||
where: (t) => t.handleKey.equals(handle.toLowerCase()),
|
||||
);
|
||||
return row == null ? null : await profileViewOf(session, row);
|
||||
// Until its person goes public, as nobody's (D5).
|
||||
return row == null || !isPublic(row)
|
||||
? null
|
||||
: await profileViewOf(session, row);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -96,6 +100,7 @@ final class LocalRegister implements AccountRegister {
|
|||
required String displayName,
|
||||
required String bio,
|
||||
required List<String> links,
|
||||
String? network,
|
||||
}) async {
|
||||
final trimmed = (
|
||||
displayName: displayName.trim(),
|
||||
|
|
@ -123,6 +128,10 @@ final class LocalRegister implements AccountRegister {
|
|||
mine.handleKey != key) {
|
||||
throw ProfileRefusedException(reason: ProfileRefusal.published);
|
||||
}
|
||||
if (mine?.handleKey != key) {
|
||||
// Counted apart from this transaction, which a refusal ends.
|
||||
await countHandleCheck(session, asker: person.uuid, network: network);
|
||||
}
|
||||
// SELECT … FROM "public_profile"
|
||||
// WHERE "handleKey" = $1 AND "authUserId" <> $2
|
||||
final theirs = await PublicProfile.db.findFirstRow(
|
||||
|
|
@ -189,8 +198,9 @@ final class LocalRegister implements AccountRegister {
|
|||
Future<ProfileRefusal?> checkHandle(
|
||||
Session session,
|
||||
UuidValue person,
|
||||
String handle,
|
||||
) async {
|
||||
String handle, {
|
||||
required String network,
|
||||
}) async {
|
||||
if (profileRefusal(
|
||||
handle: handle,
|
||||
displayName: '',
|
||||
|
|
@ -202,11 +212,13 @@ final class LocalRegister implements AccountRegister {
|
|||
}
|
||||
final key = handle.toLowerCase();
|
||||
final mine = await _rowOf(session, person);
|
||||
if (mine != null &&
|
||||
mine.firstPublishedAt != null &&
|
||||
mine.handleKey != key) {
|
||||
if (mine?.handleKey == key) {
|
||||
return null;
|
||||
}
|
||||
if (mine?.firstPublishedAt != null) {
|
||||
return ProfileRefusal.published;
|
||||
}
|
||||
await countHandleCheck(session, asker: person.uuid, network: network);
|
||||
// SELECT … FROM "public_profile"
|
||||
// WHERE "handleKey" = $1 AND "authUserId" <> $2
|
||||
final theirs = await PublicProfile.db.findFirstRow(
|
||||
|
|
@ -418,11 +430,13 @@ final class LocalRegister implements AccountRegister {
|
|||
required UuidValue attempt,
|
||||
required String handle,
|
||||
required KeysSetup setup,
|
||||
required String network,
|
||||
}) => simplelogin.signUpWithSimpleLogin(
|
||||
session,
|
||||
attempt: attempt,
|
||||
handle: handle,
|
||||
setup: setup,
|
||||
network: network,
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -464,12 +478,14 @@ final class LocalRegister implements AccountRegister {
|
|||
required UuidValue person,
|
||||
required UuidValue attempt,
|
||||
required KeysSetup setup,
|
||||
required String network,
|
||||
String? handle,
|
||||
}) => keys.setFirstKeys(
|
||||
session,
|
||||
person: person,
|
||||
attempt: attempt,
|
||||
setup: setup,
|
||||
network: network,
|
||||
handle: handle,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -164,6 +164,7 @@ final class RemoteRegister implements AccountRegister {
|
|||
required String displayName,
|
||||
required String bio,
|
||||
required List<String> links,
|
||||
String? network,
|
||||
}) async => _profile(
|
||||
(await _ask(RegisterOperation.saveProfile, {
|
||||
'person': person.uuid,
|
||||
|
|
@ -171,6 +172,7 @@ final class RemoteRegister implements AccountRegister {
|
|||
'displayName': displayName,
|
||||
'bio': bio,
|
||||
'links': links,
|
||||
'network': network,
|
||||
}))['profile'],
|
||||
)!;
|
||||
|
||||
|
|
@ -178,10 +180,12 @@ final class RemoteRegister implements AccountRegister {
|
|||
Future<ProfileRefusal?> checkHandle(
|
||||
Session session,
|
||||
UuidValue person,
|
||||
String handle,
|
||||
) async => switch ((await _ask(RegisterOperation.checkHandle, {
|
||||
String handle, {
|
||||
required String network,
|
||||
}) async => switch ((await _ask(RegisterOperation.checkHandle, {
|
||||
'person': person.uuid,
|
||||
'handle': handle,
|
||||
'network': network,
|
||||
}))['refusal']) {
|
||||
final String reason => ProfileRefusal.values.asNameMap()[reason],
|
||||
_ => null,
|
||||
|
|
@ -326,12 +330,14 @@ final class RemoteRegister implements AccountRegister {
|
|||
required UuidValue attempt,
|
||||
required String handle,
|
||||
required KeysSetup setup,
|
||||
required String network,
|
||||
}) async =>
|
||||
_id(
|
||||
(await _ask(RegisterOperation.signUpWithSimpleLogin, {
|
||||
'attempt': attempt.uuid,
|
||||
'handle': handle,
|
||||
'setup': setup.toJson(),
|
||||
'network': network,
|
||||
}))['account'],
|
||||
) ??
|
||||
(throw RegisterUnavailableException(
|
||||
|
|
@ -404,11 +410,13 @@ final class RemoteRegister implements AccountRegister {
|
|||
required UuidValue person,
|
||||
required UuidValue attempt,
|
||||
required KeysSetup setup,
|
||||
required String network,
|
||||
String? handle,
|
||||
}) => _ask(RegisterOperation.setFirstKeys, {
|
||||
'person': person.uuid,
|
||||
'attempt': attempt.uuid,
|
||||
'setup': setup.toJson(),
|
||||
'network': network,
|
||||
'handle': handle,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'package:halkyon_base_server/src/generated/protocol.dart';
|
|||
import 'package:halkyon_base_server/src/password/key_proof.dart';
|
||||
import 'package:halkyon_base_server/src/password/password_config.dart';
|
||||
import 'package:halkyon_base_server/src/password/passwords.dart';
|
||||
import 'package:halkyon_base_server/src/profile/handle_checks.dart';
|
||||
import 'package:halkyon_base_server/src/profile/profile_rules.dart';
|
||||
import 'package:halkyon_base_server/src/register/local_register.dart';
|
||||
import 'package:halkyon_base_server/src/register/people.dart';
|
||||
|
|
@ -187,10 +188,11 @@ Future<SimpleLoginProof> _signedInAs(
|
|||
/// Whether someone new, whose identity waits under [attempt], could take
|
||||
/// [handle]: null where they could, else why not, as saving a profile would
|
||||
/// refuse it (whatever its case for one taken). For the app to say so while
|
||||
/// it is typed; signing up checks again. At most 30 for an attempt and 60
|
||||
/// from a [network] in an hour ([ProfileRefusal.tooManyHandleChecks]): each
|
||||
/// answer tells which handles exist. Refused ([OidcRefusal.unknownAttempt])
|
||||
/// where no identity waits.
|
||||
/// it is typed; signing up checks again. Counted with every answer about a
|
||||
/// handle, for the attempt and from [network] (`countHandleCheck`: 30 and
|
||||
/// 60 an hour, [ProfileRefusal.tooManyHandleChecks]), as each tells which
|
||||
/// handles exist. Refused ([OidcRefusal.unknownAttempt]) where no identity
|
||||
/// waits.
|
||||
Future<ProfileRefusal?> checkSimpleLoginHandle(
|
||||
Session session, {
|
||||
required UuidValue attempt,
|
||||
|
|
@ -201,27 +203,30 @@ Future<ProfileRefusal?> checkSimpleLoginHandle(
|
|||
if (!await idp.isKept(session, attempt: attempt)) {
|
||||
throw OidcRefusedException(reason: OidcRefusal.unknownAttempt);
|
||||
}
|
||||
if (!await _handleChecksPerAttempt.tryRecordAttempt(
|
||||
session,
|
||||
key: attempt.uuid,
|
||||
) ||
|
||||
!await _handleChecksPerNetwork.tryRecordAttempt(session, key: network)) {
|
||||
throw ProfileRefusedException(reason: ProfileRefusal.tooManyHandleChecks);
|
||||
}
|
||||
return await refusalOfNewHandle(session, handle);
|
||||
return await refusalOfNewHandle(
|
||||
session,
|
||||
handle,
|
||||
asker: attempt.uuid,
|
||||
network: network,
|
||||
);
|
||||
}
|
||||
|
||||
/// Why [handle] can't be a new account's, nor the first of an account
|
||||
/// without one, as saving a profile would refuse it; null where it can.
|
||||
/// without one, as saving a profile would refuse it; null where it can. A
|
||||
/// handle at all is counted as an answer about it, for [asker] and from
|
||||
/// [network] (`countHandleCheck`), apart from [transaction].
|
||||
Future<ProfileRefusal?> refusalOfNewHandle(
|
||||
Session session,
|
||||
String handle, {
|
||||
required String asker,
|
||||
required String network,
|
||||
Transaction? transaction,
|
||||
}) async {
|
||||
if (profileRefusal(handle: handle, displayName: '', bio: '', links: const [])
|
||||
case final reason?) {
|
||||
return reason;
|
||||
}
|
||||
await countHandleCheck(session, asker: asker, network: network);
|
||||
// SELECT count(*) FROM "public_profile" WHERE "handleKey" = $1
|
||||
final taken = await PublicProfile.db.count(
|
||||
session,
|
||||
|
|
@ -238,13 +243,15 @@ Future<ProfileRefusal?> refusalOfNewHandle(
|
|||
/// the account's ID, which the caller issues a session for. Refused, the
|
||||
/// identity left waiting: for a setup out of range
|
||||
/// ([PasswordRefusal.invalidSetup]) and a handle refused as saving a
|
||||
/// profile refuses it ([ProfileRefusedException]); the identity's own
|
||||
/// refusals as `OidcIdp.takeKept`'s and `OidcIdp.link`'s.
|
||||
/// profile refuses it ([ProfileRefusedException]), the handle counted as
|
||||
/// checking it is, from [network]; the identity's own refusals as
|
||||
/// `OidcIdp.takeKept`'s and `OidcIdp.link`'s.
|
||||
Future<UuidValue> signUpWithSimpleLogin(
|
||||
Session session, {
|
||||
required UuidValue attempt,
|
||||
required String handle,
|
||||
required KeysSetup setup,
|
||||
required String network,
|
||||
}) async {
|
||||
final idp = _simpleLoginOn(session);
|
||||
if (!isKeysSetup(setup)) {
|
||||
|
|
@ -252,7 +259,13 @@ Future<UuidValue> signUpWithSimpleLogin(
|
|||
}
|
||||
try {
|
||||
return await session.db.transaction((transaction) async {
|
||||
if (await refusalOfNewHandle(session, handle, transaction: transaction)
|
||||
if (await refusalOfNewHandle(
|
||||
session,
|
||||
handle,
|
||||
asker: attempt.uuid,
|
||||
network: network,
|
||||
transaction: transaction,
|
||||
)
|
||||
case final reason?) {
|
||||
throw ProfileRefusedException(reason: reason);
|
||||
}
|
||||
|
|
@ -541,27 +554,6 @@ final _mendsPerNetwork = DatabaseRateLimiter(
|
|||
),
|
||||
);
|
||||
|
||||
/// How many handles someone new checks for one attempt in an hour: enough
|
||||
/// to try a few while typing.
|
||||
final _handleChecksPerAttempt = DatabaseRateLimiter(
|
||||
RateLimiterConfig(
|
||||
domain: 'simplelogin',
|
||||
source: 'handle_checks_per_attempt',
|
||||
maxAttempts: 30,
|
||||
timeframe: const Duration(hours: 1),
|
||||
),
|
||||
);
|
||||
|
||||
/// How many handles one network checks for people new here in an hour.
|
||||
final _handleChecksPerNetwork = DatabaseRateLimiter(
|
||||
RateLimiterConfig(
|
||||
domain: 'simplelogin',
|
||||
source: 'handle_checks_per_network',
|
||||
maxAttempts: 60,
|
||||
timeframe: const Duration(hours: 1),
|
||||
),
|
||||
);
|
||||
|
||||
/// The first handle made of [name] that nobody has, whatever its case; null
|
||||
/// for none.
|
||||
Future<String?> _suggestedHandle(Session session, Object? name) async {
|
||||
|
|
|
|||
|
|
@ -88,9 +88,10 @@ class SimpleLoginEndpoint extends Endpoint {
|
|||
/// said `newcomer`), could take [handle]: null where they could, else why
|
||||
/// not, as saving a profile would say. For the app to say so while it is
|
||||
/// typed; [signUp] checks again. At most 30 for an attempt and 60 from a
|
||||
/// network in an hour (`ProfileRefusal.tooManyHandleChecks`, thrown):
|
||||
/// each answer tells which handles exist. Refused
|
||||
/// (`OidcRefusal.unknownAttempt`) where no identity waits.
|
||||
/// network in an hour, with every other answer about a handle
|
||||
/// (`ProfileRefusal.tooManyHandleChecks`, thrown): each tells which
|
||||
/// handles exist. Refused (`OidcRefusal.unknownAttempt`) where no identity
|
||||
/// waits.
|
||||
Future<ProfileRefusal?> checkHandle(
|
||||
Session session, {
|
||||
required UuidValue attempt,
|
||||
|
|
@ -108,9 +109,10 @@ class SimpleLoginEndpoint extends Endpoint {
|
|||
/// keys [setup] describes, all made on the device beforehand; a session
|
||||
/// for it here. Refused, the identity still waiting: for a setup out of
|
||||
/// range (`PasswordRefusal.invalidSetup`) and a handle refused
|
||||
/// (`ProfileRefusedException`, as saving a profile refuses it); for an
|
||||
/// attempt unknown, taken or past its time (`OidcRefusal.unknownAttempt`),
|
||||
/// and called signed in (`OidcRefusal.wrongCaller`).
|
||||
/// (`ProfileRefusedException`, as saving a profile refuses it, the handle
|
||||
/// counted as [checkHandle] counts it); for an attempt unknown, taken or
|
||||
/// past its time (`OidcRefusal.unknownAttempt`), and called signed in
|
||||
/// (`OidcRefusal.wrongCaller`).
|
||||
Future<AuthSuccess> signUp(
|
||||
Session session, {
|
||||
required UuidValue attempt,
|
||||
|
|
@ -126,6 +128,7 @@ class SimpleLoginEndpoint extends Endpoint {
|
|||
attempt: attempt,
|
||||
handle: handle,
|
||||
setup: setup,
|
||||
network: networkOf(session),
|
||||
);
|
||||
return await issueSession(session, account, method: simpleLoginMethod);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -460,6 +460,23 @@ void main() {
|
|||
},
|
||||
);
|
||||
|
||||
test("count a new handle among the person's answers about handles "
|
||||
'(30 an hour), the sign-in still standing when refused', () async {
|
||||
final attempt = await signInAgain(onBensLaptop(), 'sl-ben');
|
||||
for (var i = 0; i < handleChecksPerAsker; i++) {
|
||||
await countHandleCheck(session, asker: ben.uuid, network: null);
|
||||
}
|
||||
|
||||
await expectLater(
|
||||
setWith(attempt),
|
||||
refusedAsProfile(ProfileRefusal.tooManyHandleChecks),
|
||||
);
|
||||
expect(
|
||||
await idp().isKept(session, attempt: attempt, caller: ben),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('refuse an account with keys already, under either kind of '
|
||||
'password', () async {
|
||||
final annas = await signInAgain(onPhone(), 'sl-anna');
|
||||
|
|
|
|||
|
|
@ -22,9 +22,13 @@ void main() {
|
|||
useBase(sessionBuilder);
|
||||
session = sessionBuilder.build();
|
||||
});
|
||||
tearDown(
|
||||
() => PublicProfile.db.deleteWhere(session, where: (t) => t.id > 0),
|
||||
);
|
||||
tearDown(() async {
|
||||
await PublicProfile.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||
await RateLimitedRequestAttempt.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id.notEquals(null),
|
||||
);
|
||||
});
|
||||
|
||||
/// A person with an identity, signed in.
|
||||
TestSessionBuilder identifiedPerson() => sessionBuilder.copyWith(
|
||||
|
|
@ -219,6 +223,100 @@ void main() {
|
|||
expect((await save(bob, 'alice')).handle, 'alice');
|
||||
});
|
||||
|
||||
test('a profile is found by its handle only once its person goes '
|
||||
'public: with something published, or the profile filled in; '
|
||||
'their own they see all along (D5)', () async {
|
||||
Future<String?> found() async {
|
||||
try {
|
||||
return (await endpoints.profiles.byHandle(bob, 'alice')).handle;
|
||||
} on ProfileNotFoundException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
await save(alice, 'Alice');
|
||||
expect(await found(), isNull);
|
||||
expect((await endpoints.profile.mine(alice))!.handle, 'Alice');
|
||||
|
||||
for (final (displayName, bio, links) in [
|
||||
('Alice', '', const <String>[]),
|
||||
('', 'Learning Japanese.', const <String>[]),
|
||||
('', '', ['https://halkyon.app']),
|
||||
]) {
|
||||
await save(
|
||||
alice,
|
||||
'Alice',
|
||||
displayName: displayName,
|
||||
bio: bio,
|
||||
links: links,
|
||||
);
|
||||
expect(await found(), 'Alice', reason: '$displayName$bio$links');
|
||||
}
|
||||
await save(alice, 'Alice');
|
||||
expect(await found(), isNull);
|
||||
|
||||
final row = (await PublicProfile.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.handleKey.equals('alice'),
|
||||
))!;
|
||||
await PublicProfile.db.updateRow(
|
||||
session,
|
||||
row.copyWith(firstPublishedAt: clock.now().toUtc()),
|
||||
);
|
||||
expect(await found(), 'Alice');
|
||||
});
|
||||
|
||||
group('Answers about handles', () {
|
||||
Future<ProfileRefusal?> check(
|
||||
TestSessionBuilder person,
|
||||
String handle,
|
||||
) => endpoints.profile.checkHandle(person, handle);
|
||||
|
||||
test('are at most 30 an hour for a person: checking a handle and '
|
||||
'saving a new one count, a refused one neither', () async {
|
||||
await save(alice, 'alice');
|
||||
for (var i = 0; i < 29; i++) {
|
||||
await check(bob, 'bob$i');
|
||||
}
|
||||
await save(bob, 'bob');
|
||||
|
||||
await expectLater(
|
||||
check(bob, 'alice'),
|
||||
refused(ProfileRefusal.tooManyHandleChecks),
|
||||
);
|
||||
await expectLater(
|
||||
save(bob, 'alice'),
|
||||
refused(ProfileRefusal.tooManyHandleChecks),
|
||||
);
|
||||
expect((await endpoints.profile.mine(bob))!.handle, 'bob');
|
||||
// Saving their own handle again, whatever its case, answers nothing
|
||||
// new, nor does checking it, or a handle that is none.
|
||||
expect(
|
||||
(await save(bob, 'Bob', displayName: 'Bob')).displayName,
|
||||
'Bob',
|
||||
);
|
||||
expect(await check(bob, 'BOB'), isNull);
|
||||
expect(await check(bob, 'a-b'), ProfileRefusal.handleInvalid);
|
||||
expect(await check(bob, 'admin'), ProfileRefusal.handleReserved);
|
||||
// Others ask on.
|
||||
expect(await check(alice, 'carla'), isNull);
|
||||
});
|
||||
|
||||
test('are at most 60 an hour from one network, whoever asks', () async {
|
||||
for (var i = 0; i < 30; i++) {
|
||||
await check(alice, 'carla$i');
|
||||
}
|
||||
for (var i = 0; i < 30; i++) {
|
||||
await check(bob, 'dora$i');
|
||||
}
|
||||
|
||||
await expectLater(
|
||||
check(identifiedPerson(), 'emil'),
|
||||
refused(ProfileRefusal.tooManyHandleChecks),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("a handle nobody has isn't found", () async {
|
||||
await expectLater(
|
||||
endpoints.profiles.byHandle(alice, 'nobody'),
|
||||
|
|
@ -306,6 +404,22 @@ void main() {
|
|||
expect(await stored(), 0);
|
||||
});
|
||||
|
||||
test('makes a profile public by itself (D5)', () async {
|
||||
await save(alice, 'alice');
|
||||
|
||||
await endpoints.profile.setAvatar(alice, png(1));
|
||||
expect(
|
||||
(await endpoints.profiles.byHandle(bob, 'alice')).handle,
|
||||
'alice',
|
||||
);
|
||||
|
||||
await endpoints.profile.removeAvatar(alice);
|
||||
await expectLater(
|
||||
endpoints.profiles.byHandle(bob, 'alice'),
|
||||
throwsA(isA<ProfileNotFoundException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('needs a profile to show on', () async {
|
||||
await expectLater(
|
||||
endpoints.profile.setAvatar(alice, png(1)),
|
||||
|
|
|
|||
|
|
@ -222,6 +222,23 @@ void main() {
|
|||
expect(page.html, isNot(contains('class="app"')));
|
||||
});
|
||||
|
||||
test('is not found until its person goes public (D5)', () async {
|
||||
useBase(sessionBuilder);
|
||||
await endpoints.profile.save(alice, 'alice', '', '', const []);
|
||||
final host = profilePageUrl(session.serverpod, 'alice').host;
|
||||
|
||||
expect(
|
||||
(await profilePage(session, '@alice', host: host)).status,
|
||||
404,
|
||||
);
|
||||
|
||||
await save(alice, 'alice', const []);
|
||||
expect(
|
||||
(await profilePage(session, '@alice', host: host)).status,
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'is not found for a handle nobody has, or anything else',
|
||||
() async {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue