Repository: osbuild/osbuild-composer
Pull request #591 (2020-05-06 16:18:58)

[tests] Improve coverage for distro packages - RHEL-8.2, RHEL-8.3

jikortus: I just found out that I could possibly use `assert.ElementsMatch` instead of `assert.Equal`, which wouldn't check for items order in the lists. Would it be a better option? (I also mentioned this in #604).

Patch:
diff --git a/cmd/osbuild-composer/main.go b/cmd/osbuild-composer/main.go
index 8a7cbeb32..338049fc5 100644
--- a/cmd/osbuild-composer/main.go
+++ b/cmd/osbuild-composer/main.go
@@ -125,7 +125,7 @@ func main() {
 		log.Fatalf("cannot create queue directory: %v", err)
 	}
 
-	jobs, err := fsjobqueue.New(queueDir)
+	jobs, err := fsjobqueue.New(queueDir, []string{"osbuild"})
 	if err != nil {
 		log.Fatalf("cannot create jobqueue: %v", err)
 	}
diff --git a/internal/client/compose.go b/internal/client/compose.go
index b2ff9eaa1..0387b3731 100644
--- a/internal/client/compose.go
+++ b/internal/client/compose.go
@@ -6,7 +6,7 @@ import (
 	"encoding/json"
 	"io"
 	"net/http"
-	"strings"
+	"net/url"
 
 	"github.com/osbuild/osbuild-composer/internal/weldr"
 )
