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) }