test: expect signed one-time material uploads
- TestUploadTicket: HMAC under the jz.bm.upload.v1: label, expiry, malformed and foreign tickets, single use through the cache - TestSignedMaterialUploadRefusesBadTickets, TestCreateMaterialUploadNeedsCaller - TestRoutes, TestBMMCP and TestManageBucketKey expect the upload-url route, the uploads route with its two buckets and the upload tool - compile-only stubs
This commit is contained in:
164
classes/uploads_test.go
Normal file
164
classes/uploads_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package classes_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/cooler"
|
||||
"git.golem15.com/jakub/sm-bm-plugin/classes"
|
||||
)
|
||||
|
||||
// signWith builds a ticket for payload with an explicit label, as another
|
||||
// plugin (or a forger who knows the format) would.
|
||||
func signWith(key []byte, label, payload string) string {
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write([]byte(label + payload))
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func memCache(t *testing.T) cooler.Cache {
|
||||
t.Helper()
|
||||
c, err := cooler.NewMemoryCache("test:", 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// TestUploadTicket (D-10, D-12) covers the signed one-time upload tickets:
|
||||
// the HMAC under the jz.bm.upload.v1: label, the expiry and the single use.
|
||||
func TestUploadTicket(t *testing.T) {
|
||||
key := classes.UploadKey(" app-key ")
|
||||
now := time.Date(2026, 10, 28, 14, 0, 0, 0, time.UTC)
|
||||
nonce := strings.Repeat("ab", 16)
|
||||
tk := classes.UploadTicket{Kind: "material", Target: 12, User: 7, Expires: now.Add(classes.UploadTTL), Nonce: nonce}
|
||||
payload := "material|12|7|" + strconvInt(now.Add(classes.UploadTTL).Unix()) + "|" + nonce
|
||||
|
||||
t.Run("key", func(t *testing.T) {
|
||||
if string(key) != "app-key" {
|
||||
t.Errorf("UploadKey trimmed = %q, want app-key", key)
|
||||
}
|
||||
if classes.UploadKey(" ") != nil || classes.UploadKey("") != nil {
|
||||
t.Error("an empty app key is not nil")
|
||||
}
|
||||
if classes.UploadTTL != 10*time.Minute {
|
||||
t.Errorf("UploadTTL = %v, want 10m", classes.UploadTTL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("round trip", func(t *testing.T) {
|
||||
signed, err := classes.SignUpload(key, tk)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if signed != signWith(key, "jz.bm.upload.v1:", payload) {
|
||||
t.Errorf("ticket %q is not base64url(payload).base64url(HMAC(label+payload))", signed)
|
||||
}
|
||||
got, err := classes.VerifyUpload(key, signed, now)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyUpload: %v", err)
|
||||
}
|
||||
if got.Kind != "material" || got.Target != 12 || got.User != 7 || !got.Expires.Equal(tk.Expires) || got.Nonce != nonce {
|
||||
t.Errorf("verified %+v, want %+v", got, tk)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("refused", func(t *testing.T) {
|
||||
signed := signWith(key, "jz.bm.upload.v1:", payload)
|
||||
head, mac, _ := strings.Cut(signed, ".")
|
||||
raw, _ := base64.RawURLEncoding.DecodeString(head)
|
||||
raw[len("material|1")] = '3'
|
||||
macRaw, _ := base64.RawURLEncoding.DecodeString(mac)
|
||||
macRaw[0] ^= 1
|
||||
cases := map[string]string{
|
||||
"empty": "",
|
||||
"no dot": head,
|
||||
"two dots": signed + ".x",
|
||||
"bad base64 payload": "!!!." + mac,
|
||||
"bad base64 mac": head + ".!!!",
|
||||
"changed payload": base64.RawURLEncoding.EncodeToString(raw) + "." + mac,
|
||||
"changed mac": head + "." + base64.RawURLEncoding.EncodeToString(macRaw),
|
||||
"other key": signWith([]byte("other-key"), "jz.bm.upload.v1:", payload),
|
||||
"other label": signWith(key, "golem15.presentations.upload.v1:", payload),
|
||||
"no label": signWith(key, "", payload),
|
||||
"four fields": signWith(key, "jz.bm.upload.v1:", "material|12|7|"+strconvInt(tk.Expires.Unix())),
|
||||
"six fields": signWith(key, "jz.bm.upload.v1:", payload+"|x"),
|
||||
"zero target": signWith(key, "jz.bm.upload.v1:", "material|0|7|"+strconvInt(tk.Expires.Unix())+"|"+nonce),
|
||||
"short nonce": signWith(key, "jz.bm.upload.v1:", "material|12|7|"+strconvInt(tk.Expires.Unix())+"|abcd"),
|
||||
"far expiry": signWith(key, "jz.bm.upload.v1:", "material|12|7|"+strconvInt(now.Add(24*time.Hour).Unix())+"|"+nonce),
|
||||
}
|
||||
for name, ticket := range cases {
|
||||
if _, err := classes.VerifyUpload(key, ticket, now); !errors.Is(err, classes.ErrUploadTicket) {
|
||||
t.Errorf("%s: err %v, want ErrUploadTicket", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := classes.VerifyUpload(key, signed, tk.Expires); !errors.Is(err, classes.ErrUploadTicket) {
|
||||
t.Errorf("expired: err %v, want ErrUploadTicket", err)
|
||||
}
|
||||
if _, err := classes.VerifyUpload(nil, signed, now); !errors.Is(err, classes.ErrUploadTicket) {
|
||||
t.Errorf("no key: err %v, want ErrUploadTicket", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("issue and redeem once", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cache := memCache(t)
|
||||
if _, _, err := classes.IssueUpload(ctx, cache, nil, "material", 12, 7, now); !errors.Is(err, classes.ErrUploadKeyMissing) {
|
||||
t.Errorf("empty key: err %v, want ErrUploadKeyMissing", err)
|
||||
}
|
||||
ticket, expires, err := classes.IssueUpload(ctx, cache, key, "material", 12, 7, now)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueUpload: %v", err)
|
||||
}
|
||||
if !expires.Equal(now.Add(classes.UploadTTL)) {
|
||||
t.Errorf("expires %v, want %v", expires, now.Add(classes.UploadTTL))
|
||||
}
|
||||
if strings.Contains(ticket, string(key)) {
|
||||
t.Error("the ticket carries the key")
|
||||
}
|
||||
got, err := classes.RedeemUpload(ctx, cache, key, ticket, now.Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("first RedeemUpload: %v", err)
|
||||
}
|
||||
if got.Kind != "material" || got.Target != 12 || got.User != 7 || len(got.Nonce) != 32 {
|
||||
t.Errorf("redeemed %+v", got)
|
||||
}
|
||||
if _, err := classes.RedeemUpload(ctx, cache, key, ticket, now.Add(time.Minute)); !errors.Is(err, classes.ErrUploadTicket) {
|
||||
t.Errorf("second RedeemUpload: err %v, want ErrUploadTicket", err)
|
||||
}
|
||||
other, _, err := classes.IssueUpload(ctx, cache, key, "material", 12, 7, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if other == ticket {
|
||||
t.Error("two issued tickets are equal (no fresh nonce)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("restart fails closed", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ticket, _, err := classes.IssueUpload(ctx, memCache(t), key, "material", 12, 7, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := classes.RedeemUpload(ctx, memCache(t), key, ticket, now); !errors.Is(err, classes.ErrUploadTicket) {
|
||||
t.Errorf("ticket without its cache entry: err %v, want ErrUploadTicket", err)
|
||||
}
|
||||
signed, _ := classes.SignUpload(key, tk)
|
||||
if _, err := classes.RedeemUpload(ctx, memCache(t), key, signed, now); !errors.Is(err, classes.ErrUploadTicket) {
|
||||
t.Errorf("signed but never issued: err %v, want ErrUploadTicket", err)
|
||||
}
|
||||
if _, err := classes.RedeemUpload(ctx, nil, key, signed, now); !errors.Is(err, classes.ErrUploadTicket) {
|
||||
t.Errorf("no cache: err %v, want ErrUploadTicket", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func strconvInt(n int64) string { return strconv.FormatInt(n, 10) }
|
||||
Reference in New Issue
Block a user