store: Sighting counters and the owner's clear control (#102)
The owner's page (issue #102) shows each Reader's Sighting record and offers one action to wipe it. The counters ship before the Sighting feature that moves them (issue #103) on purpose: the remedy for a false mark must exist before marks can be made, or the first broken adapter is fixed with SQL against production. - 0010 adds sighting_agreements / sighting_disagreements, both zero-defaulted, so every existing Reader reads as trusted. - ReaderSummary carries both, plus Blocked() against SightingDisagreementLimit, so the page states the verdict rather than making the owner derive it. - ClearReaderMarks zeroes one Reader's pair.
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
-- The owner's administrative page (issue #102) renders these counters and
|
||||||
|
-- offers a control to clear them, deliberately shipped before the Sighting
|
||||||
|
-- feature (issue #103) that fills them, so a false mark never needs SQL
|
||||||
|
-- against production. Zero counters mean a trusted Reader.
|
||||||
|
ALTER TABLE readers ADD COLUMN sighting_agreements integer NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE readers ADD COLUMN sighting_disagreements integer NOT NULL DEFAULT 0;
|
||||||
@@ -315,25 +315,42 @@ func (s *Store) EnsureReader(discordID string, epochZeroHash [32]byte) (int64, e
|
|||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SightingDisagreementLimit is the number of contradictions that stop that
|
||||||
|
// Reader's Sightings from deferring a Poll (issue #103). The counters exist
|
||||||
|
// before the mechanism that moves them, so the admin page (issue #102) can
|
||||||
|
// clear a false mark without waiting for the Sighting feature.
|
||||||
|
const SightingDisagreementLimit = 3
|
||||||
|
|
||||||
|
// Blocked reports whether this Reader's Sighting marks have reached the
|
||||||
|
// disagreement limit, which stops their Sightings from deferring a Poll.
|
||||||
|
func (r ReaderSummary) Blocked() bool {
|
||||||
|
return r.Disagreements >= SightingDisagreementLimit
|
||||||
|
}
|
||||||
|
|
||||||
// ReaderSummary is one Reader as the owner's administration panel sees them:
|
// ReaderSummary is one Reader as the owner's administration panel sees them:
|
||||||
// who they are and how many live sessions they hold. No credential material,
|
// who they are, how many live sessions they hold, and their Sighting marks.
|
||||||
// hashed or otherwise, is exposed.
|
// No credential material, hashed or otherwise, is exposed.
|
||||||
type ReaderSummary struct {
|
type ReaderSummary struct {
|
||||||
ID int64
|
ID int64
|
||||||
DiscordID string
|
DiscordID string
|
||||||
// Sessions counts unexpired session rows — what the owner revokes.
|
// Sessions counts unexpired session rows — what the owner revokes.
|
||||||
Sessions int
|
Sessions int
|
||||||
|
// Agreements and Disagreements are the Sighting counters (issue #102);
|
||||||
|
// zero means a trusted Reader.
|
||||||
|
Agreements int
|
||||||
|
Disagreements int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Readers lists every Reader with their live session count, oldest first, so
|
// Readers lists every Reader with their live session count and Sighting
|
||||||
// the owner row (always the oldest) heads the list.
|
// marks, oldest first, so the owner row (always the oldest) heads the list.
|
||||||
func (s *Store) Readers() ([]ReaderSummary, error) {
|
func (s *Store) Readers() ([]ReaderSummary, error) {
|
||||||
rows, err := s.db.Query(`
|
rows, err := s.db.Query(`
|
||||||
SELECT r.id, r.discord_id,
|
SELECT r.id, r.discord_id,
|
||||||
|
r.sighting_agreements, r.sighting_disagreements,
|
||||||
count(sess.id) FILTER (WHERE sess.expires_at > now()) AS sessions
|
count(sess.id) FILTER (WHERE sess.expires_at > now()) AS sessions
|
||||||
FROM readers r
|
FROM readers r
|
||||||
LEFT JOIN sessions sess ON sess.reader_id = r.id
|
LEFT JOIN sessions sess ON sess.reader_id = r.id
|
||||||
GROUP BY r.id, r.discord_id
|
GROUP BY r.id, r.discord_id, r.sighting_agreements, r.sighting_disagreements
|
||||||
ORDER BY r.id`)
|
ORDER BY r.id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("query readers: %w", err)
|
return nil, fmt.Errorf("query readers: %w", err)
|
||||||
@@ -343,7 +360,7 @@ func (s *Store) Readers() ([]ReaderSummary, error) {
|
|||||||
out := []ReaderSummary{}
|
out := []ReaderSummary{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r ReaderSummary
|
var r ReaderSummary
|
||||||
if err := rows.Scan(&r.ID, &r.DiscordID, &r.Sessions); err != nil {
|
if err := rows.Scan(&r.ID, &r.DiscordID, &r.Agreements, &r.Disagreements, &r.Sessions); err != nil {
|
||||||
return nil, fmt.Errorf("scan reader: %w", err)
|
return nil, fmt.Errorf("scan reader: %w", err)
|
||||||
}
|
}
|
||||||
out = append(out, r)
|
out = append(out, r)
|
||||||
@@ -351,6 +368,18 @@ func (s *Store) Readers() ([]ReaderSummary, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClearReaderMarks zeroes a Reader's Sighting counters. It is the owner's
|
||||||
|
// remedy for a mark produced by a broken Site adapter rather than a dishonest
|
||||||
|
// Reader: it restores a privilege, it is not destruction.
|
||||||
|
func (s *Store) ClearReaderMarks(readerID int64) error {
|
||||||
|
if _, err := s.db.Exec(`
|
||||||
|
UPDATE readers SET sighting_agreements = 0, sighting_disagreements = 0
|
||||||
|
WHERE id = $1`, readerID); err != nil {
|
||||||
|
return fmt.Errorf("clear reader marks for reader %d: %w", readerID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// readersMigration is the version that creates the readers table. The owner
|
// readersMigration is the version that creates the readers table. The owner
|
||||||
// seed runs between two migrate passes, so that the run-once migration which
|
// seed runs between two migrate passes, so that the run-once migration which
|
||||||
// attaches existing bookmarks (0004) finds the owner row.
|
// attaches existing bookmarks (0004) finds the owner row.
|
||||||
|
|||||||
@@ -1334,6 +1334,86 @@ func TestReadersAndSessionRevocation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Sighting counters ship before the mechanism that fills them (issue
|
||||||
|
// #102 before #103), so the admin page depends on their default: a fresh
|
||||||
|
// Reader reads back trusted. Marking one directly proves Readers() reports
|
||||||
|
// the counters and Blocked() flips at the limit, and that ClearReaderMarks —
|
||||||
|
// the owner's remedy for a false mark — zeroes them again.
|
||||||
|
func TestReaderSightingMarks(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
other := secondReader(t, s)
|
||||||
|
|
||||||
|
readers, err := s.Readers()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Readers: %v", err)
|
||||||
|
}
|
||||||
|
if len(readers) != 2 {
|
||||||
|
t.Fatalf("readers = %d, want the owner and the second Reader", len(readers))
|
||||||
|
}
|
||||||
|
for _, r := range readers {
|
||||||
|
if r.Agreements != 0 || r.Disagreements != 0 || r.Blocked() {
|
||||||
|
t.Fatalf("fresh reader %d has marks: %+v", r.ID, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The counters' default is the trusted state; writing them directly is
|
||||||
|
// the only way to exercise the read path until issue #103 moves them.
|
||||||
|
if _, err := s.db.Exec(`
|
||||||
|
UPDATE readers SET sighting_agreements = 5, sighting_disagreements = 2
|
||||||
|
WHERE id = $1`, other); err != nil {
|
||||||
|
t.Fatalf("mark reader: %v", err)
|
||||||
|
}
|
||||||
|
readers, err = s.Readers()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Readers: %v", err)
|
||||||
|
}
|
||||||
|
var marked *ReaderSummary
|
||||||
|
for i := range readers {
|
||||||
|
if readers[i].ID == other {
|
||||||
|
marked = &readers[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if marked == nil || marked.Agreements != 5 || marked.Disagreements != 2 {
|
||||||
|
t.Fatalf("marked reader = %+v, want agreements 5, disagreements 2", marked)
|
||||||
|
}
|
||||||
|
if marked.Blocked() {
|
||||||
|
t.Fatalf("reader with 2 disagreements is blocked; limit is %d", SightingDisagreementLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.db.Exec(`
|
||||||
|
UPDATE readers SET sighting_disagreements = 3 WHERE id = $1`, other); err != nil {
|
||||||
|
t.Fatalf("block reader: %v", err)
|
||||||
|
}
|
||||||
|
readers, err = s.Readers()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Readers: %v", err)
|
||||||
|
}
|
||||||
|
for _, r := range readers {
|
||||||
|
if r.ID == other && !r.Blocked() {
|
||||||
|
t.Fatalf("reader at the disagreement limit is not blocked: %+v", r)
|
||||||
|
}
|
||||||
|
if r.ID == s.OwnerID() && r.Blocked() {
|
||||||
|
t.Fatalf("untouched owner became blocked: %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.ClearReaderMarks(other); err != nil {
|
||||||
|
t.Fatalf("ClearReaderMarks: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.ClearReaderMarks(other + 9999); err != nil {
|
||||||
|
t.Fatalf("ClearReaderMarks(unknown id): %v", err)
|
||||||
|
}
|
||||||
|
readers, err = s.Readers()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Readers: %v", err)
|
||||||
|
}
|
||||||
|
for _, r := range readers {
|
||||||
|
if r.Agreements != 0 || r.Disagreements != 0 || r.Blocked() {
|
||||||
|
t.Fatalf("reader %d not cleared: %+v", r.ID, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Two Readers on one Series: one series row, two independent progresses. The
|
// Two Readers on one Series: one series row, two independent progresses. The
|
||||||
// second Reader starts at zero however far the first has read, and the shared
|
// second Reader starts at zero however far the first has read, and the shared
|
||||||
// row is still due exactly once.
|
// row is still due exactly once.
|
||||||
|
|||||||
Reference in New Issue
Block a user