Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Sync server

The server in server/ (D-33, D-45, D-60, D-61). Business logic and its interfaces are in server/service and tested with the JVM alone. The Postgres adapter in server/postgres is tested against a real Postgres: a fresh database per test, on a Testcontainers Postgres 17 or on the one JOTRY_TEST_DATABASE_URL names (TestDatabases, in the module’s test fixtures). The Ktor adapter and the Koin wiring in server/src are tested through Ktor’s test host. The development verifier in server/dev has its own tests. Store implementations pass a shared contract (GroupStoreContract, RecordStoreContract, AuthStoreContract and PromoStoreContract, in server/service’s test fixtures), run once against the in-memory store and once against Postgres. The API classes in :syncApi are tested on the JVM, the iOS simulator and an Android emulator. Right now the behavior is the health check, the start-up migration, groups with their entitlements, created from a verified purchase (D-70) or a promo code (D-72), records with the conditional put and the change feed (D-65), the sync routes over them (K1), device auth with QR enrollment and recovery (epic 4, D-67, D-68), and the deletion of groups a year after they lapsed (D-56); the store verifiers and the reverse QR flow are not built (see the cloud sync epics in pending.md).

BehaviorServicePostgres adapterKtor adapter and wiring
With no probes the server reports okHealthServiceTest.withNoProbesTheServerIsOk
All passing probes report ok, with each probe’s resultHealthServiceTest.allPassingProbesAreOkApplicationTest.healthIsOkWhenEveryProbePasses
One failing probe makes the report not okHealthServiceTest.oneFailingProbeMakesTheReportNotOkApplicationTest.healthIs503WhenAProbeFails
A probe that throws counts as failingHealthServiceTest.aProbeThatThrowsCountsAsFailing
The database probe passes while Postgres answersPostgresDatabaseTest.theHealthProbePassesWhilePostgresAnswersWiringTest.healthReportsTheDatabase
The health report fails once the database is gonePostgresDatabaseTest.theHealthReportFailsOnceTheDatabaseIsGoneWiringTest.healthReportsTheDatabase (503)
Migrations apply to an empty database, none failedPostgresDatabaseTest.migrateAppliesEveryMigrationToAnEmptyDatabase
Migrating again runs nothingPostgresDatabaseTest.migratingAgainRunsNothing
Starting the server migrates the databaseWiringTest.startingTheServerMigratesTheDatabase
Connection settings come from DATABASE_*; a missing or blank one is named; the password stays out of toStringPostgresSettingsTest (3 tests)
/ answers with the template greetingApplicationTest.testRoot

Sync routes (K1: D-63, D-65; behind device tokens since epic 4)

SyncRoutesTest runs the routes in Ktor’s test host over the in-memory stores and a test clock; WiringTest.deviceAuthAndRecordsWorkThroughTheProductionWiringOnPostgres runs them through the production wiring on Postgres.

BehaviorTest
Without the development verifier (an ordinary build) a development purchase, or one from a store with no verifier, is 422 unsupported-sourceSyncRoutesTest.withoutTheDevelopmentVerifierADevelopmentPurchaseIsRefused
A proof the verifier rejects is 422 invalid-purchase; one bought for another group is 422 purchase-for-another-groupSyncRoutesTest.aGroupNeedsAPurchaseMadeForIt
A group is created once (201); the same id again is 409 group-existsAuthRoutesTest.aGroupNeedsItsFirstDeviceToProveItsKey
A put is conditional on the seq last seen: 200 with the new seq, 409 with the current oneSyncRoutesTest.aPutIsConditionalOnTheSeqLastSeen
Changes come in seq order with kind, note, author device, server time and envelope, as CBORSyncRoutesTest.changesReturnTheRecordsInSeqOrderWithTheirMetadata
Changes come in pages of limit, with hasMore and the next cursorSyncRoutesTest.changesComeInPages
A delete is a tombstone in the changesSyncRoutesTest.aDeleteIsATombstoneInTheChanges
A write that breaks a rule is 422 with the refusal as its codeSyncRoutesTest.aWriteThatBreaksARuleIs422WithItsReason
A record is attributed to the signed-in deviceSyncRoutesTest.changesComeInSeqOrderWrittenByTheSignedInDevice
A lapsed group is 403 read-only for put and still pulls; an expired one cannot get a session (410)SyncRoutesTest.aLapsedGroupIsReadOnlyThenExpired
A body that is not CBOR is 415; bad CBOR, a bad UUID or a negative cursor is 400SyncRoutesTest.malformedRequestsAreRefused
Group, sign-in, put, changes and device removal work through the production wiring on PostgresWiringTest.deviceAuthAndRecordsWorkThroughTheProductionWiringOnPostgres

