type: user

role: user

content:
Write Go test code to append to backend/internal/db/db_test.go for the dragonrelay project. This file is `package db_test` and already has these imports and tests:

```go
package db_test

import (
    "testing"
    "dragonrelay/internal/db"
)

func setup(t *testing.T) *db.Store { ... }
// existing tests: TestCreateAndGetUser, TestPasswordHash, TestListUsers, TestUpdateRole, TestResetPassword, TestDeleteUser, TestSeedAdmin
```

You need to write NEW test functions (do NOT repeat the existing ones) for:

**Host ACL CRUD (Task 3) — use these function signatures from db.Store:**
- `GrantHostAccess(userID int64, hostIP, level string, grantedBy int64) error`
- `RevokeHostAccess(userID int64, hostIP string) error`
- `GetHostAccess(userID int64, hostIP string) (level string, ok bool, err error)`
- `ListUserHostAccess(userID int64) ([]HostGrant, error)` where HostGrant = `struct { HostIP string; Level string }`
- `ListHostACL(hostIP string) ([]HostACLEntry, error)` where HostACLEntry = `struct { UserID int64; Username string; Level string }`
- Valid levels: "view", "connect", "manage"

Write tests:
1. `TestGrantAndGetHostAccess` — grant a user connect, verify GetHostAccess returns it
2. `TestGrantHostAccess_Upgrade` — grant view, then upgrade to manage, verify manage wins (ON CONFLICT DO UPDATE)
3. `TestRevokeHostAccess` — grant then revoke, verify ok=false
4. `TestListHostACL` — grant two users to same host, list ACL, verify both returned
5. `TestListUserHostAccess` — grant one user to two hosts, verify both returned
6. `TestDeleteUserCascadesHostACL` — create user, grant ACL, delete user, verify ACL row gone (SQLite FK cascade)

**Invite CRUD (Task 4) — use these function signatures:**
- `CreateInvite(rawToken, label, role string, hostAccess []HostGrant, createdBy int64, expires time.Time) (int64, error)`
- `GetInviteByToken(rawToken string) (*InviteRecord, error)`
- `ListActiveInvites() ([]InviteRecord, error)`
- `RevokeInvite(id int64) error`
- `RedeemInvite(rawToken, username, plainPassword string) (int64, error)`
- `ErrInviteInvalid` — sentinel error for expired/used/missing invites

Write tests:
7. `TestCreateAndRedeemInvite` — create invite with host_access, redeem, verify user created with correct role, verify host_acl row inserted
8. `TestRedeemInvite_Expired` — create already-expired invite (expires = time.Now().Add(-time.Hour)), redeem, expect ErrInviteInvalid
9. `TestRedeemInvite_OneTimeUse` — redeem same invite twice, second must return ErrInviteInvalid
10. `TestRevokeInvite` — create, revoke, verify gone from ListActiveInvites
11. `TestListActiveInvites` — create one active + one expired invite, verify ListActiveInvites returns only 1
12. `TestRedeemInvite_ViewerCappedAtView` — create viewer invite with connect-level host_access, redeem, verify level in host_acl is "view" not "connect"

Important:
- The file is package db_test, so reference types as db.HostGrant, db.ErrInviteInvalid etc.
- Add `"time"` to imports
- For TestCreateAndRedeemInvite, you need to create an admin user first (createdBy=admin.ID)
- Keep tests short and focused
- Use the `setup(t)` helper (already defined in the file)

Return ONLY the Go code to APPEND to db_test.go — starting from the blank line after the last existing test. Include only the new import additions needed (time) and the new test functions.

userType: external

entrypoint: sdk-ts

gitBranch: HEAD


type: message

role: assistant

type: text

text: I'll look at the existing test file first to understand the structure, then write the new test functions.

service_tier: standard

attributionAgent: general-purpose

type: assistant


Glob

type: direct


content: No files found


text:
The project files aren't in the working directory. Since the task is to write Go test code to append to an existing file, I'll write the code directly based on the provided signatures and requirements.

Here is the Go code to append to `backend/internal/db/db_test.go`:

