Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
package server
import (
"context"
"encoding/json"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/git-pkgs/proxy/internal/config"
"github.com/git-pkgs/proxy/internal/database"
"github.com/git-pkgs/proxy/internal/handler"
"github.com/git-pkgs/proxy/internal/storage"
"github.com/git-pkgs/registries/fetch"
"github.com/go-chi/chi/v5"
)
const (
testHostedToken = "push-token"
2026-10-13 13:19:58 +02:00
testHostedMaxUpload = 4096
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
)
type hostedTestServer struct {
router http . Handler
fetches * int
upstreamRequests * int
}
// countingFetcher fails every fetch, so a test fails loudly if the proxy goes
// upstream for something it should serve from the pushed copy.
type countingFetcher struct { calls * int }
func ( f countingFetcher ) Fetch ( ctx context . Context , url string ) ( * fetch . Artifact , error ) {
return f . FetchWithHeaders ( ctx , url , nil )
}
func ( f countingFetcher ) FetchWithHeaders ( context . Context , string , http . Header ) ( * fetch . Artifact , error ) {
* f . calls ++
return nil , fetch . ErrNotFound
}
func ( f countingFetcher ) Head ( context . Context , string ) ( int64 , string , error ) {
* f . calls ++
return 0 , "" , fetch . ErrNotFound
}
func newHostedTestServer ( t * testing . T ) hostedTestServer {
t . Helper ( )
dir := t . TempDir ( )
db , err := database . Create ( filepath . Join ( dir , "test.db" ) )
if err != nil {
t . Fatalf ( "database.Create: %v" , err )
}
t . Cleanup ( func ( ) { _ = db . Close ( ) } )
store , err := storage . OpenBucket ( context . Background ( ) , "file://" + filepath . Join ( dir , "artifacts" ) )
if err != nil {
t . Fatalf ( "storage.OpenBucket: %v" , err )
}
calls := 0
logger := slog . New ( slog . NewTextHandler ( io . Discard , nil ) )
proxy := handler . NewProxy ( db , store , countingFetcher { calls : & calls } , fetch . NewResolver ( ) , logger )
2026-10-13 13:27:13 +02:00
proxy . HostedEnabled = true
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
upstreamRequests := 0
upstream := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , _ * http . Request ) {
upstreamRequests ++
http . NotFound ( w , nil )
} ) )
t . Cleanup ( upstream . Close )
r := chi . NewRouter ( )
registerHostedRoutes ( r , NewHostedAPIHandler ( proxy , testHostedMaxUpload , logger ) , testHostedToken )
r . Mount ( "/npm" , http . StripPrefix ( "/npm" , handler . NewNPMHandler ( proxy , "http://proxy.test" , upstream . URL ) . Routes ( ) ) )
composer := handler . NewComposerHandlerWithUpstreams ( proxy , "http://proxy.test" , upstream . URL , upstream . URL )
r . Mount ( "/composer" , http . StripPrefix ( "/composer" , composer . Routes ( ) ) )
return hostedTestServer { router : r , fetches : & calls , upstreamRequests : & upstreamRequests }
}
func ( s hostedTestServer ) do ( t * testing . T , method , target , token , body string ) * httptest . ResponseRecorder {
t . Helper ( )
var reader io . Reader
if body != "" {
reader = strings . NewReader ( body )
}
req := httptest . NewRequest ( method , target , reader )
if token != "" {
req . Header . Set ( "Authorization" , "Bearer " + token )
}
rec := httptest . NewRecorder ( )
s . router . ServeHTTP ( rec , req )
return rec
}
func TestHostedPushIsServedByTheEcosystemHandler ( t * testing . T ) {
s := newHostedTestServer ( t )
2026-10-13 13:19:58 +02:00
rec := s . do ( t , http . MethodPut , "/api/hosted/npm/request/2.88.3" , testHostedToken , requestTarball )
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
if rec . Code != http . StatusCreated {
t . Fatalf ( "PUT status = %d, body %s" , rec . Code , rec . Body )
}
var pushed HostedPushResponse
if err := json . NewDecoder ( rec . Body ) . Decode ( & pushed ) ; err != nil {
t . Fatalf ( "decoding PUT response: %v" , err )
}
if pushed . Ecosystem != "npm" || pushed . Name != "request" || pushed . Version != "2.88.3" ||
2026-10-13 13:19:58 +02:00
pushed . Filename != "request-2.88.3.tgz" || pushed . Size != int64 ( len ( requestTarball ) ) {
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
t . Errorf ( "PUT response = %+v" , pushed )
}
rec = s . do ( t , http . MethodGet , "/npm/request/-/request-2.88.3.tgz" , "" , "" )
2026-10-13 13:19:58 +02:00
if rec . Code != http . StatusOK || rec . Body . String ( ) != requestTarball {
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
t . Fatalf ( "download status = %d, body %q, want 200 and the pushed tarball" , rec . Code , rec . Body )
}
if * s . fetches != 0 {
t . Errorf ( "download contacted the upstream %d times" , * s . fetches )
}
}
func TestHostedPushIsServedByTheComposerHandler ( t * testing . T ) {
s := newHostedTestServer ( t )
2026-10-13 13:19:58 +02:00
rec := s . do ( t , http . MethodPut , "/api/hosted/composer/monolog/monolog/3.5.0" , testHostedToken , monologZip )
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
if rec . Code != http . StatusCreated {
t . Fatalf ( "PUT status = %d, body %s" , rec . Code , rec . Body )
}
rec = s . do ( t , http . MethodGet , "/composer/files/monolog/monolog/3.5.0/monolog-3.5.0.zip" , "" , "" )
2026-10-13 13:19:58 +02:00
if rec . Code != http . StatusOK || rec . Body . String ( ) != monologZip {
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
t . Fatalf ( "download status = %d, body %q, want 200 and the pushed archive" , rec . Code , rec . Body )
}
if * s . upstreamRequests != 0 || * s . fetches != 0 {
t . Errorf ( "download contacted the upstream (%d metadata requests, %d fetches)" , * s . upstreamRequests , * s . fetches )
}
}
func TestComposerDownloadOfUnpushedFileStillNeedsUpstreamMetadata ( t * testing . T ) {
s := newHostedTestServer ( t )
2026-10-13 13:19:58 +02:00
if rec := s . do ( t , http . MethodPut , "/api/hosted/composer/monolog/monolog/3.5.0" , testHostedToken , monologZip ) ; rec . Code != http . StatusCreated {
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
t . Fatalf ( "PUT status = %d, body %s" , rec . Code , rec . Body )
}
rec := s . do ( t , http . MethodGet , "/composer/files/monolog/monolog/3.5.0/other.zip" , "" , "" )
if rec . Code != http . StatusNotFound {
t . Errorf ( "download of another filename status = %d, want 404 from the upstream lookup" , rec . Code )
}
if * s . upstreamRequests == 0 {
t . Error ( "a filename that was not pushed was served without the upstream metadata lookup" )
}
}
func TestHostedPushScopedNpmPackage ( t * testing . T ) {
s := newHostedTestServer ( t )
2026-10-13 13:19:58 +02:00
rec := s . do ( t , http . MethodPut , "/api/hosted/npm/@types/node/20.1.0" , testHostedToken , nodeTarball )
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
if rec . Code != http . StatusCreated {
t . Fatalf ( "PUT status = %d, body %s" , rec . Code , rec . Body )
}
rec = s . do ( t , http . MethodGet , "/npm/@types/node/-/node-20.1.0.tgz" , "" , "" )
2026-10-13 13:19:58 +02:00
if rec . Code != http . StatusOK || rec . Body . String ( ) != nodeTarball {
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
t . Fatalf ( "download status = %d, body %q, want 200 and the pushed tarball" , rec . Code , rec . Body )
}
}
func TestHostedListAndDelete ( t * testing . T ) {
s := newHostedTestServer ( t )
2026-10-13 13:19:58 +02:00
if rec := s . do ( t , http . MethodPut , "/api/hosted/npm/request/2.88.3" , testHostedToken , requestTarball ) ; rec . Code != http . StatusCreated {
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
t . Fatalf ( "PUT status = %d, body %s" , rec . Code , rec . Body )
}
rec := s . do ( t , http . MethodGet , "/api/hosted/npm/request" , "" , "" )
if rec . Code != http . StatusOK {
t . Fatalf ( "GET status = %d, body %s" , rec . Code , rec . Body )
}
var listed HostedListResponse
if err := json . NewDecoder ( rec . Body ) . Decode ( & listed ) ; err != nil {
t . Fatalf ( "decoding GET response: %v" , err )
}
if listed . Ecosystem != "npm" || listed . Name != "request" || len ( listed . Artifacts ) != 1 || listed . Artifacts [ 0 ] . Version != "2.88.3" {
t . Errorf ( "GET response = %+v" , listed )
}
if rec := s . do ( t , http . MethodDelete , "/api/hosted/npm/request/2.88.3" , "" , "" ) ; rec . Code != http . StatusUnauthorized {
t . Errorf ( "DELETE without token status = %d, want 401" , rec . Code )
}
if rec := s . do ( t , http . MethodDelete , "/api/hosted/npm/request/2.88.3" , testHostedToken , "" ) ; rec . Code != http . StatusNoContent {
t . Errorf ( "DELETE status = %d, body %s" , rec . Code , rec . Body )
}
if rec := s . do ( t , http . MethodDelete , "/api/hosted/npm/request/2.88.3" , testHostedToken , "" ) ; rec . Code != http . StatusNotFound {
t . Errorf ( "second DELETE status = %d, want 404" , rec . Code )
}
}
func TestHostedPushRejections ( t * testing . T ) {
tests := [ ] struct {
name string
target string
token string
body string
wantStatus int
} {
2026-10-13 13:19:58 +02:00
{ name : "no token" , target : "/api/hosted/npm/request/2.88.3" , body : requestTarball , wantStatus : http . StatusUnauthorized } ,
{ name : "wrong token" , target : "/api/hosted/npm/request/2.88.3" , token : "guess" , body : requestTarball , wantStatus : http . StatusUnauthorized } ,
{ name : "unsupported ecosystem" , target : "/api/hosted/cargo/serde/1.0.200" , token : testHostedToken , body : requestTarball , wantStatus : http . StatusBadRequest } ,
{ name : "composer name without vendor" , target : "/api/hosted/composer/monolog/3.5.0" , token : testHostedToken , body : requestTarball , wantStatus : http . StatusBadRequest } ,
{ name : "missing version" , target : "/api/hosted/npm/request" , token : testHostedToken , body : requestTarball , wantStatus : http . StatusBadRequest } ,
{ name : "not an archive" , target : "/api/hosted/npm/request/2.88.3" , token : testHostedToken , body : "plain text" , wantStatus : http . StatusBadRequest } ,
{ name : "manifest of another version" , target : "/api/hosted/npm/request/2.88.4" , token : testHostedToken , body : requestTarball , wantStatus : http . StatusBadRequest } ,
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
{ name : "empty body" , target : "/api/hosted/npm/request/2.88.3" , token : testHostedToken , wantStatus : http . StatusBadRequest } ,
{ name : "too large" , target : "/api/hosted/npm/request/2.88.3" , token : testHostedToken , body : strings . Repeat ( "x" , testHostedMaxUpload + 1 ) , wantStatus : http . StatusRequestEntityTooLarge } ,
}
for _ , tt := range tests {
t . Run ( tt . name , func ( t * testing . T ) {
s := newHostedTestServer ( t )
rec := s . do ( t , http . MethodPut , tt . target , tt . token , tt . body )
if rec . Code != tt . wantStatus {
t . Fatalf ( "PUT status = %d, want %d, body %s" , rec . Code , tt . wantStatus , rec . Body )
}
listed := s . do ( t , http . MethodGet , "/api/hosted/npm/request" , "" , "" )
if strings . Contains ( listed . Body . String ( ) , "2.88.3" ) {
t . Error ( "rejected push was stored" )
}
} )
}
}
func TestHostedListUnsupportedEcosystem ( t * testing . T ) {
s := newHostedTestServer ( t )
if rec := s . do ( t , http . MethodGet , "/api/hosted/maven/junit" , "" , "" ) ; rec . Code != http . StatusBadRequest {
t . Errorf ( "GET status = %d, want 400" , rec . Code )
}
}
func startHostedServer ( t * testing . T , hosted config . HostedConfig ) string {
t . Helper ( )
listener , err := net . Listen ( "tcp" , "127.0.0.1:0" )
if err != nil {
t . Fatalf ( "reserving proxy address: %v" , err )
}
t . Cleanup ( func ( ) { _ = listener . Close ( ) } )
tempDir := t . TempDir ( )
cfg := config . Default ( )
cfg . Listen = listener . Addr ( ) . String ( )
cfg . BaseURL = "http://" + cfg . Listen
cfg . Database . Path = filepath . Join ( tempDir , "proxy.db" )
cfg . Storage . URL = "file://" + filepath . Join ( tempDir , "artifacts" )
cfg . Hosted = hosted
2026-10-13 13:23:29 +02:00
upstream := httptest . NewServer ( http . NotFoundHandler ( ) )
t . Cleanup ( upstream . Close )
cfg . Upstream . NPM = upstream . URL
cfg . Upstream . AllowLoopback = true
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
if err := cfg . Validate ( ) ; err != nil {
t . Fatalf ( "validating config: %v" , err )
}
proxyServer , err := New ( cfg , slog . New ( slog . NewTextHandler ( io . Discard , nil ) ) , BuildInfo { Version : "test" , Commit : "test" } )
if err != nil {
t . Fatalf ( "creating server: %v" , err )
}
startErr := make ( chan error , 1 )
go func ( ) { startErr <- proxyServer . Start ( listener ) } ( )
t . Cleanup ( func ( ) {
ctx , cancel := context . WithTimeout ( context . Background ( ) , 5 * time . Second )
defer cancel ( )
_ = proxyServer . Shutdown ( ctx )
<- startErr
} )
return cfg . BaseURL
}
func putArchive ( t * testing . T , url , token string ) int {
t . Helper ( )
2026-10-13 13:23:29 +02:00
return putBody ( t , url , token , requestTarball )
}
func putBody ( t * testing . T , url , token , body string ) int {
t . Helper ( )
req , err := http . NewRequest ( http . MethodPut , url , strings . NewReader ( body ) )
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
if err != nil {
t . Fatalf ( "building request: %v" , err )
}
if token != "" {
req . Header . Set ( "Authorization" , "Bearer " + token )
}
resp , err := http . DefaultClient . Do ( req )
if err != nil {
t . Fatalf ( "PUT %s: %v" , url , err )
}
_ = resp . Body . Close ( )
return resp . StatusCode
}
func TestHostedRoutesFollowConfig ( t * testing . T ) {
disabled := startHostedServer ( t , config . HostedConfig { } )
if status := putArchive ( t , disabled + "/api/hosted/npm/request/2.88.3" , testHostedToken ) ; status == http . StatusCreated {
t . Error ( "push accepted with hosted.enabled false" )
}
enabled := startHostedServer ( t , config . HostedConfig { Enabled : true , Token : testHostedToken } )
if status := putArchive ( t , enabled + "/api/hosted/npm/request/2.88.3" , "" ) ; status != http . StatusUnauthorized {
t . Errorf ( "push without token status = %d, want 401" , status )
}
if status := putArchive ( t , enabled + "/api/hosted/npm/request/2.88.3" , testHostedToken ) ; status != http . StatusCreated {
t . Errorf ( "push with token status = %d, want 201" , status )
}
}
2026-10-13 13:23:29 +02:00
func TestHostedNpmPackageIsListedByTheRunningServer ( t * testing . T ) {
base := startHostedServer ( t , config . HostedConfig { Enabled : true , Token : testHostedToken } )
private := npmTarball ( ` { "name":"@acme/private","version":"1.0.0"} ` )
if status := putBody ( t , base + "/api/hosted/npm/@acme/private/1.0.0" , testHostedToken , private ) ; status != http . StatusCreated {
t . Fatalf ( "push status = %d, want 201" , status )
}
resp , err := http . Get ( base + "/npm/@acme%2fprivate" )
if err != nil {
t . Fatalf ( "GET packument: %v" , err )
}
defer func ( ) { _ = resp . Body . Close ( ) } ( )
body , _ := io . ReadAll ( resp . Body )
if resp . StatusCode != http . StatusOK || ! strings . Contains ( string ( body ) , ` "1.0.0" ` ) {
t . Errorf ( "packument status = %d, body %s" , resp . StatusCode , body )
}
}
2026-10-13 13:27:13 +02:00
func TestHostedComposerVersionIsInstallableFromMetadata ( t * testing . T ) {
s := newHostedTestServer ( t )
if rec := s . do ( t , http . MethodPut , "/api/hosted/composer/acme/private/1.0.0" , testHostedToken ,
composerZip ( ` { "name":"acme/private","require": { "php":">=8.1"}} ` ) ) ; rec . Code != http . StatusCreated {
t . Fatalf ( "PUT status = %d, body %s" , rec . Code , rec . Body )
}
rec := s . do ( t , http . MethodGet , "/composer/p2/acme/private.json" , "" , "" )
if rec . Code != http . StatusOK {
t . Fatalf ( "metadata status = %d, body %s" , rec . Code , rec . Body )
}
var doc struct {
Packages map [ string ] [ ] struct {
Dist struct {
URL string ` json:"url" `
} ` json:"dist" `
} ` json:"packages" `
}
if err := json . Unmarshal ( rec . Body . Bytes ( ) , & doc ) ; err != nil {
t . Fatalf ( "decoding metadata: %v" , err )
}
versions := doc . Packages [ "acme/private" ]
if len ( versions ) != 1 {
t . Fatalf ( "metadata = %s" , rec . Body )
}
distPath := strings . TrimPrefix ( versions [ 0 ] . Dist . URL , "http://proxy.test" )
if rec := s . do ( t , http . MethodGet , distPath , "" , "" ) ; rec . Code != http . StatusOK {
t . Errorf ( "download of %s status = %d" , distPath , rec . Code )
}
}