Groups and entitlements (D-52 to D-56)

BehaviorServicePostgres adapter
An active entitlement makes the group entitled until it endsEntitlementPolicyTest.anActiveEntitlementMakesTheGroupEntitledUntilItEnds
The latest end among several entitlements wins (D-52)EntitlementPolicyTest.theLatestEndAmongSeveralEntitlementsWins
A revoked (refunded) entitlement ends when it was revokedEntitlementPolicyTest.aRevokedEntitlementEndsWhenItWasRevoked
Once all have ended the group is read-only for a year, then expired (D-56)EntitlementPolicyTest.onceEveryEntitlementHasEndedTheGroupIsReadOnlyForAYear
A year after a leap day ends on 28 FebruaryEntitlementPolicyTest.aYearAfterALeapDayEndsOnTheLastDayOfFebruary
A group with no entitlements counts as lapsed since it was createdEntitlementPolicyTest.aGroupWithNoEntitlementsCountsAsLapsedSinceItWasCreated
Only an entitled group may push; an expired one may not pullEntitlementPolicyTest.onlyAnEntitledGroupMayPushAndAnExpiredOneMayNotPull
The access service applies the policy to a stored group at the clock’s timeEntitlementPolicyTest.theServiceAppliesThePolicyToAStoredGroupAtTheClocksTime
A created group is found by id and by recovery lookup id, with its recovery fields and first entitlementInMemoryGroupStoreTest (contract)PostgresGroupStoreTest (contract)
Unknown groups are not foundcontract: anUnknownGroupIsNotFoundsame
Creating with an already bound transaction creates nothing (D-53, D-54)contract: creatingWithATransactionThatIsAlreadyBoundCreatesNothingsame
A group holds entitlements from several stores, oldest binding firstcontract: aGroupCanHoldEntitlementsFromSeveralStoressame
A transaction belongs to one group only (D-54)contract: aTransactionBelongsToOneGroupOnlysame
The same transaction id from another store is a different transactioncontract: theSameTransactionIdFromAnotherStoreIsADifferentTransactionsame
An entitlement for an unknown group is refusedcontract: anEntitlementForAnUnknownGroupIsRefusedsame
Two creates racing for one transaction bind it oncecontract: twoGroupsCreatedAtOnceWithTheSameTransactionBindItOncesame (the loser hits the unique key and its retry finds the winner)
A group is deleted a year after its last entitlement ended (a refund ends it early; a later entitlement keeps it), exactly when the policy says, across a leap day (D-56)contract: aGroupIsDeletedAYearAfterItsLastEntitlementEnded, deletionAgreesWithThePolicyAcrossALeapDaysame (one DELETE; the foreign keys take records, devices, tokens and enrollments with it)
A store merges a transaction the group holds: the expiry only moves later, a refund keeps its earliest time, and only the holding group can merge it (D-71)contract: aRenewalMovesTheExpiryLaterButNeverEarlier, aRevocationStaysAtItsEarliestTime, onlyTheGroupThatHoldsATransactionCanMergeItsame (one UPDATE with GREATEST and LEAST)
The maintenance job deletes a group only once it is dueMaintenanceTest.aGroupIsDeletedAYearAfterItLapsed

Creating a group and adding purchases (D-54, D-58, D-70, D-71)