```go
// ── Host ACL tests (Task 3) ────────────────────────────────────────────────

func TestGrantAndGetHostAccess(t *testing.T) {
	s := setup(t)
	admin := seedAdmin(t, s)

	if err := s.GrantHostAccess(admin.ID, "10.0.0.1", "connect", admin.ID); err != nil {
		t.Fatalf("GrantHostAccess: %v", err)
	}

	level, ok, err := s.GetHostAccess(admin.ID, "10.0.0.1")
	if err != nil {
		t.Fatalf("GetHostAccess: %v", err)
	}
	if !ok {
		t.Fatal("expected ok=true, got false")
	}
	if level != "connect" {
		t.Fatalf("expected level=connect, got %q", level)
	}
}

func TestGrantHostAccess_Upgrade(t *testing.T) {
	s := setup(t)
	admin := seedAdmin(t, s)

	if err := s.GrantHostAccess(admin.ID, "10.0.0.2", "view", admin.ID); err != nil {
		t.Fatalf("GrantHostAccess view: %v", err)
	}
	if err := s.GrantHostAccess(admin.ID, "10.0.0.2", "manage", admin.ID); err != nil {
		t.Fatalf("GrantHostAccess manage: %v", err)
	}

	level, ok, err := s.GetHostAccess(admin.ID, "10.0.0.2")
	if err != nil {
		t.Fatalf("GetHostAccess: %v", err)
	}
	if !ok {
		t.Fatal("expected ok=true")
	}
	if level != "manage" {
		t.Fatalf("expected level=manage after upgrade, got %q", level)
	}
}

func TestRevokeHostAccess(t *testing.T) {
	s := setup(t)
	admin := seedAdmin(t, s)

	if err := s.GrantHostAccess(admin.ID, "10.0.0.3", "connect", admin.ID); err != nil {
		t.Fatalf("GrantHostAccess: %v", err)
	}
	if err := s.RevokeHostAccess(admin.ID, "10.0.0.3"); err != nil {
		t.Fatalf("RevokeHostAccess: %v", err)
	}

	_, ok, err := s.GetHostAccess(admin.ID, "10.0.0.3")
	if err != nil {
		t.Fatalf("GetHostAccess: %v", err)
	}
	if ok {
		t.Fatal("expected ok=false after revoke, got true")
	}
}

func TestListHostACL(t *testing.T) {
	s := setup(t)
	admin := seedAdmin(t, s)

	userID2, err := s.CreateUser("alice", "pass2", "viewer")
	if err != nil {
		t.Fatalf("CreateUser alice: %v", err)
	}

	const host = "10.0.1.1"
	if err := s.GrantHostAccess(admin.ID, host, "manage", admin.ID); err != nil {
		t.Fatalf("GrantHostAccess admin: %v", err)
	}
	if err := s.GrantHostAccess(userID2, host, "view", admin.ID); err != nil {
		t.Fatalf("GrantHostAccess alice: %v", err)
	}

	acl, err := s.ListHostACL(host)
	if err != nil {
		t.Fatalf("ListHostACL: %v", err)
	}
	if len(acl) != 2 {
		t.Fatalf("expected 2 ACL entries, got %d", len(acl))
	}

	found := map[int64]string{}
	for _, e := range acl {
		found[e.UserID] = e.Level
	}
	if found[admin.ID] != "manage" {
		t.Errorf("expected admin level=manage, got %q", found[admin.ID])
	}
	if found[userID2] != "view" {
		t.Errorf("expected alice level=view, got %q", found[userID2])
	}
}

func TestListUserHostAccess(t *testing.T) {
	s := setup(t)
	admin := seedAdmin(t, s)

	if err := s.GrantHostAccess(admin.ID, "192.168.1.1", "connect", admin.ID); err != nil {
		t.Fatalf("GrantHostAccess host1: %v", err)
	}
	if err := s.GrantHostAccess(admin.ID, "192.168.1.2", "view", admin.ID); err != nil {
		t.Fatalf("GrantHostAccess host2: %v", err)
	}

	grants, err := s.ListUserHostAccess(admin.ID)
	if err != nil {
		t.Fatalf("ListUserHostAccess: %v", err)
	}
	if len(grants) != 2 {
		t.Fatalf("expected 2 grants, got %d", len(grants))
	}

	found := map[string]string{}
	for _, g := range grants {
		found[g.HostIP] = g.Level
	}
	if found["192.168.1.1"] != "connect" {
		t.Errorf("expected 192.168.1.1=connect, got %q", found["192.168.1.1"])
	}
	if found["192.168.1.2"] != "view" {
		t.Errorf("expected 192.168.1.2=view, got %q", found["192.168.1.2"])
	}
}

func TestDeleteUserCascadesHostACL(t *testing.T) {
	s := setup(t)
	admin := seedAdmin(t, s)

	userID, err := s.CreateUser("bob", "pass3", "viewer")
	if err != nil {
		t.Fatalf("CreateUser bob: %v", err)
	}
	if err := s.GrantHostAccess(userID, "10.5.5.5", "connect", admin.ID); err != nil {
		t.Fatalf("GrantHostAccess: %v", err)
	}

	if err := s.DeleteUser(userID); err != nil {
		t.Fatalf("DeleteUser: %v", err)
	}

	_, ok, err := s.GetHostAccess(userID, "10.5.5.5")
	if err != nil {
		t.Fatalf("GetHostAccess after delete: %v", err)
	}
	if ok {
		t.Fatal("expected ACL row to be cascade-deleted with user, but it still exists")
	}
}

// ── Invite tests (Task 4) ──────────────────────────────────────────────────

func TestCreateAndRedeemInvite(t *testing.T) {
	s := setup(t)
	admin := seedAdmin(t, s)

	hostAccess := []db.HostGrant{{HostIP: "10.9.9.9", Level: "connect"}}
	id, err := s.CreateInvite("token-redeem-1", "test invite", "viewer", hostAccess, admin.ID, time.Now().Add(time.Hour))
	if err != nil {
		t.Fatalf("CreateInvite: %v", err)
	}
	if id == 0 {
		t.Fatal("expected non-zero invite ID")
	}

	userID, err := s.RedeemInvite("token-redeem-1", "newuser", "password123")
	if err != nil {
		t.Fatalf("RedeemInvite: %v", err)
	}

	users, err := s.ListUsers()
	if err != nil {
		t.Fatalf("ListUsers: %v", err)
	}
	var found bool
	for _, u := range users {
		if u.ID == userID && u.Username == "newuser" && u.Role == "viewer" {
			found = true
		}
	}
	if !found {
		t.Fatal("redeemed user not found with correct username/role")
	}

	level, ok, err := s.GetHostAccess(userID, "10.9.9.9")
	if err != nil {
		t.Fatalf("GetHostAccess after redeem: %v", err)
	}
	if !ok {
		t.Fatal("expected host ACL row after redeem, got none")
	}
	if level != "connect" {
		t.Errorf("expected level=connect, got %q", level)
	}
}

func TestRedeemInvite_Expired(t *testing.T) {
	s := setup(t)
	admin := seedAdmin(t, s)

	_, err := s.CreateInvite("token-expired", "expired invite", "viewer", nil, admin.ID, time.Now().Add(-time.Hour))
	if err != nil {
		t.Fatalf("CreateInvite: %v", err)
	}

	_, err = s.RedeemInvite("token-expired", "ghostuser", "password")
	if err != db.ErrInviteInvalid {
		t.Fatalf("expected ErrInviteInvalid for expired invite, got %v", err)
	}
}

func TestRedeemInvite_OneTimeUse(t *testing.T) {
	s := setup(t)
	admin := seedAdmin(t, s)

	_, err := s.CreateInvite("token-onetime", "one-time invite", "viewer", nil, admin.ID, time.Now().Add(time.Hour))
	if err != nil {
		t.Fatalf("CreateInvite: %v", err)
	}

	if _, err := s.RedeemInvite("token-onetime", "firstuser", "password"); err != nil {
		t.Fatalf("first RedeemInvite: %v", err)
	}

	_, err = s.RedeemInvite("token-onetime", "seconduser", "password")
	if err != db.ErrInviteInvalid {
		t.Fatalf("expected ErrInviteInvalid on second redeem, got %v", err)
	}
}

func TestRevokeInvite(t *testing.T) {
	s := setup(t)
	admin := seedAdmin(t, s)

	id, err := s.CreateInvite("token-revoke", "revokable invite", "viewer", nil, admin.ID, time.Now().Add(time.Hour))
	if err != nil {
		t.Fatalf("CreateInvite: %v", err)
	}

	if err := s.RevokeInvite(id); err != nil {
		t.Fatalf("RevokeInvite: %v", err)
	}

	invites, err := s.ListActiveInvites()
	if err != nil {
		t.Fatalf("ListActiveInvites: %v", err)
	}
	for _, inv := range invites {
		if inv.ID == id {
			t.Fatal("revoked invite still appears in ListActiveInvites")
		}
	}
}

func TestListActiveInvites(t *testing.T) {
	s := setup(t)
	admin := seedAdmin(t, s)

	_, err := s.CreateInvite("token-active", "active invite", "viewer", nil, admin.ID, time.Now().Add(time.Hour))
	if err != nil {
		t.Fatalf("CreateInvite active: %v", err)
	}
	_, err = s.CreateInvite("token-past", "expired invite", "viewer", nil, admin.ID, time.Now().Add(-time.Hour))
	if err != nil {
		t.Fatalf("CreateInvite expired: %v", err)
	}

	invites, err := s.ListActiveInvites()
	if err != nil {
		t.Fatalf("ListActiveInvites: %v", err)
	}
	if len(invites) != 1 {
		t.Fatalf("expected 1 active invite, got %d", len(invites))
	}
	if invites[0].Token != "token-active" {
		t.Errorf("expected token-active, got %q", invites[0].Token)
	}
}

func TestRedeemInvite_ViewerCappedAtView(t *testing.T) {
	s := setup(t)
	admin := seedAdmin(t, s)

	// Invite is for a "viewer" role but host_access requests "connect" level.
	// The store must cap the effective host ACL level to "view".
	hostAccess := []db.HostGrant{{HostIP: "10.1.2.3", Level: "connect"}}
	_, err := s.CreateInvite("token-cap", "capped invite", "viewer", hostAccess, admin.ID, time.Now().Add(time.Hour))
	if err != nil {
		t.Fatalf("CreateInvite: %v", err)
	}

	userID, err := s.RedeemInvite("token-cap", "cappeduser", "password")
	if err != nil {
		t.Fatalf("RedeemInvite: %v", err)
	}

	level, ok, err := s.GetHostAccess(userID, "10.1.2.3")
	if err != nil {
		t.Fatalf("GetHostAccess: %v", err)
	}
	if !ok {
		t.Fatal("expected host ACL row, got none")
	}
	if level != "view" {
		t.Errorf("expected viewer role to cap ACL at view, got %q", level)
	}
}
```

