Let a signed-in user turn their own machine into a worker without the shared token. The coordinator already binds a JWT-authenticated registration to owner_id as untrusted; this adds the missing pieces. userservice: long-lived worker keys (scimesh_wk_live_*, hash-at-rest) with create/list/revoke and a public /worker-tokens/exchange that trades a key for a short-lived JWT carrying the owner current role/verified. python worker: SCIMESH_WORKER_KEY + SCIMESH_USERSERVICE_URL; a token provider exchanges the key and refreshes the JWT proactively and on 401, so a long-running worker survives token expiry. Static bearer token path is unchanged. coordinator UI: an "add your machine" page that mints a key and shows a ready-to-run command, proxying key management to the userservice; the dashboard gains an owner-scoped "my machines" section. docs: how to run a worker from your account, plus the untrusted/quorum/ verified trust model.
187 lines
5.3 KiB
Go
187 lines
5.3 KiB
Go
package usecase_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/emil28092005/SciMesh/users/internal/auth"
|
|
"github.com/emil28092005/SciMesh/users/internal/domain"
|
|
"github.com/emil28092005/SciMesh/users/internal/memstore"
|
|
"github.com/emil28092005/SciMesh/users/internal/usecase"
|
|
)
|
|
|
|
// fakeKeyRepo is an in-memory WorkerKeyRepository for the use-case tests.
|
|
type fakeKeyRepo struct {
|
|
byHash map[string]*domain.WorkerKey
|
|
byID map[uuid.UUID]*domain.WorkerKey
|
|
touched []uuid.UUID
|
|
}
|
|
|
|
func newFakeKeyRepo() *fakeKeyRepo {
|
|
return &fakeKeyRepo{byHash: map[string]*domain.WorkerKey{}, byID: map[uuid.UUID]*domain.WorkerKey{}}
|
|
}
|
|
|
|
func (r *fakeKeyRepo) Insert(_ context.Context, k *domain.WorkerKey) error {
|
|
r.byHash[k.TokenHash] = k
|
|
r.byID[k.ID] = k
|
|
return nil
|
|
}
|
|
|
|
func (r *fakeKeyRepo) ListByUser(_ context.Context, userID uuid.UUID) ([]*domain.WorkerKey, error) {
|
|
var out []*domain.WorkerKey
|
|
for _, k := range r.byID {
|
|
if k.UserID == userID && !k.Revoked() {
|
|
out = append(out, k)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *fakeKeyRepo) GetActiveByHash(_ context.Context, hash string) (*domain.WorkerKey, error) {
|
|
k, ok := r.byHash[hash]
|
|
if !ok || k.Revoked() {
|
|
return nil, usecase.ErrWorkerKeyNotFound
|
|
}
|
|
return k, nil
|
|
}
|
|
|
|
func (r *fakeKeyRepo) Revoke(_ context.Context, id, userID uuid.UUID) error {
|
|
k, ok := r.byID[id]
|
|
if !ok || k.UserID != userID || k.Revoked() {
|
|
return usecase.ErrWorkerKeyNotFound
|
|
}
|
|
now := time.Now()
|
|
k.RevokedAt = &now
|
|
return nil
|
|
}
|
|
|
|
func (r *fakeKeyRepo) TouchLastUsed(_ context.Context, id uuid.UUID) error {
|
|
r.touched = append(r.touched, id)
|
|
return nil
|
|
}
|
|
|
|
func newKeyFixtures(t *testing.T) (*usecase.CreateWorkerKey, *usecase.ExchangeWorkerKey, *usecase.RevokeWorkerKey, *usecase.ListWorkerKeys, *fakeKeyRepo, *domain.User) {
|
|
t.Helper()
|
|
users := memstore.NewUserRepo()
|
|
hasher := auth.NewHasher(4)
|
|
clk := memstore.Clock{T: time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)}
|
|
issuer := auth.NewIssuer(secret, time.Hour, nil)
|
|
keys := newFakeKeyRepo()
|
|
|
|
u, err := usecase.NewRegister(users, hasher, clk).Execute(context.Background(), "worker@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("seed user: %v", err)
|
|
}
|
|
|
|
return usecase.NewCreateWorkerKey(keys, clk),
|
|
usecase.NewExchangeWorkerKey(keys, users, issuer, time.Hour),
|
|
usecase.NewRevokeWorkerKey(keys),
|
|
usecase.NewListWorkerKeys(keys),
|
|
keys, u
|
|
}
|
|
|
|
func TestCreateAndExchangeWorkerKey(t *testing.T) {
|
|
create, exchange, _, _, keys, u := newKeyFixtures(t)
|
|
ctx := context.Background()
|
|
|
|
key, raw, err := create.Execute(ctx, u.ID, "home-desktop")
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
if key.Name != "home-desktop" || raw == "" {
|
|
t.Fatalf("unexpected key %+v raw=%q", key, raw)
|
|
}
|
|
|
|
token, expiresIn, err := exchange.Execute(ctx, raw)
|
|
if err != nil {
|
|
t.Fatalf("exchange: %v", err)
|
|
}
|
|
if expiresIn != int((time.Hour).Seconds()) {
|
|
t.Errorf("expires_in = %d, want 3600", expiresIn)
|
|
}
|
|
|
|
claims, err := auth.NewIssuer(secret, time.Hour, nil).Verify(token)
|
|
if err != nil {
|
|
t.Fatalf("issued token does not verify: %v", err)
|
|
}
|
|
if claims.Subject != u.ID.String() {
|
|
t.Errorf("token sub = %q, want owner %q", claims.Subject, u.ID)
|
|
}
|
|
if len(keys.touched) != 1 || keys.touched[0] != key.ID {
|
|
t.Errorf("exchange did not record last-used, touched=%v", keys.touched)
|
|
}
|
|
}
|
|
|
|
func TestExchangeUnknownKeyIsInvalid(t *testing.T) {
|
|
_, exchange, _, _, _, _ := newKeyFixtures(t)
|
|
if _, _, err := exchange.Execute(context.Background(), "scimesh_wk_live_nope"); !errors.Is(err, usecase.ErrInvalidWorkerKey) {
|
|
t.Errorf("got %v, want ErrInvalidWorkerKey", err)
|
|
}
|
|
}
|
|
|
|
func TestExchangeEmptyKeyIsInvalid(t *testing.T) {
|
|
_, exchange, _, _, _, _ := newKeyFixtures(t)
|
|
if _, _, err := exchange.Execute(context.Background(), ""); !errors.Is(err, usecase.ErrInvalidWorkerKey) {
|
|
t.Errorf("got %v, want ErrInvalidWorkerKey", err)
|
|
}
|
|
}
|
|
|
|
func TestExchangeRevokedKeyIsInvalid(t *testing.T) {
|
|
create, exchange, revoke, _, _, u := newKeyFixtures(t)
|
|
ctx := context.Background()
|
|
|
|
key, raw, err := create.Execute(ctx, u.ID, "laptop")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := revoke.Execute(ctx, u.ID, key.ID); err != nil {
|
|
t.Fatalf("revoke: %v", err)
|
|
}
|
|
if _, _, err := exchange.Execute(ctx, raw); !errors.Is(err, usecase.ErrInvalidWorkerKey) {
|
|
t.Errorf("revoked key still exchanges: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRevokeIsScopedToOwner(t *testing.T) {
|
|
create, _, revoke, _, _, u := newKeyFixtures(t)
|
|
ctx := context.Background()
|
|
|
|
key, _, err := create.Execute(ctx, u.ID, "laptop")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A different user must not be able to revoke this key.
|
|
if err := revoke.Execute(ctx, uuid.New(), key.ID); !errors.Is(err, usecase.ErrWorkerKeyNotFound) {
|
|
t.Errorf("cross-owner revoke returned %v, want ErrWorkerKeyNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestListReturnsOnlyLiveKeys(t *testing.T) {
|
|
create, _, revoke, list, _, u := newKeyFixtures(t)
|
|
ctx := context.Background()
|
|
|
|
live, _, err := create.Execute(ctx, u.ID, "keep")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dead, _, err := create.Execute(ctx, u.ID, "drop")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := revoke.Execute(ctx, u.ID, dead.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := list.Execute(ctx, u.ID)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if len(got) != 1 || got[0].ID != live.ID {
|
|
t.Errorf("list = %d keys, want only the live one", len(got))
|
|
}
|
|
}
|