Add the schema, Config and Service
One table, no users-table foreign key (apps own identity), key id on every row so a rotated VAPID key is visible per subscription. New refuses an empty key instead of minting one: a key minted at boot is a key lost at the next restore. Contact is validated as given and kept mailto-stripped for the wire, because webpush-go prefixes mailto: itself and would otherwise send "mailto:mailto:…" as the JWT subject.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
7 files changed,
+350
−0
aviso.go+114 −0aviso_test.go+64 −0go.mod+20 −0go.sum+73 −0migrations.go+16 −0migrations/0001_init.sql+17 −0schema_test.go+46 −0
diff --git a/aviso.go b/aviso.go| new file mode 100644 |
| index 0000000..29c2f35 |
| --- /dev/null |
| +++ b/aviso.go |
| @@ -0,0 +1,114 @@ |
| +package aviso |
| + |
| +import ( |
| + "database/sql" |
| + "errors" |
| + "log/slog" |
| + "net/http" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// Config configures New. DB, PrivateKey, Contact and Origin are |
| +// required. |
| +type Config struct { |
| + // DB is the app's writer. Schema must have been applied. |
| + DB *sql.DB |
| + // PrivateKey is the VAPID private key: unpadded base64url, 32-byte |
| + // P-256 scalar, as cmd/aviso-key prints it. Provisioned, never |
| + // minted here — see ErrEmptyPrivateKey. |
| + PrivateKey string |
| + // Contact is the VAPID "sub" claim — a mailto: or https: URL a push |
| + // service may use to reach the operator about abuse. |
| + Contact string |
| + // Origin is the app's external origin, scheme included, for |
| + // csrf.SameOrigin on the mutating handlers. |
| + Origin string |
| + // Concurrency bounds in-flight sends across the whole Service. |
| + // 0 means 32. |
| + Concurrency int |
| + Logger *slog.Logger |
| +} |
| + |
| +// Subscription is what the browser hands the app: the push service's |
| +// endpoint and the two keys RFC 8291 encrypts to. |
| +type Subscription struct { |
| + Endpoint string |
| + P256dh string |
| + Auth string |
| +} |
| + |
| +// Stored is one enrolled device: a Subscription plus its row identity. |
| +// Revision changes on every re-subscribe, and Send matches on it so a |
| +// slow send cannot prune a subscription the browser refreshed |
| +// meanwhile. |
| +type Stored struct { |
| + ID string |
| + Subject string |
| + VAPIDKeyID string |
| + Revision int64 |
| + Subscription |
| +} |
| + |
| +// ErrOwnedElsewhere is Subscribe's refusal to move an endpoint between |
| +// subjects: a second account on the same browser must re-enrol, not |
| +// silently take over the first account's device. |
| +var ErrOwnedElsewhere = errors.New("aviso: endpoint is enrolled by another subject") |
| + |
| +// ErrKeyMismatch marks a Result for a row enrolled under a VAPID key |
| +// other than this Service's: it cannot be signed for, so it is skipped |
| +// rather than sent to fail. |
| +var ErrKeyMismatch = errors.New("aviso: subscription was enrolled under a different VAPID key") |
| + |
| +// Service is the wired addon. Build one per process and share it: the |
| +// concurrency bound lives on it. |
| +type Service struct { |
| + cfg Config |
| + pub string |
| + // keyID is SHA-256 of the public point; rows carry it, and Send |
| + // skips rows that do not match rather than signing for them with |
| + // a key the browser never subscribed to. |
| + keyID string |
| + // wireContact is Contact as webpush-go wants it: it prefixes |
| + // "mailto:" itself to anything not https:, so handing it the |
| + // mailto: form verbatim would produce "mailto:mailto:…". |
| + wireContact string |
| + client *http.Client // built by newClient (ssrf.go); tests may replace it |
| + sem chan struct{} |
| + now func() time.Time |
| +} |
| + |
| +// New validates cfg and returns a ready *Service. |
| +func New(cfg Config) (*Service, error) { |
| + if cfg.DB == nil { |
| + return nil, errors.New("aviso: Config.DB is required") |
| + } |
| + pub, keyID, err := parsePrivateKey(cfg.PrivateKey) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if !strings.HasPrefix(cfg.Contact, "mailto:") && !strings.HasPrefix(cfg.Contact, "https://") { |
| + return nil, errors.New("aviso: Config.Contact must be a mailto: or https: URL") |
| + } |
| + if !strings.HasPrefix(cfg.Origin, "https://") && !strings.HasPrefix(cfg.Origin, "http://") { |
| + return nil, errors.New("aviso: Config.Origin must be an absolute origin like https://app.example.com") |
| + } |
| + if cfg.Concurrency <= 0 { |
| + cfg.Concurrency = 32 |
| + } |
| + if cfg.Logger == nil { |
| + cfg.Logger = slog.Default() |
| + } |
| + return &Service{ |
| + cfg: cfg, |
| + pub: pub, |
| + keyID: keyID, |
| + wireContact: strings.TrimPrefix(cfg.Contact, "mailto:"), |
| + sem: make(chan struct{}, cfg.Concurrency), |
| + now: time.Now, |
| + }, nil |
| +} |
| + |
| +// PublicKeyString is the applicationServerKey the browser subscribes |
| +// with: unpadded base64url of the uncompressed P-256 point. |
| +func (s *Service) PublicKeyString() string { return s.pub } |
diff --git a/aviso_test.go b/aviso_test.go| new file mode 100644 |
| index 0000000..1020ff5 |
| --- /dev/null |
| +++ b/aviso_test.go |
| @@ -0,0 +1,64 @@ |
| +package aviso_test |
| + |
| +import ( |
| + "errors" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/aviso" |
| +) |
| + |
| +func newService(t *testing.T) *aviso.Service { |
| + t.Helper() |
| + key, err := aviso.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + s, err := aviso.New(aviso.Config{ |
| + DB: openDB(t), PrivateKey: key, |
| + Contact: "mailto:ops@example.test", Origin: "https://app.example.test", |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return s |
| +} |
| + |
| +func TestNewRefusesBadConfig(t *testing.T) { |
| + key, _ := aviso.GenerateKey() |
| + good := aviso.Config{DB: openDB(t), PrivateKey: key, Contact: "mailto:x@y", Origin: "https://a"} |
| + if _, err := aviso.New(good); err != nil { |
| + t.Fatalf("good config refused: %v", err) |
| + } |
| + c := good |
| + c.PrivateKey = "" |
| + if _, err := aviso.New(c); !errors.Is(err, aviso.ErrEmptyPrivateKey) { |
| + t.Errorf("empty key: %v", err) |
| + } |
| + c = good |
| + c.PrivateKey = "not-a-key" |
| + if _, err := aviso.New(c); !errors.Is(err, aviso.ErrInvalidPrivateKey) { |
| + t.Errorf("bad key: %v", err) |
| + } |
| + c = good |
| + c.DB = nil |
| + if _, err := aviso.New(c); err == nil { |
| + t.Error("nil DB accepted") |
| + } |
| + c = good |
| + c.Contact = "ops@example.test" |
| + if _, err := aviso.New(c); err == nil { |
| + t.Error("bare address accepted as Contact") |
| + } |
| + c = good |
| + c.Origin = "app.example.test" |
| + if _, err := aviso.New(c); err == nil { |
| + t.Error("schemeless Origin accepted") |
| + } |
| +} |
| + |
| +func TestPublicKeyStringIsStable(t *testing.T) { |
| + s := newService(t) |
| + if s.PublicKeyString() == "" { |
| + t.Fatal("empty public key") |
| + } |
| +} |
diff --git a/go.mod b/go.mod| index 2c5e035..e513ce0 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -1,3 +1,23 @@ |
| module amadan.net/rastrillo/aviso |
| go 1.25.0 |
| + |
| +require amadan.net/rastrillo/rastrillo v0.26.0 |
| + |
| +require ( |
| + github.com/dustin/go-humanize v1.0.1 // indirect |
| + github.com/google/uuid v1.6.0 // indirect |
| + github.com/jinzhu/inflection v1.0.0 // indirect |
| + github.com/jinzhu/now v1.1.5 // indirect |
| + github.com/mattn/go-isatty v0.0.20 // indirect |
| + github.com/ncruces/go-strftime v1.0.0 // indirect |
| + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
| + golang.org/x/sys v0.46.0 // indirect |
| + golang.org/x/text v0.20.0 // indirect |
| + gorm.io/gorm v1.31.2 // indirect |
| + gorm.io/plugin/dbresolver v1.6.2 // indirect |
| + modernc.org/libc v1.74.1 // indirect |
| + modernc.org/mathutil v1.7.1 // indirect |
| + modernc.org/memory v1.11.0 // indirect |
| + modernc.org/sqlite v1.55.0 // indirect |
| +) |
diff --git a/go.sum b/go.sum| new file mode 100644 |
| index 0000000..2e62e67 |
| --- /dev/null |
| +++ b/go.sum |
| @@ -0,0 +1,73 @@ |
| +amadan.net/rastrillo/rastrillo v0.26.0 h1:I7UkiDbT304q9wXnmgabe84P6RDFTjMbKU174i1QZDo= |
| +amadan.net/rastrillo/rastrillo v0.26.0/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE= |
| +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= |
| +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= |
| +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= |
| +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= |
| +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= |
| +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= |
| +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= |
| +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= |
| +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= |
| +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= |
| +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= |
| +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= |
| +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= |
| +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= |
| +github.com/keymaildev/signin v0.1.1 h1:gO+1IAM99sqUkBtCezkgGxab+/DOrqwDxd5H32N1s9Q= |
| +github.com/keymaildev/signin v0.1.1/go.mod h1:Eb/sCmEel1jlcdkgPOrNeMn5jvxzoFvJrdjDUxOBHls= |
| +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= |
| +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |
| +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= |
| +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= |
| +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= |
| +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= |
| +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
| +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
| +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= |
| +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= |
| +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= |
| +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= |
| +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= |
| +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= |
| +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= |
| +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= |
| +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= |
| +gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= |
| +gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= |
| +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= |
| +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= |
| +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= |
| +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= |
| +gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc= |
| +gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM= |
| +modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= |
| +modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= |
| +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= |
| +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= |
| +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= |
| +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= |
| +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= |
| +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= |
| +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= |
| +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= |
| +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= |
| +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= |
| +modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= |
| +modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= |
| +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= |
| +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= |
| +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= |
| +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= |
| +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= |
| +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= |
| +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= |
| +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= |
| +modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM= |
| +modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= |
| +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= |
| +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= |
| +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= |
| +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= |
diff --git a/migrations.go b/migrations.go| new file mode 100644 |
| index 0000000..05203fe |
| --- /dev/null |
| +++ b/migrations.go |
| @@ -0,0 +1,16 @@ |
| +package aviso |
| + |
| +import ( |
| + "embed" |
| + |
| + "amadan.net/rastrillo/rastrillo/migrate" |
| +) |
| + |
| +//go:embed migrations/*.sql |
| +var migrationFS embed.FS |
| + |
| +// Schema is the addon's migration set. Merge it into the app's |
| +// BootSchema, never its Schema: `rastrillo migration check` diffs |
| +// Schema against Models and would propose dropping a table Models |
| +// does not know about. |
| +var Schema = migrate.MustFromFS(migrationFS, "aviso") |
diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql| new file mode 100644 |
| index 0000000..1fe1c51 |
| --- /dev/null |
| +++ b/migrations/0001_init.sql |
| @@ -0,0 +1,17 @@ |
| +-- One row per browser subscription. `endpoint` is the push service's |
| +-- unguessable URL and is unique by construction; `subject` is the |
| +-- rastrillo session subject that enrolled it. No foreign key to any |
| +-- users table: apps own their identity schema. |
| +CREATE TABLE aviso_subscriptions ( |
| + id TEXT NOT NULL PRIMARY KEY, |
| + endpoint TEXT NOT NULL UNIQUE, |
| + subject TEXT NOT NULL CHECK (length(subject) > 0), |
| + p256dh TEXT NOT NULL, |
| + auth TEXT NOT NULL, |
| + vapid_key_id TEXT NOT NULL, |
| + revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0), |
| + created_at INTEGER NOT NULL, |
| + last_confirmed_at INTEGER NOT NULL |
| +); |
| +CREATE INDEX aviso_subscriptions_subject ON aviso_subscriptions(subject); |
| +CREATE INDEX aviso_subscriptions_confirmed ON aviso_subscriptions(last_confirmed_at); |
diff --git a/schema_test.go b/schema_test.go| new file mode 100644 |
| index 0000000..059851c |
| --- /dev/null |
| +++ b/schema_test.go |
| @@ -0,0 +1,46 @@ |
| +package aviso_test |
| + |
| +import ( |
| + "context" |
| + "database/sql" |
| + "path/filepath" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/rastrillo/db" |
| + "amadan.net/rastrillo/rastrillo/migrate" |
| + |
| + "amadan.net/rastrillo/aviso" |
| +) |
| + |
| +// openDB is a fresh on-disk rastrillo db with aviso.Schema applied — |
| +// what every store and handler test starts from. |
| +func openDB(t *testing.T) *sql.DB { |
| + t.Helper() |
| + d, err := db.Open(filepath.Join(t.TempDir(), "aviso.db"), nil) |
| + if err != nil { |
| + t.Fatalf("db.Open: %v", err) |
| + } |
| + t.Cleanup(func() { d.Close() }) |
| + if _, err := migrate.Apply(context.Background(), d, aviso.Schema); err != nil { |
| + t.Fatalf("migrate.Apply: %v", err) |
| + } |
| + return d.Writer() |
| +} |
| + |
| +func TestSchemaCreatesTheTableAndReplaysCleanly(t *testing.T) { |
| + d, err := db.Open(filepath.Join(t.TempDir(), "aviso.db"), nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer d.Close() |
| + for i := 0; i < 2; i++ { // second Apply must be a no-op, not a failure |
| + if _, err := migrate.Apply(context.Background(), d, aviso.Schema); err != nil { |
| + t.Fatalf("apply %d: %v", i, err) |
| + } |
| + } |
| + var name string |
| + err = d.Writer().QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='aviso_subscriptions'`).Scan(&name) |
| + if err != nil { |
| + t.Fatalf("table missing: %v", err) |
| + } |
| +} |