BehaviorService (GroupCreationTest)Routes and builds
A purchase gives the new group its entitlement (source, transaction, end), and the group its first deviceaGroupIsEntitledByItsPurchaseAndHasItsFirstDeviceAuthRoutesTest.aGroupNeedsItsFirstDeviceToProveItsKey
A source without a verifier is refused, including development purchases on an ordinary buildaSourceWithoutAVerifierIsNotAcceptedSyncRoutesTest.withoutTheDevelopmentVerifierADevelopmentPurchaseIsRefused
A proof the store rejects creates nothingaProofTheStoreRejectsCreatesNothingSyncRoutesTest.aGroupNeedsAPurchaseMadeForIt
The purchase’s account token must be the new group’s id (D-54)thePurchaseMustNameTheNewGroupSyncRoutesTest.aGroupNeedsAPurchaseMadeForIt
An expired or refunded purchase creates nothinganEndedPurchaseCreatesNothing
A purchase creates one group onlyaPurchaseCreatesOneGroupOnly
One verifier per sourcetwoVerifiersForOneSourceAreAMistake
The development verifier takes a group id as a one-year purchase for that group, and nothing elseDevelopmentVerifierTest (2 tests)
The development verifier is found on the classpathDevelopmentVerifierTest.theServiceLoaderFindsIt; WiringTest.deviceAuthAndRecordsWorkThroughTheProductionWiringOnPostgres (production wiring, test classpath)
A signed-in device adds a purchase from another store; a renewal or refund updates the row the group holds; a lapsed group reattaches; a stale proof changes nothingPurchasesTest (5 tests)SyncRoutesTest.aSignedInDeviceAddsAPurchaseOrUpdatesOneTheGroupHolds (200 with added and the new end)
An added purchase must be for the signed-in group, not another group’s transaction, supported, valid and not endedPurchasesTest.thePurchaseMustBeForTheSignedInGroup, aTransactionOfAnotherGroupIsRefused, unsupportedInvalidAndEndedPurchasesAddNothingSyncRoutesTest.aSignedInDeviceAddsAPurchaseOrUpdatesOneTheGroupHolds (422, 401)
An ordinary build does not contain server/devGradle task :server:checkNoDevelopmentVerifier (part of check, installDist, distZip, distTar)

Promo codes (D-72)

BehaviorServiceStores (PromoStoreContract: InMemoryPromoStoreTest, PostgresPromoStoreTest)Routes and tool
A code is JOTRY- and two groups of five Crockford base-32 characters; reading one back forgives case, spacing, a missing prefix and O, I, L for 0, 1, 1; anything else is not a codePromoCodesTest (3 tests)SyncRoutesTest.aPromoCodeCreatesAGroupOnce (sent in lower case)
A code is stored as its hash with its days, uses, deadline and note, listed newest firstanAddedCodeIsListedAsItWasAddedNewestFirstPromoToolTest.createPrintsCodesWhoseHashesAreStored
A redemption lasts the code’s days from when it was redeemed and uses one use; an unknown code is refusedPromoVerifierTest.aCodeIsAPurchaseForTheGroupRedeemingIt, anUnknownOrMalformedCodeIsInvalidaRedemptionLastsTheCodesDaysFromWhenItWasRedeemedAndUsesOneUse, anUnknownCodeIsRefused
Redeeming again for the same group answers the first redemption and uses nothing, even after the code is revokedPromoVerifierTest.aCodeAddedToAGroupExtendsItOnceHoweverOftenItIsSentredeemingAgainForTheSameGroupAnswersTheFirstRedemptionAndUsesNothing, aRevokedCodeIsRefusedButGroupsThatRedeemedItKeepWhatTheyGot
A code serves as many groups as it has uses, each with its own rowPromoVerifierTest.oneCodeWithSeveralUsesCreatesSeveralGroupsaCodeServesAsManyGroupsAsItHasUsesSyncRoutesTest.aPromoCodeCreatesAGroupOnce (a used-up code is 422 invalid-purchase)
A code cannot be redeemed from its deadline; --redeem-by covers that whole day in UTCaCodeCannotBeRedeemedFromItsDeadlinePromoToolTest.aDeadlineCoversTheWholeDayInUtc
A revoked code is refused; revoking twice keeps the first timeaRevokedCodeIsRefusedButGroupsThatRedeemedItKeepWhatTheyGot, revokingTwiceKeepsTheFirstTimePromoToolTest.revokeStopsACode
Many groups racing for a code’s last use leave one winner; a group racing itself uses one usemanyGroupsRacingForTheLastUseLeaveExactlyOneWinner, oneGroupRacingItselfUsesOneUse(Postgres: the code’s row is locked FOR UPDATE)
A code creates a group, or is added to one through the purchases routePromoVerifierTest.aCodeCreatesAGroup, aCodeAddedToAGroupExtendsItOnceHoweverOftenItIsSentSyncRoutesTest.aPromoCodeCreatesAGroupOnce (on a build without the development verifier)
The tool lists uses and state, and refuses bad arguments with its usage, creating nothingPromoToolTest.listShowsUsesAndState, badArgumentsPrintUsageAndCreateNothing

