Closes #27. Guild membership is now the whole gate. `discordCallback` checks membership (and `DISCORD_REQUIRED_ROLE` when set), then `Store.EnsureReader` creates the Reader on first sight and returns the same row on every later login. The refusal returns before `EnsureReader`, so a turned-away sign-in leaves no row behind. `OWNER_DISCORD_ID` still seeds the owner, but only as the administrator — it no longer gates login. The cutover grace path goes with it: `API_TOKEN`, `API_TOKEN_GRACE_UNTIL` and the legacy branch in `httpmw.ResolveReader` are deleted, so a credential authenticates exactly one Reader or nothing. `userscript.Handler` drops its re-derivation too — the resolved path segment is already the credential. New surfaces: an empty library offers both install links (behind the tab-specific empty states, so "No favourites yet" still wins), and the owner alone gets a Readers panel with `POST /readers/{id}/revoke`. The owner's own row is not revocable — 404, not a self-logout. Isolation is asserted from both directions for read, modify and delete, and the shared-series invariant is pinned: two Readers on one series produce one series row, two independent progresses, one poll per due cycle, and one Reader's delete leaves the other's bookmark and the poll intact. Verified: `go test ./...` green; live smoke against a throwaway Postgres — empty-library state in both colour branches, roster rendering, a real revoke through the panel (target 401s next request, owner untouched), owner self-revoke refused 404, per-Reader `/u/<cred>` and bearer auth both 200 with 404 for an unknown credential. Reviewed-on: #36 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #36.
This commit is contained in:
@@ -16,7 +16,7 @@ import (
|
||||
func TestMain(m *testing.M) { os.Exit(pgtest.Main(m)) }
|
||||
|
||||
// testOwner is the owner every test store seeds. Tests that need a second
|
||||
// reader insert one directly (see secondReader).
|
||||
// reader register one (see secondReader).
|
||||
var testOwner = Owner{DiscordID: "test-owner", TokenHash: sha256.Sum256([]byte("owner-token-hash"))}
|
||||
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
@@ -29,17 +29,14 @@ func newTestStore(t *testing.T) *Store {
|
||||
return store
|
||||
}
|
||||
|
||||
// secondReader inserts an extra reader row and returns its id. The store API
|
||||
// has no reader-creation path yet — the seed is the only one — so tests that
|
||||
// need reader isolation insert directly.
|
||||
// secondReader registers an extra reader through the same path a first login
|
||||
// takes, and returns its id.
|
||||
func secondReader(t *testing.T, s *Store) int64 {
|
||||
t.Helper()
|
||||
hash := sha256.Sum256([]byte("second-token-hash"))
|
||||
var id int64
|
||||
if err := s.db.QueryRow(
|
||||
`INSERT INTO readers (discord_id, token_sha256) VALUES ($1, $2) RETURNING id`,
|
||||
"second-"+strconv.FormatInt(time.Now().UnixNano(), 10), hash[:]).Scan(&id); err != nil {
|
||||
t.Fatalf("seed second reader: %v", err)
|
||||
discordID := "second-" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
id, err := s.EnsureReader(discordID, sha256.Sum256([]byte("token-"+discordID)))
|
||||
if err != nil {
|
||||
t.Fatalf("register second reader: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -1052,3 +1049,153 @@ func TestDeleteReaderCascadesToBookmarks(t *testing.T) {
|
||||
t.Fatalf("series = %+v, want it kept after its only reader was deleted", sr)
|
||||
}
|
||||
}
|
||||
|
||||
// Registration is one code path: the first sight of a Discord identity creates
|
||||
// the Reader, every later one returns the same row. The epoch-0 hash argument
|
||||
// is for creation only — a returning Reader who has rotated must not have that
|
||||
// rotation undone by logging in again.
|
||||
func TestEnsureReaderCreatesOnceAndNeverClobbersARotation(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
first, err := s.EnsureReader("new-member", sha256.Sum256([]byte("cred-epoch-0")))
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureReader: %v", err)
|
||||
}
|
||||
if first == s.OwnerID() {
|
||||
t.Fatal("a new Discord identity resolved to the owner Reader")
|
||||
}
|
||||
if id, ok, err := s.ReaderIDForTokenHash(sha256.Sum256([]byte("cred-epoch-0"))); err != nil || !ok || id != first {
|
||||
t.Fatalf("new Reader's credential resolved to (%d, %v, %v), want (%d, true, nil)", id, ok, err, first)
|
||||
}
|
||||
|
||||
rotated := sha256.Sum256([]byte("cred-epoch-1"))
|
||||
if err := s.RotateToken(first, 0, rotated); err != nil {
|
||||
t.Fatalf("RotateToken: %v", err)
|
||||
}
|
||||
|
||||
again, err := s.EnsureReader("new-member", sha256.Sum256([]byte("cred-epoch-0")))
|
||||
if err != nil {
|
||||
t.Fatalf("second EnsureReader: %v", err)
|
||||
}
|
||||
if again != first {
|
||||
t.Fatalf("second login returned Reader %d, want the existing %d", again, first)
|
||||
}
|
||||
if _, ok, err := s.ReaderIDForTokenHash(sha256.Sum256([]byte("cred-epoch-0"))); err != nil {
|
||||
t.Fatalf("stale lookup: %v", err)
|
||||
} else if ok {
|
||||
t.Fatal("logging in again revived the pre-rotation credential")
|
||||
}
|
||||
if id, ok, err := s.ReaderIDForTokenHash(rotated); err != nil || !ok || id != first {
|
||||
t.Fatalf("rotated credential resolved to (%d, %v, %v), want the same Reader", id, ok, err)
|
||||
}
|
||||
|
||||
// Signing in as the owner's own Discord identity reuses the seeded row
|
||||
// rather than minting a duplicate library.
|
||||
if id, err := s.EnsureReader(testOwner.DiscordID, sha256.Sum256([]byte("ignored"))); err != nil {
|
||||
t.Fatalf("EnsureReader(owner): %v", err)
|
||||
} else if id != s.OwnerID() {
|
||||
t.Fatalf("owner login returned Reader %d, want the seeded owner %d", id, s.OwnerID())
|
||||
}
|
||||
}
|
||||
|
||||
// The owner's administration view: who exists and how many live sessions each
|
||||
// holds. Revocation drops all of one Reader's sessions and nobody else's.
|
||||
func TestReadersAndSessionRevocation(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
other := secondReader(t, s)
|
||||
for _, id := range []string{"own-1", "own-2"} {
|
||||
if _, err := s.CreateSession(id, s.OwnerID(), time.Hour); err != nil {
|
||||
t.Fatalf("CreateSession(%s): %v", id, err)
|
||||
}
|
||||
}
|
||||
if _, err := s.CreateSession("other-1", other, time.Hour); err != nil {
|
||||
t.Fatalf("CreateSession(other): %v", err)
|
||||
}
|
||||
// An expired row must not be counted as a session the owner can revoke.
|
||||
if _, err := s.CreateSession("other-dead", other, -time.Minute); err != nil {
|
||||
t.Fatalf("CreateSession(expired): %v", err)
|
||||
}
|
||||
|
||||
readers, err := s.Readers()
|
||||
if err != nil {
|
||||
t.Fatalf("Readers: %v", err)
|
||||
}
|
||||
if len(readers) != 2 || readers[0].ID != s.OwnerID() || readers[1].ID != other {
|
||||
t.Fatalf("readers = %+v, want the owner then the second Reader", readers)
|
||||
}
|
||||
if readers[0].DiscordID != testOwner.DiscordID {
|
||||
t.Fatalf("owner discord id = %q, want %q", readers[0].DiscordID, testOwner.DiscordID)
|
||||
}
|
||||
if readers[0].Sessions != 2 || readers[1].Sessions != 1 {
|
||||
t.Fatalf("session counts = %d, %d; want 2 and 1 live", readers[0].Sessions, readers[1].Sessions)
|
||||
}
|
||||
|
||||
if err := s.DeleteReaderSessions(other); err != nil {
|
||||
t.Fatalf("DeleteReaderSessions: %v", err)
|
||||
}
|
||||
if _, ok, err := s.GetSession("other-1", time.Now()); err != nil || ok {
|
||||
t.Fatalf("revoked session still resolves: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if _, ok, err := s.GetSession("own-1", time.Now()); err != nil || !ok {
|
||||
t.Fatalf("owner's session was collateral: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// row is still due exactly once.
|
||||
func TestTwoReadersShareOneSeriesWithIndependentProgress(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
other := secondReader(t, s)
|
||||
if _, err := s.Upsert(s.OwnerID(), Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/comics/solo",
|
||||
LastChapter: "Chapter 200", LastChapterNum: 200, UpdatedAt: 1000,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed owner: %v", err)
|
||||
}
|
||||
theirs, err := s.Upsert(other, Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo", UpdatedAt: 2000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed other: %v", err)
|
||||
}
|
||||
if theirs.LastChapterNum != 0 || theirs.LastChapter != "" {
|
||||
t.Fatalf("second Reader's progress = %+v, want zero regardless of the first's 200", theirs)
|
||||
}
|
||||
// The shared facts are still shared: the series row it joined to is the
|
||||
// one the first Reader created.
|
||||
if theirs.Title != "Solo Leveling" {
|
||||
t.Fatalf("second Reader's title = %q, want the shared series title", theirs.Title)
|
||||
}
|
||||
var series int
|
||||
if err := s.db.QueryRow(`SELECT count(*) FROM series`).Scan(&series); err != nil {
|
||||
t.Fatalf("count series: %v", err)
|
||||
}
|
||||
if series != 1 {
|
||||
t.Fatalf("series rows = %d, want 1 shared row for two bookmarks", series)
|
||||
}
|
||||
|
||||
due, err := s.DueForLatestCheck(time.Now().UnixMilli(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck: %v", err)
|
||||
}
|
||||
if len(due) != 1 || due[0].Key() != "asura:solo" {
|
||||
t.Fatalf("due = %+v, want the shared series exactly once per cycle", due)
|
||||
}
|
||||
|
||||
// One Reader dropping their bookmark leaves the other's intact and the
|
||||
// series still polled.
|
||||
if err := s.Delete(other, "asura:solo"); err != nil {
|
||||
t.Fatalf("Delete(other): %v", err)
|
||||
}
|
||||
if b, ok, err := s.Get(s.OwnerID(), "asura:solo"); err != nil || !ok || b.LastChapterNum != 200 {
|
||||
t.Fatalf("owner's bookmark after the other's delete = %+v ok=%v err=%v, want it intact", b, ok, err)
|
||||
}
|
||||
due, err = s.DueForLatestCheck(time.Now().UnixMilli(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck after delete: %v", err)
|
||||
}
|
||||
if len(due) != 1 || due[0].Key() != "asura:solo" {
|
||||
t.Fatalf("due after one Reader left = %+v, want the series still polled", due)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user