diff --git a/CHANGELOG.md b/CHANGELOG.md index 262c41f..27c29de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## Unreleased + +- Add pluggable cookie jar primitives with `CookiePolicy`, `CookieStore`, `MemoryCookieStore`, `CookieJar`, and `CookieKey`. +- Add stored-cookie creation and last-access metadata for jar sorting and storage use cases. + ## 0.2.0 ### Breaking Changes diff --git a/README.md b/README.md index b3893e4..2fbb4b2 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,13 @@ final stored = StoredCookie.fromSetCookie( print(stored.matches(Uri.parse('https://example.com/profile'))); // true print(stored.toRequestCookie()); // sid=abc +final jar = CookieJar(); +await jar.save( + Uri.parse('https://example.com/login'), + ['sid=abc; Path=/; HttpOnly'], +); +print(await jar.header(Uri.parse('https://example.com/profile'))); // sid=abc + final values = Cookie.splitSetCookie( 'a=b; Expires=Wed, 21 Oct 2015 07:28:00 GMT, c=d; Path=/', ); @@ -48,6 +55,8 @@ print(values); // [a=b; Expires=Wed, 21 Oct 2015 07:28:00 GMT, c=d; Path=/] - `Cookie.fromString` - Parse a set-cookie string to Cookie instance. - `Cookie.splitSetCookie` - Split a string of multiple set-cookie values into a set-cookie string list. - `StoredCookie.fromSetCookie` - Normalize a Set-Cookie value for request matching. +- `CookieJar` - Save Set-Cookie values and build matching Cookie request headers. +- `CookieStore` - Plug in custom persistence behind the jar policy layer. ## CopyWith And Clear diff --git a/lib/ocookie.dart b/lib/ocookie.dart index efa8d11..80dd02b 100644 --- a/lib/ocookie.dart +++ b/lib/ocookie.dart @@ -1,3 +1,5 @@ export 'src/cookie.dart'; +export 'src/cookie_jar.dart'; +export 'src/cookie_key.dart'; export 'src/stored_cookie.dart'; export 'src/types.dart'; diff --git a/lib/src/cookie_jar.dart b/lib/src/cookie_jar.dart new file mode 100644 index 0000000..215ffd3 --- /dev/null +++ b/lib/src/cookie_jar.dart @@ -0,0 +1,315 @@ +import 'cookie.dart'; +import 'cookie_key.dart'; +import 'stored_cookie.dart'; +import 'types.dart'; + +/// RFC cookie-store policy that is independent from storage. +final class CookiePolicy { + const CookiePolicy(); + + /// Parses and normalizes a Set-Cookie value received for [requestUri]. + StoredCookie normalizeSetCookie( + String setCookie, { + required Uri requestUri, + DateTime? now, + CookieCodec? decode, + }) { + return StoredCookie.fromSetCookie( + setCookie, + requestUri: requestUri, + now: now, + decode: decode, + ); + } + + /// Normalizes [cookie] as if it was received for [requestUri]. + StoredCookie normalizeCookie( + Cookie cookie, { + required Uri requestUri, + DateTime? now, + }) { + return StoredCookie.fromCookie( + cookie, + requestUri: requestUri, + now: now, + ); + } + + /// Whether [cookie] should be sent to [uri] at [now]. + bool matches(StoredCookie cookie, Uri uri, DateTime now) { + return cookie.matches(uri, now: now); + } + + /// Sorts cookies for a Cookie request header. + /// + /// Longer paths are ordered first. Cookies with the same path length are + /// ordered by earlier creation time. + List sortForHeader(Iterable cookies) { + return cookies.toList() + ..sort((a, b) { + final pathOrder = b.path.length.compareTo(a.path.length); + if (pathOrder != 0) { + return pathOrder; + } + + return a.creationTime.compareTo(b.creationTime); + }); + } + + /// Serializes one stored cookie as a request Cookie header pair. + String toRequestCookie(StoredCookie cookie, {CookieCodec? encode}) { + return cookie.toRequestCookie(encode: encode); + } + + /// Serializes stored cookies as a single Cookie request header value. + /// + /// Set [sort] to false when [cookies] are already in header order. + String toRequestHeaderValue( + Iterable cookies, { + CookieCodec? encode, + bool sort = true, + }) { + final headerCookies = sort ? sortForHeader(cookies) : cookies; + return headerCookies + .map((cookie) => toRequestCookie(cookie, encode: encode)) + .join('; '); + } +} + +/// Pluggable persistence boundary for stored cookies. +abstract interface class CookieStore { + /// Loads cookies that may match or be replaced by cookies from [uri]. + Future> loadCandidates(Uri uri); + + /// Inserts or replaces [cookie] by name, domain, path, and host-only state. + Future upsert(StoredCookie cookie); + + /// Deletes the cookie identified by [key]. + Future delete(CookieKey key); + + /// Removes every cookie. + Future clear(); +} + +/// Dependency-light in-memory cookie storage. +final class MemoryCookieStore implements CookieStore { + final Map _cookies = {}; + + @override + Future clear() async { + _cookies.clear(); + } + + @override + Future delete(CookieKey key) async { + _cookies.remove(key); + } + + @override + Future> loadCandidates(Uri uri) async { + return _cookies.values.toList(growable: false); + } + + @override + Future upsert(StoredCookie cookie) async { + _cookies[CookieKey.fromStoredCookie(cookie)] = cookie; + } +} + +/// Cookie jar that combines RFC policy with a pluggable store. +final class CookieJar { + CookieJar({ + CookieStore? store, + this.policy = const CookiePolicy(), + }) : store = store ?? MemoryCookieStore(); + + /// Cookie persistence boundary used by this jar. + final CookieStore store; + + /// Policy used for normalization, matching, sorting, and serialization. + final CookiePolicy policy; + + /// Saves received Set-Cookie header values for [uri]. + Future save( + Uri uri, + Iterable setCookieValues, { + DateTime? now, + CookieCodec? decode, + }) async { + final nowUtc = (now ?? DateTime.now()).toUtc(); + final existing = await _loadByKey(uri, nowUtc); + + for (final setCookie in setCookieValues) { + final cookie = policy.normalizeSetCookie( + setCookie, + requestUri: uri, + now: nowUtc, + decode: decode, + ); + + await _storeNormalized(cookie, existing, nowUtc, uri); + } + } + + /// Saves parsed cookies as if they were received for [uri]. + Future saveCookies( + Uri uri, + Iterable cookies, { + DateTime? now, + }) async { + final nowUtc = (now ?? DateTime.now()).toUtc(); + final existing = await _loadByKey(uri, nowUtc); + + for (final cookie in cookies) { + final stored = policy.normalizeCookie( + cookie, + requestUri: uri, + now: nowUtc, + ); + + await _storeNormalized(stored, existing, nowUtc, uri); + } + } + + /// Loads stored cookies that match [uri]. + Future> loadStored(Uri uri, {DateTime? now}) async { + final nowUtc = (now ?? DateTime.now()).toUtc(); + final matched = []; + + for (final cookie in await store.loadCandidates(uri)) { + final key = CookieKey.fromStoredCookie(cookie); + if (cookie.isExpired(nowUtc)) { + await store.delete(key); + continue; + } + + if (policy.matches(cookie, uri, nowUtc)) { + final accessed = cookie.copyWith(lastAccessTime: nowUtc); + await store.upsert(accessed); + matched.add(accessed); + } + } + + return policy.sortForHeader(matched); + } + + /// Loads parsed cookies that match [uri]. + Future> load(Uri uri, {DateTime? now}) async { + return [ + for (final stored in await loadStored(uri, now: now)) stored.cookie, + ]; + } + + /// Builds the Cookie request header value for [uri]. + /// + /// Returns null when no stored cookie matches. + Future header( + Uri uri, { + DateTime? now, + CookieCodec? encode, + }) async { + final cookies = await loadStored(uri, now: now); + if (cookies.isEmpty) { + return null; + } + + return policy.toRequestHeaderValue(cookies, encode: encode, sort: false); + } + + /// Removes every cookie from the underlying store. + Future clear() { + return store.clear(); + } + + Future> _loadByKey( + Uri uri, + DateTime now, + ) async { + final result = {}; + for (final cookie in await store.loadCandidates(uri)) { + final key = CookieKey.fromStoredCookie(cookie); + if (cookie.isExpired(now)) { + await store.delete(key); + } else { + result[key] = cookie; + } + } + + return result; + } + + Future _storeNormalized( + StoredCookie cookie, + Map existing, + DateTime now, + Uri uri, + ) async { + final key = CookieKey.fromStoredCookie(cookie); + final previous = existing[key]; + if (_wouldOverlaySecureCookie(cookie, existing.values, uri)) { + return; + } + + if (cookie.isExpired(now)) { + await store.delete(key); + existing.remove(key); + return; + } + + final normalized = previous == null + ? cookie + : cookie.copyWith(creationTime: previous.creationTime); + await store.upsert(normalized); + existing[key] = normalized; + } + + bool _wouldOverlaySecureCookie( + StoredCookie cookie, + Iterable existing, + Uri uri, + ) { + if (cookie.cookie.secure || uri.scheme == 'https') { + return false; + } + + return existing.any((stored) { + return stored.cookie.secure && + stored.cookie.name == cookie.cookie.name && + _domainsOverlap(stored, cookie) && + _pathMatches(cookie.path, stored.path); + }); + } + + bool _domainsOverlap(StoredCookie a, StoredCookie b) { + if (a.hostOnly && b.hostOnly) { + return a.domain == b.domain; + } + if (a.hostOnly) { + return _domainMatches(a.domain, b.domain); + } + if (b.hostOnly) { + return _domainMatches(b.domain, a.domain); + } + + return _domainMatches(a.domain, b.domain) || + _domainMatches(b.domain, a.domain); + } + + bool _domainMatches(String host, String domain) { + return host == domain || host.endsWith('.$domain'); + } + + bool _pathMatches(String requestPath, String cookiePath) { + if (requestPath == cookiePath) { + return true; + } + if (!requestPath.startsWith(cookiePath)) { + return false; + } + if (cookiePath.endsWith('/')) { + return true; + } + + return requestPath[cookiePath.length] == '/'; + } +} diff --git a/lib/src/cookie_key.dart b/lib/src/cookie_key.dart new file mode 100644 index 0000000..81732e9 --- /dev/null +++ b/lib/src/cookie_key.dart @@ -0,0 +1,48 @@ +import 'stored_cookie.dart'; + +/// Identity used to replace and delete cookies in a store. +final class CookieKey { + const CookieKey({ + required this.name, + required this.domain, + required this.path, + required this.hostOnly, + }); + + /// Cookie name. + final String name; + + /// Normalized domain used for matching. + final String domain; + + /// Effective path used for matching. + final String path; + + /// Whether the cookie is scoped to one host. + final bool hostOnly; + + /// Creates a key for [cookie]. + factory CookieKey.fromStoredCookie(StoredCookie cookie) { + return CookieKey( + name: cookie.cookie.name, + domain: cookie.domain, + path: cookie.path, + hostOnly: cookie.hostOnly, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CookieKey && + name == other.name && + domain == other.domain && + path == other.path && + hostOnly == other.hostOnly; + + @override + int get hashCode => Object.hash(name, domain, path, hostOnly); + + @override + String toString() => '$name;$domain;$path;$hostOnly'; +} diff --git a/lib/src/stored_cookie.dart b/lib/src/stored_cookie.dart index d0024b8..98d3a2b 100644 --- a/lib/src/stored_cookie.dart +++ b/lib/src/stored_cookie.dart @@ -7,12 +7,14 @@ import 'types.dart'; /// user-agent state needed to decide whether that cookie should be sent with a /// later request. final class StoredCookie { - const StoredCookie._({ + const StoredCookie({ required this.cookie, required this.domain, required this.path, required this.hostOnly, required this.expiresAt, + required this.creationTime, + required this.lastAccessTime, }); /// The parsed cookie with normalized Domain and Path attributes. @@ -32,6 +34,12 @@ final class StoredCookie { /// A null value represents a session cookie. final DateTime? expiresAt; + /// When this cookie was first created in the store. + final DateTime creationTime; + + /// When this cookie was last selected for a request. + final DateTime lastAccessTime; + /// Parses and normalizes a Set-Cookie value received for [requestUri]. factory StoredCookie.fromSetCookie( String setCookie, { @@ -52,6 +60,7 @@ final class StoredCookie { required Uri requestUri, DateTime? now, }) { + final receivedAt = (now ?? DateTime.now()).toUtc(); final requestHost = _requestHost(requestUri); final rawDomain = cookie.domain?.trim(); final hasDomainAttribute = rawDomain != null && rawDomain.isNotEmpty; @@ -75,7 +84,7 @@ final class StoredCookie { } final path = _effectivePath(cookie.path, requestUri.path); - final expiresAt = _expiresAt(cookie, now ?? DateTime.now().toUtc()); + final expiresAt = _expiresAt(cookie, receivedAt); final normalizedCookie = hostOnly ? cookie.copyWith( path: path, @@ -83,12 +92,44 @@ final class StoredCookie { ) : cookie.copyWith(domain: domain, path: path); - return StoredCookie._( + return StoredCookie( cookie: normalizedCookie, domain: domain, path: path, hostOnly: hostOnly, expiresAt: expiresAt, + creationTime: receivedAt, + lastAccessTime: receivedAt, + ); + } + + /// Creates a copy with selected fields replaced. + StoredCookie copyWith({ + Cookie? cookie, + String? domain, + String? path, + bool? hostOnly, + DateTime? expiresAt, + bool clearExpiresAt = false, + DateTime? creationTime, + DateTime? lastAccessTime, + }) { + if (clearExpiresAt && expiresAt != null) { + throw ArgumentError.value( + expiresAt, + 'expiresAt', + 'cannot set and clear expiresAt', + ); + } + + return StoredCookie( + cookie: cookie ?? this.cookie, + domain: domain ?? this.domain, + path: path ?? this.path, + hostOnly: hostOnly ?? this.hostOnly, + expiresAt: clearExpiresAt ? null : expiresAt ?? this.expiresAt, + creationTime: creationTime ?? this.creationTime, + lastAccessTime: lastAccessTime ?? this.lastAccessTime, ); } diff --git a/test/cookie_jar_test.dart b/test/cookie_jar_test.dart new file mode 100644 index 0000000..6fe30a0 --- /dev/null +++ b/test/cookie_jar_test.dart @@ -0,0 +1,357 @@ +import 'package:ocookie/ocookie.dart'; +import 'package:test/test.dart'; + +void main() { + group('CookieJar', () { + test('stores cookies and serializes a request header by path length', + () async { + final jar = CookieJar(); + final now = DateTime.utc(2026, 1, 1, 0, 0); + + await jar.save( + Uri.parse('https://example.com/login'), + [ + 'sid=root; Path=/; HttpOnly', + 'sid=app; Path=/app; HttpOnly', + ], + now: now, + ); + + expect( + await jar.header( + Uri.parse('https://example.com/app/page'), + now: now, + ), + 'sid=app; sid=root', + ); + expect( + await jar.load(Uri.parse('https://example.com/app/page'), now: now), + hasLength(2), + ); + }); + + test('replaces cookies by name, domain, and path', () async { + final jar = CookieJar(); + final uri = Uri.parse('https://example.com/login'); + final first = DateTime.utc(2026, 1, 1, 0, 0); + final second = first.add(const Duration(minutes: 1)); + + await jar.save(uri, ['sid=old; Path=/'], now: first); + await jar.save(uri, ['sid=new; Path=/'], now: second); + + final cookies = await jar.loadStored(uri, now: second); + expect(cookies, hasLength(1)); + expect(cookies.single.cookie.value, 'new'); + expect(cookies.single.creationTime, first); + expect(cookies.single.lastAccessTime, second); + }); + + test('preserves duplicate names with different paths', () async { + final jar = CookieJar(); + final now = DateTime.utc(2026, 1, 1, 0, 0); + + await jar.save( + Uri.parse('https://example.com/'), + ['sid=root; Path=/'], + now: now, + ); + await jar.save( + Uri.parse('https://example.com/app/login'), + ['sid=app; Path=/app'], + now: now, + ); + + expect( + await jar.header( + Uri.parse('https://example.com/app/page'), + now: now, + ), + 'sid=app; sid=root', + ); + }); + + test('preserves host-only and domain cookies with same name and path', + () async { + final jar = CookieJar(); + final now = DateTime.utc(2026, 1, 1, 0, 0); + final later = now.add(const Duration(seconds: 1)); + final latest = now.add(const Duration(seconds: 2)); + + await jar.save( + Uri.parse('https://example.com/login'), + ['sid=host; Path=/'], + now: now, + ); + await jar.save( + Uri.parse('https://api.example.com/login'), + ['sid=domain; Domain=example.com; Path=/'], + now: later, + ); + + expect( + await jar.header(Uri.parse('https://example.com/home'), now: latest), + 'sid=host; sid=domain', + ); + expect( + await jar.header(Uri.parse('https://sub.example.com/home'), + now: latest), + 'sid=domain', + ); + + await jar.save( + Uri.parse('https://api.example.com/login'), + ['sid=gone; Domain=example.com; Max-Age=0; Path=/'], + now: latest, + ); + + expect( + await jar.header( + Uri.parse('https://example.com/home'), + now: latest.add(const Duration(seconds: 1)), + ), + 'sid=host', + ); + }); + + test('enforces host-only, domain, and secure matching', () async { + final jar = CookieJar(); + final now = DateTime.utc(2026, 1, 1, 0, 0); + final later = now.add(const Duration(seconds: 1)); + final latest = now.add(const Duration(seconds: 2)); + + await jar.save( + Uri.parse('https://example.com/login'), + ['host=1; Path=/'], + now: now, + ); + await jar.save( + Uri.parse('https://api.example.com/login'), + ['wide=1; Domain=example.com; Path=/'], + now: later, + ); + await jar.save( + Uri.parse('https://api.example.com/login'), + ['secure=1; Secure; Path=/'], + now: latest, + ); + + expect( + await jar.header(Uri.parse('https://example.com/home'), now: latest), + 'host=1; wide=1', + ); + expect( + await jar.header(Uri.parse('https://sub.example.com/home'), + now: latest), + 'wide=1', + ); + expect( + await jar.header(Uri.parse('http://api.example.com/home'), now: latest), + 'wide=1', + ); + expect( + await jar.header(Uri.parse('https://api.example.com/home'), + now: latest), + 'wide=1; secure=1', + ); + }); + + test('evicts expired cookies and expired replacements', () async { + final jar = CookieJar(); + final uri = Uri.parse('https://example.com/login'); + final now = DateTime.utc(2026, 1, 1, 0, 0); + + await jar.save(uri, ['sid=short; Max-Age=1; Path=/'], now: now); + expect(await jar.header(uri, now: now), 'sid=short'); + expect( + await jar.header( + uri, + now: now.add(const Duration(seconds: 1)), + ), + isNull, + ); + + await jar.save(uri, ['sid=live; Path=/'], now: now); + await jar.save( + uri, + ['sid=gone; Max-Age=0; Path=/'], + now: now.add(const Duration(seconds: 2)), + ); + + expect( + await jar.header( + uri, + now: now.add(const Duration(seconds: 2)), + ), + isNull, + ); + }); + + test('serializes only name and value in request headers', () async { + final jar = CookieJar(); + final uri = Uri.parse('https://example.com/login'); + final now = DateTime.utc(2026, 1, 1, 0, 0); + + await jar.save( + uri, + ['sid=a b; Path=/; HttpOnly; Secure; SameSite=None'], + now: now, + ); + + expect(await jar.header(uri, now: now), 'sid=a%20b'); + }); + + test('rejects insecure overwrites of existing secure cookies', () async { + final jar = CookieJar(); + final secureUri = Uri.parse('https://example.com/login'); + final insecureUri = Uri.parse('http://example.com/login'); + final now = DateTime.utc(2026, 1, 1, 0, 0); + + await jar.save( + secureUri, + ['sid=secret; Secure; Path=/'], + now: now, + ); + await jar.save( + insecureUri, + ['sid=attacker; Path=/'], + now: now.add(const Duration(seconds: 1)), + ); + + expect( + await jar.header( + secureUri, + now: now.add(const Duration(seconds: 2)), + ), + 'sid=secret', + ); + expect( + await jar.header( + insecureUri, + now: now.add(const Duration(seconds: 2)), + ), + isNull, + ); + + await jar.save( + insecureUri, + ['sid=gone; Max-Age=0; Path=/'], + now: now.add(const Duration(seconds: 3)), + ); + + expect( + await jar.header( + secureUri, + now: now.add(const Duration(seconds: 4)), + ), + 'sid=secret', + ); + }); + + test('rejects insecure path overlays of existing secure cookies', () async { + final jar = CookieJar(); + final now = DateTime.utc(2026, 1, 1, 0, 0); + + await jar.save( + Uri.parse('https://example.com/login'), + ['sid=secret; Secure; Path=/login'], + now: now, + ); + await jar.save( + Uri.parse('http://example.com/login/en'), + ['sid=attacker; Path=/login/en'], + now: now.add(const Duration(seconds: 1)), + ); + + expect( + await jar.header( + Uri.parse('https://example.com/login/en'), + now: now.add(const Duration(seconds: 2)), + ), + 'sid=secret', + ); + }); + + test('supports parsed cookies as input', () async { + final jar = CookieJar(); + final uri = Uri.parse('https://example.com/login'); + final now = DateTime.utc(2026, 1, 1, 0, 0); + + await jar.saveCookies( + uri, + const [Cookie('sid', 'abc', path: '/')], + now: now, + ); + + expect(await jar.header(uri, now: now), 'sid=abc'); + }); + + test('uses the pluggable store boundary', () async { + final store = _RecordingCookieStore(); + final jar = CookieJar(store: store); + final uri = Uri.parse('https://example.com/login'); + final now = DateTime.utc(2026, 1, 1, 0, 0); + + await jar.save(uri, ['sid=abc; Path=/'], now: now); + + expect(store.upserts, hasLength(1)); + expect(store.upserts.single.cookie.name, 'sid'); + expect(await jar.header(uri, now: now), 'sid=abc'); + expect(store.loadCalls, greaterThanOrEqualTo(2)); + }); + }); + + group('CookiePolicy', () { + test('normalizes, matches, sorts, and serializes stored cookies', () { + const policy = CookiePolicy(); + final now = DateTime.utc(2026, 1, 1, 0, 0); + final root = policy.normalizeSetCookie( + 'sid=root; Path=/', + requestUri: Uri.parse('https://example.com/login'), + now: now, + ); + final app = policy.normalizeSetCookie( + 'sid=app; Path=/app', + requestUri: Uri.parse('https://example.com/app/login'), + now: now.add(const Duration(seconds: 1)), + ); + + expect( + policy.matches( + app, + Uri.parse('https://example.com/app/page'), + now, + ), + isTrue, + ); + expect(policy.toRequestHeaderValue([root, app]), 'sid=app; sid=root'); + }); + }); +} + +final class _RecordingCookieStore implements CookieStore { + final Map _cookies = {}; + final List upserts = []; + int loadCalls = 0; + + @override + Future clear() async { + _cookies.clear(); + } + + @override + Future delete(CookieKey key) async { + _cookies.remove(key); + } + + @override + Future> loadCandidates(Uri uri) async { + loadCalls += 1; + return _cookies.values.toList(growable: false); + } + + @override + Future upsert(StoredCookie cookie) async { + upserts.add(cookie); + _cookies[CookieKey.fromStoredCookie(cookie)] = cookie; + } +} diff --git a/test/stored_cookie_test.dart b/test/stored_cookie_test.dart index b672210..79b4cbc 100644 --- a/test/stored_cookie_test.dart +++ b/test/stored_cookie_test.dart @@ -16,6 +16,7 @@ void main() { expect(stored.path, '/'); expect(stored.hostOnly, isTrue); expect(stored.expiresAt, isNull); + expect(stored.creationTime, stored.lastAccessTime); expect(stored.matches(Uri.parse('https://example.com/profile')), isTrue); expect( stored.matches(Uri.parse('https://sub.example.com/profile')), @@ -205,5 +206,32 @@ void main() { expect(stored.toRequestCookie(), 'sid=a%20b'); }); + + test('copies stored metadata', () { + final now = DateTime.utc(2026, 1, 1, 0, 0); + final stored = StoredCookie.fromSetCookie( + 'sid=abc; Max-Age=60', + requestUri: Uri.parse('https://example.com/login'), + now: now, + ); + final accessed = now.add(const Duration(seconds: 10)); + + final copy = stored.copyWith( + expiresAt: now.add(const Duration(minutes: 2)), + lastAccessTime: accessed, + ); + expect(copy.creationTime, now); + expect(copy.lastAccessTime, accessed); + expect(copy.expiresAt, now.add(const Duration(minutes: 2))); + + expect(copy.copyWith(clearExpiresAt: true).expiresAt, isNull); + expect( + () => copy.copyWith( + expiresAt: now, + clearExpiresAt: true, + ), + throwsArgumentError, + ); + }); }); }