Records (D-65)

BehaviorService (RecordServiceTest)Stores (RecordStoreContract: InMemoryRecordStoreTest, PostgresRecordStoreTest)
A new record gets the group’s next seqaNewRecordIsStoredWithTheGroupsNextSeqaPutStoresTheRecordWithTheGroupsNextSeq (with every stored field)
A stale seq is a conflict naming the current seq; 0 means “create”aStaleSeqIsAConflictThatNamesTheCurrentSeqaPutWithAStaleSeqIsRefusedWithTheCurrentSeq, aPutToAMissingRecordThatExpectsOneIsAMismatch
Each group counts on its owneachGroupCountsOnItsOwn
Only a notebook has no note idonlyANotebookHasNoNoteId(also a CHECK constraint in V3__records.sql)
A record keeps its kind and its noteaRecordKeepsItsKindAndItsNote
A revision is written once and afterwards can only be deletedaRevisionIsWrittenOnceAndAfterwardsCanOnlyBeDeleted
A deleted notebook can be written againaDeletedNotebookCanBeWrittenAgain
A delete needs a record that is not deleted yetaDeleteNeedsARecordThatIsNotDeletedYet
A tombstone has no envelope and is in the feedaTombstoneHasNoEnvelopeAndIsInTheFeed
A negative seq is refusedaNegativeSeqIsRefused
An unknown group is reported, for put and changesanUnknownGroupIsReported
A lapsed group may pull but not push; an expired one may not pull (D-56)aLapsedGroupMayPullButNotPush, anExpiredGroupMayNotPull
The feed lists each record once at its latest seq, in order, after the cursortheFeedListsEachRecordOnceAtItsLatestSeqInOrder
The feed pages, says when more is waiting, and keeps the cursor when emptytheFeedReturnsPagesAndSaysWhenMoreIsWaiting, anEmptyPageKeepsTheCursor
Of concurrent puts to one record exactly one winsofConcurrentPutsToOneRecordExactlyOneWins
Concurrent puts in one group get every seq onceconcurrentPutsInOneGroupGetEverySeqOnce

Auth, devices, enrollment and recovery (epic 4: D-55, D-67, D-68)

