package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"craftdesign.group/quarry/internal/ops"
)
// A browser refuses to register a worker served as anything but
// JavaScript, and a worker without Service-Worker-Allowed may only
// control the path it was served from — which would make a
// notification click unable to focus any page (RFC 0011 §6).
func TestServiceWorkerHeaders(t *testing.T) {
rec := httptest.NewRecorder()
ServiceWorker().ServeHTTP(rec, httptest.NewRequest("GET", "/sw.js", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/javascript") {
t.Errorf("Content-Type = %q", ct)
}
if got := rec.Header().Get("Service-Worker-Allowed"); got != "/" {
t.Errorf("Service-Worker-Allowed = %q", got)
}
body := rec.Body.String()
// The three jobs the worker exists to do. Asserted as the event
// names it registers, not as copy.
for _, want := range []string{"'push'", "'notificationclick'", "'pushsubscriptionchange'"} {
if !strings.Contains(body, want) {
t.Errorf("sw.js does not handle %s", want)
}
}
}
// The re-subscribe path must reach an endpoint that authenticates the
// way a service worker CAN. /api/* is HTTP Basic only (router.go), and
// a worker has no credentials to send — its fetch would be answered
// 401 and the device would go quiet with nobody told. So it posts to
// the cookie-session web routes with credentials: 'same-origin'.
func TestServiceWorkerReSubscribesThroughACookieAuthedRoute(t *testing.T) {
body := serviceWorkerSource()
for _, want := range []string{"'/me/push/key'", "'/me/push'", "credentials: 'same-origin'"} {
if !strings.Contains(body, want) {
t.Errorf("sw.js does not use %s", want)
}
}
// The API routes cannot answer a worker's cookie fetch at all.
if strings.Contains(body, "/api/v1/me/push") {
t.Error("sw.js fetches an HTTP-Basic-only API route; a worker has no credentials for it")
}
}
// A notification's url arrives from outside quarry — it is whatever the
// workflow's data carried. Opening or focusing it unchecked turns every
// push into an open redirect, so the worker compares ORIGINS with the
// URL parser rather than looking for a substring.
func TestServiceWorkerGuardsAgainstForeignOrigins(t *testing.T) {
body := serviceWorkerSource()
for _, want := range []string{"new URL(", "self.location.origin", "sameOrigin"} {
if !strings.Contains(body, want) {
t.Errorf("sw.js has no same-origin check: missing %s", want)
}
}
// indexOf on a URL is the check this replaces: "https://evil.test/"
// contains "/" and would match every client.
if strings.Contains(body, ".indexOf(url)") {
t.Error("sw.js still matches clients by substring")
}
}
// serviceWorkerSource is what the route actually serves — asserted
// through the handler so a test cannot pass against a file the route
// does not ship.
func serviceWorkerSource() string {
rec := httptest.NewRecorder()
ServiceWorker().ServeHTTP(rec, httptest.NewRequest("GET", "/sw.js", nil))
return rec.Body.String()
}
// Mounted by the API router too, so a node that serves this router at
// the root needs no extra wiring.
func TestRouterServesTheServiceWorker(t *testing.T) {
h, err := Router(ops.NewRegistry(), "0.0.0-test")
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/sw.js", nil))
if rec.Code != http.StatusOK || rec.Header().Get("Service-Worker-Allowed") != "/" {
t.Fatalf("status = %d, headers = %+v", rec.Code, rec.Header())
}
}