Files
opencloud/pkg/jmap/jmap_integration_test.go
2025-12-17 09:34:22 +01:00

1200 lines
29 KiB
Go

package jmap
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log"
"maps"
"math/rand"
"net/http"
"net/http/httputil"
"net/url"
"os"
"reflect"
"regexp"
"slices"
"strings"
"testing"
"text/template"
"time"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/require"
"github.com/gorilla/websocket"
"github.com/tidwall/pretty"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
"github.com/brianvoe/gofakeit/v7"
pw "github.com/sethvargo/go-password/password"
"github.com/opencloud-eu/opencloud/pkg/jscontact"
clog "github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/structs"
"github.com/go-crypt/crypt/algorithm/shacrypt"
)
const (
EnableTypes = false
// Wireshark = "/usr/bin/wireshark"
Wireshark = ""
)
type User struct {
name string
description string
email string
password string
}
func userpassword() string {
password, err := pw.Generate(4+rand.Intn(28), 2, 0, false, true)
if err != nil {
panic(err)
}
return password
}
var (
domains = [...]string{"earth.gov", "mars.mil", "opa.org"}
users = [...]User{
{"cdrummer", "Camina Drummer", "camina.drummer@opa.org", userpassword()},
{"aburton", "Amos Burton", "amos.burton@earth.gov", userpassword()},
{"jholden", "James Holden", "james.holden@earth.gov", userpassword()},
{"adawes", "Anderson Dawes", "anderson.dawes@opa.org", userpassword()},
{"nnagata", "Naomi Nagata", "naomi.nagata@opa.org", userpassword()},
{"kashford", "Klaes Ashford", "klaes.ashford@opa.org", userpassword()},
{"fjohnson", "Fred Johnson", "fred.johnson@opa.org", userpassword()},
{"cavasarala", "Chrisjen Avasarala}", "chrissy@earth.gov", userpassword()},
{"bdraper", "Roberta Draper", "bobby@mars.mil", userpassword()},
}
)
const (
stalwartImage = "ghcr.io/stalwartlabs/stalwart:v0.15.0-alpine"
httpPort = "8080"
imapsPort = "993"
configTemplate = `
authentication.fallback-admin.secret = "secret"
authentication.fallback-admin.user = "mailadmin"
authentication.master.secret = "{{.masterpassword}}"
authentication.master.user = "{{.masterusername}}"
directory.test.bind.auth.method = "default"
directory.test.cache.size = 1048576
directory.test.cache.ttl.negative = "10m"
directory.test.cache.ttl.positive = "1h"
directory.test.store = "rocksdb"
directory.test.type = "internal"
metrics.prometheus.enable = false
server.listener.http.bind = "[::]:{{.httpPort}}"
server.listener.http.protocol = "http"
server.listener.imaptls.bind = "[::]:{{.imapsPort}}"
server.listener.imaptls.protocol = "imap"
server.listener.imaptls.tls.implicit = true
server.hostname = "{{.hostname}}"
server.max-connections = 8192
server.socket.backlog = 1024
server.socket.nodelay = true
server.socket.reuse-addr = true
server.socket.reuse-port = true
storage.blob = "rocksdb"
storage.data = "rocksdb"
storage.directory = "test"
storage.fts = "rocksdb"
storage.lookup = "rocksdb"
store.rocksdb.compression = "lz4"
store.rocksdb.path = "/opt/stalwart/data"
store.rocksdb.type = "rocksdb"
tracer.log.ansi = false
tracer.log.buffered = false
tracer.log.enable = true
tracer.log.level = "trace"
tracer.log.lossy = false
tracer.log.multiline = false
tracer.log.type = "stdout"
sharing.allow-directory-query = false
auth.dkim.sign = false
auth.dkim.verify = "disable"
auth.spf.verify.ehlo = "disable"
auth.spf.verify.mail-from = "disable"
auth.arc.verify = "disable"
auth.arc.seal = false
auth.dmarc.verify = "disable"
auth.iprev.verify = "disable"
`
)
func skip(t *testing.T) bool {
if os.Getenv("CI") == "woodpecker" {
t.Skip("Skipping tests because CI==wookpecker")
return true
}
if os.Getenv("CI_SYSTEM_NAME") == "woodpecker" {
t.Skip("Skipping tests because CI_SYSTEM_NAME==wookpecker")
return true
}
if os.Getenv("USE_TESTCONTAINERS") == "false" {
t.Skip("Skipping tests because USE_TESTCONTAINERS==false")
return true
}
return false
}
type StalwartTest struct {
t *testing.T
ip string
imapPort int
container *testcontainers.DockerContainer
ctx context.Context
cancelCtx context.CancelFunc
client *Client
logger *clog.Logger
jmapBaseUrl *url.URL
sessionUrl *url.URL
io.Closer
}
func (s *StalwartTest) Close() error {
if s.container != nil {
var c testcontainers.Container = s.container
testcontainers.CleanupContainer(s.t, c)
}
if s.cancelCtx != nil {
s.cancelCtx()
}
return nil
}
func (s *StalwartTest) Session(username string) *Session {
session, jerr := s.client.FetchSession(s.sessionUrl, username, s.logger)
require.NoError(s.t, jerr)
require.NotNil(s.t, session.Capabilities.Mail)
require.NotNil(s.t, session.Capabilities.Calendars)
require.NotNil(s.t, session.Capabilities.Contacts)
// we have to overwrite the hostname in JMAP URL because the container
// will know its name to be a random Docker container identifier, or
// "localhost" as we defined the hostname in the Stalwart configuration,
// and we also need to overwrite the port number as its not mapped
session.JmapUrl.Host = s.jmapBaseUrl.Host
session.WebsocketUrl.Host = s.jmapBaseUrl.Host
var err error
session.ApiUrl, err = replaceHost(session.ApiUrl, s.jmapBaseUrl.Host)
require.NoError(s.t, err)
session.DownloadUrl, err = replaceHost(session.DownloadUrl, s.jmapBaseUrl.Host)
require.NoError(s.t, err)
session.UploadUrl, err = replaceHost(session.UploadUrl, s.jmapBaseUrl.Host)
require.NoError(s.t, err)
session.EventSourceUrl, err = replaceHost(session.EventSourceUrl, s.jmapBaseUrl.Host)
require.NoError(s.t, err)
return &session
}
type stalwartTestLogConsumer struct{}
func (lc *stalwartTestLogConsumer) Accept(l testcontainers.Log) {
fmt.Print("STALWART: " + string(l.Content))
}
func newStalwartTest(t *testing.T) (*StalwartTest, error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
var _ context.CancelFunc = cancel // ignore context leak warning: it is passed in the struct and called in Close()
// A master user name different from "master" does not seem to work as of the current Stalwart version
//masterUsernameSuffix, err := pw.Generate(4+rand.Intn(28), 2, 0, false, true)
//require.NoError(err)
masterUsername := "master" //"master_" + masterUsernameSuffix
masterPassword, err := pw.Generate(4+rand.Intn(28), 2, 0, false, true)
if err != nil {
return nil, err
}
masterPasswordHash := ""
{
hasher, err := shacrypt.New(shacrypt.WithSHA512(), shacrypt.WithIterations(shacrypt.IterationsDefaultOmitted))
if err != nil {
return nil, err
}
digest, err := hasher.Hash(masterPassword)
if err != nil {
return nil, err
}
masterPasswordHash = digest.Encode()
}
hostname := "localhost"
configBuf := bytes.NewBufferString("")
template.Must(template.New("").Parse(configTemplate)).Execute(configBuf, map[string]any{
"hostname": hostname,
"masterusername": masterUsername,
"masterpassword": masterPasswordHash,
"httpPort": httpPort,
"imapsPort": imapsPort,
})
config := configBuf.String()
configReader := strings.NewReader(config)
container, err := testcontainers.Run(
ctx,
stalwartImage,
testcontainers.WithLogConsumers(&stalwartTestLogConsumer{}),
testcontainers.WithExposedPorts(httpPort+"/tcp", imapsPort+"/tcp"),
testcontainers.WithFiles(testcontainers.ContainerFile{
Reader: configReader,
ContainerFilePath: "/opt/stalwart/etc/config.toml",
FileMode: 0o700,
}),
testcontainers.WithWaitStrategyAndDeadline(
30*time.Second,
wait.ForLog(`Network listener started (network.listen-start) listenerId = "imaptls"`),
wait.ForLog(`Network listener started (network.listen-start) listenerId = "http"`),
),
)
success := false
defer func() {
if !success {
testcontainers.CleanupContainer(t, container)
}
}()
ip, err := container.Host(ctx)
if err != nil {
return nil, err
}
imapPort, err := container.MappedPort(ctx, "993")
if err != nil {
return nil, err
}
tlsConfig := &tls.Config{InsecureSkipVerify: true}
loggerImpl := clog.NewLogger(clog.Level("trace"))
logger := &loggerImpl
var j Client
var jmapBaseUrl *url.URL
var sessionUrl *url.URL
{
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.ResponseHeaderTimeout = time.Duration(30 * time.Second)
tr.TLSClientConfig = tlsConfig
jh := *http.DefaultClient
jh.Transport = tr
wsd := &websocket.Dialer{
TLSClientConfig: tlsConfig,
HandshakeTimeout: time.Duration(10) * time.Second,
}
jmapPort, err := container.MappedPort(ctx, httpPort)
if err != nil {
return nil, err
}
jmapBaseUrl = &url.URL{
Scheme: "http",
Host: ip + ":" + jmapPort.Port(),
Path: "/",
}
sessionUrl = jmapBaseUrl.JoinPath(".well-known", "jmap")
if Wireshark != "" {
fmt.Printf("\x1b[45;37;1m Starting Wireshark on port %v \x1b[0m\n", jmapPort)
attr := os.ProcAttr{
Dir: ".",
Env: os.Environ(),
Files: []*os.File{os.Stdin, os.Stdout, os.Stderr},
}
cmd := []string{Wireshark, "-pkSl", "-i", "lo", "-f", fmt.Sprintf("port %d", jmapPort.Int()), "-Y", "http||websocket"}
process, err := os.StartProcess(Wireshark, cmd, &attr)
require.NoError(t, err)
err = process.Release()
require.NoError(t, err)
time.Sleep(10 * time.Second)
}
eventListener := nullHttpJmapApiClientEventListener{}
api := NewHttpJmapClient(
&jh,
masterUsername,
masterPassword,
eventListener,
)
wscf, err := NewHttpWsClientFactory(wsd, masterUsername, masterPassword, logger, eventListener)
if err != nil {
return nil, err
}
j = NewClient(api, api, api, wscf)
}
// provision some things using Stalwart's Management API
{
var h http.Client
{
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.ResponseHeaderTimeout = time.Duration(30 * time.Second)
tr.TLSClientConfig = tlsConfig
h = *http.DefaultClient
h.Transport = tr
}
apiPort, err := container.MappedPort(ctx, httpPort)
require.NoError(t, err)
url := fmt.Sprintf("http://%s:%d/api/principal", ip, apiPort.Int())
for _, domain := range domains {
fmt.Printf("Creating domain '%v'\n", domain)
bb, err := json.Marshal(map[string]any{
"type": "domain",
"name": domain,
"description": domain,
})
require.NoError(t, err)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(bb))
require.NoError(t, err)
req.SetBasicAuth("mailadmin", "secret")
resp, err := h.Do(req)
require.NoError(t, err)
require.Equal(t, "200 OK", resp.Status)
}
for _, user := range users {
fmt.Printf("Creating individual '%v'\n", user.name)
bb, err := json.Marshal(map[string]any{
"type": "individual",
"name": user.name,
"description": user.description,
"emails": user.email,
"roles": []string{"user"},
"secrets": user.password,
"quota": 20000000000,
})
require.NoError(t, err)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(bb))
require.NoError(t, err)
req.SetBasicAuth("mailadmin", "secret")
resp, err := h.Do(req)
require.NoError(t, err)
require.Equal(t, "200 OK", resp.Status)
// fetch the user once with the superadmin credentials to "activate" it,
// it is unclear why that is needed, but without that, we get errors back
// that we are not allowed to access that resource
{
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
require.NoError(t, err)
req.SetBasicAuth("mailadmin", "secret")
resp, err := h.Do(req)
require.NoError(t, err)
require.Equal(t, "200 OK", resp.Status)
}
}
{
require.NoError(t, err)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
require.NoError(t, err)
req.SetBasicAuth("mailadmin", "secret")
resp, err := h.Do(req)
require.NoError(t, err)
require.Equal(t, "200 OK", resp.Status)
var list struct {
Data struct {
Total int `json:"total"`
Items []struct {
Type string `json:"type"`
Id int `json:"id"`
Name string `json:"name"`
Emails []string `json:"emails"`
Roles []string `json:"roles"`
} `json:"items"`
} `json:"data"`
}
bb, err := io.ReadAll(resp.Body)
require.NoError(t, err)
defer resp.Body.Close()
err = json.Unmarshal(bb, &list)
require.NoError(t, err)
individuals := []struct {
Id int
Name string
Emails []string
Roles []string
}{}
for _, p := range list.Data.Items {
if p.Type == "individual" {
individuals = append(individuals, struct {
Id int
Name string
Emails []string
Roles []string
}{p.Id, p.Name, p.Emails, p.Roles})
}
}
require.Equal(t, len(users), len(individuals))
}
{
// check whether we can fetch a session for the provisioned users
for _, user := range users {
session, err := j.FetchSession(sessionUrl, user.name, logger)
require.NoError(t, err, "failed to retrieve JMAP session for newly created principal '%s'", user.name)
require.Equal(t, user.name, session.Username)
}
}
}
success = true
return &StalwartTest{
t: t,
ip: ip,
imapPort: imapPort.Int(),
container: container,
ctx: ctx,
cancelCtx: cancel,
client: &j,
logger: logger,
jmapBaseUrl: jmapBaseUrl,
sessionUrl: sessionUrl,
}, nil
}
var urlHostRegex = regexp.MustCompile(`^(https?://)(.+?)/(.+)$`)
func replaceHost(u string, host string) (string, error) {
if m := urlHostRegex.FindAllStringSubmatch(u, -1); m != nil {
return fmt.Sprintf("%s%s/%s", m[0][1], host, m[0][3]), nil
} else {
return "", fmt.Errorf("'%v' does not match '%v'", u, urlHostRegex)
}
}
func pickRandomlyFromMap[K comparable, V any](m map[K]V, min int, max int) map[K]V {
if min < 0 || max < 0 {
panic("min and max must be >= 0")
}
l := len(m)
if min > l || max > l {
panic(fmt.Sprintf("min and max must be <= %d", l))
}
n := min + rand.Intn(max-min+1)
if n == l {
return m
}
// let's use a deep copy so we can remove elements as we pick them
c := make(map[K]V, l)
maps.Copy(c, m)
// r will hold the results
r := make(map[K]V, n)
for range n {
pick := rand.Intn(len(c))
j := 0
for k, v := range m {
if j == pick {
delete(c, k)
r[k] = v
break
}
j++
}
}
return r
}
var productName = "jmaptest"
type TestJmapClient struct {
h *http.Client
username string
password string
session *Session
u *url.URL
trace bool
color bool
}
func NewTestJmapClient(session *Session, username string, password string, trace bool, color bool) (*TestJmapClient, error) {
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
tlsConfig := &tls.Config{InsecureSkipVerify: true}
httpTransport.TLSClientConfig = tlsConfig
h := http.DefaultClient
h.Transport = httpTransport
u, err := url.Parse(session.ApiUrl)
if err != nil {
return nil, err
}
return &TestJmapClient{
h: h,
trace: trace,
color: color,
username: username,
password: password,
session: session,
u: u,
}, nil
}
func (j *TestJmapClient) Close() error {
return nil
}
type uploadedBlob struct {
BlobId string `json:"blobId"`
Size int `json:"size"`
Type string `json:"type"`
Sha512 string `json:"sha:512"`
}
func (j *TestJmapClient) uploadBlob(accountId string, data []byte, mimetype string) (uploadedBlob, error) {
uploadUrl := strings.ReplaceAll(j.session.UploadUrl, "{accountId}", accountId)
req, err := http.NewRequest(http.MethodPost, uploadUrl, bytes.NewReader(data))
if err != nil {
return uploadedBlob{}, err
}
req.Header.Add("Content-Type", mimetype)
req.SetBasicAuth(j.username, j.password)
res, err := j.h.Do(req)
if err != nil {
return uploadedBlob{}, err
}
defer res.Body.Close()
var response []byte = nil
if j.trace {
if b, err := httputil.DumpResponse(res, false); err == nil {
response, err = io.ReadAll(res.Body)
if err != nil {
return uploadedBlob{}, err
}
p := pretty.Pretty(response)
if j.color {
p = pretty.Color(p, nil)
}
log.Printf("<== %s%s\n", b, p)
}
}
if res.StatusCode < 200 || res.StatusCode > 299 {
return uploadedBlob{}, fmt.Errorf("blob uploading to '%v': status is %s", uploadUrl, res.Status)
}
if response == nil {
response, err = io.ReadAll(res.Body)
if err != nil {
return uploadedBlob{}, err
}
}
var result uploadedBlob
err = json.Unmarshal(response, &result)
if err != nil {
return uploadedBlob{}, err
}
return result, nil
}
func (j *TestJmapClient) command(body map[string]any) ([]any, error) {
payload, err := json.Marshal(body)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, j.u.String(), bytes.NewReader(payload))
if err != nil {
return nil, err
}
if j.trace {
if b, err := httputil.DumpRequestOut(req, false); err == nil {
p := pretty.Pretty(payload)
if j.color {
p = pretty.Color(p, nil)
}
log.Printf("==> %s%s\n", b, p)
}
}
req.SetBasicAuth(j.username, j.password)
resp, err := j.h.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var response []byte = nil
if j.trace {
if b, err := httputil.DumpResponse(resp, false); err == nil {
response, err = io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
p := pretty.Pretty(response)
if j.color {
p = pretty.Color(p, nil)
}
log.Printf("<== %s%s\n", b, p)
}
}
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("JMAP command HTTP response status is %s", resp.Status)
}
if response == nil {
response, err = io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
}
r := map[string]any{}
err = json.Unmarshal(response, &r)
if err != nil {
return nil, err
}
return r["methodResponses"].([]any), nil
}
type Commander[T any] struct {
j *TestJmapClient
closure func([]any) (T, error)
}
func newCommander[T any](j *TestJmapClient, closure func([]any) (T, error)) Commander[T] {
return Commander[T]{j: j, closure: closure}
}
func (c Commander[T]) command(body map[string]any) (T, error) {
var zero T
methodResponses, err := c.j.command(body)
if err != nil {
return zero, err
}
return c.closure(methodResponses)
}
func (j *TestJmapClient) create(id string, objectType ObjectType, body map[string]any) (string, error) {
return newCommander(j, func(methodResponses []any) (string, error) {
z := methodResponses[0].([]any)
f := z[1].(map[string]any)
if x, ok := f["created"]; ok {
created := x.(map[string]any)
if c, ok := created[id].(map[string]any); ok {
return c["id"].(string), nil
} else {
return "", fmt.Errorf("failed to create %v", objectType)
}
} else {
if ncx, ok := f["notCreated"]; ok {
nc := ncx.(map[string]any)
c := nc[id].(map[string]any)
return "", fmt.Errorf("failed to create %v: %v", objectType, c["description"])
} else {
return "", fmt.Errorf("failed to create %v", objectType)
}
}
}).command(body)
}
func (j *TestJmapClient) create1(accountId string, objectType ObjectType, ns string, obj map[string]any) (string, error) {
body := map[string]any{
"using": []string{JmapCore, ns},
"methodCalls": []any{
[]any{
objectType + "/set",
map[string]any{
"accountId": accountId,
"create": map[string]any{
"c": obj,
},
},
"0",
},
},
}
return j.create("c", objectType, body)
}
func (j *TestJmapClient) objectsById(accountId string, objectType ObjectType, scope string) (map[string]map[string]any, error) {
m := map[string]map[string]any{}
{
body := map[string]any{
"using": []string{JmapCore, scope},
"methodCalls": []any{
[]any{
objectType + "/get",
map[string]any{
"accountId": accountId,
},
"0",
},
},
}
result, err := newCommander(j, func(methodResponses []any) ([]any, error) {
z := methodResponses[0].([]any)
f := z[1].(map[string]any)
if list, ok := f["list"]; ok {
return list.([]any), nil
} else {
return nil, fmt.Errorf("methodResponse[1] has no 'list' attribute: %v", f)
}
}).command(body)
if err != nil {
return nil, err
}
for _, a := range result {
obj := a.(map[string]any)
id := obj["id"].(string)
m[id] = obj
}
}
return m, nil
}
func createName(person *gofakeit.PersonInfo) (map[string]any, jscontact.Name) {
o := jscontact.Name{
Type: jscontact.NameType,
}
m := map[string]any{
"@type": "Name",
}
mComps := make([]map[string]string, 2)
oComps := make([]jscontact.NameComponent, 2)
mComps[0] = map[string]string{
"kind": "given",
"value": person.FirstName,
}
oComps[0] = jscontact.NameComponent{
Type: jscontact.NameComponentType,
Kind: jscontact.NameComponentKindGiven,
Value: person.FirstName,
}
mComps[1] = map[string]string{
"kind": "surname",
"value": person.LastName,
}
oComps[1] = jscontact.NameComponent{
Type: jscontact.NameComponentType,
Kind: jscontact.NameComponentKindSurname,
Value: person.LastName,
}
m["components"] = mComps
o.Components = oComps
m["isOrdered"] = true
o.IsOrdered = true
m["defaultSeparator"] = " "
o.DefaultSeparator = " "
full := fmt.Sprintf("%s %s", person.FirstName, person.LastName)
m["full"] = full
o.Full = full
return m, o
}
func createNickName(_ *gofakeit.PersonInfo) (map[string]any, jscontact.Nickname) {
name := gofakeit.PetName()
contexts := pickRandoms(jscontact.NicknameContextPrivate, jscontact.NicknameContextWork)
return map[string]any{
"@type": "Nickname",
"name": name,
"contexts": toBoolMap(structs.Map(contexts, func(s jscontact.NicknameContext) string { return string(s) })),
}, jscontact.Nickname{
Type: jscontact.NicknameType,
Name: name,
Contexts: orNilMap(toBoolMap(contexts)),
}
}
func createEmail(person *gofakeit.PersonInfo, pref int) (map[string]any, jscontact.EmailAddress) {
email := person.Contact.Email
contexts := pickRandoms1(jscontact.EmailAddressContextWork, jscontact.EmailAddressContextPrivate)
label := strings.ToLower(person.FirstName)
return map[string]any{
"@type": "EmailAddress",
"address": email,
"contexts": toBoolMap(structs.Map(contexts, func(s jscontact.EmailAddressContext) string { return string(s) })),
"label": label,
"pref": pref,
}, jscontact.EmailAddress{
Type: jscontact.EmailAddressType,
Address: email,
Contexts: orNilMap(toBoolMap(contexts)),
Label: label,
Pref: uint(pref),
}
}
func createSecondaryEmail(email string, pref int) (map[string]any, jscontact.EmailAddress) {
contexts := pickRandoms(jscontact.EmailAddressContextWork, jscontact.EmailAddressContextPrivate)
return map[string]any{
"@type": "EmailAddress",
"address": email,
"contexts": toBoolMap(structs.Map(contexts, func(s jscontact.EmailAddressContext) string { return string(s) })),
"pref": pref,
}, jscontact.EmailAddress{
Type: jscontact.EmailAddressType,
Address: email,
Contexts: orNilMap(toBoolMap(contexts)),
Pref: uint(pref),
}
}
var idFirstLetters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
var idOtherLetters = append(idFirstLetters, []rune("0123456789")...)
func id() string {
n := 4 + rand.Intn(12-4+1)
b := make([]rune, n)
b[0] = idFirstLetters[rand.Intn(len(idFirstLetters))]
for i := 1; i < n; i++ {
b[i] = idOtherLetters[rand.Intn(len(idOtherLetters))]
}
return string(b)
}
func toHtml(text string) string {
return "<!DOCTYPE html>\n<html>\n<body>\n" + strings.Join(htmlJoin(paraSplitter.Split(text, -1)), "\n") + "</body>\n</html>"
}
func htmlJoin(parts []string) []string {
var result []string
for i := range parts {
result = append(result, fmt.Sprintf("<p>%v</p>", parts[i]))
}
return result
}
var paraSplitter = regexp.MustCompile("[\r\n]+")
var timezones = []string{
"America/Adak",
"America/Anchorage",
"America/Chicago",
"America/Denver",
"America/Detroit",
"America/Indiana/Knox",
"America/Kentucky/Louisville",
"America/Los_Angeles",
"America/New_York",
"Europe/Brussels",
"Europe/Berlin",
"Europe/Paris",
}
// https://www.w3.org/TR/css-color-3/#html4
var basicColors = []string{
"black",
"silver",
"gray",
"white",
"maroon",
"red",
"purple",
"fuchsia",
"green",
"lime",
"olive",
"yellow",
"navy",
"blue",
"teal",
"aqua",
}
// https://www.w3.org/TR/SVG11/types.html#ColorKeywords
var extendedColors = []string{
"aliceblue",
"antiquewhite",
"aqua",
"aquamarine",
"azure",
"beige",
"bisque",
"black",
"blanchedalmond",
"blue",
"blueviolet",
"brown",
"burlywood",
"cadetblue",
"chartreuse",
"chocolate",
"coral",
"cornflowerblue",
"cornsilk",
"crimson",
"cyan",
"darkblue",
"darkcyan",
"darkgoldenrod",
"darkgray",
"darkgreen",
"darkgrey",
"darkkhaki",
"darkmagenta",
"darkolivegreen",
"darkorange",
"darkorchid",
"darkred",
"darksalmon",
"darkseagreen",
"darkslateblue",
"darkslategray",
"darkslategrey",
"darkturquoise",
"darkviolet",
"deeppink",
"deepskyblue",
"dimgray",
"dimgrey",
"dodgerblue",
"firebrick",
"floralwhite",
"forestgreen",
"fuchsia",
"gainsboro",
"ghostwhite",
"gold",
"goldenrod",
"gray",
"grey",
"green",
"greenyellow",
"honeydew",
"hotpink",
"indianred",
"indigo",
"ivory",
"khaki",
"lavender",
"lavenderblush",
"lawngreen",
"lemonchiffon",
"lightblue",
"lightcoral",
"lightcyan",
"lightgoldenrodyellow",
"lightgray",
"lightgreen",
"lightgrey",
"lightpink",
"lightsalmon",
"lightseagreen",
"lightskyblue",
"lightslategray",
"lightslategrey",
"lightsteelblue",
"lightyellow",
"lime",
"limegreen",
"linen",
"magenta",
"maroon",
"mediumaquamarine",
"mediumblue",
"mediumorchid",
"mediumpurple",
"mediumseagreen",
"mediumslateblue",
"mediumspringgreen",
"mediumturquoise",
"mediumvioletred",
"midnightblue",
"mintcream",
"mistyrose",
"moccasin",
"navajowhite",
"navy",
"oldlace",
"olive",
"olivedrab",
"orange",
"orangered",
"orchid",
"palegoldenrod",
"palegreen",
"paleturquoise",
"palevioletred",
"papayawhip",
"peachpuff",
"peru",
"pink",
"plum",
"powderblue",
"purple",
"red",
"rosybrown",
"royalblue",
"saddlebrown",
"salmon",
"sandybrown",
"seagreen",
"seashell",
"sienna",
"silver",
"skyblue",
"slateblue",
"slategray",
"slategrey",
"snow",
"springgreen",
"steelblue",
"tan",
"teal",
"thistle",
"tomato",
"turquoise",
"violet",
"wheat",
"white",
"whitesmoke",
"yellow",
"yellowgreen",
}
func propmap[T any](enabled bool, min int, max int, container map[string]any, name string, cardProperty *map[string]T, generator func(int, string) (map[string]any, T, error)) error {
if !enabled {
return nil
}
n := min + rand.Intn(max-min+1)
m := make(map[string]map[string]any, n)
o := make(map[string]T, n)
for i := range n {
id := id()
itemForMap, itemForCard, err := generator(i, id)
if err != nil {
return err
}
if itemForMap != nil {
m[id] = itemForMap
o[id] = itemForCard
}
}
if len(m) > 0 {
container[name] = m
*cardProperty = o
}
return nil
}
func externalImageUri() string {
return fmt.Sprintf("https://picsum.photos/id/%d/%d/%d", 1+rand.Intn(200), 200, 300)
}
func orNilMap[K comparable, V any](m map[K]V) map[K]V {
if len(m) < 1 {
return nil
} else {
return m
}
}
func toBoolMap[K comparable](s []K) map[K]bool {
m := make(map[K]bool, len(s))
for _, e := range s {
m[e] = true
}
return m
}
func toBoolMapS[K comparable](s ...K) map[K]bool {
m := make(map[K]bool, len(s))
for _, e := range s {
m[e] = true
}
return m
}
func pickRandom[T any](s ...T) T {
return s[rand.Intn(len(s))]
}
func pickUser() User {
return users[rand.Intn(len(users))]
}
func pickRandoms[T any](s ...T) []T {
n := rand.Intn(len(s))
if n == 0 {
return []T{}
}
result := make([]T, n)
o := make([]T, len(s))
copy(o, s)
for i := range n {
p := rand.Intn(len(o))
result[i] = slices.Delete(o, p, p)[0]
}
return result
}
func pickRandoms1[T any](s ...T) []T {
n := 1 + rand.Intn(len(s)-1)
result := make([]T, n)
o := make([]T, len(s))
copy(o, s)
for i := range n {
p := rand.Intn(len(o))
result[i] = slices.Delete(o, p, p)[0]
}
return result
}
func pickLanguage() string {
return pickRandom("en-US", "en-GB", "en-AU")
}
func pickLocale() string {
return pickRandom("en", "fr", "de")
}
func allBoxesAreTicked[S any](t *testing.T, s S, exceptions ...string) {
v := reflect.ValueOf(s)
typ := v.Type()
for i := range v.NumField() {
name := typ.Field(i).Name
if slices.Contains(exceptions, name) {
log.Printf("(/) %s\n", name)
continue
}
value := v.Field(i).Bool()
if value {
log.Printf("(X) %s\n", name)
} else {
log.Printf("( ) %s\n", name)
}
require.True(t, value, "should be true: %v", name)
}
}
func deepEqual[T any](t *testing.T, expected, actual T) {
diff := ""
if EnableTypes {
diff = cmp.Diff(expected, actual)
} else {
diff = cmp.Diff(expected, actual, cmp.FilterPath(func(p cmp.Path) bool {
switch sf := p.Last().(type) {
case cmp.StructField:
return sf.String() == ".Type"
}
return false
}, cmp.Ignore()))
}
require.Empty(t, diff)
}