BehaviorServiceStores (AuthStoreContract: InMemoryAuthStoreTest, PostgresAuthStoreTest)Routes (AuthRoutesTest, SyncRoutesTest)
A signed challenge buys a 15-minute token; it expires on timeAuthServiceTest.aSignedChallengeBuysATokenForFifteenMinutesaTokenGivesASessionUntilItExpiresSyncRoutesTest.recordRoutesNeedAValidTokenForTheirGroup
Only the token’s SHA-256 is storedAuthServiceTest.onlyTheTokensHashIsStored
A challenge is used once, even after a wrong signature; replays are refusedAuthServiceTest.aChallengeIsUsedOnce, aWrongSignatureIsRefusedAndBurnsTheChallengeaChallengeIsTakenOnce, ofConcurrentTakesOfOneChallengeExactlyOneWinsAuthRoutesTest.aChallengeIsUsedOnceAndOnlyBySignatureOfTheDeviceItWasIssuedTo
A challenge expires after a minute and serves only the device (and purpose) it was issued forAuthServiceTest.aChallengeExpiresAfterAMinute, aChallengeServesOnlyTheDeviceItWasIssuedFor; RecoveryServiceTest.anAuthChallengeDoesNotServeRecovery
Unknown devices and groups get no challenge; a lapsed group signs in, an expired one does notAuthServiceTest.unknownDevicesAndGroupsGetNoChallenge, aLapsedGroupStillSignsInAndAnExpiredOneDoesNotAuthRoutesTest.anUnknownDeviceGetsNoChallenge, SyncRoutesTest.aLapsedGroupIsReadOnlyThenExpired
Removing a device ends its sessions at once and it cannot sign in againAuthServiceTest.aRemovedDeviceLosesItsSessionAtOnceAndCannotSignInAgainrevokingADeviceEndsItsSessionsAtOnceAuthRoutesTest.aRemovedDeviceLosesItsTokenAtOnceAndCannotSignInAgain, WiringTest.deviceAuthAndRecordsWorkThroughTheProductionWiringOnPostgres
A device can remove itself but not another group’s deviceAuthServiceTest.aDeviceMayRemoveItselfButNotAnotherGroupsDeviceAuthRoutesTest.aDeviceCannotRemoveADeviceOfAnotherGroup
A member names any active device of its group, not another group’s or a removed one; a sealed name is 1 to 512 bytes; signing in records when a device was last seen, and the list returns both (D-80)AuthServiceTest.aMemberNamesAnyActiveDeviceOfItsGroupOnly, signingInRecordsWhenTheDeviceWasLastSeenaDeviceKeepsItsSealedNameAndWhenItWasLastSeenAuthRoutesTest.aMemberNamesADeviceAndTheListShowsTheNameAndWhenItWasLastSeen; PostgresDatabaseTest.theRootKeyRotationMigrationKeepsRowsFromBeforeIt (a device from before V8 has no name)
No group-data endpoint accepts an unauthenticated request; a token for another group is refusedAuthRoutesTest.noEndpointAcceptsAnUnauthenticatedRequestForGroupData, SyncRoutesTest.recordRoutesNeedAValidTokenForTheirGroup
A device is stored with every field; its id is used once across groups; a group lists its own devices oldest firstaDeviceIsStoredWithEveryField, aDeviceIdIsUsedOnceAcrossGroups, aGroupListsItsOwnDevicesOldestFirst
The first device proves its key when a group is created; ids are used onceGroupCreationTest.theFirstDeviceMustSignWithTheKeyItRegisters, aGroupIdLookupIdOrDeviceIdIsUsedOnceAuthRoutesTest.aGroupNeedsItsFirstDeviceToProveItsKey
A QR enrollment: start, member checks the keys, wrap and approve, new device collects once and signs inEnrollmentServiceTest.aMemberAddsANewDeviceThatCollectsTheRootKeyOnceanEnrollmentIsCreatedOnceAndFound, approvingAddsTheDeviceOnceAuthRoutesTest.aNewDeviceJoinsByQrCodeAndReadsTheRootKey (real DeviceEnrollment wrap)
An enrollment expires after 10 minutesEnrollmentServiceTest.anEnrollmentExpiresAfterTenMinutesAuthRoutesTest.anEnrollmentExpiresAfterTenMinutes
The approval must be signed by the approving member for the scanned keys, onceEnrollmentServiceTest.theApprovalMustBeSignedByTheApprovingMember, anEnrollmentIsApprovedOnceapprovingWithATakenDeviceIdChangesNothingAuthRoutesTest.theApprovalMustComeFromAMemberAndNameTheScannedKeys
Taken ids and malformed keys are refusedEnrollmentServiceTest.takenIdsAndMalformedKeysAreRefused, aDeviceIdTakenBeforeApprovalIsReportedAuthRoutesTest.anEnrollmentCannotTakeAnExistingDeviceIdOrMalformedKeys
The recovery key adds a device and returns the root key copy, which unwraps with the typed wordsRecoveryServiceTest.theRecoveryKeyAddsADeviceAndReturnsTheRootKeyCopyAuthRoutesTest.theRecoveryKeyAddsADeviceAndUnwrapsTheRootKey (real RecoveryKey)
Recovery refuses another key, a reused challenge and an unknown group; works while lapsed, not once expiredRecoveryServiceTest (4 more tests)AuthRoutesTest.recoveryRefusesAnotherKeyAndAnUnknownGroup
The server’s statements are the same bytes as the clients’AuthRoutesTest.theStatementsOfTheServerAndTheClientsAreTheSameBytes

Rate limits and maintenance (D-69)