@@ -63,20 +63,20 @@ func GetFailedComposesV0(socket *http.Client) ([]weldr.ComposeEntryV0, *APIRespo
 func GetComposeStatusV0(socket *http.Client, uuids, blueprint, status, composeType string) ([]weldr.ComposeEntryV0, *APIResponse, error) {
 	// Build the query string
 	route := "/api/v0/compose/status/" + uuids
-	var filters []string
 
+	params := url.Values{}
 	if len(blueprint) > 0 {
-		filters = append(filters, "blueprint="+blueprint)
+		params.Add("blueprint", blueprint)
 	}
 	if len(status) > 0 {
-		filters = append(filters, "status="+status)
+		params.Add("status", status)
 	}
 	if len(composeType) > 0 {
-		filters = append(filters, "type="+composeType)
+		params.Add("type", composeType)
 	}
 
-	if len(filters) > 0 {
-		route = route + "?" + strings.Join(filters, "&")
+	if len(params) > 0 {
+		route = route + "?" + params.Encode()
 	}
 
 	body, resp, err := GetRaw(socket, "GET", route)
diff --git a/internal/jobqueue/fsjobqueue/fsjobqueue.go b/internal/jobqueue/fsjobqueue/fsjobqueue.go
index 76443b8cd..7a407d95d 100644
--- a/internal/jobqueue/fsjobqueue/fsjobqueue.go
+++ b/internal/jobqueue/fsjobqueue/fsjobqueue.go
@@ -3,7 +3,7 @@
 //
 // Jobs are stored in the file system, using the `jsondb` package. However,
 // this package does not use the file system as a database, but keeps some
-// state in memory. This means that access to a given directory myst be
+// state in memory. This means that access to a given directory must be
 // exclusive to only one `fsJobQueue` object at a time. A single `fsJobQueue`
 // can be safely accessed from multiple goroutines, though.
 //
@@ -14,6 +14,7 @@ package fsjobqueue
 import (
 	"context"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"reflect"
 	"sort"
@@ -26,19 +27,20 @@ import (
 )
 
 type fsJobQueue struct {
+	// Protects all fields of this struct. In particular, it ensures
+	// transactions on `db` are atomic. All public functions except
+	// JobStatus hold it while they're running. Dequeue() releases it
+	// briefly while waiting on pending channels.
+	mu sync.Mutex
+
 	db *jsondb.JSONDatabase
 
-	// Maps job types to channels of job ids for that type. Only access
-	// through pendingChannel() to ensure concurrent access is restricted
-	// by the mutex.
-	pending      map[string]chan uuid.UUID
-	pendingMutex sync.Mutex
+	// Maps job types to channels of job ids for that type.
+	pending map[string]chan uuid.UUID
 
 	// Maps job ids to the jobs that depend on it, if any of those
-	// dependants have not yet finished. Only acccess while holding the
-	// associated mutex.
-	dependants      map[uuid.UUID][]uuid.UUID
-	dependantsMutex sync.Mutex
+	// dependants have not yet finished.
+	dependants map[uuid.UUID][]uuid.UUID
 }
 
 // On-disk job struct. Contains all necessary (but non-redundant) information
@@ -51,22 +53,27 @@ type job struct {
 	Dependencies []uuid.UUID     `json:"dependencies"`
 	Result       json.RawMessage `json:"result,omitempty"`
 
-	Status     jobqueue.JobStatus `json:"status"`
-	QueuedAt   time.Time          `json:"queued-at,omitempty"`
-	StartedAt  time.Time          `json:"started-at,omitempty"`
-	FinishedAt time.Time          `json:"finished-at,omitempty"`
+	QueuedAt   time.Time `json:"queued_at,omitempty"`
+	StartedAt  time.Time `json:"started_at,omitempty"`
+	FinishedAt time.Time `json:"finished_at,omitempty"`
+
+	Canceled bool `json:"canceled,omitempty"`
 }
 
 // Create a new fsJobQueue object for `dir`. This object must have exclusive
 // access to `dir`. If `dir` contains jobs created from previous runs, they are
 // loaded and rescheduled to run if necessary.
-func New(dir string) (*fsJobQueue, error) {
+func New(dir string, acceptedJobTypes []string) (*fsJobQueue, error) {
 	q := &fsJobQueue{
 		db:         jsondb.New(dir, 0600),
 		pending:    make(map[string]chan uuid.UUID),
 		dependants: make(map[uuid.UUID][]uuid.UUID),
 	}
 
+	for _, jt := range acceptedJobTypes {
+		q.pending[jt] = make(chan uuid.UUID, 100)
+	}
+
 	// Look for jobs that are still pending and build the dependant map.
 	ids, err := q.db.List()
 	if err != nil {
@@ -81,36 +88,27 @@ func New(dir string) (*fsJobQueue, error) {
 		if err != nil {
 			return nil, err
 		}
-		// We only enqueue jobs that were previously pending.
-		if j.Status != jobqueue.JobPending {
-			continue
-		}
-		// Initialize dependants for this job.
-		q.dependantsMutex.Lock()
-		defer q.dependantsMutex.Unlock()
-		for _, dep := range j.Dependencies {
-			q.dependants[dep] = append(q.dependants[dep], j.Id)
-		}
-		// Enqueue a job if all its dependencies (if there are any)
-		// have finished, but the job itself hasn't run yet.
-		n, err := q.countFinishedJobs(j.Dependencies)
+		err = q.maybeEnqueue(j, true)
 		if err != nil {
 			return nil, err
 		}
-		if n == len(j.Dependencies) {
-			q.pendingChannel(j.Type) <- j.Id
-		}
 	}
 
 	return q, nil
 }
 
 func (q *fsJobQueue) Enqueue(jobType string, args interface{}, dependencies []uuid.UUID) (uuid.UUID, error) {
+	q.mu.Lock()
+	defer q.mu.Unlock()
+
+	if _, exists := q.pending[jobType]; !exists {
+		return uuid.Nil, fmt.Errorf("this queue does not accept job type '%s'", jobType)
+	}
+
 	var j = job{
 		Id:           uuid.New(),
 		Type:         jobType,
 		Dependencies: uniqueUUIDList(dependencies),
-		Status:       jobqueue.JobPending,
 		QueuedAt:     time.Now(),
 	}
 
@@ -120,10 +118,16 @@ func (q *fsJobQueue) Enqueue(jobType string, args interface{}, dependencies []uu
 		return uuid.Nil, fmt.Errorf("error marshaling job arguments: %v", err)
 	}
 
-	// Verify dependencies and check how many of them are already finished.
-	finished, err := q.countFinishedJobs(j.Dependencies)
-	if err != nil {
-		return uuid.Nil, err
+	// Verify dependendencies early, so that the job doesn't get written
+	// when one of them doesn't exist.
+	for _, d := range j.Dependencies {
+		exists, err := q.db.Read(d.String(), nil)
+		if err != nil {
+			return uuid.Nil, err
+		}
+		if !exists {
+			return uuid.Nil, jobqueue.ErrNotExist
+		}
 	}
 
 	// Write the job before updating in-memory state, so that the latter
@@ -133,85 +137,87 @@ func (q *fsJobQueue) Enqueue(jobType string, args interface{}, dependencies []uu
 		return uuid.Nil, fmt.Errorf("cannot write job: %v:", err)
 	}
 
-	// If all dependencies have finished, or there are none, queue the job.
-	// Otherwise, update dependants so that this check is done again when
-	// FinishJob() is called for a dependency.
-	if finished == len(j.Dependencies) {
-		q.pendingChannel(j.Type) <- j.Id
-	} else {
-		q.dependantsMutex.Lock()
-		defer q.dependantsMutex.Unlock()
-		for _, id := range j.Dependencies {
-			q.dependants[id] = append(q.dependants[id], j.Id)
-		}
+	err = q.maybeEnqueue(&j, true)
+	if err != nil {
+		return uuid.Nil, err
 	}
 
 	return j.Id, nil
 }
 
 func (q *fsJobQueue) Dequeue(ctx context.Context, jobTypes []string, args interface{}) (uuid.UUID, error) {
-	// Return early if the conext is already canceled.
+	q.mu.Lock()
+	defer q.mu.Unlock()
+
+	// Return early if the context is already canceled.
 	if err := ctx.Err(); err != nil {
 		return uuid.Nil, err
 	}
 
-	// Use reflect.Select(), because the `select` statement cannot operate
-	// on an unknown amount of channels.
-	cases := []reflect.SelectCase{
-		{
-			Dir:  reflect.SelectRecv,
-			Chan: reflect.ValueOf(ctx.Done()),
-		},
-	}
+	// Filter q.pending by the `jobTypes`. Ignore those job types that this
+	// queue doesn't accept.
+	chans := []chan uuid.UUID{}
 	for _, jt := range jobTypes {
-		cases = append(cases, reflect.SelectCase{
-			Dir:  reflect.SelectRecv,
-			Chan: reflect.ValueOf(q.pendingChannel(jt)),
-		})
+		if c, exists := q.pending[jt]; exists {
+			chans = append(chans, c)
+		}
 	}
 
-	chosen, value, recvOK := reflect.Select(cases)
-	if chosen == 0 && !recvOK {
-		return uuid.Nil, ctx.Err()
-	}
-	// pending channels are never closed
-	if !recvOK {
-		panic("pending channel was closed unexpectedly")
-	}
-	id := value.Interface().(uuid.UUID)
+	// Loop until finding a non-canceled job.
+	var j *job
+	for {
+		// Unlock the mutex while polling channels, so that multiple goroutines
+		// can wait at the same time.
+		q.mu.Unlock()
+		id, err := selectUUIDChannel(ctx, chans)
+		q.mu.Lock()
 
-	j, err := q.readJob(id)
-	if err != nil {
-		return uuid.Nil, err
+		if err != nil {
+			return uuid.Nil, err
+		}
+
+		j, err = q.readJob(id)
+		if err != nil {
+			return uuid.Nil, err
+		}
+
+		if !j.Canceled {
+			break
+		}
 	}
 
-	err = json.Unmarshal(j.Args, args)
+	err := json.Unmarshal(j.Args, args)
 	if err != nil {
 		return uuid.Nil, fmt.Errorf("error unmarshaling arguments for job '%s': %v", j.Id, err)
 	}
 
-	j.Status = jobqueue.JobRunning
 	j.StartedAt = time.Now()
 
-	err = q.db.Write(id.String(), j)
+	err = q.db.Write(j.Id.String(), j)
 	if err != nil {
-		return uuid.Nil, fmt.Errorf("error writing job %s: %v", id, err)
+		return uuid.Nil, fmt.Errorf("error writing job %s: %v", j.Id, err)
 	}
 
 	return j.Id, nil
 }
 
 func (q *fsJobQueue) FinishJob(id uuid.UUID, result interface{}) error {
+	q.mu.Lock()
+	defer q.mu.Unlock()
+
 	j, err := q.readJob(id)
 	if err != nil {
 		return err
 	}
 
-	if j.Status != jobqueue.JobRunning {
+	if j.Canceled {
+		return jobqueue.ErrCanceled
+	}
+
+	if j.StartedAt.IsZero() || !j.FinishedAt.IsZero() {
 		return jobqueue.ErrNotRunning
 	}
 
-	j.Status = jobqueue.JobFinished
 	j.FinishedAt = time.Now()
 
 	j.Result, err = json.Marshal(result)
@@ -225,35 +231,51 @@ func (q *fsJobQueue) FinishJob(id uuid.UUID, result interface{}) error {
 		return fmt.Errorf("error writing job %s: %v", id, err)
 	}
 
-	q.dependantsMutex.Lock()
-	defer q.dependantsMutex.Unlock()
 	for _, depid := range q.dependants[id] {
 		dep, err := q.readJob(depid)
 		if err != nil {
 			return err
 		}
-		n, err := q.countFinishedJobs(dep.Dependencies)
+		err = q.maybeEnqueue(dep, false)
 		if err != nil {
 			return err
 		}
-		if n == len(dep.Dependencies) {
-			q.pendingChannel(dep.Type) <- dep.Id
-		}
 	}
 	delete(q.dependants, id)
 
 	return nil
 }
 
-func (q *fsJobQueue) JobStatus(id uuid.UUID, result interface{}) (status jobqueue.JobStatus, queued, started, finished time.Time, err error) {
-	var j *job
+func (q *fsJobQueue) CancelJob(id uuid.UUID) error {
+	q.mu.Lock()
+	defer q.mu.Unlock()
+
+	j, err := q.readJob(id)
+	if err != nil {
+		return err
+	}
+
+	if !j.FinishedAt.IsZero() {
+		return nil
+	}
+
+	j.Canceled = true
+
+	err = q.db.Write(id.String(), j)
+	if err != nil {
+		return fmt.Errorf("error writing job %s: %v", id, err)
+	}
 
-	j, err = q.readJob(id)
+	return nil
+}
+
+func (q *fsJobQueue) JobStatus(id uuid.UUID, result interface{}) (queued, started, finished time.Time, canceled bool, err error) {
+	j, err := q.readJob(id)
 	if err != nil {
 		return
 	}
 
-	if j.Status == jobqueue.JobFinished {
+	if !j.FinishedAt.IsZero() && !j.Canceled {
 		err = json.Unmarshal(j.Result, result)
 		if err != nil {
 			err = fmt.Errorf("error unmarshaling result for job '%s': %v", id, err)
@@ -261,30 +283,14 @@ func (q *fsJobQueue) JobStatus(id uuid.UUID, result interface{}) (status jobqueu
 		}
 	}
 
-	status = j.Status
 	queued = j.QueuedAt
 	started = j.StartedAt
 	finished = j.FinishedAt
+	canceled = j.Canceled
 
 	return
 }
 
-// Returns the number of finished jobs in `ids`.
-func (q *fsJobQueue) countFinishedJobs(ids []uuid.UUID) (int, error) {
-	n := 0
-	for _, id := range ids {
-		j, err := q.readJob(id)
-		if err != nil {
-			return 0, err
-		}
-		if j.Status == jobqueue.JobFinished {
-			n += 1
-		}
-	}
-
-	return n, nil
-}
-
 // Reads job with `id`. This is a thin wrapper around `q.db.Read`, which
 // returns the job directly, or and error if a job with `id` does not exist.
 func (q *fsJobQueue) readJob(id uuid.UUID) (*job, error) {
@@ -300,19 +306,39 @@ func (q *fsJobQueue) readJob(id uuid.UUID) (*job, error) {
 	return &j, nil
 }
 
-// Safe access to the pending channel for `jobType`. Channels are created on
-// demand.
-func (q *fsJobQueue) pendingChannel(jobType string) chan uuid.UUID {
-	q.pendingMutex.Lock()
-	defer q.pendingMutex.Unlock()
+// Enqueue `job` if it is pending and all its dependencies have finished.
+// Update `q.dependants` if the job was not queued and updateDependants is true
+// (i.e., when this is a new job).
+func (q *fsJobQueue) maybeEnqueue(j *job, updateDependants bool) error {
+	if !j.StartedAt.IsZero() {
+		return nil
+	}
 
-	c, exists := q.pending[jobType]
-	if !exists {
-		c = make(chan uuid.UUID, 100)
-		q.pending[jobType] = c
+	depsFinished := true
+	for _, id := range j.Dependencies {
+		j, err := q.readJob(id)
+		if err != nil {
+			return err
+		}
+		if j.FinishedAt.IsZero() {
+			depsFinished = false
+			break
+		}
+	}
+
+	if depsFinished {
+		c, exists := q.pending[j.Type]
+		if !exists {
+			return fmt.Errorf("this queue doesn't accept job type '%s'", j.Type)
+		}
+		c <- j.Id
+	} else if updateDependants {
+		for _, id := range j.Dependencies {
+			q.dependants[id] = append(q.dependants[id], j.Id)
+		}
 	}
 
-	return c
+	return nil
 }
 
 // Sorts and removes duplicates from `ids`.
@@ -338,3 +364,34 @@ func uniqueUUIDList(ids []uuid.UUID) []uuid.UUID {
 
 	return l
 }
+
+// Select on a list of `chan uuid.UUID`s. Returns an error if one of the
+// channels is closed.
+//
+// Uses reflect.Select(), because the `select` statement cannot operate on an
+// unknown amount of channels.
+func selectUUIDChannel(ctx context.Context, chans []chan uuid.UUID) (uuid.UUID, error) {
+	cases := []reflect.SelectCase{
+		{
+			Dir:  reflect.SelectRecv,
+			Chan: reflect.ValueOf(ctx.Done()),
+		},
+	}
+	for _, c := range chans {
+		cases = append(cases, reflect.SelectCase{
+			Dir:  reflect.SelectRecv,
+			Chan: reflect.ValueOf(c),
+		})
+	}
+
+	chosen, value, recvOK := reflect.Select(cases)
+	if !recvOK {
+		if chosen == 0 {
+			return uuid.Nil, ctx.Err()
+		} else {
+			return uuid.Nil, errors.New("channel was closed unexpectedly")
+		}
+	}
+
+	return value.Interface().(uuid.UUID), nil
+}
diff --git a/internal/jobqueue/jobqueue.go b/internal/jobqueue/jobqueue.go
index a33a32c1a..6dbd96b0e 100644
--- a/internal/jobqueue/jobqueue.go
+++ b/internal/jobqueue/jobqueue.go
@@ -14,7 +14,6 @@ package jobqueue
 
 import (
 	"context"
-	"encoding/json"
 	"errors"
 	"time"
 
@@ -49,57 +48,20 @@ type JobQueue interface {
 	// job type and must be serializable to JSON.
 	FinishJob(id uuid.UUID, result interface{}) error
 
-	// Returns the current status of the job. If the job has already
-	// finished, its result will be returned in `result`. Also returns the
-	// time the job was
-	//    queued   - always valid
-	//    started  - valid when the job is running or has finished
-	//    finished - valid when the job has finished
-	JobStatus(id uuid.UUID, result interface{}) (status JobStatus, queued, started, finished time.Time, err error)
-}
-
-type JobStatus int
+	// Cancel a job. Does nothing if the job has already finished.
+	CancelJob(id uuid.UUID) error
 
-const (
-	JobPending JobStatus = iota
-	JobRunning
-	JobFinished
-)
-
-func (s JobStatus) String() string {
-	switch s {
-	case JobPending:
-		return "pending"
-	case JobRunning:
-		return "running"
-	case JobFinished:
-		return "finished"
-	default:
-		return "<invalid>"
-	}
-}
-
-func (s JobStatus) MarshalJSON() ([]byte, error) {
-	return json.Marshal(s.String())
-}
-
-func (s *JobStatus) UnmarshalJSON(data []byte) error {
-	var str string
-	if err := json.Unmarshal(data, &str); err != nil {
-		return err
-	}
-	switch str {
-	case "pending":
-		*s = JobPending
-	case "running":
-		*s = JobRunning
-	case "finished":
-		*s = JobFinished
-	}
-	return nil
+	// Returns the current status of the job, in the form of three times:
+	// queued, started, and finished. `started` and `finished` might be the
+	// zero time (check with t.IsZero()), when the job is not running or
+	// finished, respectively.
+	//
+	// If the job is finished, its result will be returned in `result`.
+	JobStatus(id uuid.UUID, result interface{}) (queued, started, finished time.Time, canceled bool, err error)
 }
 
 var (
 	ErrNotExist   = errors.New("job does not exist")
 	ErrNotRunning = errors.New("job is not running")
+	ErrCanceled   = errors.New("job ws canceled")
 )
diff --git a/internal/jsondb/db.go b/internal/jsondb/db.go
index 6551f4ed1..10cef85c1 100644
--- a/internal/jsondb/db.go
+++ b/internal/jsondb/db.go
@@ -36,7 +36,8 @@ func New(dir string, perm os.FileMode) *JSONDatabase {
 	return &JSONDatabase{dir, perm}
 }
 
-// Reads the value at `name`. `document` must deserializable from JSON. Returns
+// Reads the value at `name`. `document` must be a type that is deserializable
+// from the JSON document `name`, or nil to not deserialize at all. Returns
 // false if a document with `name` does not exist.
 func (db *JSONDatabase) Read(name string, document interface{}) (bool, error) {
 	f, err := os.Open(path.Join(db.dir, name+".json"))
@@ -48,9 +49,11 @@ func (db *JSONDatabase) Read(name string, document interface{}) (bool, error) {
 	}
 	defer f.Close()
 
-	err = json.NewDecoder(f).Decode(&document)
-	if err != nil {
-		return false, fmt.Errorf("error reading db file %s: %v", name, err)
+	if document != nil {
+		err = json.NewDecoder(f).Decode(&document)
+		if err != nil {
+			return false, fmt.Errorf("error reading db file %s: %v", name, err)
+		}
 	}
 
 	return true, nil
diff --git a/internal/weldr/api.go b/internal/weldr/api.go
index f080493f4..0ae09569c 100644
--- a/internal/weldr/api.go
+++ b/internal/weldr/api.go
@@ -11,6 +11,7 @@ import (
 	"net"
 	"net/http"
 	"net/url"
+	"regexp"
 	"sort"
 	"strconv"
 	"strings"
@@ -43,6 +44,8 @@ type API struct {
 	router *httprouter.Router
 }
 
+var ValidBlueprintName = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
+
 func New(rpmmd rpmmd.RPMMD, arch distro.Arch, distro distro.Distro, repos []rpmmd.RepoConfig, logger *log.Logger, store *store.Store, workers *worker.Server) *API {
 	api := &API{
 		store:   store,
@@ -232,6 +235,24 @@ type responseError struct {
 	Msg  string `json:"msg"`
 }
 
+// verifyStringsWithRegex checks a slive of strings against a regex of allowed characters
+// it writes the InvalidChars error to the writer and returns false if any of them fail the check
+// It will also return an error if the string is empty
+func verifyStringsWithRegex(writer http.ResponseWriter, strings []string, re *regexp.Regexp) bool {
+	for _, s := range strings {
+		if len(s) > 0 && re.MatchString(s) {
+			continue
+		}
+		errors := responseError{
+			ID:  "InvalidChars",
+			Msg: "Invalid characters in API path",
+		}
+		statusResponseError(writer, http.StatusBadRequest, errors)
+		return false
+	}
+	return true
+}
+
 func statusResponseError(writer http.ResponseWriter, code int, errors ...responseError) {
 	type reply struct {
 		Status bool            `json:"status"`
@@ -807,6 +828,13 @@ func (api *API) blueprintsInfoHandler(writer http.ResponseWriter, request *http.
 		return
 	}
 
+	// Remove the leading / from the first entry (check above ensures it is not just a /
+	names[0] = names[0][1:]
+
+	if !verifyStringsWithRegex(writer, names, ValidBlueprintName) {
+		return
+	}
+
 	query, err := url.ParseQuery(request.URL.RawQuery)
 	if err != nil {
 		errors := responseError{
@@ -821,12 +849,7 @@ func (api *API) blueprintsInfoHandler(writer http.ResponseWriter, request *http.
 	changes := []change{}
 	blueprintErrors := []responseError{}
 
-	for i, name := range names {
-		// remove leading / from first name
-		if i == 0 {
-			name = name[1:]
-		}
-
+	for _, name := range names {
 		blueprint, changed := api.store.GetBlueprint(name)
 		if blueprint == nil {
 			blueprintErrors = append(blueprintErrors, responseError{
@@ -902,13 +925,16 @@ func (api *API) blueprintsDepsolveHandler(writer http.ResponseWriter, request *h
 		return
 	}
 
+	// Remove the leading / from the first entry (check above ensures it is not just a /
+	names[0] = names[0][1:]
+
+	if !verifyStringsWithRegex(writer, names, ValidBlueprintName) {
+		return
+	}
+
 	blueprints := []entry{}
 	blueprintsErrors := []responseError{}
-	for i, name := range names {
-		// remove leading / from first name
-		if i == 0 {
-			name = name[1:]
-		}
+	for _, name := range names {
 		blueprint, _ := api.store.GetBlueprint(name)
 		if blueprint == nil {
 			blueprintsErrors = append(blueprintsErrors, responseError{
@@ -987,13 +1013,16 @@ func (api *API) blueprintsFreezeHandler(writer http.ResponseWriter, request *htt
 		return
 	}
 
+	// Remove the leading / from the first entry (check above ensures it is not just a /
+	names[0] = names[0][1:]
+
+	if !verifyStringsWithRegex(writer, names, ValidBlueprintName) {
+		return
+	}
+
 	blueprints := []blueprintFrozen{}
 	errors := []responseError{}
-	for i, name := range names {
-		// remove leading / from first name
-		if i == 0 {
-			name = name[1:]
-		}
+	for _, name := range names {
 		bp, _ := api.store.GetBlueprint(name)
 		if bp == nil {
 			rerr := responseError{
@@ -1076,8 +1105,19 @@ func (api *API) blueprintsDiffHandler(writer http.ResponseWriter, request *http.
 	}
 
 	name := params.ByName("blueprint")
+	if !verifyStringsWithRegex(writer, []string{name}, ValidBlueprintName) {
+		return
+	}
+
 	fromCommit := params.ByName("from")
+	if !verifyStringsWithRegex(writer, []string{fromCommit}, ValidBlueprintName) {
+		return
+	}
+
 	toCommit := params.ByName("to")
+	if !verifyStringsWithRegex(writer, []string{toCommit}, ValidBlueprintName) {
+		return
+	}
 
 	if len(name) == 0 || len(fromCommit) == 0 || len(toCommit) == 0 {
 		errors := responseError{
@@ -1178,6 +1218,13 @@ func (api *API) blueprintsChangesHandler(writer http.ResponseWriter, request *ht
 		return
 	}
 
+	// Remove the leading / from the first entry (check above ensures it is not just a /
+	names[0] = names[0][1:]
+
+	if !verifyStringsWithRegex(writer, names, ValidBlueprintName) {
+		return
+	}
+
 	offset, limit, err := parseOffsetAndLimit(request.URL.Query())
 	if err != nil {
 		errors := responseError{
@@ -1190,11 +1237,7 @@ func (api *API) blueprintsChangesHandler(writer http.ResponseWriter, request *ht
 
 	allChanges := []change{}
 	errors := []responseError{}
-	for i, name := range names {
-		// remove leading / from first name
-		if i == 0 {
-			name = name[1:]
-		}
+	for _, name := range names {
 		bpChanges := api.store.GetBlueprintChanges(name)
 		// Reverse the changes, newest first
 		reversed := make([]blueprint.Change, 0, len(bpChanges))
@@ -1269,6 +1312,10 @@ func (api *API) blueprintsNewHandler(writer http.ResponseWriter, request *http.R
 		return
 	}
 
+	if !verifyStringsWithRegex(writer, []string{blueprint.Name}, ValidBlueprintName) {
+		return
+	}
+
 	commitMsg := "Recipe " + blueprint.Name + ", version " + blueprint.Version + " saved."
 	err = api.store.PushBlueprint(blueprint, commitMsg)
 	if err != nil {
@@ -1326,6 +1373,10 @@ func (api *API) blueprintsWorkspaceHandler(writer http.ResponseWriter, request *
 		return
 	}
 
+	if !verifyStringsWithRegex(writer, []string{blueprint.Name}, ValidBlueprintName) {
+		return
+	}
+
 	err = api.store.PushBlueprintToWorkspace(blueprint)
 	if err != nil {
 		errors := responseError{
@@ -1345,7 +1396,15 @@ func (api *API) blueprintUndoHandler(writer http.ResponseWriter, request *http.R
 	}
 
 	name := params.ByName("blueprint")
+	if !verifyStringsWithRegex(writer, []string{name}, ValidBlueprintName) {
+		return
+	}
+
 	commit := params.ByName("commit")
+	if !verifyStringsWithRegex(writer, []string{commit}, ValidBlueprintName) {
+		return
+	}
+
 	bpChange, err := api.store.GetBlueprintChange(name, commit)
 	if err != nil {
 		errors := responseError{
@@ -1375,7 +1434,12 @@ func (api *API) blueprintDeleteHandler(writer http.ResponseWriter, request *http
 		return
 	}
 
-	if err := api.store.DeleteBlueprint(params.ByName("blueprint")); err != nil {
+	name := params.ByName("blueprint")
+	if !verifyStringsWithRegex(writer, []string{name}, ValidBlueprintName) {
+		return
+	}
+
+	if err := api.store.DeleteBlueprint(name); err != nil {
 		errors := responseError{
 			ID:  "BlueprintsError",
 			Msg: err.Error(),
@@ -1391,7 +1455,12 @@ func (api *API) blueprintDeleteWorkspaceHandler(writer http.ResponseWriter, requ
 		return
 	}
 
-	if err := api.store.DeleteBlueprintFromWorkspace(params.ByName("blueprint")); err != nil {
+	name := params.ByName("blueprint")
+	if !verifyStringsWithRegex(writer, []string{name}, ValidBlueprintName) {
+		return
+	}
+
+	if err := api.store.DeleteBlueprintFromWorkspace(name); err != nil {
 		errors := responseError{
 			ID:  "BlueprintsError",
 			Msg: err.Error(),
@@ -1408,7 +1477,12 @@ func (api *API) blueprintsTagHandler(writer http.ResponseWriter, request *http.R
 		return
 	}
 
-	err := api.store.TagBlueprint(params.ByName("blueprint"))
+	name := params.ByName("blueprint")
+	if !verifyStringsWithRegex(writer, []string{name}, ValidBlueprintName) {
+		return
+	}
+
+	err := api.store.TagBlueprint(name)
 	if err != nil {
 		errors := responseError{
 			ID:  "BlueprintsError",
@@ -1472,6 +1546,10 @@ func (api *API) composeHandler(writer http.ResponseWriter, request *http.Request
 		return
 	}
 
+	if !verifyStringsWithRegex(writer, []string{cr.BlueprintName}, ValidBlueprintName) {
+		return
+	}
+
 	composeID := uuid.New()
 
 	var targets []*target.Target
@@ -1727,6 +1805,10 @@ func (api *API) composeStatusHandler(writer http.ResponseWriter, request *http.R
 	}
 
 	filterBlueprint := q.Get("blueprint")
+	if len(filterBlueprint) > 0 && !verifyStringsWithRegex(writer, []string{filterBlueprint}, ValidBlueprintName) {
+		return
+	}
+
 	filterStatus := q.Get("status")
 	filterImageType, filterImageTypeExists := common.ImageTypeFromCompatString(q.Get("type"))
 
diff --git a/internal/worker/server.go b/internal/worker/server.go
index b13345c8e..768120c14 100644
--- a/internal/worker/server.go
+++ b/internal/worker/server.go
@@ -79,26 +79,54 @@ func (s *Server) Enqueue(manifest *osbuild.Manifest, targets []*target.Target) (
 }
 
 func (s *Server) JobStatus(id uuid.UUID) (state common.ComposeState, queued, started, finished time.Time, err error) {
+	var canceled bool
 	var result OSBuildJobResult
-	var status jobqueue.JobStatus
 
-	status, queued, started, finished, err = s.jobs.JobStatus(id, &result)
+	queued, started, finished, canceled, err = s.jobs.JobStatus(id, &result)
 	if err != nil {
 		return
 	}
 
-	state = composeStateFromJobStatus(status, result.OSBuildOutput)
+	if canceled {
+		state = common.CFailed
+	} else if !finished.IsZero() {
+		if result.OSBuildOutput.Success {
+			state = common.CFinished
+		} else {
+			state = common.CFailed
+		}
+	} else if !started.IsZero() {
+		state = common.CRunning
+	} else {
+		state = common.CWaiting
+	}
+
 	return
 }
 
 func (s *Server) JobResult(id uuid.UUID) (common.ComposeState, *common.ComposeResult, error) {
+	var canceled bool
 	var result OSBuildJobResult
-	status, _, _, _, err := s.jobs.JobStatus(id, &result)
+
+	_, started, finished, canceled, err := s.jobs.JobStatus(id, &result)
 	if err != nil {
 		return common.CWaiting, nil, err
 	}
 
-	return composeStateFromJobStatus(status, result.OSBuildOutput), result.OSBuildOutput, nil
+	state := common.CWaiting
+	if canceled {
+		state = common.CFailed
+	} else if !finished.IsZero() {
+		if result.OSBuildOutput.Success {
+			state = common.CFinished
+		} else {
+			state = common.CFailed
+		}
+	} else if !started.IsZero() {
+		state = common.CRunning
+	}
+
+	return state, result.OSBuildOutput, nil
 }
 
 // jsonErrorf() is similar to http.Error(), but returns the message in a json
@@ -216,19 +244,3 @@ func (s *Server) addJobImageHandler(writer http.ResponseWriter, request *http.Re
 		jsonErrorf(writer, http.StatusInternalServerError, "%v", err)
 	}
 }
-
-func composeStateFromJobStatus(status jobqueue.JobStatus, output *common.ComposeResult) common.ComposeState {
-	switch status {
-	case jobqueue.JobPending:
-		return common.CWaiting
-	case jobqueue.JobRunning:
-		return common.CRunning
-	case jobqueue.JobFinished:
-		if output.Success {
-			return common.CFinished
-		} else {
-			return common.CFailed
-		}
-	}
-	return common.CWaiting
-}


Test patch:
diff --git a/internal/client/blueprints_test.go b/internal/client/blueprints_test.go
index 2f932bd86..fe8b993e7 100644
--- a/internal/client/blueprints_test.go
+++ b/internal/client/blueprints_test.go
@@ -63,6 +63,42 @@ func TestPostEmptyTOMLBlueprintV0(t *testing.T) {
 	require.False(t, resp.Status, "did not return an error")
 }
 
+// POST a TOML blueprint with an empty name
+func TestPostEmptyNameTOMLBlueprintV0(t *testing.T) {
+	// Use a blueprint with an empty Name
+	bp := `
+		name=""
+		version="0.0.1"
+		[package]
+		name="bash"
+		version="*"
+		`
+	resp, err := PostTOMLBlueprintV0(testState.socket, bp)
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp, "did not return an error")
+	require.False(t, resp.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", resp.Errors[0].ID)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
+// POST a TOML blueprint with an invalid name chars
+func TestPostInvalidNameTOMLBlueprintV0(t *testing.T) {
+	// Use a blueprint with invalid Name
+	bp := `
+		name="I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜"
+		version="0.0.1"
+		[package]
+		name="bash"
+		version="*"
+		`
+	resp, err := PostTOMLBlueprintV0(testState.socket, bp)
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp, "did not return an error")
+	require.False(t, resp.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", resp.Errors[0].ID)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
 // POST a new JSON blueprint
 func TestPostJSONBlueprintV0(t *testing.T) {
 	bp := `{
@@ -100,6 +136,40 @@ func TestPostEmptyJSONBlueprintV0(t *testing.T) {
 	require.False(t, resp.Status, "did not return an error")
 }
 
+// POST a JSON blueprint with an empty name
+func TestPostEmptyNameJSONBlueprintV0(t *testing.T) {
+	// Use a blueprint with an empty Name
+	bp := `{
+		"name": "",
+		"version": "0.0.1",
+		"modules": [{"name": "util-linux", "version": "*"}]
+	}`
+
+	resp, err := PostJSONBlueprintV0(testState.socket, bp)
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp, "did not return an error")
+	require.False(t, resp.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", resp.Errors[0].ID)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
+// POST a JSON blueprint with invalid name chars
+func TestPostInvalidNameJSONBlueprintV0(t *testing.T) {
+	// Use a blueprint with an empty Name
+	bp := `{
+		"name": "I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜",
+		"version": "0.0.1",
+		"modules": [{"name": "util-linux", "version": "*"}]
+	}`
+
+	resp, err := PostJSONBlueprintV0(testState.socket, bp)
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp, "did not return an error")
+	require.False(t, resp.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", resp.Errors[0].ID)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
 // POST a blueprint to the workspace as TOML
 func TestPostTOMLWorkspaceV0(t *testing.T) {
 	bp := `
@@ -145,6 +215,42 @@ func TestPostEmptyTOMLWorkspaceV0(t *testing.T) {
 	require.False(t, resp.Status, "did not return an error")
 }
 
+// POST a TOML blueprint with an empty name to the workspace
+func TestPostEmptyNameTOMLWorkspaceV0(t *testing.T) {
+	// Use a blueprint with an empty Name
+	bp := `
+		name=""
+		version="0.0.1"
+		[package]
+		name="bash"
+		version="*"
+		`
+	resp, err := PostTOMLWorkspaceV0(testState.socket, bp)
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp, "did not return an error")
+	require.False(t, resp.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", resp.Errors[0].ID)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
+// POST a TOML blueprint with an invalid name chars to the workspace
+func TestPostInvalidNameTOMLWorkspaceV0(t *testing.T) {
+	// Use a blueprint with invalid Name
+	bp := `
+		name="I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜"
+		version="0.0.1"
+		[package]
+		name="bash"
+		version="*"
+		`
+	resp, err := PostTOMLWorkspaceV0(testState.socket, bp)
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp, "did not return an error")
+	require.False(t, resp.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", resp.Errors[0].ID)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
 // POST a blueprint to the workspace as JSON
 func TestPostJSONWorkspaceV0(t *testing.T) {
 	bp := `{
@@ -182,6 +288,40 @@ func TestPostEmptyJSONWorkspaceV0(t *testing.T) {
 	require.False(t, resp.Status, "did not return an error")
 }
 
+// POST a JSON blueprint with an empty name to the workspace
+func TestPostEmptyNameJSONWorkspaceV0(t *testing.T) {
+	// Use a blueprint with an empty Name
+	bp := `{
+		"name": "",
+		"version": "0.0.1",
+		"modules": [{"name": "util-linux", "version": "*"}]
+	}`
+
+	resp, err := PostJSONWorkspaceV0(testState.socket, bp)
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp, "did not return an error")
+	require.False(t, resp.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", resp.Errors[0].ID)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
+// POST a JSON blueprint with invalid name chars to the workspace
+func TestPostInvalidNameJSONWorkspaceV0(t *testing.T) {
+	// Use a blueprint with an empty Name
+	bp := `{
+		"name": "I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜",
+		"version": "0.0.1",
+		"modules": [{"name": "util-linux", "version": "*"}]
+	}`
+
+	resp, err := PostJSONWorkspaceV0(testState.socket, bp)
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp, "did not return an error")
+	require.False(t, resp.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", resp.Errors[0].ID)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
 // delete a blueprint
 func TestDeleteBlueprintV0(t *testing.T) {
 	// POST a blueprint to delete
@@ -211,6 +351,16 @@ func TestDeleteNonBlueprint0(t *testing.T) {
 	require.False(t, resp.Status, "did not return an error")
 }
 
+// delete a blueprint with invalid name characters
+func TestDeleteInvalidBlueprintV0(t *testing.T) {
+	resp, err := DeleteBlueprintV0(testState.socket, "I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜")
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp, "did not return an error")
+	require.False(t, resp.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", resp.Errors[0].ID)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
 // delete a new blueprint from the workspace
 func TestDeleteNewWorkspaceV0(t *testing.T) {
 	// POST a blueprint to delete
@@ -291,6 +441,23 @@ func TestDeleteChangesWorkspaceV0(t *testing.T) {
 	require.Equal(t, "deleteBlueprintChangesWSV0", info.Blueprints[0].Description, "original blueprint not returned")
 }
 
+// delete a non-existent blueprint workspace
+func TestDeleteNonWorkspace0(t *testing.T) {
+	resp, err := DeleteWorkspaceV0(testState.socket, "test-delete-non-blueprint-ws-v0")
+	require.NoError(t, err, "failed with a client error")
+	require.False(t, resp.Status, "did not return an error")
+}
+
+// delete a blueprint with invalid name characters
+func TestDeleteInvalidWorkspaceV0(t *testing.T) {
+	resp, err := DeleteWorkspaceV0(testState.socket, "I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜")
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp, "did not return an error")
+	require.False(t, resp.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", resp.Errors[0].ID)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
 // list blueprints
 func TestListBlueprintsV0(t *testing.T) {
 	// Post a couple of blueprints
@@ -361,6 +528,16 @@ func TestGetNonTOMLBlueprintV0(t *testing.T) {
 	require.False(t, api.Status, "wrong Status (true)")
 }
 
+// get blueprint with invalid name characters
+func TestGetInvalidTOMLBlueprintV0(t *testing.T) {
+	_, api, err := GetBlueprintInfoTOMLV0(testState.socket, "I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜")
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, api, "did not return an error")
+	require.False(t, api.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", api.Errors[0].ID)
+	require.Contains(t, api.Errors[0].Msg, "Invalid characters in API path")
+}
+
 // get blueprint contents as JSON
 func TestGetJSONBlueprintV0(t *testing.T) {
 	bp := `{
@@ -490,6 +667,16 @@ func TestBlueprintNonChangesV0(t *testing.T) {
 	require.Greater(t, len(resp.Errors), 0, "failed with no error: %#v", resp)
 }
 
+// get changes with invalid name characters
+func TestInvalidBlueprintChangesV0(t *testing.T) {
+	_, api, err := GetBlueprintsChangesV0(testState.socket, []string{"I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜"})
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, api, "did not return an error")
+	require.False(t, api.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", api.Errors[0].ID)
+	require.Contains(t, api.Errors[0].Msg, "Invalid characters in API path")
+}
+
 // Undo blueprint changes
 func TestUndoBlueprintV0(t *testing.T) {
 	bps := []string{`{
@@ -589,6 +776,26 @@ func TestUndoNonBlueprintV0(t *testing.T) {
 	require.False(t, resp.Status, "did not return an error")
 }
 
+// undo a blueprint with invalid name characters
+func TestUndoInvalidBlueprintV0(t *testing.T) {
+	resp, err := UndoBlueprintChangeV0(testState.socket, "I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜", "FFFF")
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp, "did not return an error")
+	require.False(t, resp.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", resp.Errors[0].ID)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
+// undo a blueprint with invalid commit characters
+func TestUndoInvalidBlueprintCommitV0(t *testing.T) {
+	resp, err := UndoBlueprintChangeV0(testState.socket, "test-undo-non-blueprint-v0", "I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜")
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp, "did not return an error")
+	require.False(t, resp.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", resp.Errors[0].ID)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
 // Tag a blueprint with a new revision
 // The blueprint revision tag cannot be reset, it always increments by one, and cannot be deleted.
 // So to test tagging we tag two blueprint changes and make sure the second is first +1
@@ -660,6 +867,16 @@ func TestNonBlueprintTagV0(t *testing.T) {
 	require.False(t, tagResp.Status, "did not return an error")
 }
 
+// tag a blueprint with invalid name characters
+func TestTagInvalidBlueprintV0(t *testing.T) {
+	resp, err := TagBlueprintV0(testState.socket, "I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜")
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp, "did not return an error")
+	require.False(t, resp.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", resp.Errors[0].ID)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
 // depsolve a blueprint with packages and modules
 func TestBlueprintDepsolveV0(t *testing.T) {
 	bp := `{
@@ -695,6 +912,16 @@ func TestNonBlueprintDepsolveV0(t *testing.T) {
 	require.Greater(t, len(resp.Errors), 0, "failed with no error: %#v", resp)
 }
 
+// depsolve a blueprint with invalid name characters
+func TestDepsolveInvalidBlueprintV0(t *testing.T) {
+	_, api, err := DepsolveBlueprintV0(testState.socket, "I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜")
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, api, "did not return an error")
+	require.False(t, api.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", api.Errors[0].ID)
+	require.Contains(t, api.Errors[0].Msg, "Invalid characters in API path")
+}
+
 // freeze a blueprint
 func TestBlueprintFreezeV0(t *testing.T) {
 	if testState.unitTest {
@@ -758,4 +985,14 @@ func TestNonBlueprintFreezeV0(t *testing.T) {
 	require.Greater(t, len(resp.Errors), 0, "failed with no error: %#v", resp)
 }
 
+// freeze a blueprint with invalid name characters
+func TestFreezeInvalidBlueprintV0(t *testing.T) {
+	_, api, err := FreezeBlueprintV0(testState.socket, "I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜")
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, api, "did not return an error")
+	require.False(t, api.Status, "wrong Status (true)")
+	require.Equal(t, "InvalidChars", api.Errors[0].ID)
+	require.Contains(t, api.Errors[0].Msg, "Invalid characters in API path")
+}
+
 // TODO diff of blueprint changes
diff --git a/internal/client/compose_test.go b/internal/client/compose_test.go
index de73cd040..69a8ef605 100644
--- a/internal/client/compose_test.go
+++ b/internal/client/compose_test.go
@@ -89,6 +89,36 @@ func TestComposeInvalidBlueprintV0(t *testing.T) {
 	require.Contains(t, resp.Errors[0].Msg, "test-invalid-bp-compose-v0")
 }
 
+// Test compose for empty blueprint fails
+func TestComposeEmptyBlueprintV0(t *testing.T) {
+	compose := `{
+		"blueprint_name": "",
+		"compose_type": "qcow2",
+		"branch": "master"
+	}`
+	resp, err := PostComposeV0(testState.socket, compose)
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp)
+	require.False(t, resp.Status, "POST did not fail")
+	require.Equal(t, len(resp.Errors), 1)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
+// Test compose for blueprint with invalid characters fails
+func TestComposeInvalidCharsBlueprintV0(t *testing.T) {
+	compose := `{
+		"blueprint_name": "I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜",
+		"compose_type": "qcow2",
+		"branch": "master"
+	}`
+	resp, err := PostComposeV0(testState.socket, compose)
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp)
+	require.False(t, resp.Status, "POST did not fail")
+	require.Equal(t, len(resp.Errors), 1)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+}
+
 // Test compose cancel for unknown uuid fails
 // Is cancel implemented at all?
 
@@ -162,6 +192,24 @@ func TestComposeInvalidStatusV0(t *testing.T) {
 	require.Equal(t, 0, len(status))
 }
 
+// Test status filter for unknown blueprint
+func TestComposeUnknownBlueprintStatusV0(t *testing.T) {
+	status, resp, err := GetComposeStatusV0(testState.socket, "*", "unknown-blueprint-test", "", "")
+	require.NoError(t, err, "failed with a client error")
+	require.Nil(t, resp)
+	require.Equal(t, 0, len(status))
+}
+
+// Test status filter for blueprint with invalid characters
+func TestComposeInvalidBlueprintStatusV0(t *testing.T) {
+	status, resp, err := GetComposeStatusV0(testState.socket, "*", "I ｗ𝒊ll 𝟉ο𝘁 𝛠ａ𝔰ꜱ 𝘁𝒉𝝸𝚜", "", "")
+	require.NoError(t, err, "failed with a client error")
+	require.NotNil(t, resp)
+	require.Equal(t, "InvalidChars", resp.Errors[0].ID)
+	require.Contains(t, resp.Errors[0].Msg, "Invalid characters in API path")
+	require.Equal(t, 0, len(status))
+}
+
 // Helper for searching compose results for a UUID
 func UUIDInComposeResults(buildID uuid.UUID, results []weldr.ComposeEntryV0) bool {
 	for idx := range results {
diff --git a/internal/distro/rhel82/distro_test.go b/internal/distro/rhel82/distro_test.go
index 83cbf1d97..351a74346 100644
--- a/internal/distro/rhel82/distro_test.go
+++ b/internal/distro/rhel82/distro_test.go
@@ -3,7 +3,9 @@ package rhel82_test
 import (
 	"testing"
 
+	"github.com/osbuild/osbuild-composer/internal/distro/distro_test_common"
 	"github.com/osbuild/osbuild-composer/internal/distro/rhel82"
+	"github.com/stretchr/testify/assert"
 )
 
 func TestFilenameFromType(t *testing.T) {
@@ -93,3 +95,321 @@ func TestFilenameFromType(t *testing.T) {
 		})
 	}
 }
+
+func TestImageType_BuildPackages(t *testing.T) {
+	x8664BuildPackages := []string{
+		"dnf",
+		"dosfstools",
+		"e2fsprogs",
+		"grub2-pc",
+		"policycoreutils",
+		"qemu-img",
+		"systemd",
+		"tar",
+		"xz",
+	}
+	aarch64BuildPackages := []string{
+		"dnf",
+		"dosfstools",
+		"e2fsprogs",
+		"policycoreutils",
+		"qemu-img",
+		"systemd",
+		"tar",
+		"xz",
+	}
+	buildPackages := map[string][]string{
+		"x86_64":  x8664BuildPackages,
+		"aarch64": aarch64BuildPackages,
+	}
+	d := rhel82.New()
+	for _, archLabel := range d.ListArches() {
+		archStruct, err := d.GetArch(archLabel)
+		if assert.NoErrorf(t, err, "d.GetArch(%v) returned err = %v; expected nil", archLabel, err) {
+			continue
+		}
+		for _, itLabel := range archStruct.ListImageTypes() {
+			itStruct, err := archStruct.GetImageType(itLabel)
+			if assert.NoErrorf(t, err, "d.GetArch(%v) returned err = %v; expected nil", archLabel, err) {
+				continue
+			}
+			assert.ElementsMatch(t, buildPackages[archLabel], itStruct.BuildPackages())
+		}
+	}
+}
+
+func TestImageType_Name(t *testing.T) {
+	distro := rhel82.New()
+	imgMap := []struct {
+		arch     string
+		imgNames []string
+	}{
+		{
+			arch: "x86_64",
+			imgNames: []string{
+				"ami",
+				"ext4-filesystem",
+				"partitioned-disk",
+				"qcow2",
+				"openstack",
+				"tar",
+				"vhd",
+				"vmdk",
+			},
+		},
+		{
+			arch: "aarch64",
+			imgNames: []string{
+				"ami",
+				"ext4-filesystem",
+				"partitioned-disk",
+				"qcow2",
+				"openstack",
+				"tar",
+			},
+		},
+	}
+	for _, mapping := range imgMap {
+		arch, err := distro.GetArch(mapping.arch)
+		if assert.NoError(t, err) {
+			for _, imgName := range mapping.imgNames {
+				imgType, err := arch.GetImageType(imgName)
+				if assert.NoError(t, err) {
+					assert.Equalf(t, imgName, imgType.Name(), "arch: %s", mapping.arch)
+				}
+			}
+		}
+	}
+}
+
+func TestImageType_Size(t *testing.T) {
+	const gigaByte = 1024 * 1024 * 1024
+	sizeMap := []struct {
+		name       string
+		inputSize  uint64
+		outputSize uint64
+	}{
+		{
+			name:       "ami",
+			inputSize:  6*gigaByte + 1,
+			outputSize: 6*gigaByte + 1,
+		},
+		{
+			name:       "ami",
+			inputSize:  0,
+			outputSize: 6 * gigaByte,
+		},
+		{
+			name:       "vhd",
+			inputSize:  10 * gigaByte,
+			outputSize: 10 * gigaByte,
+		},
+		{
+			name:       "vhd",
+			inputSize:  10*gigaByte - 1,
+			outputSize: 10 * gigaByte,
+		},
+	}
+
+	distro := rhel82.New()
+	arch, err := distro.GetArch("x86_64")
+	if assert.NoError(t, err) {
+		for _, mapping := range sizeMap {
+			imgType, err := arch.GetImageType(mapping.name)
+			if assert.NoError(t, err) {
+				size := imgType.Size(mapping.inputSize)
+				assert.Equalf(t, mapping.outputSize, size, "Image type: %s, input size: %d, expected: %d, got: %d",
+					mapping.name, mapping.inputSize, mapping.outputSize, size)
+			}
+		}
+	}
+}
+
+func TestImageType_BasePackages(t *testing.T) {
+	pkgMaps := []struct {
+		name               string
+		basePackages       []string
+		bootloaderPackages []string
+		excludedPackages   []string
+		bootable           bool
+	}{
+		{
+			name: "ami",
+			basePackages: []string{
+				"checkpolicy",
+				"chrony",
+				"cloud-init",
+				"cloud-init",
+				"cloud-utils-growpart",
+				"@core",
+				"dhcp-client",
+				"gdisk",
+				"insights-client",
+				"kernel",
+				"langpacks-en",
+				"net-tools",
+				"NetworkManager",
+				"redhat-release",
+				"redhat-release-eula",
+				"rng-tools",
+				"rsync",
+				"selinux-policy-targeted",
+				"tar",
+				"yum-utils",
+			},
+			bootloaderPackages: []string{
+				"dracut-config-generic",
+				"grub2-pc",
+			},
+			excludedPackages: []string{
+				"aic94xx-firmware",
+				"alsa-firmware",
+				"alsa-lib",
+				"alsa-tools-firmware",
+				"biosdevname",
+				"dracut-config-rescue",
+				"firewalld",
+				"iprutils",
+				"ivtv-firmware",
+				"iwl1000-firmware",
+				"iwl100-firmware",
+				"iwl105-firmware",
+				"iwl135-firmware",
+				"iwl2000-firmware",
+				"iwl2030-firmware",
+				"iwl3160-firmware",
+				"iwl3945-firmware",
+				"iwl4965-firmware",
+				"iwl5000-firmware",
+				"iwl5150-firmware",
+				"iwl6000-firmware",
+				"iwl6000g2a-firmware",
+				"iwl6000g2b-firmware",
+				"iwl6050-firmware",
+				"iwl7260-firmware",
+				"libertas-sd8686-firmware",
+				"libertas-sd8787-firmware",
+				"libertas-usb8388-firmware",
+				"plymouth",
+
+				// TODO this cannot be removed, because the kernel (?)
+				// depends on it. The ec2 kickstart force-removes it.
+				// "linux-firmware",
+
+				// TODO setfiles failes because of usr/sbin/timedatex. Exlude until
+				// https://errata.devel.redhat.com/advisory/47339 lands
+				"timedatex",
+			},
+			bootable: true,
+		},
+		{
+			name: "ext4-filesystem",
+			basePackages: []string{
+				"policycoreutils",
+				"selinux-policy-targeted",
+				"kernel",
+				"firewalld",
+				"chrony",
+				"langpacks-en",
+			},
+			excludedPackages: []string{
+				"dracut-config-rescue",
+
+				// TODO setfiles failes because of usr/sbin/timedatex. Exlude until
+				// https://errata.devel.redhat.com/advisory/47339 lands
+				"timedatex",
+			},
+			bootable: true,
+		},
+		{
+			name: "openstack",
+			basePackages: []string{
+				// Defaults
+				"@Core",
+				"langpacks-en",
+
+				// From the lorax kickstart
+				"kernel",
+				"selinux-policy-targeted",
+				"cloud-init",
+				"qemu-guest-agent",
+				"spice-vdagent",
+			},
+			bootloaderPackages: []string{
+				"dracut-config-generic",
+				"grub2-pc",
+			},
+			excludedPackages: []string{
+				"dracut-config-rescue",
+			},
+			bootable: true,
+		},
+	}
+	distro := rhel82.New()
+	arch, err := distro.GetArch("x86_64")
+	assert.NoError(t, err)
+
+	for _, pkgMap := range pkgMaps {
+		imgType, err := arch.GetImageType(pkgMap.name)
+		assert.NoError(t, err)
+		basePackages, excludedPackages := imgType.BasePackages()
+		assert.Equalf(
+			t,
+			append(pkgMap.basePackages, pkgMap.bootloaderPackages...),
+			basePackages,
+			"image type: %s",
+			pkgMap.name,
+		)
+		assert.Equalf(t, pkgMap.excludedPackages, excludedPackages, "image type: %s", pkgMap.name)
+	}
+}
+
+func TestDistro_Manifest(t *testing.T) {
+	distro_test_common.TestDistro_Manifest(t, "../../../test/cases/", "rhel_8.2*", rhel82.New())
+}
+
+func TestRhel82_ListArches(t *testing.T) {
+	distro := rhel82.New()
+	arches := distro.ListArches()
+	assert.Equal(t, []string{"aarch64", "x86_64"}, arches)
+}
+
+func TestRhel82_GetArch(t *testing.T) {
+	distro := rhel82.New()
+	arches := []struct {
+		name          string
+		errorExpected bool
+	}{
+		{
+			name: "x86_64",
+		},
+		{
+			name: "aarch64",
+		},
+		{
+			name:          "foo-arch",
+			errorExpected: true,
+		},
+	}
+
+	for _, a := range arches {
+		actualArch, err := distro.GetArch(a.name)
+		if !a.errorExpected {
+			assert.Equal(t, a.name, actualArch.Name())
+			assert.NoError(t, err)
+		} else {
+			assert.Nil(t, actualArch)
+			assert.Error(t, err)
+		}
+	}
+}
+
+func TestRhel82_Name(t *testing.T) {
+	distro := rhel82.New()
+	assert.Equal(t, "rhel-8.2", distro.Name())
+}
+
+func TestRhel82_ModulePlatformID(t *testing.T) {
+	distro := rhel82.New()
+	assert.Equal(t, "platform:el8", distro.ModulePlatformID())
+}
diff --git a/internal/distro/rhel83/distro_test.go b/internal/distro/rhel83/distro_test.go
index 64fb2f9c4..a2c849821 100644
--- a/internal/distro/rhel83/distro_test.go
+++ b/internal/distro/rhel83/distro_test.go
@@ -4,6 +4,7 @@ import (
 	"testing"
 
 	"github.com/osbuild/osbuild-composer/internal/distro/rhel83"
+	"github.com/stretchr/testify/assert"
 )
 
 func TestFilenameFromType(t *testing.T) {
@@ -93,3 +94,322 @@ func TestFilenameFromType(t *testing.T) {
 		})
 	}
 }
+
+func TestImageType_BuildPackages(t *testing.T) {
+	x8664BuildPackages := []string{
+		"dnf",
+		"dosfstools",
+		"e2fsprogs",
+		"grub2-pc",
+		"policycoreutils",
+		"qemu-img",
+		"systemd",
+		"tar",
+		"xz",
+	}
+	aarch64BuildPackages := []string{
+		"dnf",
+		"dosfstools",
+		"e2fsprogs",
+		"policycoreutils",
+		"qemu-img",
+		"systemd",
+		"tar",
+		"xz",
+	}
+	buildPackages := map[string][]string{
+		"x86_64":  x8664BuildPackages,
+		"aarch64": aarch64BuildPackages,
+	}
+	d := rhel83.New()
+	for _, archLabel := range d.ListArches() {
+		archStruct, err := d.GetArch(archLabel)
+		if assert.NoErrorf(t, err, "d.GetArch(%v) returned err = %v; expected nil", archLabel, err) {
+			continue
+		}
+		for _, itLabel := range archStruct.ListImageTypes() {
+			itStruct, err := archStruct.GetImageType(itLabel)
+			if assert.NoErrorf(t, err, "d.GetArch(%v) returned err = %v; expected nil", archLabel, err) {
+				continue
+			}
+			assert.ElementsMatch(t, buildPackages[archLabel], itStruct.BuildPackages())
+		}
+	}
+}
+
+func TestImageType_Name(t *testing.T) {
+	distro := rhel83.New()
+	imgMap := []struct {
+		arch     string
+		imgNames []string
+	}{
+		{
+			arch: "x86_64",
+			imgNames: []string{
+				"ami",
+				"ext4-filesystem",
+				"partitioned-disk",
+				"qcow2",
+				"openstack",
+				"tar",
+				"vhd",
+				"vmdk",
+			},
+		},
+		{
+			arch: "aarch64",
+			imgNames: []string{
+				"ami",
+				"ext4-filesystem",
+				"partitioned-disk",
+				"qcow2",
+				"openstack",
+				"tar",
+			},
+		},
+	}
+	for _, mapping := range imgMap {
+		arch, err := distro.GetArch(mapping.arch)
+		if assert.NoError(t, err) {
+			for _, imgName := range mapping.imgNames {
+				imgType, err := arch.GetImageType(imgName)
+				if assert.NoError(t, err) {
+					assert.Equalf(t, imgName, imgType.Name(), "arch: %s", mapping.arch)
+				}
+			}
+		}
+	}
+}
+
+func TestImageType_Size(t *testing.T) {
+	const gigaByte = 1024 * 1024 * 1024
+	sizeMap := []struct {
+		name       string
+		inputSize  uint64
+		outputSize uint64
+	}{
+		{
+			name:       "ami",
+			inputSize:  6*gigaByte + 1,
+			outputSize: 6*gigaByte + 1,
+		},
+		{
+			name:       "ami",
+			inputSize:  0,
+			outputSize: 6 * gigaByte,
+		},
+		{
+			name:       "vhd",
+			inputSize:  10 * gigaByte,
+			outputSize: 10 * gigaByte,
+		},
+		{
+			name:       "vhd",
+			inputSize:  10*gigaByte - 1,
+			outputSize: 10 * gigaByte,
+		},
+	}
+
+	distro := rhel83.New()
+	arch, err := distro.GetArch("x86_64")
+	if assert.NoError(t, err) {
+		for _, mapping := range sizeMap {
+			imgType, err := arch.GetImageType(mapping.name)
+			if assert.NoError(t, err) {
+				size := imgType.Size(mapping.inputSize)
+				assert.Equalf(t, mapping.outputSize, size, "Image type: %s, input size: %d, expected: %d, got: %d",
+					mapping.name, mapping.inputSize, mapping.outputSize, size)
+			}
+		}
+	}
+}
+
+func TestImageType_BasePackages(t *testing.T) {
+	pkgMaps := []struct {
+		name               string
+		basePackages       []string
+		bootloaderPackages []string
+		excludedPackages   []string
+		bootable           bool
+	}{
+		{
+			name: "ami",
+			basePackages: []string{
+				"checkpolicy",
+				"chrony",
+				"cloud-init",
+				"cloud-init",
+				"cloud-utils-growpart",
+				"@core",
+				"dhcp-client",
+				"gdisk",
+				"insights-client",
+				"kernel",
+				"langpacks-en",
+				"net-tools",
+				"NetworkManager",
+				"redhat-release",
+				"redhat-release-eula",
+				"rng-tools",
+				"rsync",
+				"selinux-policy-targeted",
+				"tar",
+				"yum-utils",
+			},
+			bootloaderPackages: []string{
+				"dracut-config-generic",
+				"grub2-pc",
+			},
+			excludedPackages: []string{
+				"aic94xx-firmware",
+				"alsa-firmware",
+				"alsa-lib",
+				"alsa-tools-firmware",
+				"biosdevname",
+				"dracut-config-rescue",
+				"firewalld",
+				"iprutils",
+				"ivtv-firmware",
+				"iwl1000-firmware",
+				"iwl100-firmware",
+				"iwl105-firmware",
+				"iwl135-firmware",
+				"iwl2000-firmware",
+				"iwl2030-firmware",
+				"iwl3160-firmware",
+				"iwl3945-firmware",
+				"iwl4965-firmware",
+				"iwl5000-firmware",
+				"iwl5150-firmware",
+				"iwl6000-firmware",
+				"iwl6000g2a-firmware",
+				"iwl6000g2b-firmware",
+				"iwl6050-firmware",
+				"iwl7260-firmware",
+				"libertas-sd8686-firmware",
+				"libertas-sd8787-firmware",
+				"libertas-usb8388-firmware",
+				"plymouth",
+
+				// TODO this cannot be removed, because the kernel (?)
+				// depends on it. The ec2 kickstart force-removes it.
+				// "linux-firmware",
+
+				// TODO setfiles failes because of usr/sbin/timedatex. Exlude until
+				// https://errata.devel.redhat.com/advisory/47339 lands
+				"timedatex",
+			},
+			bootable: true,
+		},
+		{
+			name: "ext4-filesystem",
+			basePackages: []string{
+				"policycoreutils",
+				"selinux-policy-targeted",
+				"kernel",
+				"firewalld",
+				"chrony",
+				"langpacks-en",
+			},
+			excludedPackages: []string{
+				"dracut-config-rescue",
+
+				// TODO setfiles failes because of usr/sbin/timedatex. Exlude until
+				// https://errata.devel.redhat.com/advisory/47339 lands
+				"timedatex",
+			},
+			bootable: true,
+		},
+		{
+			name: "openstack",
+			basePackages: []string{
+				// Defaults
+				"@Core",
+				"langpacks-en",
+
+				// From the lorax kickstart
+				"kernel",
+				"selinux-policy-targeted",
+				"cloud-init",
+				"qemu-guest-agent",
+				"spice-vdagent",
+			},
+			bootloaderPackages: []string{
+				"dracut-config-generic",
+				"grub2-pc",
+			},
+			excludedPackages: []string{
+				"dracut-config-rescue",
+			},
+			bootable: true,
+		},
+	}
+	distro := rhel83.New()
+	arch, err := distro.GetArch("x86_64")
+	assert.NoError(t, err)
+
+	for _, pkgMap := range pkgMaps {
+		imgType, err := arch.GetImageType(pkgMap.name)
+		assert.NoError(t, err)
+		basePackages, excludedPackages := imgType.BasePackages()
+		assert.Equalf(
+			t,
+			append(pkgMap.basePackages, pkgMap.bootloaderPackages...),
+			basePackages,
+			"image type: %s",
+			pkgMap.name,
+		)
+		assert.Equalf(t, pkgMap.excludedPackages, excludedPackages, "image type: %s", pkgMap.name)
+	}
+}
+
+func TestDistro_Manifest(t *testing.T) {
+	// disabled as there is no usable manifest file available ATM
+	// distro_test_common.TestDistro_Manifest(t, "../../../test/cases/", "rhel_8.3*", rhel83.New())
+}
+
+func TestRhel83_ListArches(t *testing.T) {
+	distro := rhel83.New()
+	arches := distro.ListArches()
+	assert.Equal(t, []string{"aarch64", "x86_64"}, arches)
+}
+
+func TestRhel83_GetArch(t *testing.T) {
+	distro := rhel83.New()
+	arches := []struct {
+		name          string
+		errorExpected bool
+	}{
+		{
+			name: "x86_64",
+		},
+		{
+			name: "aarch64",
+		},
+		{
+			name:          "foo-arch",
+			errorExpected: true,
+		},
+	}
+
+	for _, a := range arches {
+		actualArch, err := distro.GetArch(a.name)
+		if !a.errorExpected {
+			assert.Equal(t, a.name, actualArch.Name())
+			assert.NoError(t, err)
+		} else {
+			assert.Nil(t, actualArch)
+			assert.Error(t, err)
+		}
+	}
+}
+
+func TestRhel83_Name(t *testing.T) {
+	distro := rhel83.New()
+	assert.Equal(t, "rhel-8.3", distro.Name())
+}
+
+func TestRhel83_ModulePlatformID(t *testing.T) {
+	distro := rhel83.New()
+	assert.Equal(t, "platform:el8", distro.ModulePlatformID())
+}
diff --git a/internal/jobqueue/fsjobqueue/fsjobqueue_test.go b/internal/jobqueue/fsjobqueue/fsjobqueue_test.go
index 0482557a7..108da8677 100644
--- a/internal/jobqueue/fsjobqueue/fsjobqueue_test.go
+++ b/internal/jobqueue/fsjobqueue/fsjobqueue_test.go
@@ -6,6 +6,7 @@ import (
 	"io/ioutil"
 	"os"
 	"testing"
+	"time"
 
 	"github.com/google/uuid"
 	"github.com/stretchr/testify/require"
@@ -22,11 +23,11 @@ func cleanupTempDir(t *testing.T, dir string) {
 	require.NoError(t, err)
 }
 
-func newTemporaryQueue(t *testing.T) (jobqueue.JobQueue, string) {
+func newTemporaryQueue(t *testing.T, jobTypes []string) (jobqueue.JobQueue, string) {
 	dir, err := ioutil.TempDir("", "jobqueue-test-")
 	require.NoError(t, err)
 
-	q, err := fsjobqueue.New(dir)
+	q, err := fsjobqueue.New(dir, jobTypes)
 	require.NoError(t, err)
 	require.NotNil(t, q)
 
@@ -34,6 +35,7 @@ func newTemporaryQueue(t *testing.T) (jobqueue.JobQueue, string) {
 }
 
 func pushTestJob(t *testing.T, q jobqueue.JobQueue, jobType string, args interface{}, dependencies []uuid.UUID) uuid.UUID {
+	t.Helper()
 	id, err := q.Enqueue(jobType, args, dependencies)
 	require.NoError(t, err)
 	require.NotEmpty(t, id)
@@ -52,13 +54,13 @@ func finishNextTestJob(t *testing.T, q jobqueue.JobQueue, jobType string, result
 }
 
 func TestNonExistant(t *testing.T) {
-	q, err := fsjobqueue.New("/non-existant-directory")
+	q, err := fsjobqueue.New("/non-existant-directory", []string{})
 	require.Error(t, err)
 	require.Nil(t, q)
 }
 
 func TestErrors(t *testing.T) {
-	q, dir := newTemporaryQueue(t)
+	q, dir := newTemporaryQueue(t, []string{"test"})
 	defer cleanupTempDir(t, dir)
 
 	// not serializable to JSON
@@ -78,7 +80,7 @@ func TestArgs(t *testing.T) {
 		S string
 	}
 
-	q, dir := newTemporaryQueue(t)
+	q, dir := newTemporaryQueue(t, []string{"fish", "octopus"})
 	defer cleanupTempDir(t, dir)
 
 	oneargs := argument{7, "🐠"}
@@ -100,7 +102,7 @@ func TestArgs(t *testing.T) {
 }
 
 func TestJobTypes(t *testing.T) {
-	q, dir := newTemporaryQueue(t)
+	q, dir := newTemporaryQueue(t, []string{"octopus", "clownfish"})
 	defer cleanupTempDir(t, dir)
 
 	one := pushTestJob(t, q, "octopus", nil, nil)
@@ -117,7 +119,7 @@ func TestJobTypes(t *testing.T) {
 }
 
 func TestDependencies(t *testing.T) {
-	q, dir := newTemporaryQueue(t)
+	q, dir := newTemporaryQueue(t, []string{"test"})
 	defer cleanupTempDir(t, dir)
 
 	t.Run("done-before-pushing-dependant", func(t *testing.T) {
@@ -130,15 +132,21 @@ func TestDependencies(t *testing.T) {
 		require.ElementsMatch(t, []uuid.UUID{one, two}, r)
 
 		j := pushTestJob(t, q, "test", nil, []uuid.UUID{one, two})
-		status, _, _, _, err := q.JobStatus(j, nil)
+		queued, started, finished, canceled, err := q.JobStatus(j, nil)
 		require.NoError(t, err)
-		require.Equal(t, jobqueue.JobPending, status)
+		require.True(t, !queued.IsZero())
+		require.True(t, started.IsZero())
+		require.True(t, finished.IsZero())
+		require.False(t, canceled)
 
 		require.Equal(t, j, finishNextTestJob(t, q, "test", testResult{}))
 
-		status, _, _, _, err = q.JobStatus(j, &testResult{})
+		queued, started, finished, canceled, err = q.JobStatus(j, &testResult{})
 		require.NoError(t, err)
-		require.Equal(t, jobqueue.JobFinished, status)
+		require.True(t, !queued.IsZero())
+		require.True(t, !started.IsZero())
+		require.True(t, !finished.IsZero())
+		require.False(t, canceled)
 	})
 
 	t.Run("done-after-pushing-dependant", func(t *testing.T) {
@@ -146,9 +154,12 @@ func TestDependencies(t *testing.T) {
 		two := pushTestJob(t, q, "test", nil, nil)
 
 		j := pushTestJob(t, q, "test", nil, []uuid.UUID{one, two})
-		status, _, _, _, err := q.JobStatus(j, nil)
+		queued, started, finished, canceled, err := q.JobStatus(j, nil)
 		require.NoError(t, err)
-		require.Equal(t, jobqueue.JobPending, status)
+		require.True(t, !queued.IsZero())
+		require.True(t, started.IsZero())
+		require.True(t, finished.IsZero())
+		require.False(t, canceled)
 
 		r := []uuid.UUID{}
 		r = append(r, finishNextTestJob(t, q, "test", testResult{}))
@@ -157,8 +168,90 @@ func TestDependencies(t *testing.T) {
 
 		require.Equal(t, j, finishNextTestJob(t, q, "test", testResult{}))
 
-		status, _, _, _, err = q.JobStatus(j, &testResult{})
+		queued, started, finished, canceled, err = q.JobStatus(j, &testResult{})
 		require.NoError(t, err)
-		require.Equal(t, jobqueue.JobFinished, status)
+		require.True(t, !queued.IsZero())
+		require.True(t, !started.IsZero())
+		require.True(t, !finished.IsZero())
+		require.False(t, canceled)
 	})
 }
+
+// Test that a job queue allows parallel access to multiple workers, mainly to
+// verify the quirky unlocking in Dequeue().
+func TestMultipleWorkers(t *testing.T) {
+	q, dir := newTemporaryQueue(t, []string{"octopus", "clownfish"})
+	defer cleanupTempDir(t, dir)
+
+	done := make(chan struct{})
+	go func() {
+		defer close(done)
+		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+		defer cancel()
+		id, err := q.Dequeue(ctx, []string{"octopus"}, &json.RawMessage{})
+		require.NoError(t, err)
+		require.NotEmpty(t, id)
+	}()
+
+	// Increase the likelihood that the above goroutine was scheduled and
+	// is waiting in Dequeue().
+	time.Sleep(10 * time.Millisecond)
+
+	// This call to Dequeue() should not block on the one in the goroutine.
+	id := pushTestJob(t, q, "clownfish", nil, nil)
+	r, err := q.Dequeue(context.Background(), []string{"clownfish"}, &json.RawMessage{})
+	require.NoError(t, err)
+	require.Equal(t, id, r)
+
+	// Now wake up the Dequeue() in the goroutine and wait for it to finish.
+	_ = pushTestJob(t, q, "octopus", nil, nil)
+	<-done
+}
+
+func TestCancel(t *testing.T) {
+	q, dir := newTemporaryQueue(t, []string{"octopus", "clownfish"})
+	defer cleanupTempDir(t, dir)
+
+	// Cancel a non-existing job
+	err := q.CancelJob(uuid.New())
+	require.Error(t, err)
+
+	// Cancel a pending job
+	id := pushTestJob(t, q, "clownfish", nil, nil)
+	require.NotEmpty(t, id)
+	err = q.CancelJob(id)
+	require.NoError(t, err)
+	_, _, _, canceled, err := q.JobStatus(id, &testResult{})
+	require.NoError(t, err)
+	require.True(t, canceled)
+	err = q.FinishJob(id, &testResult{})
+	require.Error(t, err)
+
+	// Cancel a running job, which should not dequeue the canceled job from above
+	id = pushTestJob(t, q, "clownfish", nil, nil)
+	require.NotEmpty(t, id)
+	r, err := q.Dequeue(context.Background(), []string{"clownfish"}, &json.RawMessage{})
+	require.NoError(t, err)
+	require.Equal(t, id, r)
+	err = q.CancelJob(id)
+	require.NoError(t, err)
+	_, _, _, canceled, err = q.JobStatus(id, &testResult{})
+	require.NoError(t, err)
+	require.True(t, canceled)
+	err = q.FinishJob(id, &testResult{})
+	require.Error(t, err)
+
+	// Cancel a finished job, which is a no-op
+	id = pushTestJob(t, q, "clownfish", nil, nil)
+	require.NotEmpty(t, id)
+	r, err = q.Dequeue(context.Background(), []string{"clownfish"}, &json.RawMessage{})
+	require.NoError(t, err)
+	require.Equal(t, id, r)
+	err = q.FinishJob(id, &testResult{})
+	require.NoError(t, err)
+	err = q.CancelJob(id)
+	require.NoError(t, err)
+	_, _, _, canceled, err = q.JobStatus(id, &testResult{})
+	require.NoError(t, err)
+	require.False(t, canceled)
+}
diff --git a/internal/jobqueue/testjobqueue/testjobqueue.go b/internal/jobqueue/testjobqueue/testjobqueue.go
index a47afe9a1..2c0d5e3f1 100644
--- a/internal/jobqueue/testjobqueue/testjobqueue.go
+++ b/internal/jobqueue/testjobqueue/testjobqueue.go
@@ -30,7 +30,10 @@ type job struct {
 	Args         json.RawMessage
 	Dependencies []uuid.UUID
 	Result       json.RawMessage
-	Status       jobqueue.JobStatus
+	QueuedAt     time.Time
+	StartedAt    time.Time
+	FinishedAt   time.Time
+	Canceled     bool
 }
 
 func New() *testJobQueue {
@@ -45,7 +48,7 @@ func (q *testJobQueue) Enqueue(jobType string, args interface{}, dependencies []
 		Id:           uuid.New(),
 		Type:         jobType,
 		Dependencies: uniqueUUIDList(dependencies),
-		Status:       jobqueue.JobPending,
+		QueuedAt:     time.Now(),
 	}
 
 	var err error
@@ -92,7 +95,7 @@ func (q *testJobQueue) Dequeue(ctx context.Context, jobTypes []string, args inte
 			return uuid.Nil, err
 		}
 
-		j.Status = jobqueue.JobRunning
+		j.StartedAt = time.Now()
 		return j.Id, nil
 	}
 
@@ -105,7 +108,7 @@ func (q *testJobQueue) FinishJob(id uuid.UUID, result interface{}) error {
 		return jobqueue.ErrNotExist
 	}
 
-	if j.Status != jobqueue.JobRunning {
+	if j.StartedAt.IsZero() || !j.FinishedAt.IsZero() {
 		return jobqueue.ErrNotRunning
 	}
 
@@ -115,7 +118,7 @@ func (q *testJobQueue) FinishJob(id uuid.UUID, result interface{}) error {
 		return fmt.Errorf("error marshaling result: %v", err)
 	}
 
-	j.Status = jobqueue.JobFinished
+	j.FinishedAt = time.Now()
 
 	for _, depid := range q.dependants[id] {
 		dep := q.jobs[depid]
@@ -132,25 +135,35 @@ func (q *testJobQueue) FinishJob(id uuid.UUID, result interface{}) error {
 	return nil
 }
 
-func (q *testJobQueue) JobStatus(id uuid.UUID, result interface{}) (status jobqueue.JobStatus, queued, started, finished time.Time, err error) {
-	var j *job
+func (q *testJobQueue) CancelJob(id uuid.UUID) error {
+	j, exists := q.jobs[id]
+	if !exists {
+		return jobqueue.ErrNotExist
+	}
+
+	j.Canceled = true
+
+	return nil
+}
 
+func (q *testJobQueue) JobStatus(id uuid.UUID, result interface{}) (queued, started, finished time.Time, canceled bool, err error) {
 	j, exists := q.jobs[id]
 	if !exists {
 		err = jobqueue.ErrNotExist
 		return
 	}
 
-	if j.Status == jobqueue.JobFinished {
+	if !j.FinishedAt.IsZero() {
 		err = json.Unmarshal(j.Result, result)
 		if err != nil {
 			return
 		}
 	}
 
-	queued = time.Time{}
-	started = time.Time{}
-	finished = time.Time{}
+	queued = j.QueuedAt
+	started = j.StartedAt
+	finished = j.FinishedAt
+	canceled = j.Canceled
 
 	return
 }
diff --git a/internal/jsondb/db_test.go b/internal/jsondb/db_test.go
index 70dbbd0bc..c18b45949 100644
--- a/internal/jsondb/db_test.go
+++ b/internal/jsondb/db_test.go
@@ -72,6 +72,39 @@ func TestCorrupt(t *testing.T) {
 	require.Error(t, err)
 }
 
+func TestRead(t *testing.T) {
+	dir, err := ioutil.TempDir("", "jsondb-test-")
+	require.NoError(t, err)
+	defer cleanupTempDir(t, dir)
+
+	err = ioutil.WriteFile(path.Join(dir, "one.json"), []byte("true"), 0755)
+	require.NoError(t, err)
+
+	db := jsondb.New(dir, 0755)
+
+	var b bool
+	exists, err := db.Read("one", &b)
+	require.NoError(t, err)
+	require.True(t, exists)
+	require.True(t, b)
+
+	// nil means don't deserialize
+	exists, err = db.Read("one", nil)
+	require.NoError(t, err)
+	require.True(t, exists)
+
+	b = false
+	exists, err = db.Read("two", &b)
+	require.NoError(t, err)
+	require.False(t, exists)
+	require.False(t, b)
+
+	// nil means don't deserialize
+	exists, err = db.Read("two", nil)
+	require.NoError(t, err)
+	require.False(t, exists)
+}
+
 func TestMultiple(t *testing.T) {
 	dir, err := ioutil.TempDir("", "jsondb-test-")
 	require.NoError(t, err)
diff --git a/internal/weldr/api_test.go b/internal/weldr/api_test.go
index b33bc1db9..9c56c0684 100644
--- a/internal/weldr/api_test.go
+++ b/internal/weldr/api_test.go
@@ -79,6 +79,7 @@ func TestBlueprintsNew(t *testing.T) {
 		{"POST", "/api/v0/blueprints/new", `{"name":"test","description":"Test","packages":[],"version":""}`, http.StatusOK, `{"status":true}`},
 		{"POST", "/api/v0/blueprints/new", `{"name":"test","description":"Test","packages":[{"name":"httpd","version":"2.4.*"}],"version":"0.0.0"}`, http.StatusOK, `{"status":true}`},
 		{"POST", "/api/v0/blueprints/new", `{"name":"test","description":"Test","packages:}`, http.StatusBadRequest, `{"status":false,"errors":[{"id":"BlueprintsError","msg":"400 Bad Request: The browser (or proxy) sent a request that this server could not understand: unexpected EOF"}]}`},
+		{"POST", "/api/v0/blueprints/new", `{"name":"","description":"Test","packages":[{"name":"httpd","version":"2.4.*"}],"version":"0.0.0"}`, http.StatusBadRequest, `{"status":false,"errors":[{"id":"InvalidChars","msg":"Invalid characters in API path"}]}`},
 		{"POST", "/api/v0/blueprints/new", ``, http.StatusBadRequest, `{"status":false,"errors":[{"id":"BlueprintsError","msg":"Missing blueprint"}]}`},
 	}