diff --git a/CODING_CONVENTIONS.md b/CODING_CONVENTIONS.md index 083383b..cad71a0 100644 --- a/CODING_CONVENTIONS.md +++ b/CODING_CONVENTIONS.md @@ -93,6 +93,7 @@ file. - **Readers go through `require`; writers go through `requireForUpdate`**, which takes a row lock held until the transaction commits. That pairing replaced a `synchronized` on every method: a monitor inside a transactional proxy is released before the commit, so it reads like a guarantee and is not one. - `spring.jpa.open-in-view=false`. A screen holds a record and never an entity, so a lazy read outside a transaction is a bug, and this is what makes it fail loudly instead of hiding until the next thing moves. - A path-like setting names the directory, not the whole URL: `WHICHDAY_DATA_DIR`, so moving the database file cannot drop `MODE=PostgreSQL` on the way past. +- **Nothing is stored forever.** Two retention windows delete a poll — a few days after it ends, and unconditionally at its maximum age — and `docs/REQUIREMENTS.md` §9 is the record. A new table that hangs off `poll` needs `on delete cascade` or the sweep leaves its rows behind; one that does not needs a rule of its own, because the only reason the sweep reaches anything is that a poll is the root of it. ## 10a. Security headers diff --git a/README.md b/README.md index 3ae7334..b4ee216 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,14 @@ It is one container with its database inside it, and nothing else to bring up. you want it. - **Run one container, not two.** The database is a file, and only the process holding it can open it — a second container on the same volume will not start. +- **Polls do not stay forever.** A poll is deleted five days after it ends + (`WHICHDAY_RETENTION_AFTER_POLL_ENDS`), and no poll survives 90 days from the day it + was made (`WHICHDAY_RETENTION_DAYS`) — whatever state it is in, a draft nobody sent + and a poll still collecting answers included. Deleting a poll deletes the answers with + it and cannot be undone, so a saved link then reads as a link to a poll that never + existed. Both are whole days, and either is `never` to switch that window off. + `WHICHDAY_RETENTION_DAYS` also drops the names anonymous visitors typed, once no poll + refers to them any more. - `WHICHDAY_ACCESS_MODE` is `anonymous` or `login`, and defaults to `anonymous`. Anything else is a startup failure naming both. - **`login` mode needs an OIDC client**: `WHICHDAY_OIDC_ISSUER_URI`, @@ -74,6 +82,8 @@ services: - WHICHDAY_OIDC_CLIENT_ID=change-me - WHICHDAY_OIDC_CLIENT_SECRET=change-me - FORWARD_HEADERS_STRATEGY=native + - WHICHDAY_RETENTION_AFTER_POLL_ENDS=5 + - WHICHDAY_RETENTION_DAYS=90 restart: unless-stopped volumes: @@ -86,6 +96,8 @@ dropped — it is the default, and it configures nothing: ```yaml environment: - FORWARD_HEADERS_STRATEGY=native + - WHICHDAY_RETENTION_AFTER_POLL_ENDS=5 + - WHICHDAY_RETENTION_DAYS=90 ``` The left side of the mount is yours — the named volume above, or any host path. The @@ -117,6 +129,8 @@ Environment=WHICHDAY_OIDC_ISSUER_URI=https://accounts.example.com Environment=WHICHDAY_OIDC_CLIENT_ID=change-me Environment=WHICHDAY_OIDC_CLIENT_SECRET=change-me Environment=FORWARD_HEADERS_STRATEGY=native +Environment=WHICHDAY_RETENTION_AFTER_POLL_ENDS=5 +Environment=WHICHDAY_RETENTION_DAYS=90 AutoUpdate=registry [Service] @@ -139,8 +153,8 @@ systemctl start whichday.service `AutoUpdate=registry` is safe here because systemd stops the old container before starting the new one, and only one process at a time may hold the database file. -Anonymous mode drops the same four lines here, leaving `FORWARD_HEADERS_STRATEGY` as -the only `Environment=` the unit needs. +Anonymous mode drops the same four lines here, leaving `FORWARD_HEADERS_STRATEGY` and +the two retention lines as the only `Environment=` the unit needs. ### Behind a proxy @@ -219,6 +233,16 @@ A poll row names people by email address; the one place a name lives is the `acc table, written when somebody signs in. So an invitee who signs up later shows their real name on polls that predate their account. +Nothing stays forever. A sweep runs daily and deletes a poll once either retention +window has passed it — five days after it ended, or 90 days after it was created, +whichever comes first. It takes the ballots, the invitations, the candidate days and the +proposals with it. + +The same 90 days drop the names typed into anonymous mode's who-are-you screen, once no +poll refers to them any more — so a visitor who typed a name and closed the tab leaves +nothing behind. An address a provider vouched for is never dropped: it belongs to somebody +who can sign in again and be recognised. + It is H2 rather than PostgreSQL on purpose: [`docs/REQUIREMENTS.md`](docs/REQUIREMENTS.md) says why. diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index fb396ee..3b5d751 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -96,9 +96,9 @@ authenticated. It is the one place a name lives — a poll stores nothing but ad wherever a name belongs, including on other people's ballots. What the table holds in this mode is a session's chosen name, and nothing reads it beyond rendering: the invitee search is its only other reader and that screen is not part of this mode. The rows are -written when somebody says who they are rather than when they do anything, so they -accumulate with visitors and nothing removes them — -[`issues/0019-anonymous-names-accumulate-forever.md`](issues/0019-anonymous-names-accumulate-forever.md). +written when somebody says who they are rather than when they do anything, so a visitor +who typed a name and closed the tab leaves one behind. Those are swept: a minted name no +poll refers to any more is dropped at the same maximum age a poll has (§9). **Identity does not outlive the session.** Close the tab and you are a new person. That is the cost of having no accounts, and the admin code is what buys the organizer a way @@ -496,6 +496,11 @@ writing method in `PollService` goes through `requireEditable`, which admits onl `DRAFT` and `OPEN`; answers are refused separately by `requireOpen`, which says so in the voter's own terms rather than the organizer's. +Final is not the same as permanent. A poll that has ended is deleted a few days later, +and every poll is deleted at its maximum age whatever state it is in — §9 records both +windows. So the last thing that happens to a poll is that it stops existing, and a link +to it then reads as a link to a poll that never did. + That means **the organizer has to settle on a day before the poll closes.** The default closing date is the last day on the table, so the window is the whole life of the poll, and a date locked in after the days have gone would be a decision about days nobody can @@ -523,8 +528,9 @@ no way to act on it. day, whether there are candidate days, the closing date and the clock. A stored column would be wrong from the moment the clock crossed the closing date with nobody writing to the row, and the thing that would fix that is a scheduled sweep -([issue 0005](issues/0005-closing-happens-on-read.md)). Adding the column later is a -migration and a backfill; removing one that lied for a month is not. +([issue 0005](issues/0005-closing-happens-on-read.md)) — which now has somewhere to live, +since retention brought one (§9). Adding the column later is a migration and a backfill; +removing one that lied for a month is not. Seeding is the one path around the guard: `PollService.record` is package-private and only `castVote` and the test fixtures reach it, to build polls that were decided before @@ -674,6 +680,82 @@ instead of hiding from it. against. Both versions are Boot's managed ones, so the pairing is Boot's rather than ours. +### What is kept, and for how long + +Nothing is kept forever. A sweep runs on a fixed delay and deletes polls that either of +two retention windows has passed, both set once by a deployment and both counted in whole +days like every other span here: + +- **`WHICHDAY_RETENTION_AFTER_POLL_ENDS`, five days by default.** Measured from the day + the poll ended, and it reaches only the two final states, `CLOSED` and `LOCKED`. +- **`WHICHDAY_RETENTION_DAYS`, ninety days by default.** Measured from `created_at`, and + it reaches **every** poll there is — a draft nobody sent, a poll still collecting + answers, a settled one whose day has not come. Nothing survives it. It governs the + anonymous names below by the same number, since they are the other thing here that + accumulates on its own. + +Either is `never` to switch that window off, which is what a deployment that wants to +keep everything sets. A window nobody can parse is a startup failure quoting the variable +and the value, for the same reason `WHICHDAY_ACCESS_MODE` is (§1): there is no safe +guess — one reading deletes what somebody meant to keep and the other keeps what they +meant to have gone. + +**The second window exists because the first cannot reach everything.** A draft has no +date to have ended on, and an open poll's closing date is at most the last day on the +table, which can be months out. Without a rule measured from creation there are rows no +rule reaches, in a store that is one file nothing else prunes (§9). The consequence is +deliberate and worth naming: a poll created in September with candidate days in March is +deleted in December, while people are still answering it. The ceiling is a ceiling. + +**A poll that has ended is dated by the later of its two dates** — its closing date, or +the day locked in. A poll settled for a day after it stopped taking answers has not +happened yet, and its closing date can be five days gone while the team is still waiting +on the date they came back to the poll to find. Anchoring on the closing date alone would +delete the answer before the meeting. + +**Deleting a poll deletes everything about it.** The ballots, the invitations, the +candidate days and the counter-proposals all go, by the `on delete cascade` `V1` already +declares. Afterwards a link somebody saved reads exactly as a link nobody issued: the +not-found screen, the same words either way, because `PollScreen` forwards there on a +poll it cannot read and `poll()` cannot read one that is not there. That is the whole of +what retention asked of the views — none of them changed, and `NotFoundView`'s "The link +may have expired" became true rather than aspirational. + +**The names go too, but only the minted ones, and only when nothing refers to them.** +The same maximum age drops an `account` row whose address was minted for an anonymous +session (`@whichday.anonymous`) once no poll refers to it as an organizer, an invitee or a +voter. Three conditions, each earning its place: + +- **Minted only.** An address a provider vouched for belongs to somebody who can come back + and be recognised. A minted one belonged to a session that no longer exists, and nothing + will ever match it again. +- **Referred to by nothing.** A name is the account table's alone (§10), so dropping a row + a live poll still mentions would render that person as their own minted address on + everybody else's screen. `PollService.addressesOnAnyPoll` is what the sweep asks, and it + asks in whole columns rather than by joining `account` — those two are deliberately not + joined anywhere. +- **Old enough.** The age is what makes it safe rather than merely tidy: a session that has + just typed a name has written its row and referred to nothing yet, so unreferenced does + not mean abandoned until no session could still be holding it. + +The order in `RetentionSweep` follows from that: the polls go first, so a name whose last +mention was on a poll deleted this run is already unreferenced when the accounts are looked +at. A voter's name therefore outlives their poll by no sweeps at all, and an idle visitor's +by ninety days. + +**Who may run it: nobody.** `deleteExpiredPolls` and `forgetExpiredAnonymous` take no +viewer, because there is no viewer to check — it is the one write in `PollService` that no person asked for, and the +windows are the authority instead. It is also the first thing in the application that +happens because time passed rather than because somebody looked, which is the trigger +[issue 0005](issues/0005-closing-happens-on-read.md) has been waiting for. + +A fixed delay rather than a nightly cron: a cron at three in the morning is skipped +outright by a machine asleep at three, there is nothing about deleting rows that wants a +particular hour, and a fixed delay always runs shortly after start-up — which is when a +deployment that has been down for a week needs it most. The sweep is off under the `test` +profile, because one Spring context serves the whole suite and a scheduled delete would +race whatever test is running; `PollRetentionTest` calls it directly. + --- ## 10. How a person is stored diff --git a/docs/issues/0005-closing-happens-on-read.md b/docs/issues/0005-closing-happens-on-read.md index 0a17d81..9200c55 100644 --- a/docs/issues/0005-closing-happens-on-read.md +++ b/docs/issues/0005-closing-happens-on-read.md @@ -18,6 +18,14 @@ Anything that has to happen when voting ends needs a trigger rather than a comparison: telling the organizer the poll is theirs to settle, mailing the team the result, or auto-locking the leading day. There is nowhere to put any of that. +## What has changed since + +The scheduling itself is no longer missing. Retention added `@EnableScheduling` and a +sweep that runs on a fixed delay (`RetentionSweep`, `PollService.deleteExpiredPolls`), so +there is now a place for anything that has to happen because time passed rather than +because somebody looked. What is still missing is anything to *do* at the moment a poll +closes, which is the half below. + ## What fixing it looks like A scheduled sweep over open polls whose closing date has passed, doing whatever the diff --git a/docs/issues/0019-anonymous-names-accumulate-forever.md b/docs/issues/0019-anonymous-names-accumulate-forever.md deleted file mode 100644 index f78847b..0000000 --- a/docs/issues/0019-anonymous-names-accumulate-forever.md +++ /dev/null @@ -1,44 +0,0 @@ -# Anonymous names accumulate in the account table and nothing removes them - -**Severity:** low — it costs disk and nothing else, but it grows with visitors rather -than with polls and there is no sweep. - -## What happens - -`AnonymousViewerSession.identify` calls `AccountDirectory.remember`, which inserts a row -keyed on the minted `-@whichday.anonymous` address. It has to: a poll -stores addresses and reads names from that table ([`../REQUIREMENTS.md`](../REQUIREMENTS.md) -§10), so a name that is not written there is a name nobody else on the poll ever sees. - -But it is written when somebody says who they are, not when they do anything. Somebody -who opens a shared link, types a name and closes the tab leaves a row behind. So does a -crawler that fills the field. Nothing deletes it, and nothing ever matches it again — -the address belonged to a session that no longer exists. - -A poll deleted as a draft takes its own rows with it (`on delete cascade`); the account -rows are not among them. - -## What it does not do - -It is not a leak. `AccountDirectory.matching` is the only reader that could list these, -and the screen that calls it — `/new/invitees` — is not part of anonymous mode and -forwards home. `forInvites` only resolves addresses already on a poll. So a minted -address surfaces exactly where its owner answered something, and nowhere else. - -## What would fix it - -Either write later or sweep. Writing later means remembering the name at the first -thing the session actually stores — creating a poll, casting a vote, declining one — -which bounds the table to people who took part. That is three call sites in -`PollPresenter` and a new dependency there, which is why it was not done in the change -that introduced the mode. - -Sweeping means deleting `@whichday.anonymous` accounts that no poll, invitee row or -ballot refers to. Cheap as a query, and it needs somewhere to run from — this -application has no scheduled work at all today, which is the same gap -`0005-closing-happens-on-read.md` records. - -## Where - -- `src/main/java/io/binarycodes/whichday/people/ui/presenter/AnonymousViewerSession.java` — `identify` -- `src/main/java/io/binarycodes/whichday/people/service/AccountDirectory.java` — `remember` diff --git a/src/main/java/io/binarycodes/whichday/Application.java b/src/main/java/io/binarycodes/whichday/Application.java index 2d3a21a..dd4137f 100644 --- a/src/main/java/io/binarycodes/whichday/Application.java +++ b/src/main/java/io/binarycodes/whichday/Application.java @@ -5,6 +5,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; +import org.springframework.scheduling.annotation.EnableScheduling; import com.vaadin.flow.component.dependency.StyleSheet; import com.vaadin.flow.component.page.AppShellConfigurator; @@ -15,6 +16,7 @@ * works. */ @SpringBootApplication +@EnableScheduling @StyleSheet(Aura.STYLESHEET) @StyleSheet("/styles.css") public class Application implements AppShellConfigurator { diff --git a/src/main/java/io/binarycodes/whichday/base/config/Retention.java b/src/main/java/io/binarycodes/whichday/base/config/Retention.java new file mode 100644 index 0000000..a36a85e --- /dev/null +++ b/src/main/java/io/binarycodes/whichday/base/config/Retention.java @@ -0,0 +1,22 @@ +package io.binarycodes.whichday.base.config; + +/** + * How long a poll is kept. Two windows, and a poll goes when either one has passed it. + * + *