BehaviorServiceStoresRoutes (RateLimitRoutesTest)
A key may burst up to the capacity, then gets 429 with how long to wait; calls come back evenlyRateLimitTest.aKeyMayBurstUpToTheCapacityThenIsLimited, callsComeBackEvenlyOverTheRefillTime, retryAfterIsAtLeastOneWholeSecondtheCallAfterTheCapacityGets429WithRetryAfterAndACode
Keys, limits and policies are counted separatelyRateLimitTest.keysAndLimitsAreIndependent, policiesCountPerPolicyAndKeytheTokenEndpointSharesTheAuthBucketAndOtherEndpointsKeepTheirOwn
Of many threads at once exactly the capacity get throughRateLimitTest.ofManyThreadsAtOnceExactlyTheCapacityGetThrough
Idle buckets are dropped and busy ones kept; a full table refuses new keys until room is madeRateLimitTest.idleBucketsAreDroppedAndBusyOnesKept, aFullTableRefusesNewKeysUntilIdleOnesAreDropped
A limit must allow a call and refill over timeRateLimitTest.aLimitMustAllowACallAndRefillOverTime
Enrollments, recovery (per address and per group) and group creation are limitedenrollmentsMayBeStartedTenTimesAnHour, recoveryIsLimitedPerAddressAndPerGroup, theGroupCreationRouteIsLimitedToo
Without a trusted proxy X-Forwarded-For is ignored; behind one each client address has its bucket and only the last entry countswithoutATrustedProxyTheForwardedHeaderIsIgnored, behindATrustedProxyEachClientAddressHasItsOwnBucket, aCallerCannotChooseItsBucketByPrependingToTheForwardedHeader
Signed-in sync traffic is not limitedtheSyncTrafficOfSignedInDevicesIsNotLimited
Expired challenges, tokens and enrollments are removed (an expiry at or before now counts), live ones kept; an approved uncollected enrollment goes too; devices stayMaintenanceTest (4 tests)AuthStoreContract.deletingExpiredRemovesOnlyWhatHasExpired, deletingExpiredAlsoRemovesAnApprovedEnrollmentWithItsWrappedKey
The job runs while the server runs, only when an interval is given, and its setting is validatedtheMaintenanceJobFreesExpiredRowsWhileTheServerRuns, noMaintenanceJobRunsUnlessAnIntervalIsGiven, theMaintenanceIntervalComesFromTheEnvironmentValue

Root key rotation (D-64, D-74, D-75)

BehaviorService (RootKeyServiceTest)Stores (GroupStoreContract, AuthStoreContract)Routes (RootKeyRoutesTest)
A new group is at root key 1 with nothing to do; the recovery key’s public key is reportedaNewGroupIsAtRootKeyOneWithNothingToDoaCreatedGroupIsFoundByIdAndByRecoveryLookupId
Removing a device makes a rotation due until one happens; a later removal makes another dueremovingADeviceMakesARotationNeededUntilOneHappensaRemovedDevicesGroupRotatesItsRootKeyAndEveryoneCanOpenTheNewOne
A rotation moves the id on, replaces the recovery copy and leaves the recovery lookup id, keys and recovery public key aloneaRotationMovesTheIdOnAndReplacesTheRecoveryCopyrotatingTheRootKeyMovesTheIdOnAndReplacesTheRecoveryCopy
Only the next id can be rotated to; of two rotations at once one winsonlyTheNextIdCanBeRotatedToOrSentaRotationToAnyIdButTheNextChangesNothing, twoRotationsToTheSameIdAtOnceHaveOneWinneronlyTheNextRootKeyIdCanBeRotatedToOrSent
A rotation needs an entitled group and a copy of sensible sizeaRotationNeedsAnEntitledGroupAndASensibleCopy, aLapsedGroupCannotHandOutKeysaLapsedGroupCannotRotate, onlyTheNextRootKeyIdCanBeRotatedToOrSent (400)
A device not given the new key is listed as behind until a member seals it; the device collects its copy with its own keyaDeviceNotGivenTheNewKeyIsListedAsBehindUntilAMemberSendsIt, theCallerIsNeverListedAsBehindaRootKeySealedToADeviceIsKeptUntilReplacedAndRaisesItsId, recordingThatADeviceHoldsTheKeyKeepsNothingForItaRemovedDevicesGroupRotatesItsRootKeyAndEveryoneCanOpenTheNewOne (real keys)
A copy for an older key is not handed out as the current one; the wrong id, an unknown or removed device and another group’s device are refusedaCopyForAnOlderKeyIsNotHandedOutAsTheCurrentOne, aCopyCannotBeStoredForTheWrongKeyDeviceOrGroupnoRootKeyIsKeptForARemovedOrUnknownDeviceonlyTheNextRootKeyIdCanBeRotatedToOrSent
A device enrolled with an older key during a rotation is behind; one with no encryption key (from before D-74) is never listedanEnrollmentApprovedWithAnOlderKeyLeavesTheDeviceBehind, aDeviceWithoutAnEncryptionKeyCannotBeSentOneaDeviceEnrolledWithTheOldKeyDuringARotationIsListedUntilSent
The words open the server’s copy of the new key, and a recovered device is given the current onetheRecoveryKeyAddsADeviceAndReturnsTheRootKeyCopy (RecoveryServiceTest)aRemovedDevicesGroupRotatesItsRootKeyAndEveryoneCanOpenTheNewOne
The three routes need a session for their grouprootKeyRoutesNeedASessionForTheGroup
A group needs 32-byte device and recovery encryption keys; the first device’s are stored and it holds root key 1aGroupCreatedWithoutValidKeysIsRefusedaDeviceIsStoredWithEveryFieldaGroupCannotBeCreatedWithoutKeysOfTheRightSize
A QR approval stores the scanned encryption key and the root key id; the signature must cover that keyEnrollmentServiceTest.theApprovalMustBeSignedByTheApprovingMemberAuthRoutesTest.theApprovalMustComeFromAMemberAndNameTheScannedKeys, aNewDeviceJoinsByQrCodeAndReadsTheRootKey
Rows from before the migration keep working: devices hold their group’s current key, with no encryption keyPostgresDatabaseTest.theRootKeyRotationMigrationKeepsRowsFromBeforeIt

