Files
Loki/internal/loki/parity_push_test.go
T
Claude eddcf97e3b Lot 12 : notifications Web Push tenues par la façade (LOKI_PUSH=rust)
La façade sert la clé VAPID et les (dés)abonnements, et pousse elle-même
les notifications « réponse prête » quand LOKI_PUSH=rust (expérimental ;
le Go reste le défaut) : chiffrement RFC 8291 (aes128gcm, enregistrement
complété à 4096 octets comme webpush-go), signature VAPID RFC 8292 (JWT
ES256), abonnements purgés sur 404/410, par ring déjà présent via rustls.
Mêmes clés en base que le Go.

Côté Go : facade_push.go remet les routes du tunnel à la façade, et
sendPushToAll (fin de tour, fin de tâche) lui délègue l'envoi.

Parité : parity_push_test.go tire du Go les durées et onze appels de route
avec la liste des abonnements en base, rejoués par tests/push.rs ; le
chiffrement reproduit l'exemple de la RFC 8291 à l'octet près, et un envoi
de bout en bout est déchiffré côté navigateur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 17:01:07 +00:00

110 lines
4.1 KiB
Go

package loki
import (
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
)
// parity_push_test.go — journal de référence des notifications Web Push (lot
// 12 de la migration Rust) : clé VAPID (générée une fois, stable), abonnement,
// désabonnement, et le texte du corps de notification. Rangé dans
// rust/loki-rs/tests/parity_push/push.json ; tests/push.rs le rejoue par la
// façade. Les clés tirées au hasard sont masquées (seule leur forme compte).
//
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteNotifications
const parityPushFile = "../../rust/loki-rs/tests/parity_push/push.json"
type parityPushCall struct {
Method string `json:"method"`
Path string `json:"path"`
Body string `json:"body,omitempty"`
Status int `json:"status"`
Out any `json:"out"`
// Après l'appel : les abonnements enregistrés (JSON brut, tel qu'en base).
Subs string `json:"subs"`
}
type parityPushFixture struct {
Durations map[string]string `json:"durations"`
Calls []parityPushCall `json:"calls"`
}
func TestPariteNotifications(t *testing.T) {
testHome(t)
var fx parityPushFixture
fx.Durations = map[string]string{}
for _, ms := range []int64{0, 400, 600, 42_000, 185_000, 3_600_000, 4_330_000, -5_000} {
fx.Durations[strings.TrimSpace(time.Duration(ms*int64(time.Millisecond)).String())] = fmtDurFR(time.Duration(ms) * time.Millisecond)
}
keyRe := regexp.MustCompile(`"key":"[A-Za-z0-9_-]{87}"`)
call := func(method, path, body string) parityPushCall {
w := httptest.NewRecorder()
r := httptest.NewRequest(method, path, strings.NewReader(body))
switch path {
case "/api/push/key":
handlePushKey(w, r)
case "/api/push/subscribe":
handlePushSubscribe(w, r)
case "/api/push/unsubscribe":
handlePushUnsubscribe(w, r)
default:
t.Fatalf("route inattendue %s", path)
}
out := keyRe.ReplaceAllString(w.Body.String(), `"key":"{{CLE}}"`)
c := parityPushCall{Method: method, Path: path, Body: body, Status: w.Code, Subs: string(getBytes(bkState, stPushSubs))}
if err := json.Unmarshal([]byte(out), &c.Out); err != nil {
t.Fatalf("%s : %v (%s)", path, err, w.Body.String())
}
return c
}
for _, c := range []struct{ m, p, b string }{
{"GET", "/api/push/key", ""},
{"POST", "/api/push/subscribe", `{"endpoint":"https://push.exemple.fr/a","keys":{"p256dh":"BAAA","auth":"x1"}}`},
{"POST", "/api/push/subscribe", `{"endpoint":"https://push.exemple.fr/b","keys":{"p256dh":"BBBB","auth":"x2"},"expirationTime":null}`},
{"POST", "/api/push/subscribe", `{"endpoint":"https://push.exemple.fr/a","keys":{"p256dh":"BCCC","auth":"x3"}}`},
{"POST", "/api/push/subscribe", `{"keys":{"p256dh":"B","auth":"y"}}`},
{"POST", "/api/push/subscribe", `pas du json`},
{"POST", "/api/push/unsubscribe", `{"endpoint":"https://push.exemple.fr/inconnu"}`},
{"POST", "/api/push/unsubscribe", `{"endpoint":"https://push.exemple.fr/a"}`},
{"POST", "/api/push/unsubscribe", `pas du json`},
{"POST", "/api/push/unsubscribe", `{"endpoint":"https://push.exemple.fr/b"}`},
{"GET", "/api/push/key", ""},
} {
fx.Calls = append(fx.Calls, call(c.m, c.p, c.b))
}
// La paire VAPID est stable : la clé servie est la clé rangée.
if pub := getStr(bkState, stPushVAPIDPub); len(pub) != 87 || len(getStr(bkState, stPushVAPIDPriv)) != 43 {
t.Fatalf("paire VAPID inattendue : %q", pub)
}
got, err := parityJSON(fx)
if err != nil {
t.Fatal(err)
}
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
if err := os.MkdirAll(filepath.Dir(parityPushFile), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(parityPushFile, got, 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(parityPushFile)
if err != nil {
t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
}
if string(got) != string(want) {
t.Errorf("les notifications ne produisent plus le journal rangé dans %s.\n"+
"Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteNotifications,\n"+
"puis cargo test --test push dit si le port Rust suit.\n--- obtenu ---\n%s", parityPushFile, got)
}
}