package runtime import ( "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "io" "strings" "testing" "time" "git.novox.be/novox/mesh-sdk/go/buscalls" ) // fakeObjects is the bus of a session: what was put whole, what a read gives, and what the store deleted. type fakeObjects struct { put map[string][]byte description string deleted []string read []byte ctxEnded chan struct{} } func (f *fakeObjects) ObjectsPutStream(ctx context.Context, _ string, r buscalls.ObjectsNameRequest, description string, in io.Reader) (buscalls.ObjectInfo, error) { data, err := io.ReadAll(in) if err != nil { f.deleted = append(f.deleted, r.Name) if f.ctxEnded != nil { <-ctx.Done() close(f.ctxEnded) } return buscalls.ObjectInfo{}, err } f.put[r.Name], f.description = data, description return buscalls.ObjectInfo{Name: r.Name, Size: uint64(len(data)), Description: description}, nil } func (f *fakeObjects) ObjectsOpen(context.Context, string, buscalls.ObjectsNameRequest) (buscalls.ObjectInfo, io.ReadCloser, bool, error) { return buscalls.ObjectInfo{Name: "o", Size: uint64(len(f.read))}, io.NopCloser(bytes.NewReader(f.read)), true, nil } func (f *fakeObjects) SentOpen(context.Context, buscalls.ObjectsSentGetRequest) (buscalls.ObjectInfo, io.ReadCloser, error) { return buscalls.ObjectInfo{Size: uint64(len(f.read))}, io.NopCloser(bytes.NewReader(f.read)), nil } func aSession(t *testing.T, f *fakeObjects) *moduleBus { t.Helper() c := &consumers{} c.sessions.store = f return &moduleBus{all: c, module: "m"} } func asked(t *testing.T, b *moduleBus, method string, req any, into any) { t.Helper() params, _ := json.Marshal(req) raw, err := b.BusState(method, params) if err != nil { t.Fatalf("%s: %v", method, err) } if err := json.Unmarshal(raw, into); err != nil { t.Fatal(err) } } // §490, §518, §620: a value over one chunk is got as a session (the chunked answer, then each chunk by its call and sequence, // the whole checked against the digest); a sent object over one chunk the same, with `chunked` true. func TestAGetOverOneChunkRunsAsASession(t *testing.T) { whole := bytes.Repeat([]byte("y"), 2*buscalls.ChunkBytes) f := &fakeObjects{read: whole} b := aSession(t, f) sum := sha256.Sum256(whole) want := "sha256:" + hex.EncodeToString(sum[:]) for name, run := range map[string]func() buscalls.ObjectsChunked{ "state": func() buscalls.ObjectsChunked { var c buscalls.ObjectsChunked asked(t, b, buscalls.ObjectsGet, buscalls.ObjectsNameRequest{Objects: "files", Name: "o"}, &c) return c }, "sent": func() buscalls.ObjectsChunked { var c buscalls.ObjectsChunked asked(t, b, buscalls.ObjectsSentGet, buscalls.ObjectsSentGetRequest{Store: "m_sent-objects", Object: "o", Size: int64(len(whole)), Digest: hex.EncodeToString(sum[:])}, &c) if string(c.Chunked) != "true" { t.Errorf("a sent object's chunked is %s", c.Chunked) } return c }, } { t.Run(name, func(t *testing.T) { c := run() if c.Call == "" || c.Size != uint64(len(whole)) { t.Fatalf("the get answered %+v", c) } if name == "state" { if string(c.Chunked) != "2" { t.Errorf("chunked is %s, want 2", c.Chunked) } } var got []byte for seq := uint32(1); ; seq++ { var ch buscalls.ObjectsChunk asked(t, b, buscalls.ObjectsGetChunk, buscalls.ObjectsGetChunkRequest{Call: c.Call, Seq: seq}, &ch) if ch.Error != "" || ch.Seq != seq { t.Fatalf("chunk %d: %+v", seq, ch) } got = append(got, ch.Data...) if ch.Last { if seq != 2 { t.Fatalf("an object of two chunks ended at chunk %d", seq) } break } } if !bytes.Equal(got, whole) || name == "state" && c.Digest == "" { t.Fatalf("read %d bytes, digest %s", len(got), c.Digest) } if name == "sent" && c.Digest != want { t.Errorf("digest %s, want %s", c.Digest, want) } var again buscalls.ObjectsChunk asked(t, b, buscalls.ObjectsGetChunk, buscalls.ObjectsGetChunkRequest{Call: c.Call, Seq: 3}, &again) if again.Error == "" { t.Error("a finished session answered another chunk") } }) } // A value of one chunk or less is the value itself, as before. f.read = []byte("small") var small buscalls.ObjectData asked(t, b, buscalls.ObjectsGet, buscalls.ObjectsNameRequest{Objects: "files", Name: "o"}, &small) if string(small.Data) != "small" { t.Fatalf("a small value was answered %+v", small) } // Design 47 G §518, A-21, A-25: a put session writes each chunk as it is taken, carries the description of its first chunk, // and answers the put's own info for the last; a chunk out of order fails the put naming both sequences and leaves none. t.Run("APutSessionSealsOnTheLastChunkAndDropsOnADisorder", func(t *testing.T) { f := &fakeObjects{put: map[string][]byte{}} b := aSession(t, f) whole := bytes.Repeat([]byte("x"), 2*buscalls.ChunkBytes+5) var taken buscalls.ObjectsChunkTaken for seq, at := uint32(1), 0; ; seq++ { end := min(at+buscalls.ChunkBytes, len(whole)) req := buscalls.ObjectsPutChunkRequest{Objects: "files", Name: "a", Call: "c1", Seq: seq, Last: end == len(whole), Data: whole[at:end]} if seq == 1 { req.Description = "what it is" } if req.Last { var info buscalls.ObjectInfo asked(t, b, buscalls.ObjectsPutChunk, req, &info) if info.Size != uint64(len(whole)) || info.Description != "what it is" { t.Fatalf("the put answered %+v", info) } break } asked(t, b, buscalls.ObjectsPutChunk, req, &taken) if taken.Seq != seq || taken.Error != "" { t.Fatalf("chunk %d was answered %+v", seq, taken) } at = end } if !bytes.Equal(f.put["a"], whole) { t.Fatalf("the store holds %d bytes, want %d", len(f.put["a"]), len(whole)) } asked(t, b, buscalls.ObjectsPutChunk, buscalls.ObjectsPutChunkRequest{Objects: "files", Name: "b", Call: "c2", Seq: 1, Data: []byte("one")}, &taken) asked(t, b, buscalls.ObjectsPutChunk, buscalls.ObjectsPutChunkRequest{Objects: "files", Name: "b", Call: "c2", Seq: 3, Data: []byte("three")}, &taken) if !strings.Contains(taken.Error, "chunk 3") || !strings.Contains(taken.Error, "chunk 2") { t.Fatalf("a disorder was answered %+v", taken) } if _, ok := f.put["b"]; ok || len(f.deleted) != 1 || f.deleted[0] != "b" { t.Fatalf("a dropped put left %v, deleted %v", f.put, f.deleted) } asked(t, b, buscalls.ObjectsPutChunk, buscalls.ObjectsPutChunkRequest{Objects: "files", Name: "b", Call: "c2", Seq: 2, Data: []byte("two")}, &taken) if taken.Error == "" { t.Fatal("a chunk of a dropped put was taken") } }) // §518 "The bound": a put not sealed within the bound is dropped, its object deleted. t.Run("APutNotSealedWithinTheBoundIsDropped", func(t *testing.T) { was := SessionBound SessionBound = time.Millisecond t.Cleanup(func() { SessionBound = was }) f := &fakeObjects{put: map[string][]byte{}, ctxEnded: make(chan struct{})} b := aSession(t, f) var taken buscalls.ObjectsChunkTaken asked(t, b, buscalls.ObjectsPutChunk, buscalls.ObjectsPutChunkRequest{Objects: "files", Name: "a", Call: "c", Seq: 1, Data: []byte("x")}, &taken) <-f.ctxEnded if len(f.deleted) != 1 { t.Fatalf("the unsealed object was left: %v", f.deleted) } }) }