Wire format (D-63, :syncApi)

BehaviorTest (SyncApiTest: JVM, iOS simulator, Android emulator)
Fields go on the wire as integer labels (exact bytes)fieldsGoOnTheWireAsIntegerLabels
Envelopes are byte strings and absent fields are left out (exact bytes)envelopesAreByteStringsAndAbsentFieldsAreLeftOut
A request survives the round tripaRequestSurvivesTheRoundTrip
The statements devices sign match known answers, differ by purpose, cover the encryption key and reject a bad idSyncStatementsTest (7 tests)
A delete has no envelopeaDeleteHasNoEnvelope
A reader ignores fields it does not knowaReaderIgnoresFieldsItDoesNotKnow
The root key classes round-trip; fields added later default when an older writer leaves them outrootKeyClassesRoundTrip, fieldsAddedLaterDefaultWhenAnOlderWriterLeavesThemOut

The server suite of the Tests workflow (run by hand) runs these tests, then starts server/compose.dev.yaml with a development build of the server in a container and checks /health and that a sync route answers, which covers the Dockerfile and that compose file. It has not run on GitHub yet.

Gaps

  • The promo tool’s main (connecting with DATABASE_*, quiet logging) and the bin/promo start script have no automated test; they were run by hand against a scratch database on 2026-10-10 (create, list, revoke, a usage error).
  • Rotation with the server under load, and a removal racing an enrollment: the single winner of two rotations is tested at the store and by the id check in the service; a device approved while another removes the approver is not.
  • Not built yet, so not tested: the reverse QR flow, the Apple, Google and Microsoft verifiers and store notifications (renewal, expiry, refund), and rebinding a transaction to another group.
  • Deleting an expired group in memory removes only the group and its entitlements: the in-memory stores are separate, so the cascade to records and devices is tested on Postgres only (the contract test checks the group and entitlements).
  • That changes become visible in seq order (a reader never sees seq 5 before seq 4 commits) rests on the counter row lock in PostgresRecordStore; the concurrency tests check unique seqs and one winner, not the order a concurrent reader sees.
  • SyncStatementsTest (7 tests) has run on the JVM, the iOS simulator and an API 28 emulator (2026-10-11) (SyncApiTest has, on API 28 and 37). It uses Kotlin’s Uuid, which is pure Kotlin.
  • Rate limits across containers are per container (D-69); no test runs two server instances.
  • A failing maintenance run (Postgres down) is logged and retried at the next tick; no test makes it fail.
  • Live check: a throwaway Python client (hand-made CBOR, its own Ed25519 keys) ran group creation, sign-in, records, QR enrollment, device removal and recovery against :server:run on Postgres on 2026-10-10, and a burst of 34 calls got 30 answers then 429 with Retry-After: 2; with the interval set to 2 seconds the job deleted a planted expired row. Another on 2026-10-10 (D-70) created a group with a development purchase against an ordinary installDist (422 unsupported-source, no warning logged) and a -Pdevelopment one (201, a one-year dev entitlement, the start-up warning). The clients are not kept in the repository. Since epic 6 the shared JVM tests reach the auth, record, group-creation and enrollment routes over HTTP through KtorDestination and HttpSyncGroup (sync-engine.md).
  • server/compose.test.yaml has no automated check; it differs from the dev file only in its secrets, restarts and published ports.
  • A server that cannot reach Postgres at start stops (the pool fails fast); no test covers it.
  • testRoot tests the template greeting, not product behavior.