{@link #afterPollEnds} is the ordinary path: a poll that is over goes a few days + * later, measured from the day it ended. {@link #maximumAge} is a ceiling measured from + * the day it was created, and it reaches every poll there is — a draft nobody sent, a + * poll still taking answers, a settled one whose day is still ahead. Nothing survives + * it, which is the point of it: the store is one file nothing else prunes ({@code + * docs/REQUIREMENTS.md} §9), so without a rule that reaches every row there are rows + * no rule reaches. + * + *

The ceiling therefore wins where the two disagree, and a deployment that sets + * {@code afterPollEnds} beyond it has made the longer window unreachable rather than + * made a mistake worth refusing at startup. + * + *

Both may be {@link RetentionWindow#NEVER} independently, which is how a deployment + * keeps everything for good. + */ +public record Retention(RetentionWindow afterPollEnds, RetentionWindow maximumAge) { +} diff --git a/src/main/java/io/binarycodes/whichday/base/config/RetentionConfiguration.java b/src/main/java/io/binarycodes/whichday/base/config/RetentionConfiguration.java new file mode 100644 index 0000000..1d1545f --- /dev/null +++ b/src/main/java/io/binarycodes/whichday/base/config/RetentionConfiguration.java @@ -0,0 +1,24 @@ +package io.binarycodes.whichday.base.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Turns the two windows a deployment named into the bean the sweep asks. + * + *

Both properties are written by {@code application.properties} with a default, so + * neither is ever absent — an empty one is an operator who set the variable to nothing, + * and that is a mistake worth failing on rather than reading as "keep everything". + * {@code never} is how that is said on purpose. + */ +@Configuration(proxyBeanMethods = false) +public class RetentionConfiguration { + + @Bean + Retention retention(@Value("${whichday.retention.after-poll-ends}") String afterPollEnds, + @Value("${whichday.retention.days}") String maximumAge) { + return new Retention(RetentionWindow.of("WHICHDAY_RETENTION_AFTER_POLL_ENDS", afterPollEnds), + RetentionWindow.of("WHICHDAY_RETENTION_DAYS", maximumAge)); + } +} diff --git a/src/main/java/io/binarycodes/whichday/base/config/RetentionWindow.java b/src/main/java/io/binarycodes/whichday/base/config/RetentionWindow.java new file mode 100644 index 0000000..11ae28f --- /dev/null +++ b/src/main/java/io/binarycodes/whichday/base/config/RetentionWindow.java @@ -0,0 +1,87 @@ +package io.binarycodes.whichday.base.config; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.util.Locale; +import java.util.Optional; + +/** + * How long Whichday keeps something: a whole number of days, or {@code never}, which + * keeps it indefinitely. Whole days, like every other span in this application — a + * poll has a closing date rather than a closing moment (see {@code + * docs/REQUIREMENTS.md} §6), and a retention window measured in hours would be the + * only thing here that was not. + * + *

One type used twice, so the two windows {@link Retention} carries cannot drift + * apart in what they accept or in how they refuse it. + */ +public record RetentionWindow(Optional days) { + + /** Nothing is ever deleted for having reached this window's age. */ + public static final RetentionWindow NEVER = new RetentionWindow(Optional.empty()); + + private static final String OFF = "never"; + + private static final String UNREADABLE = """ + %s is "%s", which is not a retention window. It is a whole number of days, \ + or "%s" to keep polls indefinitely."""; + + /** + * The value as written, or a startup failure quoting both it and the variable it + * came from. A window nobody can parse has no safe reading: guessing at the + * operator's intent would either delete data they meant to keep or keep data they + * meant to have gone. + * + * @param variable the environment variable to name in a failure, since the property + * it resolved into is not what the operator typed + */ + public static RetentionWindow of(String variable, String value) { + var written = value == null ? "" : value.strip(); + if (written.toLowerCase(Locale.ROOT).equals(OFF)) { + return NEVER; + } + return new RetentionWindow(Optional.of(parsed(variable, written))); + } + + /** + * The day an anchor has to fall before for this window to have passed it. Empty + * when the window is off, which is what leaves the whole rule unrun rather than + * running it against a cutoff that means nothing. + */ + public Optional cutoff(LocalDate today) { + return days.map(count -> today.minusDays(count)); + } + + /** + * The same cutoff for a column that holds an instant rather than a date. The start of + * the cutoff day in the clock's own zone, so that comparing instants against it is + * the same comparison as comparing the days — one unit for both windows, and both of + * them whole days. + */ + public Optional cutoff(Clock clock) { + return cutoff(LocalDate.now(clock)).map(day -> day.atStartOfDay(clock.getZone()).toInstant()); + } + + /** How a window reads in a log line: "5 days", or "never". */ + @Override + public String toString() { + return days.map(count -> count + " days").orElse(OFF); + } + + private static int parsed(String variable, String value) { + try { + var days = Integer.parseInt(value); + if (days < 0) { + throw unreadable(variable, value); + } + return days; + } catch (NumberFormatException notANumber) { + throw unreadable(variable, value); + } + } + + private static IllegalStateException unreadable(String variable, String value) { + return new IllegalStateException(UNREADABLE.formatted(variable, value, OFF)); + } +} diff --git a/src/main/java/io/binarycodes/whichday/people/domain/AnonymousAddress.java b/src/main/java/io/binarycodes/whichday/people/domain/AnonymousAddress.java new file mode 100644 index 0000000..c5bcb3b --- /dev/null +++ b/src/main/java/io/binarycodes/whichday/people/domain/AnonymousAddress.java @@ -0,0 +1,49 @@ +package io.binarycodes.whichday.people.domain; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.UUID; + +/** + * The address an anonymous session is known by. There is no provider to name anybody in + * that mode, so a session mints one of these for itself and every poll, invitation and + * ballot it writes is keyed on it. + * + *

One class owns the shape because two things need it: the session that mints one, and + * the retention sweep that recognises one in the {@code account} table. A suffix known in + * two places is a suffix that can disagree with itself. + */ +public final class AnonymousAddress { + + /** + * The domain the minted addresses sit under. Not a domain anybody can receive mail + * at, and not one anybody could register either — which is the point: an address here + * identifies a session and promises nothing else. + */ + public static final String DOMAIN = "@whichday.anonymous"; + + private static final DateTimeFormatter MINTED_AT = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss"); + + private AnonymousAddress() { + } + + /** + * A UUID for uniqueness and the moment for legibility: an address that turns up in a + * database row or a log line says when the session behind it started, which is the + * only thing anybody can usefully know about it. + */ + public static String mintedAt(Clock clock) { + return UUID.randomUUID() + "-" + MINTED_AT.format(LocalDateTime.now(clock)) + DOMAIN; + } + + /** + * Whether an address was minted for a session rather than given by a provider. The + * two never mix: a real address cannot end in this domain, and a minted one is not an + * address at all. + */ + public static boolean isMinted(String email) { + return email != null && email.toLowerCase(Locale.ROOT).endsWith(DOMAIN); + } +} diff --git a/src/main/java/io/binarycodes/whichday/people/service/AccountDirectory.java b/src/main/java/io/binarycodes/whichday/people/service/AccountDirectory.java index 4d23505..e8118ab 100644 --- a/src/main/java/io/binarycodes/whichday/people/service/AccountDirectory.java +++ b/src/main/java/io/binarycodes/whichday/people/service/AccountDirectory.java @@ -1,17 +1,21 @@ package io.binarycodes.whichday.people.service; +import java.time.Clock; import java.util.Collection; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import io.binarycodes.whichday.base.config.Retention; +import io.binarycodes.whichday.people.domain.AnonymousAddress; import io.binarycodes.whichday.people.domain.EmailAddress; import io.binarycodes.whichday.people.domain.Person; @@ -37,9 +41,13 @@ public class AccountDirectory implements PersonLookup { private static final int MAXIMUM_MATCHES = 5; private final AccountRepository accounts; + private final Clock clock; + private final Retention retention; - public AccountDirectory(AccountRepository accounts) { + public AccountDirectory(AccountRepository accounts, Clock clock, Retention retention) { this.accounts = accounts; + this.clock = clock; + this.retention = retention; } /** @@ -53,13 +61,50 @@ public void remember(Person person) { var email = EmailAddress.normalise(person.email()); var stored = accounts.findById(email); if (stored.isEmpty()) { - accounts.save(new StoredAccount(email, person.name())); + accounts.save(new StoredAccount(email, person.name(), clock.instant())); return; } stored.filter(account -> !account.name().equals(person.name())) .ifPresent(account -> account.rename(person.name())); } + /** + * Drops the names minted for anonymous sessions that nothing refers to any more, once + * they have reached the same maximum age a poll has ({@code WHICHDAY_RETENTION_DAYS}). + * Only those: an address a provider vouched for belongs to somebody who can come back + * and be recognised, where a minted one belonged to a session that no longer exists. + * + *

Still referred to means still needed, whichever of the three ways it is — the + * organizer of a poll, somebody invited, somebody who answered. A name is the account + * table's alone, so deleting a row a live poll still mentions would leave that person + * rendered as their own minted address on everybody else's screen. + * + *

The age is what makes it safe rather than merely tidy. A session that has just + * said who it is has written its row and referred to nothing yet, so an unreferenced + * row is not the same as an abandoned one until enough time has passed that no session + * could still be holding it. + * + *

Loaded and filtered in Java, which is the same shape {@link #matching} is + * criticised for and defensible here for the reason that one is not: this runs once a + * sweep rather than once a keystroke. + * + * @param stillInUse every address any poll refers to + * @return how many names were dropped + */ + @Transactional + public int forgetExpiredAnonymous(Set stillInUse) { + var cutoff = retention.maximumAge().cutoff(clock); + if (cutoff.isEmpty()) { + return 0; + } + var abandoned = accounts + .findByEmailEndingWithAndCreatedAtBefore(AnonymousAddress.DOMAIN, cutoff.get()).stream() + .filter(account -> !stillInUse.contains(account.email())) + .toList(); + accounts.deleteAll(abandoned); + return abandoned.size(); + } + /** * Accounts whose address starts with the query, or one of whose name-parts does. * Never the domain: matching "acme" would hand back five colleagues to somebody diff --git a/src/main/java/io/binarycodes/whichday/people/service/AccountRepository.java b/src/main/java/io/binarycodes/whichday/people/service/AccountRepository.java index 7daf944..69c2893 100644 --- a/src/main/java/io/binarycodes/whichday/people/service/AccountRepository.java +++ b/src/main/java/io/binarycodes/whichday/people/service/AccountRepository.java @@ -1,5 +1,6 @@ package io.binarycodes.whichday.people.service; +import java.time.Instant; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; @@ -14,4 +15,11 @@ interface AccountRepository extends JpaRepository { * answer twice. */ List findAllByOrderByEmailAsc(); + + /** + * Accounts minted for a session that are old enough to be swept. Which of them are + * still spoken for is not a question this table can answer — a poll refers to an + * address, never to an account row — so the directory decides that part. + */ + List findByEmailEndingWithAndCreatedAtBefore(String domain, Instant cutoff); } diff --git a/src/main/java/io/binarycodes/whichday/people/service/StoredAccount.java b/src/main/java/io/binarycodes/whichday/people/service/StoredAccount.java index 627d1b9..bc44b65 100644 --- a/src/main/java/io/binarycodes/whichday/people/service/StoredAccount.java +++ b/src/main/java/io/binarycodes/whichday/people/service/StoredAccount.java @@ -1,5 +1,7 @@ package io.binarycodes.whichday.people.service; +import java.time.Instant; + import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; @@ -26,12 +28,21 @@ class StoredAccount { @Column(name = "name", nullable = false) private String name; + /** + * First seen, and never rewritten — {@code remember} runs on every read of who is + * looking, and a column that moved with it would keep an anonymous session's row + * alive for as long as somebody left the tab open. The retention sweep reads this. + */ + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + protected StoredAccount() { } - StoredAccount(String email, String name) { + StoredAccount(String email, String name, Instant createdAt) { this.email = email; this.name = name; + this.createdAt = createdAt; } String email() { @@ -42,6 +53,10 @@ String name() { return name; } + Instant createdAt() { + return createdAt; + } + void rename(String newName) { this.name = newName; } diff --git a/src/main/java/io/binarycodes/whichday/people/ui/presenter/AnonymousViewerSession.java b/src/main/java/io/binarycodes/whichday/people/ui/presenter/AnonymousViewerSession.java index 772867b..c9b81c5 100644 --- a/src/main/java/io/binarycodes/whichday/people/ui/presenter/AnonymousViewerSession.java +++ b/src/main/java/io/binarycodes/whichday/people/ui/presenter/AnonymousViewerSession.java @@ -1,10 +1,7 @@ package io.binarycodes.whichday.people.ui.presenter; import java.time.Clock; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; import java.util.Optional; -import java.util.UUID; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @@ -13,6 +10,7 @@ import com.vaadin.flow.server.VaadinSession; import com.vaadin.flow.spring.annotation.VaadinSessionScope; +import io.binarycodes.whichday.people.domain.AnonymousAddress; import io.binarycodes.whichday.people.domain.Person; import io.binarycodes.whichday.people.service.AccountDirectory; @@ -32,15 +30,6 @@ @ConditionalOnProperty(name = "whichday.access.mode", havingValue = "anonymous") public class AnonymousViewerSession implements ViewerSession { - /** - * The domain the minted addresses sit under. Not a domain anybody can receive mail - * at, and not one anybody could register either — which is the point: an address - * here identifies a session and promises nothing else. - */ - private static final String DOMAIN = "@whichday.anonymous"; - - private static final DateTimeFormatter MINTED_AT = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss"); - private final Clock clock; private final AccountDirectory directory; @@ -91,16 +80,8 @@ public Optional adminCode() { @Override public void identify(String name, String adminCode) { this.adminCode = adminCode == null ? "" : adminCode.trim(); - viewer = Person.signedIn(viewer == null ? mintedAddress() : viewer.email(), name); + viewer = Person.signedIn( + viewer == null ? AnonymousAddress.mintedAt(clock) : viewer.email(), name); directory.remember(viewer); } - - /** - * A UUID for uniqueness and the moment for legibility: an address that turns up in - * a database row or a log line says when the session behind it started, which is - * the only thing anybody can usefully know about it. - */ - private String mintedAddress() { - return UUID.randomUUID() + "-" + MINTED_AT.format(LocalDateTime.now(clock)) + DOMAIN; - } } diff --git a/src/main/java/io/binarycodes/whichday/poll/service/PollRepository.java b/src/main/java/io/binarycodes/whichday/poll/service/PollRepository.java index 127422a..44ebdb5 100644 --- a/src/main/java/io/binarycodes/whichday/poll/service/PollRepository.java +++ b/src/main/java/io/binarycodes/whichday/poll/service/PollRepository.java @@ -1,5 +1,7 @@ package io.binarycodes.whichday.poll.service; +import java.time.Instant; +import java.time.LocalDate; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -65,6 +67,39 @@ interface PollRepository extends JpaRepository { @Query(VISIBLE_TO + " and poll.lockedDay is not null") List findSettledVisibleTo(@Param("email") String email, Sort sort); + /** + * Polls with a date behind them, for the retention sweep. Narrowed on the dates + * alone — which of these have actually ended, and which of a poll's two dates is + * the one that ended it, is {@code stateOf}'s to say rather than JPQL's. + * + *

No visibility arm, because a sweep has nobody to be visible to. + */ + @Query(""" + select poll from StoredPoll poll + where poll.closesOn < :cutoff or poll.lockedDay < :cutoff + """) + List findEndedBefore(@Param("cutoff") LocalDate cutoff); + + /** + * Every poll made before an instant, whatever state it is in. The ceiling reaches + * drafts and open polls too, so this one carries no predicate but the age. + */ + List findByCreatedAtBefore(Instant cutoff); + + /** + * The three ways a poll refers to a person, for the sweep that drops accounts nothing + * refers to any more. Addresses rather than accounts, because a poll stores addresses + * and some of them never had an account behind them. + */ + @Query("select poll.organizerEmail from StoredPoll poll") + List organizerAddresses(); + + @Query("select invitee from StoredPoll poll join poll.inviteeEmails invitee") + List inviteeAddresses(); + + @Query("select ballot.voterEmail from StoredBallot ballot") + List voterAddresses(); + /** Drafts are the organizer's alone, so this needs no invitee arm. */ List findByOrganizerEmailAndLockedDayIsNull(String organizerEmail, Sort sort); diff --git a/src/main/java/io/binarycodes/whichday/poll/service/PollService.java b/src/main/java/io/binarycodes/whichday/poll/service/PollService.java index e38fe91..01d75bf 100644 --- a/src/main/java/io/binarycodes/whichday/poll/service/PollService.java +++ b/src/main/java/io/binarycodes/whichday/poll/service/PollService.java @@ -2,6 +2,7 @@ import java.security.SecureRandom; import java.time.Clock; +import java.time.Instant; import java.time.LocalDate; import java.util.ArrayList; import java.util.Collection; @@ -19,6 +20,7 @@ import org.springframework.transaction.annotation.Transactional; import io.binarycodes.whichday.base.config.AccessMode; +import io.binarycodes.whichday.base.config.Retention; import io.binarycodes.whichday.people.domain.EmailAddress; import io.binarycodes.whichday.people.domain.Person; import io.binarycodes.whichday.people.service.PersonLookup; @@ -73,13 +75,16 @@ public class PollService { private final PollRepository polls; private final PersonLookup people; private final AccessMode access; + private final Retention retention; private final SecureRandom codes = new SecureRandom(); - public PollService(Clock clock, PollRepository polls, PersonLookup people, AccessMode access) { + public PollService(Clock clock, PollRepository polls, PersonLookup people, AccessMode access, + Retention retention) { this.clock = clock; this.polls = polls; this.people = people; this.access = access; + this.retention = retention; } /** @@ -253,6 +258,93 @@ public void deleteDraft(UUID id, Caller organizer) { polls.delete(stored); } + /** + * Throws away every poll a retention window has passed: a few days after it ended, + * and unconditionally once it reaches its maximum age. Deleting a poll deletes + * everything about it — the ballots, the invitations, the days and the answers — + * so afterwards a link somebody saved reads exactly as a link to a poll that never + * existed. That is the whole of what the screens have to do about it. + * + *

It takes no viewer because there is nobody to check: it is the one write here + * that no person asked for, so §10b's question of who may do what has no subject. + * The windows are the authority instead, and they are a deployment's to set. + * + *

Public deliberately, and not a candidate for tidying: Spring's proxy ignores + * {@code @Transactional} on a method that is not public, and this one has to + * override the read-only default the class carries — see {@link #record}, which + * documents the same trap from the other side. + * + *

No row lock of its own, and it does not need one. Every poll the ended window + * reaches is in a state {@link #requireEditable} and {@link #requireOpen} already + * refuse every writer. The maximum age can take one somebody is still answering — + * that is the ceiling doing what it is for — and the delete's own lock is what + * settles the race: either the answer commits and the poll goes afterwards, or the + * poll goes and the answer's locked read finds nothing, which is the same refusal a + * link nobody issued gets. + * + *

A set rather than two lists, because a poll both windows reach is one poll. The + * two queries run in one transaction, so the row they share comes back as the same + * managed instance and identity is enough to hold it once. + * + * @return how many polls were deleted, for the caller to say so + */ + @Transactional + public int deleteExpiredPolls() { + var today = LocalDate.now(clock); + var doomed = new LinkedHashSet(); + retention.afterPollEnds().cutoff(today).ifPresent(cutoff -> doomed.addAll(endedBefore(cutoff))); + retention.maximumAge().cutoff(clock) + .ifPresent(cutoff -> doomed.addAll(polls.findByCreatedAtBefore(cutoff))); + polls.deleteAll(doomed); + return doomed.size(); + } + + /** + * Polls that were over before the cutoff. The query narrows on the dates and this + * decides what the dates mean, so what counts as over stays {@link #stateOf}'s + * answer rather than becoming a second copy of it in JPQL. + * + *

The anchor is the later of the two days a poll can end on. A poll settled for a + * day after it stopped taking answers has not happened yet, and deleting it on its + * closing date would take away the answer the team comes back to it for. + */ + private List endedBefore(LocalDate cutoff) { + return polls.findEndedBefore(cutoff).stream() + .filter(this::isOver) + .filter(stored -> endedOn(stored).isBefore(cutoff)) + .toList(); + } + + private boolean isOver(StoredPoll stored) { + var state = stateOf(stored); + return state == PollState.CLOSED || state == PollState.LOCKED; + } + + /** Only ever asked of a poll {@link #isOver} has already vouched for, so it has a day. */ + private static LocalDate endedOn(StoredPoll stored) { + return Stream.of(stored.closesOn(), stored.lockedDay()) + .filter(Objects::nonNull) + .max(LocalDate::compareTo) + .orElseThrow(); + } + + /** + * Every address any poll refers to, in any of the three ways one can: as the person + * who called it, as somebody invited, or as somebody who answered. What the retention + * sweep needs to know before it drops an account, since a name that is still on a + * poll is a name that poll still has to show. + * + *

Whole columns rather than a join against the {@code account} table: the two are + * deliberately not joined anywhere (§10 — an invitee may have no account at all), and + * this keeps that true of the sweep as well. + */ + public Set addressesOnAnyPoll() { + var addresses = new LinkedHashSet<>(polls.organizerAddresses()); + addresses.addAll(polls.inviteeAddresses()); + addresses.addAll(polls.voterAddresses()); + return addresses; + } + /** * Nulls last rather than {@code Comparator.comparing} alone. A settled poll has a * locked day and so always has a headline day — but a row can now be written by diff --git a/src/main/java/io/binarycodes/whichday/poll/service/RetentionSweep.java b/src/main/java/io/binarycodes/whichday/poll/service/RetentionSweep.java new file mode 100644 index 0000000..821cc85 --- /dev/null +++ b/src/main/java/io/binarycodes/whichday/poll/service/RetentionSweep.java @@ -0,0 +1,73 @@ +package io.binarycodes.whichday.poll.service; + +import jakarta.annotation.PostConstruct; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import io.binarycodes.whichday.base.config.Retention; +import io.binarycodes.whichday.people.service.AccountDirectory; + +/** + * Runs the retention sweep and does nothing else. What it deletes and why belongs to the + * two services it asks; this is only the thing that asks them, and the order matters — + * the polls go first, so the names they were the last thing referring to are already + * unreferenced by the time the accounts are looked at. + * + *

A fixed delay rather than a nightly cron. A cron at three in the morning is skipped + * outright by a machine that is asleep then, and there is nothing about deleting rows + * that wants a particular hour — where a fixed delay always runs shortly after start-up, + * which is exactly when a deployment that has been down for a week needs it. + * + *

Conditional so that the test profile can turn it off: one Spring context serves the + * whole suite and {@code TestDatabase} empties the tables between methods, so a + * scheduled delete would be a race against whatever is running. The sweep is called + * directly there, which is the boundary worth testing anyway. + */ +@Component +@ConditionalOnProperty(name = "whichday.retention.sweep", havingValue = "on", matchIfMissing = true) +class RetentionSweep { + + private static final Logger LOG = LoggerFactory.getLogger(RetentionSweep.class); + + private final PollService polls; + private final AccountDirectory directory; + private final Retention retention; + + RetentionSweep(PollService polls, AccountDirectory directory, Retention retention) { + this.polls = polls; + this.directory = directory; + this.retention = retention; + } + + /** + * Said once at start-up, because these two windows are what decides whether a poll + * somebody is looking for still exists — and the only other place they are written + * down is the environment they arrived in. + */ + @PostConstruct + void announce() { + LOG.info("Retention: after a poll ends = {}, since it was created = {}", + retention.afterPollEnds(), retention.maximumAge()); + } + + /** + * Logged when it deletes anything, because a poll that vanished is otherwise + * something an operator can only discover from its absence. + */ + @Scheduled(initialDelayString = "${whichday.retention.sweep-delay:PT1M}", + fixedDelayString = "${whichday.retention.sweep-interval:PT24H}") + void sweep() { + var deletedPolls = polls.deleteExpiredPolls(); + var forgottenNames = directory.forgetExpiredAnonymous(polls.addressesOnAnyPoll()); + if (deletedPolls > 0 || forgottenNames > 0) { + LOG.info("Retention: deleted {} poll(s) and {} anonymous name(s)", + deletedPolls, forgottenNames); + } else { + LOG.debug("Retention: nothing past its window"); + } + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 2f90544..a58b25b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -49,6 +49,30 @@ spring.jpa.hibernate.ddl-auto=validate # a transaction is a bug, and this would hide it until the next thing moved. spring.jpa.open-in-view=false +# What a deployment keeps, and for how long. Two windows in whole days, and a poll goes +# when either has passed it — deleting the poll and everything about it, so a link +# somebody saved reads as a link to a poll that never existed. +# +# AFTER_POLL_ENDS measures from the day the poll ended: its closing date, or the day +# locked in if that one is later. A poll settled for a day still ahead is a poll the +# team has not had yet, and the date is exactly what they come back to it for. +# +# DAYS measures from the day it was created and reaches every poll there is, whatever +# state it is in — a draft nobody sent, a poll still taking answers, all of it. Nothing +# survives it. It is also the only rule that can reach every row, since created_at is +# the one date column on a poll that is never null. +# +# The same number drops the names anonymous sessions typed, once no poll refers to them +# any more. Only those: an address a provider vouched for belongs to somebody who can +# sign in again and be recognised. +# +# Either is "never" to turn that window off. A deletion takes the answers with it and +# cannot be undone — the only backup is the database file, copied while the application +# is stopped (docs/REQUIREMENTS.md §9) — so "never" is what a deployment that wants none +# of this says, rather than a very large number of days. +whichday.retention.after-poll-ends=${WHICHDAY_RETENTION_AFTER_POLL_ENDS:5} +whichday.retention.days=${WHICHDAY_RETENTION_DAYS:90} + vaadin.launch-browser=false # Copilot off. The application is started to be looked at, not edited in the browser: diff --git a/src/main/resources/db/migration/V3__account_created_at.sql b/src/main/resources/db/migration/V3__account_created_at.sql new file mode 100644 index 0000000..6916fc4 --- /dev/null +++ b/src/main/resources/db/migration/V3__account_created_at.sql @@ -0,0 +1,16 @@ +-- When an account was first seen, which is what the retention sweep needs to know +-- before it drops an anonymous one (docs/REQUIREMENTS.md §9). +-- +-- First seen and not last seen: `remember` runs on every read of who is looking, and a +-- column that moved each time would keep a session's row alive for as long as somebody +-- kept the tab open. The entity therefore declares it not updatable. +-- +-- Rows that already exist are stamped with the moment this runs. That is the only +-- honest answer available — nothing recorded when they arrived — and it means an +-- anonymous name already in the table gets its full window from here rather than being +-- swept the first time the sweep runs. +alter table account add column created_at timestamp(6) with time zone; + +update account set created_at = current_timestamp where created_at is null; + +alter table account alter column created_at set not null; diff --git a/src/test/java/io/binarycodes/whichday/base/config/RetentionWindowTest.java b/src/test/java/io/binarycodes/whichday/base/config/RetentionWindowTest.java new file mode 100644 index 0000000..7ff9f92 --- /dev/null +++ b/src/test/java/io/binarycodes/whichday/base/config/RetentionWindowTest.java @@ -0,0 +1,65 @@ +package io.binarycodes.whichday.base.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.LocalDate; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Reading a retention window") +class RetentionWindowTest { + + private static final String VARIABLE = "WHICHDAY_RETENTION_DAYS"; + private static final LocalDate TODAY = LocalDate.of(2026, 8, 20); + + @Test + @DisplayName("turns a number of days into the day an anchor has to fall before") + void cutoff() { + assertThat(RetentionWindow.of(VARIABLE, "5").cutoff(TODAY)).contains(TODAY.minusDays(5)); + assertThat(RetentionWindow.of(VARIABLE, " 90 ").cutoff(TODAY)).contains(TODAY.minusDays(90)); + } + + /** Zero is a real answer: everything that has ended, on the day it ended. */ + @Test + @DisplayName("takes zero as a window rather than as an absence") + void zeroIsAWindow() { + assertThat(RetentionWindow.of(VARIABLE, "0").cutoff(TODAY)).contains(TODAY); + } + + @Test + @DisplayName("has no cutoff at all when it is off, so the rule goes unrun") + void never() { + assertThat(RetentionWindow.of(VARIABLE, "never").cutoff(TODAY)).isEmpty(); + assertThat(RetentionWindow.of(VARIABLE, "Never").cutoff(TODAY)).isEmpty(); + assertThat(RetentionWindow.NEVER.cutoff(TODAY)).isEmpty(); + } + + /** + * Quoting both, because the property the application reads is not the variable the + * operator typed — and a window nobody can parse has no safe reading to fall back on. + */ + @Test + @DisplayName("refuses what it cannot read, naming the variable and the value") + void unreadable() { + assertThatThrownBy(() -> RetentionWindow.of(VARIABLE, "five")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining(VARIABLE) + .hasMessageContaining("\"five\"") + .hasMessageContaining("never"); + assertThatThrownBy(() -> RetentionWindow.of(VARIABLE, "")) + .isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> RetentionWindow.of(VARIABLE, null)) + .isInstanceOf(IllegalStateException.class); + } + + /** A negative window would delete a poll before the day it is measured from. */ + @Test + @DisplayName("refuses a negative number of days") + void negative() { + assertThatThrownBy(() -> RetentionWindow.of(VARIABLE, "-1")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("\"-1\""); + } +} diff --git a/src/test/java/io/binarycodes/whichday/people/service/AccountRetentionTest.java b/src/test/java/io/binarycodes/whichday/people/service/AccountRetentionTest.java new file mode 100644 index 0000000..297f3fb --- /dev/null +++ b/src/test/java/io/binarycodes/whichday/people/service/AccountRetentionTest.java @@ -0,0 +1,150 @@ +package io.binarycodes.whichday.people.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import io.binarycodes.whichday.AnonymousWhichdayTest; +import io.binarycodes.whichday.Sample; +import io.binarycodes.whichday.TestClock; +import io.binarycodes.whichday.TestDatabase; +import io.binarycodes.whichday.people.domain.AnonymousAddress; +import io.binarycodes.whichday.people.domain.Person; +import io.binarycodes.whichday.poll.domain.Caller; +import io.binarycodes.whichday.poll.service.PollService; + +/** + * What becomes of a name somebody typed into the who-are-you screen. Anonymous mode, + * because that is the only mode that mints an address — though the rule itself asks about + * the address and not about the mode. + * + *

The sweep is two calls in the order {@code RetentionSweep} makes them: the polls + * first, then the names nothing refers to any more. Called directly here, since the + * schedule is off under the test profile. + */ +@AnonymousWhichdayTest +@DisplayName("Forgetting a name nothing refers to") +class AccountRetentionTest { + + /** The default {@code WHICHDAY_RETENTION_DAYS}, which governs accounts as well as polls. */ + private static final int MAXIMUM_AGE = 90; + + @Autowired + private TestClock clock; + + @Autowired + private TestDatabase database; + + @Autowired + private AccountDirectory directory; + + @Autowired + private PollService polls; + + @BeforeEach + void setUp() { + database.empty(); + clock.reset(); + } + + @Test + @DisplayName("keeps a name for its whole window and drops it the day after") + void dropsAnAbandonedName() { + var visitor = typedAName("Ada"); + + clock.advanceDays(MAXIMUM_AGE); + assertThat(sweep()).isZero(); + assertThat(directory.byEmail(visitor.email())).isPresent(); + + clock.advanceDays(1); + assertThat(sweep()).isEqualTo(1); + assertThat(directory.byEmail(visitor.email())).isEmpty(); + } + + /** + * The whole of the condition: a name a poll still mentions is a name that poll still + * has to show, however old the row is. Dropping it would render that person as their + * own minted address on everybody else's screen. + */ + @Test + @DisplayName("keeps a name past its window while a poll still refers to it") + void keepsANameAPollStillNeeds() { + var invitee = typedAName("Sara"); + + clock.advanceDays(80); + var organizer = typedAName("Miro"); + pollBy(organizer, List.of(organizer, invitee)); + + clock.advanceDays(11); + assertThat(sweep()).isZero(); + assertThat(directory.byEmail(invitee.email())).isPresent(); + + // Once that poll reaches its own maximum age, nothing refers to either of them. + clock.advanceDays(MAXIMUM_AGE); + assertThat(sweep()).isEqualTo(2); + assertThat(directory.byEmail(invitee.email())).isEmpty(); + assertThat(directory.byEmail(organizer.email())).isEmpty(); + } + + /** + * An address a provider vouched for belongs to somebody who can come back and be + * recognised. A minted one belonged to a session that no longer exists, and that is + * the whole difference. + */ + @Test + @DisplayName("never drops an address a provider vouched for, at any age") + void keepsWhatAProviderVouchedFor() { + directory.remember(Sample.ADA); + + clock.advanceDays(MAXIMUM_AGE * 4); + + assertThat(sweep()).isZero(); + assertThat(directory.byEmail(Sample.ADA.email())).isPresent(); + } + + /** A voter's name outlives their poll by one sweep, and no longer. */ + @Test + @DisplayName("drops a voter's name once the poll they answered has gone") + void aVotersNameGoesWithTheirPoll() { + var organizer = typedAName("Ada"); + var voter = typedAName("Tom"); + var poll = pollBy(organizer, List.of(organizer)); + polls.castVote(poll, voter, Set.of(polls.poll(poll, organizer).orElseThrow() + .candidateDays().getFirst())); + + clock.advanceDays(MAXIMUM_AGE + 1); + + assertThat(polls.deleteExpiredPolls()).isEqualTo(1); + assertThat(directory.forgetExpiredAnonymous(polls.addressesOnAnyPoll())).isEqualTo(2); + assertThat(directory.byEmail(voter.email())).isEmpty(); + assertThat(database.rowsIn("account")).isZero(); + } + + /** What the who-are-you screen does: mint an address, then remember the name on it. */ + private Person typedAName(String name) { + var visitor = Person.signedIn(AnonymousAddress.mintedAt(clock), name); + directory.remember(visitor); + return visitor; + } + + private UUID pollBy(Person organizer, List invited) { + var id = polls.create("Q3 offsite", organizer, invited); + polls.replaceCandidateDays(id, Caller.of(organizer), + List.of(Sample.mondayAfterNext(LocalDate.now(clock)))); + polls.send(id, Caller.of(organizer)); + return id; + } + + private int sweep() { + polls.deleteExpiredPolls(); + return directory.forgetExpiredAnonymous(polls.addressesOnAnyPoll()); + } +} diff --git a/src/test/java/io/binarycodes/whichday/poll/service/PollRetentionTest.java b/src/test/java/io/binarycodes/whichday/poll/service/PollRetentionTest.java new file mode 100644 index 0000000..fb31f7f --- /dev/null +++ b/src/test/java/io/binarycodes/whichday/poll/service/PollRetentionTest.java @@ -0,0 +1,199 @@ +package io.binarycodes.whichday.poll.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import io.binarycodes.whichday.Sample; +import io.binarycodes.whichday.TestClock; +import io.binarycodes.whichday.TestDatabase; +import io.binarycodes.whichday.WhichdayTest; +import io.binarycodes.whichday.people.service.AccountDirectory; +import io.binarycodes.whichday.poll.domain.Caller; +import io.binarycodes.whichday.poll.domain.PollState; + +/** + * The retention sweep, at the two windows a deployment gets by default: five days after + * a poll ends, and ninety from the day it was made whatever state it is in. + * + *

Every case moves {@link TestClock} rather than computing a cutoff, so what is being + * checked is the rule a deployment lives with and not an arithmetic restatement of it. + */ +@WhichdayTest +@DisplayName("Sweeping polls that are past their window") +class PollRetentionTest { + + /** The defaults in {@code application.properties}, which the test profile leaves alone. */ + private static final int AFTER_POLL_ENDS = 5; + private static final int MAXIMUM_AGE = 90; + + @Autowired + private TestClock clock; + + @Autowired + private TestDatabase database; + + @Autowired + private PollService service; + + @Autowired + private AccountDirectory directory; + + @BeforeEach + void setUp() { + database.empty(); + clock.reset(); + Sample.signedInBefore(directory); + } + + @Test + @DisplayName("keeps a poll for its whole window and deletes it the day after") + void endedPollGoesWhenTheWindowHasPassed() { + var ends = today().plusDays(7); + var id = pollClosingOn(ends); + clock.advanceDays(8); + assertThat(state(id)).isEqualTo(PollState.CLOSED); + + clock.advanceDays(4); + assertThat(service.deleteExpiredPolls()).isZero(); + assertThat(service.poll(id, Sample.ADA)).isPresent(); + assertThat(today()).isEqualTo(ends.plusDays(AFTER_POLL_ENDS)); + + clock.advanceDays(1); + assertThat(service.deleteExpiredPolls()).isEqualTo(1); + assertThat(service.poll(id, Sample.ADA)).isEmpty(); + } + + /** + * The case the anchor exists for: a poll settled for a day after it stopped taking + * answers has not happened yet, and its closing date is five days gone while the + * team is still waiting on the date it went there to find. + */ + @Test + @DisplayName("measures a settled poll from the day locked in, not the day it closed") + void settledPollGoesFromItsLockedDay() { + var soon = today().plusDays(3); + var meeting = today().plusDays(30); + var id = service.create("Sprint retro", Sample.ADA, Sample.TEAM); + service.replaceCandidateDays(id, Caller.of(Sample.ADA), List.of(soon, meeting)); + service.send(id, Caller.of(Sample.ADA)); + service.closeOn(id, Caller.of(Sample.ADA), soon); + service.lock(id, Caller.of(Sample.ADA), meeting); + + clock.advanceDays(9); + assertThat(service.deleteExpiredPolls()).isZero(); + assertThat(service.poll(id, Sample.ADA)).isPresent(); + + clock.advanceDays(27); + assertThat(today()).isEqualTo(meeting.plusDays(AFTER_POLL_ENDS + 1)); + assertThat(service.deleteExpiredPolls()).isEqualTo(1); + assertThat(service.poll(id, Sample.ADA)).isEmpty(); + } + + @Test + @DisplayName("leaves a draft and a poll still taking answers alone") + void nothingUnfinishedGoesEarly() { + var draft = service.create("Not sent yet", Sample.ADA, Sample.TEAM); + var open = pollClosingOn(today().plusDays(200)); + + clock.advanceDays(40); + + assertThat(service.deleteExpiredPolls()).isZero(); + assertThat(service.poll(draft, Sample.ADA)).isPresent(); + assertThat(state(open)).isEqualTo(PollState.OPEN); + } + + /** + * The ceiling, and the whole reason there is one: a draft has no date to have ended + * on and an open poll's closing date can be months out, so without a rule measured + * from creation there are rows no rule reaches. + */ + @Test + @DisplayName("takes everything at the maximum age, answers still coming in or not") + void maximumAgeTakesEverything() { + var draft = service.create("Never sent", Sample.ADA, Sample.TEAM); + var open = pollClosingOn(today().plusDays(200)); + var ended = pollClosingOn(today().plusDays(2)); + var settled = Sample.settled(service, clock); + + clock.advanceDays(MAXIMUM_AGE + 1); + + assertThat(state(open)).isEqualTo(PollState.OPEN); + assertThat(service.deleteExpiredPolls()).isEqualTo(4); + assertThat(database.rowsIn("poll")).isZero(); + assertThat(List.of(draft, open, ended, settled)) + .allSatisfy(id -> assertThat(service.poll(id, Sample.ADA)).isEmpty()); + } + + @Test + @DisplayName("keeps a draft for its ninetieth day and deletes it on the next") + void maximumAgeBoundary() { + var draft = service.create("Never sent", Sample.ADA, Sample.TEAM); + + clock.advanceDays(MAXIMUM_AGE); + assertThat(service.deleteExpiredPolls()).isZero(); + assertThat(service.poll(draft, Sample.ADA)).isPresent(); + + clock.advanceDays(1); + assertThat(service.deleteExpiredPolls()).isEqualTo(1); + assertThat(service.poll(draft, Sample.ADA)).isEmpty(); + } + + /** Both windows reach the same poll here, and it is still one poll. */ + @Test + @DisplayName("deletes a poll both windows have passed once") + void countedOnce() { + pollClosingOn(today().plusDays(2)); + + clock.advanceDays(MAXIMUM_AGE + 1); + + assertThat(service.deleteExpiredPolls()).isEqualTo(1); + } + + /** + * Everything about the poll, which is what makes a saved link read as a link to a + * poll that never existed. The account rows are not this sweep's to touch: dropping a + * name is a separate rule with conditions of its own, and it never reaches an address + * a provider vouched for — {@code AccountRetentionTest} is that half. + */ + @Test + @DisplayName("takes the answers, the invitations, the days and the proposals with it") + void deletesEverythingAboutThePoll() { + var id = Sample.offsite(service, clock); + service.decline(id, Sample.JONAS, List.of(today().plusMonths(2)), "None of these work"); + assertThat(database.rowsIn("ballot_proposal")).isEqualTo(1); + + clock.advanceDays(40); + assertThat(service.deleteExpiredPolls()).isEqualTo(1); + + assertThat(database.rowsIn("poll")).isZero(); + assertThat(database.rowsIn("poll_invitee")).isZero(); + assertThat(database.rowsIn("candidate_day")).isZero(); + assertThat(database.rowsIn("ballot")).isZero(); + assertThat(database.rowsIn("ballot_day")).isZero(); + assertThat(database.rowsIn("ballot_proposal")).isZero(); + assertThat(database.rowsIn("account")).isEqualTo(Sample.EVERYBODY.size()); + } + + private UUID pollClosingOn(LocalDate day) { + var id = service.create("Poll closing " + day, Sample.ADA, Sample.TEAM); + service.replaceCandidateDays(id, Caller.of(Sample.ADA), List.of(day)); + service.send(id, Caller.of(Sample.ADA)); + return id; + } + + private PollState state(UUID id) { + return service.poll(id, Sample.ADA).orElseThrow().state(); + } + + private LocalDate today() { + return LocalDate.now(clock); + } +} diff --git a/src/test/java/io/binarycodes/whichday/poll/ui/view/AnonymousPollJourneyTest.java b/src/test/java/io/binarycodes/whichday/poll/ui/view/AnonymousPollJourneyTest.java index 2790f7a..c8a5a18 100644 --- a/src/test/java/io/binarycodes/whichday/poll/ui/view/AnonymousPollJourneyTest.java +++ b/src/test/java/io/binarycodes/whichday/poll/ui/view/AnonymousPollJourneyTest.java @@ -217,6 +217,29 @@ void aCodeIsOnlyGoodForItsOwnPoll() { .isInstanceOf(NotTheOrganizerException.class); } + /** + * Retention takes an anonymous poll like any other, and the six digits that let + * somebody change one are worth nothing once there is nothing left to change. The + * saved link lands where a link nobody issued lands. + */ + @Test + @DisplayName("leaves a swept poll's code and link worth nothing") + void aSweptPollIsGoneForTheCodeHolderToo() { + var poll = pollCalledBy("Ada", "Q3 offsite"); + var code = presenter().adminCode(poll).orElseThrow(); + var day = onlyDayOf(poll); + + clock.advanceDays(40); + assertThat(context.getBean(PollService.class).deleteExpiredPolls()).isEqualTo(1); + + var holder = visitor("Miro", code); + assertThat(holder.poll(poll)).isEmpty(); + assertThatThrownBy(() -> holder.lock(poll, day)).isInstanceOf(IllegalArgumentException.class); + + navigateTo(ResultsView.class, poll); + assertThat(currentView()).isInstanceOf(NotFoundView.class); + } + /** * The standings header stacked avatars, and an initial identifies nobody here for * the same reason it identifies nobody on the ballot. diff --git a/src/test/java/io/binarycodes/whichday/poll/ui/view/PollJourneyTest.java b/src/test/java/io/binarycodes/whichday/poll/ui/view/PollJourneyTest.java index a1c411a..7cc03b6 100644 --- a/src/test/java/io/binarycodes/whichday/poll/ui/view/PollJourneyTest.java +++ b/src/test/java/io/binarycodes/whichday/poll/ui/view/PollJourneyTest.java @@ -753,6 +753,26 @@ void unknownPoll() { assertThat(currentView()).isInstanceOf(NotFoundView.class); } + /** + * A link somebody saved, after retention deleted the poll it points at. Word for word + * the screen an id nobody issued gets — which is the whole of what the sweep asks of + * the views, and why it needed none of them changed. + */ + @Test + @DisplayName("sends a link to a swept poll to the same screen as a link to no poll") + void sweptPoll() { + clock.advanceDays(40); + assertThat(context.getBean(PollService.class).deleteExpiredPolls()).isEqualTo(1); + + navigateToPoll(ResultsView.class, offsite); + var swept = textOf(currentView()); + assertThat(currentView()).isInstanceOf(NotFoundView.class); + + navigateToPoll(ResultsView.class, UUID.randomUUID()); + + assertThat(swept).isEqualTo(textOf(currentView())); + } + @Test @DisplayName("signing in as somebody who was not invited is the same as no poll at all") void aStrangerCannotTellThePollExists() { diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 55035a0..fd828da 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -35,3 +35,9 @@ logging.level.io.binarycodes.whichday=WARN # deployed one. DB_CLOSE_DELAY=-1 keeps the schema alive if the pool ever holds no # connection. spring.datasource.url=jdbc:h2:mem:whichday;DB_CLOSE_DELAY=-1;${whichday.database.options} + +# The sweep is off here, and the windows themselves are left at their defaults. One +# context serves the whole suite and TestDatabase empties the tables between methods, so +# a scheduled delete would be a race against whatever test is running; PollRetentionTest +# calls the sweep itself. +whichday.retention.sweep=off