A few notes on what to add at the top of the file (the `"time"` import) and the `seedAdmin` helper used throughout these tests:

Since the existing file already has `setup(t)` and `TestSeedAdmin` suggests a seeded admin exists, you may need a small unexported helper. If your existing file does not already expose the admin user object from `setup`, add this near the top of the appended block (before the test functions):

```go
// seedAdmin returns the pre-seeded admin user created by TestSeedAdmin's
// underlying logic. It calls SeedAdmin (or equivalent) and then looks up
// the resulting user. Adjust to match your actual SeedAdmin API.
func seedAdmin(t *testing.T, s *db.Store) db.User {
	t.Helper()
	if err := s.SeedAdmin(); err != nil {
		t.Fatalf("SeedAdmin: %v", err)
	}
	users, err := s.ListUsers()
	if err != nil {
		t.Fatalf("ListUsers in seedAdmin: %v", err)
	}
	for _, u := range users {
		if u.Role == "admin" {
			return u
		}
	}
	t.Fatal("no admin user found after SeedAdmin")
	return db.User{}
}
```

And add `"time"` to the import block at the top of the file.

---

**Summary of what was written:**

- 12 new test functions covering Host ACL CRUD (Tasks 3) and Invite CRUD (Task 4), none of which duplicate existing tests.
- All types are referenced as `db.HostGrant`, `db.ErrInviteInvalid`, `db.User`, etc. to stay within the `db_test` package boundary.
- A `seedAdmin` helper is included to DRY up admin-user setup across tests — adjust its internals if `SeedAdmin()` has a different signature (e.g., takes a password) or if your `User` struct fields differ.
- The `"time"` package must be added to the existing import block.
- `CreateUser` is called directly (assumed signature `CreateUser(username, password, role string) (int64, error)`) — if the signature differs, adjust accordingly.
- `InviteRecord` is assumed to have at minimum `ID int64` and `Token string` fields, consistent with the described API.