0ba56ed30d
Operator-minted enrollment tokens now carry the repo URL/username/
password as one AEAD blob bound (via additional-data) to the token
hash. ConsumeEnrollmentToken re-encrypts under host_id and writes a
host_credentials row in the same tx as token-burn, so the binding
moves with the credential.
PUT /api/hosts/{id}/repo-credentials lets an operator edit creds
post-enrollment; merges with the existing blob, audits, and pushes
config.update if the agent is connected.
WS handler grows an OnHello hook that the HTTP layer wires to send
the host's decrypted creds as a config.update immediately after the
hello succeeds — synchronously, so a racing command.run lands after
the agent has its repo password.
Schema: 0002_host_credentials.sql adds enc_repo_creds to
enrollment_tokens and a host_credentials table (PK = host_id, FK
ON DELETE CASCADE).
Tests: round-trip token → consume → host_credentials with AAD swap
detection; no-creds path stays compatible.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
159 lines
3.8 KiB
Go
159 lines
3.8 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestUserCRUD(t *testing.T) {
|
|
t.Parallel()
|
|
s := openTestStore(t)
|
|
ctx := context.Background()
|
|
|
|
now := time.Now().UTC()
|
|
u := User{
|
|
ID: "u1",
|
|
Username: "alice",
|
|
PasswordHash: "$argon2id$...",
|
|
Role: RoleAdmin,
|
|
CreatedAt: now,
|
|
}
|
|
if err := s.CreateUser(ctx, u); err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
got, err := s.GetUserByUsername(ctx, "alice")
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
if got.ID != "u1" || got.Role != RoleAdmin {
|
|
t.Errorf("unexpected user: %+v", got)
|
|
}
|
|
|
|
// Username uniqueness is enforced by the schema.
|
|
if err := s.CreateUser(ctx, u); err == nil {
|
|
t.Error("duplicate username should fail")
|
|
}
|
|
|
|
if _, err := s.GetUserByUsername(ctx, "bob"); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("missing user: want ErrNotFound, got %v", err)
|
|
}
|
|
|
|
if err := s.MarkUserLogin(ctx, "u1", now); err != nil {
|
|
t.Fatalf("mark login: %v", err)
|
|
}
|
|
got, _ = s.GetUserByUsername(ctx, "alice")
|
|
if got.LastLoginAt == nil {
|
|
t.Error("last_login_at not updated")
|
|
}
|
|
}
|
|
|
|
func TestCountUsers(t *testing.T) {
|
|
t.Parallel()
|
|
s := openTestStore(t)
|
|
ctx := context.Background()
|
|
|
|
n, _ := s.CountUsers(ctx)
|
|
if n != 0 {
|
|
t.Errorf("fresh db: want 0, got %d", n)
|
|
}
|
|
_ = s.CreateUser(ctx, User{
|
|
ID: "u1", Username: "a", PasswordHash: "x",
|
|
Role: RoleAdmin, CreatedAt: time.Now(),
|
|
})
|
|
n, _ = s.CountUsers(ctx)
|
|
if n != 1 {
|
|
t.Errorf("after insert: want 1, got %d", n)
|
|
}
|
|
}
|
|
|
|
func TestSessionLifecycle(t *testing.T) {
|
|
t.Parallel()
|
|
s := openTestStore(t)
|
|
ctx := context.Background()
|
|
|
|
// Need a user for FK.
|
|
_ = s.CreateUser(ctx, User{
|
|
ID: "u1", Username: "alice", PasswordHash: "x",
|
|
Role: RoleAdmin, CreatedAt: time.Now(),
|
|
})
|
|
|
|
now := time.Now().UTC()
|
|
sess := Session{
|
|
UserID: "u1",
|
|
CreatedAt: now,
|
|
ExpiresAt: now.Add(time.Hour),
|
|
IP: "10.0.0.1",
|
|
UA: "test/1.0",
|
|
}
|
|
hash := "deadbeef" + "00000000000000000000000000000000000000000000000000000000"
|
|
if err := s.CreateSession(ctx, sess, hash); err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
got, err := s.LookupSession(ctx, hash)
|
|
if err != nil {
|
|
t.Fatalf("lookup: %v", err)
|
|
}
|
|
if got.UserID != "u1" {
|
|
t.Errorf("user mismatch: %s", got.UserID)
|
|
}
|
|
|
|
// Expired sessions should not resolve.
|
|
expiredHash := "expired-hash"
|
|
expired := Session{
|
|
UserID: "u1",
|
|
CreatedAt: now.Add(-2 * time.Hour),
|
|
ExpiresAt: now.Add(-time.Hour),
|
|
}
|
|
if err := s.CreateSession(ctx, expired, expiredHash); err != nil {
|
|
t.Fatalf("create expired: %v", err)
|
|
}
|
|
if _, err := s.LookupSession(ctx, expiredHash); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("expired session should look like ErrNotFound, got %v", err)
|
|
}
|
|
|
|
if err := s.DeleteSession(ctx, hash); err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
if _, err := s.LookupSession(ctx, hash); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("deleted session: want ErrNotFound, got %v", err)
|
|
}
|
|
|
|
n, err := s.PurgeExpiredSessions(ctx)
|
|
if err != nil {
|
|
t.Fatalf("purge: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("purge should remove the 1 expired row, got %d", n)
|
|
}
|
|
}
|
|
|
|
func TestEnrollmentTokenSingleUse(t *testing.T) {
|
|
t.Parallel()
|
|
s := openTestStore(t)
|
|
ctx := context.Background()
|
|
|
|
hash := "tok-hash"
|
|
if err := s.CreateEnrollmentToken(ctx, hash, time.Hour, ""); err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
// Need a host for FK.
|
|
_, err := s.DB().Exec(`INSERT INTO hosts (id, name, os, arch, enrolled_at) VALUES (?,?,?,?,?)`,
|
|
"h1", "host1", "linux", "amd64", time.Now().UTC().Format(time.RFC3339Nano))
|
|
if err != nil {
|
|
t.Fatalf("insert host: %v", err)
|
|
}
|
|
|
|
if err := s.ConsumeEnrollmentToken(ctx, hash, "h1", ""); err != nil {
|
|
t.Fatalf("consume: %v", err)
|
|
}
|
|
// Second consume must fail — the whole point of one-time tokens.
|
|
if err := s.ConsumeEnrollmentToken(ctx, hash, "h1", ""); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("re-consume: want ErrNotFound, got %v", err)
|
|
}
|
|
}
|