package http import ( "context" "encoding/json" "testing" "time" "gitea.dcglab.co.uk/steve/restic-manager/internal/api" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) // TestEnrollmentTransfersRepoCreds verifies the round-trip: // - operator mints a token with repo_url/username/password // - encrypted blob lands on the token row, bound to token_hash // - on consume, the blob is re-encrypted bound to host_id and // written to host_credentials in the same tx. func TestEnrollmentTransfersRepoCreds(t *testing.T) { t.Parallel() srv, _, st := newTestServerWithHub(t) ctx := context.Background() want := repoCredsBlob{ RepoURL: "rest:https://repo.example/host42", RepoUsername: "host42", RepoPassword: "hunter2", } // Encrypt + create token like the operator endpoint would. const tokHash = "tok-hash-fixture" enc, err := srv.encryptRepoCreds(want, []byte("token:"+tokHash)) if err != nil { t.Fatalf("encrypt: %v", err) } if err := st.CreateEnrollmentToken(ctx, tokHash, time.Hour, enc, ""); err != nil { t.Fatalf("create token: %v", err) } // Rebind under host_id, then consume (this is what the agent // enroll handler does inline). const hostID = "h-fixture" _, encForHost, err := srv.rebindTokenAttachments(ctx, tokHash, hostID) if err != nil { t.Fatalf("rebind: %v", err) } if encForHost == "" { t.Fatal("rebind returned empty blob; expected re-encrypted ciphertext") } if encForHost == enc { t.Errorf("rebind should change ciphertext (additional-data differs); got identical") } // Burn the token, then create the host row, then promote — same // order the HTTP handler runs. if err := st.ConsumeEnrollmentToken(ctx, tokHash, hostID); err != nil { t.Fatalf("consume: %v", err) } if _, err := st.DB().Exec( `INSERT INTO hosts (id, name, os, arch, enrolled_at) VALUES (?,?,?,?,?)`, hostID, "host42", "linux", "amd64", "2026-01-01T00:00:00Z"); err != nil { t.Fatalf("insert host: %v", err) } if err := st.SetHostCredentials(ctx, hostID, store.CredKindRepo, encForHost); err != nil { t.Fatalf("set host credentials: %v", err) } // host_credentials row should now hold the host-bound ciphertext. got, err := st.GetHostCredentials(ctx, hostID, store.CredKindRepo) if err != nil { t.Fatalf("get host creds: %v", err) } plain, err := srv.deps.AEAD.Decrypt(got, []byte("host:"+hostID)) if err != nil { t.Fatalf("decrypt: %v", err) } var blob repoCredsBlob if err := json.Unmarshal(plain, &blob); err != nil { t.Fatalf("unmarshal: %v", err) } if blob != want { t.Errorf("blob mismatch:\n got %+v\nwant %+v", blob, want) } // Cross-check: decrypting with a wrong AD must fail (swap // detection — proves the AAD binding is doing real work). if _, err := srv.deps.AEAD.Decrypt(got, []byte("host:other-host")); err == nil { t.Error("decrypt with wrong AD must fail; AAD binding is broken") } } // TestEnrollmentTokenWithoutCreds is the regression that ensures the // existing ttl/single-use semantics still work when no creds are // attached (used by the enrollment_test.go fixture path). func TestEnrollmentTokenWithoutCreds(t *testing.T) { t.Parallel() _, _, st := newTestServerWithHub(t) ctx := context.Background() const tokHash = "no-creds-token" if err := st.CreateEnrollmentToken(ctx, tokHash, time.Hour, "", ""); err != nil { t.Fatalf("create: %v", err) } att, err := st.GetEnrollmentTokenAttachments(ctx, tokHash) if err != nil { t.Fatalf("get token attachments: %v", err) } if att.EncRepoCreds != "" { t.Errorf("token without creds should return empty blob; got %q", att.EncRepoCreds) } } // ----- admin credentials tests ---------------------------------------- // TestAdminCredentialsRoundTrip verifies set→get→delete→get (404). func TestAdminCredentialsRoundTrip(t *testing.T) { t.Parallel() srv, url, st := newTestServerWithHub(t) cookie := loginAsAdmin(t, st) hostID := makeHost(t, st, "admin-creds-host") // Mark init done so auto-init doesn't interfere. _ = st.CreateJob(context.Background(), store.Job{ ID: "init-" + hostID, HostID: hostID, Kind: string(api.JobInit), ActorKind: "system", CreatedAt: time.Now().UTC(), }) // GET before set → 404. status, body := doJSON(t, url, "GET", "/api/hosts/"+hostID+"/admin-credentials", nil, cookie) if status != 404 { t.Fatalf("before set: want 404, got %d body=%+v", status, body) } // PUT — set admin creds. status, body = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/admin-credentials", map[string]any{ "repo_url": "rest:http://admin.example/host", "repo_username": "admin", "repo_password": "s3cur3", }, cookie) if status != 204 { t.Fatalf("set: want 204, got %d body=%+v", status, body) } // GET — should return redacted view. status, body = doJSON(t, url, "GET", "/api/hosts/"+hostID+"/admin-credentials", nil, cookie) if status != 200 { t.Fatalf("get after set: want 200, got %d body=%+v", status, body) } if body["repo_url"] != "rest:http://admin.example/host" { t.Errorf("repo_url: %+v", body) } if body["repo_username"] != "admin" { t.Errorf("repo_username: %+v", body) } if body["has_password"] != true { t.Errorf("has_password: %+v", body) } // DELETE. status, _ = doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/admin-credentials", nil, cookie) if status != 204 { t.Fatalf("delete: want 204, got %d", status) } // GET after delete → 404. status, _ = doJSON(t, url, "GET", "/api/hosts/"+hostID+"/admin-credentials", nil, cookie) if status != 404 { t.Fatalf("after delete: want 404, got %d", status) } // Extra: suppress unused import warning by actually using srv in assertion. _ = srv } // TestAdminCredsAADIsolatedFromRepo writes a blob encrypted with the repo // AAD ("host:") into the admin kind slot, then GETs it — the handler // should fail to decrypt and return 500 decrypt_failed. This proves the // AAD scoping is real. func TestAdminCredsAADIsolatedFromRepo(t *testing.T) { t.Parallel() srv, url, st := newTestServerWithHub(t) cookie := loginAsAdmin(t, st) hostID := makeHost(t, st, "aad-isolation-host") ctx := context.Background() // Encrypt with the REPO AAD (wrong for admin slot). enc, err := srv.encryptRepoCreds(repoCredsBlob{ RepoURL: "rest:http://r/x", RepoPassword: "p", }, []byte("host:"+hostID)) // wrong AAD — repo, not admin if err != nil { t.Fatalf("encrypt: %v", err) } // Write it directly into the admin kind slot. if err := st.SetHostCredentials(ctx, hostID, store.CredKindAdmin, enc); err != nil { t.Fatalf("set host credentials: %v", err) } // GET admin-credentials — handler decrypts with admin AAD, which // is different, so decrypt must fail → 500. status, body := doJSON(t, url, "GET", "/api/hosts/"+hostID+"/admin-credentials", nil, cookie) if status != 500 { t.Fatalf("want 500 (decrypt_failed), got %d body=%+v", status, body) } if code, _ := body["code"].(string); code != "decrypt_failed" { t.Errorf("want code=decrypt_failed, got %+v", body) } } // TestAdminCredsPushOnSet connects a fake WS host, sets admin creds via // PUT, drains the conn, and asserts a config.update with Slot:"admin" // was shipped. func TestAdminCredsPushOnSet(t *testing.T) { t.Parallel() srv, ts, st := rawTestServer(t) hostID, token := enrolHostForWS(t, srv, st, "admin-push-host") cookie := loginAsAdmin(t, st) c := agentDial(t, srv, ts, hostID, token) sendHello(t, c, "admin-push-host") // Drain the on-hello burst (config.update for repo + schedule.set // + possibly command.run(init)). _ = drainUntil(t, c, api.MsgScheduleSet) // Now PUT admin creds — should trigger an immediate push. status, body := doJSON(t, ts.URL, "PUT", "/api/hosts/"+hostID+"/admin-credentials", map[string]any{ "repo_url": "rest:http://admin.example/h", "repo_username": "admin", "repo_password": "prune-pass", }, cookie) if status != 204 { t.Fatalf("set admin creds: want 204, got %d body=%+v", status, body) } // Drain until we see a config.update with Slot=admin. deadline := time.Now().Add(3 * time.Second) found := false for !found && time.Now().Before(deadline) { env := readEnvelope(t, c) if env.Type != api.MsgConfigUpdate { continue } var p api.ConfigUpdatePayload if err := env.UnmarshalPayload(&p); err != nil { t.Fatalf("unmarshal config.update: %v", err) } if p.Slot == "admin" { found = true if p.RepoURL != "rest:http://admin.example/h" { t.Errorf("admin push: wrong URL %q", p.RepoURL) } } } if !found { t.Fatal("timed out waiting for config.update(slot=admin)") } } // TestDeleteAdminCredentialsAuditLogged checks that DELETE appends an // audit row with action='host.admin_credentials_deleted' and that the // row carries the acting user's ID. func TestDeleteAdminCredentialsAuditLogged(t *testing.T) { t.Parallel() _, url, st := newTestServerWithHub(t) cookie, userID := loginAsAdminWithID(t, st) hostID := makeHost(t, st, "audit-del-host") ctx := context.Background() // Set admin creds first so there is something to delete. status, body := doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/admin-credentials", map[string]any{ "repo_url": "rest:http://x/h", "repo_password": "p", }, cookie) if status != 204 { t.Fatalf("set: want 204, got %d body=%+v", status, body) } // Delete. status, _ = doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/admin-credentials", nil, cookie) if status != 204 { t.Fatalf("delete: want 204, got %d", status) } // Query audit_log for the delete row — action, user_id. rows, err := st.DB().QueryContext(ctx, `SELECT action, user_id FROM audit_log WHERE target_id = ? AND target_kind = 'host' AND action = 'host.admin_credentials_deleted'`, hostID) if err != nil { t.Fatalf("query audit: %v", err) } defer rows.Close() found := false for rows.Next() { var action string var gotUserID *string if err := rows.Scan(&action, &gotUserID); err != nil { t.Fatalf("scan: %v", err) } found = true if gotUserID == nil { t.Error("audit row: user_id is NULL, want non-nil") } else if *gotUserID != userID { t.Errorf("audit row: user_id=%q, want %q", *gotUserID, userID) } } if err := rows.Err(); err != nil { t.Fatalf("rows: %v", err) } if !found { t.Error("audit row with action='host.admin_credentials_deleted' not found") } } // TestSetAdminCredentialsAuditCarriesUserID checks that PUT // /api/hosts/{id}/admin-credentials appends an audit row with the // correct action and a non-nil UserID matching the acting session. func TestSetAdminCredentialsAuditCarriesUserID(t *testing.T) { t.Parallel() _, url, st := newTestServerWithHub(t) cookie, userID := loginAsAdminWithID(t, st) hostID := makeHost(t, st, "audit-set-admin-host") ctx := context.Background() status, body := doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/admin-credentials", map[string]any{ "repo_url": "rest:http://admin.example/h", "repo_password": "s3cr3t", }, cookie) if status != 204 { t.Fatalf("set: want 204, got %d body=%+v", status, body) } rows, err := st.DB().QueryContext(ctx, `SELECT action, user_id FROM audit_log WHERE target_id = ? AND target_kind = 'host' AND action = 'host.admin_credentials_set'`, hostID) if err != nil { t.Fatalf("query audit: %v", err) } defer rows.Close() found := false for rows.Next() { var action string var gotUserID *string if err := rows.Scan(&action, &gotUserID); err != nil { t.Fatalf("scan: %v", err) } found = true if gotUserID == nil { t.Error("audit row: user_id is NULL, want non-nil") } else if *gotUserID != userID { t.Errorf("audit row: user_id=%q, want %q", *gotUserID, userID) } } if err := rows.Err(); err != nil { t.Fatalf("rows: %v", err) } if !found { t.Error("audit row with action='host.admin_credentials_set' not found") } }