mirror of
https://github.com/hanzoai/annotations.git
synced 2026-10-30 18:48:18 +00:00
459 lines
17 KiB
Go
459 lines
17 KiB
Go
package server_test
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
basetests "github.com/hanzoai/base/tests"
|
|
luxlog "github.com/luxfi/log"
|
|
zaplib "github.com/luxfi/zap"
|
|
zcap "github.com/zap-proto/go/cap"
|
|
|
|
gen "github.com/hanzoai/annotations/gen"
|
|
"github.com/hanzoai/annotations/server"
|
|
)
|
|
|
|
const testProject = "proj-test"
|
|
|
|
// allPerms is a cap holding every category bit — the common case for a session
|
|
// that can do anything across the three routers.
|
|
const allPerms = server.AnnReadQueues | server.AnnWriteQueues |
|
|
server.AnnReadAssignments | server.AnnWriteAssignments
|
|
|
|
// newService spins up a Base test app with the three annotation collections
|
|
// provisioned, a ZAP router node listening, and returns the listen address, the
|
|
// node id, and a cleanup func.
|
|
func newService(t *testing.T, port int) (addr, peerID string, cleanup func()) {
|
|
t.Helper()
|
|
|
|
app, err := basetests.NewTestApp()
|
|
if err != nil {
|
|
t.Fatalf("new test app: %v", err)
|
|
}
|
|
if err := server.EnsureCollections(app); err != nil {
|
|
t.Fatalf("ensure collections: %v", err)
|
|
}
|
|
|
|
logger := luxlog.New("component", "ann-test")
|
|
node := zaplib.NewNode(zaplib.NodeConfig{
|
|
NodeID: "ann-test-srv",
|
|
Port: port,
|
|
NoDiscovery: true,
|
|
})
|
|
srv := server.NewServer(app, logger, zcap.Verifier{})
|
|
srv.Register(node)
|
|
if err := node.Start(); err != nil {
|
|
t.Fatalf("node start: %v", err)
|
|
}
|
|
|
|
return "127.0.0.1:" + itoa(port), "ann-test-srv", func() {
|
|
node.Stop()
|
|
app.Cleanup()
|
|
}
|
|
}
|
|
|
|
// newClient dials the service with a synthetic CapKindIAMSession cap holding the
|
|
// given permissions, on its own ZAP node (unique port per connection).
|
|
func newClient(t *testing.T, addr, peerID string, perms uint64, port int) (*server.Client, func()) {
|
|
t.Helper()
|
|
capBuf, err := server.SyntheticCap(perms)
|
|
if err != nil {
|
|
t.Fatalf("synthetic cap: %v", err)
|
|
}
|
|
cli := zaplib.NewNode(zaplib.NodeConfig{
|
|
NodeID: "ann-test-cli-" + itoa(port),
|
|
Port: port,
|
|
NoDiscovery: true,
|
|
})
|
|
if err := cli.Start(); err != nil {
|
|
t.Fatalf("client node start: %v", err)
|
|
}
|
|
c, err := server.Dial(cli, addr, peerID, capBuf)
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
time.Sleep(150 * time.Millisecond) // let the handshake settle
|
|
return c, func() { cli.Stop() }
|
|
}
|
|
|
|
func testCtx() (context.Context, context.CancelFunc) {
|
|
return context.WithTimeout(context.Background(), 5*time.Second)
|
|
}
|
|
|
|
// ──────────────────────────── queue lifecycle ────────────────────────────
|
|
|
|
// TestQueueLifecycle exercises create → byId → all → update → count → delete,
|
|
// asserting each step's wire view, on a single connection.
|
|
func TestQueueLifecycle(t *testing.T) {
|
|
addr, peerID, stop := newService(t, 19700)
|
|
defer stop()
|
|
cli, stopCli := newClient(t, addr, peerID, allPerms, 19701)
|
|
defer stopCli()
|
|
ctx, cancel := testCtx()
|
|
defer cancel()
|
|
|
|
// Empty project: hasAny false, count 0.
|
|
if has, err := cli.QueueHasAny(ctx, testProject); err != nil || has.Value() {
|
|
t.Fatalf("hasAny empty: %v val=%v", err, has.Value())
|
|
}
|
|
if c, err := cli.QueueCount(ctx, testProject); err != nil || c.Count() != 0 {
|
|
t.Fatalf("count empty: %v c=%d", err, c.Count())
|
|
}
|
|
|
|
// Create.
|
|
q, err := cli.QueueCreate(ctx, testProject, "My Queue", "desc", []string{"sc1", "sc2"})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
if q.Id() == "" || q.Name() != "My Queue" || q.Description() != "desc" {
|
|
t.Fatalf("create view wrong: id=%q name=%q desc=%q", q.Id(), q.Name(), q.Description())
|
|
}
|
|
if q.ScoreConfigIds().Len() != 2 {
|
|
t.Fatalf("scoreConfigIds = %d, want 2", q.ScoreConfigIds().Len())
|
|
}
|
|
queueID := q.Id()
|
|
|
|
// Duplicate name → conflict (error from the client).
|
|
if _, err := cli.QueueCreate(ctx, testProject, "My Queue", "", nil); err == nil {
|
|
t.Fatalf("expected conflict on duplicate queue name")
|
|
}
|
|
|
|
// hasAny now true; count 1.
|
|
if has, err := cli.QueueHasAny(ctx, testProject); err != nil || !has.Value() {
|
|
t.Fatalf("hasAny after create: %v val=%v", err, has.Value())
|
|
}
|
|
if c, err := cli.QueueCount(ctx, testProject); err != nil || c.Count() != 1 {
|
|
t.Fatalf("count after create: %v c=%d", err, c.Count())
|
|
}
|
|
|
|
// byId.
|
|
got, err := cli.QueueById(ctx, testProject, queueID)
|
|
if err != nil || got.Id() != queueID {
|
|
t.Fatalf("byId: %v id=%q", err, got.Id())
|
|
}
|
|
|
|
// all: one queue, totalCount 1.
|
|
list, err := cli.QueueAll(ctx, testProject, 0, 0)
|
|
if err != nil || list.TotalCount() != 1 || list.Queues().Len() != 1 {
|
|
t.Fatalf("all: %v total=%d n=%d", err, list.TotalCount(), list.Queues().Len())
|
|
}
|
|
|
|
// namesAndIds.
|
|
refs, err := cli.QueueNamesAndIds(ctx, testProject)
|
|
if err != nil || refs.Refs().Len() != 1 {
|
|
t.Fatalf("namesAndIds: %v n=%d", err, refs.Refs().Len())
|
|
}
|
|
|
|
// Update.
|
|
upd, err := cli.QueueUpdate(ctx, testProject, queueID, "Renamed", "newdesc", []string{"sc3"})
|
|
if err != nil || upd.Name() != "Renamed" || upd.Description() != "newdesc" {
|
|
t.Fatalf("update: %v name=%q", err, upd.Name())
|
|
}
|
|
|
|
// Delete returns the (renamed) row; subsequent byId fails.
|
|
del, err := cli.QueueDelete(ctx, testProject, queueID)
|
|
if err != nil || del.Id() != queueID {
|
|
t.Fatalf("delete: %v id=%q", err, del.Id())
|
|
}
|
|
if _, err := cli.QueueById(ctx, testProject, queueID); err == nil {
|
|
t.Fatalf("expected byId to fail after delete")
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────── item lifecycle ────────────────────────────
|
|
|
|
// TestItemLifecycle covers createMany (with de-dup) → itemsByQueueId →
|
|
// typeById → byId → unseenCount → complete → deleteMany, plus the queue's
|
|
// computed completed/pending counts and byObjectId.
|
|
func TestItemLifecycle(t *testing.T) {
|
|
addr, peerID, stop := newService(t, 19702)
|
|
defer stop()
|
|
cli, stopCli := newClient(t, addr, peerID, allPerms, 19703)
|
|
defer stopCli()
|
|
ctx, cancel := testCtx()
|
|
defer cancel()
|
|
|
|
q, err := cli.QueueCreate(ctx, testProject, "Items Q", "", nil)
|
|
if err != nil {
|
|
t.Fatalf("create queue: %v", err)
|
|
}
|
|
queueID := q.Id()
|
|
|
|
// createMany 3 objects; a second call with an overlapping id de-dupes.
|
|
res, err := cli.ItemCreateMany(ctx, testProject, queueID, server.ObjectTrace, []string{"t1", "t2", "t3"})
|
|
if err != nil || res.CreatedCount() != 3 || res.QueueId() != queueID {
|
|
t.Fatalf("createMany: %v created=%d", err, res.CreatedCount())
|
|
}
|
|
res2, err := cli.ItemCreateMany(ctx, testProject, queueID, server.ObjectTrace, []string{"t3", "t4"})
|
|
if err != nil || res2.CreatedCount() != 1 { // t3 dup-skipped, t4 new
|
|
t.Fatalf("createMany dedup: %v created=%d (want 1)", err, res2.CreatedCount())
|
|
}
|
|
|
|
// itemsByQueueId: 4 items total.
|
|
items, err := cli.ItemsByQueueId(ctx, testProject, queueID, 0, 0)
|
|
if err != nil || items.TotalCount() != 4 || items.Items().Len() != 4 {
|
|
t.Fatalf("itemsByQueueId: %v total=%d n=%d", err, items.TotalCount(), items.Items().Len())
|
|
}
|
|
|
|
// Pull the first item from the wire list to drive typeById/byId/complete.
|
|
// List elements were built with AddObjectBytes (self-contained NewQueueItem
|
|
// buffers), so BytesAt(i) yields exactly such a buffer for WrapQueueItem.
|
|
firstItem, err := gen.WrapQueueItem(items.Items().BytesAt(0))
|
|
if err != nil {
|
|
t.Fatalf("wrap first item: %v", err)
|
|
}
|
|
itemID := firstItem.Id()
|
|
if itemID == "" {
|
|
t.Fatalf("first item id empty")
|
|
}
|
|
|
|
// typeById → TRACE.
|
|
if ty, err := cli.ItemTypeById(ctx, testProject, queueID, itemID); err != nil || ty.Value() != server.ObjectTrace {
|
|
t.Fatalf("typeById: %v ty=%q", err, ty.Value())
|
|
}
|
|
|
|
// byId → status PENDING.
|
|
one, err := cli.ItemById(ctx, testProject, itemID)
|
|
if err != nil || one.Id() != itemID || one.Status() != server.StatusPending {
|
|
t.Fatalf("byId: %v id=%q status=%q", err, one.Id(), one.Status())
|
|
}
|
|
|
|
// unseenCount with 1 item already seen → 3 (4 pending - 1 seen).
|
|
if u, err := cli.ItemUnseenCount(ctx, testProject, queueID, []string{itemID}); err != nil || u.Count() != 3 {
|
|
t.Fatalf("unseenCount: %v c=%d (want 3)", err, u.Count())
|
|
}
|
|
|
|
// complete the item → status COMPLETED, completedAt set.
|
|
done, err := cli.ItemComplete(ctx, testProject, itemID)
|
|
if err != nil || done.Status() != server.StatusCompleted || done.CompletedAt() == 0 {
|
|
t.Fatalf("complete: %v status=%q completedAt=%d", err, done.Status(), done.CompletedAt())
|
|
}
|
|
|
|
// Queue counts now reflect 1 completed, 3 pending.
|
|
gotQ, err := cli.QueueById(ctx, testProject, queueID)
|
|
if err != nil || gotQ.CountCompletedItems() != 1 || gotQ.CountPendingItems() != 3 {
|
|
t.Fatalf("queue counts: %v completed=%d pending=%d", err, gotQ.CountCompletedItems(), gotQ.CountPendingItems())
|
|
}
|
|
|
|
// completing an already-completed item → not found.
|
|
if _, err := cli.ItemComplete(ctx, testProject, itemID); err == nil {
|
|
t.Fatalf("expected complete to fail on already-completed item")
|
|
}
|
|
|
|
// byObjectId for t1: one hit in this queue.
|
|
hits, err := cli.QueueByObjectId(ctx, testProject, "t1", server.ObjectTrace)
|
|
if err != nil || hits.TotalCount() != 1 || hits.Hits().Len() != 1 {
|
|
t.Fatalf("byObjectId: %v total=%d n=%d", err, hits.TotalCount(), hits.Hits().Len())
|
|
}
|
|
|
|
// deleteMany the completed item.
|
|
if d, err := cli.ItemDeleteMany(ctx, testProject, []string{itemID}); err != nil || d.Count() != 1 {
|
|
t.Fatalf("deleteMany: %v deleted=%d", err, d.Count())
|
|
}
|
|
|
|
// byId on a deleted item → empty view (Id == "").
|
|
if gone, err := cli.ItemById(ctx, testProject, itemID); err != nil || gone.Id() != "" {
|
|
t.Fatalf("byId deleted: %v id=%q (want empty)", err, gone.Id())
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────── assignment lifecycle ────────────────────────────
|
|
|
|
// TestAssignmentLifecycle covers createMany (with de-dup) → byQueueId →
|
|
// delete, and the not-found path when the queue is absent.
|
|
func TestAssignmentLifecycle(t *testing.T) {
|
|
addr, peerID, stop := newService(t, 19704)
|
|
defer stop()
|
|
cli, stopCli := newClient(t, addr, peerID, allPerms, 19705)
|
|
defer stopCli()
|
|
ctx, cancel := testCtx()
|
|
defer cancel()
|
|
|
|
q, err := cli.QueueCreate(ctx, testProject, "Assign Q", "", nil)
|
|
if err != nil {
|
|
t.Fatalf("create queue: %v", err)
|
|
}
|
|
queueID := q.Id()
|
|
|
|
// createMany 2 users; second call overlaps one → de-dup.
|
|
r, err := cli.AssignCreateMany(ctx, testProject, queueID, []string{"u1", "u2"})
|
|
if err != nil || !r.Success() || r.AddedCount() != 2 {
|
|
t.Fatalf("assignCreateMany: %v added=%d", err, r.AddedCount())
|
|
}
|
|
r2, err := cli.AssignCreateMany(ctx, testProject, queueID, []string{"u2", "u3"})
|
|
if err != nil || r2.AddedCount() != 1 || r2.SkippedCount() != 1 {
|
|
t.Fatalf("assignCreateMany dedup: %v added=%d skipped=%d", err, r2.AddedCount(), r2.SkippedCount())
|
|
}
|
|
|
|
// byQueueId → 3 assignments.
|
|
list, err := cli.AssignByQueueId(ctx, testProject, queueID, 0, 0)
|
|
if err != nil || list.TotalCount() != 3 || list.Assignments().Len() != 3 {
|
|
t.Fatalf("assignByQueueId: %v total=%d n=%d", err, list.TotalCount(), list.Assignments().Len())
|
|
}
|
|
|
|
// delete u1 → success; count drops to 2.
|
|
if d, err := cli.AssignDelete(ctx, testProject, queueID, "u1"); err != nil || !d.Value() {
|
|
t.Fatalf("assignDelete: %v val=%v", err, d.Value())
|
|
}
|
|
if list, err := cli.AssignByQueueId(ctx, testProject, queueID, 0, 0); err != nil || list.TotalCount() != 2 {
|
|
t.Fatalf("assignByQueueId after delete: %v total=%d", err, list.TotalCount())
|
|
}
|
|
|
|
// createMany against a missing queue → not found (client error).
|
|
if _, err := cli.AssignCreateMany(ctx, testProject, "nope", []string{"u9"}); err == nil {
|
|
t.Fatalf("expected not-found assigning to missing queue")
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────── permission gating ────────────────────────────
|
|
|
|
// TestPermissionDenied proves the bitmask chokepoint per category: a cap missing
|
|
// the required bit is rejected before any data op, while a cap holding only the
|
|
// matching bit succeeds.
|
|
func TestPermissionDenied(t *testing.T) {
|
|
addr, peerID, stop := newService(t, 19706)
|
|
defer stop()
|
|
ctx, cancel := testCtx()
|
|
defer cancel()
|
|
|
|
// Read-queues only: a queue read works, a queue write is denied.
|
|
roCli, stopRO := newClient(t, addr, peerID, server.AnnReadQueues, 19707)
|
|
defer stopRO()
|
|
if _, err := roCli.QueueCount(ctx, testProject); err != nil {
|
|
t.Fatalf("read-queues cap should allow queueCount: %v", err)
|
|
}
|
|
if _, err := roCli.QueueCreate(ctx, testProject, "X", "", nil); err == nil {
|
|
t.Fatalf("read-queues cap must NOT allow queueCreate")
|
|
}
|
|
|
|
// Seed a queue with a fully-permissioned client for the cross-category checks.
|
|
wc, stopWC := newClient(t, addr, peerID, allPerms, 19708)
|
|
defer stopWC()
|
|
seed, err := wc.QueueCreate(ctx, testProject, "Seed", "", nil)
|
|
if err != nil {
|
|
t.Fatalf("seed queue: %v", err)
|
|
}
|
|
queueID := seed.Id()
|
|
|
|
// Read-queues must not allow assignment read (different category).
|
|
if _, err := roCli.AssignByQueueId(ctx, testProject, queueID, 0, 0); err == nil {
|
|
t.Fatalf("read-queues cap must NOT allow assignByQueueId (needs AnnReadAssignments)")
|
|
}
|
|
|
|
// Write-assignments only: assignment write works, queue read denied.
|
|
waCli, stopWA := newClient(t, addr, peerID, server.AnnWriteAssignments, 19709)
|
|
defer stopWA()
|
|
if _, err := waCli.AssignCreateMany(ctx, testProject, queueID, []string{"u1"}); err != nil {
|
|
t.Fatalf("write-assignments cap should allow assignCreateMany: %v", err)
|
|
}
|
|
if _, err := waCli.QueueCount(ctx, testProject); err == nil {
|
|
t.Fatalf("write-assignments cap must NOT allow queueCount (needs AnnReadQueues)")
|
|
}
|
|
|
|
// No bits at all: everything denied.
|
|
noCli, stopNo := newClient(t, addr, peerID, 0, 19710)
|
|
defer stopNo()
|
|
if _, err := noCli.QueueCount(ctx, testProject); err == nil {
|
|
t.Fatalf("zero-perm cap must be rejected")
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────── pipelining proof ────────────────────────────
|
|
|
|
// TestPipeliningCreateQueueThenItems is the load-bearing pipelining proof: the
|
|
// dependent itemCreateMany (which targets queueCreate's promise and ships an
|
|
// EMPTY queueId) must be on the wire BEFORE queueCreate's answer resolves, and
|
|
// the server must fill the items' queueId from the resolved promise (the
|
|
// just-created queue's id). We assert BOTH on the instrumented send log (two
|
|
// sends before the first recv) AND on the data (items landed in the new queue).
|
|
func TestPipeliningCreateQueueThenItems(t *testing.T) {
|
|
addr, peerID, stop := newService(t, 19711)
|
|
defer stop()
|
|
|
|
var log []server.SendEvent
|
|
cli, stopCli := newClient(t, addr, peerID, allPerms, 19712)
|
|
defer stopCli()
|
|
cli.WithSendLog(&log)
|
|
dep, stopDep := newClient(t, addr, peerID, allPerms, 19713)
|
|
defer stopDep()
|
|
dep.WithSendLog(&log)
|
|
|
|
ctx, cancel := testCtx()
|
|
defer cancel()
|
|
|
|
queue, result, err := cli.PipelineCreateQueueThenItems(
|
|
ctx, dep, testProject, "Pipelined Q", server.ObjectObservation, []string{"o1", "o2"})
|
|
if err != nil {
|
|
t.Fatalf("pipeline: %v", err)
|
|
}
|
|
if queue.Id() == "" {
|
|
t.Fatalf("pipeline: queue not created")
|
|
}
|
|
if result.CreatedCount() != 2 {
|
|
t.Fatalf("pipeline: createdCount = %d, want 2", result.CreatedCount())
|
|
}
|
|
// The promise join worked: the items' queueId equals the created queue's id.
|
|
if result.QueueId() != queue.Id() {
|
|
t.Fatalf("pipeline join failed: itemCreateMany queueId=%q, created queueId=%q",
|
|
result.QueueId(), queue.Id())
|
|
}
|
|
|
|
// Verify on the data plane: the new queue has exactly the 2 pipelined items.
|
|
items, err := cli.ItemsByQueueId(ctx, testProject, queue.Id(), 0, 0)
|
|
if err != nil || items.TotalCount() != 2 {
|
|
t.Fatalf("pipelined items not in queue: %v total=%d", err, items.TotalCount())
|
|
}
|
|
|
|
// Ordering proof: two "send" events precede the first "recv".
|
|
firstRecv := -1
|
|
sendsBeforeFirstRecv := 0
|
|
for i, e := range log {
|
|
if e.Kind == "recv" {
|
|
firstRecv = i
|
|
break
|
|
}
|
|
if e.Kind == "send" {
|
|
sendsBeforeFirstRecv++
|
|
}
|
|
}
|
|
t.Logf("send log: %s", formatLog(log))
|
|
if firstRecv == -1 {
|
|
t.Fatalf("no recv events recorded")
|
|
}
|
|
if sendsBeforeFirstRecv < 2 {
|
|
t.Fatalf("pipelining violated: only %d sends before first answer resolved; want 2",
|
|
sendsBeforeFirstRecv)
|
|
}
|
|
t.Logf("PIPELINING PROVEN: %d calls shipped before the first answer resolved; promise join filled queueId=%q",
|
|
sendsBeforeFirstRecv, result.QueueId())
|
|
}
|
|
|
|
// ──────────────────────────── tiny helpers ────────────────────────────
|
|
|
|
func itoa(n int) string {
|
|
if n == 0 {
|
|
return "0"
|
|
}
|
|
var b [12]byte
|
|
i := len(b)
|
|
for n > 0 {
|
|
i--
|
|
b[i] = byte('0' + n%10)
|
|
n /= 10
|
|
}
|
|
return string(b[i:])
|
|
}
|
|
|
|
func formatLog(log []server.SendEvent) string {
|
|
out := ""
|
|
for _, e := range log {
|
|
method := "queueCreate"
|
|
if e.Method == server.MethodItemCreateMany {
|
|
method = "itemCreateMany"
|
|
}
|
|
out += e.Kind + "(" + method + ",p=" + itoa(int(e.PromiseID)) + ",t=" + itoa(int(e.Target)) + ") "
|
|
}
|
|
return out
|
|
}
|