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).
| Behavior | Service | Postgres adapter | Ktor adapter and wiring |
|---|---|---|---|
| With no probes the server reports ok | HealthServiceTest.withNoProbesTheServerIsOk | ||
| All passing probes report ok, with each probe’s result | HealthServiceTest.allPassingProbesAreOk | ApplicationTest.healthIsOkWhenEveryProbePasses | |
| One failing probe makes the report not ok | HealthServiceTest.oneFailingProbeMakesTheReportNotOk | ApplicationTest.healthIs503WhenAProbeFails | |
| A probe that throws counts as failing | HealthServiceTest.aProbeThatThrowsCountsAsFailing | ||
| The database probe passes while Postgres answers | PostgresDatabaseTest.theHealthProbePassesWhilePostgresAnswers | WiringTest.healthReportsTheDatabase | |
| The health report fails once the database is gone | PostgresDatabaseTest.theHealthReportFailsOnceTheDatabaseIsGone | WiringTest.healthReportsTheDatabase (503) | |
| Migrations apply to an empty database, none failed | PostgresDatabaseTest.migrateAppliesEveryMigrationToAnEmptyDatabase | ||
| Migrating again runs nothing | PostgresDatabaseTest.migratingAgainRunsNothing | ||
| Starting the server migrates the database | WiringTest.startingTheServerMigratesTheDatabase | ||
Connection settings come from DATABASE_*; a missing or blank one is named; the password stays out of toString | PostgresSettingsTest (3 tests) | ||
/ answers with the template greeting | ApplicationTest.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.
| Behavior | Test |
|---|---|
Without the development verifier (an ordinary build) a development purchase, or one from a store with no verifier, is 422 unsupported-source | SyncRoutesTest.withoutTheDevelopmentVerifierADevelopmentPurchaseIsRefused |
A proof the verifier rejects is 422 invalid-purchase; one bought for another group is 422 purchase-for-another-group | SyncRoutesTest.aGroupNeedsAPurchaseMadeForIt |
A group is created once (201); the same id again is 409 group-exists | AuthRoutesTest.aGroupNeedsItsFirstDeviceToProveItsKey |
| A put is conditional on the seq last seen: 200 with the new seq, 409 with the current one | SyncRoutesTest.aPutIsConditionalOnTheSeqLastSeen |
| Changes come in seq order with kind, note, author device, server time and envelope, as CBOR | SyncRoutesTest.changesReturnTheRecordsInSeqOrderWithTheirMetadata |
Changes come in pages of limit, with hasMore and the next cursor | SyncRoutesTest.changesComeInPages |
| A delete is a tombstone in the changes | SyncRoutesTest.aDeleteIsATombstoneInTheChanges |
| A write that breaks a rule is 422 with the refusal as its code | SyncRoutesTest.aWriteThatBreaksARuleIs422WithItsReason |
| A record is attributed to the signed-in device | SyncRoutesTest.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 400 | SyncRoutesTest.malformedRequestsAreRefused |
| Group, sign-in, put, changes and device removal work through the production wiring on Postgres | WiringTest.deviceAuthAndRecordsWorkThroughTheProductionWiringOnPostgres |
Groups and entitlements (D-52 to D-56)
| Behavior | Service | Postgres adapter |
|---|---|---|
| An active entitlement makes the group entitled until it ends | EntitlementPolicyTest.anActiveEntitlementMakesTheGroupEntitledUntilItEnds | |
| The latest end among several entitlements wins (D-52) | EntitlementPolicyTest.theLatestEndAmongSeveralEntitlementsWins | |
| A revoked (refunded) entitlement ends when it was revoked | EntitlementPolicyTest.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 February | EntitlementPolicyTest.aYearAfterALeapDayEndsOnTheLastDayOfFebruary | |
| A group with no entitlements counts as lapsed since it was created | EntitlementPolicyTest.aGroupWithNoEntitlementsCountsAsLapsedSinceItWasCreated | |
| Only an entitled group may push; an expired one may not pull | EntitlementPolicyTest.onlyAnEntitledGroupMayPushAndAnExpiredOneMayNotPull | |
| The access service applies the policy to a stored group at the clock’s time | EntitlementPolicyTest.theServiceAppliesThePolicyToAStoredGroupAtTheClocksTime | |
| A created group is found by id and by recovery lookup id, with its recovery fields and first entitlement | InMemoryGroupStoreTest (contract) | PostgresGroupStoreTest (contract) |
| Unknown groups are not found | contract: anUnknownGroupIsNotFound | same |
| Creating with an already bound transaction creates nothing (D-53, D-54) | contract: creatingWithATransactionThatIsAlreadyBoundCreatesNothing | same |
| A group holds entitlements from several stores, oldest binding first | contract: aGroupCanHoldEntitlementsFromSeveralStores | same |
| A transaction belongs to one group only (D-54) | contract: aTransactionBelongsToOneGroupOnly | same |
| The same transaction id from another store is a different transaction | contract: theSameTransactionIdFromAnotherStoreIsADifferentTransaction | same |
| An entitlement for an unknown group is refused | contract: anEntitlementForAnUnknownGroupIsRefused | same |
| Two creates racing for one transaction bind it once | contract: twoGroupsCreatedAtOnceWithTheSameTransactionBindItOnce | same (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, deletionAgreesWithThePolicyAcrossALeapDay | same (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, onlyTheGroupThatHoldsATransactionCanMergeIt | same (one UPDATE with GREATEST and LEAST) |
| The maintenance job deletes a group only once it is due | MaintenanceTest.aGroupIsDeletedAYearAfterItLapsed |
Creating a group and adding purchases (D-54, D-58, D-70, D-71)
| Behavior | Service (GroupCreationTest) | Routes and builds |
|---|---|---|
| A purchase gives the new group its entitlement (source, transaction, end), and the group its first device | aGroupIsEntitledByItsPurchaseAndHasItsFirstDevice | AuthRoutesTest.aGroupNeedsItsFirstDeviceToProveItsKey |
| A source without a verifier is refused, including development purchases on an ordinary build | aSourceWithoutAVerifierIsNotAccepted | SyncRoutesTest.withoutTheDevelopmentVerifierADevelopmentPurchaseIsRefused |
| A proof the store rejects creates nothing | aProofTheStoreRejectsCreatesNothing | SyncRoutesTest.aGroupNeedsAPurchaseMadeForIt |
| The purchase’s account token must be the new group’s id (D-54) | thePurchaseMustNameTheNewGroup | SyncRoutesTest.aGroupNeedsAPurchaseMadeForIt |
| An expired or refunded purchase creates nothing | anEndedPurchaseCreatesNothing | |
| A purchase creates one group only | aPurchaseCreatesOneGroupOnly | |
| One verifier per source | twoVerifiersForOneSourceAreAMistake | |
| The development verifier takes a group id as a one-year purchase for that group, and nothing else | DevelopmentVerifierTest (2 tests) | |
| The development verifier is found on the classpath | DevelopmentVerifierTest.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 nothing | PurchasesTest (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 ended | PurchasesTest.thePurchaseMustBeForTheSignedInGroup, aTransactionOfAnotherGroupIsRefused, unsupportedInvalidAndEndedPurchasesAddNothing | SyncRoutesTest.aSignedInDeviceAddsAPurchaseOrUpdatesOneTheGroupHolds (422, 401) |
An ordinary build does not contain server/dev | Gradle task :server:checkNoDevelopmentVerifier (part of check, installDist, distZip, distTar) |
Promo codes (D-72)
| Behavior | Service | Stores (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 code | PromoCodesTest (3 tests) | SyncRoutesTest.aPromoCodeCreatesAGroupOnce (sent in lower case) | |
| A code is stored as its hash with its days, uses, deadline and note, listed newest first | anAddedCodeIsListedAsItWasAddedNewestFirst | PromoToolTest.createPrintsCodesWhoseHashesAreStored | |
| A redemption lasts the code’s days from when it was redeemed and uses one use; an unknown code is refused | PromoVerifierTest.aCodeIsAPurchaseForTheGroupRedeemingIt, anUnknownOrMalformedCodeIsInvalid | aRedemptionLastsTheCodesDaysFromWhenItWasRedeemedAndUsesOneUse, anUnknownCodeIsRefused | |
| Redeeming again for the same group answers the first redemption and uses nothing, even after the code is revoked | PromoVerifierTest.aCodeAddedToAGroupExtendsItOnceHoweverOftenItIsSent | redeemingAgainForTheSameGroupAnswersTheFirstRedemptionAndUsesNothing, aRevokedCodeIsRefusedButGroupsThatRedeemedItKeepWhatTheyGot | |
| A code serves as many groups as it has uses, each with its own row | PromoVerifierTest.oneCodeWithSeveralUsesCreatesSeveralGroups | aCodeServesAsManyGroupsAsItHasUses | SyncRoutesTest.aPromoCodeCreatesAGroupOnce (a used-up code is 422 invalid-purchase) |
A code cannot be redeemed from its deadline; --redeem-by covers that whole day in UTC | aCodeCannotBeRedeemedFromItsDeadline | PromoToolTest.aDeadlineCoversTheWholeDayInUtc | |
| A revoked code is refused; revoking twice keeps the first time | aRevokedCodeIsRefusedButGroupsThatRedeemedItKeepWhatTheyGot, revokingTwiceKeepsTheFirstTime | PromoToolTest.revokeStopsACode | |
| Many groups racing for a code’s last use leave one winner; a group racing itself uses one use | manyGroupsRacingForTheLastUseLeaveExactlyOneWinner, oneGroupRacingItselfUsesOneUse | (Postgres: the code’s row is locked FOR UPDATE) | |
| A code creates a group, or is added to one through the purchases route | PromoVerifierTest.aCodeCreatesAGroup, aCodeAddedToAGroupExtendsItOnceHoweverOftenItIsSent | SyncRoutesTest.aPromoCodeCreatesAGroupOnce (on a build without the development verifier) | |
| The tool lists uses and state, and refuses bad arguments with its usage, creating nothing | PromoToolTest.listShowsUsesAndState, badArgumentsPrintUsageAndCreateNothing |
Records (D-65)
| Behavior | Service (RecordServiceTest) | Stores (RecordStoreContract: InMemoryRecordStoreTest, PostgresRecordStoreTest) |
|---|---|---|
| A new record gets the group’s next seq | aNewRecordIsStoredWithTheGroupsNextSeq | aPutStoresTheRecordWithTheGroupsNextSeq (with every stored field) |
| A stale seq is a conflict naming the current seq; 0 means “create” | aStaleSeqIsAConflictThatNamesTheCurrentSeq | aPutWithAStaleSeqIsRefusedWithTheCurrentSeq, aPutToAMissingRecordThatExpectsOneIsAMismatch |
| Each group counts on its own | eachGroupCountsOnItsOwn | |
| Only a notebook has no note id | onlyANotebookHasNoNoteId | (also a CHECK constraint in V3__records.sql) |
| A record keeps its kind and its note | aRecordKeepsItsKindAndItsNote | |
| A revision is written once and afterwards can only be deleted | aRevisionIsWrittenOnceAndAfterwardsCanOnlyBeDeleted | |
| A deleted notebook can be written again | aDeletedNotebookCanBeWrittenAgain | |
| A delete needs a record that is not deleted yet | aDeleteNeedsARecordThatIsNotDeletedYet | |
| A tombstone has no envelope and is in the feed | aTombstoneHasNoEnvelopeAndIsInTheFeed | |
| A negative seq is refused | aNegativeSeqIsRefused | |
| An unknown group is reported, for put and changes | anUnknownGroupIsReported | |
| 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 cursor | theFeedListsEachRecordOnceAtItsLatestSeqInOrder | |
| The feed pages, says when more is waiting, and keeps the cursor when empty | theFeedReturnsPagesAndSaysWhenMoreIsWaiting, anEmptyPageKeepsTheCursor | |
| Of concurrent puts to one record exactly one wins | ofConcurrentPutsToOneRecordExactlyOneWins | |
| Concurrent puts in one group get every seq once | concurrentPutsInOneGroupGetEverySeqOnce |
Auth, devices, enrollment and recovery (epic 4: D-55, D-67, D-68)
| Behavior | Service | Stores (AuthStoreContract: InMemoryAuthStoreTest, PostgresAuthStoreTest) | Routes (AuthRoutesTest, SyncRoutesTest) |
|---|---|---|---|
| A signed challenge buys a 15-minute token; it expires on time | AuthServiceTest.aSignedChallengeBuysATokenForFifteenMinutes | aTokenGivesASessionUntilItExpires | SyncRoutesTest.recordRoutesNeedAValidTokenForTheirGroup |
| Only the token’s SHA-256 is stored | AuthServiceTest.onlyTheTokensHashIsStored | ||
| A challenge is used once, even after a wrong signature; replays are refused | AuthServiceTest.aChallengeIsUsedOnce, aWrongSignatureIsRefusedAndBurnsTheChallenge | aChallengeIsTakenOnce, ofConcurrentTakesOfOneChallengeExactlyOneWins | AuthRoutesTest.aChallengeIsUsedOnceAndOnlyBySignatureOfTheDeviceItWasIssuedTo |
| A challenge expires after a minute and serves only the device (and purpose) it was issued for | AuthServiceTest.aChallengeExpiresAfterAMinute, aChallengeServesOnlyTheDeviceItWasIssuedFor; RecoveryServiceTest.anAuthChallengeDoesNotServeRecovery | ||
| Unknown devices and groups get no challenge; a lapsed group signs in, an expired one does not | AuthServiceTest.unknownDevicesAndGroupsGetNoChallenge, aLapsedGroupStillSignsInAndAnExpiredOneDoesNot | AuthRoutesTest.anUnknownDeviceGetsNoChallenge, SyncRoutesTest.aLapsedGroupIsReadOnlyThenExpired | |
| Removing a device ends its sessions at once and it cannot sign in again | AuthServiceTest.aRemovedDeviceLosesItsSessionAtOnceAndCannotSignInAgain | revokingADeviceEndsItsSessionsAtOnce | AuthRoutesTest.aRemovedDeviceLosesItsTokenAtOnceAndCannotSignInAgain, WiringTest.deviceAuthAndRecordsWorkThroughTheProductionWiringOnPostgres |
| A device can remove itself but not another group’s device | AuthServiceTest.aDeviceMayRemoveItselfButNotAnotherGroupsDevice | AuthRoutesTest.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, signingInRecordsWhenTheDeviceWasLastSeen | aDeviceKeepsItsSealedNameAndWhenItWasLastSeen | AuthRoutesTest.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 refused | AuthRoutesTest.noEndpointAcceptsAnUnauthenticatedRequestForGroupData, SyncRoutesTest.recordRoutesNeedAValidTokenForTheirGroup | ||
| A device is stored with every field; its id is used once across groups; a group lists its own devices oldest first | aDeviceIsStoredWithEveryField, aDeviceIdIsUsedOnceAcrossGroups, aGroupListsItsOwnDevicesOldestFirst | ||
| The first device proves its key when a group is created; ids are used once | GroupCreationTest.theFirstDeviceMustSignWithTheKeyItRegisters, aGroupIdLookupIdOrDeviceIdIsUsedOnce | AuthRoutesTest.aGroupNeedsItsFirstDeviceToProveItsKey | |
| A QR enrollment: start, member checks the keys, wrap and approve, new device collects once and signs in | EnrollmentServiceTest.aMemberAddsANewDeviceThatCollectsTheRootKeyOnce | anEnrollmentIsCreatedOnceAndFound, approvingAddsTheDeviceOnce | AuthRoutesTest.aNewDeviceJoinsByQrCodeAndReadsTheRootKey (real DeviceEnrollment wrap) |
| An enrollment expires after 10 minutes | EnrollmentServiceTest.anEnrollmentExpiresAfterTenMinutes | AuthRoutesTest.anEnrollmentExpiresAfterTenMinutes | |
| The approval must be signed by the approving member for the scanned keys, once | EnrollmentServiceTest.theApprovalMustBeSignedByTheApprovingMember, anEnrollmentIsApprovedOnce | approvingWithATakenDeviceIdChangesNothing | AuthRoutesTest.theApprovalMustComeFromAMemberAndNameTheScannedKeys |
| Taken ids and malformed keys are refused | EnrollmentServiceTest.takenIdsAndMalformedKeysAreRefused, aDeviceIdTakenBeforeApprovalIsReported | AuthRoutesTest.anEnrollmentCannotTakeAnExistingDeviceIdOrMalformedKeys | |
| The recovery key adds a device and returns the root key copy, which unwraps with the typed words | RecoveryServiceTest.theRecoveryKeyAddsADeviceAndReturnsTheRootKeyCopy | AuthRoutesTest.theRecoveryKeyAddsADeviceAndUnwrapsTheRootKey (real RecoveryKey) | |
| Recovery refuses another key, a reused challenge and an unknown group; works while lapsed, not once expired | RecoveryServiceTest (4 more tests) | AuthRoutesTest.recoveryRefusesAnotherKeyAndAnUnknownGroup | |
| The server’s statements are the same bytes as the clients’ | AuthRoutesTest.theStatementsOfTheServerAndTheClientsAreTheSameBytes |
Rate limits and maintenance (D-69)
| Behavior | Service | Stores | Routes (RateLimitRoutesTest) |
|---|---|---|---|
| A key may burst up to the capacity, then gets 429 with how long to wait; calls come back evenly | RateLimitTest.aKeyMayBurstUpToTheCapacityThenIsLimited, callsComeBackEvenlyOverTheRefillTime, retryAfterIsAtLeastOneWholeSecond | theCallAfterTheCapacityGets429WithRetryAfterAndACode | |
| Keys, limits and policies are counted separately | RateLimitTest.keysAndLimitsAreIndependent, policiesCountPerPolicyAndKey | theTokenEndpointSharesTheAuthBucketAndOtherEndpointsKeepTheirOwn | |
| Of many threads at once exactly the capacity get through | RateLimitTest.ofManyThreadsAtOnceExactlyTheCapacityGetThrough | ||
| Idle buckets are dropped and busy ones kept; a full table refuses new keys until room is made | RateLimitTest.idleBucketsAreDroppedAndBusyOnesKept, aFullTableRefusesNewKeysUntilIdleOnesAreDropped | ||
| A limit must allow a call and refill over time | RateLimitTest.aLimitMustAllowACallAndRefillOverTime | ||
| Enrollments, recovery (per address and per group) and group creation are limited | enrollmentsMayBeStartedTenTimesAnHour, recoveryIsLimitedPerAddressAndPerGroup, theGroupCreationRouteIsLimitedToo | ||
Without a trusted proxy X-Forwarded-For is ignored; behind one each client address has its bucket and only the last entry counts | withoutATrustedProxyTheForwardedHeaderIsIgnored, behindATrustedProxyEachClientAddressHasItsOwnBucket, aCallerCannotChooseItsBucketByPrependingToTheForwardedHeader | ||
| Signed-in sync traffic is not limited | theSyncTrafficOfSignedInDevicesIsNotLimited | ||
| Expired challenges, tokens and enrollments are removed (an expiry at or before now counts), live ones kept; an approved uncollected enrollment goes too; devices stay | MaintenanceTest (4 tests) | AuthStoreContract.deletingExpiredRemovesOnlyWhatHasExpired, deletingExpiredAlsoRemovesAnApprovedEnrollmentWithItsWrappedKey | |
| The job runs while the server runs, only when an interval is given, and its setting is validated | theMaintenanceJobFreesExpiredRowsWhileTheServerRuns, noMaintenanceJobRunsUnlessAnIntervalIsGiven, theMaintenanceIntervalComesFromTheEnvironmentValue |
Root key rotation (D-64, D-74, D-75)
| Behavior | Service (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 reported | aNewGroupIsAtRootKeyOneWithNothingToDo | aCreatedGroupIsFoundByIdAndByRecoveryLookupId | |
| Removing a device makes a rotation due until one happens; a later removal makes another due | removingADeviceMakesARotationNeededUntilOneHappens | aRemovedDevicesGroupRotatesItsRootKeyAndEveryoneCanOpenTheNewOne | |
| A rotation moves the id on, replaces the recovery copy and leaves the recovery lookup id, keys and recovery public key alone | aRotationMovesTheIdOnAndReplacesTheRecoveryCopy | rotatingTheRootKeyMovesTheIdOnAndReplacesTheRecoveryCopy | |
| Only the next id can be rotated to; of two rotations at once one wins | onlyTheNextIdCanBeRotatedToOrSent | aRotationToAnyIdButTheNextChangesNothing, twoRotationsToTheSameIdAtOnceHaveOneWinner | onlyTheNextRootKeyIdCanBeRotatedToOrSent |
| A rotation needs an entitled group and a copy of sensible size | aRotationNeedsAnEntitledGroupAndASensibleCopy, aLapsedGroupCannotHandOutKeys | aLapsedGroupCannotRotate, 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 key | aDeviceNotGivenTheNewKeyIsListedAsBehindUntilAMemberSendsIt, theCallerIsNeverListedAsBehind | aRootKeySealedToADeviceIsKeptUntilReplacedAndRaisesItsId, recordingThatADeviceHoldsTheKeyKeepsNothingForIt | aRemovedDevicesGroupRotatesItsRootKeyAndEveryoneCanOpenTheNewOne (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 refused | aCopyForAnOlderKeyIsNotHandedOutAsTheCurrentOne, aCopyCannotBeStoredForTheWrongKeyDeviceOrGroup | noRootKeyIsKeptForARemovedOrUnknownDevice | onlyTheNextRootKeyIdCanBeRotatedToOrSent |
| A device enrolled with an older key during a rotation is behind; one with no encryption key (from before D-74) is never listed | anEnrollmentApprovedWithAnOlderKeyLeavesTheDeviceBehind, aDeviceWithoutAnEncryptionKeyCannotBeSentOne | aDeviceEnrolledWithTheOldKeyDuringARotationIsListedUntilSent | |
| The words open the server’s copy of the new key, and a recovered device is given the current one | theRecoveryKeyAddsADeviceAndReturnsTheRootKeyCopy (RecoveryServiceTest) | aRemovedDevicesGroupRotatesItsRootKeyAndEveryoneCanOpenTheNewOne | |
| The three routes need a session for their group | rootKeyRoutesNeedASessionForTheGroup | ||
| A group needs 32-byte device and recovery encryption keys; the first device’s are stored and it holds root key 1 | aGroupCreatedWithoutValidKeysIsRefused | aDeviceIsStoredWithEveryField | aGroupCannotBeCreatedWithoutKeysOfTheRightSize |
| A QR approval stores the scanned encryption key and the root key id; the signature must cover that key | EnrollmentServiceTest.theApprovalMustBeSignedByTheApprovingMember | AuthRoutesTest.theApprovalMustComeFromAMemberAndNameTheScannedKeys, aNewDeviceJoinsByQrCodeAndReadsTheRootKey | |
| Rows from before the migration keep working: devices hold their group’s current key, with no encryption key | PostgresDatabaseTest.theRootKeyRotationMigrationKeepsRowsFromBeforeIt |
Wire format (D-63, :syncApi)
| Behavior | Test (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 trip | aRequestSurvivesTheRoundTrip |
| The statements devices sign match known answers, differ by purpose, cover the encryption key and reject a bad id | SyncStatementsTest (7 tests) |
| A delete has no envelope | aDeleteHasNoEnvelope |
| A reader ignores fields it does not know | aReaderIgnoresFieldsItDoesNotKnow |
| The root key classes round-trip; fields added later default when an older writer leaves them out | rootKeyClassesRoundTrip, 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 withDATABASE_*, quiet logging) and thebin/promostart 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) (SyncApiTesthas, on API 28 and 37). It uses Kotlin’sUuid, 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:runon Postgres on 2026-10-10, and a burst of 34 calls got 30 answers then 429 withRetry-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 ordinaryinstallDist(422unsupported-source, no warning logged) and a-Pdevelopmentone (201, a one-yeardeventitlement, 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 throughKtorDestinationandHttpSyncGroup(sync-engine.md). server/compose.test.yamlhas 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.
testRoottests the template greeting, not product behavior.